Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
2b60fe0
Support inline single attachments via Attachment type
busehalis-sap Mar 27, 2026
13ce9b4
fix: resolve correct inline prefix for multi-field inline attachments
busehalis-sap Mar 30, 2026
6dc91e1
Use LinkedHashSet in getInlineAttachmentFieldNames to avoid O(n²) lookup
busehalis-sap Mar 30, 2026
d311213
Appy spotless formatting
busehalis-sap Mar 30, 2026
e317ab5
refactor: remove redundant inline-attachment entries from association…
busehalis-sap Mar 30, 2026
8ef3dcd
Add UI annotations to Attachment type and show scan status in booksho…
busehalis-sap Apr 10, 2026
13eb10b
Add tests to meet JaCoCo branch and complexity coverage thresholds
busehalis-sap Apr 10, 2026
3c99d8c
Apply spotless formatting
busehalis-sap Apr 10, 2026
08fd4d2
Add extra tests to increase JaCoCo coverage margin for CI
busehalis-sap Apr 10, 2026
561e889
Clear mimeType/fileName for inline attachments in MarkAsDeletedAttach…
busehalis-sap Apr 13, 2026
4cb1e07
Support nested single attachments in AssociationCascader
busehalis-sap Apr 13, 2026
f041e27
Add Single (Inline) Attachments section to README
busehalis-sap Apr 13, 2026
d58680d
Move statusNav to MediaData aspect and update bookshop version
busehalis-sap Apr 14, 2026
067e115
fundamentals
Schmarvinius Apr 15, 2026
ab2a73a
spotless
Schmarvinius Apr 16, 2026
6d4c117
Merge remote-tracking branch 'origin/main' into feature/support-singl…
Schmarvinius Apr 16, 2026
304f772
Fix compilation after merge: add third argument to getChangeSetListen…
Schmarvinius Apr 16, 2026
cc93c13
add tests
Schmarvinius Apr 16, 2026
779c253
reduce diff
Schmarvinius Apr 16, 2026
6669168
fix with cds-services fix/stream-utils-cqn-element-ref branch
Schmarvinius Apr 19, 2026
1d7c89c
add tests
Schmarvinius Apr 20, 2026
bf81e44
add deep deletion
Schmarvinius Apr 20, 2026
eb409cf
Remove integration tests (moved to feature/support-single-attachments…
Schmarvinius Apr 21, 2026
6453155
Merge branch 'main' into feat/separate-bucket-multitenancy-v2/sm-hand…
Schmarvinius Apr 24, 2026
976d564
update versions and fix merging issue
Schmarvinius Apr 24, 2026
8b96506
fix etag issue
Schmarvinius Apr 24, 2026
9721ac4
simplify
Schmarvinius Apr 24, 2026
f06d353
Merge branch 'main' into feature/support-single-attachments
Schmarvinius Apr 25, 2026
818f0a8
Add integration tests for inline (single) attachments (#805)
Schmarvinius Apr 25, 2026
51291fc
fix build
Schmarvinius Apr 25, 2026
5e8ee0e
remove unit tests
Schmarvinius Apr 25, 2026
f8e32c0
remove pitest
Schmarvinius Apr 27, 2026
0c1ad1e
add more tests
Schmarvinius Apr 27, 2026
73a5f75
improve inline for val max and also draft cancel
Schmarvinius Apr 27, 2026
3113513
make merge ready
Schmarvinius Apr 27, 2026
5a1dfe8
add example annotation to bookshop
Schmarvinius Apr 27, 2026
3c8642c
improvement
Schmarvinius Apr 27, 2026
7d321a1
cosmetics
Schmarvinius Apr 27, 2026
5534cdd
revert import
Schmarvinius Apr 27, 2026
1d67472
simplify function declaration
Schmarvinius Apr 30, 2026
76c1905
fix: include entity keys in persistInlineAttachmentMetadata WHERE clause
Schmarvinius Apr 30, 2026
162def3
Add integration tests for inline attachment isolation and draft size …
Schmarvinius Apr 30, 2026
80c779a
refactor: extract shared resolveFieldName utility to ApplicationHandl…
Schmarvinius Apr 30, 2026
c7ef2d4
fix: consolidate double fileName/mimeType header extraction into sing…
Schmarvinius Apr 30, 2026
89310b5
refactor: clarify operator precedence in MarkAsDeletedAttachmentEvent
Schmarvinius Apr 30, 2026
85787a4
fix: use entity keys in malware scanner update to prevent cross-entit…
Schmarvinius Apr 30, 2026
1b4f157
refactor: simplify DraftCancel validator and prevent orphan attachmen…
Schmarvinius Apr 30, 2026
03879a2
Merge branch 'main' into feature/support-single-attachments
Schmarvinius May 4, 2026
406a168
updates
Schmarvinius May 4, 2026
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
9 changes: 0 additions & 9 deletions .github/actions/build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ inputs:
maven-version:
description: The Maven version the build will run with.
required: true
mutation-testing:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not have anything todo with single attachments, no?

description: Whether to run mutation testing or not.
default: 'true'
required: false

runs:
using: composite
Expand All @@ -33,8 +29,3 @@ runs:
with:
step-name: mavenBuild
docker-image: ''

- name: Mutation Testing
if: ${{ inputs.mutation-testing == 'true' }}
run: mvn org.pitest:pitest-maven:mutationCoverage -f cds-feature-attachments/pom.xml -ntp -B
shell: bash
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ Defined in `cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-featu
All enforced in CI:

- **JaCoCo:** 95% minimum (instruction, branch, complexity), 0 missed classes
- **Mutation testing (Pitest):** 90% aggregated threshold on `handler.*` and `service.*`
- **SpotBugs:** max effort, includes tests
- **PMD:** SAP Cloud SDK rules, excludes generated code and tests
- **Spotless:** Google Java Format check
Expand Down
115 changes: 76 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[![Java Build with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml)
[![Deploy new Version with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml)
[![Java Build with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml)
[![Deploy new Version with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml)
[![REUSE status](https://api.reuse.software/badge/github.com/cap-java/cds-feature-attachments)](https://api.reuse.software/info/github.com/cap-java/cds-feature-attachments)

# Attachments Plugin for SAP Cloud Application Programming Model (CAP)
Expand All @@ -14,30 +14,31 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu

<!-- TOC -->

* [Quick Start](#quick-start)
* [Usage](#usage)
* [MVN Setup](#mvn-setup)
* [Changes in the CDS Models and for the UI](#changes-in-the-cds-models-and-for-the-UI)
* [Try the Bookshop Sample](#try-the-bookshop-sample)
* [Storage Targets](#storage-targets)
* [Malware Scanner](#malware-scanner)
* [Specify the maximum file size](#specify-the-maximum-file-size)
* [Restrict allowed MIME types](#restrict-allowed-mime-types)
* [Outbox](#outbox)
* [Restore Endpoint](#restore-endpoint)
* [Motivation](#motivation)
* [HTTP Endpoint](#http-endpoint)
* [Security](#security)
* [Releases: Maven Central and Artifactory](#releases-maven-central-and-artifactory)
* [Minimum UI5 and CAP Java Version](#minimum-ui5-and-cap-java-version)
* [Architecture Overview](#architecture-overview)
* [Design](#design)
* [Multitenancy](#multitenancy)
* [Object Stores](#object-stores)
* [Model Texts](#model-texts)
* [Monitoring \& Logging](#monitoring--logging)
* [Support, Feedback, Contributing](#support-feedback-contributing)
* [References \& Links](#references--links)
- [Quick Start](#quick-start)
- [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)
- [Specify the maximum file size](#specify-the-maximum-file-size)
- [Restrict allowed MIME types](#restrict-allowed-mime-types)
- [Outbox](#outbox)
- [Restore Endpoint](#restore-endpoint)
- [Motivation](#motivation)
- [HTTP Endpoint](#http-endpoint)
- [Security](#security)
- [Releases: Maven Central and Artifactory](#releases-maven-central-and-artifactory)
- [Minimum UI5 and CAP Java Version](#minimum-ui5-and-cap-java-version)
- [Architecture Overview](#architecture-overview)
- [Design](#design)
- [Multitenancy](#multitenancy)
- [Object Stores](#object-stores)
- [Model Texts](#model-texts)
- [Monitoring \& Logging](#monitoring--logging)
- [Support, Feedback, Contributing](#support-feedback-contributing)
- [References \& Links](#references--links)

## Quick Start

Expand Down Expand Up @@ -95,7 +96,7 @@ To use this file with the [incidents app](https://github.com/cap-java/incidents-

```cds
using { sap.capire.incidents as my } from '../db/schema';
using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments';
using { Attachments } from 'com.sap.cds/cds-feature-attachments';
extend my.Incidents with {
attachments: Composition of many Attachments;
}
Expand All @@ -115,19 +116,56 @@ annotate service.Incidents with @(

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

### Try the Bookshop Sample
### Single (Inline) Attachments

The easiest way to get started is with the included [bookshop sample](samples/bookshop/):
> [!Important]
> Inline attachments require **cds-services 4.9.0** or higher and are available from **cds-feature-attachments 1.6.0**.

```bash
cd samples/bookshop
mvn compile
mvn spring-boot:run
In addition to the composition-based `Attachments` aspect (which supports multiple files), `cds-feature-attachments` 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 { Attachment } from 'com.sap.cds/cds-feature-attachments';

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

Then browse to http://localhost:8080/browse/index.html to see attachments in action.
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)
- `profileIcon_note` (String)

All plugin features: malware scanning, 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

For detailed setup instructions and implementation details, see the [bookshop sample README](samples/bookshop/README.md).
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}
]}
});
```

### Storage Targets

Expand All @@ -144,8 +182,7 @@ When using a dedicated storage target, the attachment is not stored in the under

### Malware Scanner

This plugin checks for a binding to
the [SAP Malware Scanning Service](https://help.sap.com/docs/malware-scanning-servce), which needs to have the label `malware-scanner`. The entry in the [mta-file](https://cap.cloud.sap/docs/guides/deployment/to-cf#add-mta-yaml) may look like:
This plugin checks for a binding to the [SAP Malware Scanning Service](https://help.sap.com/docs/malware-scanning-servce), which needs to have the label `malware-scanner`. The entry in the [mta-file](https://cap.cloud.sap/docs/guides/deployment/to-cf#add-mta-yaml) may look like:

```
_schema-version: '0.1'
Expand Down Expand Up @@ -212,6 +249,7 @@ annotate Books.attachments with {
```

The @Validation.Maximum value is a size string consisting of a number followed by a unit. The following units are supported:

- B (bytes)
- KB, MB, GB, TB (decimal units)
- KiB, MiB, GiB, TiB (binary units)
Expand Down Expand Up @@ -249,7 +287,6 @@ annotate Books.attachments with {
}
```


### Outbox

In this plugin the [persistent outbox](https://cap.cloud.sap/docs/java/outbox#persistent) is used to mark attachments as
Expand Down Expand Up @@ -343,7 +380,7 @@ In the Spring Boot context the `AttachmentService` can be autowired in the handl
To secure the endpoint, security annotations can be used. For example:

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

entity Items : cuid {
...
Expand Down
34 changes: 0 additions & 34 deletions cds-feature-attachments/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -92,40 +92,6 @@
<!-- The deploy requires a stable artifact name -->
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<configuration>
<targetClasses>
<param>com.sap.cds.feature.attachments.handler.*</param>
<param>com.sap.cds.feature.attachments.service.*</param>
</targetClasses>
<mutators>
<mutator>CONSTRUCTOR_CALLS</mutator>
<mutator>VOID_METHOD_CALLS</mutator>
<mutator>NON_VOID_METHOD_CALLS</mutator>
<mutator>REMOVE_CONDITIONALS_ORDER_ELSE</mutator>
<mutator>CONDITIONALS_BOUNDARY</mutator>
<mutator>EMPTY_RETURNS</mutator>
<mutator>NEGATE_CONDITIONALS</mutator>
<mutator>REMOVE_CONDITIONALS_EQUAL_IF</mutator>
<mutator>REMOVE_CONDITIONALS_EQUAL_ELSE</mutator>
<mutator>REMOVE_CONDITIONALS_ORDER_IF</mutator>
<mutator>REMOVE_CONDITIONALS_ORDER_ELSE</mutator>
</mutators>
<coverageThreshold>95</coverageThreshold>
<aggregatedMutationThreshold>90</aggregatedMutationThreshold>
</configuration>

<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.3</version>
</dependency>
</dependencies>
</plugin>

<plugin>
<artifactId>maven-clean-plugin</artifactId>
<configuration>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,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 Down Expand Up @@ -163,7 +163,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,21 @@ 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());
// For inline attachments, extract the prefixed fields to get proper contentId
Attachments attachment;
if (inlinePrefix.isPresent()) {
attachment =
ApplicationHandlerHelper.extractInlineAttachment(
path.target().values(), inlinePrefix.get());
} else {
attachment = Attachments.of(path.target().values());
}
return deleteEvent.processEvent(path, (InputStream) value, attachment, context);
};

CdsDataProcessor.create()
.addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -99,8 +100,11 @@ void processBefore(CdsReadEventContext context) {

CdsModel cdsModel = context.getModel();
List<String> fieldNames = cascader.findMediaAssociationNames(cdsModel, context.getTarget());
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 @@ -114,7 +118,18 @@ 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);
Expand All @@ -135,21 +150,23 @@ void processAfter(CdsReadEventContext context, List<CdsData> data) {
}

private void verifyStatus(Path path, Attachments attachment) {
if (areKeysEmpty(path.target().keys())) {
Optional<String> inlinePrefix =
Optional.ofNullable((String) attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER));
if (areKeysEmpty(path.target().keys()) || inlinePrefix.isPresent()) {
String currentStatus = attachment.getStatus();
logger.debug(
"In verify status for content id {} and status {}",
attachment.getContentId(),
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 @@ -168,21 +185,26 @@ 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 =
ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix);
String statusCol = ApplicationHandlerHelper.resolveFieldName(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);
Expand Down
Loading