mirror of
https://github.com/apache/struts.git
synced 2026-08-05 14:47:09 +00:00
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:
@@ -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);
|
||||
}
|
||||
+81
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+152
@@ -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`?
|
||||
Reference in New Issue
Block a user