This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new 4c94c4f89 WW-5549 Fix I18nInterceptor supportedLocale breaking
request_locale (#1594)
4c94c4f89 is described below
commit 4c94c4f89a15b3102c3822dfc64dca15ee42a731
Author: Lukasz Lenart <[email protected]>
AuthorDate: Fri Mar 6 07:50:06 2026 +0100
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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
Co-authored-by: Cursor <[email protected]>
---
.../struts2/interceptor/I18nInterceptor.java | 274 +++++++++------------
.../interceptor/i18n/AbstractLocaleHandler.java | 49 ++++
.../i18n/AbstractStoredLocaleHandler.java | 81 ++++++
.../i18n/AcceptLanguageLocaleHandler.java | 78 ++++++
.../interceptor/i18n/CookieLocaleHandler.java | 78 ++++++
.../struts2/interceptor/i18n/LocaleHandler.java | 63 +++++
.../interceptor/i18n/RequestLocaleHandler.java | 88 +++++++
.../interceptor/i18n/SessionLocaleHandler.java | 90 +++++++
.../struts2/interceptor/I18nInterceptorTest.java | 168 +++++++++++--
...9-i18n-supportedlocale-breaks-request-locale.md | 152 ++++++++++++
10 files changed, 947 insertions(+), 174 deletions(-)
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/I18nInterceptor.java
b/core/src/main/java/org/apache/struts2/interceptor/I18nInterceptor.java
index d0837a95b..204780ca9 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/I18nInterceptor.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/I18nInterceptor.java
@@ -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;
+ protected
LocaleHandlerAdapter(org.apache.struts2.interceptor.i18n.LocaleHandler
delegate) {
+ this.delegate = delegate;
}
+ @Override
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;
+ return delegate.find();
}
@Override
- public Locale store(ActionInvocation invocation, Locale locale) {
- return locale;
+ public Locale read(ActionInvocation invocation) {
+ return delegate.read(invocation);
}
@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;
+ 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 {
-
- protected AcceptLanguageLocaleHandler(ActionInvocation invocation) {
- super(invocation);
- }
+ 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
- @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;
- }
- }
+ @Override
+ protected Parameter findLocaleParameter(ActionInvocation inv,
String paramName) {
+ return I18nInterceptor.this.findLocaleParameter(inv,
paramName);
}
- return super.find();
- }
+ @Override
+ protected boolean isLocaleSupported(Locale locale) {
+ return I18nInterceptor.this.isLocaleSupported(locale);
+ }
+ };
}
- protected class SessionLocaleHandler extends AcceptLanguageLocaleHandler {
-
- 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;
+ 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);
}
- LOG.debug("Searching locale in request under parameter {}",
parameterName);
- Parameter requestedLocale = findLocaleParameter(actionInvocation,
parameterName);
- if (requestedLocale.isDefined()) {
- return getLocaleFromParam(requestedLocale.getValue());
+ @Override
+ protected Parameter findLocaleParameter(ActionInvocation inv,
String paramName) {
+ return I18nInterceptor.this.findLocaleParameter(inv,
paramName);
}
- 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);
- }
+ @Override
+ protected boolean isLocaleSupported(Locale locale) {
+ return I18nInterceptor.this.isLocaleSupported(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);
- }
- }
+ 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);
}
- 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);
+ @Override
+ protected Parameter findLocaleParameter(ActionInvocation inv,
String paramName) {
+ return I18nInterceptor.this.findLocaleParameter(inv,
paramName);
}
- return locale;
- }
+ @Override
+ protected boolean isLocaleSupported(Locale locale) {
+ return I18nInterceptor.this.isLocaleSupported(locale);
+ }
+ };
}
- protected class CookieLocaleHandler extends AcceptLanguageLocaleHandler {
- protected CookieLocaleHandler(ActionInvocation invocation) {
- super(invocation);
- }
-
- @Override
- public Locale find() {
- Locale requestOnlySessionLocale = super.find();
+ 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);
+ }
- if (requestOnlySessionLocale != null) {
- shouldStore = false;
- return requestOnlySessionLocale;
+ @Override
+ protected Parameter findLocaleParameter(ActionInvocation inv,
String paramName) {
+ return I18nInterceptor.this.findLocaleParameter(inv,
paramName);
}
- LOG.debug("Searching locale in request under parameter {}",
requestCookieParameterName);
- Parameter requestedLocale = findLocaleParameter(actionInvocation,
requestCookieParameterName);
- if (requestedLocale.isDefined()) {
- return getLocaleFromParam(requestedLocale.getValue());
+ @Override
+ protected boolean isLocaleSupported(Locale locale) {
+ return I18nInterceptor.this.isLocaleSupported(locale);
}
+ };
+ }
- return null;
+ /**
+ * @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));
}
+ }
- @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;
+ /**
+ * @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(createAcceptLanguageDelegate(invocation));
}
+ }
- @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());
- }
- }
- }
+ /**
+ * @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(createSessionDelegate(invocation));
+ }
+ }
- 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;
+ /**
+ * @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(createCookieDelegate(invocation));
}
}
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractLocaleHandler.java
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractLocaleHandler.java
new file mode 100644
index 000000000..935081663
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractLocaleHandler.java
@@ -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);
+}
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractStoredLocaleHandler.java
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractStoredLocaleHandler.java
new file mode 100644
index 000000000..659a2fb6e
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AbstractStoredLocaleHandler.java
@@ -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;
+ }
+ }
+}
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/i18n/AcceptLanguageLocaleHandler.java
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AcceptLanguageLocaleHandler.java
new file mode 100644
index 000000000..6ca229987
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/interceptor/i18n/AcceptLanguageLocaleHandler.java
@@ -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;
+ }
+}
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/i18n/CookieLocaleHandler.java
b/core/src/main/java/org/apache/struts2/interceptor/i18n/CookieLocaleHandler.java
new file mode 100644
index 000000000..7c0efc0de
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/interceptor/i18n/CookieLocaleHandler.java
@@ -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);
+ }
+}
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/i18n/LocaleHandler.java
b/core/src/main/java/org/apache/struts2/interceptor/i18n/LocaleHandler.java
new file mode 100644
index 000000000..78476dc78
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/interceptor/i18n/LocaleHandler.java
@@ -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();
+}
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/i18n/RequestLocaleHandler.java
b/core/src/main/java/org/apache/struts2/interceptor/i18n/RequestLocaleHandler.java
new file mode 100644
index 000000000..b421c8c26
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/interceptor/i18n/RequestLocaleHandler.java
@@ -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;
+ }
+}
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/i18n/SessionLocaleHandler.java
b/core/src/main/java/org/apache/struts2/interceptor/i18n/SessionLocaleHandler.java
new file mode 100644
index 000000000..44acc17f2
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/interceptor/i18n/SessionLocaleHandler.java
@@ -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);
+ }
+}
diff --git
a/core/src/test/java/org/apache/struts2/interceptor/I18nInterceptorTest.java
b/core/src/test/java/org/apache/struts2/interceptor/I18nInterceptorTest.java
index 2617d9f61..243bf5e51 100644
--- a/core/src/test/java/org/apache/struts2/interceptor/I18nInterceptorTest.java
+++ b/core/src/test/java/org/apache/struts2/interceptor/I18nInterceptorTest.java
@@ -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());
}
}
diff --git
a/thoughts/shared/research/2026-02-22-WW-5549-i18n-supportedlocale-breaks-request-locale.md
b/thoughts/shared/research/2026-02-22-WW-5549-i18n-supportedlocale-breaks-request-locale.md
new file mode 100644
index 000000000..3125ec72b
--- /dev/null
+++
b/thoughts/shared/research/2026-02-22-WW-5549-i18n-supportedlocale-breaks-request-locale.md
@@ -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`?
\ No newline at end of file