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 9ff45b55f06a5b30ed69892f4323a3ac9073d7dd
Author: Lukasz Lenart <[email protected]>
AuthorDate: Fri Oct 23 12:36:04 2026 +0200

    WW-5791 feat(boot): add struts2-boot-starter module with fluent action 
registry
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 bom/pom.xml                                        |   5 +
 boot/pom.xml                                       |  55 +++++++++++
 .../org/apache/struts2/boot/ActionDefinition.java  | 108 +++++++++++++++++++++
 .../org/apache/struts2/boot/ActionRegistry.java    |  49 ++++++++++
 .../java/org/apache/struts2/boot/HttpMethods.java  |  35 +++++++
 .../org/apache/struts2/boot/NamespaceActions.java  |  71 ++++++++++++++
 .../apache/struts2/boot/StrutsBootApplication.java |  24 +++++
 .../apache/struts2/boot/ActionRegistryTest.java    |  91 +++++++++++++++++
 parent/pom.xml                                     |  12 +++
 pom.xml                                            |   3 +
 10 files changed, 453 insertions(+)

diff --git a/bom/pom.xml b/bom/pom.xml
index 9d4200471..ab2c72a91 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -129,6 +129,11 @@
                 <artifactId>struts2-testng-plugin</artifactId>
                 <version>${project.version}</version>
             </dependency>
+            <dependency>
+                <groupId>org.apache.struts</groupId>
+                <artifactId>struts2-boot-starter</artifactId>
+                <version>${project.version}</version>
+            </dependency>
             <dependency>
                 <groupId>org.apache.struts</groupId>
                 <artifactId>struts2-tiles-plugin</artifactId>
diff --git a/boot/pom.xml b/boot/pom.xml
new file mode 100644
index 000000000..7fc3b3541
--- /dev/null
+++ b/boot/pom.xml
@@ -0,0 +1,55 @@
+<?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.
+ */
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/maven-v4_0_0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.struts</groupId>
+        <artifactId>struts2-parent</artifactId>
+        <version>7.5.0-SNAPSHOT</version>
+        <relativePath>../parent/pom.xml</relativePath>
+    </parent>
+
+    <artifactId>struts2-boot-starter</artifactId>
+    <packaging>jar</packaging>
+    <name>Struts 2 Boot Starter</name>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.struts</groupId>
+            <artifactId>struts2-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.tomcat.embed</groupId>
+            <artifactId>tomcat-embed-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java 
b/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java
new file mode 100644
index 000000000..d287e5ca5
--- /dev/null
+++ b/boot/src/main/java/org/apache/struts2/boot/ActionDefinition.java
@@ -0,0 +1,108 @@
+/*
+ * 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.ConfigurationException;
+import org.apache.struts2.interceptor.httpmethod.HttpMethod;
+
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+public final class ActionDefinition {
+
+    public record ResultDefinition(Kind kind, String value) {
+
+        public enum Kind {
+            TEMPLATE, REDIRECT_ACTION
+        }
+    }
+
+    private final String name;
+    private final Class<?> actionClass;
+    private final Map<String, ResultDefinition> results = new 
LinkedHashMap<>();
+    private final Set<HttpMethod> httpMethods = 
EnumSet.noneOf(HttpMethod.class);
+    private String methodName = "execute";
+    private String interceptorStack;
+
+    ActionDefinition(String name, Class<?> actionClass) {
+        this.name = name;
+        this.actionClass = actionClass;
+    }
+
+    public ActionDefinition method(String methodName) {
+        this.methodName = methodName;
+        return this;
+    }
+
+    public ActionDefinition success(String templatePath) {
+        return result("success", templatePath);
+    }
+
+    public ActionDefinition result(String name, String templatePath) {
+        if (templatePath == null || !templatePath.endsWith(".ftl")) {
+            throw new ConfigurationException("Action '" + this.name + "' 
result '" + name + "' uses '" + templatePath
+                    + "': Struts Boot renders FreeMarker templates only, the 
path must end with .ftl");
+        }
+        results.put(name, new ResultDefinition(ResultDefinition.Kind.TEMPLATE, 
templatePath));
+        return this;
+    }
+
+    public ActionDefinition redirectAction(String resultName, String 
actionName) {
+        results.put(resultName, new 
ResultDefinition(ResultDefinition.Kind.REDIRECT_ACTION, actionName));
+        return this;
+    }
+
+    public ActionDefinition httpMethods(HttpMethod... methods) {
+        Collections.addAll(httpMethods, methods);
+        return this;
+    }
+
+    public ActionDefinition interceptors(String stackName) {
+        this.interceptorStack = stackName;
+        return this;
+    }
+
+    public String name() {
+        return name;
+    }
+
+    public Class<?> actionClass() {
+        return actionClass;
+    }
+
+    public String methodName() {
+        return methodName;
+    }
+
+    public Map<String, ResultDefinition> results() {
+        return Collections.unmodifiableMap(results);
+    }
+
+    public Set<HttpMethod> httpMethodSet() {
+        return Collections.unmodifiableSet(httpMethods);
+    }
+
+    public Optional<String> interceptorStack() {
+        return Optional.ofNullable(interceptorStack);
+    }
+}
diff --git a/boot/src/main/java/org/apache/struts2/boot/ActionRegistry.java 
b/boot/src/main/java/org/apache/struts2/boot/ActionRegistry.java
new file mode 100644
index 000000000..de9206530
--- /dev/null
+++ b/boot/src/main/java/org/apache/struts2/boot/ActionRegistry.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.boot;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+public final class ActionRegistry {
+
+    public static final String SERVLET_CONTEXT_ATTRIBUTE = 
ActionRegistry.class.getName();
+
+    private final Map<String, NamespaceActions> namespaces = new 
LinkedHashMap<>();
+
+    public ActionDefinition action(String name, Class<?> actionClass) {
+        return namespaceActions("").action(name, actionClass);
+    }
+
+    public ActionRegistry namespace(String namespace, 
Consumer<NamespaceActions> body) {
+        body.accept(namespaceActions(namespace));
+        return this;
+    }
+
+    public Collection<NamespaceActions> namespaces() {
+        return Collections.unmodifiableCollection(namespaces.values());
+    }
+
+    private NamespaceActions namespaceActions(String namespace) {
+        return namespaces.computeIfAbsent(namespace, NamespaceActions::new);
+    }
+}
diff --git a/boot/src/main/java/org/apache/struts2/boot/HttpMethods.java 
b/boot/src/main/java/org/apache/struts2/boot/HttpMethods.java
new file mode 100644
index 000000000..44199df2b
--- /dev/null
+++ b/boot/src/main/java/org/apache/struts2/boot/HttpMethods.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.boot;
+
+import org.apache.struts2.interceptor.httpmethod.HttpMethod;
+
+public final class HttpMethods {
+
+    public static final HttpMethod GET = HttpMethod.GET;
+    public static final HttpMethod POST = HttpMethod.POST;
+    public static final HttpMethod PUT = HttpMethod.PUT;
+    public static final HttpMethod DELETE = HttpMethod.DELETE;
+    public static final HttpMethod PATCH = HttpMethod.PATCH;
+    public static final HttpMethod HEAD = HttpMethod.HEAD;
+    public static final HttpMethod OPTIONS = HttpMethod.OPTIONS;
+
+    private HttpMethods() {
+    }
+}
diff --git a/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java 
b/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java
new file mode 100644
index 000000000..d18084d2a
--- /dev/null
+++ b/boot/src/main/java/org/apache/struts2/boot/NamespaceActions.java
@@ -0,0 +1,71 @@
+/*
+ * 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.ConfigurationException;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+public final class NamespaceActions {
+
+    private final String namespace;
+    private final List<ActionDefinition> actions = new ArrayList<>();
+    private String interceptorStack;
+
+    NamespaceActions(String namespace) {
+        this.namespace = namespace;
+    }
+
+    public ActionDefinition action(String name, Class<?> actionClass) {
+        if (name == null || name.isEmpty()) {
+            throw new ConfigurationException("Action name in namespace '" + 
namespace + "' must not be empty");
+        }
+        if (name.contains("/")) {
+            throw new ConfigurationException("Action name '" + name + "' 
contains a slash; declare the path with namespace(...) instead");
+        }
+        for (ActionDefinition existing : actions) {
+            if (existing.name().equals(name)) {
+                throw new ConfigurationException("Action '" + name + "' is 
declared twice in namespace '" + namespace + "'");
+            }
+        }
+        ActionDefinition definition = new ActionDefinition(name, actionClass);
+        actions.add(definition);
+        return definition;
+    }
+
+    public NamespaceActions interceptors(String stackName) {
+        this.interceptorStack = stackName;
+        return this;
+    }
+
+    public String namespace() {
+        return namespace;
+    }
+
+    public Optional<String> interceptorStack() {
+        return Optional.ofNullable(interceptorStack);
+    }
+
+    public List<ActionDefinition> actions() {
+        return Collections.unmodifiableList(actions);
+    }
+}
diff --git 
a/boot/src/main/java/org/apache/struts2/boot/StrutsBootApplication.java 
b/boot/src/main/java/org/apache/struts2/boot/StrutsBootApplication.java
new file mode 100644
index 000000000..8377be29c
--- /dev/null
+++ b/boot/src/main/java/org/apache/struts2/boot/StrutsBootApplication.java
@@ -0,0 +1,24 @@
+/*
+ * 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;
+
+public interface StrutsBootApplication {
+
+    void actions(ActionRegistry actions);
+}
diff --git a/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java 
b/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java
new file mode 100644
index 000000000..6e552ac1b
--- /dev/null
+++ b/boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.boot.ActionDefinition.ResultDefinition;
+import org.apache.struts2.boot.ActionDefinition.ResultDefinition.Kind;
+import org.apache.struts2.config.ConfigurationException;
+import org.apache.struts2.interceptor.httpmethod.HttpMethod;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class ActionRegistryTest {
+
+    @Test
+    public void testTopLevelActionGoesToEmptyNamespace() {
+        ActionRegistry r = new ActionRegistry();
+        r.action("hello", Object.class).success("hello.ftl");
+        
assertThat(r.namespaces()).extracting(NamespaceActions::namespace).containsExactly("");
+        ActionDefinition a = r.namespaces().iterator().next().actions().get(0);
+        assertThat(a.methodName()).isEqualTo("execute");
+        assertThat(a.results()).containsEntry("success", new 
ResultDefinition(Kind.TEMPLATE, "hello.ftl"));
+    }
+
+    @Test
+    public void testNamespaceDeclaredTwiceMerges() {
+        ActionRegistry r = new ActionRegistry();
+        r.namespace("/admin", n -> n.action("a", Object.class));
+        r.namespace("/admin", n -> n.action("b", Object.class));
+        assertThat(r.namespaces()).hasSize(1);
+        
assertThat(r.namespaces().iterator().next().actions()).extracting(ActionDefinition::name).containsExactly("a",
 "b");
+    }
+
+    @Test
+    public void testDuplicateAcrossRepeatedNamespaceThrows() {
+        ActionRegistry r = new ActionRegistry();
+        r.namespace("/admin", n -> n.action("a", Object.class));
+        assertThatThrownBy(() -> r.namespace("/admin", n -> n.action("a", 
Object.class)))
+                
.isInstanceOf(ConfigurationException.class).hasMessageContaining("/admin").hasMessageContaining("a");
+    }
+
+    @Test
+    public void testDuplicateTopLevelThrows() {
+        ActionRegistry r = new ActionRegistry();
+        r.action("hello", Object.class);
+        assertThatThrownBy(() -> r.action("hello", Object.class))
+                
.isInstanceOf(ConfigurationException.class).hasMessageContaining("hello");
+    }
+
+    @Test
+    public void testSlashInActionNameThrows() {
+        ActionRegistry r = new ActionRegistry();
+        assertThatThrownBy(() -> r.action("users/save", Object.class))
+                
.isInstanceOf(ConfigurationException.class).hasMessageContaining("namespace");
+    }
+
+    @Test
+    public void testNonFtlTemplateThrows() {
+        ActionRegistry r = new ActionRegistry();
+        assertThatThrownBy(() -> r.action("hello", 
Object.class).success("hello.jsp"))
+                
.isInstanceOf(ConfigurationException.class).hasMessageContaining("FreeMarker");
+    }
+
+    @Test
+    public void testFluentCallsAreRecorded() {
+        ActionDefinition a = new ActionRegistry().action("save", Object.class)
+                .method("save").httpMethods(HttpMethods.POST)
+                .result("input", "form.ftl").redirectAction("success", 
"users").interceptors("paramsPrepareParamsStack");
+        assertThat(a.methodName()).isEqualTo("save");
+        assertThat(a.httpMethodSet()).containsExactly(HttpMethod.POST);
+        assertThat(a.results()).containsEntry("success", new 
ResultDefinition(Kind.REDIRECT_ACTION, "users"));
+        assertThat(a.interceptorStack()).contains("paramsPrepareParamsStack");
+    }
+}
diff --git a/parent/pom.xml b/parent/pom.xml
index 4d74508ad..42b672a34 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -42,6 +42,18 @@
                 <version>${struts-annotations.version}</version>
             </dependency>
 
+            <dependency>
+                <groupId>org.apache.tomcat.embed</groupId>
+                <artifactId>tomcat-embed-core</artifactId>
+                <version>${tomcat-embed.version}</version>
+            </dependency>
+
+            <dependency>
+                <groupId>org.apache.struts</groupId>
+                <artifactId>struts2-boot-starter</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+
             <dependency>
                 <groupId>org.freemarker</groupId>
                 <artifactId>freemarker</artifactId>
diff --git a/pom.xml b/pom.xml
index 02f06f258..30d0bf283 100644
--- a/pom.xml
+++ b/pom.xml
@@ -87,6 +87,7 @@
         <module>jakarta</module>
         <module>core</module>
         <module>plugins</module>
+        <module>boot</module>
         <module>apps</module>
     </modules>
 
@@ -129,6 +130,7 @@
         <ognl.version>3.4.16</ognl.version>
         <spring.version>6.2.19</spring.version>
         <struts-annotations.version>2.0</struts-annotations.version>
+        <tomcat-embed.version>10.1.59</tomcat-embed.version>
         <velocity-tools.version>3.1</velocity-tools.version>
         <webjars-bootstrap.version>5.3.8</webjars-bootstrap.version>
         
<webjars-bootstrap-icons.version>1.13.2</webjars-bootstrap-icons.version>
@@ -166,6 +168,7 @@
             <properties>
                 <jakarta-ee.version>11.0.0</jakarta-ee.version>
                 <spring.version>7.0.9</spring.version>
+                <tomcat-embed.version>11.0.27</tomcat-embed.version>
             </properties>
         </profile>
         <profile>

Reply via email to