Skip to content
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

Add internationalization support #133

Open
wants to merge 32 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b606b1a
Add internationalization support
jpraet Nov 3, 2024
4b3a1c1
Merge branch 'main' into feature/#124-i18n
jpraet Nov 5, 2024
d4d0770
Add french and german translations (by CBSS translation service)
jpraet Nov 5, 2024
f585c5c
Pluggable LocaleResolver
jpraet Nov 5, 2024
73997eb
Remove obsolete dependency
jpraet Nov 5, 2024
a683b8d
Locale constructor is deprecated in JDK21
jpraet Nov 5, 2024
075d8f5
Sonar
jpraet Nov 5, 2024
ec89168
Merge branch 'main' into feature/#124-i18n
jpraet Nov 6, 2024
4508118
Merge branch 'main' into feature/#124-i18n
jpraet Nov 19, 2024
07b96a1
Merge branch 'main' into feature/#124-i18n
jpraet Nov 28, 2024
84fcb31
Correct french translations
jflabatBCSS Nov 29, 2024
1a091fa
Merge remote-tracking branch 'origin/feature/#124-i18n' into feature/…
jflabatBCSS Nov 29, 2024
170d04a
handle review
jpraet Nov 29, 2024
66412bd
Fix french translations after review of @usku01
jflabatBCSS Nov 29, 2024
a8103be
Merge branch 'main' into feature/#124-i18n
jpraet Dec 6, 2024
d0d46b4
Minor message tweaks
jpraet Dec 8, 2024
28ab016
Merge branch 'main' into feature/#124-i18n
jpraet Dec 9, 2024
6028ec9
Fix translation of zeroOrExactlyOneOfExpected.detail
jflabatBCSS Dec 10, 2024
58c298f
Merge branch 'main' into feature/#124-i18n
jpraet Dec 11, 2024
0bee5cc
Add check from missing keys in I18N bundle
jpraet Dec 14, 2024
238ceb7
Use Locale.ENGLISH
jpraet Dec 14, 2024
2a483e1
Extract I18NConfigurator from I18NAcceptLanguageFilter
jpraet Dec 14, 2024
f2a0a56
Disable I18N tests on Quarkus (not working)
jpraet Dec 14, 2024
7d53e5c
Add i18nDisabled test
jpraet Dec 14, 2024
5cd75d6
Move system property / environment property configuration to belgif-r…
jpraet Dec 14, 2024
3cd8f4c
Add test for weighted Accept-Language header
jpraet Dec 14, 2024
c77f154
Document LocaleResolver
jpraet Dec 15, 2024
7587a79
Support I18N on Quarkus
jpraet Dec 16, 2024
cf9795a
Add integration tests for unsupported language
jpraet Dec 16, 2024
8367304
Add integration test for disabled I18N
jpraet Dec 16, 2024
a7e75e4
Add InputValidationIssue.localizedDetail()
jpraet Dec 16, 2024
16543b8
Merge branch 'main' into feature/#124-i18n
jpraet Dec 17, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import io.github.belgif.rest.problem.api.ClientProblem;
import io.github.belgif.rest.problem.api.ProblemType;
import io.github.belgif.rest.problem.i18n.I18N;

@ProblemType(CustomProblem.TYPE)
public class CustomProblem extends ClientProblem {
Expand All @@ -24,6 +25,7 @@ public class CustomProblem extends ClientProblem {
@JsonCreator
public CustomProblem(@JsonProperty("customField") String customField) {
super(TYPE_URI, TITLE, STATUS);
setDetail(I18N.getLocalizedDetail(CustomProblem.class));
this.customField = customField;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ void constraintViolationMissingRequiredQueryParameter() {
.get("/beanValidation/queryParameter").then().assertThat()
.statusCode(400)
.body("type", equalTo("urn:problem-type:belgif:badRequest"))
.body("detail", equalTo("The input message is incorrect"))
.body("issues[0].type", equalTo("urn:problem-type:belgif:input-validation:schemaViolation"))
.body("issues[0].title", equalTo("Input value is invalid with respect to the schema"))
.body("issues[0].in", equalTo("query"))
Expand Down Expand Up @@ -331,4 +332,39 @@ void constraintViolationBodyInheritance() {
.body("issues[1].detail", equalTo("must not be blank"));
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We never test localization can be disabled, would it be easy to add a test for that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we could expose an endpoint that calls I18N.setEnabled(false).

@Test
void i18n() {
getSpec().when()
.header("Accept-Language", "nl-BE")
Copy link
Collaborator

@clone1612 clone1612 Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be interesting to add an additional test that verifies behavior with multiple values that use weighting

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test added. Note that we just take the one with the highest weight though, and if that one is not supported, it will fallback to English. So with Accept-Language = "fr-BE;q=0.5, es;q=0.7" the resulting language will be English, not French.

.queryParam("param", -1)
.queryParam("other", "TOO_LONG")
.get("/beanValidation/queryParameter").then().assertThat()
.statusCode(400)
.body("type", equalTo("urn:problem-type:belgif:badRequest"))
.body("detail", equalTo("Het input bericht is ongeldig"))
.body("issues[0].type", equalTo("urn:problem-type:belgif:input-validation:schemaViolation"))
.body("issues[0].title", equalTo("Input value is invalid with respect to the schema"))
.body("issues[0].detail", equalTo("grootte moet tussen 0 en 5 liggen"))
.body("issues[0].in", equalTo("query"))
.body("issues[0].name", equalTo("other"))
.body("issues[0].value", equalTo("TOO_LONG"))
.body("issues[1].type", equalTo("urn:problem-type:belgif:input-validation:schemaViolation"))
.body("issues[1].title", equalTo("Input value is invalid with respect to the schema"))
.body("issues[1].detail", equalTo("moet groter dan 0 zijn"))
.body("issues[1].in", equalTo("query"))
.body("issues[1].name", equalTo("param"))
.body("issues[1].value", equalTo(-1));
}

@Test
void i18nCustom() {
getSpec().when()
.header("Accept-Language", "nl-BE")
.get("/custom").then().assertThat()
.statusCode(409)
.body("type", equalTo("urn:problem-type:acme:custom"))
.body("customField", equalTo("value from frontend"))
.body("detail", equalTo("NL detail"));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CustomProblem.detail=Custom detail
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CustomProblem.detail=DE detail
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CustomProblem.detail=FR detail
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CustomProblem.detail=NL detail
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.github.belgif.rest.problem.jaxrs.i18n;

import javax.annotation.PostConstruct;
import javax.servlet.ServletContext;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.ContainerResponseContext;
import javax.ws.rs.container.ContainerResponseFilter;
import javax.ws.rs.container.PreMatching;
import javax.ws.rs.core.Context;
import javax.ws.rs.ext.Provider;

import io.github.belgif.rest.problem.i18n.I18N;

/**
* Filter that registers the requested locale, as specified in Accept-Language HTTP header,
* with the {@link ThreadLocalLocaleResolver} (and clears it afterward).
*/
@PreMatching
@Provider
public class I18NAcceptLanguageFilter implements ContainerRequestFilter, ContainerResponseFilter {

@Context
private ServletContext servletContext;

@PostConstruct
public void initialize() {
if (servletContext.getInitParameter(I18N.I18N_FLAG) != null) {
jpraet marked this conversation as resolved.
Show resolved Hide resolved
I18N.setEnabled(Boolean.parseBoolean(servletContext.getInitParameter(I18N.I18N_FLAG)));
}
}

@Override
public void filter(ContainerRequestContext requestContext) {
if (I18N.isEnabled() && !requestContext.getAcceptableLanguages().isEmpty()) {
ThreadLocalLocaleResolver.setLocale(requestContext.getAcceptableLanguages().get(0));
}
}

@Override
public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) {
if (I18N.isEnabled()) {
ThreadLocalLocaleResolver.clear();
}
}

protected void setServletContext(ServletContext servletContext) {
jpraet marked this conversation as resolved.
Show resolved Hide resolved
this.servletContext = servletContext;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package io.github.belgif.rest.problem.jaxrs.i18n;

import java.util.Locale;

import io.github.belgif.rest.problem.i18n.I18N;
import io.github.belgif.rest.problem.i18n.LocaleResolver;

/**
* LocaleResolver implementation that uses a ThreadLocal.
*/
public class ThreadLocalLocaleResolver implements LocaleResolver {

/**
* ThreadLocal with the locale of the current request (defaults to English).
*/
private static final ThreadLocal<Locale> LOCALE = new InheritableThreadLocal<Locale>() {
@Override
protected Locale initialValue() {
return I18N.DEFAULT_LOCALE;
}
};

@Override
public Locale getLocale() {
return LOCALE.get();
}

/**
* Set the locale of the current request.
*
* <p>
* NOTE: The locale is stored in a ThreadLocal that must be cleaned up with {@link #clear()}.
* </p>
*
* @param locale the locale of the current request.
*/
public static void setLocale(Locale locale) {
LOCALE.set(locale);
}

/**
* Clear the locale of the current request.
*/
public static void clear() {
LOCALE.remove();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
io.github.belgif.rest.problem.jaxrs.i18n.ThreadLocalLocaleResolver
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package io.github.belgif.rest.problem.jaxrs.i18n;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

import java.util.Collections;
import java.util.Locale;

import javax.servlet.ServletContext;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerResponseContext;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import io.github.belgif.rest.problem.i18n.I18N;

@ExtendWith(MockitoExtension.class)
class I18NAcceptLanguageFilterTest {

@InjectMocks
private I18NAcceptLanguageFilter filter;

@Mock
private ContainerRequestContext requestContext;

@Mock
private ContainerResponseContext responseContext;

@Mock
private ServletContext servletContext;

@AfterEach
void cleanup() {
ThreadLocalLocaleResolver.clear();
I18N.setEnabled(true);
}

@Test
void languageRequested() {
when(requestContext.getAcceptableLanguages())
.thenReturn(Collections.singletonList(Locale.forLanguageTag("nl-BE")));
filter.filter(requestContext);
assertThat(I18N.getRequestLocale()).isEqualTo(Locale.forLanguageTag("nl-BE"));
}

@Test
void noLanguageRequested() {
when(requestContext.getAcceptableLanguages()).thenReturn(Collections.emptyList());
filter.filter(requestContext);
assertThat(I18N.getRequestLocale()).isEqualTo(Locale.forLanguageTag("en"));
}

@Test
void clearLocaleAfterResponse() {
ThreadLocalLocaleResolver.setLocale(Locale.forLanguageTag("nl-BE"));
filter.filter(requestContext, responseContext);
assertThat(I18N.getRequestLocale()).isEqualTo(Locale.forLanguageTag("en"));
}

@Test
void disabledViaInitParam() {
when(servletContext.getInitParameter(I18N.I18N_FLAG)).thenReturn("false");
filter.initialize();
filter.filter(requestContext);
jpraet marked this conversation as resolved.
Show resolved Hide resolved
verifyNoInteractions(requestContext);
}

jpraet marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.github.belgif.rest.problem.jaxrs.i18n;

import static org.assertj.core.api.Assertions.*;

import java.util.Locale;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

class ThreadLocalLocaleResolverTest {

private final ThreadLocalLocaleResolver resolver = new ThreadLocalLocaleResolver();

@AfterEach
void cleanup() {
ThreadLocalLocaleResolver.clear();
}

@Test
void defaultLocale() {
assertThat(resolver.getLocale()).isEqualTo(Locale.forLanguageTag("en"));
}

@Test
void setAndGetLocale() {
Locale locale = Locale.forLanguageTag("nl-BE");
ThreadLocalLocaleResolver.setLocale(locale);
assertThat(resolver.getLocale()).isEqualTo(locale);
}

@Test
void clear() {
Locale locale = Locale.forLanguageTag("nl-BE");
ThreadLocalLocaleResolver.setLocale(locale);
ThreadLocalLocaleResolver.clear();
assertThat(resolver.getLocale()).isEqualTo(Locale.forLanguageTag("en"));
}

}
2 changes: 1 addition & 1 deletion belgif-rest-problem-spring/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<artifactId>spring-webmvc</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public class ProblemConfigurationProperties {

private List<String> scanAdditionalProblemPackages = new ArrayList<>();

private boolean i18n = true;

public void setScanAdditionalProblemPackages(List<String> scanAdditionalProblemPackages) {
this.scanAdditionalProblemPackages = scanAdditionalProblemPackages;
}
Expand All @@ -21,4 +23,12 @@ public List<String> getScanAdditionalProblemPackages() {
return scanAdditionalProblemPackages;
}

public void setI18n(boolean i18n) {
this.i18n = i18n;
}

public boolean isI18n() {
return i18n;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.github.belgif.rest.problem.spring.i18n;

import org.springframework.stereotype.Component;

import io.github.belgif.rest.problem.i18n.I18N;
import io.github.belgif.rest.problem.spring.ProblemConfigurationProperties;

/**
* Enables or disables problem I18N support based on configuration property.
*/
@Component
public class I18NConfigurator {

public I18NConfigurator(ProblemConfigurationProperties configuration) {
I18N.setEnabled(configuration.isI18n());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.github.belgif.rest.problem.spring.i18n;

import java.util.Locale;

import org.springframework.context.i18n.LocaleContextHolder;

import io.github.belgif.rest.problem.i18n.LocaleResolver;

/**
* Spring LocaleResolver implementation that delegates to {@link LocaleContextHolder#getLocale()}.
*/
public class LocaleContextHolderLocaleResolver implements LocaleResolver {

@Override
public Locale getLocale() {
return LocaleContextHolder.getLocale();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
io.github.belgif.rest.problem.spring.i18n.LocaleContextHolderLocaleResolver
Loading