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

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

commit 788d9280b517a228bd51a2b1319ee3acf9943d89
Author: Lukasz Lenart <[email protected]>
AuthorDate: Fri Oct 23 12:39:55 2026 +0200

    WW-5791 feat(boot): translate the action registry into Struts package 
configs
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 boot/pom.xml                                       |   5 +
 .../boot/RegistryConfigurationProvider.java        | 149 +++++++++++++++
 .../boot/RegistryConfigurationProviderTest.java    | 206 +++++++++++++++++++++
 .../apache/struts2/boot/fixture/UserAction.java    |  38 ++++
 boot/src/test/resources/struts-boot-clash.xml      |  29 +++
 boot/src/test/resources/templates/admin/users.ftl  |   1 +
 boot/src/test/resources/templates/hello.ftl        |   1 +
 7 files changed, 429 insertions(+)

diff --git a/boot/pom.xml b/boot/pom.xml
index 7fc3b3541..93242450d 100644
--- a/boot/pom.xml
+++ b/boot/pom.xml
@@ -41,6 +41,11 @@
             <groupId>org.apache.tomcat.embed</groupId>
             <artifactId>tomcat-embed-core</artifactId>
         </dependency>
+        <dependency>
+            <groupId>jakarta.servlet.jsp</groupId>
+            <artifactId>jakarta.servlet.jsp-api</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>junit</groupId>
             <artifactId>junit</artifactId>
diff --git 
a/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java 
b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java
new file mode 100644
index 000000000..c164f59bb
--- /dev/null
+++ 
b/boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java
@@ -0,0 +1,149 @@
+/*
+ * 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.boot;
+
+import jakarta.servlet.ServletContext;
+import org.apache.struts2.ObjectFactory;
+import org.apache.struts2.config.Configuration;
+import org.apache.struts2.config.ConfigurationException;
+import org.apache.struts2.config.ServletContextAwareConfigurationProvider;
+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.providers.InterceptorBuilder;
+import org.apache.struts2.inject.ContainerBuilder;
+import org.apache.struts2.util.ClassLoaderUtil;
+import org.apache.struts2.util.location.LocatableProperties;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+public class RegistryConfigurationProvider implements 
ServletContextAwareConfigurationProvider {
+
+    private static final String PACKAGE_PREFIX = "struts-boot";
+    private static final String TEMPLATE_ROOT = "templates/";
+
+    private ActionRegistry registry;
+    private Configuration configuration;
+
+    public RegistryConfigurationProvider() {
+    }
+
+    RegistryConfigurationProvider(ActionRegistry registry) {
+        this.registry = registry;
+    }
+
+    @Override
+    public void initWithContext(ServletContext servletContext) {
+        Object attribute = 
servletContext.getAttribute(ActionRegistry.SERVLET_CONTEXT_ATTRIBUTE);
+        if (!(attribute instanceof ActionRegistry actionRegistry)) {
+            throw new ConfigurationException("No ActionRegistry found in the 
servlet context attribute "
+                    + ActionRegistry.SERVLET_CONTEXT_ATTRIBUTE);
+        }
+        this.registry = actionRegistry;
+    }
+
+    @Override
+    public void init(Configuration configuration) {
+        this.configuration = configuration;
+    }
+
+    @Override
+    public void register(ContainerBuilder builder, LocatableProperties props) {
+    }
+
+    @Override
+    public boolean needsReload() {
+        return false;
+    }
+
+    @Override
+    public void destroy() {
+    }
+
+    @Override
+    public void loadPackages() {
+        PackageConfig strutsDefault = 
configuration.getPackageConfig("struts-default");
+        ObjectFactory objectFactory = 
configuration.getContainer().getInstance(ObjectFactory.class);
+        String freemarker = 
strutsDefault.getAllResultTypeConfigs().get("freemarker").getClassName();
+        String redirectAction = 
strutsDefault.getAllResultTypeConfigs().get("redirectAction").getClassName();
+        List<PackageConfig> existing = new 
ArrayList<>(configuration.getPackageConfigs().values());
+
+        for (NamespaceActions namespace : registry.namespaces()) {
+            String packageName = namespace.namespace().isEmpty() ? 
PACKAGE_PREFIX : PACKAGE_PREFIX + namespace.namespace();
+            PackageConfig.Builder pkg = new PackageConfig.Builder(packageName)
+                    .namespace(namespace.namespace())
+                    .addParent(strutsDefault);
+
+            for (ActionDefinition definition : namespace.actions()) {
+                assertNoClash(existing, namespace.namespace(), 
definition.name());
+                pkg.addActionConfig(definition.name(),
+                        actionConfig(packageName, namespace, definition, pkg, 
objectFactory, freemarker, redirectAction));
+            }
+            configuration.addPackageConfig(packageName, pkg.build());
+        }
+    }
+
+    private ActionConfig actionConfig(String packageName, NamespaceActions 
namespace, ActionDefinition definition,
+                                      PackageConfig.Builder pkg, ObjectFactory 
objectFactory,
+                                      String freemarker, String 
redirectAction) {
+        ActionConfig.Builder action = new ActionConfig.Builder(packageName, 
definition.name(), definition.actionClass().getName())
+                .methodName(definition.methodName())
+                .addAllowedMethod(definition.methodName());
+        if (!definition.httpMethodSet().isEmpty()) {
+            action.addHttpMethods(definition.httpMethodSet());
+        }
+        Optional<String> stack = 
definition.interceptorStack().or(namespace::interceptorStack);
+        if (stack.isPresent()) {
+            
action.addInterceptors(InterceptorBuilder.constructInterceptorReference(
+                    pkg, stack.get(), Map.of(), null, objectFactory));
+        }
+        definition.results().forEach((resultName, result) ->
+                action.addResultConfig(resultConfig(definition, resultName, 
result, freemarker, redirectAction)));
+        return action.build();
+    }
+
+    private ResultConfig resultConfig(ActionDefinition definition, String 
resultName,
+                                      ActionDefinition.ResultDefinition 
result, String freemarker, String redirectAction) {
+        return switch (result.kind()) {
+            case TEMPLATE -> {
+                String resource = TEMPLATE_ROOT + result.value();
+                if (ClassLoaderUtil.getResource(resource, 
RegistryConfigurationProvider.class) == null) {
+                    throw new ConfigurationException("Action '" + 
definition.name() + "' result '" + resultName
+                            + "' refers to the template '" + resource + "' 
which is not on the classpath");
+                }
+                yield new ResultConfig.Builder(resultName, 
freemarker).addParam("location", "/" + resource).build();
+            }
+            case REDIRECT_ACTION -> new ResultConfig.Builder(resultName, 
redirectAction)
+                    .addParam("actionName", result.value()).build();
+        };
+    }
+
+    private void assertNoClash(List<PackageConfig> existing, String namespace, 
String actionName) {
+        for (PackageConfig other : existing) {
+            if (!other.isAbstract() && namespace.equals(other.getNamespace())
+                    && other.getActionConfigs().containsKey(actionName)) {
+                throw new ConfigurationException("Action '" + actionName + "' 
in namespace '" + namespace
+                        + "' is declared both in the action registry and in 
the XML package '" + other.getName() + "'");
+            }
+        }
+    }
+}
diff --git 
a/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java
 
b/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java
new file mode 100644
index 000000000..0e9e82307
--- /dev/null
+++ 
b/boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java
@@ -0,0 +1,206 @@
+/*
+ * 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.boot;
+
+import org.apache.struts2.config.Configuration;
+import org.apache.struts2.config.ConfigurationException;
+import org.apache.struts2.config.ConfigurationManager;
+import org.apache.struts2.config.DefaultPropertiesProvider;
+import org.apache.struts2.config.StrutsBeanSelectionProvider;
+import org.apache.struts2.config.StrutsXmlConfigurationProvider;
+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.boot.fixture.UserAction;
+import org.apache.struts2.inject.Container;
+import org.apache.struts2.ActionContext;
+import org.junit.After;
+import org.junit.Test;
+
+import static org.apache.struts2.boot.HttpMethods.POST;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class RegistryConfigurationProviderTest {
+
+    private ConfigurationManager configurationManager;
+
+    @After
+    public void tearDown() {
+        if (configurationManager != null) {
+            configurationManager.destroyConfiguration();
+        }
+        ActionContext.clear();
+    }
+
+    private Configuration load(ActionRegistry registry, String... extraXml) {
+        configurationManager = new 
ConfigurationManager(Container.DEFAULT_NAME);
+        configurationManager.addContainerProvider(new 
DefaultPropertiesProvider());
+        configurationManager.addContainerProvider(new 
StrutsXmlConfigurationProvider("struts-default.xml"));
+        for (String xml : extraXml) {
+            configurationManager.addContainerProvider(new 
StrutsXmlConfigurationProvider(xml));
+        }
+        configurationManager.addContainerProvider(new 
StrutsBeanSelectionProvider());
+        configurationManager.addContainerProvider(new 
RegistryConfigurationProvider(registry));
+        return configurationManager.getConfiguration();
+    }
+
+    private ActionRegistry usersRegistry() {
+        ActionRegistry registry = new ActionRegistry();
+        registry.namespace("/admin", ns -> ns.action("users", 
UserAction.class).success("admin/users.ftl"));
+        return registry;
+    }
+
+    private ActionConfig action(Configuration configuration, String pkg, 
String name) {
+        return 
configuration.getPackageConfig(pkg).getActionConfigs().get(name);
+    }
+
+    private String resultType(Configuration configuration, String type) {
+        return 
configuration.getPackageConfig("struts-default").getAllResultTypeConfigs().get(type).getClassName();
+    }
+
+    @Test
+    public void testPackageExtendsStrutsDefaultWithNamespace() {
+        Configuration configuration = load(usersRegistry());
+
+        PackageConfig pkg = 
configuration.getPackageConfig("struts-boot/admin");
+
+        assertThat(pkg.getNamespace()).isEqualTo("/admin");
+        
assertThat(pkg.getParents()).extracting(PackageConfig::getName).containsExactly("struts-default");
+        assertThat(pkg.isStrictMethodInvocation()).isTrue();
+    }
+
+    @Test
+    public void testRootNamespacePackageName() {
+        ActionRegistry registry = new ActionRegistry();
+        registry.action("hello", UserAction.class).success("hello.ftl");
+
+        Configuration configuration = load(registry);
+
+        
assertThat(configuration.getPackageConfig("struts-boot").getNamespace()).isEmpty();
+    }
+
+    @Test
+    public void testMethodIsAddedToAllowedMethods() {
+        ActionRegistry registry = new ActionRegistry();
+        registry.namespace("/admin", ns -> ns.action("users", 
UserAction.class).method("list").success("admin/users.ftl"));
+
+        ActionConfig action = action(load(registry), "struts-boot/admin", 
"users");
+
+        assertThat(action.getMethodName()).isEqualTo("list");
+        
assertThat(action.getAllowedMethods()).contains("list").doesNotContain("delete");
+    }
+
+    @Test
+    public void testTemplateResultIsAbsoluteFreemarker() {
+        Configuration configuration = load(usersRegistry());
+
+        ResultConfig result = action(configuration, "struts-boot/admin", 
"users").getResults().get("success");
+
+        assertThat(result.getClassName()).isEqualTo(resultType(configuration, 
"freemarker"));
+        assertThat(result.getParams()).containsEntry("location", 
"/templates/admin/users.ftl");
+    }
+
+    @Test
+    public void testRedirectActionResult() {
+        ActionRegistry registry = new ActionRegistry();
+        registry.namespace("/admin", ns -> ns.action("save", 
UserAction.class).method("save").redirectAction("success", "users"));
+        Configuration configuration = load(registry);
+
+        ResultConfig result = action(configuration, "struts-boot/admin", 
"save").getResults().get("success");
+
+        assertThat(result.getClassName()).isEqualTo(resultType(configuration, 
"redirectAction"));
+        assertThat(result.getParams()).containsEntry("actionName", "users");
+    }
+
+    @Test
+    public void testHttpMethodsCarried() {
+        ActionRegistry registry = new ActionRegistry();
+        registry.namespace("/admin", ns -> ns.action("save", 
UserAction.class).method("save").httpMethods(POST).success("admin/users.ftl"));
+
+        ActionConfig action = action(load(registry), "struts-boot/admin", 
"save");
+
+        assertThat(action.getHttpMethods()).containsExactly(POST);
+    }
+
+    @Test
+    public void testNoStackMeansPackageDefault() throws Exception {
+        Configuration configuration = load(usersRegistry());
+
+        assertThat(action(configuration, "struts-boot/admin", 
"users").getInterceptors()).isEmpty();
+        
assertThat(configuration.getRuntimeConfiguration().getActionConfig("/admin", 
"users").getInterceptors()).isNotEmpty();
+    }
+
+    @Test
+    public void testNamedStackResolved() {
+        ActionRegistry registry = new ActionRegistry();
+        registry.namespace("/admin", ns -> ns.action("users", 
UserAction.class).interceptors("basicStack").success("admin/users.ftl"));
+
+        ActionConfig action = action(load(registry), "struts-boot/admin", 
"users");
+
+        assertThat(action.getInterceptors()).isNotEmpty();
+    }
+
+    @Test
+    public void testNamespaceStackAppliesToActions() {
+        ActionRegistry registry = new ActionRegistry();
+        registry.namespace("/admin", ns -> {
+            ns.interceptors("basicStack");
+            ns.action("users", UserAction.class).success("admin/users.ftl");
+        });
+
+        assertThat(action(load(registry), "struts-boot/admin", 
"users").getInterceptors()).isNotEmpty();
+    }
+
+    @Test
+    public void testUnknownStackThrows() {
+        ActionRegistry registry = new ActionRegistry();
+        registry.namespace("/admin", ns -> ns.action("users", 
UserAction.class).interceptors("noSuchStack").success("admin/users.ftl"));
+
+        assertThatThrownBy(() -> 
load(registry)).isInstanceOf(ConfigurationException.class);
+    }
+
+    @Test
+    public void testMissingTemplateThrows() {
+        ActionRegistry registry = new ActionRegistry();
+        registry.namespace("/admin", ns -> ns.action("users", 
UserAction.class).success("missing.ftl"));
+
+        assertThatThrownBy(() -> load(registry))
+                .isInstanceOf(ConfigurationException.class)
+                .rootCause().hasMessageContaining("templates/missing.ftl");
+    }
+
+    @Test
+    public void testClashWithXmlThrows() {
+        assertThatThrownBy(() -> load(usersRegistry(), 
"struts-boot-clash.xml"))
+                .isInstanceOf(ConfigurationException.class)
+                
.rootCause().hasMessageContaining("xml-pkg").hasMessageContaining("users");
+    }
+
+    @Test
+    public void testSameNamespaceAsXmlWithDistinctNamesLoads() throws 
Exception {
+        ActionRegistry registry = new ActionRegistry();
+        registry.namespace("/admin", ns -> ns.action("list", 
UserAction.class).method("list").success("admin/users.ftl"));
+
+        Configuration configuration = load(registry, "struts-boot-clash.xml");
+
+        
assertThat(configuration.getRuntimeConfiguration().getActionConfig("/admin", 
"users")).isNotNull();
+        
assertThat(configuration.getRuntimeConfiguration().getActionConfig("/admin", 
"list")).isNotNull();
+    }
+}
diff --git a/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java 
b/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java
new file mode 100644
index 000000000..56a5bde33
--- /dev/null
+++ b/boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java
@@ -0,0 +1,38 @@
+/*
+ * 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.boot.fixture;
+
+public class UserAction {
+
+    public String execute() {
+        return "success";
+    }
+
+    public String list() {
+        return "success";
+    }
+
+    public String save() {
+        return "success";
+    }
+
+    public String delete() {
+        return "success";
+    }
+}
diff --git a/boot/src/test/resources/struts-boot-clash.xml 
b/boot/src/test/resources/struts-boot-clash.xml
new file mode 100644
index 000000000..04482e723
--- /dev/null
+++ b/boot/src/test/resources/struts-boot-clash.xml
@@ -0,0 +1,29 @@
+<?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 6.0//EN"
+        "https://struts.apache.org/dtds/struts-6.0.dtd";>
+<struts>
+    <package name="xml-pkg" namespace="/admin" extends="struts-default">
+        <action name="users" 
class="org.apache.struts2.boot.fixture.UserAction"/>
+    </package>
+</struts>
diff --git a/boot/src/test/resources/templates/admin/users.ftl 
b/boot/src/test/resources/templates/admin/users.ftl
new file mode 100644
index 000000000..e9544fbee
--- /dev/null
+++ b/boot/src/test/resources/templates/admin/users.ftl
@@ -0,0 +1 @@
+<h1>users</h1>
diff --git a/boot/src/test/resources/templates/hello.ftl 
b/boot/src/test/resources/templates/hello.ftl
new file mode 100644
index 000000000..6e7c1e6ac
--- /dev/null
+++ b/boot/src/test/resources/templates/hello.ftl
@@ -0,0 +1 @@
+<h1>hello</h1>

Reply via email to