event) {
+ final ObjectMapper objectMapper = event.getBean();
+ SimpleModule fieldParamModule = new SimpleModule();
+ fieldParamModule.addSerializer(DocumentSpecificationVO.class, new FieldCleaningSerializer<>());
+ objectMapper.registerModule(fieldParamModule);
+ return objectMapper;
+ }
+ }
+}
diff --git a/document-management/src/main/java/org/fiware/tmforum/documentmanagement/AttachmentService.java b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/AttachmentService.java
new file mode 100644
index 00000000..d95e1316
--- /dev/null
+++ b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/AttachmentService.java
@@ -0,0 +1,68 @@
+package org.fiware.tmforum.documentmanagement;
+
+import org.fiware.tmforum.common.domain.AttachmentRefOrValue;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+
+/**
+ * Service interface for managing document attachments.
+ *
+ * Implementations may back this with different storage systems (e.g. S3-compatible object
+ * storage, local filesystem, a third-party DMS). When no implementation is configured the
+ * DocumentManagement API still operates normally, but rejects any attachment that carries inline
+ * content — pure URL/href references are always accepted regardless of the backing store.
+ */
+public interface AttachmentService {
+
+ /**
+ * Validates that the attachment's inline content (if any) is acceptable for storage —
+ * e.g. correct encoding and within the size limit. Throws a {@code TmForumException} on
+ * violation. Attachments that carry only a URL reference are always considered valid.
+ *
+ * @param attachment the attachment to validate
+ */
+ void validateAttachmentContent(AttachmentRefOrValue attachment);
+
+ /**
+ * Offloads inline base64 content from the given attachments to the backing store. The
+ * {@code content} field is cleared and the {@code url} field is set to an implementation-
+ * specific internal reference that can later be resolved by {@link #resolveAttachments}.
+ * Attachments that already carry a managed URL or that have no content are returned unchanged.
+ *
+ * @param attachments list of attachments to process
+ * @param entityId the owning entity's ID, used to namespace stored objects
+ * @return a {@code Mono} emitting the processed list
+ */
+ Mono> offloadAttachments(List attachments, String entityId);
+
+ /**
+ * Resolves the internal storage reference in the {@code url} field of each attachment to an
+ * accessible URL (e.g. a pre-signed download link) and returns a copy with {@code url} updated
+ * accordingly. Attachments whose {@code url} is not managed by this service are returned unchanged.
+ *
+ * @param attachments list of attachments to resolve
+ * @return a {@code Mono} emitting the resolved list
+ */
+ Mono> resolveAttachments(List attachments);
+
+ /**
+ * Deletes any stored objects that are referenced by the given attachments. Attachments
+ * without a retrieval reference are silently ignored.
+ *
+ * @param attachments list of attachments whose stored content should be removed
+ * @return a {@code Mono} that completes when all deletions are done
+ */
+ Mono deleteAttachments(List attachments);
+
+ /**
+ * Deletes stored objects for attachments that were present in {@code existing} but are no
+ * longer present in {@code updated}. Used during PATCH to clean up orphaned objects when
+ * the client replaces or removes attachments.
+ *
+ * @param existing attachments currently persisted for the entity
+ * @param updated attachments supplied in the PATCH request
+ * @return a {@code Mono} that completes when all orphaned deletions are done
+ */
+ Mono deleteOrphanedAttachments(List existing, List updated);
+}
diff --git a/document-management/src/main/java/org/fiware/tmforum/documentmanagement/DocumentManagementEventMapper.java b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/DocumentManagementEventMapper.java
new file mode 100644
index 00000000..743d57ef
--- /dev/null
+++ b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/DocumentManagementEventMapper.java
@@ -0,0 +1,39 @@
+package org.fiware.tmforum.documentmanagement;
+
+import lombok.RequiredArgsConstructor;
+import org.fiware.document.model.DocumentSpecificationVO;
+import org.fiware.tmforum.common.exception.TmForumException;
+import org.fiware.tmforum.common.exception.TmForumExceptionReason;
+import org.fiware.tmforum.common.mapping.EventMapping;
+import org.fiware.tmforum.common.notification.ModuleEventMapper;
+import org.fiware.tmforum.documentmanagement.domain.DocumentSpecification;
+
+import javax.inject.Singleton;
+import java.util.Map;
+
+import static java.util.Map.entry;
+
+@RequiredArgsConstructor
+@Singleton
+public class DocumentManagementEventMapper implements ModuleEventMapper {
+
+ private final TMForumMapper tmForumMapper;
+
+ @Override
+ public Map getEntityClassMapping() {
+ return Map.ofEntries(
+ entry(DocumentSpecification.TYPE_DOCUMENT_SPECIFICATION,
+ new EventMapping(DocumentSpecificationVO.class, DocumentSpecification.class))
+ );
+ }
+
+ @Override
+ public Object mapPayload(Object rawPayload, Class> rawClass) {
+ if (rawClass == DocumentSpecification.class) {
+ return tmForumMapper.map((DocumentSpecification) rawPayload);
+ }
+ throw new TmForumException(
+ String.format("Event-Payload %s is not supported.", rawPayload),
+ TmForumExceptionReason.INVALID_DATA);
+ }
+}
diff --git a/document-management/src/main/java/org/fiware/tmforum/documentmanagement/TMForumMapper.java b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/TMForumMapper.java
new file mode 100644
index 00000000..d6b5f8f3
--- /dev/null
+++ b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/TMForumMapper.java
@@ -0,0 +1,101 @@
+package org.fiware.tmforum.documentmanagement;
+
+import io.github.wistefan.mapping.MappingException;
+import org.fiware.document.model.*;
+import org.fiware.tmforum.common.domain.AttachmentRefOrValue;
+import org.fiware.tmforum.common.domain.ConstraintRef;
+import org.fiware.tmforum.common.domain.subscription.TMForumSubscription;
+import org.fiware.tmforum.common.mapping.BaseMapper;
+import org.fiware.tmforum.common.mapping.IdHelper;
+import org.fiware.tmforum.documentmanagement.domain.DocumentSpecification;
+import org.fiware.tmforum.service.CharacteristicSpecification;
+import org.fiware.tmforum.service.CharacteristicValueSpecification;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URL;
+
+@Mapper(componentModel = "jsr330", uses = {IdHelper.class})
+public abstract class TMForumMapper extends BaseMapper {
+
+ // DocumentSpecification mappings
+
+ @Mapping(target = "id", source = "id")
+ @Mapping(target = "href", source = "id")
+ public abstract DocumentSpecificationVO map(DocumentSpecificationCreateVO createVO, URI id);
+
+ public abstract DocumentSpecificationVO map(DocumentSpecification documentSpecification);
+
+ @Mapping(target = "href", source = "id")
+ public abstract DocumentSpecification map(DocumentSpecificationVO documentSpecificationVO);
+
+ @Mapping(target = "id", source = "id")
+ @Mapping(target = "href", source = "id")
+ public abstract DocumentSpecification map(DocumentSpecificationUpdateVO updateVO, String id);
+
+ // ConstraintRef mappings
+
+ public abstract ConstraintRefVO map(ConstraintRef constraintRef);
+
+ public abstract ConstraintRef map(ConstraintRefVO constraintRefVO);
+
+ // CharacteristicSpecification mappings
+
+ @Mapping(target = "id", source = "tmfId")
+ public abstract CharacteristicSpecificationVO map(CharacteristicSpecification characteristicSpecification);
+
+ @Mapping(target = "tmfId", source = "id")
+ public abstract CharacteristicSpecification map(CharacteristicSpecificationVO characteristicSpecificationVO);
+
+ @Mapping(target = "tmfValue", source = "value")
+ public abstract CharacteristicValueSpecification map(CharacteristicValueSpecificationVO characteristicVO);
+
+ @Mapping(target = "value", source = "tmfValue")
+ public abstract CharacteristicValueSpecificationVO map(CharacteristicValueSpecification characteristic);
+
+ // AttachmentRefOrValue mappings
+
+ public abstract AttachmentRefOrValue map(AttachmentRefOrValueVO attachmentVO);
+
+ public abstract AttachmentRefOrValueVO map(AttachmentRefOrValue attachment);
+
+ // URL/URI helper methods
+
+ public URL map(String value) {
+ if (value == null) {
+ return null;
+ }
+ try {
+ return new URL(value);
+ } catch (MalformedURLException e) {
+ throw new MappingException(String.format("%s is not a URL.", value), e);
+ }
+ }
+
+ public String map(URL value) {
+ if (value == null) {
+ return null;
+ }
+ return value.toString();
+ }
+
+ public URI map(URI value) {
+ return value;
+ }
+
+ public URI mapToURI(String value) {
+ if (value == null) {
+ return null;
+ }
+ return URI.create(value);
+ }
+
+ public String mapFromURI(URI value) {
+ if (value == null) {
+ return null;
+ }
+ return value.toString();
+ }
+}
diff --git a/document-management/src/main/java/org/fiware/tmforum/documentmanagement/domain/DocumentSpecification.java b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/domain/DocumentSpecification.java
new file mode 100644
index 00000000..77d6b47f
--- /dev/null
+++ b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/domain/DocumentSpecification.java
@@ -0,0 +1,93 @@
+package org.fiware.tmforum.documentmanagement.domain;
+
+import io.github.wistefan.mapping.annotations.AttributeGetter;
+import io.github.wistefan.mapping.annotations.AttributeSetter;
+import io.github.wistefan.mapping.annotations.AttributeType;
+import io.github.wistefan.mapping.annotations.MappingEnabled;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import org.fiware.tmforum.common.domain.AttachmentRefOrValue;
+import org.fiware.tmforum.common.domain.ConstraintRef;
+import org.fiware.tmforum.common.domain.EntityWithId;
+import org.fiware.tmforum.common.domain.RelatedParty;
+import org.fiware.tmforum.common.domain.TimePeriod;
+import org.fiware.tmforum.service.CharacteristicSpecification;
+import org.fiware.tmforum.service.EntitySpecificationRelationship;
+import org.fiware.tmforum.service.TargetEntitySchema;
+
+import java.net.URI;
+import java.time.Instant;
+import java.util.List;
+
+@EqualsAndHashCode(callSuper = true)
+@MappingEnabled(entityType = DocumentSpecification.TYPE_DOCUMENT_SPECIFICATION)
+public class DocumentSpecification extends EntityWithId {
+
+ public static final String TYPE_DOCUMENT_SPECIFICATION = "document-specification";
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY, targetName = "href")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY, targetName = "href")}))
+ private URI href;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY, targetName = "name")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY, targetName = "name")}))
+ private String name;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY, targetName = "description")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY, targetName = "description")}))
+ private String description;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY, targetName = "version")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY, targetName = "version")}))
+ private String version;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY, targetName = "isBundle")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY, targetName = "isBundle")}))
+ private Boolean isBundle;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY, targetName = "lastUpdate")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY, targetName = "lastUpdate")}))
+ private Instant lastUpdate;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY, targetName = "lifecycleStatus")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY, targetName = "lifecycleStatus")}))
+ private String lifecycleStatus;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY_LIST, targetName = "attachment")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY_LIST, targetName = "attachment", targetClass = AttachmentRefOrValue.class)}))
+ private List attachment;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.RELATIONSHIP_LIST, targetName = "relatedParty")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.RELATIONSHIP_LIST, targetName = "relatedParty", targetClass = RelatedParty.class, fromProperties = true)}))
+ private List relatedParty;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.RELATIONSHIP_LIST, targetName = "constraint")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.RELATIONSHIP_LIST, targetName = "constraint", targetClass = ConstraintRef.class)}))
+ private List constraint;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.RELATIONSHIP_LIST, targetName = "entitySpecRelationship")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.RELATIONSHIP_LIST, targetName = "entitySpecRelationship", targetClass = EntitySpecificationRelationship.class)}))
+ private List entitySpecRelationship;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY_LIST, targetName = "specCharacteristic")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY_LIST, targetName = "specCharacteristic", targetClass = CharacteristicSpecification.class)}))
+ private List specCharacteristic;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY, targetName = "targetEntitySchema")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY, targetName = "targetEntitySchema")}))
+ private TargetEntitySchema targetEntitySchema;
+
+ @Getter(onMethod = @__({@AttributeGetter(value = AttributeType.PROPERTY, targetName = "validFor")}))
+ @Setter(onMethod = @__({@AttributeSetter(value = AttributeType.PROPERTY, targetName = "validFor")}))
+ private TimePeriod validFor;
+
+ public DocumentSpecification(String id) {
+ super(TYPE_DOCUMENT_SPECIFICATION, id);
+ }
+
+ @Override
+ public String getEntityState() {
+ return lifecycleStatus;
+ }
+}
diff --git a/document-management/src/main/java/org/fiware/tmforum/documentmanagement/rest/DocumentSpecificationApiController.java b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/rest/DocumentSpecificationApiController.java
new file mode 100644
index 00000000..9d095ce4
--- /dev/null
+++ b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/rest/DocumentSpecificationApiController.java
@@ -0,0 +1,224 @@
+package org.fiware.tmforum.documentmanagement.rest;
+
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.annotation.Controller;
+import lombok.extern.slf4j.Slf4j;
+import org.fiware.document.api.DocumentSpecificationApi;
+import org.fiware.document.model.DocumentSpecificationCreateVO;
+import org.fiware.document.model.DocumentSpecificationUpdateVO;
+import org.fiware.document.model.DocumentSpecificationVO;
+import org.fiware.tmforum.common.exception.TmForumException;
+import org.fiware.tmforum.common.exception.TmForumExceptionReason;
+import org.fiware.tmforum.common.mapping.IdHelper;
+import org.fiware.tmforum.common.notification.TMForumEventHandler;
+import org.fiware.tmforum.common.querying.QueryParser;
+import org.fiware.tmforum.common.repository.TmForumRepository;
+import org.fiware.tmforum.common.rest.AbstractApiController;
+import org.fiware.tmforum.common.validation.ReferenceValidationService;
+import org.fiware.tmforum.common.validation.ReferencedEntity;
+import org.fiware.tmforum.common.domain.AttachmentRefOrValue;
+import org.fiware.tmforum.documentmanagement.AttachmentService;
+import org.fiware.tmforum.documentmanagement.TMForumMapper;
+import org.fiware.tmforum.documentmanagement.domain.DocumentSpecification;
+import reactor.core.publisher.Mono;
+
+import javax.annotation.Nullable;
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+@Slf4j
+@Controller("${api.document-management.basepath:/}")
+public class DocumentSpecificationApiController extends AbstractApiController
+ implements DocumentSpecificationApi {
+
+ private final TMForumMapper tmForumMapper;
+ private final Clock clock;
+ @Nullable
+ private final AttachmentService attachmentService;
+
+ public DocumentSpecificationApiController(
+ QueryParser queryParser,
+ ReferenceValidationService validationService,
+ TmForumRepository repository,
+ TMForumMapper tmForumMapper,
+ Clock clock,
+ TMForumEventHandler eventHandler,
+ @Nullable AttachmentService attachmentService) {
+ super(queryParser, validationService, repository, eventHandler);
+ this.tmForumMapper = tmForumMapper;
+ this.clock = clock;
+ this.attachmentService = attachmentService;
+ }
+
+ @Override
+ public Mono> createDocumentSpecification(
+ DocumentSpecificationCreateVO createVO) {
+
+ if (createVO.getName() == null || createVO.getName().trim().isEmpty()) {
+ throw new TmForumException(
+ "Name field is required and must not be blank to create a document specification.",
+ TmForumExceptionReason.INVALID_DATA);
+ }
+
+ DocumentSpecification docSpec = tmForumMapper.map(
+ tmForumMapper.map(createVO,
+ IdHelper.toNgsiLd(UUID.randomUUID().toString(),
+ DocumentSpecification.TYPE_DOCUMENT_SPECIFICATION)));
+
+ docSpec.setLastUpdate(clock.instant());
+
+ List attachments = docSpec.getAttachment();
+ if (attachmentService == null) {
+ rejectInlineContent(attachments);
+ } else if (attachments != null) {
+ attachments.forEach(attachmentService::validateAttachmentContent);
+ }
+
+ Mono preparedSpec = attachmentService != null
+ ? attachmentService.offloadAttachments(attachments, docSpec.getId().toString())
+ .doOnNext(docSpec::setAttachment)
+ .thenReturn(docSpec)
+ : Mono.just(docSpec);
+
+ return preparedSpec
+ .flatMap(spec -> create(getCheckingMono(spec), DocumentSpecification.class))
+ .map(tmForumMapper::map)
+ .map(HttpResponse::created);
+ }
+
+ @Override
+ public Mono> deleteDocumentSpecification(String id) {
+ if (!IdHelper.isNgsiLdId(id)) {
+ throw new TmForumException(
+ "Did not receive a valid id, such document specification cannot exist.",
+ TmForumExceptionReason.NOT_FOUND);
+ }
+
+ // First retrieve to get attachment info for S3 cleanup
+ return retrieve(id, DocumentSpecification.class)
+ .flatMap(docSpec -> {
+ Mono deletionStep = attachmentService != null
+ ? attachmentService.deleteAttachments(docSpec.getAttachment())
+ : Mono.empty();
+ return deletionStep.then(delete(id));
+ })
+ .switchIfEmpty(Mono.defer(() -> delete(id)));
+ }
+
+ @Override
+ public Mono>> listDocumentSpecification(
+ @Nullable String fields,
+ @Nullable Integer offset,
+ @Nullable Integer limit) {
+ // List returns retrieval info only (no hydration)
+ return list(offset, limit, DocumentSpecification.TYPE_DOCUMENT_SPECIFICATION, DocumentSpecification.class)
+ .map(stream -> stream.map(tmForumMapper::map).toList())
+ .switchIfEmpty(Mono.just(List.of()))
+ .map(HttpResponse::ok);
+ }
+
+ @Override
+ public Mono> patchDocumentSpecification(
+ String id,
+ DocumentSpecificationUpdateVO updateVO) {
+ if (!IdHelper.isNgsiLdId(id)) {
+ throw new TmForumException(
+ "Did not receive a valid id, such document specification cannot exist.",
+ TmForumExceptionReason.NOT_FOUND);
+ }
+
+ DocumentSpecification updatedSpec = tmForumMapper.map(updateVO, id);
+ List newAttachments = updatedSpec.getAttachment();
+
+ if (attachmentService == null) {
+ rejectInlineContent(newAttachments);
+ return retrieve(id, DocumentSpecification.class)
+ .flatMap(existing -> {
+ if (updatedSpec.getAtSchemaLocation() == null) {
+ updatedSpec.setAtSchemaLocation(existing.getAtSchemaLocation());
+ }
+ return patch(id, updatedSpec, getCheckingMono(updatedSpec), DocumentSpecification.class);
+ })
+ .switchIfEmpty(Mono.defer(() -> patch(id, updatedSpec, getCheckingMono(updatedSpec), DocumentSpecification.class)))
+ .map(tmForumMapper::map)
+ .map(HttpResponse::ok);
+ }
+
+ if (newAttachments != null) {
+ newAttachments.forEach(attachmentService::validateAttachmentContent);
+ }
+
+ return retrieve(id, DocumentSpecification.class)
+ .flatMap(existing -> {
+ if (updatedSpec.getAtSchemaLocation() == null) {
+ updatedSpec.setAtSchemaLocation(existing.getAtSchemaLocation());
+ }
+ if (newAttachments == null || newAttachments.isEmpty()) {
+ return Mono.just(updatedSpec);
+ }
+ return attachmentService.deleteOrphanedAttachments(existing.getAttachment(), newAttachments)
+ .then(attachmentService.offloadAttachments(newAttachments, id))
+ .doOnNext(updatedSpec::setAttachment)
+ .thenReturn(updatedSpec);
+ })
+ .switchIfEmpty(Mono.just(updatedSpec))
+ .flatMap(spec -> patch(id, spec, getCheckingMono(spec), DocumentSpecification.class))
+ .map(tmForumMapper::map)
+ .map(HttpResponse::ok);
+ }
+
+ @Override
+ public Mono> retrieveDocumentSpecification(
+ String id,
+ @Nullable String fields) {
+ if (!IdHelper.isNgsiLdId(id)) {
+ throw new TmForumException(
+ "Did not receive a valid id, such document specification cannot exist.",
+ TmForumExceptionReason.NOT_FOUND);
+ }
+
+ return retrieve(id, DocumentSpecification.class)
+ .switchIfEmpty(Mono.error(new TmForumException(
+ "No such document specification exists.",
+ TmForumExceptionReason.NOT_FOUND)))
+ .flatMap(docSpec -> {
+ if (attachmentService != null) {
+ return attachmentService.resolveAttachments(docSpec.getAttachment())
+ .doOnNext(docSpec::setAttachment)
+ .thenReturn(docSpec);
+ }
+ return Mono.just(docSpec);
+ })
+ .map(tmForumMapper::map)
+ .map(HttpResponse::ok);
+ }
+
+ private void rejectInlineContent(List attachments) {
+ if (attachments == null) {
+ return;
+ }
+ attachments.stream()
+ .filter(att -> att.getContent() != null && !att.getContent().isEmpty())
+ .findFirst()
+ .ifPresent(att -> {
+ throw new TmForumException(
+ "Attachments with inline content are not supported when no AttachmentService is configured. Provide a URL reference instead.",
+ TmForumExceptionReason.INVALID_DATA);
+ });
+ }
+
+ private Mono getCheckingMono(DocumentSpecification docSpec) {
+ List> references = new ArrayList<>();
+ references.add(docSpec.getRelatedParty());
+ references.add(docSpec.getConstraint());
+ references.add(docSpec.getEntitySpecRelationship());
+
+ return getCheckingMono(docSpec, references)
+ .onErrorMap(throwable -> new TmForumException(
+ String.format("Was not able to create document specification %s", docSpec.getId()),
+ throwable,
+ TmForumExceptionReason.INVALID_RELATIONSHIP));
+ }
+}
diff --git a/document-management/src/main/java/org/fiware/tmforum/documentmanagement/s3/S3AttachmentService.java b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/s3/S3AttachmentService.java
new file mode 100644
index 00000000..9623fa5a
--- /dev/null
+++ b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/s3/S3AttachmentService.java
@@ -0,0 +1,335 @@
+package org.fiware.tmforum.documentmanagement.s3;
+
+import io.micronaut.context.annotation.Requires;
+import org.fiware.tmforum.documentmanagement.AttachmentService;
+import io.minio.BucketExistsArgs;
+import io.minio.GetPresignedObjectUrlArgs;
+import io.minio.MakeBucketArgs;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import io.minio.RemoveObjectArgs;
+import io.minio.http.Method;
+import jakarta.annotation.PostConstruct;
+import jakarta.inject.Singleton;
+import lombok.extern.slf4j.Slf4j;
+import org.fiware.tmforum.common.domain.AttachmentRefOrValue;
+import org.fiware.tmforum.common.domain.Quantity;
+import org.fiware.tmforum.common.domain.TimePeriod;
+import org.fiware.tmforum.common.exception.TmForumException;
+import org.fiware.tmforum.common.exception.TmForumExceptionReason;
+
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.io.ByteArrayInputStream;
+import java.net.URL;
+import java.util.Base64;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * {@link AttachmentService} implementation backed by an S3-compatible object store (e.g. MinIO,
+ * AWS S3, IONOS Object Storage).
+ *
+ * Inline base64 attachment content is decoded, validated against the configured size limit,
+ * and uploaded to the configured bucket. The {@code content} field is cleared and the {@code url}
+ * field is set to an internal S3 path ({@code endpoint/bucket/key}). On retrieval the internal path
+ * is resolved to a pre-signed download URL so the raw bytes are never persisted in the context broker.
+ *
+ *
This bean is only instantiated when {@code s3.enabled=true} is set. When it is absent the
+ * API falls back to accepting pure URL/href references only.
+ */
+@Singleton
+@Requires(property = "s3.enabled", value = "true")
+@Slf4j
+public class S3AttachmentService implements AttachmentService {
+
+ private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
+ private static final int PRESIGNED_URL_EXPIRY_SECONDS = 3600;
+
+ private MinioClient s3Client;
+ private final S3Configuration config;
+
+ public S3AttachmentService(S3Configuration config) {
+ this.config = config;
+ }
+
+ /**
+ * Initialises the MinIO client from configuration and ensures the target bucket exists.
+ * Called automatically by the Micronaut container after dependency injection.
+ *
+ * @throws RuntimeException if the MinIO client cannot be created
+ */
+ @PostConstruct
+ public void init() {
+ log.info("Initializing S3AttachmentService: endpoint={}, bucket={}, maxContentSize={}",
+ config.getEndpoint(), config.getBucket(), config.getMaxContentSize());
+
+ try {
+ MinioClient.Builder builder = MinioClient.builder()
+ .endpoint(config.getEndpoint())
+ .credentials(config.getAccessKey(), config.getSecretKey());
+ if (config.getRegion() != null && !config.getRegion().isBlank()) {
+ builder.region(config.getRegion());
+ }
+ this.s3Client = builder.build();
+ log.info("S3 client created successfully");
+ } catch (Exception e) {
+ log.error("Failed to create S3 client: {}", e.getMessage(), e);
+ throw new TmForumException("Failed to initialize S3 client", e, TmForumExceptionReason.UNKNOWN);
+ }
+ try {
+ ensureBucketExists();
+ } catch (Exception e) {
+ log.warn("Could not ensure bucket exists on startup: {}. Will retry on first upload.", e.getMessage());
+ }
+ }
+
+ private void ensureBucketExists() throws Exception {
+ boolean exists = s3Client.bucketExists(
+ BucketExistsArgs.builder().bucket(config.getBucket()).build());
+ if (!exists) {
+ s3Client.makeBucket(
+ MakeBucketArgs.builder().bucket(config.getBucket()).build());
+ log.info("Created S3 bucket: {}", config.getBucket());
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void validateAttachmentContent(AttachmentRefOrValue attachment) {
+ String content = attachment.getContent();
+ if (content == null || content.isEmpty()) {
+ return;
+ }
+ byte[] decoded;
+ try {
+ decoded = Base64.getDecoder().decode(content);
+ } catch (IllegalArgumentException e) {
+ throw new TmForumException("Attachment content is not valid base64.", TmForumExceptionReason.INVALID_DATA);
+ }
+ if (decoded.length > config.getMaxContentSize()) {
+ throw new TmForumException(
+ String.format("Attachment content exceeds maximum size of %d bytes (got %d bytes)",
+ config.getMaxContentSize(), decoded.length),
+ TmForumExceptionReason.INVALID_DATA);
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Mono> offloadAttachments(List attachments, String entityId) {
+ if (attachments == null || attachments.isEmpty()) {
+ return Mono.justOrEmpty(attachments);
+ }
+
+ return Mono.fromCallable(() -> attachments.stream()
+ .map(att -> processAttachmentForOffload(att, entityId))
+ .collect(Collectors.toList()))
+ .subscribeOn(Schedulers.boundedElastic());
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Mono> resolveAttachments(List attachments) {
+ if (attachments == null || attachments.isEmpty()) {
+ return Mono.justOrEmpty(attachments);
+ }
+
+ return Mono.fromCallable(() -> attachments.stream()
+ .map(this::resolveAttachment)
+ .collect(Collectors.toList()))
+ .subscribeOn(Schedulers.boundedElastic());
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Mono deleteAttachments(List attachments) {
+ if (attachments == null || attachments.isEmpty()) {
+ return Mono.empty();
+ }
+
+ return Mono.fromRunnable(() -> attachments.stream()
+ .map(AttachmentRefOrValue::getUrl)
+ .filter(this::isManagedUrl)
+ .map(this::extractKey)
+ .forEach(key -> {
+ try {
+ deleteFromS3(key);
+ } catch (Exception e) {
+ log.warn("Failed to delete S3 object {}", key, e);
+ }
+ }))
+ .subscribeOn(Schedulers.boundedElastic())
+ .then();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Mono deleteOrphanedAttachments(List existing, List updated) {
+ if (existing == null || existing.isEmpty()) {
+ return Mono.empty();
+ }
+ List updatedUrls = updated == null ? List.of() : updated.stream()
+ .filter(a -> a.getUrl() != null)
+ .map(a -> a.getUrl().toString())
+ .toList();
+
+ return Mono.fromRunnable(() -> existing.stream()
+ .filter(a -> isManagedUrl(a.getUrl()))
+ .filter(a -> !updatedUrls.contains(a.getUrl().toString()))
+ .map(a -> extractKey(a.getUrl()))
+ .forEach(key -> {
+ try {
+ deleteFromS3(key);
+ } catch (Exception e) {
+ log.warn("Failed to delete orphaned S3 object {}", key, e);
+ }
+ }))
+ .subscribeOn(Schedulers.boundedElastic())
+ .then();
+ }
+
+ private AttachmentRefOrValue processAttachmentForOffload(AttachmentRefOrValue attachment, String entityId) {
+ String content = attachment.getContent();
+ if (content == null || content.isEmpty()) {
+ return attachment;
+ }
+
+ // Skip if this attachment already has a managed URL
+ if (isManagedUrl(attachment.getUrl())) {
+ return attachment;
+ }
+
+ byte[] decoded;
+ try {
+ decoded = Base64.getDecoder().decode(content);
+ } catch (IllegalArgumentException e) {
+ log.warn("Content is not valid base64, skipping offload");
+ return attachment;
+ }
+
+ String key = generateKey(entityId, attachment);
+ uploadToS3(key, decoded, attachment.getMimeType());
+
+ AttachmentRefOrValue modified = copyAttachment(attachment);
+ modified.setContent(null);
+ try {
+ modified.setUrl(new URL(config.getEndpoint() + "/" + config.getBucket() + "/" + key));
+ } catch (Exception e) {
+ throw new TmForumException("Failed to construct S3 URL for attachment: " + e.getMessage(),
+ TmForumExceptionReason.INVALID_DATA);
+ }
+ return modified;
+ }
+
+ private AttachmentRefOrValue resolveAttachment(AttachmentRefOrValue attachment) {
+ if (!isManagedUrl(attachment.getUrl())) {
+ return attachment;
+ }
+
+ try {
+ String key = extractKey(attachment.getUrl());
+ String presignedUrl = s3Client.getPresignedObjectUrl(
+ GetPresignedObjectUrlArgs.builder()
+ .method(Method.GET)
+ .bucket(config.getBucket())
+ .object(key)
+ .expiry(PRESIGNED_URL_EXPIRY_SECONDS)
+ .build());
+ AttachmentRefOrValue resolved = copyAttachment(attachment);
+ resolved.setUrl(new URL(presignedUrl));
+ return resolved;
+ } catch (Exception e) {
+ log.error("Failed to resolve attachment URL from S3: {}", e.getMessage());
+ return attachment;
+ }
+ }
+
+ private boolean isManagedUrl(URL url) {
+ if (url == null) {
+ return false;
+ }
+ String prefix = config.getEndpoint() + "/" + config.getBucket() + "/";
+ return url.toString().startsWith(prefix);
+ }
+
+ private String extractKey(URL url) {
+ String prefix = config.getEndpoint() + "/" + config.getBucket() + "/";
+ return url.toString().substring(prefix.length());
+ }
+
+ private String generateKey(String entityId, AttachmentRefOrValue attachment) {
+ String name = attachment.getName();
+ if (name == null || name.isEmpty()) {
+ name = "attachment";
+ }
+ // Clean the name for S3 key
+ name = name.replaceAll("[^a-zA-Z0-9._-]", "_");
+ return String.format("%s/%s-%s", entityId, UUID.randomUUID().toString(), name);
+ }
+
+ private void uploadToS3(String key, byte[] content, String mimeType) {
+ try {
+ ensureBucketExists();
+
+ String contentType = mimeType != null ? mimeType : DEFAULT_CONTENT_TYPE;
+
+ s3Client.putObject(
+ PutObjectArgs.builder()
+ .bucket(config.getBucket())
+ .object(key)
+ .stream(new ByteArrayInputStream(content), content.length, -1)
+ .contentType(contentType)
+ .build()
+ );
+ log.debug("Uploaded to S3: {}/{}", config.getBucket(), key);
+ } catch (Exception e) {
+ throw new TmForumException(
+ "Failed to upload attachment to S3: " + e.getMessage(),
+ TmForumExceptionReason.INVALID_DATA);
+ }
+ }
+
+ private void deleteFromS3(String key) {
+ try {
+ s3Client.removeObject(
+ RemoveObjectArgs.builder()
+ .bucket(config.getBucket())
+ .object(key)
+ .build()
+ );
+ log.debug("Deleted from S3: {}/{}", config.getBucket(), key);
+ } catch (Exception e) {
+ log.warn("Failed to delete S3 object {}", key, e);
+ }
+ }
+
+ private AttachmentRefOrValue copyAttachment(AttachmentRefOrValue source) {
+ AttachmentRefOrValue copy = new AttachmentRefOrValue();
+ copy.setTmfId(source.getTmfId());
+ copy.setHref(source.getHref());
+ copy.setAttachmentType(source.getAttachmentType());
+ copy.setContent(source.getContent());
+ copy.setDescription(source.getDescription());
+ copy.setMimeType(source.getMimeType());
+ copy.setUrl(source.getUrl());
+ if (source.getSize() != null) {
+ Quantity sizeCopy = new Quantity();
+ sizeCopy.setAmount(source.getSize().getAmount());
+ sizeCopy.setUnits(source.getSize().getUnits());
+ copy.setSize(sizeCopy);
+ }
+ if (source.getValidFor() != null) {
+ TimePeriod validForCopy = new TimePeriod();
+ validForCopy.setStartDateTime(source.getValidFor().getStartDateTime());
+ validForCopy.setEndDateTime(source.getValidFor().getEndDateTime());
+ copy.setValidFor(validForCopy);
+ }
+ copy.setName(source.getName());
+ copy.setAtReferredType(source.getAtReferredType());
+ copy.setAtSchemaLocation(source.getAtSchemaLocation());
+ return copy;
+ }
+}
diff --git a/document-management/src/main/java/org/fiware/tmforum/documentmanagement/s3/S3Configuration.java b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/s3/S3Configuration.java
new file mode 100644
index 00000000..437f1c77
--- /dev/null
+++ b/document-management/src/main/java/org/fiware/tmforum/documentmanagement/s3/S3Configuration.java
@@ -0,0 +1,70 @@
+package org.fiware.tmforum.documentmanagement.s3;
+
+import io.micronaut.context.annotation.ConfigurationProperties;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * Configuration properties for the S3-compatible object storage backend, bound from the
+ * {@code s3.*} namespace in {@code application.yaml}.
+ *
+ * S3 support is opt-in: set {@code s3.enabled=true} together with the connection properties
+ * to activate the {@link S3AttachmentService}. When disabled, the DocumentManagement API
+ * still operates normally but rejects attachments with inline content.
+ *
+ *
Example configuration for a local MinIO instance:
+ *
+ * s3:
+ * enabled: true
+ * endpoint: http://minio:9000
+ * access-key: minioadmin
+ * secret-key: minioadmin
+ * bucket: document-attachments
+ *
+ */
+@ConfigurationProperties("s3")
+@Getter
+@Setter
+public class S3Configuration {
+
+ /**
+ * Whether the S3 storage backend is enabled. When {@code false} (the default) the
+ * {@link S3AttachmentService} bean is not instantiated and attachments with inline
+ * content are rejected by the API.
+ */
+ private boolean enabled = false;
+
+ /**
+ * URL of the S3-compatible endpoint, e.g. {@code http://minio:9000} for MinIO or
+ * {@code https://s3.amazonaws.com} for AWS S3. Defaults to {@code http://localhost:9000}.
+ */
+ private String endpoint = "http://localhost:9000";
+
+ /**
+ * Access key (username) used to authenticate with the S3 endpoint.
+ */
+ private String accessKey = "minioadmin";
+
+ /**
+ * Secret key (password) used to authenticate with the S3 endpoint.
+ */
+ private String secretKey = "minioadmin";
+
+ /**
+ * Name of the S3 bucket where attachment objects are stored. The bucket is created
+ * automatically on startup if it does not already exist.
+ */
+ private String bucket = "document-attachments";
+
+ /**
+ * Maximum allowed size in bytes for a single attachment's inline content. Requests
+ * that exceed this limit are rejected with a 422. Defaults to 10 MB.
+ */
+ private long maxContentSize = 10 * 1024 * 1024;
+
+ /**
+ * S3 region identifier (e.g. {@code us-east-1} for AWS, {@code eu-central-3} for IONOS).
+ * Required for AWS S3 and IONOS Object Storage. Leave unset for local MinIO instances.
+ */
+ private String region;
+}
diff --git a/document-management/src/main/resources/application.yaml b/document-management/src/main/resources/application.yaml
new file mode 100644
index 00000000..e7fbde68
--- /dev/null
+++ b/document-management/src/main/resources/application.yaml
@@ -0,0 +1,60 @@
+micronaut:
+
+ server:
+ port: 8667
+
+ metrics:
+ enabled: true
+ export:
+ prometheus:
+ step: PT2s
+ descriptions: false
+
+ http:
+ services:
+ read-timeout: 30s
+ ngsi:
+ path: ngsi-ld/v1
+ url: http://localhost:1026
+ read-timeout: 30
+---
+jackson:
+ serialization:
+ writeDatesAsTimestamps: false
+---
+endpoints:
+ metrics:
+ enabled: true
+ health:
+ enabled: true
+
+---
+loggers:
+ levels:
+ ROOT: WARN
+
+---
+general:
+ contextUrl: https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld
+ serverHost: http://localhost:8667
+
+api:
+ document-management:
+ basepath: ${general.basepath:/}
+
+mapping:
+ strictRelationships: false
+ api_extension_enabled: true
+
+s3:
+ # Any S3-compatible provider is supported. Examples:
+ # MinIO (local/k3s): endpoint: "http://localhost:9000"
+ # AWS S3: endpoint: "https://s3.amazonaws.com", region: "us-east-1"
+ # IONOS: endpoint: "https://s3-eu-central-1.ionoscloud.com"
+ enabled: false
+ endpoint: "http://localhost:9000"
+ accessKey: "minioadmin"
+ secretKey: "minioadmin"
+ bucket: "document-attachments"
+ maxContentSize: 10485760
+ # region: "" # Required for AWS S3; omit for MinIO, IONOS, and other providers
diff --git a/document-management/src/main/resources/logback.xml b/document-management/src/main/resources/logback.xml
new file mode 100644
index 00000000..bd177f67
--- /dev/null
+++ b/document-management/src/main/resources/logback.xml
@@ -0,0 +1,16 @@
+
+
+
+ true
+
+
+ %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n
+
+
+
+
+
+
+
+
diff --git a/document-management/src/test/java/org/fiware/tmforum/documentmanagement/DocumentSpecificationApiIT.java b/document-management/src/test/java/org/fiware/tmforum/documentmanagement/DocumentSpecificationApiIT.java
new file mode 100644
index 00000000..99c57495
--- /dev/null
+++ b/document-management/src/test/java/org/fiware/tmforum/documentmanagement/DocumentSpecificationApiIT.java
@@ -0,0 +1,577 @@
+package org.fiware.tmforum.documentmanagement;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.HttpStatus;
+import io.micronaut.test.annotation.MockBean;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import org.fiware.document.api.DocumentSpecificationApiTestClient;
+import org.fiware.document.api.DocumentSpecificationApiTestSpec;
+import org.fiware.document.model.AttachmentRefOrValueVO;
+import org.fiware.document.model.DocumentSpecificationCreateVO;
+import org.fiware.document.model.DocumentSpecificationStatusTypeVO;
+import org.fiware.document.model.DocumentSpecificationUpdateVO;
+import org.fiware.document.model.DocumentSpecificationVO;
+import org.fiware.document.model.RelatedPartyVO;
+import org.fiware.ngsi.api.EntitiesApiClient;
+import org.fiware.tmforum.common.configuration.GeneralProperties;
+import org.fiware.tmforum.common.exception.ErrorDetails;
+import org.fiware.tmforum.common.domain.AttachmentRefOrValue;
+import org.fiware.tmforum.common.notification.TMForumEventHandler;
+import org.fiware.tmforum.common.test.AbstractApiIT;
+import org.fiware.tmforum.documentmanagement.domain.DocumentSpecification;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import reactor.core.publisher.Mono;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@MicronautTest(packages = {"org.fiware.tmforum.documentmanagement"})
+public class DocumentSpecificationApiIT extends AbstractApiIT implements DocumentSpecificationApiTestSpec {
+
+ public final DocumentSpecificationApiTestClient documentSpecificationApiTestClient;
+
+ private String message;
+ private DocumentSpecificationCreateVO documentSpecificationCreateVO;
+ private DocumentSpecificationVO expectedDocSpec;
+
+ public DocumentSpecificationApiIT(
+ DocumentSpecificationApiTestClient documentSpecificationApiTestClient,
+ EntitiesApiClient entitiesApiClient,
+ ObjectMapper objectMapper,
+ GeneralProperties generalProperties) {
+ super(entitiesApiClient, objectMapper, generalProperties);
+ this.documentSpecificationApiTestClient = documentSpecificationApiTestClient;
+ }
+
+ @Override
+ protected String getEntityType() {
+ return DocumentSpecification.TYPE_DOCUMENT_SPECIFICATION;
+ }
+
+ @MockBean(TMForumEventHandler.class)
+ public TMForumEventHandler eventHandler() {
+ TMForumEventHandler eventHandler = mock(TMForumEventHandler.class);
+ when(eventHandler.handleCreateEvent(any())).thenReturn(Mono.empty());
+ when(eventHandler.handleUpdateEvent(any(), any())).thenReturn(Mono.empty());
+ return eventHandler;
+ }
+
+ @MockBean(AttachmentService.class)
+ public AttachmentService attachmentService() {
+ AttachmentService attachmentService = mock(AttachmentService.class);
+ when(attachmentService.offloadAttachments(any(), any())).thenAnswer(i -> {
+ List list = i.getArgument(0);
+ return list == null ? Mono.empty() : Mono.just(list);
+ });
+ when(attachmentService.resolveAttachments(any())).thenAnswer(i -> {
+ List list = i.getArgument(0);
+ return list == null ? Mono.empty() : Mono.just(list);
+ });
+ when(attachmentService.deleteAttachments(any())).thenReturn(Mono.empty());
+ when(attachmentService.deleteOrphanedAttachments(any(), any())).thenReturn(Mono.empty());
+ return attachmentService;
+ }
+
+ @ParameterizedTest
+ @MethodSource("provideValidDocumentSpecifications")
+ public void createDocumentSpecification201(String message, DocumentSpecificationCreateVO createVO,
+ DocumentSpecificationVO expectedDocSpec) throws Exception {
+ this.message = message;
+ this.documentSpecificationCreateVO = createVO;
+ this.expectedDocSpec = expectedDocSpec;
+ createDocumentSpecification201();
+ }
+
+ @Override
+ public void createDocumentSpecification201() throws Exception {
+ HttpResponse response = callAndCatch(
+ () -> documentSpecificationApiTestClient.createDocumentSpecification(null, documentSpecificationCreateVO));
+
+ assertEquals(HttpStatus.CREATED, response.getStatus(), message);
+ DocumentSpecificationVO body = response.body();
+ assertNotNull(body, message);
+ assertNotNull(body.getId(), message);
+ assertEquals(expectedDocSpec.getName(), body.getName(), message);
+ if (expectedDocSpec.getDescription() != null) {
+ assertEquals(expectedDocSpec.getDescription(), body.getDescription(), message);
+ }
+ if (expectedDocSpec.getVersion() != null) {
+ assertEquals(expectedDocSpec.getVersion(), body.getVersion(), message);
+ }
+ if (expectedDocSpec.getLifecycleStatus() != null) {
+ assertEquals(expectedDocSpec.getLifecycleStatus(), body.getLifecycleStatus(), message);
+ }
+ }
+
+ private static Stream provideValidDocumentSpecifications() {
+ List testEntries = new ArrayList<>();
+
+ DocumentSpecificationCreateVO simpleCreateVO = new DocumentSpecificationCreateVO();
+ simpleCreateVO.setName("Test Document Specification");
+ simpleCreateVO.setDescription("A test document specification");
+ simpleCreateVO.setVersion("1.0.0");
+ DocumentSpecificationVO simpleExpected = new DocumentSpecificationVO();
+ simpleExpected.setName("Test Document Specification");
+ simpleExpected.setDescription("A test document specification");
+ simpleExpected.setVersion("1.0.0");
+ testEntries.add(Arguments.of("A simple document specification should be created.", simpleCreateVO, simpleExpected));
+
+ DocumentSpecificationCreateVO withLifecycleVO = new DocumentSpecificationCreateVO();
+ withLifecycleVO.setName("Document with Lifecycle");
+ withLifecycleVO.setLifecycleStatus(DocumentSpecificationStatusTypeVO.APPROVED);
+ DocumentSpecificationVO lifecycleExpected = new DocumentSpecificationVO();
+ lifecycleExpected.setName("Document with Lifecycle");
+ lifecycleExpected.setLifecycleStatus(DocumentSpecificationStatusTypeVO.APPROVED);
+ testEntries.add(Arguments.of("A document specification with lifecycle status should be created.", withLifecycleVO, lifecycleExpected));
+
+ return testEntries.stream();
+ }
+
+ @Test
+ public void createDocumentSpecificationWithUrlAttachment201() throws Exception {
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setName("Document with Attachment");
+ createVO.setVersion("1.0.0");
+
+ AttachmentRefOrValueVO attachment = new AttachmentRefOrValueVO();
+ attachment.setName("test-file.txt");
+ attachment.setMimeType("text/plain");
+ attachment.setUrl(URI.create("https://example.com/test-file.txt"));
+ createVO.setAttachment(List.of(attachment));
+
+ HttpResponse response = callAndCatch(
+ () -> documentSpecificationApiTestClient.createDocumentSpecification(null, createVO));
+
+ assertEquals(HttpStatus.CREATED, response.getStatus(), "Document specification with URL attachment should be created.");
+ assertNotNull(response.body());
+ assertNotNull(response.body().getAttachment());
+ assertFalse(response.body().getAttachment().isEmpty());
+ assertEquals("https://example.com/test-file.txt",
+ response.body().getAttachment().get(0).getUrl().toString(),
+ "URL attachment should be preserved as-is.");
+ }
+
+ @Test
+ public void createDocumentSpecificationWithInlineContent201() throws Exception {
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setName("Document with Inline Content");
+
+ AttachmentRefOrValueVO attachment = new AttachmentRefOrValueVO();
+ attachment.setName("test-file.txt");
+ attachment.setContent(Base64.getEncoder().encodeToString("Hello World".getBytes()));
+ createVO.setAttachment(List.of(attachment));
+
+ HttpResponse response = callAndCatch(
+ () -> documentSpecificationApiTestClient.createDocumentSpecification(null, createVO));
+
+ assertEquals(HttpStatus.CREATED, response.getStatus(),
+ "Inline content should be accepted when an AttachmentService is configured.");
+ }
+
+ @Test
+ @Override
+ public void createDocumentSpecification400() throws Exception {
+ // Test creation with missing required field (name)
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setDescription("Missing name field");
+
+ HttpResponse response = callAndCatch(
+ () -> documentSpecificationApiTestClient.createDocumentSpecification(null, createVO));
+
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatus(), "Creation without name should fail.");
+
+ Optional optionalErrorDetails = response.getBody(ErrorDetails.class);
+ assertTrue(optionalErrorDetails.isPresent(), "Error details should be provided.");
+ }
+
+ @Disabled("Security is handled externally, thus 401 and 403 cannot happen.")
+ @Test
+ @Override
+ public void createDocumentSpecification401() throws Exception {
+ }
+
+ @Disabled("Security is handled externally, thus 401 and 403 cannot happen.")
+ @Test
+ @Override
+ public void createDocumentSpecification403() throws Exception {
+ }
+
+ @Disabled("Prohibited by the framework.")
+ @Test
+ @Override
+ public void createDocumentSpecification405() throws Exception {
+ }
+
+ @Disabled("DocumentSpecification doesn't have 'implicit' entities and id is generated, no conflict possible.")
+ @Test
+ @Override
+ public void createDocumentSpecification409() throws Exception {
+ }
+
+ @Override
+ public void createDocumentSpecification500() throws Exception {
+ }
+
+ @Test
+ @Override
+ public void deleteDocumentSpecification204() throws Exception {
+ // First create a document specification
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setName("Document to Delete");
+ createVO.setVersion("1.0.0");
+
+ HttpResponse createResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.createDocumentSpecification(null, createVO));
+
+ assertEquals(HttpStatus.CREATED, createResponse.getStatus(), "The document specification should have been created first.");
+ String id = createResponse.body().getId();
+
+ // Then delete it
+ HttpResponse> deleteResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.deleteDocumentSpecification(null, id));
+
+ assertEquals(HttpStatus.NO_CONTENT, deleteResponse.getStatus(), "The document specification should have been deleted.");
+
+ // Verify it no longer exists
+ assertEquals(HttpStatus.NOT_FOUND,
+ callAndCatch(() -> documentSpecificationApiTestClient.retrieveDocumentSpecification(null, id, null)).getStatus(),
+ "The document specification should not exist anymore.");
+ }
+
+ @Disabled("400 is impossible to happen on deletion with the current implementation.")
+ @Test
+ @Override
+ public void deleteDocumentSpecification400() throws Exception {
+ }
+
+ @Disabled("Security is handled externally, thus 401 and 403 cannot happen.")
+ @Test
+ @Override
+ public void deleteDocumentSpecification401() throws Exception {
+ }
+
+ @Disabled("Security is handled externally, thus 401 and 403 cannot happen.")
+ @Test
+ @Override
+ public void deleteDocumentSpecification403() throws Exception {
+ }
+
+ @Test
+ @Override
+ public void deleteDocumentSpecification404() throws Exception {
+ HttpResponse> notFoundResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.deleteDocumentSpecification(null,
+ "urn:ngsi-ld:document-specification:non-existent"));
+
+ assertEquals(HttpStatus.NOT_FOUND, notFoundResponse.getStatus(), "No such document specification should exist.");
+
+ Optional optionalErrorDetails = notFoundResponse.getBody(ErrorDetails.class);
+ assertTrue(optionalErrorDetails.isPresent(), "Error details should be provided.");
+
+ // Also test with invalid id format
+ notFoundResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.deleteDocumentSpecification(null, "invalid-id"));
+ assertEquals(HttpStatus.NOT_FOUND, notFoundResponse.getStatus(), "Invalid ID should return not found.");
+
+ optionalErrorDetails = notFoundResponse.getBody(ErrorDetails.class);
+ assertTrue(optionalErrorDetails.isPresent(), "Error details should be provided.");
+ }
+
+ @Disabled("Prohibited by the framework.")
+ @Test
+ @Override
+ public void deleteDocumentSpecification405() throws Exception {
+ }
+
+ @Disabled("Impossible status.")
+ @Test
+ @Override
+ public void deleteDocumentSpecification409() throws Exception {
+ }
+
+ @Override
+ public void deleteDocumentSpecification500() throws Exception {
+ }
+
+ @Test
+ @Override
+ public void listDocumentSpecification200() throws Exception {
+ List expectedSpecs = new ArrayList<>();
+
+ // Create document specifications
+ for (int i = 0; i < 10; i++) {
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setName("Document " + i);
+ createVO.setVersion("1.0.0");
+
+ HttpResponse createResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.createDocumentSpecification(null, createVO));
+ assertEquals(HttpStatus.CREATED, createResponse.getStatus());
+ expectedSpecs.add(createResponse.body());
+ }
+
+ // List all
+ HttpResponse> listResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.listDocumentSpecification(null, null, null, null));
+
+ assertEquals(HttpStatus.OK, listResponse.getStatus(), "The list should be accessible.");
+ assertEquals(expectedSpecs.size(), listResponse.body().size(), "All document specifications should have been returned.");
+
+ // Test pagination
+ Integer limit = 5;
+ HttpResponse> firstPartResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.listDocumentSpecification(null, null, 0, limit));
+ assertEquals(limit, firstPartResponse.body().size(), "Only the requested number of entries should be returned.");
+
+ HttpResponse> secondPartResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.listDocumentSpecification(null, null, limit, limit));
+ assertEquals(limit, secondPartResponse.body().size(), "Only the requested number of entries should be returned.");
+ }
+
+ @Test
+ @Override
+ public void listDocumentSpecification400() throws Exception {
+ HttpResponse> badRequestResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.listDocumentSpecification(null, null, -1, null));
+ assertEquals(HttpStatus.BAD_REQUEST, badRequestResponse.getStatus(), "Negative offsets are impossible.");
+
+ Optional optionalErrorDetails = badRequestResponse.getBody(ErrorDetails.class);
+ assertTrue(optionalErrorDetails.isPresent(), "Error details should be provided.");
+
+ badRequestResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.listDocumentSpecification(null, null, null, -1));
+ assertEquals(HttpStatus.BAD_REQUEST, badRequestResponse.getStatus(), "Negative limits are impossible.");
+
+ optionalErrorDetails = badRequestResponse.getBody(ErrorDetails.class);
+ assertTrue(optionalErrorDetails.isPresent(), "Error details should be provided.");
+ }
+
+ @Disabled("Security is handled externally, thus 401 and 403 cannot happen.")
+ @Test
+ @Override
+ public void listDocumentSpecification401() throws Exception {
+ }
+
+ @Disabled("Security is handled externally, thus 401 and 403 cannot happen.")
+ @Test
+ @Override
+ public void listDocumentSpecification403() throws Exception {
+ }
+
+ @Disabled("Not found is not possible here, will be answered with an empty list instead.")
+ @Test
+ @Override
+ public void listDocumentSpecification404() throws Exception {
+ }
+
+ @Disabled("Prohibited by the framework.")
+ @Test
+ @Override
+ public void listDocumentSpecification405() throws Exception {
+ }
+
+ @Disabled("Impossible status.")
+ @Test
+ @Override
+ public void listDocumentSpecification409() throws Exception {
+ }
+
+ @Override
+ public void listDocumentSpecification500() throws Exception {
+ }
+
+ @Test
+ @Override
+ public void patchDocumentSpecification200() throws Exception {
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setName("Original Name");
+ createVO.setVersion("1.0.0");
+ HttpResponse createResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.createDocumentSpecification(null, createVO));
+ assertEquals(HttpStatus.CREATED, createResponse.getStatus());
+ String id = createResponse.body().getId();
+
+ DocumentSpecificationUpdateVO updateVO = new DocumentSpecificationUpdateVO();
+ updateVO.setDescription("Updated description");
+ HttpResponse patchResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.patchDocumentSpecification(null, id, updateVO));
+
+ assertEquals(HttpStatus.OK, patchResponse.getStatus(), "Patch should succeed.");
+ assertNotNull(patchResponse.body());
+ assertEquals("Updated description", patchResponse.body().getDescription());
+ }
+
+ @Test
+ @Override
+ public void patchDocumentSpecification400() throws Exception {
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setName("Document for Patch 400");
+ HttpResponse createResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.createDocumentSpecification(null, createVO));
+ assertEquals(HttpStatus.CREATED, createResponse.getStatus());
+ String id = createResponse.body().getId();
+
+ RelatedPartyVO nonExistentParty = new RelatedPartyVO();
+ nonExistentParty.setId("urn:ngsi-ld:organization:non-existent");
+ DocumentSpecificationUpdateVO updateVO = new DocumentSpecificationUpdateVO();
+ updateVO.setRelatedParty(List.of(nonExistentParty));
+ HttpResponse response = callAndCatch(
+ () -> documentSpecificationApiTestClient.patchDocumentSpecification(null, id, updateVO));
+
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatus(),
+ "Patch with a non-existent relatedParty reference should fail with 400.");
+ }
+
+ @Disabled("Security is handled externally, thus 401 and 403 cannot happen.")
+ @Test
+ @Override
+ public void patchDocumentSpecification401() throws Exception {
+ }
+
+ @Disabled("Security is handled externally, thus 401 and 403 cannot happen.")
+ @Test
+ @Override
+ public void patchDocumentSpecification403() throws Exception {
+ }
+
+ @Test
+ @Override
+ public void patchDocumentSpecification404() throws Exception {
+ DocumentSpecificationUpdateVO updateVO = new DocumentSpecificationUpdateVO();
+ updateVO.setDescription("Updated description");
+ HttpResponse response = callAndCatch(
+ () -> documentSpecificationApiTestClient.patchDocumentSpecification(null,
+ "urn:ngsi-ld:document-specification:non-existent", updateVO));
+
+ assertEquals(HttpStatus.NOT_FOUND, response.getStatus(), "No such document specification should exist.");
+ }
+
+ @Disabled("Prohibited by the framework.")
+ @Test
+ @Override
+ public void patchDocumentSpecification405() throws Exception {
+ }
+
+ @Disabled("DocumentSpecification doesn't have 'implicit' entities and id is generated, no conflict possible.")
+ @Test
+ @Override
+ public void patchDocumentSpecification409() throws Exception {
+ }
+
+ @Override
+ public void patchDocumentSpecification500() throws Exception {
+ }
+
+ @Test
+ @Override
+ public void retrieveDocumentSpecification200() throws Exception {
+ // Create a document specification
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setName("Document to Retrieve");
+ createVO.setVersion("1.0.0");
+
+ HttpResponse createResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.createDocumentSpecification(null, createVO));
+
+ assertEquals(HttpStatus.CREATED, createResponse.getStatus(), "The document specification should have been created first.");
+ String id = createResponse.body().getId();
+
+ // Retrieve it
+ HttpResponse retrieveResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.retrieveDocumentSpecification(null, id, null));
+
+ assertEquals(HttpStatus.OK, retrieveResponse.getStatus(), "The retrieval should be ok.");
+ assertNotNull(retrieveResponse.body());
+ assertEquals(id, retrieveResponse.body().getId());
+ assertEquals("Document to Retrieve", retrieveResponse.body().getName());
+ }
+
+ @Test
+ public void retrieveDocumentSpecificationWithUrlAttachment() throws Exception {
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setName("Document with URL Attachment");
+ createVO.setVersion("1.0.0");
+
+ AttachmentRefOrValueVO attachment = new AttachmentRefOrValueVO();
+ attachment.setName("test-file.txt");
+ attachment.setMimeType("text/plain");
+ attachment.setUrl(URI.create("https://example.com/test-file.txt"));
+ createVO.setAttachment(List.of(attachment));
+
+ HttpResponse createResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.createDocumentSpecification(null, createVO));
+ assertEquals(HttpStatus.CREATED, createResponse.getStatus());
+ String id = createResponse.body().getId();
+
+ HttpResponse retrieveResponse = callAndCatch(
+ () -> documentSpecificationApiTestClient.retrieveDocumentSpecification(null, id, null));
+
+ assertEquals(HttpStatus.OK, retrieveResponse.getStatus());
+ assertNotNull(retrieveResponse.body().getAttachment());
+ assertFalse(retrieveResponse.body().getAttachment().isEmpty());
+ assertEquals("https://example.com/test-file.txt",
+ retrieveResponse.body().getAttachment().get(0).getUrl().toString(),
+ "URL attachment should be returned unchanged when no AttachmentService is configured.");
+ }
+
+ @Disabled("400 cannot happen, only 404")
+ @Test
+ @Override
+ public void retrieveDocumentSpecification400() throws Exception {
+ }
+
+ @Disabled("Security is handled externally, thus 401 and 403 cannot happen.")
+ @Test
+ @Override
+ public void retrieveDocumentSpecification401() throws Exception {
+ }
+
+ @Disabled("Security is handled externally, thus 401 and 403 cannot happen.")
+ @Test
+ @Override
+ public void retrieveDocumentSpecification403() throws Exception {
+ }
+
+ @Test
+ @Override
+ public void retrieveDocumentSpecification404() throws Exception {
+ HttpResponse response = callAndCatch(
+ () -> documentSpecificationApiTestClient.retrieveDocumentSpecification(null,
+ "urn:ngsi-ld:document-specification:non-existent", null));
+
+ assertEquals(HttpStatus.NOT_FOUND, response.getStatus(), "No such document specification should exist.");
+
+ Optional optionalErrorDetails = response.getBody(ErrorDetails.class);
+ assertTrue(optionalErrorDetails.isPresent(), "Error details should have been provided.");
+ }
+
+ @Disabled("Prohibited by the framework.")
+ @Test
+ @Override
+ public void retrieveDocumentSpecification405() throws Exception {
+ }
+
+ @Disabled("Conflict not possible on retrieval")
+ @Test
+ @Override
+ public void retrieveDocumentSpecification409() throws Exception {
+ }
+
+ @Override
+ public void retrieveDocumentSpecification500() throws Exception {
+ }
+}
diff --git a/document-management/src/test/java/org/fiware/tmforum/documentmanagement/DocumentSpecificationNoS3IT.java b/document-management/src/test/java/org/fiware/tmforum/documentmanagement/DocumentSpecificationNoS3IT.java
new file mode 100644
index 00000000..cc531525
--- /dev/null
+++ b/document-management/src/test/java/org/fiware/tmforum/documentmanagement/DocumentSpecificationNoS3IT.java
@@ -0,0 +1,91 @@
+package org.fiware.tmforum.documentmanagement;
+
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.HttpStatus;
+import io.micronaut.test.annotation.MockBean;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import io.micronaut.context.annotation.Property;
+import org.fiware.document.api.DocumentSpecificationApiTestClient;
+import org.fiware.document.model.AttachmentRefOrValueVO;
+import org.fiware.document.model.DocumentSpecificationCreateVO;
+import org.fiware.document.model.DocumentSpecificationUpdateVO;
+import org.fiware.document.model.DocumentSpecificationVO;
+import org.fiware.ngsi.api.EntitiesApiClient;
+import org.fiware.tmforum.common.configuration.GeneralProperties;
+import org.fiware.tmforum.common.notification.TMForumEventHandler;
+import org.fiware.tmforum.common.test.AbstractApiIT;
+import org.fiware.tmforum.documentmanagement.domain.DocumentSpecification;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import java.util.Base64;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@MicronautTest(packages = {"org.fiware.tmforum.documentmanagement"})
+@Property(name = "s3.enabled", value = "false")
+public class DocumentSpecificationNoS3IT extends AbstractApiIT {
+
+ private final DocumentSpecificationApiTestClient testClient;
+
+ public DocumentSpecificationNoS3IT(
+ DocumentSpecificationApiTestClient testClient,
+ EntitiesApiClient entitiesApiClient,
+ GeneralProperties generalProperties,
+ com.fasterxml.jackson.databind.ObjectMapper objectMapper) {
+ super(entitiesApiClient, objectMapper, generalProperties);
+ this.testClient = testClient;
+ }
+
+ @Override
+ protected String getEntityType() {
+ return DocumentSpecification.TYPE_DOCUMENT_SPECIFICATION;
+ }
+
+ @MockBean(TMForumEventHandler.class)
+ public TMForumEventHandler eventHandler() {
+ TMForumEventHandler eventHandler = mock(TMForumEventHandler.class);
+ when(eventHandler.handleCreateEvent(any())).thenReturn(Mono.empty());
+ when(eventHandler.handleUpdateEvent(any(), any())).thenReturn(Mono.empty());
+ return eventHandler;
+ }
+
+ @Test
+ public void createWithInlineContent_rejected() throws Exception {
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setName("Document with Inline Content");
+ AttachmentRefOrValueVO attachment = new AttachmentRefOrValueVO();
+ attachment.setContent(Base64.getEncoder().encodeToString("Hello World".getBytes()));
+ createVO.setAttachment(List.of(attachment));
+
+ HttpResponse response = callAndCatch(
+ () -> testClient.createDocumentSpecification(null, createVO));
+
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatus(),
+ "Inline content must be rejected when no AttachmentService is configured.");
+ }
+
+ @Test
+ public void patchWithInlineContent_rejected() throws Exception {
+ DocumentSpecificationCreateVO createVO = new DocumentSpecificationCreateVO();
+ createVO.setName("Document for Patch");
+ HttpResponse created = callAndCatch(
+ () -> testClient.createDocumentSpecification(null, createVO));
+ assertEquals(HttpStatus.CREATED, created.getStatus());
+
+ AttachmentRefOrValueVO attachment = new AttachmentRefOrValueVO();
+ attachment.setContent(Base64.getEncoder().encodeToString("data".getBytes()));
+ DocumentSpecificationUpdateVO updateVO = new DocumentSpecificationUpdateVO();
+ updateVO.setAttachment(List.of(attachment));
+
+ HttpResponse response = callAndCatch(
+ () -> testClient.patchDocumentSpecification(null, created.body().getId(), updateVO));
+
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatus(),
+ "Inline content must be rejected when no AttachmentService is configured.");
+ }
+}
diff --git a/document-management/src/test/java/org/fiware/tmforum/documentmanagement/S3AttachmentServiceTest.java b/document-management/src/test/java/org/fiware/tmforum/documentmanagement/S3AttachmentServiceTest.java
new file mode 100644
index 00000000..012e2fe8
--- /dev/null
+++ b/document-management/src/test/java/org/fiware/tmforum/documentmanagement/S3AttachmentServiceTest.java
@@ -0,0 +1,341 @@
+package org.fiware.tmforum.documentmanagement;
+
+import io.minio.GetPresignedObjectUrlArgs;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import io.minio.RemoveObjectArgs;
+import org.fiware.tmforum.common.domain.AttachmentRefOrValue;
+import org.fiware.tmforum.common.domain.Quantity;
+import org.fiware.tmforum.common.domain.TimePeriod;
+import org.fiware.tmforum.common.exception.TmForumException;
+import org.fiware.tmforum.documentmanagement.s3.S3AttachmentService;
+import org.fiware.tmforum.documentmanagement.s3.S3Configuration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.net.URL;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class S3AttachmentServiceTest {
+
+ private static final String ENDPOINT = "http://localhost:9000";
+ private static final String BUCKET = "test-bucket";
+ private static final String PRESIGNED_URL = "http://localhost:9000/test-bucket/entity-1/uuid-file.txt?presigned=true";
+
+ /**
+ * Subclass that skips the MinIO @PostConstruct initialisation so the service
+ * can be unit-tested without a running S3 endpoint.
+ */
+ private static class TestableS3AttachmentService extends S3AttachmentService {
+ TestableS3AttachmentService(S3Configuration config) {
+ super(config);
+ }
+
+ @Override
+ public void init() {
+ // skip MinIO connection for unit tests
+ }
+ }
+
+ @Mock
+ private MinioClient s3Client;
+
+ private S3Configuration config;
+ private S3AttachmentService service;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ config = new S3Configuration();
+ config.setEndpoint(ENDPOINT);
+ config.setAccessKey("minioadmin");
+ config.setSecretKey("minioadmin");
+ config.setBucket(BUCKET);
+ config.setMaxContentSize(1024 * 1024);
+
+ service = new TestableS3AttachmentService(config);
+
+ Field minioClientField = S3AttachmentService.class.getDeclaredField("s3Client");
+ minioClientField.setAccessible(true);
+ minioClientField.set(service, s3Client);
+ }
+
+ // --- validateAttachmentContent ---
+
+ @Test
+ void validateAttachmentContent_nullContent_doesNotThrow() {
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setContent(null);
+ assertDoesNotThrow(() -> service.validateAttachmentContent(attachment),
+ "null content should be accepted without validation");
+ }
+
+ @Test
+ void validateAttachmentContent_emptyContent_doesNotThrow() {
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setContent("");
+ assertDoesNotThrow(() -> service.validateAttachmentContent(attachment),
+ "empty content should be accepted without validation");
+ }
+
+ @Test
+ void validateAttachmentContent_validBase64WithinLimit_doesNotThrow() {
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setContent(Base64.getEncoder().encodeToString("Hello World".getBytes()));
+ assertDoesNotThrow(() -> service.validateAttachmentContent(attachment),
+ "valid base64 content within size limit should pass validation");
+ }
+
+ @Test
+ void validateAttachmentContent_invalidBase64_throwsTmForumException() {
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setContent("not-valid-base64!!!");
+ assertThrows(TmForumException.class, () -> service.validateAttachmentContent(attachment),
+ "invalid base64 content should throw TmForumException");
+ }
+
+ @Test
+ void validateAttachmentContent_contentExceedsMaxSize_throwsTmForumException() {
+ config.setMaxContentSize(4);
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setContent(Base64.getEncoder().encodeToString(new byte[10]));
+ assertThrows(TmForumException.class, () -> service.validateAttachmentContent(attachment),
+ "content exceeding max size should throw TmForumException");
+ }
+
+ // --- offloadAttachments ---
+
+ @Test
+ void offloadAttachments_nullList_returnsNull() {
+ assertNull(service.offloadAttachments(null, "entity-1").block(),
+ "null attachment list should return null Mono");
+ }
+
+ @Test
+ void offloadAttachments_emptyList_returnsEmptyList() {
+ List result = service.offloadAttachments(List.of(), "entity-1").block();
+ assertNotNull(result, "empty list should return an empty list, not null");
+ assertTrue(result.isEmpty(), "result should be empty");
+ }
+
+ @Test
+ void offloadAttachments_nullContent_attachmentUnchanged() {
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setContent(null);
+
+ List result = service.offloadAttachments(List.of(attachment), "entity-1").block();
+
+ assertNotNull(result, "result should not be null");
+ assertNull(result.get(0).getContent(), "content should remain null");
+ assertNull(result.get(0).getUrl(), "url should remain null when no content to offload");
+ verifyNoInteractions(s3Client);
+ }
+
+ @Test
+ void offloadAttachments_withContent_uploadsToS3ClearsContentAndSetsUrl() throws Exception {
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setName("file.txt");
+ attachment.setMimeType("text/plain");
+ attachment.setContent(Base64.getEncoder().encodeToString("file content".getBytes()));
+
+ List result = service.offloadAttachments(List.of(attachment), "entity-1").block();
+
+ assertNotNull(result, "result should not be null");
+ AttachmentRefOrValue processed = result.get(0);
+ assertNull(processed.getContent(), "inline content should be cleared after offload");
+ assertNotNull(processed.getUrl(), "url should be set to the internal S3 path");
+ assertTrue(processed.getUrl().toString().startsWith(ENDPOINT + "/" + BUCKET + "/"),
+ "url should be prefixed with endpoint/bucket/");
+ verify(s3Client).putObject(any(PutObjectArgs.class));
+ }
+
+ @Test
+ void offloadAttachments_alreadyManagedUrl_skipsUpload() throws Exception {
+ URL managedUrl = new URL(ENDPOINT + "/" + BUCKET + "/entity-1/existing-file.txt");
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setUrl(managedUrl);
+ attachment.setContent(Base64.getEncoder().encodeToString("data".getBytes()));
+
+ List result = service.offloadAttachments(List.of(attachment), "entity-1").block();
+
+ assertNotNull(result, "result should not be null");
+ assertEquals(managedUrl, result.get(0).getUrl(), "managed url should remain unchanged");
+ verifyNoInteractions(s3Client);
+ }
+
+ // --- resolveAttachments ---
+
+ @Test
+ void resolveAttachments_nullList_returnsNull() {
+ assertNull(service.resolveAttachments(null).block(),
+ "null attachment list should return null Mono");
+ }
+
+ @Test
+ void resolveAttachments_nullUrl_attachmentUnchanged() {
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setUrl(null);
+
+ List result = service.resolveAttachments(List.of(attachment)).block();
+
+ assertNotNull(result, "result should not be null");
+ assertNull(result.get(0).getUrl(), "url should remain null when not managed");
+ verifyNoInteractions(s3Client);
+ }
+
+ @Test
+ void resolveAttachments_nonManagedUrl_attachmentUnchanged() throws Exception {
+ URL externalUrl = new URL("https://example.com/file.pdf");
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setUrl(externalUrl);
+
+ List result = service.resolveAttachments(List.of(attachment)).block();
+
+ assertNotNull(result, "result should not be null");
+ assertEquals(externalUrl, result.get(0).getUrl(), "non-managed url should be returned unchanged");
+ verifyNoInteractions(s3Client);
+ }
+
+ @Test
+ void resolveAttachments_managedUrl_replacesWithPresignedUrl() throws Exception {
+ URL managedUrl = new URL(ENDPOINT + "/" + BUCKET + "/entity-1/uuid-file.txt");
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setUrl(managedUrl);
+
+ when(s3Client.getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class)))
+ .thenReturn(PRESIGNED_URL);
+
+ List result = service.resolveAttachments(List.of(attachment)).block();
+
+ assertNotNull(result, "result should not be null");
+ assertEquals(new URL(PRESIGNED_URL), result.get(0).getUrl(), "managed url should be replaced with presigned url");
+ verify(s3Client).getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class));
+ }
+
+ // --- deleteAttachments ---
+
+ @Test
+ void deleteAttachments_nullList_noS3Calls() {
+ service.deleteAttachments(null).block();
+ verifyNoInteractions(s3Client);
+ }
+
+ @Test
+ void deleteAttachments_nonManagedUrl_noS3Calls() throws Exception {
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setUrl(new URL("https://example.com/file.pdf"));
+
+ service.deleteAttachments(List.of(attachment)).block();
+
+ verifyNoInteractions(s3Client);
+ }
+
+ @Test
+ void deleteAttachments_managedUrl_deletesFromS3() throws Exception {
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setUrl(new URL(ENDPOINT + "/" + BUCKET + "/entity-1/uuid-file.txt"));
+
+ service.deleteAttachments(List.of(attachment)).block();
+
+ verify(s3Client).removeObject(any(RemoveObjectArgs.class));
+ }
+
+ @Test
+ void deleteAttachments_s3DeleteFails_doesNotThrow() throws Exception {
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setUrl(new URL(ENDPOINT + "/" + BUCKET + "/entity-1/uuid-file.txt"));
+
+ doThrow(new RuntimeException("S3 unavailable")).when(s3Client).removeObject(any(RemoveObjectArgs.class));
+
+ assertDoesNotThrow(() -> service.deleteAttachments(List.of(attachment)).block(),
+ "S3 delete failure should be swallowed and not propagate to the caller");
+ }
+
+ // --- deleteOrphanedAttachments ---
+
+ @Test
+ void deleteOrphanedAttachments_nullExisting_noS3Calls() {
+ service.deleteOrphanedAttachments(null, List.of()).block();
+ verifyNoInteractions(s3Client);
+ }
+
+ @Test
+ void deleteOrphanedAttachments_emptyExisting_noS3Calls() {
+ service.deleteOrphanedAttachments(List.of(), List.of()).block();
+ verifyNoInteractions(s3Client);
+ }
+
+ @Test
+ void deleteOrphanedAttachments_managedUrlNotInUpdated_deletesFromS3() throws Exception {
+ AttachmentRefOrValue existing = new AttachmentRefOrValue();
+ existing.setUrl(new URL(ENDPOINT + "/" + BUCKET + "/entity-1/old-file.txt"));
+
+ service.deleteOrphanedAttachments(List.of(existing), List.of()).block();
+
+ verify(s3Client).removeObject(any(RemoveObjectArgs.class));
+ }
+
+ @Test
+ void deleteOrphanedAttachments_managedUrlStillInUpdated_doesNotDelete() throws Exception {
+ URL managedUrl = new URL(ENDPOINT + "/" + BUCKET + "/entity-1/kept-file.txt");
+ AttachmentRefOrValue existing = new AttachmentRefOrValue();
+ existing.setUrl(managedUrl);
+ AttachmentRefOrValue updated = new AttachmentRefOrValue();
+ updated.setUrl(managedUrl);
+
+ service.deleteOrphanedAttachments(List.of(existing), List.of(updated)).block();
+
+ verifyNoInteractions(s3Client);
+ }
+
+ @Test
+ void deleteOrphanedAttachments_nonManagedUrl_doesNotDelete() throws Exception {
+ AttachmentRefOrValue existing = new AttachmentRefOrValue();
+ existing.setUrl(new URL("https://example.com/file.pdf"));
+
+ service.deleteOrphanedAttachments(List.of(existing), List.of()).block();
+
+ verifyNoInteractions(s3Client);
+ }
+
+ // --- deep copy ---
+
+ @Test
+ void offloadAttachments_deepCopiesValidForAndSize() throws Exception {
+ TimePeriod validFor = new TimePeriod();
+ validFor.setStartDateTime(Instant.parse("2024-01-01T00:00:00Z"));
+ validFor.setEndDateTime(Instant.parse("2025-01-01T00:00:00Z"));
+ Quantity size = new Quantity();
+ size.setAmount(1.5f);
+ size.setUnits("MB");
+
+ AttachmentRefOrValue attachment = new AttachmentRefOrValue();
+ attachment.setContent(Base64.getEncoder().encodeToString("data".getBytes()));
+ attachment.setName("file.txt");
+ attachment.setValidFor(validFor);
+ attachment.setSize(size);
+
+ List result = service.offloadAttachments(List.of(attachment), "entity-1").block();
+
+ assertNotNull(result, "result should not be null");
+ AttachmentRefOrValue copy = result.get(0);
+
+ assertNotSame(validFor, copy.getValidFor(), "validFor should be a new instance, not the same reference");
+ assertEquals(validFor.getStartDateTime(), copy.getValidFor().getStartDateTime(), "validFor startDateTime should be preserved");
+ assertEquals(validFor.getEndDateTime(), copy.getValidFor().getEndDateTime(), "validFor endDateTime should be preserved");
+
+ assertNotSame(size, copy.getSize(), "size should be a new instance, not the same reference");
+ assertEquals(size.getAmount(), copy.getSize().getAmount(), "size amount should be preserved");
+ assertEquals(size.getUnits(), copy.getSize().getUnits(), "size units should be preserved");
+ }
+}
diff --git a/k3s/minio/minio.yaml b/k3s/minio/minio.yaml
new file mode 100644
index 00000000..9e89ddbd
--- /dev/null
+++ b/k3s/minio/minio.yaml
@@ -0,0 +1,90 @@
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: minio
+ labels:
+ app.kubernetes.io/name: minio
+ app.kubernetes.io/instance: minio
+spec:
+ type: LoadBalancer
+ ports:
+ - port: 9000
+ targetPort: 9000
+ protocol: TCP
+ name: api
+ - port: 9001
+ targetPort: 9001
+ protocol: TCP
+ name: console
+ selector:
+ app.kubernetes.io/name: minio
+ app.kubernetes.io/instance: minio
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: minio
+ labels:
+ app.kubernetes.io/name: minio
+ app.kubernetes.io/instance: minio
+spec:
+ replicas: 1
+ revisionHistoryLimit: 3
+ strategy:
+ rollingUpdate:
+ maxSurge: 1
+ maxUnavailable: 0
+ type: RollingUpdate
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: minio
+ app.kubernetes.io/instance: minio
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: minio
+ app.kubernetes.io/instance: minio
+ spec:
+ serviceAccountName: default
+ containers:
+ - name: minio
+ image: "minio/minio:latest"
+ imagePullPolicy: IfNotPresent
+ args:
+ - server
+ - /data
+ - --console-address
+ - ":9001"
+ ports:
+ - name: api
+ containerPort: 9000
+ protocol: TCP
+ - name: console
+ containerPort: 9001
+ protocol: TCP
+ env:
+ - name: MINIO_ROOT_USER
+ value: "minioadmin"
+ - name: MINIO_ROOT_PASSWORD
+ value: "minioadmin"
+ livenessProbe:
+ httpGet:
+ path: /minio/health/live
+ port: 9000
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ failureThreshold: 3
+ readinessProbe:
+ httpGet:
+ path: /minio/health/ready
+ port: 9000
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ failureThreshold: 3
+ volumeMounts:
+ - name: data
+ mountPath: /data
+ volumes:
+ - name: data
+ emptyDir: {}
diff --git a/pom.xml b/pom.xml
index 35ff0c49..ad304f0b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -56,6 +56,7 @@
party-role
data-migrator
all-in-one
+ document-management
@@ -128,12 +129,13 @@
3.2.0
3.0.1u2
+ 8.5.7
myToken
${project.parent.build.directory}/jacoco/${artifactId}
${jacoco.reportFolder}/test.exec
${project.parent.basedir}/
${project.build.directory}/site/spotbugs
-
+
orion-ld
in-memory
@@ -222,6 +224,11 @@
json-schema-validator
1.5.4