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>
