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

commit 4d061a464b6df4a0df49be847ff92ca602b25610
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Nov 2 07:56:03 2026 +0100

    WW-4164 feat(core): map declared extensions to their actions
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../dispatcher/mapper/DefaultActionMapper.java     |  80 ++++++++++++++-
 .../dispatcher/mapper/DefaultActionMapperTest.java | 114 +++++++++++++++++++++
 ...s-extension.xml => struts-extension-reload.xml} |  16 +--
 .../struts2/config/providers/struts-extension.xml  |   4 +
 4 files changed, 199 insertions(+), 15 deletions(-)

diff --git 
a/core/src/main/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java
 
b/core/src/main/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java
index ffdda8bea..b5a38921b 100644
--- 
a/core/src/main/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java
+++ 
b/core/src/main/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java
@@ -21,6 +21,7 @@ package org.apache.struts2.dispatcher.mapper;
 import org.apache.struts2.ActionContext;
 import org.apache.struts2.config.Configuration;
 import org.apache.struts2.config.ConfigurationManager;
+import org.apache.struts2.config.RuntimeConfiguration;
 import org.apache.struts2.config.entities.ActionConfig;
 import org.apache.struts2.config.entities.PackageConfig;
 import org.apache.struts2.inject.Container;
@@ -34,6 +35,7 @@ import org.apache.struts2.RequestUtils;
 import org.apache.struts2.StrutsConstants;
 import org.apache.struts2.util.PrefixTrie;
 
+import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -135,6 +137,8 @@ public class DefaultActionMapper implements ActionMapper {
 
     protected List<String> extensions = List.of("action", "");
 
+    private volatile DeclaredExtensions declaredExtensions;
+
     protected Container container;
 
     public DefaultActionMapper() {
@@ -249,6 +253,7 @@ public class DefaultActionMapper implements ActionMapper {
         } else {
             this.extensions = null;
         }
+        this.declaredExtensions = null;
     }
 
     @Override
@@ -275,7 +280,8 @@ public class DefaultActionMapper implements ActionMapper {
         int indexOfSemicolon = uri.indexOf(';');
         uri = (indexOfSemicolon > -1) ? uri.substring(0, indexOfSemicolon) : 
uri;
 
-        uri = dropExtension(uri, mapping);
+        String declaredUri = stripDeclaredExtension(uri, mapping, 
configManager);
+        uri = declaredUri != null ? declaredUri : dropExtension(uri, mapping);
         if (uri == null) {
             return null;
         }
@@ -468,6 +474,78 @@ public class DefaultActionMapper implements ActionMapper {
         mapping.setMethod(methodName);
     }
 
+    /**
+     * Strips an extension that only package or action {@code extension} 
attributes declare, but only when the
+     * remaining URI resolves to an action that itself declares extensions.
+     *
+     * @param uri           the request URI
+     * @param mapping       the action mapping to store the extension in
+     * @param configManager configuration manager
+     * @return the URI without the extension, or {@code null} when no 
declared-only extension claims it
+     * @since 7.5.0
+     */
+    protected String stripDeclaredExtension(String uri, ActionMapping mapping, 
ConfigurationManager configManager) {
+        RuntimeConfiguration runtimeConfiguration = 
configManager.getConfiguration().getRuntimeConfiguration();
+        if (runtimeConfiguration == null) {
+            return null;
+        }
+        for (String ext : declaredOnlyExtensions(runtimeConfiguration)) {
+            String suffix = "." + ext;
+            if (uri.endsWith(suffix)) {
+                String candidate = uri.substring(0, uri.length() - 
suffix.length());
+                if (declaresExtensions(candidate, runtimeConfiguration, 
configManager)) {
+                    mapping.setExtension(ext);
+                    return candidate;
+                }
+            }
+        }
+        return null;
+    }
+
+    private boolean declaresExtensions(String candidate, RuntimeConfiguration 
runtimeConfiguration, ConfigurationManager configManager) {
+        ActionMapping probe = new ActionMapping();
+        parseNameAndNamespace(candidate, probe, configManager);
+        String name = probe.getName();
+        int bang = name.indexOf('!');
+        String actionName = bang == -1 ? name : name.substring(0, bang);
+        ActionConfig config = 
runtimeConfiguration.getActionConfig(probe.getNamespace(), actionName);
+        return config != null && config.getExtensions() != null;
+    }
+
+    private List<String> declaredOnlyExtensions(RuntimeConfiguration 
runtimeConfiguration) {
+        DeclaredExtensions cached = declaredExtensions;
+        if (cached != null && cached.runtimeConfiguration == 
runtimeConfiguration) {
+            return cached.declaredOnly;
+        }
+        Set<String> declared = new HashSet<>();
+        for (Map<String, ActionConfig> actionConfigs : 
runtimeConfiguration.getActionConfigs().values()) {
+            for (ActionConfig actionConfig : actionConfigs.values()) {
+                if (actionConfig.getExtensions() != null) {
+                    declared.addAll(actionConfig.getExtensions());
+                }
+            }
+        }
+        declared.remove("");
+        if (extensions != null) {
+            extensions.forEach(declared::remove);
+        }
+        List<String> declaredOnly = declared.stream()
+            
.sorted(Comparator.comparingInt(String::length).reversed().thenComparing(Comparator.naturalOrder()))
+            .toList();
+        declaredExtensions = new DeclaredExtensions(runtimeConfiguration, 
declaredOnly);
+        return declaredOnly;
+    }
+
+    private static final class DeclaredExtensions {
+        private final RuntimeConfiguration runtimeConfiguration;
+        private final List<String> declaredOnly;
+
+        private DeclaredExtensions(RuntimeConfiguration runtimeConfiguration, 
List<String> declaredOnly) {
+            this.runtimeConfiguration = runtimeConfiguration;
+            this.declaredOnly = declaredOnly;
+        }
+    }
+
     /**
      * Drops the extension from the action name, storing it in the mapping for 
later use
      *
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapperTest.java
 
b/core/src/test/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapperTest.java
index a03319c3a..75683b488 100644
--- 
a/core/src/test/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapperTest.java
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapperTest.java
@@ -36,13 +36,19 @@ import org.apache.struts2.config.entities.ActionConfig;
 import org.apache.struts2.config.entities.PackageConfig;
 import org.apache.struts2.config.entities.ResultConfig;
 import org.apache.struts2.config.impl.DefaultConfiguration;
+import org.apache.struts2.config.StrutsXmlConfigurationProvider;
 import org.apache.struts2.inject.Container;
 
+import static org.assertj.core.api.Assertions.assertThat;
+
 /**
  * DefaultActionMapper test case.
  */
 public class DefaultActionMapperTest extends StrutsInternalTestCase {
 
+    private static final String EXTENSION_CONFIG = 
"org/apache/struts2/config/providers/struts-extension.xml";
+    private static final String EXTENSION_RELOAD_CONFIG = 
"org/apache/struts2/config/providers/struts-extension-reload.xml";
+
     private MockHttpServletRequest req;
     private ConfigurationManager configManager;
     private Configuration config;
@@ -997,4 +1003,112 @@ public class DefaultActionMapperTest extends 
StrutsInternalTestCase {
         assertEquals("error", result);
     }
 
+    public void testDeclaredExtensionClaimedForResolvingAction() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_CONFIG));
+
+        ActionMapping mapping = mapDeclared(declaredMapper(), 
"/child/inherits.html");
+
+        assertThat(mapping.getName()).isEqualTo("inherits");
+        assertThat(mapping.getNamespace()).isEqualTo("/child");
+        assertThat(mapping.getExtension()).isEqualTo("html");
+    }
+
+    public void testDeclaredExtensionNotClaimedForStaticFile() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_CONFIG));
+
+        assertThat(mapDeclared(declaredMapper(), "/index.html")).isNull();
+    }
+
+    public void testDeclaredOnlyExtensionNotClaimedForUndeclaredAction() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_CONFIG));
+
+        assertThat(mapDeclared(declaredMapper(), 
"/plain/undeclared.html")).isNull();
+    }
+
+    public void testDeclaredActionClaimsOtherDeclaredExtension() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_CONFIG));
+
+        ActionMapping mapping = mapDeclared(declaredMapper(), 
"/child/inherits.json");
+
+        assertThat(mapping.getName()).isEqualTo("inherits");
+        assertThat(mapping.getExtension()).isEqualTo("json");
+    }
+
+    public void testGlobalExtensionUnchanged() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_CONFIG));
+
+        ActionMapping mapping = mapDeclared(declaredMapper(), 
"/plain/undeclared.action");
+
+        assertThat(mapping.getName()).isEqualTo("undeclared");
+        assertThat(mapping.getExtension()).isEqualTo("action");
+    }
+
+    public void testDmiNameWithDeclaredExtension() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_CONFIG));
+        DefaultActionMapper mapper = declaredMapper();
+        mapper.setAllowDynamicMethodCalls("true");
+
+        ActionMapping mapping = mapDeclared(mapper, 
"/child/inherits!foo.html");
+
+        assertThat(mapping.getName()).isEqualTo("inherits");
+        assertThat(mapping.getMethod()).isEqualTo("foo");
+        assertThat(mapping.getExtension()).isEqualTo("html");
+    }
+
+    public void testNamespaceFallbackWithDeclaredExtension() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_CONFIG));
+        
assertThat(configurationManager.getConfiguration().getRuntimeConfiguration().getActionConfig("/other",
 "thing")).isNotNull();
+
+        ActionMapping mapping = mapDeclared(declaredMapper(), 
"/other/thing.html");
+
+        assertThat(mapping.getName()).isEqualTo("thing");
+        assertThat(mapping.getExtension()).isEqualTo("html");
+    }
+
+    public void testWildcardWithDeclaredExtension() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_CONFIG));
+
+        ActionMapping mapping = mapDeclared(declaredMapper(), 
"/wild/edit_user.html");
+
+        assertThat(mapping.getName()).isEqualTo("edit_user");
+        assertThat(mapping.getExtension()).isEqualTo("html");
+    }
+
+    public void testGlobalNullStillStripsDeclared() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_CONFIG));
+        DefaultActionMapper mapper = new DefaultActionMapper();
+        mapper.setExtensions("");
+
+        ActionMapping declared = mapDeclared(mapper, "/child/inherits.html");
+        assertThat(declared.getName()).isEqualTo("inherits");
+        assertThat(declared.getExtension()).isEqualTo("html");
+
+        ActionMapping literal = mapDeclared(mapper, "/plain/foo.bar");
+        assertThat(literal.getName()).isEqualTo("foo.bar");
+        assertThat(literal.getExtension()).isNull();
+    }
+
+    public void testCacheFollowsReload() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_CONFIG));
+        DefaultActionMapper mapper = declaredMapper();
+        assertThat(mapDeclared(mapper, "/child/inherits.html")).isNotNull();
+
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_RELOAD_CONFIG));
+
+        assertThat(mapDeclared(mapper, "/child/inherits.html")).isNull();
+        assertThat(mapDeclared(mapper, 
"/child/inherits.json").getExtension()).isEqualTo("json");
+    }
+
+    private DefaultActionMapper declaredMapper() {
+        DefaultActionMapper mapper = new DefaultActionMapper();
+        mapper.setExtensions("action,");
+        return mapper;
+    }
+
+    private ActionMapping mapDeclared(DefaultActionMapper mapper, String uri) {
+        req.setRequestURI(uri);
+        req.setServletPath(uri);
+        return mapper.getMapping(req, configurationManager);
+    }
+
 }
diff --git 
a/core/src/test/resources/org/apache/struts2/config/providers/struts-extension.xml
 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-extension-reload.xml
similarity index 65%
copy from 
core/src/test/resources/org/apache/struts2/config/providers/struts-extension.xml
copy to 
core/src/test/resources/org/apache/struts2/config/providers/struts-extension-reload.xml
index 15b3e7fd4..502dc8775 100644
--- 
a/core/src/test/resources/org/apache/struts2/config/providers/struts-extension.xml
+++ 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-extension-reload.xml
@@ -23,19 +23,7 @@
     "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
     "https://struts.apache.org/dtds/struts-7.5.dtd";>
 <struts>
-    <package name="base" abstract="true" extension="html"/>
-
-    <package name="child" extends="base" namespace="/child">
-        <action name="inherits" class="org.apache.struts2.TestAction"/>
-        <action name="own" class="org.apache.struts2.TestAction" 
extension="json,"/>
-    </package>
-
-    <package name="plain" namespace="/plain">
-        <action name="undeclared" class="org.apache.struts2.TestAction"/>
-        <action name="none" class="org.apache.struts2.TestAction" 
extension=""/>
-    </package>
-
-    <package name="wild" namespace="/wild">
-        <action name="edit_*" class="org.apache.struts2.TestAction" 
extension="html"/>
+    <package name="child" namespace="/child">
+        <action name="inherits" class="org.apache.struts2.TestAction" 
extension="json"/>
     </package>
 </struts>
diff --git 
a/core/src/test/resources/org/apache/struts2/config/providers/struts-extension.xml
 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-extension.xml
index 15b3e7fd4..2dd2fd1a3 100644
--- 
a/core/src/test/resources/org/apache/struts2/config/providers/struts-extension.xml
+++ 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-extension.xml
@@ -38,4 +38,8 @@
     <package name="wild" namespace="/wild">
         <action name="edit_*" class="org.apache.struts2.TestAction" 
extension="html"/>
     </package>
+
+    <package name="fallback" namespace="">
+        <action name="thing" class="org.apache.struts2.TestAction" 
extension="html"/>
+    </package>
 </struts>

Reply via email to