1
0

scoped programmatic i18n-keys (#190)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/190
Reviewed-by: Timotheus Pokorra <timotheus.pokorra@hostsharing.net>
This commit is contained in:
Michael Hoennig
2025-08-26 15:28:42 +02:00
parent 2a6e86aca8
commit 68e642c034
29 changed files with 283 additions and 104 deletions
@@ -1,5 +1,7 @@
package net.hostsharing.hsadminng.config;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service;
@@ -10,6 +12,7 @@ import java.util.Locale;
@Service
@RequestScope
@Slf4j
public class MessageTranslator {
@Autowired
@@ -21,19 +24,26 @@ public class MessageTranslator {
public String translateTo(final Locale locale, final String messageKey, final Object... args) {
try {
// we don't use the method which also takes a default message right away ...
final var translatedMessage = messageSource.getMessage(messageKey, args, locale);
val translatedMessage = messageSource.getMessage(messageKey, args, locale);
return translatedMessage;
} catch (final Exception e) {
final var defaultMessage = messageKey.replace("'", "''");
final var translatedMessage = messageSource.getMessage(messageKey, args, defaultMessage, locale);
if (locale != Locale.ENGLISH) {
// ... because we want to add a hint that the translation is missing, even if placeholders got replaced
return translatedMessage + " [" + locale + " translation missing]";
}
return translatedMessage;
// ... but log the missing translation ...
log.error("Missing translation for message key '{}' in locale '{}'", messageKey, locale, e);
// and decorate the default message to mark it as not really translated:
val defaultMessage = messageKey.substring(messageKey.indexOf('.') + 1)
.replaceAll("--+", " - ")
.replaceAll("(?<! )-(?! )", " ")
.replace("'", "''");
val fallbackMessage = messageSource.getMessage(messageKey, args, defaultMessage, Locale.ENGLISH);
return decorateMissingTranslation(fallbackMessage);
}
}
private static String decorateMissingTranslation(final String translatedMessage) {
return "【⍰" + translatedMessage + "⍰】";
}
public String translate(final String messageKey, final Object... args) {
return translateTo(httpRequest.getLocale(), messageKey, args);
}
@@ -11,6 +11,8 @@ public class MessagesResourceConfig {
final var source = new ResourceBundleMessageSource();
source.setBasenames("i18n/messages");
source.setDefaultEncoding("UTF-8");
source.setFallbackToSystemLocale(false);
source.setUseCodeAsDefaultMessage(false);
return source;
}
@@ -57,7 +57,7 @@ public class RealCasAuthenticator implements CasAuthenticator {
private Document verifyServiceTicket(final String serviceTicket) throws SAXException, IOException, ParserConfigurationException {
if ( !serviceTicket.startsWith("ST-") ) {
throwBadCredentialsException("unknown authorization ticket");
throwBadCredentialsException("auth.unknown-authorization-ticket");
}
final var url = casServerUrl + "/cas/p3/serviceValidate" +
@@ -74,7 +74,7 @@ public class RealCasAuthenticator implements CasAuthenticator {
private String extractUserName(final Document verification) {
if (verification.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) {
throwBadCredentialsException("CAS service-ticket could not be verified");
throwBadCredentialsException("auth.cas-service-ticket-could-not-be-verified");
}
return verification.getElementsByTagName("cas:user").item(0).getTextContent();
}
@@ -8,7 +8,7 @@ import static java.util.Locale.ENGLISH;
public class ReferenceNotFoundException extends RuntimeException {
private final String TRANSLATABLE_MESSAGE = "{0} \"{1}\" not found";
private final String TRANSLATABLE_MESSAGE = "general.{0}-{1}-not-found";
private final MessageTranslator translator;
@@ -156,7 +156,7 @@ public class RestResponseEntityExceptionHandler
}
private Function<FieldError, String> toEnrichedFieldErrorMessage() {
final var translatedButIsLiteral = messageTranslator.translate("but is");
final var translatedButIsLiteral = messageTranslator.translate("general.but-is");
// TODO.i18n: the following does not work in all languages, e.g. not in right-to-left languages
return fieldError -> fieldError.getField() + " " + fieldError.getDefaultMessage() +
" " + translatedButIsLiteral + " " + optionallyQuoted(fieldError.getRejectedValue());
@@ -52,13 +52,15 @@ public class CredentialContextResourceToEntityMapper {
final var existingContextEntity = em.find(HsCredentialsContextRealEntity.class, resource.getUuid());
if (existingContextEntity == null) {
throw new EntityNotFoundException(
messageTranslator.translate("{0} \"{1}\" not found or not accessible",
messageTranslator.translate(
"general.{0}-{1}-not-found-or-not-accessible",
"credentials uuid", resource.getUuid()));
}
if ((resource.getType() != null && !existingContextEntity.getType().equals(resource.getType())) ||
(resource.getQualifier() != null && !existingContextEntity.getQualifier().equals(resource.getQualifier()))) {
throw new EntityNotFoundException(
messageTranslator.translate("existing {0} does not match given resource {1}",
messageTranslator.translate(
"credentials.existing-{0}-does-not-match-given-resource-{1}",
existingContextEntity, resource));
}
entities.add(existingContextEntity);
@@ -203,12 +203,12 @@ public class HsCredentialsController implements CredentialsApi {
private void validate(final HsCredentialsEntity newCredentialsEntity) {
// the referenced person must be represented by currently logged in person
final var personUuid = newCredentialsEntity.getPerson().getUuid();
final var representedPersonUuids = rbacPersonRepo.findPersonsrepresentedByPersonWithUuid(personUuid)
final var representedPersonUuids = rbacPersonRepo.findPersonsRepresentedByPersonWithUuid(personUuid)
.stream().map(HsOfficePerson::getUuid).toList();
if ( !representedPersonUuids.contains(personUuid)) {
throw new ValidationException(
messageTranslator.translate(
"access-denied-personUuid-{0}-not-represented-by-currently-logged-in-person",
"credentials.access-denied-person-uuid-{0}-not-represented-by-currently-logged-in-person",
personUuid));
}
}
@@ -224,7 +224,7 @@ public class HsCredentialsController implements CredentialsApi {
private List<HsCredentialsEntity> findByPersonUuid(final UUID personUuid) {
final var person = rbacPersonRepo.findByUuid(personUuid).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("{0} \"{1}\" not found or not accessible", "personUuid", personUuid)
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", personUuid)
)
);
@@ -269,7 +269,7 @@ public class HsCredentialsController implements CredentialsApi {
final BiConsumer<CredentialsInsertResource, HsCredentialsEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
final var person = rbacPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("{0} \"{1}\" not found or not accessible", "personUuid", resource.getPersonUuid())
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid())
)
);
@@ -17,7 +17,7 @@ public class HsCoopAssetTranslations implements RetroactiveTranslator {
@Override
public boolean canTranslate(final String message) {
return message.equals("ERROR: [400] coop assets transaction would result in a negative balance of assets");
return message.equals("ERROR: [400] office.coop-assets.transaction-would-result-in-a-negative-balance-of-assets");
}
@Override
@@ -26,7 +26,7 @@ public class HsCoopAssetTranslations implements RetroactiveTranslator {
// and in this case it's just one
return ERROR_400_PREFIX + messageTranslator.translate(message.substring(ERROR_400_PREFIX.length()));
// HOWTO extract variable parts from a messages which got created without i18n support:
// HOWTO extract variable parts from messages which got created without i18n support:
// final var regex = "(?<propertyName>[^ ]+) (?<propertyValue>.+) not found";
// final var pattern = Pattern.compile(regex);
// final var matcher = pattern.matcher(message);
@@ -141,7 +141,8 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
final ArrayList<String> violations) {
if (List.of(DEPOSIT, HsOfficeCoopAssetsTransactionTypeResource.ADOPTION).contains(requestBody.getTransactionType())
&& requestBody.getAssetValue().signum() < 0) {
violations.add(messageTranslator.translate("for transactionType={0}, assetValue must be positive but is {1,number,#0.00}",
violations.add(messageTranslator.translate(
"office.coop-assets.for-transactiontype-{0}-assetvalue-must-be-positive-but-is-{1,number,#0.00}",
requestBody.getTransactionType(), requestBody.getAssetValue()));
}
}
@@ -152,7 +153,8 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
if (List.of(DISBURSAL, HsOfficeCoopAssetsTransactionTypeResource.TRANSFER, CLEARING, LOSS)
.contains(requestBody.getTransactionType())
&& requestBody.getAssetValue().signum() > 0) {
violations.add(messageTranslator.translate("for transactionType={0}, assetValue must be negative but is {1,number,#0.00}",
violations.add(messageTranslator.translate(
"office.coop-assets.for-transactiontype-{0}-assetvalue-must-be-negative-but-is-{1,number,#0.00}",
requestBody.getTransactionType(), requestBody.getAssetValue()));
}
}
@@ -161,7 +163,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
final HsOfficeCoopAssetsTransactionInsertResource requestBody,
final ArrayList<String> violations) {
if (requestBody.getAssetValue().signum() == 0) {
violations.add(messageTranslator.translate("assetValue must not be 0"));
violations.add(messageTranslator.translate("office.coop-assets.assetvalue-must-not-be-0"));
}
}
@@ -227,18 +229,18 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
resource.getMembershipUuid()))
.orElseThrow(() -> new EntityNotFoundException(
messageTranslator.translate(
"{0} \"{1}\" not found", "membership.uuid", resource.getMembershipUuid())));
"general.{0}-{1}-not-found", "membership.uuid", resource.getMembershipUuid())));
entity.setMembership(membership);
}
if (entity.getTransactionType() == REVERSAL) {
if (resource.getRevertedAssetTxUuid() == null) {
throw new ValidationException(messageTranslator.translate(
"a REVERSAL asset transaction requires specifying a revertedAssetTx.uuid"));
"office.coop-assets.a-reversal-asset-transaction-requires-specifying-a-revertedassettx-uuid"));
}
final var revertedAssetTx = coopAssetsTransactionRepo.findByUuid(resource.getRevertedAssetTxUuid())
.orElseThrow(() -> new EntityNotFoundException(messageTranslator.translate(
"{0} \"{1}\" not found",
"general.{0}-{1}-not-found",
"revertedAssetTx.uuid",
resource.getRevertedAssetTxUuid())));
revertedAssetTx.setReversalAssetTx(entity);
@@ -246,7 +248,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
if (resource.getAssetValue().negate().compareTo(revertedAssetTx.getAssetValue()) != 0) {
throw new ValidationException(
messageTranslator.translate(
"given assetValue {0,number,#0.00} must be the negative value of the reverted asset transaction: {1,number,#0.00}",
"office.coop-assets.given-assetvalue-{0,number,#0.00}-must-be-the-negative-value-of-the-reverted-asset-transaction-{1,number,#0.00}",
resource.getAssetValue(), revertedAssetTx.getAssetValue()));
}
@@ -270,7 +272,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
final var adoptingMembership = determineAdoptingMembership(resource);
if ( entity.getMembership() == adoptingMembership) {
throw new ValidationException(messageTranslator.translate(
"transferring and adopting membership must be different, but both are {0}",
"office.coop-assets.transferring-and-adopting-membership-must-be-different-but-both-are-{0}",
adoptingMembership.getTaggedMemberNumber()));
}
final var adoptingAssetTx = createAdoptingAssetTx(entity, adoptingMembership);
@@ -285,8 +287,8 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
// @formatter:off
final var message = messageTranslator.translate(
resource.getTransactionType() == HsOfficeCoopAssetsTransactionTypeResource.TRANSFER
? "either {0} or {1} must be given, not both"
: "neither {0} nor {1} must be given for transactionType={2}",
? "office.coop-assets.either-{0}-or-{1}-must-be-given-not-both"
: "office.coop-assets.neither-{0}-nor-{1}-must-be-given-for-transactiontype-{2}",
"adoptingMembership.uuid",
"adoptingMembership.memberNumber",
resource.getTransactionType());
@@ -298,7 +300,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
final var adoptingMembership = membershipRepo.findByUuid(adoptingMembershipUuid);
return adoptingMembership.orElseThrow(() ->
new ValidationException(messageTranslator.translate(
"{0} \"{1}\" not found or not accessible",
"general.{0}-{1}-not-found-or-not-accessible",
"adoptingMembership.uuid",
adoptingMembershipUuid)));
}
@@ -309,14 +311,14 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
return adoptingMembership.orElseThrow( () ->
new ValidationException(
messageTranslator.translate(
"{0} \"{1}\" not found or not accessible",
"general.{0}-{1}-not-found-or-not-accessible",
"adoptingMembership.memberNumber",
adoptingMembershipMemberNumber)));
}
throw new ValidationException(
messageTranslator.translate(
"either {0} or {1} must be given for transactionType={2}",
"office.coop-assets.either-{0}-or-{1}-must-be-given-for-transactiontype-{2}",
"adoptingMembership.uuid",
"adoptingMembership.memberNumber",
resource.getTransactionType()
@@ -16,7 +16,7 @@ public class HsCoopShareTranslations implements RetroactiveTranslator {
@Override
public boolean canTranslate(final String message) {
return message.equals("ERROR: [400] coop shares transaction would result in a negative number of shares");
return message.equals("ERROR: [400] office.coop-shares.transaction-would-result-in-a-negative-number-of-shares");
}
@Override
@@ -129,7 +129,8 @@ public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopShar
final ArrayList<String> violations) {
if (requestBody.getTransactionType() == SUBSCRIPTION
&& requestBody.getShareCount() < 0) {
violations.add(messageTranslator.translate("for transactionType={0}, shareCount must be positive but is {1}",
violations.add(messageTranslator.translate(
"office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}",
requestBody.getTransactionType(), requestBody.getShareCount()));
}
}
@@ -139,7 +140,8 @@ public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopShar
final ArrayList<String> violations) {
if (requestBody.getTransactionType() == CANCELLATION
&& requestBody.getShareCount() > 0) {
violations.add(messageTranslator.translate("for transactionType={0}, shareCount must be negative but is {1}",
violations.add(messageTranslator.translate(
"office.coop-shares.for-transactiontype-{0}-sharecount-must-be-negative-but-is-{1}",
requestBody.getTransactionType(), requestBody.getShareCount()));
}
}
@@ -148,7 +150,7 @@ public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopShar
final HsOfficeCoopSharesTransactionInsertResource requestBody,
final ArrayList<String> violations) {
if (requestBody.getShareCount() == 0) {
violations.add(messageTranslator.translate("shareCount must not be 0"));
violations.add(messageTranslator.translate("office.coop-shares.sharecount-must-not-be-0"));
}
}
@@ -42,7 +42,7 @@ public class HsOfficePersonController implements HsOfficePersonsApi {
context.assumeRoles(assumedRoles);
final var entities = representedByPersonUuid != null
? personRepo.findPersonsrepresentedByPersonWithUuid(representedByPersonUuid)
? personRepo.findPersonsRepresentedByPersonWithUuid(representedByPersonUuid)
: personRepo.findPersonByOptionalNameLike(name);
final var resources = mapper.mapList(entities, HsOfficePersonResource.class);
@@ -37,7 +37,7 @@ public interface HsOfficePersonRbacRepository extends Repository<HsOfficePersonR
OR person.uuid = :personUuid
""", nativeQuery = true)
@Timed("app.office.persons.repo.findRepresentedPersons.rbac")
List<HsOfficePersonRbacEntity> findPersonsrepresentedByPersonWithUuid(UUID personUuid);
List<HsOfficePersonRbacEntity> findPersonsRepresentedByPersonWithUuid(UUID personUuid);
@Timed("app.office.persons.repo.save.rbac")
HsOfficePersonRbacEntity save(final HsOfficePersonRbacEntity entity);
@@ -21,9 +21,8 @@ public class PingController implements TestApi {
@PreAuthorize("permitAll()")
@Timed("app.api.ping")
public ResponseEntity<String> ping() {
final var userName = SecurityContextHolder.getContext().getAuthentication().getName();
// HOWTO translate text with placeholders - also see in resource files i18n/messages_*.properties.
final var translatedMessage = messageTranslator.translate("pong {0} - in English", userName);
final var translatedMessage = messageTranslator.translate("test.pinged--in-your-language");
return ResponseEntity.ok(translatedMessage + "\n");
}