Skip to content

Commit

Permalink
fix: resolve locale in message interpolator (#486)
Browse files Browse the repository at this point in the history
* fix: resolve locale in message interpolator

Close: #485

* add @Inject
  • Loading branch information
sdelamo authored Jan 31, 2025
1 parent d972d02 commit b9f5e79
Show file tree
Hide file tree
Showing 8 changed files with 222 additions and 3 deletions.
2 changes: 2 additions & 0 deletions validation/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ dependencies {
testImplementation mn.micronaut.inject.java.test
testImplementation mn.micronaut.jackson.databind

testImplementation(mnTest.micronaut.test.junit5)
testRuntimeOnly(libs.junit.jupiter.engine)
}

spotless {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@

import io.micronaut.context.annotation.Requires;
import io.micronaut.core.util.StringUtils;
import io.micronaut.validation.validator.ValidatorConfiguration;
import io.micronaut.validation.validator.ValidatorConfiguration;
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@

import io.micronaut.context.MessageSource;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.util.ArgumentUtils;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.validation.MessageInterpolator;

import java.util.HashMap;
import java.util.Locale;
import java.util.Optional;

/**
* The default error messages.
Expand All @@ -38,10 +41,31 @@ public class DefaultMessageInterpolator implements MessageInterpolator {
private static final char R_BRACE = '}';
private static final char DOLL_BRACE = '$';

@Nullable
private final InterpolatorLocaleResolver interpolatorLocaleResolver;

private final MessageSource messageSource;

public DefaultMessageInterpolator(MessageSource messageSource) {
/**
*
* @param messageSource Message Source
* @param interpolatorLocaleResolver Interpolator Locale Resolver
*/
@Inject
public DefaultMessageInterpolator(MessageSource messageSource,
@Nullable InterpolatorLocaleResolver interpolatorLocaleResolver) {
this.messageSource = messageSource;
this.interpolatorLocaleResolver = interpolatorLocaleResolver;
}

/**
*
* @param messageSource Message Source
* @deprecated Use {@link #DefaultMessageInterpolator(MessageSource, InterpolatorLocaleResolver)} instead.
*/
@Deprecated(forRemoval = true, since = "4.9.0")
public DefaultMessageInterpolator(MessageSource messageSource) {
this(messageSource, Optional::empty);
}

private String interpolate(@NonNull String template, @NonNull MessageSource.MessageContext context) {
Expand Down Expand Up @@ -102,7 +126,10 @@ private String interpolate(@NonNull String template, @NonNull MessageSource.Mess

@Override
public String interpolate(String messageTemplate, Context context) {
return interpolate(messageTemplate, context, Locale.ENGLISH);
Locale locale = interpolatorLocaleResolver != null
? interpolatorLocaleResolver.resolve().orElseGet(Locale::getDefault)
: Locale.getDefault();
return interpolate(messageTemplate, context, locale);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright 2017-2025 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.validation.validator.messages;

import io.micronaut.context.annotation.Requires;
import io.micronaut.core.annotation.Internal;
import io.micronaut.http.context.ServerRequestContext;
import io.micronaut.http.server.util.locale.HttpLocaleResolver;
import jakarta.inject.Singleton;

import java.util.Locale;
import java.util.Optional;

@Internal
@Requires(beans = HttpLocaleResolver.class)
@Singleton
class HttpInterpolatorLocaleResolver implements InterpolatorLocaleResolver {

private final HttpLocaleResolver localeResolver;

HttpInterpolatorLocaleResolver(HttpLocaleResolver localeResolver) {
this.localeResolver = localeResolver;
}

@Override
public Optional<Locale> resolve() {
return ServerRequestContext.currentRequest().map(localeResolver::resolveOrDefault);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright 2017-2025 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.validation.validator.messages;

import io.micronaut.core.annotation.NonNull;

import java.util.Locale;
import java.util.Optional;

/**
* Resolves the Locale for the {@link DefaultMessageInterpolator}.
* @author Sergio del Amo
* @since 4.9.
*/
public interface InterpolatorLocaleResolver {
/**
*
* @return If the locale could be resolved.
*/
@NonNull
Optional<Locale> resolve();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package io.micronaut.validation.validator.messages;

import io.micronaut.context.AbstractMessageSource;
import io.micronaut.context.MessageSource;
import io.micronaut.context.annotation.*;
import io.micronaut.context.i18n.ResourceBundleMessageSource;
import io.micronaut.core.annotation.*;
import io.micronaut.core.order.OrderUtil;
import io.micronaut.core.order.Ordered;
import io.micronaut.core.util.ArgumentUtils;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.annotation.Status;
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.runtime.context.CompositeMessageSource;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Singleton;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import org.junit.jupiter.api.Test;

import java.util.*;
import java.util.function.Predicate;

import static org.junit.jupiter.api.Assertions.*;

@Property(name = "spec.name", value = "DefaultMessageInterpolatorTest")
@MicronautTest
class DefaultMessageInterpolatorTest {

@Test
void validationMessageCanBeLocalized(@Client("/") HttpClient httpClient,
MessageSource messageSource) {
BlockingHttpClient client = httpClient.toBlocking();
String code = "jakarta.validation.constraints.Positive.message";
assertEquals("Debe ser positivo", messageSource.getMessage(code, new Locale("es", "ES")).get());
assertEquals("Must be positive", messageSource.getMessage(code, Locale.ENGLISH).get());
Book invalid = new Book("Netty in Action", "9781617291470", 0);

assertValidationJsonError(client,
HttpRequest.POST("/books", invalid),
json -> json.contains("Must be positive") && !json.contains("Debe ser positivo")
);

assertValidationJsonError(client,
HttpRequest.POST("/books", invalid).header(HttpHeaders.ACCEPT_LANGUAGE, "es-ES"),
json -> json.contains("Debe ser positivo") && !json.contains("Must be positive")
);
}

void assertValidationJsonError(BlockingHttpClient client, HttpRequest<?> request, Predicate<String> predicate) {
HttpClientResponseException ex = assertThrows(HttpClientResponseException.class, () -> client.exchange(request));
Optional<String> jsonOptional = ex.getResponse().getBody(String.class);
assertTrue(jsonOptional.isPresent());
String json = jsonOptional.get();
assertNotNull(json);
assertTrue(predicate.test(json));
}

@Requires(property = "spec.name", value = "DefaultMessageInterpolatorTest")
@Factory
static class MessageSourceFactory {

@Singleton
MessageSource createMessageSource() {
return new MessageSource() {
private final MessageSource delegate = new ResourceBundleMessageSource("i18n.messages");

@Override
public @NonNull Optional<String> getRawMessage(@NonNull String code, @NonNull MessageContext context) {
return delegate.getRawMessage(code, context);
}

@Override
public @NonNull String interpolate(@NonNull String template, @NonNull MessageContext context) {
return delegate.interpolate(template, context);
}

@Override
public int getOrder() {
return HIGHEST_PRECEDENCE;
}
};
}
}

@Requires(property = "spec.name", value = "DefaultMessageInterpolatorTest")
@Controller("/books")
static class BookController {
@Status(HttpStatus.CREATED)
@Post
public void save(@Body @Valid Book book) {
}
}

@Introspected
public record Book(@NotBlank String name,
@Nullable String isbn,
@Nullable @Positive Integer pages) {
}
}
1 change: 1 addition & 0 deletions validation/src/test/resources/i18n/messages.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
jakarta.validation.constraints.Positive.message=Must be positive
1 change: 1 addition & 0 deletions validation/src/test/resources/i18n/messages_es.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
jakarta.validation.constraints.Positive.message=Debe ser positivo

0 comments on commit b9f5e79

Please sign in to comment.