This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch WW-3476-scoped-constants
in repository https://gitbox.apache.org/repos/asf/struts.git

commit 617faf1b1607c0eb8cbf06ea4f23a16839310981
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sat Oct 10 17:14:56 2026 +0200

    WW-3476 feat(config): add ScopedConstantProvider resolving package overrides
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../java/org/apache/struts2/StrutsConstants.java   |   7 ++
 .../struts2/config/ScopedConstantProvider.java     |  35 ++++++
 .../config/StrutsBeanSelectionProvider.java        |   1 +
 .../config/StrutsScopedConstantProvider.java       |  79 +++++++++++++
 .../StrutsDefaultConfigurationProvider.java        |   4 +
 core/src/main/resources/struts-beans.xml           |   3 +
 .../config/StrutsScopedConstantProviderTest.java   | 129 +++++++++++++++++++++
 .../struts2/config/scoped-constants-provider.xml   |  46 ++++++++
 8 files changed, 304 insertions(+)

diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java 
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 3d3fef328..cfa3e30a7 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -628,6 +628,13 @@ public final class StrutsConstants {
      */
     public static final String STRUTS_PARAMETER_ALLOWLISTER = 
"struts.parameterAllowlister";
 
+    /**
+     * The {@link org.apache.struts2.config.ScopedConstantProvider} 
implementation class.
+     *
+     * @since 7.5.0
+     */
+    public static final String STRUTS_SCOPED_CONSTANT_PROVIDER = 
"struts.scopedConstantProvider";
+
     /**
      * Enables evaluation of OGNL expressions
      *
diff --git 
a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java 
b/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
new file mode 100644
index 000000000..53e655483
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
@@ -0,0 +1,35 @@
+/*
+ * 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.config;
+
+/**
+ * Resolves a scopable constant for the current action invocation: the 
package's {@code <scoped-constant>}, inherited
+ * through {@code extends}, or else the global constant.
+ *
+ * @since 7.5.0
+ */
+public interface ScopedConstantProvider {
+
+    /**
+     * @param name a constant registered through a {@link ScopableConstants} 
bean
+     * @return the scoped value, or the global value ({@code null} when there 
is none)
+     * @throws IllegalArgumentException when {@code name} is not scopable
+     */
+    String getValue(String name);
+}
diff --git 
a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java 
b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
index b0dda639b..8c141c6c7 100644
--- 
a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
@@ -454,6 +454,7 @@ public class StrutsBeanSelectionProvider extends 
AbstractBeanSelectionProvider {
         alias(ProxyService.class, StrutsConstants.STRUTS_PROXYSERVICE, 
builder, props, Scope.SINGLETON);
         alias(ParameterAuthorizer.class, 
StrutsConstants.STRUTS_PARAMETER_AUTHORIZER, builder, props, Scope.SINGLETON);
         alias(ParameterAllowlister.class, 
StrutsConstants.STRUTS_PARAMETER_ALLOWLISTER, builder, props, Scope.SINGLETON);
+        alias(ScopedConstantProvider.class, 
StrutsConstants.STRUTS_SCOPED_CONSTANT_PROVIDER, builder, props, 
Scope.SINGLETON);
 
         alias(SecurityMemberAccess.class, 
StrutsConstants.STRUTS_MEMBER_ACCESS, builder, props, Scope.PROTOTYPE);
         alias(OgnlGuard.class, StrutsConstants.STRUTS_OGNL_GUARD, builder, 
props, Scope.SINGLETON);
diff --git 
a/core/src/main/java/org/apache/struts2/config/StrutsScopedConstantProvider.java
 
b/core/src/main/java/org/apache/struts2/config/StrutsScopedConstantProvider.java
new file mode 100644
index 000000000..2bbb40e31
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/config/StrutsScopedConstantProvider.java
@@ -0,0 +1,79 @@
+/*
+ * 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.config;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.ActionProxy;
+import org.apache.struts2.config.entities.PackageConfig;
+import org.apache.struts2.inject.Container;
+import org.apache.struts2.inject.Inject;
+
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * @since 7.5.0
+ */
+public class StrutsScopedConstantProvider implements ScopedConstantProvider {
+
+    private Container container;
+    private Configuration configuration;
+    private volatile Set<String> scopableNames;
+
+    @Inject
+    public void setContainer(Container container) {
+        this.container = container;
+    }
+
+    @Inject
+    public void setConfiguration(Configuration configuration) {
+        this.configuration = configuration;
+    }
+
+    @Override
+    public String getValue(String name) {
+        if (!getScopableNames().contains(name)) {
+            throw new IllegalArgumentException(String.format(
+                    "Constant [%s] is not scopable; register it through a %s 
bean", name, ScopableConstants.class.getName()));
+        }
+        return currentPackage()
+                .map(packageConfig -> 
packageConfig.getAllScopedConstants().get(name))
+                .orElseGet(() -> container.getInstance(String.class, name));
+    }
+
+    private Optional<PackageConfig> currentPackage() {
+        ActionContext context = ActionContext.getContext();
+        ActionInvocation invocation = context != null ? 
context.getActionInvocation() : null;
+        ActionProxy proxy = invocation != null ? invocation.getProxy() : null;
+        if (proxy == null || proxy.getConfig() == null) {
+            return Optional.empty();
+        }
+        return 
Optional.ofNullable(configuration.getPackageConfig(proxy.getConfig().getPackageName()));
+    }
+
+    private Set<String> getScopableNames() {
+        Set<String> names = scopableNames;
+        if (names == null) {
+            names = ScopableConstants.collectNames(container);
+            scopableNames = names;
+        }
+        return names;
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java
 
b/core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java
index 40d757586..c66911bb9 100644
--- 
a/core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java
@@ -26,6 +26,8 @@ import org.apache.struts2.UnknownHandlerManager;
 import org.apache.struts2.config.Configuration;
 import org.apache.struts2.config.ConfigurationException;
 import org.apache.struts2.config.ConfigurationProvider;
+import org.apache.struts2.config.ScopedConstantProvider;
+import org.apache.struts2.config.StrutsScopedConstantProvider;
 import org.apache.struts2.config.impl.DefaultConfiguration;
 import org.apache.struts2.conversion.NullHandler;
 import org.apache.struts2.conversion.impl.ArrayConverter;
@@ -130,6 +132,8 @@ public class StrutsDefaultConfigurationProvider implements 
ConfigurationProvider
 
                 .factory(UnknownHandlerManager.class, 
DefaultUnknownHandlerManager.class, Scope.SINGLETON)
 
+                .factory(ScopedConstantProvider.class, 
StrutsScopedConstantProvider.class, Scope.SINGLETON)
+
                 // silly workarounds for ognl since there is no way to flush 
its caches
                 .factory(PropertyAccessor.class, List.class.getName(), 
XWorkListPropertyAccessor.class, Scope.SINGLETON)
                 .factory(PropertyAccessor.class, ArrayList.class.getName(), 
XWorkListPropertyAccessor.class, Scope.SINGLETON)
diff --git a/core/src/main/resources/struts-beans.xml 
b/core/src/main/resources/struts-beans.xml
index d12535b9c..81109bf06 100644
--- a/core/src/main/resources/struts-beans.xml
+++ b/core/src/main/resources/struts-beans.xml
@@ -258,6 +258,9 @@
     <bean type="org.apache.struts2.interceptor.parameter.ParameterAllowlister" 
name="struts"
           
class="org.apache.struts2.interceptor.parameter.OgnlParameterAllowlister" 
scope="singleton"/>
 
+    <bean type="org.apache.struts2.config.ScopedConstantProvider" name="struts"
+          class="org.apache.struts2.config.StrutsScopedConstantProvider" 
scope="singleton"/>
+
     <bean type="org.apache.struts2.url.QueryStringBuilder" 
name="strutsQueryStringBuilder"
           class="org.apache.struts2.url.StrutsQueryStringBuilder" 
scope="singleton"/>
     <bean type="org.apache.struts2.url.QueryStringParser" 
name="strutsQueryStringParser"
diff --git 
a/core/src/test/java/org/apache/struts2/config/StrutsScopedConstantProviderTest.java
 
b/core/src/test/java/org/apache/struts2/config/StrutsScopedConstantProviderTest.java
new file mode 100644
index 000000000..be3ccc53e
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/config/StrutsScopedConstantProviderTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.config;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.XWorkTestCase;
+import org.apache.struts2.config.entities.ActionConfig;
+import org.apache.struts2.mock.MockActionInvocation;
+import org.apache.struts2.mock.MockActionProxy;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class StrutsScopedConstantProviderTest extends XWorkTestCase {
+
+    private ScopedConstantProvider provider;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider("org/apache/struts2/config/scoped-constants-provider.xml"));
+        provider = container.getInstance(ScopedConstantProvider.class);
+    }
+
+    public void testDefaultProviderIsStrutsImplementation() {
+        assertTrue(provider instanceof StrutsScopedConstantProvider);
+    }
+
+    public void testPackageValueWinsInsideInvocation() {
+        enterPackage("override");
+
+        assertEquals("override", provider.getValue("sample.scopable"));
+    }
+
+    public void testInheritedValueResolvesForChildPackage() {
+        enterPackage("child");
+
+        assertEquals("base", provider.getValue("sample.scopable"));
+    }
+
+    public void testEmptyOverrideIsReturnedAsIs() {
+        enterPackage("empty");
+
+        assertEquals("", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWithoutOverride() {
+        enterPackage("plain");
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWithoutInvocation() {
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWithoutActionContext() {
+        ActionContext context = ActionContext.getContext();
+        ActionContext.clear();
+        try {
+            assertEquals("global", provider.getValue("sample.scopable"));
+        } finally {
+            context.bind();
+        }
+    }
+
+    public void testGlobalValueWhenInvocationHasNoProxy() {
+        ActionContext.getContext().withActionInvocation(new 
MockActionInvocation());
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWhenProxyHasNoConfig() {
+        MockActionInvocation invocation = new MockActionInvocation();
+        invocation.setProxy(new MockActionProxy());
+        ActionContext.getContext().withActionInvocation(invocation);
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWhenPackageIsUnknown() {
+        enterPackage("missing");
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testUnsetConstantWithoutOverrideIsNull() {
+        enterPackage("plain");
+
+        assertNull(provider.getValue("sample.unset"));
+    }
+
+    public void testUnregisteredNameThrows() {
+        assertThatThrownBy(() -> provider.getValue("sample.unknown"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("[sample.unknown]");
+    }
+
+    public void testGlobalConstantUntouchedByScopedRead() {
+        enterPackage("override");
+        provider.getValue("sample.scopable");
+
+        assertEquals("global", container.getInstance(String.class, 
"sample.scopable"));
+    }
+
+    private void enterPackage(String packageName) {
+        MockActionProxy proxy = new MockActionProxy();
+        proxy.setConfig(new ActionConfig.Builder(packageName, "test", 
ActionSupport.class.getName()).build());
+        MockActionInvocation invocation = new MockActionInvocation();
+        invocation.setProxy(proxy);
+        ActionContext.getContext().withActionInvocation(invocation);
+    }
+}
diff --git 
a/core/src/test/resources/org/apache/struts2/config/scoped-constants-provider.xml
 
b/core/src/test/resources/org/apache/struts2/config/scoped-constants-provider.xml
new file mode 100644
index 000000000..84a163ad6
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/scoped-constants-provider.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * 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.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+
+    <constant name="sample.scopable" value="global"/>
+
+    <package name="base" abstract="true">
+        <scoped-constant name="sample.scopable" value="base"/>
+    </package>
+
+    <package name="child" namespace="/child" extends="base"/>
+
+    <package name="override" namespace="/override" extends="base">
+        <scoped-constant name="sample.scopable" value="override"/>
+    </package>
+
+    <package name="empty" namespace="/empty" extends="base">
+        <scoped-constant name="sample.scopable" value=""/>
+    </package>
+
+    <package name="plain" namespace="/plain"/>
+</struts>

Reply via email to