diff --git a/.pipeline/config.yml b/.pipeline/config.yml index e31529d3..aef9a8da 100644 --- a/.pipeline/config.yml +++ b/.pipeline/config.yml @@ -36,4 +36,4 @@ steps: - sonar.java.source=17 - sonar.exclusions=**/node_modules/**,**/target/**,**/test/** - sonar.coverage.jacoco.xmlReportPaths=cds-feature-attachments/target/site/jacoco/jacoco.xml - - sonar.coverage.exclusions=cds-feature-attachments/src/test/**,cds-feature-attachments/src/gen/**,integration-tests/** + - sonar.coverage.exclusions=cds-feature-attachments/src/test/**,cds-feature-attachments/src/gen/**,integration-tests/**,examples/** diff --git a/cds-feature-attachments/pom.xml b/cds-feature-attachments/pom.xml index 59d3c7c6..43cdb1a3 100644 --- a/cds-feature-attachments/pom.xml +++ b/cds-feature-attachments/pom.xml @@ -78,13 +78,11 @@ ch.qos.logback logback-classic - 1.5.18 test org.awaitility awaitility - 4.3.0 test diff --git a/examples/cds-feature-attachments-fs/README.md b/examples/cds-feature-attachments-fs/README.md new file mode 100644 index 00000000..02580881 --- /dev/null +++ b/examples/cds-feature-attachments-fs/README.md @@ -0,0 +1,7 @@ +### General Info + +This artifact demonstrates how to implement a custom storage for the cds-feature-attachments. This is just a sample implementation using a filessystem as content storage. It's not supposed for productive usage. + +### Implementation details + +This artifact provides a custom handlers for events from the [AttachmentService](../cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/AttachmentService.java). \ No newline at end of file diff --git a/examples/cds-feature-attachments-fs/pom.xml b/examples/cds-feature-attachments-fs/pom.xml new file mode 100644 index 00000000..73f678c0 --- /dev/null +++ b/examples/cds-feature-attachments-fs/pom.xml @@ -0,0 +1,64 @@ + + 4.0.0 + + + com.sap.cds + cds-feature-attachments-root + ${revision} + ../.. + + + cds-feature-attachments-fs + jar + + CDS Feature for Attachments - Filesystem + https://cap.cloud.sap/docs/plugins/#attachments + + + + com.sap.cds + cds-feature-attachments + + + + + com.sap.cds + cds-services-impl + test + + + ch.qos.logback + logback-classic + test + + + + + + + + com.sap.cds + cds-maven-plugin + + + cds.resolve + + resolve + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + true + + + + + + + diff --git a/examples/cds-feature-attachments-fs/src/main/java/com/sap/cds/feature/attachments/fs/configuration/Registration.java b/examples/cds-feature-attachments-fs/src/main/java/com/sap/cds/feature/attachments/fs/configuration/Registration.java new file mode 100644 index 00000000..446e4071 --- /dev/null +++ b/examples/cds-feature-attachments-fs/src/main/java/com/sap/cds/feature/attachments/fs/configuration/Registration.java @@ -0,0 +1,31 @@ +package com.sap.cds.feature.attachments.fs.configuration; + +import java.io.IOException; +import java.nio.file.Path; + +import org.apache.commons.io.FileUtils; + +import com.sap.cds.feature.attachments.fs.handler.FSAttachmentsServiceHandler; +import com.sap.cds.feature.attachments.fs.handler.FSDraftServiceHandler; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; + +/** + * The class registers the event handlers for the attachments feature based on filesystem. + */ +public class Registration implements CdsRuntimeConfiguration { + + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + Path tmpPath = FileUtils.getTempDirectory().toPath(); + Path rootFolder = tmpPath.resolve("com.sap.cds.cds-feature-attachments-fs"); + + try { + configurer.eventHandler(new FSAttachmentsServiceHandler(rootFolder)); + configurer.eventHandler(new FSDraftServiceHandler()); + } catch (IOException e) { + throw new IllegalStateException("Error while creating the FSAttachmentsServiceHandler", e); + } + } + +} \ No newline at end of file diff --git a/examples/cds-feature-attachments-fs/src/main/java/com/sap/cds/feature/attachments/fs/handler/FSAttachmentsServiceHandler.java b/examples/cds-feature-attachments-fs/src/main/java/com/sap/cds/feature/attachments/fs/handler/FSAttachmentsServiceHandler.java new file mode 100644 index 00000000..797b0bf6 --- /dev/null +++ b/examples/cds-feature-attachments-fs/src/main/java/com/sap/cds/feature/attachments/fs/handler/FSAttachmentsServiceHandler.java @@ -0,0 +1,118 @@ +/************************************************************************** + * (C) 2019-2024 SAP SE or an SAP affiliate company. All rights reserved. * + **************************************************************************/ +package com.sap.cds.feature.attachments.fs.handler; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.commons.io.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; +import com.sap.cds.feature.attachments.service.AttachmentService; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentRestoreEventContext; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; + +/** + * This class is an event handler that is called when an attachment is created, marked as deleted, restored or read. + */ +@ServiceName(value = "*", type = AttachmentService.class) +public class FSAttachmentsServiceHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(FSAttachmentsServiceHandler.class); + + private final Path rootFolder; + + /** + * Creates a new FSAttachmentsServiceHandler with the given root folder. + * + * @param rootFolder the root folder where the attachments are stored + * @throws IOException if the root folder cannot be created + */ + public FSAttachmentsServiceHandler(Path rootFolder) throws IOException { + this.rootFolder = rootFolder; + if (!Files.exists(this.rootFolder)) { + Files.createDirectories(this.rootFolder); + } + } + + @On + void createAttachment(AttachmentCreateEventContext context) throws IOException { + logger.info("FS Attachment Service handler called for creating attachment for entity name: {}", + context.getAttachmentEntity().getQualifiedName()); + + String contentId = (String) context.getAttachmentIds().get(Attachments.ID); + + MediaData data = context.getData(); + data.setStatus(StatusCode.CLEAN); + + try (InputStream input = data.getContent()) { + Path contentPath = getContentPath(context, contentId); + Files.createDirectories(contentPath.getParent()); + Files.copy(input, contentPath); + + context.setIsInternalStored(false); + context.setContentId(contentId); + context.setCompleted(); + } + } + + @On + void markAttachmentAsDeleted(AttachmentMarkAsDeletedEventContext context) throws IOException { + logger.info("Marking attachment as deleted with document id: {}", context.getContentId()); + + Path contenPath = getContentPath(context, context.getContentId()); + Path parent = contenPath.getParent(); + Path destPath = getDeletedFolder(context).resolve(parent.getFileName()); + + FileUtils.moveDirectory(parent.toFile(), destPath.toFile()); + context.setCompleted(); + } + + @On + void restoreAttachment(AttachmentRestoreEventContext context) { + logger.info("FS Attachment Service handler called for restoring attachment for timestamp: {}", + context.getRestoreTimestamp()); + + // nothing to do as data are stored in the database and handled by the database + context.setCompleted(); + } + + @On + void readAttachment(AttachmentReadEventContext context) throws IOException { + logger.info("FS Attachment Service handler called for reading attachment with document id: {}", + context.getContentId()); + InputStream fileInputStream = Files.newInputStream(getContentPath(context, context.getContentId())); + context.getData().setContent(fileInputStream); + context.setCompleted(); + } + + private Path getContentPath(EventContext context, String contentId) { + return this.rootFolder.resolve("%s/%s/content.bin".formatted(getTenant(context), contentId)); + } + + private Path getDeletedFolder(EventContext context) { + return this.rootFolder.resolve("%s/deleted".formatted(getTenant(context))); + } + + private static String getTenant(EventContext context) { + String tenant = context.getUserInfo().getTenant(); + if (tenant == null) { + tenant = "default"; + } + return tenant; + } + +} diff --git a/examples/cds-feature-attachments-fs/src/main/java/com/sap/cds/feature/attachments/fs/handler/FSDraftServiceHandler.java b/examples/cds-feature-attachments-fs/src/main/java/com/sap/cds/feature/attachments/fs/handler/FSDraftServiceHandler.java new file mode 100644 index 00000000..64df231d --- /dev/null +++ b/examples/cds-feature-attachments-fs/src/main/java/com/sap/cds/feature/attachments/fs/handler/FSDraftServiceHandler.java @@ -0,0 +1,88 @@ +/************************************************************************** + * (C) 2019-2024 SAP SE or an SAP affiliate company. All rights reserved. * + **************************************************************************/ +package com.sap.cds.feature.attachments.fs.handler; + +import java.net.URLConnection; +import java.util.List; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.draft.DraftCreateEventContext; +import com.sap.cds.services.draft.DraftPatchEventContext; +import com.sap.cds.services.draft.DraftService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.HandlerOrder; +import com.sap.cds.services.handler.annotations.ServiceName; + +/** + * Event handler for events on the DraftService. + */ +@ServiceName(value = "*", type = DraftService.class) +public class FSDraftServiceHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(FSDraftServiceHandler.class); + + @Before + @HandlerOrder(HandlerOrder.LATE) + void createDraftAttachment(DraftCreateEventContext context, CdsData data) { + CdsEntity target = context.getTarget(); + + // check if target entity contains aspect Attachments + if (ApplicationHandlerHelper.isMediaEntity(target)) { + String fileName = (String) data.get(Attachments.FILE_NAME); + + // guessing the MIME type of the attachment based on the file name + String mimeType = URLConnection.guessContentTypeFromName(fileName); + data.put(Attachments.MIME_TYPE, mimeType); + + // get unique identifier of attachment's parent entity, e.g. the Books entity + Parent parent = getParentId(target, data); + + logger.info("Creating draft attachment '{}' for parent entity '{}' with ids {}", fileName, + parent != null ? parent.entity : "unknown", parent != null ? parent.ids : "unknown"); + + // do something with the data of the draft attachments entity + } + } + + @Before + @HandlerOrder(HandlerOrder.LATE) + void patchDraftAttachment(DraftPatchEventContext context, CdsData data) { + CdsEntity target = context.getTarget(); + + // check if target entity contains aspect Attachments + if (ApplicationHandlerHelper.isMediaEntity(target)) { + // remove wrong mime type from data + // TODO: remove this once the SAPUI5 sets the correct MIME type + data.remove(Attachments.MIME_TYPE); + } + } + + private static Parent getParentId(CdsEntity target, CdsData data) { + // find association to parent entity + Optional upAssociation = target.findAssociation("up_"); + + // if association is found, try to get foreign key to parent entity + if (upAssociation.isPresent()) { + // get association type + CdsAssociationType assocType = upAssociation.get().getType(); + // get the refs of the association and map them to the corresponding data of the entity + List ids = assocType.refs().map(ref -> "up__" + ref.path()).map(data::get).toList(); + return new Parent(assocType.getTarget(), ids); + } + return null; + } + + record Parent(CdsEntity entity, List ids) { + } +} \ No newline at end of file diff --git a/examples/cds-feature-attachments-fs/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/examples/cds-feature-attachments-fs/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 00000000..922cac38 --- /dev/null +++ b/examples/cds-feature-attachments-fs/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.feature.attachments.fs.configuration.Registration \ No newline at end of file diff --git a/examples/cds-feature-attachments-fs/src/test/java/com/sap/cds/feature/attachments/fs/handler/FSAttachmentsServiceHandlerTest.java b/examples/cds-feature-attachments-fs/src/test/java/com/sap/cds/feature/attachments/fs/handler/FSAttachmentsServiceHandlerTest.java new file mode 100644 index 00000000..9a3842e1 --- /dev/null +++ b/examples/cds-feature-attachments-fs/src/test/java/com/sap/cds/feature/attachments/fs/handler/FSAttachmentsServiceHandlerTest.java @@ -0,0 +1,154 @@ +package com.sap.cds.feature.attachments.fs.handler; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; + +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.CleanupMode; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.request.UserInfo; + +class FSAttachmentsServiceHandlerTest { + + private static final String TEST_CONTENT = "Hello World !!"; + + private static FSAttachmentsServiceHandler handler; + + @TempDir(cleanup = CleanupMode.ALWAYS) + private static Path rootFolder; + + private static CdsEntity entity; + + @BeforeAll + static void setUpBeforeClass() throws IOException { + handler = new FSAttachmentsServiceHandler(rootFolder); + entity = mock(CdsEntity.class); + when(entity.getQualifiedName()).thenReturn("test.Attachments"); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = { "t0" }) + void testCreateAttachment(String tenant) throws IOException { + String contentId = UUID.randomUUID().toString(); + AttachmentCreateEventContext createContext = createAttachment(tenant, contentId, TEST_CONTENT); + + assertEquals(contentId, createContext.getContentId()); + Path file = resolveContentPath(tenant, contentId); + assertTrue(Files.exists(file)); + assertTrue(createContext.isCompleted()); + assertFalse(createContext.getIsInternalStored()); + + String content = Files.readString(file); + assertEquals(TEST_CONTENT, content); + + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = { "t0" }) + void testReadAttachment(String tenant) throws IOException { + String contentId = UUID.randomUUID().toString(); + createAttachment(tenant, contentId, TEST_CONTENT); + + AttachmentReadEventContext context = spy(AttachmentReadEventContext.create()); + context.setContentId(contentId); + context.setData(MediaData.create()); + doReturn(getUserInfoMock(tenant)).when(context).getUserInfo(); + + handler.readAttachment(context); + + String content = IOUtils.toString(context.getData().getContent(), UTF_8); + assertEquals(TEST_CONTENT, content); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = { "t0" }) + void testMarkAttachmentAsDeleted(String tenant) throws IOException { + String contentId = UUID.randomUUID().toString(); + createAttachment(tenant, contentId, TEST_CONTENT); + + AttachmentMarkAsDeletedEventContext context = spy(AttachmentMarkAsDeletedEventContext.create()); + doReturn(getUserInfoMock(tenant)).when(context).getUserInfo(); + context.setContentId(contentId); + + Path filePath = resolveContentPath(tenant, contentId); + Path deletedPath = resolveDeletedContentPath(tenant, contentId); + assertTrue(Files.exists(filePath)); + assertFalse(Files.exists(deletedPath)); + + handler.markAttachmentAsDeleted(context); + + assertFalse(Files.exists(filePath)); + assertTrue(Files.exists(deletedPath)); + assertTrue(context.isCompleted()); + } + + @Test + void testRestoreAttachment() { + } + + private static AttachmentCreateEventContext createAttachment(String tenant, String id, String content) + throws IOException { + AttachmentCreateEventContext createContext = spy(AttachmentCreateEventContext.create()); + createContext.setAttachmentEntity(entity); + doReturn(getUserInfoMock(tenant)).when(createContext).getUserInfo(); + assertFalse(createContext.isCompleted()); + assertNull(createContext.getIsInternalStored()); + + Map keys = Map.of(Attachments.ID, id); + createContext.setAttachmentIds(keys); + try (InputStream testStream = new ByteArrayInputStream(content.getBytes(UTF_8))) { + MediaData mediaData = MediaData.create(); + mediaData.setContent(testStream); + createContext.setData(mediaData); + + handler.createAttachment(createContext); + return createContext; + } + } + + private static UserInfo getUserInfoMock(String tenant) { + UserInfo userInfo = mock(UserInfo.class); + when(userInfo.getTenant()).thenReturn(tenant); + return userInfo; + } + + private static Path resolveDeletedContentPath(String tenant, String contentId) { + return rootFolder + .resolve("%s/deleted/%s/content.bin".formatted(tenant == null ? "default" : tenant, contentId)); + } + + private static Path resolveContentPath(String tenant, String contentId) { + return rootFolder.resolve("%s/%s/content.bin".formatted(tenant == null ? "default" : tenant, contentId)); + } + +} diff --git a/pom.xml b/pom.xml index 6060bc0b..4acca655 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,7 @@ cds-feature-attachments + examples/cds-feature-attachments-fs integration-tests @@ -87,6 +88,20 @@ import + + org.awaitility + awaitility + 4.3.0 + test + + + + ch.qos.logback + logback-classic + 1.5.18 + test + + com.sap.cds cds-feature-attachments