Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu
* [Usage](#usage)
* [MVN Setup](#mvn-setup)
* [Changes in the CDS Models and for the UI](#changes-in-the-cds-models-and-for-the-UI)
* [Single (Inline) Attachments](#single-inline-attachments)
* [Try the Bookshop Sample](#try-the-bookshop-sample)
* [Storage Targets](#storage-targets)
* [Malware Scanner](#malware-scanner)
Expand Down Expand Up @@ -115,6 +116,86 @@ annotate service.Incidents with @(

The UI Facet can also be added directly after other UI Facets in a `cds` file in the `app` folder.

### Single (Inline) Attachments

In addition to the composition-based `Attachments` aspect (which supports multiple files), CDS provides the `Attachment` type for **single-file** attachment fields directly on an entity. This is useful when an entity needs exactly one file, for example a profile icon or a cover image.

```cds
using { sap.attachments.Attachment } from 'com.sap.cds/cds-feature-attachments';

entity Books {
key ID : UUID;
title : String;
profileIcon : Attachment;
coverImage : Attachment;
}
```

CDS flattens inline attachment fields onto the parent entity. For example, `profileIcon : Attachment` generates the following columns on the `Books` table:

- `profileIcon_content` (LargeBinary)
- `profileIcon_mimeType` (String)
- `profileIcon_fileName` (String)
- `profileIcon_contentId` (String)
- `profileIcon_status` (StatusCode)
- `profileIcon_scannedAt` (Timestamp)

All plugin features: malware scanning, status tracking, storage targets, maximum file size, and MIME type validation work the same way for inline attachments as for composition-based attachments.

#### UI Annotations for Inline Attachments

To display inline attachments in a Fiori Elements UI, use a `FieldGroup` referencing the flattened field names:

```cds
annotate AdminService.Books with @(UI: {
Facets: [
// ... other facets ...
{
$Type : 'UI.ReferenceFacet',
Label : 'Profile Icon',
Target: '@UI.FieldGroup#ProfileIcon'
}
],
FieldGroup #ProfileIcon: {Data: [
{Value: profileIcon_content},
{Value: profileIcon_status}
]}
});
```

> [!Note]
> For inline attachments, the `content` field is annotated with `@Core.MediaType: 'application/octet-stream'` (a static value) instead of a path reference to the `mimeType` field. This is because CDS flattening rewrites `content` to `profileIcon_content` but does **not** rewrite path references like `mimeType` to `profileIcon_mimeType`, which would result in a broken reference. Static annotations propagate correctly through CDS flattening.

#### Inline Attachments on Composition Children

Inline attachments also work on entities that are composition children. For example, if `Items` is a composition of `Orders`, you can add an inline attachment field to `Items`:

```cds
entity Orders {
key ID : UUID;
items : Composition of many Items;
}
entity Items {
key ID : UUID;
title : String;
receipt : Attachment;
}
```

The plugin automatically discovers inline attachment fields at any level of the composition tree.

#### Combining Inline and Composition-Based Attachments

An entity can use both inline single attachments and composition-based multiple attachments simultaneously:

```cds
entity Books {
key ID : UUID;
profileIcon : Attachment; // single file
attachments : Composition of many Attachments; // multiple files
}
```

### Try the Bookshop Sample

The easiest way to get started is with the included [bookshop sample](samples/bookshop/):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
new DefaultAttachmentMalwareScanner(persistenceService, attachmentService, scanClient);

EndTransactionMalwareScanProvider malwareScanEndTransactionListener =
(attachmentEntity, contentId) ->
(attachmentEntity, contentId, inlinePrefix) ->
new EndTransactionMalwareScanRunner(
attachmentEntity, contentId, malwareScanner, runtime);
attachmentEntity, contentId, inlinePrefix, malwareScanner, runtime);

// register event handlers for attachment service
configurer.eventHandler(
Expand All @@ -157,7 +157,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
eventFactory, attachmentsReader, outboxedAttachmentService, storage, defaultMaxSize));
configurer.eventHandler(new DeleteAttachmentsHandler(attachmentsReader, deleteEvent));
EndTransactionMalwareScanRunner scanRunner =
new EndTransactionMalwareScanRunner(null, null, malwareScanner, runtime);
new EndTransactionMalwareScanRunner(
null, null, Optional.empty(), malwareScanner, runtime);
configurer.eventHandler(
new ReadAttachmentsHandler(
attachmentService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import com.sap.cds.services.handler.annotations.ServiceName;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -52,9 +53,17 @@ void processBefore(CdsDeleteEventContext context) {
context.getModel(), context.getTarget(), context.getCqn());

Converter converter =
(path, element, value) ->
deleteEvent.processEvent(
path, (InputStream) value, Attachments.of(path.target().values()), context);
(path, element, value) -> {
Optional<String> inlinePrefix =
ApplicationHandlerHelper.getInlineAttachmentPrefix(
path.target().entity(), element.getName());
return deleteEvent.processEvent(
path,
(InputStream) value,
Attachments.of(path.target().values()),
context,
inlinePrefix);
};

CdsDataProcessor.create()
.addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.slf4j.Logger;
Expand Down Expand Up @@ -102,8 +103,11 @@ void processBefore(CdsReadEventContext context) {
CdsModel cdsModel = context.getModel();
List<String> fieldNames =
getAttachmentAssociations(cdsModel, context.getTarget(), "", new ArrayList<>());
if (!fieldNames.isEmpty()) {
CqnSelect resultCqn = CQL.copy(context.getCqn(), new BeforeReadItemsModifier(fieldNames));
List<String> inlinePrefixes =
ApplicationHandlerHelper.getInlineAttachmentFieldNames(context.getTarget());
if (!fieldNames.isEmpty() || !inlinePrefixes.isEmpty()) {
CqnSelect resultCqn =
CQL.copy(context.getCqn(), new BeforeReadItemsModifier(fieldNames, inlinePrefixes));
context.setCqn(resultCqn);
}
}
Expand All @@ -117,10 +121,21 @@ void processAfter(CdsReadEventContext context, List<CdsData> data) {

Converter converter =
(path, element, value) -> {
Attachments attachment = Attachments.of(path.target().values());
Attachments attachment;
// Check if this is an inline attachment field
Optional<String> inlinePrefix =
ApplicationHandlerHelper.getInlineAttachmentPrefix(
path.target().type(), element.getName());
if (inlinePrefix.isPresent()) {
attachment =
ApplicationHandlerHelper.extractInlineAttachment(
path.target().values(), inlinePrefix.get());
} else {
attachment = Attachments.of(path.target().values());
}
InputStream content = attachment.getContent();
if (nonNull(attachment.getContentId())) {
verifyStatus(path, attachment);
verifyStatus(path, attachment, inlinePrefix);
Supplier<InputStream> supplier =
nonNull(content)
? () -> content
Expand All @@ -140,7 +155,7 @@ void processAfter(CdsReadEventContext context, List<CdsData> data) {
private List<String> getAttachmentAssociations(
CdsModel model, CdsEntity entity, String associationName, List<String> processedEntities) {
List<String> associationNames = new ArrayList<>();
if (ApplicationHandlerHelper.isMediaEntity(entity)) {
if (ApplicationHandlerHelper.isDirectMediaEntity(entity)) {
associationNames.add(associationName);
}

Expand Down Expand Up @@ -170,7 +185,7 @@ private List<String> getAttachmentAssociations(
return associationNames;
}

private void verifyStatus(Path path, Attachments attachment) {
private void verifyStatus(Path path, Attachments attachment, Optional<String> inlinePrefix) {
if (areKeysEmpty(path.target().keys())) {
String currentStatus = attachment.getStatus();
logger.debug(
Expand All @@ -179,13 +194,13 @@ private void verifyStatus(Path path, Attachments attachment) {
currentStatus);
if (scannerAvailable && needsScan(currentStatus, attachment.getScannedAt())) {
if (StatusCode.CLEAN.equals(currentStatus)) {
transitionToScanning(path.target().entity(), attachment);
transitionToScanning(path.target().entity(), attachment, inlinePrefix);
}
logger.debug(
"Scanning content with ID {} for malware, has current status {}",
attachment.getContentId(),
currentStatus);
scanExecutor.scanAsync(path.target().entity(), attachment.getContentId());
scanExecutor.scanAsync(path.target().entity(), attachment.getContentId(), inlinePrefix);
}
statusValidator.verifyStatus(attachment.getStatus());
}
Expand All @@ -204,26 +219,34 @@ private boolean isScanStale(Instant scannedAt) {
return scannedAt == null || Instant.now().isAfter(scannedAt.plus(RESCAN_THRESHOLD));
}

private void transitionToScanning(CdsEntity entity, Attachments attachment) {
private void transitionToScanning(
CdsEntity entity, Attachments attachment, Optional<String> inlinePrefix) {
logger.debug(
"Attachment {} has stale scan (scannedAt={}), transitioning to SCANNING for rescan.",
attachment.getContentId(),
attachment.getScannedAt());

String contentIdCol = resolveColumn(Attachments.CONTENT_ID, inlinePrefix);
String statusCol = resolveColumn(Attachments.STATUS, inlinePrefix);

Attachments updateData = Attachments.create();
updateData.setStatus(StatusCode.SCANNING);
updateData.put(statusCol, StatusCode.SCANNING);

// Filter by contentId because primary keys are unavailable during content-only reads
// (areKeysEmpty returns true). This is consistent with DefaultAttachmentMalwareScanner.
CqnUpdate update =
Update.entity(entity)
.data(updateData)
.where(entry -> entry.get(Attachments.CONTENT_ID).eq(attachment.getContentId()));
.where(entry -> entry.get(contentIdCol).eq(attachment.getContentId()));
persistenceService.run(update);

attachment.setStatus(StatusCode.SCANNING);
}

private static String resolveColumn(String fieldName, Optional<String> inlinePrefix) {
return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName);
}

private boolean areKeysEmpty(Map<String, Object> keys) {
return keys.values().stream().allMatch(Objects::isNull);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,25 @@ void processBefore(CdsUpdateEventContext context, List<CdsData> data) {
}

private boolean associationsAreUnchanged(CdsEntity entity, List<CdsData> data) {
return entity
.compositions()
.noneMatch(
association -> data.stream().anyMatch(d -> d.containsKey(association.getName())));
// Check composition associations
boolean compositionsUnchanged =
entity
.compositions()
.noneMatch(
association -> data.stream().anyMatch(d -> d.containsKey(association.getName())));

// Also check inline attachment fields
List<String> inlinePrefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity);
boolean inlineUnchanged =
inlinePrefixes.stream()
.noneMatch(
prefix ->
data.stream()
.anyMatch(
d ->
d.keySet().stream().anyMatch(key -> key.startsWith(prefix + "_"))));

return compositionsUnchanged && inlineUnchanged;
}

private void deleteRemovedAttachments(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public final class ModifyApplicationHandlerHelper {

Expand Down Expand Up @@ -51,14 +52,19 @@ public static void handleAttachmentForEntities(
ApplicationHandlerHelper.condenseAttachments(existingAttachments, entity);

Converter converter =
(path, element, value) ->
handleAttachmentForEntity(
condensedExistingAttachments,
eventFactory,
eventContext,
path,
(InputStream) value,
defaultMaxSize);
(path, element, value) -> {
Optional<String> inlinePrefix =
ApplicationHandlerHelper.getInlineAttachmentPrefix(
path.target().entity(), element.getName());
return handleAttachmentForEntity(
condensedExistingAttachments,
eventFactory,
eventContext,
path,
(InputStream) value,
defaultMaxSize,
inlinePrefix);
};

CdsDataProcessor.create()
.addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter)
Expand All @@ -74,6 +80,7 @@ public static void handleAttachmentForEntities(
* @param path the {@link Path} of the attachment
* @param content the content of the attachment
* @param defaultMaxSize the default max size to use when no annotation is present
* @param inlinePrefix the inline attachment field prefix, or empty for composition-based
* @return the processed content as an {@link InputStream}
*/
public static InputStream handleAttachmentForEntity(
Expand All @@ -82,11 +89,20 @@ public static InputStream handleAttachmentForEntity(
EventContext eventContext,
Path path,
InputStream content,
String defaultMaxSize) {
String defaultMaxSize,
Optional<String> inlinePrefix) {
Map<String, Object> keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys());
ReadonlyDataContextEnhancer.restoreReadonlyFields((CdsData) path.target().values());
Attachments attachment = getExistingAttachment(keys, existingAttachments);
String contentId = (String) path.target().values().get(Attachments.CONTENT_ID);

// For inline attachment fields, extract contentId using the known prefix
String contentId;
if (inlinePrefix.isPresent()) {
contentId =
(String) path.target().values().get(inlinePrefix.get() + "_" + Attachments.CONTENT_ID);
} else {
contentId = (String) path.target().values().get(Attachments.CONTENT_ID);
}
Comment thread
Schmarvinius marked this conversation as resolved.
String contentLength = eventContext.getParameterInfo().getHeader("Content-Length");
String maxSizeStr = getValMaxValue(path.target().entity(), defaultMaxSize);
eventContext.put(
Expand All @@ -112,7 +128,8 @@ public static InputStream handleAttachmentForEntity(
ModifyAttachmentEvent eventToProcess =
eventFactory.getEvent(wrappedContent, contentId, attachment);
try {
return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext);
return eventToProcess.processEvent(
path, wrappedContent, attachment, eventContext, inlinePrefix);
} catch (Exception e) {
if (wrappedContent != null && wrappedContent.isLimitExceeded()) {
throw tooLargeException;
Expand All @@ -122,8 +139,20 @@ public static InputStream handleAttachmentForEntity(
}

private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) {
// Try direct content element first (composition-based)
return entity
.findElement("content")
.or(
() -> {
// Try inline attachment content elements (e.g. profilePicture_content)
List<String> prefixes =
ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity);
for (String prefix : prefixes) {
var found = entity.findElement(prefix + "_content");
if (found.isPresent()) return found;
}
return Optional.empty();
})
.flatMap(e -> e.findAnnotation("Validation.Maximum"))
.map(CdsAnnotation::getValue)
.filter(v -> !"true".equals(v.toString()))
Expand Down
Loading
Loading