Skip to content
Draft
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
@@ -0,0 +1,66 @@
/*
* Β© 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.configuration;

import com.sap.cds.services.messages.LocalizedMessageProvider;
import java.text.MessageFormat;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* A {@link LocalizedMessageProvider} that resolves error messages from a package-qualified resource
* bundle ({@code com.sap.cds.feature.attachments.i18n.errors}), avoiding classpath conflicts with
* the consuming application's {@code messages.properties}.
*
* <p>Resolution order:
*
* <ol>
* <li>Look up {@code messageOrKey} in the plugin's own resource bundle
* <li>If not found, delegate to the previous provider in the chain
* </ol>
*/
public class AttachmentsLocalizedMessageProvider implements LocalizedMessageProvider {

private static final Logger logger =
LoggerFactory.getLogger(AttachmentsLocalizedMessageProvider.class);

static final String BUNDLE_NAME = "com.sap.cds.feature.attachments.i18n.errors";

private LocalizedMessageProvider previous;

@Override
public void setPrevious(LocalizedMessageProvider previous) {
this.previous = previous;
}

@Override
public String get(String messageOrKey, Object[] args, Locale locale) {
String resolved = resolveFromBundle(messageOrKey, args, locale);
if (resolved != null) {
return resolved;
}
if (previous != null) {
return previous.get(messageOrKey, args, locale);
}
return null;
}

private static String resolveFromBundle(String key, Object[] args, Locale locale) {
try {
Locale effectiveLocale = locale != null ? locale : Locale.getDefault();
ResourceBundle bundle = ResourceBundle.getBundle(BUNDLE_NAME, effectiveLocale);
Copy link
Contributor

Choose a reason for hiding this comment

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

Performance: ResourceBundle.getBundle(BUNDLE_NAME, effectiveLocale) is called on every single invocation of get(). ResourceBundle.getBundle does have an internal cache, but it still incurs synchronization and map-lookup overhead on each call. Because this provider is a singleton registered via Registration.providers(), the bundle can be loaded once and reused.

Consider loading the bundle lazily or eagerly at construction time and storing it in a field, falling back to delegating to previous when the bundle itself is absent.


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

if (bundle.containsKey(key)) {
String pattern = bundle.getString(key);
Comment on lines +56 to +57
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: bundle.containsKey(key) followed immediately by bundle.getString(key) is a TOCTOU (time-of-check/time-of-use) pattern that, while safe within a single ResourceBundle instance, needlessly pays the key-lookup cost twice. More importantly, containsKey on a ResourceBundle does not search parent bundles the same way getString does, which can cause a key present only in a parent bundle to be missed.

Instead, call getString directly inside a try-catch for MissingResourceException.

Suggested change
if (bundle.containsKey(key)) {
String pattern = bundle.getString(key);
try {
String pattern = bundle.getString(key);
return new MessageFormat(pattern, effectiveLocale)
.format(args != null ? args : new Object[0]);
} catch (MissingResourceException ignored) {
// key not in bundle, fall through to return null
}

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

return new MessageFormat(pattern, effectiveLocale)
.format(args != null ? args : new Object[0]);
}
} catch (MissingResourceException e) {
logger.debug("Resource bundle '{}' not found for key '{}'", BUNDLE_NAME, key, e);
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Β© 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.configuration;

/**
* Constants for message keys used in localized error messages. These keys correspond to entries in
* the package-qualified resource bundle {@code com.sap.cds.feature.attachments.i18n.errors}.
*/
public final class MessageKeys {

// Core module - file size validation
/** File size exceeds the configured @Validation.Maximum limit. Arg: {0} = max size string. */
public static final String FILE_SIZE_EXCEEDED =
"com.sap.cds.feature.attachments.FILE_SIZE_EXCEEDED";

/** File size exceeds the limit (fallback without size info). */
public static final String FILE_SIZE_EXCEEDED_NO_SIZE =
"com.sap.cds.feature.attachments.FILE_SIZE_EXCEEDED_NO_SIZE";

/** Invalid Content-Length header. */
public static final String INVALID_CONTENT_LENGTH =
"com.sap.cds.feature.attachments.INVALID_CONTENT_LENGTH";

// OSS module - operational errors
/** Failed to upload file. Arg: {0} = file name. */
public static final String UPLOAD_FAILED = "com.sap.cds.feature.attachments.UPLOAD_FAILED";

/** Failed to delete file. Arg: {0} = document id. */
public static final String DELETE_FAILED = "com.sap.cds.feature.attachments.DELETE_FAILED";

/** Failed to read file. Arg: {0} = document id. */
public static final String READ_FAILED = "com.sap.cds.feature.attachments.READ_FAILED";

/** Document not found. Arg: {0} = document id. */
public static final String DOCUMENT_NOT_FOUND =
"com.sap.cds.feature.attachments.DOCUMENT_NOT_FOUND";

private MessageKeys() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ public class Registration implements CdsRuntimeConfiguration {

private static final Logger logger = LoggerFactory.getLogger(Registration.class);

@Override
public void providers(CdsRuntimeConfigurer configurer) {
configurer.provider(new AttachmentsLocalizedMessageProvider());
}

@Override
public void services(CdsRuntimeConfigurer configurer) {
configurer.service(new AttachmentsServiceImpl());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import static java.util.Objects.requireNonNull;

import com.sap.cds.CdsData;
import com.sap.cds.feature.attachments.configuration.MessageKeys;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer;
Expand Down Expand Up @@ -82,12 +83,10 @@ void restoreError(EventContext context) {
String maxSizeStr = (String) context.get("attachment.MaxSize");
if (maxSizeStr != null) {
throw new ServiceException(
ExtendedErrorStatuses.CONTENT_TOO_LARGE,
"File size exceeds the limit of {}.",
maxSizeStr);
ExtendedErrorStatuses.CONTENT_TOO_LARGE, MessageKeys.FILE_SIZE_EXCEEDED, maxSizeStr);
}
throw new ServiceException(
ExtendedErrorStatuses.CONTENT_TOO_LARGE, "File size exceeds the limit.");
ExtendedErrorStatuses.CONTENT_TOO_LARGE, MessageKeys.FILE_SIZE_EXCEEDED_NO_SIZE);
}
throw e;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.sap.cds.CdsData;
import com.sap.cds.CdsDataProcessor;
import com.sap.cds.CdsDataProcessor.Converter;
import com.sap.cds.feature.attachments.configuration.MessageKeys;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEvent;
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory;
Expand Down Expand Up @@ -94,17 +95,15 @@ public static InputStream handleAttachmentForEntity(
maxSizeStr); // make max size available in context for error handling later
ServiceException tooLargeException =
new ServiceException(
ExtendedErrorStatuses.CONTENT_TOO_LARGE,
"File size exceeds the limit of {}.",
maxSizeStr);
ExtendedErrorStatuses.CONTENT_TOO_LARGE, MessageKeys.FILE_SIZE_EXCEEDED, maxSizeStr);

if (contentLength != null) {
try {
if (Long.parseLong(contentLength) > FileSizeUtils.parseFileSizeToBytes(maxSizeStr)) {
throw tooLargeException;
}
} catch (NumberFormatException e) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Invalid Content-Length header");
throw new ServiceException(ErrorStatuses.BAD_REQUEST, MessageKeys.INVALID_CONTENT_LENGTH);
}
}
CountingInputStream wrappedContent =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
package com.sap.cds.feature.attachments.handler.applicationservice.readhelper;

import com.sap.cds.feature.attachments.configuration.MessageKeys;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.FileSizeUtils;
import com.sap.cds.services.ServiceException;
Expand Down Expand Up @@ -81,9 +82,7 @@ private void checkLimit(long bytes) {
byteCount += bytes;
if (byteCount > maxBytes) {
throw new ServiceException(
ExtendedErrorStatuses.CONTENT_TOO_LARGE,
"File size exceeds the limit of {}.",
maxBytesString);
ExtendedErrorStatuses.CONTENT_TOO_LARGE, MessageKeys.FILE_SIZE_EXCEEDED, maxBytesString);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Error messages for the cds-feature-attachments plugin.
# Placeholders use java.text.MessageFormat syntax: {0}, {1}, etc.
# Consumers can override these messages by registering their own LocalizedMessageProvider
# or by adding the same keys to their application's messages.properties.

# Core module: file size validation
com.sap.cds.feature.attachments.FILE_SIZE_EXCEEDED=File size exceeds the limit of {0}.
com.sap.cds.feature.attachments.FILE_SIZE_EXCEEDED_NO_SIZE=File size exceeds the limit.
com.sap.cds.feature.attachments.INVALID_CONTENT_LENGTH=Invalid Content-Length header.

# OSS module: operational errors
com.sap.cds.feature.attachments.UPLOAD_FAILED=Failed to upload file {0}.
com.sap.cds.feature.attachments.DELETE_FAILED=Failed to delete file with document id {0}.
com.sap.cds.feature.attachments.READ_FAILED=Failed to read file with document id {0}.
com.sap.cds.feature.attachments.DOCUMENT_NOT_FOUND=Document not found for id {0}.
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Β© 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.configuration;

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

import com.sap.cds.services.messages.LocalizedMessageProvider;
import java.util.Locale;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class AttachmentsLocalizedMessageProviderTest {

private AttachmentsLocalizedMessageProvider cut;
private LocalizedMessageProvider previousProvider;

@BeforeEach
void setup() {
cut = new AttachmentsLocalizedMessageProvider();
previousProvider = mock(LocalizedMessageProvider.class);
}

@Test
void knownKeyReturnsFormattedMessage() {
var result = cut.get(MessageKeys.FILE_SIZE_EXCEEDED, new Object[] {"400MB"}, Locale.ENGLISH);

assertThat(result).isEqualTo("File size exceeds the limit of 400MB.");
}

@Test
void knownKeyWithoutArgsReturnsMessage() {
var result = cut.get(MessageKeys.FILE_SIZE_EXCEEDED_NO_SIZE, null, Locale.ENGLISH);

assertThat(result).isEqualTo("File size exceeds the limit.");
}

@Test
void knownKeyWithEmptyArgsReturnsMessage() {
var result = cut.get(MessageKeys.INVALID_CONTENT_LENGTH, new Object[] {}, Locale.ENGLISH);

assertThat(result).isEqualTo("Invalid Content-Length header.");
}

@Test
void unknownKeyDelegatesToPreviousProvider() {
cut.setPrevious(previousProvider);
var args = new Object[] {"arg1"};
when(previousProvider.get("unknown.key", args, Locale.ENGLISH)).thenReturn("previous result");

var result = cut.get("unknown.key", args, Locale.ENGLISH);

assertThat(result).isEqualTo("previous result");
verify(previousProvider).get("unknown.key", args, Locale.ENGLISH);
}

@Test
void unknownKeyWithNoPreviousReturnsNull() {
var result = cut.get("unknown.key", new Object[] {}, Locale.ENGLISH);

assertThat(result).isNull();
}

@Test
void knownKeyDoesNotDelegateToPrevious() {
cut.setPrevious(previousProvider);

var result = cut.get(MessageKeys.FILE_SIZE_EXCEEDED, new Object[] {"10KB"}, Locale.ENGLISH);

assertThat(result).isEqualTo("File size exceeds the limit of 10KB.");
verifyNoInteractions(previousProvider);
}

@Test
void nullLocaleUsesDefault() {
var result = cut.get(MessageKeys.FILE_SIZE_EXCEEDED, new Object[] {"100MB"}, null);

assertThat(result).isNotNull();
assertThat(result).contains("100MB");
}

@Test
void setPreviousStoresProvider() {
cut.setPrevious(previousProvider);
when(previousProvider.get("any.key", null, Locale.ENGLISH)).thenReturn("from previous");

var result = cut.get("any.key", null, Locale.ENGLISH);

assertThat(result).isEqualTo("from previous");
}

@Test
void uploadFailedKeyReturnsFormattedMessage() {
var result = cut.get(MessageKeys.UPLOAD_FAILED, new Object[] {"test.pdf"}, Locale.ENGLISH);

assertThat(result).isEqualTo("Failed to upload file test.pdf.");
}

@Test
void deleteFailedKeyReturnsFormattedMessage() {
var result = cut.get(MessageKeys.DELETE_FAILED, new Object[] {"doc-123"}, Locale.ENGLISH);

assertThat(result).isEqualTo("Failed to delete file with document id doc-123.");
}

@Test
void readFailedKeyReturnsFormattedMessage() {
var result = cut.get(MessageKeys.READ_FAILED, new Object[] {"doc-456"}, Locale.ENGLISH);

assertThat(result).isEqualTo("Failed to read file with document id doc-456.");
}

@Test
void documentNotFoundKeyReturnsFormattedMessage() {
var result = cut.get(MessageKeys.DOCUMENT_NOT_FOUND, new Object[] {"doc-789"}, Locale.ENGLISH);

assertThat(result).isEqualTo("Document not found for id doc-789.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import static org.mockito.Mockito.when;

import com.sap.cds.CdsData;
import com.sap.cds.feature.attachments.configuration.MessageKeys;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events_;
Expand Down Expand Up @@ -340,7 +341,7 @@ void restoreError_contentTooLargeWithMaxSize_throwsWithMaxSize() {
var exception = assertThrows(ServiceException.class, () -> cut.restoreError(context));

assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE);
assertThat(exception.getMessage()).contains("File size exceeds the limit of 10MB.");
assertThat(exception.getMessage()).contains(MessageKeys.FILE_SIZE_EXCEEDED);
Copy link
Contributor

Choose a reason for hiding this comment

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

Logic Error: Test assertions now verify the message key constant string rather than the resolved message text

After the refactor, ServiceException is constructed with MessageKeys.FILE_SIZE_EXCEEDED (i.e., "com.sap.cds.feature.attachments.FILE_SIZE_EXCEEDED") as its raw message, because there is no LocalizedMessageProvider wired in the unit-test context. So exception.getMessage() returns the unresolved key string, and contains(MessageKeys.FILE_SIZE_EXCEEDED) passes β€” but it no longer actually verifies that a meaningful, human-readable error message is produced. The test at line 344 used to confirm the user-facing text "File size exceeds the limit of 10MB." and now provides no such guarantee.

Consider asserting the resolved message text (as before), or β€” if the intent is only to verify which key is used β€” assert the ServiceException's message parameter directly instead of getMessage().

Suggested change
assertThat(exception.getMessage()).contains(MessageKeys.FILE_SIZE_EXCEEDED);
assertThat(exception.getMessage()).contains("File size exceeds the limit of 10MB.");

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

assertThat(exception).isNotSameAs(originalException);
}

Expand All @@ -355,7 +356,7 @@ void restoreError_contentTooLargeWithoutMaxSize_throwsWithoutMaxSize() {
var exception = assertThrows(ServiceException.class, () -> cut.restoreError(context));

assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE);
assertThat(exception.getMessage()).contains("File size exceeds the limit.");
assertThat(exception.getMessage()).contains(MessageKeys.FILE_SIZE_EXCEEDED_NO_SIZE);
Copy link
Contributor

Choose a reason for hiding this comment

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

Logic Error: Same weakened assertion for the no-size variant β€” the test now only checks that the raw key constant appears in the exception message, not that the resolved text "File size exceeds the limit." is present.

Consider asserting the resolved message text to preserve the original intent of the test.

Suggested change
assertThat(exception.getMessage()).contains(MessageKeys.FILE_SIZE_EXCEEDED_NO_SIZE);
assertThat(exception.getMessage()).contains("File size exceeds the limit.");

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

assertThat(exception).isNotSameAs(originalException);
}

Expand Down
Loading
Loading