-
Notifications
You must be signed in to change notification settings - Fork 7
Implement localized error messages for attachment handling #758
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||||||||||||||||||||
| if (bundle.containsKey(key)) { | ||||||||||||||||||||
| String pattern = bundle.getString(key); | ||||||||||||||||||||
|
Comment on lines
+56
to
+57
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Instead, call
Suggested change
Double-check suggestion before committing. Edit this comment for amendments. Please provide feedback on the review comment by checking the appropriate box:
|
||||||||||||||||||||
| 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 |
|---|---|---|
| @@ -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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Consider asserting the resolved message text (as before), or β if the intent is only to verify which key is used β assert the
Suggested change
Double-check suggestion before committing. Edit this comment for amendments. Please provide feedback on the review comment by checking the appropriate box:
|
||||||
| 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); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Consider asserting the resolved message text to preserve the original intent of the test.
Suggested change
Double-check suggestion before committing. Edit this comment for amendments. Please provide feedback on the review comment by checking the appropriate box:
|
||||||
| assertThat(exception).isNotSameAs(originalException); | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
There was a problem hiding this comment.
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 ofget().ResourceBundle.getBundledoes have an internal cache, but it still incurs synchronization and map-lookup overhead on each call. Because this provider is a singleton registered viaRegistration.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
previouswhen the bundle itself is absent.Please provide feedback on the review comment by checking the appropriate box: