From 1e9aa3cbdd9462b36fa5974f19ba469f9b127d4e Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Thu, 7 Nov 2024 10:19:31 +0100 Subject: [PATCH] Add variables to Message (#903) * Add variables to Messages This change adds MessageResource and LocalizedMessageResolver APIs to ease resolving a message given a instance of io.micronaut.views.fields.messages.Message * improves compareTo --- views-fieldset/build.gradle.kts | 1 + .../messages/LocalizedMessageResolver.java | 54 +++++++++++++ .../views/fields/messages/Message.java | 71 ++++++++++++++++- .../fields/messages/MessageResolver.java | 58 ++++++++++++++ .../messages/MessageResolverFactory.java | 39 ++++++++++ .../views/fields/messages/MessageTest.java | 78 +++++++++++++++++++ .../test/resources/i18n/messages.properties | 1 + .../resources/i18n/messages_es.properties | 1 + 8 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 views-fieldset/src/main/java/io/micronaut/views/fields/messages/LocalizedMessageResolver.java create mode 100644 views-fieldset/src/main/java/io/micronaut/views/fields/messages/MessageResolver.java create mode 100644 views-fieldset/src/main/java/io/micronaut/views/fields/messages/MessageResolverFactory.java create mode 100644 views-fieldset/src/test/resources/i18n/messages.properties create mode 100644 views-fieldset/src/test/resources/i18n/messages_es.properties diff --git a/views-fieldset/build.gradle.kts b/views-fieldset/build.gradle.kts index 5ae0bc5fa..21284f510 100644 --- a/views-fieldset/build.gradle.kts +++ b/views-fieldset/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { compileOnly(projects.micronautViewsCore) testImplementation(mnData.micronaut.data.model) testImplementation(mn.micronaut.http) + testImplementation(mn.micronaut.http.server.netty) testAnnotationProcessor(mn.micronaut.inject.java) testImplementation(mnTest.junit.jupiter.api) testImplementation(mnTest.micronaut.test.junit5) diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/messages/LocalizedMessageResolver.java b/views-fieldset/src/main/java/io/micronaut/views/fields/messages/LocalizedMessageResolver.java new file mode 100644 index 000000000..da53287b7 --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/messages/LocalizedMessageResolver.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.fields.messages; + +import io.micronaut.context.LocalizedMessageSource; +import io.micronaut.core.annotation.NonNull; +import java.util.Optional; + +/** + * Utility class to resolve message of a {@link Message} with a given {@link LocalizedMessageSource}. + * @author Sergio del Amo + * @since 5.6.0 + */ +public class LocalizedMessageResolver { + + private final LocalizedMessageSource messageSource; + + public LocalizedMessageResolver(LocalizedMessageSource messageSource) { + this.messageSource = messageSource; + } + + /** + * + * @param message The message + * @return the resolved message or the defaultMessage if the message is not found. + */ + @NonNull + public String getMessageOrDefault(@NonNull Message message) { + return messageSource.getMessageOrDefault(message.code(), message.defaultMessage(), message.variables()); + } + + /** + * + * @param message The message + * @return the resolved message or an empty optional if not found. + */ + @NonNull + public Optional getMessage(@NonNull Message message) { + return messageSource.getMessage(message.code(), message.variables()); + } +} diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/messages/Message.java b/views-fieldset/src/main/java/io/micronaut/views/fields/messages/Message.java index 270aab56a..cefb4f7ef 100644 --- a/views-fieldset/src/main/java/io/micronaut/views/fields/messages/Message.java +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/messages/Message.java @@ -25,7 +25,9 @@ import jakarta.validation.constraints.NotBlank; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Objects; /** * Message representation. It features an optional {@link Message#code()} to allow localization. @@ -33,15 +35,42 @@ * @since 4.1.0 * @param defaultMessage The default message to use if no code is specified or no localized message found. * @param code The i18n code which can be used to fetch a localized message. + * @param variables The variables to use resolve message placeholders */ @Experimental @Introspected public record Message(@NonNull @NotBlank String defaultMessage, - @Nullable String code) implements Comparable { + @Nullable String code, + @Nullable Object[] variables) implements Comparable { private static final String REGEX = "(.)([A-Z])"; private static final String REPLACEMENT = "$1 $2"; private static final String DOT = "."; + public Message(@NonNull String defaultMessage, @Nullable String code) { + this(defaultMessage, code, null); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Message message = (Message) o; + return defaultMessage.equals(message.defaultMessage) && + Objects.equals(code, message.code) && + Arrays.equals(variables, message.variables); + } + + @Override + public int hashCode() { + int result = Objects.hash(defaultMessage, code); + result = 31 * result + Arrays.hashCode(variables); + return result; + } + @Override public int compareTo(Message o) { int compare = defaultMessage().compareTo(o.defaultMessage()); @@ -54,10 +83,30 @@ public int compareTo(Message o) { if (this.code() != null && o.code() == null) { return 1; } - if (this.code() == null) { - return 0; + if (this.code() != null) { + compare = this.code().compareTo(o.code()); + if (compare != 0) { + return compare; + } } - return this.code().compareTo(o.code()); + if (this.variables == null && o.variables != null) { + return -1; + } + if (this.variables != null && o.variables == null) { + return 1; + } + if (this.variables != null) { + for (int i = 0; i < Math.min(this.variables.length, o.variables.length); i++) { + if (this.variables[i] instanceof Comparable variablesComparable) { + compare = variablesComparable.compareTo(o.variables[i]); + if (compare != 0) { + return compare; + } + } + } + return Integer.compare(this.variables.length, o.variables.length); + } + return 0; } /** @@ -72,6 +121,20 @@ public static Message of(@NonNull String defaultMessage, return new Message(defaultMessage, code); } + /** + * + * @param defaultMessage The default message to use if no code is specified or no localized message found + * @param code The i18n code which can be used to fetch a localized message. + * @param variables The variables to use resolve message placeholders + * @return A {@link Message} instance. + */ + @NonNull + public static Message of(@NonNull String defaultMessage, + @Nullable String code, + @Nullable Object... variables) { + return new Message(defaultMessage, code, variables); + } + /** * * @param defaultMessage The default message to use if no code is specified or no localized message found diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/messages/MessageResolver.java b/views-fieldset/src/main/java/io/micronaut/views/fields/messages/MessageResolver.java new file mode 100644 index 000000000..479eee500 --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/messages/MessageResolver.java @@ -0,0 +1,58 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.fields.messages; + +import io.micronaut.context.MessageSource; +import io.micronaut.core.annotation.NonNull; + +import java.util.Locale; +import java.util.Optional; + +/** + * Utility class to resolve message of a {@link Message} with a given {@link MessageSource}. + * @author Sergio del Amo + * @since 5.6.0 + */ +public class MessageResolver { + + private final MessageSource messageSource; + + public MessageResolver(MessageSource messageSource) { + this.messageSource = messageSource; + } + + /** + * + * @param message The message + * @param locale the Locale + * @return the resolved message or the defaultMessage if the message is not found. + */ + @NonNull + public String getMessageOrDefault(@NonNull Message message, @NonNull Locale locale) { + return messageSource.getMessage(message.code(), message.defaultMessage(), locale, message.variables()); + } + + /** + * + * @param message The message + * @param locale the Locale + * @return the resolved message or an empty optional if not found. + */ + @NonNull + public Optional getMessage(@NonNull Message message, @NonNull Locale locale) { + return messageSource.getMessage(message.code(), locale, message.variables()); + } +} diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/messages/MessageResolverFactory.java b/views-fieldset/src/main/java/io/micronaut/views/fields/messages/MessageResolverFactory.java new file mode 100644 index 000000000..0688a54e3 --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/messages/MessageResolverFactory.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.fields.messages; + +import io.micronaut.context.LocalizedMessageSource; +import io.micronaut.context.MessageSource; +import io.micronaut.context.annotation.Factory; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import jakarta.inject.Singleton; + +@Factory +@Internal +final class MessageResolverFactory { + + @Singleton + MessageResolver createMessageResolver(MessageSource messageSource) { + return new MessageResolver(messageSource); + } + + @Requires(beans = LocalizedMessageSource.class) + @Singleton + LocalizedMessageResolver createMessageResolver(LocalizedMessageSource messageSource) { + return new LocalizedMessageResolver(messageSource); + } +} diff --git a/views-fieldset/src/test/java/io/micronaut/views/fields/messages/MessageTest.java b/views-fieldset/src/test/java/io/micronaut/views/fields/messages/MessageTest.java index 5a3a2459a..7834df886 100644 --- a/views-fieldset/src/test/java/io/micronaut/views/fields/messages/MessageTest.java +++ b/views-fieldset/src/test/java/io/micronaut/views/fields/messages/MessageTest.java @@ -1,13 +1,41 @@ package io.micronaut.views.fields.messages; +import io.micronaut.context.AbstractLocalizedMessageSource; +import io.micronaut.context.MessageSource; +import io.micronaut.context.annotation.*; +import io.micronaut.context.i18n.ResourceBundleMessageSource; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.LocaleResolver; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.server.util.locale.HttpLocalizedMessageSource; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import io.micronaut.validation.validator.Validator; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import org.junit.jupiter.api.Test; +import java.util.Locale; + import static org.junit.jupiter.api.Assertions.*; +@Property(name = "spec.name", value = "MessageTest") @MicronautTest(startApplication = false) class MessageTest { + private static final Locale ES = new Locale("es"); + + @Inject + MessageSource messageSource; + + @Inject + MessageResolver messageResolver; + + @Inject + LocalizedMessageResolver localizedMessageResolver; @Test void messageValidation(Validator validator) { @@ -17,4 +45,54 @@ void messageValidation(Validator validator) { String msg = null; assertFalse(validator.validate(Message.of(msg)).isEmpty()); } + + @Test + void twoArgumentConstructor() { + assertEquals(new Message("Foo", "foo.code"), Message.of("Foo", "foo.code")); + } + + @Test + void threeArgumentConstructor() { + String code = "default.null.message"; + String defaultMessage = "Property [{0}] of class [{1}] cannot be null"; + Message message = Message.of(defaultMessage, code, "pages", "Book"); + Message expected = new Message(defaultMessage, code, new Object[]{"pages", "Book"}); + assertEquals(expected, message); + } + + @Test + void messageSource() { + String code = "default.null.message"; + String defaultMessage = "Property [{0}] of class [{1}] cannot be null"; + Message message = Message.of(defaultMessage, code, "pages", "Book"); + assertEquals("La propiedad [pages] de la clase [Book] no puede ser nulo", + messageSource.getMessage(message.code(), message.defaultMessage(), ES, message.variables())); + assertEquals("La propiedad [pages] de la clase [Book] no puede ser nulo", + messageResolver.getMessageOrDefault(message, ES)); + assertEquals("La propiedad [pages] de la clase [Book] no puede ser nulo", + localizedMessageResolver.getMessageOrDefault(message)); + } + + @Requires(property = "spec.name", value = "MessageTest") + @Factory + static class MessageSourceFactory { + @Singleton + MessageSource createMessageSource() { + return new ResourceBundleMessageSource("i18n.messages"); + } + } + + @Requires(property = "spec.name", value = "MessageTest") + @Primary + @Singleton + static class HttpLocalizedMessageSourceReplacement extends AbstractLocalizedMessageSource { + HttpLocalizedMessageSourceReplacement(LocaleResolver> localeResolver, MessageSource messageSource) { + super(localeResolver, messageSource); + } + + @Override + protected @NonNull Locale getLocale() { + return ES; + } + } } \ No newline at end of file diff --git a/views-fieldset/src/test/resources/i18n/messages.properties b/views-fieldset/src/test/resources/i18n/messages.properties new file mode 100644 index 000000000..94c621506 --- /dev/null +++ b/views-fieldset/src/test/resources/i18n/messages.properties @@ -0,0 +1 @@ +default.null.message=Property [{0}] of class [{1}] cannot be null diff --git a/views-fieldset/src/test/resources/i18n/messages_es.properties b/views-fieldset/src/test/resources/i18n/messages_es.properties new file mode 100644 index 000000000..fd915aa49 --- /dev/null +++ b/views-fieldset/src/test/resources/i18n/messages_es.properties @@ -0,0 +1 @@ +default.null.message=La propiedad [{0}] de la clase [{1}] no puede ser nulo