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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import com.sap.cds.CdsData;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.HeaderMediaMetadataResolver;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
Expand Down Expand Up @@ -69,6 +70,10 @@ void processBeforeForDraft(CdsCreateEventContext context, List<CdsData> data) {
@HandlerOrder(HandlerOrder.BEFORE)
void processBeforeForMetadata(EventContext context, List<CdsData> data) {
CdsEntity target = context.getTarget();
// Normalize file name / MIME type derived from request headers (Content-Disposition, slug,
// Content-Type) into the data first, so acceptable-media-type validation runs over the exact
// values that storage will persist and the read model will serve.
HeaderMediaMetadataResolver.applyHeaderFallback(target, data, context);
AttachmentValidationHelper.validateMediaAttachments(target, data, cdsRuntime);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.handler.applicationservice.helper;

import com.sap.cds.CdsData;
import com.sap.cds.CdsDataProcessor;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData;
import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.services.EventContext;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Resolves attachment metadata (file name, MIME type) from HTTP headers and applies it as a
* fallback to the request data.
*
* <p>For direct media uploads the file name is typically provided via the {@code
* Content-Disposition} or {@code slug} header and the MIME type via the {@code Content-Type} header
* rather than in the request payload. This resolver normalizes those header values into the
* attachment data <em>before</em> acceptable-media-type validation runs, so that validation and
* storage operate on the same file name / MIME type that is ultimately persisted and served.
*/
public final class HeaderMediaMetadataResolver {

private static final Pattern RFC5987_FILENAME_PATTERN =
Pattern.compile("filename\\*=UTF-8''([^;]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PLAIN_FILENAME_PATTERN =
Pattern.compile("(?<!\\*)filename=\"?([^\";]+)\"?", Pattern.CASE_INSENSITIVE);

/**
* Fills the {@code fileName} and {@code mimeType} of every media content attachment in the given
* data from the request headers, unless those values are already present in the payload.
*
* @param entity the {@link CdsEntity} type of the given data
* @param data the request data to normalize
* @param eventContext the current {@link EventContext} carrying the request headers
*/
public static void applyHeaderFallback(
CdsEntity entity, List<? extends CdsData> data, EventContext eventContext) {
if (entity == null
|| data == null
|| data.isEmpty()
|| eventContext.getParameterInfo() == null) {
return;
}

CdsDataProcessor.create()
.addValidator(
ApplicationHandlerHelper.MEDIA_CONTENT_FILTER,
(path, element, value) -> {
Map<String, Object> values = path.target().values();
if (values.get(MediaData.FILE_NAME) == null) {
extractFileNameFromHeader(eventContext)
.ifPresent(fn -> values.put(MediaData.FILE_NAME, fn));
}
if (values.get(MediaData.MIME_TYPE) == null) {
extractMimeTypeFromHeader(eventContext)
.ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt));
}
})
.process(data, entity);
Comment on lines +54 to +68

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.

Logic Error: applyHeaderFallback calls extractFileNameFromHeader and extractMimeTypeFromHeader once per matching content element, but these methods always read from the same headers — the same header values will be applied identically to every attachment. For a bulk create with multiple attachments having different values, this is fine by design, but the real problem is that the two extract* calls happen inside the validator lambda, meaning the headers are re-read (and regex matched) on every single attachment row. For requests with many attachments this is wasteful, but more importantly it is logically cleaner and efficient to resolve the header values once outside the lambda and close over them.

Consider extracting the header lookups before the CdsDataProcessor call:

    Optional<String> headerFileName = extractFileNameFromHeader(eventContext);
    Optional<String> headerMimeType = extractMimeTypeFromHeader(eventContext);

    if (headerFileName.isEmpty() && headerMimeType.isEmpty()) {
      return;
    }

    CdsDataProcessor.create()
        .addValidator(
            ApplicationHandlerHelper.MEDIA_CONTENT_FILTER,
            (path, element, value) -> {
              Map<String, Object> values = path.target().values();
              if (values.get(MediaData.FILE_NAME) == null) {
                headerFileName.ifPresent(fn -> values.put(MediaData.FILE_NAME, fn));
              }
              if (values.get(MediaData.MIME_TYPE) == null) {
                headerMimeType.ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt));
              }
            })
        .process(data, entity);

Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

}

/**
* Extracts the file name from the {@code Content-Disposition} header or falls back to the {@code
* slug} header. Supports RFC 5987 encoded file names ({@code filename*=UTF-8''...}) and plain
* file names.
*/
public static Optional<String> extractFileNameFromHeader(EventContext eventContext) {
String header = eventContext.getParameterInfo().getHeader("Content-Disposition");
if (header != null) {
// Try RFC 5987 encoded filename first (filename*=UTF-8''...)
Matcher utf8Matcher = RFC5987_FILENAME_PATTERN.matcher(header);
if (utf8Matcher.find()) {
return Optional.of(URLDecoder.decode(utf8Matcher.group(1), StandardCharsets.UTF_8));

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.

Bug: The RFC 5987 pattern filename\\*=UTF-8''([^;]+) does not strip trailing whitespace from the captured group before passing it to URLDecoder.decode. If the header contains trailing spaces or a trailing \r (as is common in raw HTTP headers), the decoded file name will include them.

Should call .trim() on the captured group before decoding, consistent with how the plain-filename branch handles trimming:

        return Optional.of(URLDecoder.decode(utf8Matcher.group(1).trim(), StandardCharsets.UTF_8));
Suggested change
return Optional.of(URLDecoder.decode(utf8Matcher.group(1), StandardCharsets.UTF_8));
return Optional.of(URLDecoder.decode(utf8Matcher.group(1).trim(), StandardCharsets.UTF_8));

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

}
// Fall back to plain filename=
Matcher plainMatcher = PLAIN_FILENAME_PATTERN.matcher(header);
if (plainMatcher.find()) {
return Optional.of(plainMatcher.group(1).trim());
}
}
// Fiori Elements may use the slug header instead
String slug = eventContext.getParameterInfo().getHeader("slug");
if (slug != null) {
return Optional.of(URLDecoder.decode(slug, StandardCharsets.UTF_8));
}
return Optional.empty();
}

/**
* Extracts the MIME type from the {@code Content-Type} header, stripping charset and other
* parameters. Returns empty if the {@code Content-Type} is null or empty.
*/
public static Optional<String> extractMimeTypeFromHeader(EventContext eventContext) {
String contentType = eventContext.getParameterInfo().getHeader("Content-Type");
if (contentType == null) {
return Optional.empty();
}
String mimeType = contentType.split(";")[0].trim();
if (mimeType.isEmpty()) {
return Optional.empty();
}
return Optional.of(mimeType);
}

private HeaderMediaMetadataResolver() {
// to prevent instantiation
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

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.handler.applicationservice.helper.HeaderMediaMetadataResolver;
import com.sap.cds.feature.attachments.handler.applicationservice.transaction.ListenerProvider;
import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
import com.sap.cds.feature.attachments.service.AttachmentService;
Expand All @@ -16,12 +17,8 @@
import com.sap.cds.services.EventContext;
import com.sap.cds.services.changeset.ChangeSetListener;
import java.io.InputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -33,10 +30,6 @@
public class CreateAttachmentEvent implements ModifyAttachmentEvent {

private static final Logger logger = LoggerFactory.getLogger(CreateAttachmentEvent.class);
private static final Pattern RFC5987_FILENAME_PATTERN =
Pattern.compile("filename\\*=UTF-8''([^;]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PLAIN_FILENAME_PATTERN =
Pattern.compile("(?<!\\*)filename=\"?([^\";]+)\"?", Pattern.CASE_INSENSITIVE);

private final AttachmentService attachmentService;
private final ListenerProvider listenerProvider;
Expand All @@ -61,11 +54,11 @@ public InputStream processEvent(
// Fall back to HTTP headers when values are not set in payload
if (eventContext.getParameterInfo() != null) {
if (fileNameOptional.isEmpty()) {
fileNameOptional = extractFileNameFromHeader(eventContext);
fileNameOptional = HeaderMediaMetadataResolver.extractFileNameFromHeader(eventContext);
fileNameOptional.ifPresent(fn -> values.put(MediaData.FILE_NAME, fn));
}
if (mimeTypeOptional.isEmpty()) {
mimeTypeOptional = extractMimeTypeFromHeader(eventContext);
mimeTypeOptional = HeaderMediaMetadataResolver.extractMimeTypeFromHeader(eventContext);
mimeTypeOptional.ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt));
}
}
Expand Down Expand Up @@ -96,46 +89,4 @@ private static Optional<String> getFieldValue(
Object value = nonNull(annotationValue) ? annotationValue : attachment.get(fieldName);
return Optional.ofNullable((String) value);
}

/**
* Extracts the filename from the Content-Disposition header or falls back to the slug header.
* Supports RFC 5987 encoded filenames (filename*=UTF-8''...) and plain filenames.
*/
private static Optional<String> extractFileNameFromHeader(EventContext eventContext) {
String header = eventContext.getParameterInfo().getHeader("Content-Disposition");
if (header != null) {
// Try RFC 5987 encoded filename first (filename*=UTF-8''...)
Matcher utf8Matcher = RFC5987_FILENAME_PATTERN.matcher(header);
if (utf8Matcher.find()) {
return Optional.of(URLDecoder.decode(utf8Matcher.group(1), StandardCharsets.UTF_8));
}
// Fall back to plain filename=
Matcher plainMatcher = PLAIN_FILENAME_PATTERN.matcher(header);
if (plainMatcher.find()) {
return Optional.of(plainMatcher.group(1).trim());
}
}
// Fiori Elements may use the slug header instead
String slug = eventContext.getParameterInfo().getHeader("slug");
if (slug != null) {
return Optional.of(URLDecoder.decode(slug, StandardCharsets.UTF_8));
}
return Optional.empty();
}

/**
* Extracts the MIME type from the Content-Type header, stripping charset and other parameters.
* Returns empty if the Content-Type is null or empty.
*/
private static Optional<String> extractMimeTypeFromHeader(EventContext eventContext) {
String contentType = eventContext.getParameterInfo().getHeader("Content-Type");
if (contentType == null) {
return Optional.empty();
}
String mimeType = contentType.split(";")[0].trim();
if (mimeType.isEmpty()) {
return Optional.empty();
}
return Optional.of(mimeType);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.HeaderMediaMetadataResolver;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation.AttachmentValidationHelper;
Expand Down Expand Up @@ -371,6 +372,28 @@ void processBeforeForMetadata_executesValidation() {
}
}

@Test
void processBeforeForMetadata_appliesHeaderFallbackBeforeValidation() {
EventContext context = mock(EventContext.class);
CdsEntity entity = mock(CdsEntity.class);
List<CdsData> data = List.of(mock(CdsData.class));
when(context.getTarget()).thenReturn(entity);

try (MockedStatic<HeaderMediaMetadataResolver> resolver =
mockStatic(HeaderMediaMetadataResolver.class);
MockedStatic<AttachmentValidationHelper> helper =
mockStatic(AttachmentValidationHelper.class)) {
// when
new CreateAttachmentsHandler(eventFactory, storageReader, "400MB", runtime)
.processBeforeForMetadata(context, data);

// then header-derived metadata is normalized into the data and validation runs over it
resolver.verify(() -> HeaderMediaMetadataResolver.applyHeaderFallback(entity, data, context));
helper.verify(
() -> AttachmentValidationHelper.validateMediaAttachments(entity, data, runtime));
}
Comment on lines +382 to +394

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.

Best Practice: The existing processBeforeForMetadata_executesValidation test still mocks HeaderMediaMetadataResolver only implicitly (it does not mock it at all), so when both tests run the MockedStatic<HeaderMediaMetadataResolver> from the new test is unrelated to the old one. However, the old test now implicitly calls the real applyHeaderFallback with a mocked CdsEntity and mocked CdsData — this could silently succeed or fail in unexpected ways as the real implementation evolves. The two tests are also partially redundant: the ordering assertion in the new test (fallback before validation) is only weakly enforced by call-order in MockedStatic.verify, which does not actually assert invocation order.

Consider using InOrder from Mockito to assert that applyHeaderFallback is called before validateMediaAttachments, making the ordering guarantee explicit and reliable:

    try (MockedStatic<HeaderMediaMetadataResolver> resolver =
            mockStatic(HeaderMediaMetadataResolver.class);
        MockedStatic<AttachmentValidationHelper> helper =
            mockStatic(AttachmentValidationHelper.class)) {
      InOrder inOrder = inOrder(resolver, helper);
      // when
      new CreateAttachmentsHandler(eventFactory, storageReader, "400MB", runtime)
          .processBeforeForMetadata(context, data);

      // then header-derived metadata is normalized into the data and validation runs over it
      inOrder.verify(resolver, () -> HeaderMediaMetadataResolver.applyHeaderFallback(entity, data, context));
      inOrder.verify(helper, () -> AttachmentValidationHelper.validateMediaAttachments(entity, data, runtime));
    }

Note: MockedStatic.InOrder is available since Mockito 4.x via MockedStatic#inOrder; if that API is unavailable here, an AtomicInteger counter can enforce the order.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

}

private void getEntityAndMockContext(String cdsName) {
var serviceEntity = runtime.getCdsModel().findEntity(cdsName);
mockTargetInCreateContext(serviceEntity.orElseThrow());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.handler.applicationservice.helper;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.services.EventContext;
import com.sap.cds.services.request.ParameterInfo;
import com.sap.cds.services.runtime.CdsRuntime;
import java.io.InputStream;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class HeaderMediaMetadataResolverTest {

private static final String MEDIA_ENTITY = "unit.test.Attachment";

private static CdsRuntime runtime;

private EventContext eventContext;
private ParameterInfo parameterInfo;

@BeforeAll
static void classSetup() {
runtime = RuntimeHelper.runtime;
}

@BeforeEach
void setup() {
eventContext = mock(EventContext.class);
parameterInfo = mock(ParameterInfo.class);
when(eventContext.getParameterInfo()).thenReturn(parameterInfo);
}

@Test
void extractsFileNameFromContentDispositionHeader() {
when(parameterInfo.getHeader("Content-Disposition"))
.thenReturn("attachment; filename=\"report.pdf\"");

assertThat(HeaderMediaMetadataResolver.extractFileNameFromHeader(eventContext))
.contains("report.pdf");
}

@Test
void extractsFileNameFromSlugHeader() {
when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null);
when(parameterInfo.getHeader("slug")).thenReturn("document.docx");

assertThat(HeaderMediaMetadataResolver.extractFileNameFromHeader(eventContext))
.contains("document.docx");
}

@Test
void extractsMimeTypeFromContentTypeHeaderStrippingParameters() {
when(parameterInfo.getHeader("Content-Type")).thenReturn("text/html; charset=utf-8");

assertThat(HeaderMediaMetadataResolver.extractMimeTypeFromHeader(eventContext))
.contains("text/html");
}

@Test
void returnsEmptyWhenNoHeadersPresent() {
assertThat(HeaderMediaMetadataResolver.extractFileNameFromHeader(eventContext)).isEmpty();
assertThat(HeaderMediaMetadataResolver.extractMimeTypeFromHeader(eventContext)).isEmpty();
}

@Test
void fillsFileNameAndMimeTypeFromHeadersWhenAbsentInData() {
CdsEntity entity = runtime.getCdsModel().getEntity(MEDIA_ENTITY);
Attachments data = Attachments.create();
data.setContent(mock(InputStream.class));
when(parameterInfo.getHeader("Content-Disposition"))
.thenReturn("attachment; filename=\"notes.txt\"");
when(parameterInfo.getHeader("Content-Type")).thenReturn("text/html");

HeaderMediaMetadataResolver.applyHeaderFallback(entity, List.of(data), eventContext);

assertThat(data.getFileName()).isEqualTo("notes.txt");
assertThat(data.getMimeType()).isEqualTo("text/html");
}

@Test
void doesNotOverridePayloadValuesWithHeaderValues() {
CdsEntity entity = runtime.getCdsModel().getEntity(MEDIA_ENTITY);
Attachments data = Attachments.create();
data.setContent(mock(InputStream.class));
data.setFileName("payload.png");
data.setMimeType("image/png");
when(parameterInfo.getHeader("Content-Disposition"))
.thenReturn("attachment; filename=\"notes.txt\"");
when(parameterInfo.getHeader("Content-Type")).thenReturn("text/html");

HeaderMediaMetadataResolver.applyHeaderFallback(entity, List.of(data), eventContext);

assertThat(data.getFileName()).isEqualTo("payload.png");
assertThat(data.getMimeType()).isEqualTo("image/png");
}

@Test
void doesNothingWhenDataHasNoContentElement() {
CdsEntity entity = runtime.getCdsModel().getEntity(MEDIA_ENTITY);
Attachments data = Attachments.create();
when(parameterInfo.getHeader("Content-Disposition"))
.thenReturn("attachment; filename=\"notes.txt\"");

HeaderMediaMetadataResolver.applyHeaderFallback(entity, List.of(data), eventContext);

assertThat(data.getFileName()).isNull();
assertThat(data.getMimeType()).isNull();
}
}
Loading