diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProvider.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProvider.java new file mode 100644 index 000000000..ceddb3829 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProvider.java @@ -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}. + * + *

Resolution order: + * + *

    + *
  1. Look up {@code messageOrKey} in the plugin's own resource bundle + *
  2. If not found, delegate to the previous provider in the chain + *
+ */ +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); + if (bundle.containsKey(key)) { + String pattern = bundle.getString(key); + 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; + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/MessageKeys.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/MessageKeys.java new file mode 100644 index 000000000..810787442 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/MessageKeys.java @@ -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() {} +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java index e99586ea3..7bf49ac33 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java @@ -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()); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java index 8372bc689..20ad65ebe 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java @@ -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; @@ -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; } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java index 2c315bbe9..487d045b0 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java @@ -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; @@ -94,9 +95,7 @@ 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 { @@ -104,7 +103,7 @@ public static InputStream handleAttachmentForEntity( 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 = diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java index f297e0ad7..c494101b3 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java @@ -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; @@ -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); } } } diff --git a/cds-feature-attachments/src/main/resources/com/sap/cds/feature/attachments/i18n/errors.properties b/cds-feature-attachments/src/main/resources/com/sap/cds/feature/attachments/i18n/errors.properties new file mode 100644 index 000000000..0435de183 --- /dev/null +++ b/cds-feature-attachments/src/main/resources/com/sap/cds/feature/attachments/i18n/errors.properties @@ -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}. diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProviderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProviderTest.java new file mode 100644 index 000000000..2d6b578bc --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProviderTest.java @@ -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."); + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java index f79504fff..b3dcaef24 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java @@ -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_; @@ -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); assertThat(exception).isNotSameAs(originalException); } @@ -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); assertThat(exception).isNotSameAs(originalException); } diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java index 9a44d9c8a..6526631ee 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java @@ -3,6 +3,7 @@ */ package com.sap.cds.feature.attachments.oss.handler; +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.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; @@ -115,9 +116,9 @@ void createAttachment(AttachmentCreateEventContext context) { context.setContentId(contentId); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); - throw new ServiceException("Failed to upload file {}", fileName, ex); + throw new ServiceException(MessageKeys.UPLOAD_FAILED, fileName, ex); } catch (ObjectStoreServiceException | ExecutionException ex) { - throw new ServiceException("Failed to upload file {}", fileName, ex); + throw new ServiceException(MessageKeys.UPLOAD_FAILED, fileName, ex); } finally { context.setCompleted(); } @@ -133,11 +134,9 @@ void markAttachmentAsDeleted(AttachmentMarkAsDeletedEventContext context) { osClient.deleteContent(context.getContentId()).get(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); - throw new ServiceException( - "Failed to delete file with document id {}", context.getContentId(), ex); + throw new ServiceException(MessageKeys.DELETE_FAILED, context.getContentId(), ex); } catch (ObjectStoreServiceException | ExecutionException ex) { - throw new ServiceException( - "Failed to delete file with document id {}", context.getContentId(), ex); + throw new ServiceException(MessageKeys.DELETE_FAILED, context.getContentId(), ex); } finally { context.setCompleted(); } @@ -165,16 +164,13 @@ void readAttachment(AttachmentReadEventContext context) { context.getData().setContent(inputStream); } else { logger.error("Document not found for id {}", context.getContentId()); - throw new ServiceException( - "Document not found for id " + context.getContentId(), context.getContentId()); + throw new ServiceException(MessageKeys.DOCUMENT_NOT_FOUND, context.getContentId()); } } catch (InterruptedException ex) { Thread.currentThread().interrupt(); - throw new ServiceException( - "Failed to read file with document id {}", context.getContentId(), ex); + throw new ServiceException(MessageKeys.READ_FAILED, context.getContentId(), ex); } catch (ObjectStoreServiceException | ExecutionException ex) { - throw new ServiceException( - "Failed to read file with document id {}", context.getContentId(), ex); + throw new ServiceException(MessageKeys.READ_FAILED, context.getContentId(), ex); } finally { context.setCompleted(); }