RetroactiveTranslatorWithPlaceholderSupport.java

package net.hostsharing.hsadminng.config;

import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.IntStream;

import static java.util.Collections.emptyList;

/**
 * Translates external messages that contain dynamic values,
 * which need to replace placeholders in the translation templates.
 *
 * <p>Implement this as a Spring service when a message includes values such as names, roles,
 * identifiers or amounts. Register one {@link TranslatableMessage} per supported message shape.
 *
 * <p>Capture groups in the pattern become {@code {0}}, {@code {1}}, ... arguments of the translated message:
 * <pre>{@code
 * new TranslatableMessage(
 *         "ERROR: [403] ",
 *         "^ERROR: \\[403] subject (.+) has no permission to assume role (.+)$",
 *         "rbac.subject-{0}-has-no-permisson-to-assume-role-{1}")
 * }</pre>
 */
public interface RetroactiveTranslatorWithPlaceholderSupport extends RetroactiveTranslator {

    /**
     * In your Spring service implementation,
     * overwrite this method to provide the translator used to resolve message keys.
     *
     * @return the message translator to use; implementations must override this method
     */
    default MessageTranslator getMessageTranslator() {
        return null;
    }

    /**
     * In your Spring service implementation,
     * overwrite this method to provide the message shapes supported by this translator.
     * <p>
     * More specific patterns should come before more general patterns.
     *
     * @return the supported messages; implementations must override this method
     */
    default List<TranslatableMessage> getTranslatableMessages() {
        return emptyList();
    }

    /**
     * Checks whether any registered message shape matches the message.
     * Usually does not need to be overridden by implementation service anymore.
     *
     * @param message the original external message
     * @return {@code true} if this translator can translate the message
     */
    @Override
    default boolean canTranslate(final String message) {
        return getTranslatableMessages().stream()
                .anyMatch(translatableMessage -> translatableMessage.pattern().matcher(message).matches());
    }

    /**
     * Translates the message using the first matching message shape.
     * Usually does not need to be overridden by implementation service anymore.
     *
     * @param message the original external message
     * @return the translated message, or the original message if no pattern matches
     */
    @Override
    default String translate(final String message) {
        for (final var translatableMessage : getTranslatableMessages()) {
            final var matcher = translatableMessage.pattern().matcher(message);

            if (matcher.matches()) {
                final var args = IntStream.rangeClosed(1, matcher.groupCount())
                        .mapToObj(matcher::group)
                        .toArray();
                return translatableMessage.messagePrefix() +
                        getMessageTranslator().translate(translatableMessage.messageKey(), args);
            }
        }

        return message;
    }

    /**
     * Defines one supported external message shape.
     */
    class TranslatableMessage {

        private final String messagePrefix;
        private final Pattern pattern;
        private final String messageKey;

        /**
         * Creates a descriptor with a precompiled pattern.
         *
         * @param messagePrefix the prefix to keep in the translated message, e.g. {@code ERROR: [403] }
         * @param pattern the pattern for the full original message
         * @param messageKey the key to resolve via {@link MessageTranslator}
         */
        public TranslatableMessage(
                final String messagePrefix,
                final Pattern pattern,
                final String messageKey) {
            this.messagePrefix = messagePrefix;
            this.pattern = pattern;
            this.messageKey = messageKey;
        }

        /**
         * Creates a descriptor for messages that equal {@code messagePrefix + messageKey}.
         *
         * @param messagePrefix the prefix to keep in the translated message, e.g. {@code ERROR: [400] }
         * @param messageKey the key to resolve via {@link MessageTranslator}
         */
        public TranslatableMessage(final String messagePrefix, final String messageKey) {
            this(
                    messagePrefix,
                    Pattern.compile("^" + Pattern.quote(messagePrefix + messageKey) + "$"),
                    messageKey);
        }

        /**
         * Creates a descriptor from a regular expression.
         *
         * @param messagePrefix the prefix to keep in the translated message, e.g. {@code ERROR: [403] }
         * @param regex the regular expression for the full original message
         * @param messageKey the key to resolve via {@link MessageTranslator}
         */
        public TranslatableMessage(final String messagePrefix, final String regex, final String messageKey) {
            this(messagePrefix, Pattern.compile(regex), messageKey);
        }

        /**
         * Returns the prefix kept in the translated message.
         *
         * @return the message prefix, e.g. {@code ERROR: [403] }
         */
        public String messagePrefix() {
            return messagePrefix;
        }

        /**
         * Returns the pattern for the original message.
         *
         * @return the message pattern
         */
        public Pattern pattern() {
            return pattern;
        }

        /**
         * Returns the message key.
         *
         * @return the translation key
         */
        public String messageKey() {
            return messageKey;
        }
    }
}