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>
