WW-5549 Fix I18nInterceptor supportedLocale breaking request_locale (#1594)

* fix(i18n): ensure request_locale takes precedence over Accept-Language when supportedLocale is configured

When supportedLocale was configured on the I18nInterceptor, the Accept-Language
header match in AcceptLanguageLocaleHandler.find() returned early before
SessionLocaleHandler/CookieLocaleHandler ever checked their explicit locale
parameters (request_locale, request_cookie_locale). This made it impossible
to switch locale via request parameters when supportedLocale was set.

Changes:
- Reorder AcceptLanguageLocaleHandler.find() to check request_only_locale
  before Accept-Language matching
- Reorder SessionLocaleHandler.find() to check request_locale before super
- Reorder CookieLocaleHandler.find() to check request_cookie_locale before super
- Add isLocaleSupported() helper to validate locales against supportedLocale
- Filter all locale sources (params, session, cookies) through supportedLocale
- Add 4 tests covering the bug scenario and supportedLocale filtering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* test(i18n): cover missing supportedLocale locale-selection paths

Add regression tests for unsupported request_cookie_locale fallback, stored cookie revalidation, and request_only_locale precedence to lock in WW-5549 behavior across remaining branches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(i18n): extract locale handlers with deprecated inner wrappers

Move locale handler implementations into a dedicated interceptor.i18n package with reusable abstract bases, keep thin deprecated inner wrappers in I18nInterceptor for one release-cycle compatibility, and document the LocaleHandler contract.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(i18n): validate request_only_locale against supportedLocale and fix Accept-Language fallback

RequestLocaleHandler.find() now checks isLocaleSupported() before
returning, preventing unsupported locales from slipping through via
the request_only_locale parameter. AcceptLanguageLocaleHandler.find()
now returns the first Accept-Language locale when supportedLocale is
empty, fixing ACCEPT_LANGUAGE storage mode with no filter configured.

Also includes refactoring: deprecated inner classes collapsed with
LocaleHandlerAdapter, shouldStore field encapsulated via disableStore(),
logger pattern standardized to private static final, and class-level
JavaDoc added to handler classes.

Made-with: Cursor

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lukasz Lenart
2026-03-06 07:50:06 +01:00
committed by GitHub
parent 507c64e49a
commit 4c94c4f89a
10 changed files with 966 additions and 193 deletions
@@ -26,18 +26,11 @@ import org.apache.struts2.util.TextParseUtil;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.message.ParameterizedMessage;
import org.apache.struts2.ServletActionContext;
import org.apache.struts2.dispatcher.HttpParameters;
import org.apache.struts2.dispatcher.Parameter;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@@ -108,6 +101,10 @@ public class I18nInterceptor extends AbstractInterceptor {
.collect(Collectors.toSet());
}
protected boolean isLocaleSupported(Locale locale) {
return supportedLocale.isEmpty() || supportedLocale.contains(locale);
}
@Inject
public void setLocaleProviderFactory(LocaleProviderFactory localeProviderFactory) {
this.localeProviderFactory = localeProviderFactory;
@@ -219,202 +216,167 @@ public class I18nInterceptor extends AbstractInterceptor {
/**
* Uses to handle reading/storing Locale from/in different locations
*/
protected interface LocaleHandler {
Locale find();
Locale read(ActionInvocation invocation);
Locale store(ActionInvocation invocation, Locale locale);
boolean shouldStore();
@Deprecated(forRemoval = true, since = "7.2.0")
protected interface LocaleHandler extends org.apache.struts2.interceptor.i18n.LocaleHandler {
}
protected class RequestLocaleHandler implements LocaleHandler {
/**
* @deprecated Since 7.2.0, use the top-level handler classes in {@code org.apache.struts2.interceptor.i18n}.
* Scheduled for removal in the next release cycle.
*/
@Deprecated(forRemoval = true, since = "7.2.0")
protected abstract class LocaleHandlerAdapter implements LocaleHandler {
protected ActionInvocation actionInvocation;
protected boolean shouldStore = true;
private final org.apache.struts2.interceptor.i18n.LocaleHandler delegate;
protected RequestLocaleHandler(ActionInvocation invocation) {
actionInvocation = invocation;
}
public Locale find() {
LOG.debug("Searching locale in request under parameter {}", requestOnlyParameterName);
Parameter requestedLocale = findLocaleParameter(actionInvocation, requestOnlyParameterName);
if (requestedLocale.isDefined()) {
return getLocaleFromParam(requestedLocale.getValue());
}
return null;
protected LocaleHandlerAdapter(org.apache.struts2.interceptor.i18n.LocaleHandler delegate) {
this.delegate = delegate;
}
@Override
public Locale store(ActionInvocation invocation, Locale locale) {
return locale;
public Locale find() {
return delegate.find();
}
@Override
public Locale read(ActionInvocation invocation) {
LOG.debug("Searching current Invocation context");
// no overriding locale definition found, stay with current invocation (=browser) locale
Locale locale = invocation.getInvocationContext().getLocale();
if (locale != null) {
LOG.debug("Applied invocation context locale: {}", locale);
}
return locale;
return delegate.read(invocation);
}
@Override
public Locale store(ActionInvocation invocation, Locale locale) {
return delegate.store(invocation, locale);
}
@Override
public boolean shouldStore() {
return shouldStore;
return delegate.shouldStore();
}
}
protected class AcceptLanguageLocaleHandler extends RequestLocaleHandler {
private org.apache.struts2.interceptor.i18n.RequestLocaleHandler createRequestDelegate(ActionInvocation invocation, String requestOnlyParam) {
return new org.apache.struts2.interceptor.i18n.RequestLocaleHandler(invocation, requestOnlyParam) {
@Override
protected Locale getLocaleFromParam(String requestedLocale) {
return I18nInterceptor.this.getLocaleFromParam(requestedLocale);
}
@Override
protected Parameter findLocaleParameter(ActionInvocation inv, String paramName) {
return I18nInterceptor.this.findLocaleParameter(inv, paramName);
}
@Override
protected boolean isLocaleSupported(Locale locale) {
return I18nInterceptor.this.isLocaleSupported(locale);
}
};
}
private org.apache.struts2.interceptor.i18n.AcceptLanguageLocaleHandler createAcceptLanguageDelegate(ActionInvocation invocation) {
return new org.apache.struts2.interceptor.i18n.AcceptLanguageLocaleHandler(
invocation, requestOnlyParameterName, supportedLocale
) {
@Override
protected Locale getLocaleFromParam(String requestedLocale) {
return I18nInterceptor.this.getLocaleFromParam(requestedLocale);
}
@Override
protected Parameter findLocaleParameter(ActionInvocation inv, String paramName) {
return I18nInterceptor.this.findLocaleParameter(inv, paramName);
}
@Override
protected boolean isLocaleSupported(Locale locale) {
return I18nInterceptor.this.isLocaleSupported(locale);
}
};
}
private org.apache.struts2.interceptor.i18n.SessionLocaleHandler createSessionDelegate(ActionInvocation invocation) {
return new org.apache.struts2.interceptor.i18n.SessionLocaleHandler(
invocation, requestOnlyParameterName, supportedLocale, parameterName, attributeName
) {
@Override
protected Locale getLocaleFromParam(String requestedLocale) {
return I18nInterceptor.this.getLocaleFromParam(requestedLocale);
}
@Override
protected Parameter findLocaleParameter(ActionInvocation inv, String paramName) {
return I18nInterceptor.this.findLocaleParameter(inv, paramName);
}
@Override
protected boolean isLocaleSupported(Locale locale) {
return I18nInterceptor.this.isLocaleSupported(locale);
}
};
}
private org.apache.struts2.interceptor.i18n.CookieLocaleHandler createCookieDelegate(ActionInvocation invocation) {
return new org.apache.struts2.interceptor.i18n.CookieLocaleHandler(
invocation, requestOnlyParameterName, supportedLocale, requestCookieParameterName, attributeName
) {
@Override
protected Locale getLocaleFromParam(String requestedLocale) {
return I18nInterceptor.this.getLocaleFromParam(requestedLocale);
}
@Override
protected Parameter findLocaleParameter(ActionInvocation inv, String paramName) {
return I18nInterceptor.this.findLocaleParameter(inv, paramName);
}
@Override
protected boolean isLocaleSupported(Locale locale) {
return I18nInterceptor.this.isLocaleSupported(locale);
}
};
}
/**
* @deprecated Since 7.2.0, use {@link org.apache.struts2.interceptor.i18n.RequestLocaleHandler}.
* Scheduled for removal in the next release cycle.
*/
@Deprecated(forRemoval = true, since = "7.2.0")
protected class RequestLocaleHandler extends LocaleHandlerAdapter {
protected RequestLocaleHandler(ActionInvocation invocation) {
super(createRequestDelegate(invocation, requestOnlyParameterName));
}
}
/**
* @deprecated Since 7.2.0, use {@link org.apache.struts2.interceptor.i18n.AcceptLanguageLocaleHandler}.
* Scheduled for removal in the next release cycle.
*/
@Deprecated(forRemoval = true, since = "7.2.0")
protected class AcceptLanguageLocaleHandler extends LocaleHandlerAdapter {
protected AcceptLanguageLocaleHandler(ActionInvocation invocation) {
super(invocation);
super(createAcceptLanguageDelegate(invocation));
}
@Override
@SuppressWarnings("rawtypes")
public Locale find() {
if (!supportedLocale.isEmpty()) {
Enumeration locales = actionInvocation.getInvocationContext().getServletRequest().getLocales();
while (locales.hasMoreElements()) {
Locale locale = (Locale) locales.nextElement();
if (supportedLocale.contains(locale)) {
return locale;
}
}
}
return super.find();
}
}
protected class SessionLocaleHandler extends AcceptLanguageLocaleHandler {
/**
* @deprecated Since 7.2.0, use {@link org.apache.struts2.interceptor.i18n.SessionLocaleHandler}.
* Scheduled for removal in the next release cycle.
*/
@Deprecated(forRemoval = true, since = "7.2.0")
protected class SessionLocaleHandler extends LocaleHandlerAdapter {
protected SessionLocaleHandler(ActionInvocation invocation) {
super(invocation);
}
@Override
public Locale find() {
Locale requestOnlyLocale = super.find();
if (requestOnlyLocale != null) {
LOG.debug("Found locale under request only param, it won't be stored in session!");
shouldStore = false;
return requestOnlyLocale;
}
LOG.debug("Searching locale in request under parameter {}", parameterName);
Parameter requestedLocale = findLocaleParameter(actionInvocation, parameterName);
if (requestedLocale.isDefined()) {
return getLocaleFromParam(requestedLocale.getValue());
}
return null;
}
@Override
public Locale store(ActionInvocation invocation, Locale locale) {
Map<String, Object> session = invocation.getInvocationContext().getSession();
if (session != null) {
String sessionId = ServletActionContext.getRequest().getSession().getId();
synchronized (sessionId.intern()) {
session.put(attributeName, locale);
}
}
return locale;
}
@Override
public Locale read(ActionInvocation invocation) {
Locale locale = null;
LOG.debug("Checks session for saved locale");
HttpSession session = ServletActionContext.getRequest().getSession(false);
if (session != null) {
String sessionId = session.getId();
synchronized (sessionId.intern()) {
Object sessionLocale = invocation.getInvocationContext().getSession().get(attributeName);
if (sessionLocale instanceof Locale) {
locale = (Locale) sessionLocale;
LOG.debug("Applied session locale: {}", locale);
}
}
}
if (locale == null) {
LOG.debug("No Locale defined in session, fetching from current request and it won't be stored in session!");
shouldStore = false;
locale = super.read(invocation);
} else {
LOG.debug("Found stored Locale {} in session, using it!", locale);
}
return locale;
super(createSessionDelegate(invocation));
}
}
protected class CookieLocaleHandler extends AcceptLanguageLocaleHandler {
/**
* @deprecated Since 7.2.0, use {@link org.apache.struts2.interceptor.i18n.CookieLocaleHandler}.
* Scheduled for removal in the next release cycle.
*/
@Deprecated(forRemoval = true, since = "7.2.0")
protected class CookieLocaleHandler extends LocaleHandlerAdapter {
protected CookieLocaleHandler(ActionInvocation invocation) {
super(invocation);
}
@Override
public Locale find() {
Locale requestOnlySessionLocale = super.find();
if (requestOnlySessionLocale != null) {
shouldStore = false;
return requestOnlySessionLocale;
}
LOG.debug("Searching locale in request under parameter {}", requestCookieParameterName);
Parameter requestedLocale = findLocaleParameter(actionInvocation, requestCookieParameterName);
if (requestedLocale.isDefined()) {
return getLocaleFromParam(requestedLocale.getValue());
}
return null;
}
@Override
public Locale store(ActionInvocation invocation, Locale locale) {
HttpServletResponse response = ServletActionContext.getResponse();
Cookie cookie = new Cookie(attributeName, locale.toString());
cookie.setMaxAge(1209600); // two weeks
response.addCookie(cookie);
return locale;
}
@Override
public Locale read(ActionInvocation invocation) {
Locale locale = null;
Cookie[] cookies = ServletActionContext.getRequest().getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (attributeName.equals(cookie.getName())) {
locale = getLocaleFromParam(cookie.getValue());
}
}
}
if (locale == null) {
LOG.debug("No Locale defined in cookie, fetching from current request and it won't be stored!");
shouldStore = false;
locale = super.read(invocation);
} else {
LOG.debug("Found stored Locale {} in cookie, using it!", locale);
}
return locale;
super(createCookieDelegate(invocation));
}
}
@@ -0,0 +1,49 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
*
* http://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 org.apache.struts2.interceptor.i18n;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.dispatcher.Parameter;
import java.util.Locale;
public abstract class AbstractLocaleHandler implements LocaleHandler {
protected final ActionInvocation actionInvocation;
private boolean shouldStore = true;
protected AbstractLocaleHandler(ActionInvocation invocation) {
this.actionInvocation = invocation;
}
@Override
public boolean shouldStore() {
return shouldStore;
}
protected void disableStore() {
this.shouldStore = false;
}
protected abstract Locale getLocaleFromParam(String requestedLocale);
protected abstract Parameter findLocaleParameter(ActionInvocation invocation, String parameterName);
protected abstract boolean isLocaleSupported(Locale locale);
}
@@ -0,0 +1,81 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
*
* http://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 org.apache.struts2.interceptor.i18n;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.dispatcher.Parameter;
import java.util.Locale;
import java.util.Set;
public abstract class AbstractStoredLocaleHandler extends AcceptLanguageLocaleHandler {
private static final Logger LOG = LogManager.getLogger(AbstractStoredLocaleHandler.class);
private final String explicitParameterName;
protected AbstractStoredLocaleHandler(ActionInvocation invocation,
String requestOnlyParameterName,
Set<Locale> supportedLocale,
String explicitParameterName) {
super(invocation, requestOnlyParameterName, supportedLocale);
this.explicitParameterName = explicitParameterName;
}
protected Locale findExplicitLocale() {
LOG.debug("Searching locale in request under parameter {}", explicitParameterName);
Parameter requestedLocale = findLocaleParameter(actionInvocation, explicitParameterName);
if (requestedLocale.isDefined()) {
Locale locale = getLocaleFromParam(requestedLocale.getValue());
if (locale != null && isLocaleSupported(locale)) {
return locale;
}
LOG.debug("Requested locale {} is not supported, ignoring", requestedLocale.getValue());
}
return null;
}
protected Locale findRequestOnlyLocale() {
Locale requestOnlyLocale = findRequestOnlyParamLocale();
if (requestOnlyLocale != null) {
LOG.debug("Found locale under request only param, it won't be stored!");
disableStore();
return requestOnlyLocale;
}
return null;
}
protected Locale normalizeStoredLocale(Locale locale, ActionInvocation invocation) {
if (locale != null && !isLocaleSupported(locale)) {
LOG.debug("Stored locale {} is not in supportedLocale, ignoring", locale);
locale = null;
}
if (locale == null) {
LOG.debug("No Locale defined in storage, fetching from current request and it won't be stored!");
disableStore();
return super.read(invocation);
} else {
LOG.debug("Found stored Locale {}, using it!", locale);
return locale;
}
}
}
@@ -0,0 +1,78 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
*
* http://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 org.apache.struts2.interceptor.i18n;
import org.apache.struts2.ActionInvocation;
import java.util.Enumeration;
import java.util.Locale;
import java.util.Set;
/**
* Resolves locale by first checking the request-only parameter and then falling back
* to the browser's {@code Accept-Language} header.
* <p>
* When a {@code supportedLocale} set is configured, only Accept-Language values present
* in that set are accepted. When the set is empty (the default), the first locale
* advertised by the browser is returned as-is.
*
* @see RequestLocaleHandler
* @see AbstractStoredLocaleHandler
*/
public abstract class AcceptLanguageLocaleHandler extends RequestLocaleHandler {
private final Set<Locale> supportedLocale;
protected AcceptLanguageLocaleHandler(ActionInvocation invocation, String requestOnlyParameterName, Set<Locale> supportedLocale) {
super(invocation, requestOnlyParameterName);
this.supportedLocale = supportedLocale;
}
@Override
public Locale find() {
Locale locale = findRequestOnlyParamLocale();
if (locale != null) {
return locale;
}
return findAcceptLanguageLocale();
}
@Override
public Locale read(ActionInvocation invocation) {
if (!supportedLocale.isEmpty()) {
Locale locale = findAcceptLanguageLocale();
if (locale != null) {
return locale;
}
}
return super.read(invocation);
}
@SuppressWarnings("rawtypes")
protected Locale findAcceptLanguageLocale() {
Enumeration locales = actionInvocation.getInvocationContext().getServletRequest().getLocales();
while (locales.hasMoreElements()) {
Locale acceptLocale = (Locale) locales.nextElement();
if (supportedLocale.isEmpty() || supportedLocale.contains(acceptLocale)) {
return acceptLocale;
}
}
return null;
}
}
@@ -0,0 +1,78 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
*
* http://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 org.apache.struts2.interceptor.i18n;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.ServletActionContext;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Locale;
import java.util.Set;
public abstract class CookieLocaleHandler extends AbstractStoredLocaleHandler {
private final String attributeName;
protected CookieLocaleHandler(ActionInvocation invocation,
String requestOnlyParameterName,
Set<Locale> supportedLocale,
String requestCookieParameterName,
String attributeName) {
super(invocation, requestOnlyParameterName, supportedLocale, requestCookieParameterName);
this.attributeName = attributeName;
}
@Override
public Locale find() {
Locale locale = findExplicitLocale();
if (locale != null) {
return locale;
}
return findRequestOnlyLocale();
}
@Override
public Locale store(ActionInvocation invocation, Locale locale) {
HttpServletResponse response = ServletActionContext.getResponse();
Cookie cookie = new Cookie(attributeName, locale.toString());
cookie.setMaxAge(1209600); // two weeks
response.addCookie(cookie);
return locale;
}
@Override
public Locale read(ActionInvocation invocation) {
Locale locale = null;
Cookie[] cookies = ServletActionContext.getRequest().getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (attributeName.equals(cookie.getName())) {
locale = getLocaleFromParam(cookie.getValue());
}
}
}
return normalizeStoredLocale(locale, invocation);
}
}
@@ -0,0 +1,63 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
*
* http://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 org.apache.struts2.interceptor.i18n;
import org.apache.struts2.ActionInvocation;
import java.util.Locale;
/**
* Strategy used by {@code I18nInterceptor} to resolve and optionally persist the current request locale.
* <p>
* Implementations encapsulate locale source-specific behavior (request parameters, session, cookies,
* or Accept-Language header), while the interceptor orchestrates the overall lifecycle.
*/
public interface LocaleHandler {
/**
* Looks for an explicit locale override in request-scoped sources.
*
* @return a locale override or {@code null} when no explicit override is present
*/
Locale find();
/**
* Reads locale from persistent/context sources when {@link #find()} did not resolve one.
*
* @param invocation current action invocation
* @return resolved locale or {@code null} when no locale could be resolved
*/
Locale read(ActionInvocation invocation);
/**
* Persists the resolved locale when storage is enabled for the current handler.
*
* @param invocation current action invocation
* @param locale locale to store
* @return the effective locale to apply to the invocation context
*/
Locale store(ActionInvocation invocation, Locale locale);
/**
* Indicates if the locale should be persisted for the current request.
*
* @return {@code true} when {@link #store(ActionInvocation, Locale)} should be invoked
*/
boolean shouldStore();
}
@@ -0,0 +1,88 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
*
* http://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 org.apache.struts2.interceptor.i18n;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.dispatcher.Parameter;
import java.util.Locale;
/**
* Resolves locale from a request-only parameter (not persisted to session or cookie).
* <p>
* When a matching request parameter is present and the locale is
* {@linkplain #isLocaleSupported(Locale) supported}, it is applied to the current
* request only; it is never stored for subsequent requests.
*
* @see AcceptLanguageLocaleHandler
* @see AbstractStoredLocaleHandler
*/
public abstract class RequestLocaleHandler extends AbstractLocaleHandler {
private static final Logger LOG = LogManager.getLogger(RequestLocaleHandler.class);
private final String requestOnlyParameterName;
protected RequestLocaleHandler(ActionInvocation invocation, String requestOnlyParameterName) {
super(invocation);
this.requestOnlyParameterName = requestOnlyParameterName;
}
@Override
public Locale find() {
return findRequestOnlyParamLocale();
}
/**
* Looks up the locale from the request-only parameter without any additional fallback.
* Subclasses that add fallback logic (e.g. Accept-Language) can override {@link #find()}
* while stored-locale handlers can call this method directly to skip the fallback.
*/
protected Locale findRequestOnlyParamLocale() {
LOG.debug("Searching locale in request under parameter {}", requestOnlyParameterName);
Parameter requestedLocale = findLocaleParameter(actionInvocation, requestOnlyParameterName);
if (requestedLocale.isDefined()) {
Locale locale = getLocaleFromParam(requestedLocale.getValue());
if (locale != null && isLocaleSupported(locale)) {
return locale;
}
LOG.debug("Requested locale {} is not supported, ignoring", requestedLocale.getValue());
}
return null;
}
@Override
public Locale store(ActionInvocation invocation, Locale locale) {
return locale;
}
@Override
public Locale read(ActionInvocation invocation) {
LOG.debug("Searching current Invocation context");
Locale locale = invocation.getInvocationContext().getLocale();
if (locale != null) {
LOG.debug("Applied invocation context locale: {}", locale);
}
return locale;
}
}
@@ -0,0 +1,90 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
*
* http://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 org.apache.struts2.interceptor.i18n;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.ServletActionContext;
import jakarta.servlet.http.HttpSession;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public abstract class SessionLocaleHandler extends AbstractStoredLocaleHandler {
private static final Logger LOG = LogManager.getLogger(SessionLocaleHandler.class);
private final String attributeName;
protected SessionLocaleHandler(ActionInvocation invocation,
String requestOnlyParameterName,
Set<Locale> supportedLocale,
String parameterName,
String attributeName) {
super(invocation, requestOnlyParameterName, supportedLocale, parameterName);
this.attributeName = attributeName;
}
@Override
public Locale find() {
Locale locale = findExplicitLocale();
if (locale != null) {
return locale;
}
return findRequestOnlyLocale();
}
@Override
public Locale store(ActionInvocation invocation, Locale locale) {
Map<String, Object> session = invocation.getInvocationContext().getSession();
if (session != null) {
String sessionId = ServletActionContext.getRequest().getSession().getId();
synchronized (sessionId.intern()) {
session.put(attributeName, locale);
}
}
return locale;
}
@Override
public Locale read(ActionInvocation invocation) {
Locale locale = null;
LOG.debug("Checks session for saved locale");
HttpSession session = ServletActionContext.getRequest().getSession(false);
if (session != null) {
String sessionId = session.getId();
synchronized (sessionId.intern()) {
Object sessionLocale = invocation.getInvocationContext().getSession().get(attributeName);
if (sessionLocale instanceof Locale) {
locale = (Locale) sessionLocale;
LOG.debug("Applied session locale: {}", locale);
}
}
}
return normalizeStoredLocale(locale, invocation);
}
}
@@ -35,6 +35,7 @@ import org.springframework.mock.web.MockHttpSession;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.io.Serializable;
import java.util.Arrays;
import java.util.HashMap;
@@ -100,7 +101,7 @@ public class I18nInterceptorTest extends TestCase {
assertFalse(mai.getInvocationContext().getParameters().get(I18nInterceptor.DEFAULT_PARAMETER).isDefined()); // should have been removed
Locale denmark = new Locale("da", "DK");
Locale denmark = new Locale.Builder().setLanguage("da").setRegion("DK").build();
assertNotNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // should be stored here
assertEquals(denmark, session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // should create a locale object
}
@@ -111,7 +112,7 @@ public class I18nInterceptorTest extends TestCase {
assertFalse(mai.getInvocationContext().getParameters().get(I18nInterceptor.DEFAULT_PARAMETER).isDefined()); // should have been removed
Locale denmark = new Locale("da", "DK");
Locale denmark = new Locale.Builder().setLanguage("da").setRegion("DK").build();
assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // should be stored here
assertEquals(denmark, mai.getInvocationContext().getLocale()); // should create a locale object
}
@@ -122,7 +123,7 @@ public class I18nInterceptorTest extends TestCase {
assertFalse(mai.getInvocationContext().getParameters().get(I18nInterceptor.DEFAULT_PARAMETER).isDefined()); // should have been removed
Locale denmark = new Locale("da");
Locale denmark = Locale.forLanguageTag("da");
assertNotNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // should be stored here
assertEquals(denmark, session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // should create a locale object
}
@@ -173,7 +174,7 @@ public class I18nInterceptorTest extends TestCase {
assertFalse(mai.getInvocationContext().getParameters().get(I18nInterceptor.DEFAULT_PARAMETER).isDefined()); // should have been removed
Locale variant = new Locale("ja", "JP", "JP");
Locale variant = Locale.forLanguageTag("ja-JP-x-lvariant-JP");
Locale locale = (Locale) session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE);
assertNotNull(locale); // should be stored here
assertEquals(variant, locale);
@@ -187,7 +188,7 @@ public class I18nInterceptorTest extends TestCase {
assertFalse(mai.getInvocationContext().getParameters().get(I18nInterceptor.DEFAULT_PARAMETER).isDefined()); // should have been removed
assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE));
Locale variant = new Locale("ja", "JP", "JP");
Locale variant = Locale.forLanguageTag("ja-JP-x-lvariant-JP");
Locale locale = mai.getInvocationContext().getLocale();
assertNotNull(locale); // should be stored here
assertEquals(variant, locale);
@@ -205,7 +206,7 @@ public class I18nInterceptorTest extends TestCase {
}
public void testRealLocalesInParams() throws Exception {
Locale[] locales = new Locale[] { Locale.CANADA_FRENCH };
Locale[] locales = new Locale[]{Locale.CANADA_FRENCH};
assertTrue(locales.getClass().isArray());
prepare(I18nInterceptor.DEFAULT_PARAMETER, locales);
interceptor.intercept(mai);
@@ -265,7 +266,7 @@ public class I18nInterceptorTest extends TestCase {
public void testAcceptLanguageBasedLocale() throws Exception {
// given
request.setPreferredLocales(Arrays.asList(new Locale("da_DK"), new Locale("pl")));
request.setPreferredLocales(Arrays.asList(Locale.forLanguageTag("da-DK"), Locale.forLanguageTag("pl")));
interceptor.setLocaleStorage(null);
interceptor.setSupportedLocale("en,pl");
@@ -275,12 +276,143 @@ public class I18nInterceptorTest extends TestCase {
// then
assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // should not be stored here
assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE)); // should not create a locale object
assertEquals(new Locale("pl"), mai.getInvocationContext().getLocale());
assertEquals(Locale.forLanguageTag("pl"), mai.getInvocationContext().getLocale());
}
public void testSupportedLocaleWithRequestLocale() throws Exception {
// given - supportedLocale configured + request_locale param with SESSION storage
request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
interceptor.setSupportedLocale("en,fr");
prepare(I18nInterceptor.DEFAULT_PARAMETER, "fr");
// when
interceptor.intercept(mai);
// then - request_locale wins over Accept-Language
assertEquals(Locale.FRENCH, session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE));
assertEquals(Locale.FRENCH, mai.getInvocationContext().getLocale());
}
public void testSupportedLocaleRejectsUnsupportedRequestLocale() throws Exception {
// given - request_locale=es but supportedLocale="en,fr"
request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
interceptor.setSupportedLocale("en,fr");
prepare(I18nInterceptor.DEFAULT_PARAMETER, "es");
// when
interceptor.intercept(mai);
// then - es rejected, falls back to Accept-Language match (en)
assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE));
assertEquals(Locale.ENGLISH, mai.getInvocationContext().getLocale());
}
public void testSupportedLocaleRevalidatesSessionLocale() throws Exception {
// given - session has stored locale "de" but supportedLocale changed to "en,fr"
session.put(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE, Locale.GERMAN);
request.setPreferredLocales(Arrays.asList(Locale.FRENCH));
interceptor.setSupportedLocale("en,fr");
// when
interceptor.intercept(mai);
// then - stored "de" rejected, falls back to Accept-Language match (fr)
assertEquals(Locale.FRENCH, mai.getInvocationContext().getLocale());
}
public void testSupportedLocaleWithCookieStorage() throws Exception {
// given - supportedLocale configured + request_cookie_locale param with COOKIE storage
prepare(I18nInterceptor.DEFAULT_COOKIE_PARAMETER, "fr");
request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
interceptor.setSupportedLocale("en,fr");
final Cookie cookie = new Cookie(I18nInterceptor.DEFAULT_COOKIE_ATTRIBUTE, "fr");
HttpServletResponse response = EasyMock.createMock(HttpServletResponse.class);
response.addCookie(CookieMatcher.eqCookie(cookie));
EasyMock.replay(response);
ac.put(StrutsStatics.HTTP_RESPONSE, response);
interceptor.setLocaleStorage(I18nInterceptor.Storage.COOKIE.name());
// when
interceptor.intercept(mai);
// then - request_cookie_locale=fr wins
EasyMock.verify(response);
assertEquals(Locale.FRENCH, mai.getInvocationContext().getLocale());
}
public void testSupportedLocaleRejectsUnsupportedRequestCookieLocale() throws Exception {
// given - request_cookie_locale=es but supportedLocale="en,fr"
prepare(I18nInterceptor.DEFAULT_COOKIE_PARAMETER, "es");
request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
interceptor.setSupportedLocale("en,fr");
HttpServletResponse response = EasyMock.createStrictMock(HttpServletResponse.class);
EasyMock.replay(response);
ac.put(StrutsStatics.HTTP_RESPONSE, response);
interceptor.setLocaleStorage(I18nInterceptor.Storage.COOKIE.name());
// when
interceptor.intercept(mai);
// then - unsupported request_cookie_locale ignored, falls back to Accept-Language match
EasyMock.verify(response);
assertEquals(Locale.ENGLISH, mai.getInvocationContext().getLocale());
}
public void testSupportedLocaleRevalidatesStoredCookieLocale() throws Exception {
// given - cookie has stored "de" but supportedLocale changed to "en,fr"
request.setCookies(new Cookie(I18nInterceptor.DEFAULT_COOKIE_ATTRIBUTE, "de"));
request.setPreferredLocales(Arrays.asList(Locale.ITALIAN));
interceptor.setSupportedLocale("en,fr");
HttpServletResponse response = EasyMock.createStrictMock(HttpServletResponse.class);
EasyMock.replay(response);
ac.put(StrutsStatics.HTTP_RESPONSE, response);
interceptor.setLocaleStorage(I18nInterceptor.Storage.COOKIE.name());
// when
interceptor.intercept(mai);
// then - stored "de" rejected and fallback locale from invocation context is used
EasyMock.verify(response);
assertEquals(Locale.US, mai.getInvocationContext().getLocale());
}
public void testRequestOnlyLocalePrecedenceWithSupportedLocale() throws Exception {
// given - request_only_locale should win over Accept-Language match
prepare(I18nInterceptor.DEFAULT_REQUEST_ONLY_PARAMETER, "fr");
request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
interceptor.setSupportedLocale("en,fr");
// when
interceptor.intercept(mai);
// then - request_only_locale applied and not persisted
assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE));
assertEquals(Locale.FRENCH, mai.getInvocationContext().getLocale());
}
public void testSupportedLocaleRejectsUnsupportedRequestOnlyLocale() throws Exception {
// given - request_only_locale=es but supportedLocale="en,fr"
prepare(I18nInterceptor.DEFAULT_REQUEST_ONLY_PARAMETER, "es");
request.setPreferredLocales(Arrays.asList(Locale.ENGLISH));
interceptor.setSupportedLocale("en,fr");
// when
interceptor.intercept(mai);
// then - es rejected, falls back to stored session locale / invocation context
assertNull(session.get(I18nInterceptor.DEFAULT_SESSION_ATTRIBUTE));
assertEquals(Locale.ENGLISH, mai.getInvocationContext().getLocale());
}
public void testAcceptLanguageBasedLocaleWithFallbackToDefault() throws Exception {
// given
request.setPreferredLocales(Arrays.asList(new Locale("da_DK"), new Locale("es")));
request.setPreferredLocales(Arrays.asList(Locale.forLanguageTag("da-DK"), Locale.forLanguageTag("es")));
interceptor.setLocaleStorage(null);
interceptor.setSupportedLocale("en,pl");
@@ -308,9 +440,9 @@ public class I18nInterceptorTest extends TestCase {
session = new HashMap<>();
ac = ActionContext.of()
.bind()
.withSession(session)
.withParameters(HttpParameters.create().build());
.bind()
.withSession(session)
.withParameters(HttpParameters.create().build());
request = new MockHttpServletRequest();
request.setSession(new MockHttpSession());
@@ -348,8 +480,8 @@ public class I18nInterceptorTest extends TestCase {
public boolean matches(Object argument) {
Cookie cookie = ((Cookie) argument);
return
(cookie.getName().equals(expected.getName()) &&
cookie.getValue().equals(expected.getValue()));
(cookie.getName().equals(expected.getName()) &&
cookie.getValue().equals(expected.getValue()));
}
public static Cookie eqCookie(Cookie ck) {
@@ -359,10 +491,10 @@ public class I18nInterceptorTest extends TestCase {
public void appendTo(StringBuffer buffer) {
buffer
.append("Received")
.append(expected.getName())
.append("/")
.append(expected.getValue());
.append("Received")
.append(expected.getName())
.append("/")
.append(expected.getValue());
}
}
@@ -0,0 +1,152 @@
---
date: 2026-02-22T12:00:00+01:00
topic: "I18nInterceptor supportedLocale disables request_locale parameter"
tags: [research, codebase, i18n, interceptor, locale, WW-5549]
status: complete
git_commit: a21c763d8a8592f1056086134414123f6d8d168d
---
# Research: WW-5549 - I18nInterceptor supportedLocale disables request_locale
**Date**: 2026-02-22
## Research Question
When `supportedLocale` is configured on the i18n interceptor, the `request_locale` parameter stops working if the browser's Accept-Language header matches a supported locale.
## Summary
The bug is in the class hierarchy of `LocaleHandler` implementations. `AcceptLanguageLocaleHandler.find()` returns early when it finds a match between the browser's Accept-Language header and the `supportedLocale` set. Since `SessionLocaleHandler` and `CookieLocaleHandler` both extend `AcceptLanguageLocaleHandler` and call `super.find()` first, the explicit `request_locale` parameter is never checked when the Accept-Language header matches a supported locale.
## Detailed Findings
### Class Hierarchy
```
LocaleHandler (interface)
└── RequestLocaleHandler (storage=REQUEST, checks request_only_locale)
└── AcceptLanguageLocaleHandler (storage=ACCEPT_LANGUAGE, checks Accept-Language header)
├── SessionLocaleHandler (storage=SESSION, checks request_locale + session)
└── CookieLocaleHandler (storage=COOKIE, checks request_cookie_locale + cookie)
```
### The Bug: AcceptLanguageLocaleHandler.find() — Line 279
```java
// I18nInterceptor.java:279-290
@Override
public Locale find() {
if (!supportedLocale.isEmpty()) {
Enumeration locales = actionInvocation.getInvocationContext().getServletRequest().getLocales();
while (locales.hasMoreElements()) {
Locale locale = (Locale) locales.nextElement();
if (supportedLocale.contains(locale)) {
return locale; // ← RETURNS HERE, never calls super.find()
}
}
}
return super.find(); // ← Only reached if supportedLocale is empty or no match
}
```
### SessionLocaleHandler.find() — Line 301
```java
// I18nInterceptor.java:300-317
@Override
public Locale find() {
Locale requestOnlyLocale = super.find(); // ← calls AcceptLanguageLocaleHandler.find()
if (requestOnlyLocale != null) {
LOG.debug("Found locale under request only param, it won't be stored in session!");
shouldStore = false; // ← prevents session storage
return requestOnlyLocale; // ← returns WITHOUT checking request_locale
}
// request_locale is only checked here, which is never reached when super.find() returns non-null
Parameter requestedLocale = findLocaleParameter(actionInvocation, parameterName);
if (requestedLocale.isDefined()) {
return getLocaleFromParam(requestedLocale.getValue());
}
return null;
}
```
### Concrete Bug Scenario
Configuration: `supportedLocale="fr,en"`, storage=SESSION (default)
1. User has French browser (`Accept-Language: fr,en`)
2. App defaults to French — correct
3. User clicks "English" link with `?request_locale=en`
4. `SessionLocaleHandler.find()` calls `super.find()``AcceptLanguageLocaleHandler.find()`
5. Accept-Language header yields `fr`, which IS in `supportedLocale`
6. Returns `fr` immediately — `request_locale=en` is **never checked**
7. `shouldStore = false` — so even if it were checked, it wouldn't persist
8. Locale stays French despite explicit user request to switch to English
### intercept() Flow
```java
// I18nInterceptor.java:117-144
LocaleHandler localeHandler = getLocaleHandler(invocation); // SessionLocaleHandler for default
Locale locale = localeHandler.find(); // BUG: returns Accept-Language match, skips request_locale
if (locale == null) {
locale = localeHandler.read(invocation); // never reached when find() returns non-null
}
if (localeHandler.shouldStore()) {
locale = localeHandler.store(invocation, locale); // shouldStore=false, skipped
}
useLocale(invocation, locale); // sets the wrong locale
```
### Root Cause
`SessionLocaleHandler.find()` was designed to call `super.find()` to check `request_only_locale` (a non-persistent locale override). It interprets any non-null result from `super.find()` as "a request-only locale was found." But `AcceptLanguageLocaleHandler.find()` conflates two different things:
1. A locale from `request_only_locale` parameter (legitimate non-persistent override)
2. A locale from Accept-Language header matching `supportedLocale` (ambient browser preference)
Both return non-null from `super.find()`, and `SessionLocaleHandler` cannot distinguish between them.
### The Same Bug Affects CookieLocaleHandler
`CookieLocaleHandler.find()` (line 369) has the identical pattern — calls `super.find()` and returns early if non-null, skipping `request_cookie_locale`.
## Code References
- [`I18nInterceptor.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/interceptor/I18nInterceptor.java) — Full interceptor
- Line 65: `supportedLocale` field declaration
- Line 103-109: `setSupportedLocale()` — parses comma-delimited string to `Set<Locale>`
- Line 117-144: `intercept()` — main flow
- Line 152-167: `getLocaleHandler()` — factory for handler selection
- Line 229-269: `RequestLocaleHandler` — base handler, checks `request_only_locale`
- Line 271-292: `AcceptLanguageLocaleHandler`**bug location** at line 279-290
- Line 294-361: `SessionLocaleHandler`**affected** at line 300-317
- Line 363-419: `CookieLocaleHandler`**also affected** at line 369-384
- [`I18nInterceptorTest.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/test/java/org/apache/struts2/interceptor/I18nInterceptorTest.java) — Test class
- Line 266: `testAcceptLanguageBasedLocale` — only tests ACCEPT_LANGUAGE storage mode
- Line 281: `testAcceptLanguageBasedLocaleWithFallbackToDefault` — fallback test
## Test Coverage Gaps
1. **No test** for `supportedLocale` + `request_locale` used simultaneously
2. **No test** for `supportedLocale` with SESSION storage mode (the default!)
3. **No test** for `supportedLocale` with COOKIE storage mode
4. Existing `supportedLocale` tests only use `ACCEPT_LANGUAGE` storage mode where the bug doesn't manifest (because `AcceptLanguageLocaleHandler` is used directly, not through `SessionLocaleHandler`)
## Fix Direction
The `request_locale` / `request_cookie_locale` parameter (explicit user choice) should always take precedence over the Accept-Language header (ambient browser preference). Options:
1. **Reorder in SessionLocaleHandler/CookieLocaleHandler**: Check `request_locale` **before** calling `super.find()`, so the explicit parameter always wins
2. **Reorder in AcceptLanguageLocaleHandler**: Check `super.find()` (request_only_locale) first, then fall back to Accept-Language matching — this would fix it for `AcceptLanguageLocaleHandler` itself but not for `SessionLocaleHandler`/`CookieLocaleHandler` which have their own `find()` override
3. **Restructure the hierarchy**: Separate Accept-Language matching from the `find()` chain so it doesn't interfere with explicit parameter checks
Option 1 is the most targeted fix with minimal risk.
## Open Questions
1. Should `supportedLocale` also validate/filter `request_locale` values? (e.g., reject `request_locale=es` if `supportedLocale="en,fr"`)
2. Should the session-stored locale also be validated against `supportedLocale` on subsequent requests?
3. The `Locale::new` constructor (line 107) is deprecated — should this be updated to use `Locale.forLanguageTag()` or `Locale.Builder`?