diff --git a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java b/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java index 53e655483f..5148cfce53 100644 --- a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java +++ b/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java @@ -18,8 +18,10 @@ */ package org.apache.struts2.config; +import org.apache.struts2.config.entities.PackageConfig; + /** - * Resolves a scopable constant for the current action invocation: the package's {@code }, inherited + * Resolves a scopable constant for the current action invocation or a given package: the package's {@code }, inherited * through {@code extends}, or else the global constant. * * @since 7.5.0 @@ -32,4 +34,18 @@ public interface ScopedConstantProvider { * @throws IllegalArgumentException when {@code name} is not scopable */ String getValue(String name); + + /** + * @param name a constant registered through a {@link ScopableConstants} bean + * @param packageConfig the package whose scoped constants, inherited ones included, take precedence; may be + * {@code null} + * @return the package's scoped value, or else {@link #getValue(String)} + * @throws IllegalArgumentException when {@code name} is not scopable; this default checks only on the + * {@link #getValue(String)} fallback, as package values were validated at startup + * @since 7.5.0 + */ + default String getValue(String name, PackageConfig packageConfig) { + String value = packageConfig != null ? packageConfig.getAllScopedConstants().get(name) : null; + return value != null ? value : getValue(name); + } } diff --git a/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java b/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java index ebfc968f58..647fa9eb63 100644 --- a/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java +++ b/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java @@ -49,13 +49,29 @@ public void setContainer(Container container) { @Override public String getValue(String name) { + requireScopable(name); + return resolve(name, currentPackage().orElse(null)); + } + + @Override + public String getValue(String name, PackageConfig packageConfig) { + if (packageConfig == null) { + return getValue(name); + } + requireScopable(name); + return resolve(name, packageConfig); + } + + private void requireScopable(String name) { if (!scopableNames.contains(name)) { throw new IllegalArgumentException(String.format( "Constant [%s] is not scopable; register it through a %s bean", name, ScopableConstants.class.getName())); } - return currentPackage() - .map(packageConfig -> packageConfig.getAllScopedConstants().get(name)) - .orElseGet(() -> container.getInstance(String.class, name)); + } + + private String resolve(String name, PackageConfig packageConfig) { + String value = packageConfig != null ? packageConfig.getAllScopedConstants().get(name) : null; + return value != null ? value : container.getInstance(String.class, name); } private Optional currentPackage() { diff --git a/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java b/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java index c00ded1c5e..b6bde86574 100644 --- a/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java +++ b/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java @@ -24,6 +24,7 @@ import org.apache.struts2.config.ScopedConstantProvider; import org.apache.struts2.config.StrutsXmlConfigurationProvider; import org.apache.struts2.config.entities.ActionConfig; +import org.apache.struts2.config.entities.PackageConfig; import org.apache.struts2.mock.MockActionInvocation; import org.apache.struts2.mock.MockActionProxy; @@ -121,6 +122,52 @@ public void testGlobalConstantUntouchedByScopedRead() { assertEquals("global", container.getInstance(String.class, "sample.scopable")); } + public void testExplicitPackageResolvesInheritedValue() { + assertEquals("base", provider.getValue("sample.scopable", configuration.getPackageConfig("child"))); + } + + public void testExplicitPackageWinsOverInvocationPackage() { + enterPackage("override"); + + assertEquals("base", provider.getValue("sample.scopable", configuration.getPackageConfig("child"))); + } + + public void testExplicitPackageWithoutOverrideIgnoresInvocationPackage() { + enterPackage("override"); + + assertEquals("global", provider.getValue("sample.scopable", configuration.getPackageConfig("plain"))); + } + + public void testExplicitPackageWithoutOverrideGivesGlobalOutsideInvocation() { + assertEquals("global", provider.getValue("sample.scopable", configuration.getPackageConfig("plain"))); + } + + public void testNullPackageFallsBackToInvocation() { + enterPackage("override"); + + assertEquals("override", provider.getValue("sample.scopable", null)); + } + + public void testExplicitPackageEmptyOverrideIsReturnedAsIs() { + assertEquals("", provider.getValue("sample.scopable", configuration.getPackageConfig("empty"))); + } + + public void testExplicitPackageRejectsUnregisteredName() { + PackageConfig plain = configuration.getPackageConfig("plain"); + + assertThatThrownBy(() -> provider.getValue("sample.unknown", plain)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[sample.unknown]"); + } + + public void testLambdaProviderGetsPackageLookup() { + ScopedConstantProvider lambda = name -> "fallback"; + + assertEquals("base", lambda.getValue("sample.scopable", configuration.getPackageConfig("child"))); + assertEquals("fallback", lambda.getValue("sample.scopable", configuration.getPackageConfig("plain"))); + assertEquals("fallback", lambda.getValue("sample.scopable", null)); + } + private void enterPackage(String packageName) { MockActionProxy proxy = new MockActionProxy(); proxy.setConfig(new ActionConfig.Builder(packageName, "test", ActionSupport.class.getName()).build()); diff --git a/docs/superpowers/plans/2026-10-14-WW-5770-scoped-convention-result-path.md b/docs/superpowers/plans/2026-10-14-WW-5770-scoped-convention-result-path.md new file mode 100644 index 0000000000..e6e2f75d8c --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-WW-5770-scoped-convention-result-path.md @@ -0,0 +1,986 @@ +# WW-5770 Scopable Convention Result Path Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let `` on a parent package set the Convention +result path for every action under it. + +**Architecture:** A `default` method `ScopedConstantProvider.getValue(name, PackageConfig)` resolves a constant +against an explicit package. `ConventionsService` gains package-aware `default` overloads; `ConventionsServiceImpl` +implements them with precedence `@ResultPath` → package scoped value → global. `DefaultResultMapBuilder` (startup) +and `ConventionUnknownHandler` (runtime) pass the package they already hold. + +**Tech Stack:** Java 17, Struts container (`@Inject`), JUnit 4 + JUnit 3-style `TestCase`, EasyMock, AssertJ +(core only — the Convention module has no AssertJ), `StrutsJUnit4TestCase` from `struts2-junit-plugin`. + +**Spec:** `docs/superpowers/specs/2026-10-14-WW-5770-scoped-convention-result-path-design.md` + +## Global Constraints + +- Fix version 7.5.0; new public API carries `@since 7.5.0`. +- Tests are JUnit 3 or 4 — never Jupiter. Match the target file's style; an `@Test` in a `TestCase` subclass never runs. +- `ScopedConstantProvider` must stay a functional interface (23 test sites stub it as `name -> ...`). +- `ConventionsServiceImpl`'s constructor signature does not change. +- No null guard on the injected `ScopedConstantProvider`; tests inject one. +- No new comments beyond Javadoc on new public methods; match the file's density. +- Commit messages: `WW-5770 (): ...`, ending with `Co-Authored-By: Claude Opus 5.5 `; + no session links. +- Stage explicit paths only — never `git add -A`, never `git stash`. +- Use `./mvnw` (there is no `mvn` on the PATH); log to a file and read Maven's exit code, not a grep of the output. + +## Review Focus + +1. **Override without a trailing slash** (`/WEB-INF/admin`) — must behave like the global constant does: a slash is + appended. Pinned in Task 3 (startup) and Task 4 (runtime). +2. **Empty override** (`value=""`) — must be used as-is, not fall back to the global path. Pinned in Task 2. +3. **Package-level `@ResultPath` (`package-info.java`)** — must beat the scoped value just as the class-level one + does. Pinned in Task 2. +4. **Action-less request when the default parent package has no override** — global path, unchanged from today. + Pinned in Task 4. +5. **A custom `ConventionsService` that implements only the one-argument methods** — keeps its old behaviour. + Pinned in Task 2. + +--- + +### Task 1: Package-aware lookup on `ScopedConstantProvider` + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java` +- Test: `core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java` + +**Interfaces:** +- Produces: `default String getValue(String name, PackageConfig packageConfig)` on + `org.apache.struts2.config.ScopedConstantProvider` — the package's `getAllScopedConstants().get(name)` when + non-null, else `getValue(name)`; a `null` package goes straight to `getValue(name)`. + +- [ ] **Step 1: Write the failing tests** + +Append to `StrutsScopedConstantProviderTest` (JUnit 3 style, before `enterPackage`), and add imports +`org.apache.struts2.config.entities.PackageConfig`: + +```java + public void testExplicitPackageResolvesInheritedValue() { + assertEquals("base", provider.getValue("sample.scopable", configuration.getPackageConfig("child"))); + } + + public void testExplicitPackageWinsOverInvocationPackage() { + enterPackage("override"); + + assertEquals("base", provider.getValue("sample.scopable", configuration.getPackageConfig("child"))); + } + + public void testExplicitPackageWithoutOverrideFallsBackToInvocation() { + enterPackage("override"); + + assertEquals("override", provider.getValue("sample.scopable", configuration.getPackageConfig("plain"))); + } + + public void testExplicitPackageWithoutOverrideGivesGlobalOutsideInvocation() { + assertEquals("global", provider.getValue("sample.scopable", configuration.getPackageConfig("plain"))); + } + + public void testNullPackageFallsBackToInvocation() { + enterPackage("override"); + + assertEquals("override", provider.getValue("sample.scopable", null)); + } + + public void testExplicitPackageEmptyOverrideIsReturnedAsIs() { + assertEquals("", provider.getValue("sample.scopable", configuration.getPackageConfig("empty"))); + } + + public void testExplicitPackageRejectsUnregisteredName() { + PackageConfig plain = configuration.getPackageConfig("plain"); + + assertThatThrownBy(() -> provider.getValue("sample.unknown", plain)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[sample.unknown]"); + } + + public void testLambdaProviderGetsPackageLookup() { + ScopedConstantProvider lambda = name -> "fallback"; + + assertEquals("base", lambda.getValue("sample.scopable", configuration.getPackageConfig("child"))); + assertEquals("fallback", lambda.getValue("sample.scopable", configuration.getPackageConfig("plain"))); + assertEquals("fallback", lambda.getValue("sample.scopable", null)); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +./mvnw test -DskipAssembly -pl core -Dtest=StrutsScopedConstantProviderTest > /tmp/ww5770-t1.log 2>&1; echo "exit=$?"; grep -E "ERROR|Tests run" /tmp/ww5770-t1.log | head -20 +``` +Expected: non-zero exit, compilation error `method getValue in interface ScopedConstantProvider cannot be applied`. + +- [ ] **Step 3: Add the default method** + +In `ScopedConstantProvider.java` add the import `org.apache.struts2.config.entities.PackageConfig`, change the +class Javadoc's first sentence to "Resolves a scopable constant for the current action invocation or a given +package:" and add after `getValue(String name)`: + +```java + /** + * @param name a constant registered through a {@link ScopableConstants} bean + * @param packageConfig the package whose scoped constants, inherited ones included, take precedence; may be + * {@code null} + * @return the package's scoped value, or else {@link #getValue(String)} + * @throws IllegalArgumentException when {@code name} is not scopable + * @since 7.5.0 + */ + default String getValue(String name, PackageConfig packageConfig) { + String value = packageConfig != null ? packageConfig.getAllScopedConstants().get(name) : null; + return value != null ? value : getValue(name); + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +./mvnw test -DskipAssembly -pl core -Dtest='StrutsScopedConstantProviderTest,ScopedConstantProviderWiringTest,StrutsJavaConfigurationProviderTest,ConstantConfigTest' > /tmp/ww5770-t1.log 2>&1; echo "exit=$?"; grep -E "Tests run:|FAIL" /tmp/ww5770-t1.log | tail -5 +``` +Expected: `exit=0`, no failures. + +- [ ] **Step 5: Install core so the Convention module compiles against it** + +```bash +./mvnw install -DskipAssembly -DskipTests -pl core > /tmp/ww5770-install.log 2>&1; echo "exit=$?" +``` +Expected: `exit=0`. Repeat this step whenever core changes again. + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java +git diff --cached --name-only +git commit -m "WW-5770 feat(core): resolve a scoped constant against an explicit package + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Package-aware result path in `ConventionsService` + +**Files:** +- Create: `plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java` +- Modify: `plugins/convention/src/main/resources/struts-plugin.xml` (after the `ConventionsService` bean, line ~40) +- Modify: `plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java` +- Modify: `plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java` +- Create: `plugins/convention/src/test/java/org/apache/struts2/convention/ConventionsServiceImplTest.java` + +**Interfaces:** +- Consumes: `ScopedConstantProvider.getValue(String, PackageConfig)` (Task 1). +- Produces: + - `ConventionsService#determineResultPath(Class actionClass, PackageConfig packageConfig)` — `default`, delegates to `determineResultPath(actionClass)`. + - `ConventionsService#determineResultPath(ActionConfig actionConfig, PackageConfig packageConfig)` — `default`, delegates to `determineResultPath(actionConfig)`. + - `ConventionsServiceImpl#setScopedConstantProvider(ScopedConstantProvider)` — `@Inject` setter. + - `ConventionScopableConstants` — `ScopableConstants` returning `Set.of(ConventionConstants.CONVENTION_RESULT_PATH)`. + +- [ ] **Step 1: Write the failing tests** + +Create `ConventionsServiceImplTest.java` with the ASF license header copied from `ConventionsServiceImpl.java`: + +```java +package org.apache.struts2.convention; + +import org.apache.struts2.config.entities.ActionConfig; +import org.apache.struts2.config.entities.PackageConfig; +import org.apache.struts2.config.entities.ResultTypeConfig; +import org.apache.struts2.convention.actions.NoAnnotationAction; +import org.apache.struts2.convention.actions.resultpath.ClassLevelResultPathAction; +import org.apache.struts2.convention.actions.resultpath.PackageLevelResultPathAction; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ConventionsServiceImplTest { + + private static final String GLOBAL = "/WEB-INF/content/"; + private static final String SCOPED = "/WEB-INF/admin/"; + + private ConventionsServiceImpl service; + private PackageConfig scopedPackage; + private PackageConfig plainPackage; + + @Before + public void setUp() { + service = new ConventionsServiceImpl(GLOBAL); + service.setScopedConstantProvider(name -> GLOBAL); + scopedPackage = childOf(SCOPED); + plainPackage = new PackageConfig.Builder("plain").build(); + } + + @Test + public void scopedValueOfParentPackageWinsOverGlobal() { + assertEquals(SCOPED, service.determineResultPath(NoAnnotationAction.class, scopedPackage)); + } + + @Test + public void classLevelResultPathWinsOverScopedValue() { + assertEquals("/class-level", service.determineResultPath(ClassLevelResultPathAction.class, scopedPackage)); + } + + @Test + public void packageLevelResultPathWinsOverScopedValue() { + assertEquals("/package-level", service.determineResultPath(PackageLevelResultPathAction.class, scopedPackage)); + } + + @Test + public void packageWithoutScopedValueGivesGlobal() { + assertEquals(GLOBAL, service.determineResultPath(NoAnnotationAction.class, plainPackage)); + } + + @Test + public void emptyScopedValueIsUsedAsIs() { + assertEquals("", service.determineResultPath(NoAnnotationAction.class, childOf(""))); + } + + @Test + public void nullPackageGivesGlobalWithoutConsultingProvider() { + ConventionsServiceImpl withoutProvider = new ConventionsServiceImpl(GLOBAL); + + assertEquals(GLOBAL, withoutProvider.determineResultPath(NoAnnotationAction.class, null)); + assertEquals(GLOBAL, withoutProvider.determineResultPath(NoAnnotationAction.class)); + assertEquals(GLOBAL, withoutProvider.determineResultPath((ActionConfig) null, null)); + assertEquals(GLOBAL, withoutProvider.determineResultPath((ActionConfig) null)); + } + + @Test + public void actionlessRequestUsesScopedValueOfPackage() { + assertEquals(SCOPED, service.determineResultPath((ActionConfig) null, scopedPackage)); + } + + @Test + public void actionConfigResolvesThroughItsClass() { + ActionConfig annotated = actionConfig(ClassLevelResultPathAction.class.getName()); + ActionConfig plain = actionConfig(NoAnnotationAction.class.getName()); + + assertEquals("/class-level", service.determineResultPath(annotated, scopedPackage)); + assertEquals(SCOPED, service.determineResultPath(plain, scopedPackage)); + } + + @Test + public void unknownActionClassStillFails() { + ActionConfig missing = actionConfig("com.example.MissingAction"); + + RuntimeException e = assertThrows(RuntimeException.class, + () -> service.determineResultPath(missing, scopedPackage)); + assertTrue(e.getMessage().contains("[com.example.MissingAction]")); + } + + @Test + public void customServiceWithOnlyOneArgumentMethodsKeepsItsBehaviour() { + ConventionsService custom = new ConventionsService() { + @Override + public String determineResultPath(Class actionClass) { + return "/custom/"; + } + + @Override + public String determineResultPath(ActionConfig actionConfig) { + return "/custom/"; + } + + @Override + public Map getResultTypesByExtension(PackageConfig packageConfig) { + return Collections.emptyMap(); + } + }; + + assertEquals("/custom/", custom.determineResultPath(NoAnnotationAction.class, scopedPackage)); + assertEquals("/custom/", custom.determineResultPath((ActionConfig) null, scopedPackage)); + } + + @Test + public void resultPathIsScopable() { + assertEquals(Set.of(ConventionConstants.CONVENTION_RESULT_PATH), new ConventionScopableConstants().getNames()); + } + + private static PackageConfig childOf(String scopedResultPath) { + PackageConfig parent = new PackageConfig.Builder("admin-base") + .addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, scopedResultPath) + .build(); + return new PackageConfig.Builder("admin-base#/admin").namespace("/admin").addParent(parent).build(); + } + + private static ActionConfig actionConfig(String className) { + return new ActionConfig.Builder("admin-base#/admin", "foo", className).build(); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +./mvnw test -DskipAssembly -pl plugins/convention -Dtest=ConventionsServiceImplTest > /tmp/ww5770-t2.log 2>&1; echo "exit=$?"; grep -E "ERROR|Tests run" /tmp/ww5770-t2.log | head -20 +``` +Expected: non-zero exit, compilation errors for `setScopedConstantProvider`, `ConventionScopableConstants` and the +two-argument `determineResultPath`. + +- [ ] **Step 3: Add `ConventionScopableConstants` and register it** + +Create `ConventionScopableConstants.java` (ASF header, same shape as +`core/src/main/java/org/apache/struts2/components/StrutsUiScopableConstants.java`): + +```java +package org.apache.struts2.convention; + +import org.apache.struts2.config.ScopableConstants; + +import java.util.Set; + +/** + * Lets a package override the Convention result path with {@code }. + * + * @since 7.5.0 + */ +public class ConventionScopableConstants implements ScopableConstants { + + @Override + public Set getNames() { + return Set.of(ConventionConstants.CONVENTION_RESULT_PATH); + } +} +``` + +In `struts-plugin.xml`, directly after the `ConventionsService` bean: + +```xml + +``` + +- [ ] **Step 4: Add the default methods to `ConventionsService`** + +After `String determineResultPath(ActionConfig actionConfig);`: + +```java + /** + * Locates the result location like {@link #determineResultPath(Class)}, using the package's scoped + * {@code struts.convention.result.path} when the action carries no {@code @ResultPath}. + * + * @param actionClass The action class. + * @param packageConfig The package the action belongs to, or null for the global default. + * @return The result location. + * @since 7.5.0 + */ + default String determineResultPath(Class actionClass, PackageConfig packageConfig) { + return determineResultPath(actionClass); + } + + /** + * Locates the result location like {@link #determineResultPath(ActionConfig)}, using the package's scoped + * {@code struts.convention.result.path} when the action carries no {@code @ResultPath}. + * + * @param actionConfig (Optional) The configuration for the action, or null when there is no action. + * @param packageConfig The package whose scoped value applies, or null for the global default. + * @return The result location. + * @since 7.5.0 + */ + default String determineResultPath(ActionConfig actionConfig, PackageConfig packageConfig) { + return determineResultPath(actionConfig); + } +``` + +- [ ] **Step 5: Implement them in `ConventionsServiceImpl`** + +Add imports `org.apache.struts2.config.ScopedConstantProvider`. Add the field and setter after the constructor: + +```java + private ScopedConstantProvider scopedConstantProvider; +``` +(declare next to `private String resultPath;`) + +```java + @Inject + public void setScopedConstantProvider(ScopedConstantProvider scopedConstantProvider) { + this.scopedConstantProvider = scopedConstantProvider; + } +``` + +Replace both existing `determineResultPath` methods with: + +```java + /** + * {@inheritDoc} + */ + public String determineResultPath(Class actionClass) { + return determineResultPath(actionClass, null); + } + + /** + * {@inheritDoc} + */ + @Override + public String determineResultPath(Class actionClass, PackageConfig packageConfig) { + String annotatedResultPath = findAnnotatedResultPath(actionClass); + return annotatedResultPath != null ? annotatedResultPath : scopedResultPath(packageConfig); + } + + /** + * {@inheritDoc} + */ + public String determineResultPath(ActionConfig actionConfig) { + return determineResultPath(actionConfig, null); + } + + /** + * {@inheritDoc} + */ + @Override + public String determineResultPath(ActionConfig actionConfig, PackageConfig packageConfig) { + if (actionConfig == null) { + return scopedResultPath(packageConfig); + } + + try { + return determineResultPath(ClassLoaderUtil.loadClass(actionConfig.getClassName(), this.getClass()), packageConfig); + } catch (ClassNotFoundException e) { + throw new RuntimeException("Invalid action class configuration that references an unknown " + + "class named [" + actionConfig.getClassName() + "]", e); + } + } + + private String scopedResultPath(PackageConfig packageConfig) { + if (packageConfig == null) { + return resultPath; + } + return scopedConstantProvider.getValue(ConventionConstants.CONVENTION_RESULT_PATH, packageConfig); + } + + private String findAnnotatedResultPath(Class actionClass) { + ResultPath resultPathAnnotation = AnnotationUtils.findAnnotation(actionClass, ResultPath.class); + if (resultPathAnnotation == null) { + return null; + } + if (resultPathAnnotation.value().equals("") && resultPathAnnotation.property().equals("")) { + throw new ConfigurationException("The ResultPath annotation must have either" + + " a value or property specified."); + } + + String property = resultPathAnnotation.property(); + if (property.equals("")) { + return resultPathAnnotation.value(); + } + try { + ResourceBundle strutsBundle = ResourceBundle.getBundle("struts"); + return strutsBundle.getString(property); + } catch (Exception e) { + throw new ConfigurationException("The action class [" + actionClass + "] defines" + + " a @ResultPath annotation and a property definition however the" + + " struts.properties could not be found in the classpath using ResourceBundle" + + " OR the bundle exists but the property [" + property + "] is not defined" + + " in the file.", e); + } + } +``` + +The exception messages are copied verbatim from the current implementation. + +- [ ] **Step 6: Run the tests to verify they pass, plus the module** + +```bash +./mvnw test -DskipAssembly -pl plugins/convention > /tmp/ww5770-t2.log 2>&1; echo "exit=$?"; grep -E "Tests run:|FAIL" /tmp/ww5770-t2.log | tail -5 +``` +Expected: `exit=0`. `DefaultResultMapBuilderTest` is still green because `DefaultResultMapBuilder` still calls the +one-argument method. + +- [ ] **Step 7: Commit** + +```bash +git add plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java plugins/convention/src/main/resources/struts-plugin.xml plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java plugins/convention/src/test/java/org/apache/struts2/convention/ConventionsServiceImplTest.java +git diff --cached --name-only +git commit -m "WW-5770 feat(convention): resolve the result path against the action's package + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Startup — `DefaultResultMapBuilder` uses the package's result path + +**Files:** +- Modify: `plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java:159` +- Modify: `plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java` +- Create: `plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java` +- Create: `plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml` +- Create: `plugins/convention/src/test/resources/scoped/message-success.ftl` + +**Interfaces:** +- Consumes: `ConventionsService#determineResultPath(Class, PackageConfig)`, + `ConventionsServiceImpl#setScopedConstantProvider` (Task 2). + +- [ ] **Step 1: Route the existing test sites through a helper** + +In `DefaultResultMapBuilderTest`, use Edit with `replace_all` to replace `new ConventionsServiceImpl(` with +`newConventionsService(` (21 sites). Then add, next to `createPackageConfigBuilder`: + +```java + private ConventionsService newConventionsService(String resultPath) { + ConventionsServiceImpl service = new ConventionsServiceImpl(resultPath); + service.setScopedConstantProvider(name -> resultPath); + return service; + } +``` + +Check with `git diff --stat` that only this file changed and `grep -c "newConventionsService(" ` reports 22. + +- [ ] **Step 2: Write the failing unit tests** + +Replace the existing `createPackageConfigBuilder(String namespace)` with these two methods (the result types are +unchanged; the body only moves into a `builder` local so a parent can be added): + +```java + private PackageConfig createPackageConfigBuilder(String namespace) { + return createPackageConfigBuilder(namespace, null); + } + + private PackageConfig createPackageConfigBuilder(String namespace, String scopedResultPath) { + ResultTypeConfig resultType = new ResultTypeConfig.Builder("dispatcher", "org.apache.struts2.result.ServletDispatcherResult"). + addParam("key", "value").addParam("key1", "value1").defaultResultParam("location").build(); + + ResultTypeConfig redirect = new ResultTypeConfig.Builder("redirectAction", + "org.apache.struts2.result.ServletActionRedirectResult").defaultResultParam("actionName").build(); + + ResultTypeConfig ftlResultType = new ResultTypeConfig.Builder("freemarker", + "org.apache.struts2.views.freemarker.FreemarkerResult").defaultResultParam("location").build(); + + PackageConfig.Builder builder = new PackageConfig.Builder("package"). + namespace(namespace). + defaultResultType("dispatcher"). + addResultTypeConfig(resultType). + addResultTypeConfig(redirect). + addResultTypeConfig(ftlResultType); + if (scopedResultPath != null) { + builder.addParent(new PackageConfig.Builder("scoped-parent") + .addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, scopedResultPath) + .build()); + } + return builder.build(); + } +``` + +Then add the tests: + +```java + @Test + public void testScopedResultPathFromParentPackage() { + ServletContext context = mockServletContext("/WEB-INF/admin"); + + PackageConfig packageConfig = createPackageConfigBuilder("/namespace", "/WEB-INF/admin/"); + this.conventionsService = newConventionsService("/WEB-INF/location"); + DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); + Map results = builder.build(NoAnnotationAction.class, null, "action", packageConfig); + verify(context, "/WEB-INF/admin", results, false); + } + + @Test + public void testScopedResultPathWithoutTrailingSlash() { + ServletContext context = mockServletContext("/WEB-INF/admin"); + + PackageConfig packageConfig = createPackageConfigBuilder("/namespace", "/WEB-INF/admin"); + this.conventionsService = newConventionsService("/WEB-INF/location"); + DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); + Map results = builder.build(NoAnnotationAction.class, null, "action", packageConfig); + verify(context, "/WEB-INF/admin", results, false); + } + + @Test + public void testResultPathAnnotationWinsOverScopedResultPath() { + ServletContext context = mockServletContext("/class-level"); + + PackageConfig packageConfig = createPackageConfigBuilder("/namespace", "/WEB-INF/admin/"); + this.conventionsService = newConventionsService("/not-used"); + DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); + Map results = builder.build(ClassLevelResultPathAction.class, null, "action", packageConfig); + verify(context, "/class-level", results, false); + } +``` + +- [ ] **Step 3: Write the failing integration test** + +`struts-convention-scoped-result-path.xml` (copy the license comment block from +`struts-convention-configuration.xml`): + +```xml + + + + + + + + + +``` + +`scoped/message-success.ftl`: + +``` +<#-- + 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. +--> + + +This is the scoped view ${message} + + +``` + +`ConventionScopedResultPathTest.java` (ASF header; same shape as `ConventionJUnit4Test`): + +```java +package org.apache.struts2.convention; + +import actions.MessageAction; +import org.apache.struts2.junit.StrutsJUnit4TestCase; +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class ConventionScopedResultPathTest extends StrutsJUnit4TestCase { + + @Test + public void testParentPackageScopedResultPath() throws Exception { + String output = executeAction("/message.action"); + + assertTrue(output, output.contains("This is the scoped view Hello World")); + } + + @Override + protected String getConfigPath() { + return "struts-plugin.xml,struts-convention-scoped-result-path.xml"; + } +} +``` + +The root `message-success.ftl` ("This is the view …") stays where it is: if the override is ignored, that file +is picked and the assertion fails. + +- [ ] **Step 4: Run the tests to verify they fail** + +```bash +./mvnw test -DskipAssembly -pl plugins/convention -Dtest='DefaultResultMapBuilderTest,ConventionScopedResultPathTest' > /tmp/ww5770-t3.log 2>&1; echo "exit=$?"; grep -E "Tests run:|FAIL|testScoped|testResultPathAnnotationWins|testParentPackage" /tmp/ww5770-t3.log | head -20 +``` +Expected: non-zero exit; `testScopedResultPathFromParentPackage`, `testScopedResultPathWithoutTrailingSlash` +and `testParentPackageScopedResultPath` fail (the EasyMock servlet context sees `/WEB-INF/location/namespace/`; +the integration output is "This is the view Hello World"). `testResultPathAnnotationWinsOverScopedResultPath` +already passes — that is expected, it guards precedence. If `ConventionScopedResultPathTest` fails at startup +instead of on the assertion, read the cause before going on — it is the spec's startup-ordering check. + +- [ ] **Step 5: Pass the package at the call site** + +`DefaultResultMapBuilder.java:159`: + +```java + String defaultResultPath = conventionsService.determineResultPath(actionClass, packageConfig); +``` + +- [ ] **Step 6: Run the module to verify everything passes** + +```bash +./mvnw test -DskipAssembly -pl plugins/convention > /tmp/ww5770-t3.log 2>&1; echo "exit=$?"; grep -E "Tests run:|FAIL" /tmp/ww5770-t3.log | tail -5 +``` +Expected: `exit=0`. + +- [ ] **Step 7: Commit** + +```bash +git add plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml plugins/convention/src/test/resources/scoped/message-success.ftl +git diff --cached --name-only +git commit -m "WW-5770 feat(convention): honour the scoped result path when building results + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Runtime — `ConventionUnknownHandler` uses the package's result path + +**Files:** +- Modify: `plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java` (lines 134, 282, 385-386) +- Modify: `plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java` + +**Interfaces:** +- Consumes: `ConventionsService#determineResultPath(ActionConfig, PackageConfig)` (Task 2). +- Produces: `protected String determinePath(ActionConfig actionConfig, PackageConfig packageConfig, String namespace)` + replacing `determinePath(ActionConfig, String)`. + +- [ ] **Step 1: Write the failing tests** + +In `ConventionUnknownHandlerTest` (JUnit 3 `TestCase`, EasyMock static imports already present), add imports +`java.util.ArrayList`, `java.util.List`, `org.apache.struts2.convention.actions.NoAnnotationAction`, and these +tests plus helpers: + +```java + private static final String DEFAULT_PARENT = "convention-default"; + + public void testUnknownActionUsesScopedResultPathOfDefaultParentPackage() throws Exception { + List requested = new ArrayList<>(); + ConventionUnknownHandler handler = scopedHandler(recordingServletContext(requested), + packageWithResultPath(DEFAULT_PARENT, "", "/WEB-INF/scoped/"), null); + + handler.handleUnknownAction("", "does-not-exist"); + + assertFalse(requested.isEmpty()); + for (String path : requested) { + assertTrue(path, path.startsWith("/WEB-INF/scoped/does-not-exist")); + } + } + + public void testUnknownActionWithoutOverrideUsesGlobalResultPath() throws Exception { + List requested = new ArrayList<>(); + ConventionUnknownHandler handler = scopedHandler(recordingServletContext(requested), + new PackageConfig.Builder(DEFAULT_PARENT).build(), null); + + handler.handleUnknownAction("", "does-not-exist"); + + assertFalse(requested.isEmpty()); + for (String path : requested) { + assertTrue(path, path.startsWith("/WEB-INF/content/does-not-exist")); + } + } + + public void testUnknownResultUsesScopedResultPathOfActionPackage() throws Exception { + List requested = new ArrayList<>(); + PackageConfig actionPackage = packageWithResultPath("admin", "/admin", "/WEB-INF/scoped/"); + ConventionUnknownHandler handler = scopedHandler(recordingServletContext(requested), + new PackageConfig.Builder(DEFAULT_PARENT).build(), actionPackage); + ActionConfig actionConfig = new ActionConfig.Builder("admin", "foo", NoAnnotationAction.class.getName()).build(); + + handler.handleUnknownResult(null, "foo", actionConfig, "success"); + + assertFalse(requested.isEmpty()); + for (String path : requested) { + assertTrue(path, path.startsWith("/WEB-INF/scoped/admin/")); + } + } + + public void testDeterminePathAppendsSlashToScopedResultPath() { + PackageConfig parent = packageWithResultPath(DEFAULT_PARENT, "", "/WEB-INF/scoped"); + ConventionUnknownHandler handler = scopedHandler(null, parent, null); + + assertEquals("/WEB-INF/scoped/admin/", handler.determinePath(null, parent, "/admin")); + } + + private ServletContext recordingServletContext(List requested) throws Exception { + ServletContext servletContext = createNiceMock(ServletContext.class); + expect(servletContext.getResource(anyString())).andAnswer(() -> { + requested.add((String) getCurrentArguments()[0]); + return null; + }).anyTimes(); + replay(servletContext); + return servletContext; + } + + private static PackageConfig packageWithResultPath(String name, String namespace, String resultPath) { + PackageConfig parent = new PackageConfig.Builder(name + "-base") + .addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, resultPath) + .build(); + return new PackageConfig.Builder(name).namespace(namespace).addParent(parent).build(); + } + + private ConventionUnknownHandler scopedHandler(ServletContext servletContext, PackageConfig defaultParent, + PackageConfig actionPackage) { + Configuration configuration = createNiceMock(Configuration.class); + expect(configuration.getPackageConfig(DEFAULT_PARENT)).andStubReturn(defaultParent); + if (actionPackage != null) { + expect(configuration.getPackageConfig(actionPackage.getName())).andStubReturn(actionPackage); + } + RuntimeConfiguration runtime = createNiceMock(RuntimeConfiguration.class); + expect(configuration.getRuntimeConfiguration()).andStubReturn(runtime); + + ConventionsServiceImpl service = new ConventionsServiceImpl("/WEB-INF/content/"); + service.setScopedConstantProvider(name -> "/WEB-INF/content/"); + Container container = createNiceMock(Container.class); + expect(container.getInstance(String.class, ConventionConstants.CONVENTION_CONVENTIONS_SERVICE)).andStubReturn("test"); + expect(container.getInstance(ConventionsService.class, "test")).andStubReturn(service); + + replay(configuration, runtime, container); + + return new ConventionUnknownHandler(configuration, null, servletContext, container, DEFAULT_PARENT, + "false", "-"); + } +``` + +In the existing `container()` helper, replace + +```java + ActionConfig actionConfig = null; + expect(service.determineResultPath(actionConfig)).andReturn(""); +``` + +with + +```java + expect(service.determineResultPath((ActionConfig) null, packageConfiguration)).andReturn(""); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +./mvnw test -DskipAssembly -pl plugins/convention -Dtest=ConventionUnknownHandlerTest > /tmp/ww5770-t4.log 2>&1; echo "exit=$?"; grep -E "ERROR|Tests run|FAIL" /tmp/ww5770-t4.log | head -20 +``` +Expected: non-zero exit — compilation error on the three-argument `determinePath`. (Without that test the two +`ScopedResultPath` tests fail on paths starting `/WEB-INF/content/`.) + +- [ ] **Step 3: Change `determinePath` and its two callers** + +```java + protected String determinePath(ActionConfig actionConfig, PackageConfig packageConfig, String namespace) { + String finalPrefix = conventionsService.determineResultPath(actionConfig, packageConfig); +``` + +Add to its Javadoc, after `@param actionConfig`: + +```java + * @param packageConfig The package whose scoped result path applies: the action's package, or the default + * parent package when there is no action. +``` + +Line 134 in `handleUnknownAction`: + +```java + String pathPrefix = determinePath(null, parentPackage, namespace); +``` + +Line 282 in `handleUnknownResult`: + +```java + String pathPrefix = determinePath(actionConfig, pkg, ns); +``` + +- [ ] **Step 4: Run the module to verify everything passes** + +```bash +./mvnw test -DskipAssembly -pl plugins/convention > /tmp/ww5770-t4.log 2>&1; echo "exit=$?"; grep -E "Tests run:|FAIL" /tmp/ww5770-t4.log | tail -5 +``` +Expected: `exit=0`. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java +git diff --cached --name-only +git commit -m "WW-5770 feat(convention): honour the scoped result path for unknown actions and results + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Full verification + +- [ ] **Step 1: Compile the whole reactor and run core + Convention tests** + +```bash +./mvnw install -DskipAssembly -DskipTests > /tmp/ww5770-reactor.log 2>&1; echo "exit=$?" +./mvnw test -DskipAssembly -pl core,plugins/convention,plugins/javatemplates > /tmp/ww5770-tests.log 2>&1; echo "exit=$?"; grep -E "Tests run:.*Fail|BUILD" /tmp/ww5770-tests.log | tail -8 +``` +Expected: both `exit=0`. The javatemplates module proves its `name -> null` lambdas still compile. + +- [ ] **Step 2: Check for stray files** + +```bash +git status --short +``` +Expected: no `??` entries from this work (review agents leave probe tests behind). + +--- + +### Task 6: struts-site documentation + +Separate repository: `~/Projects/Apache/struts-site`, branch `WW-5770-scoped-convention-result-path` from `main`. + +**Files:** +- Modify: `source/core-developers/package-configuration.md` (the list after "Since Struts 7.5.0 these core constants are scopable:", line ~120) +- Modify: `source/plugins/convention/index.md` (end of "### ResultPath annotation", line ~641) + +- [ ] **Step 1: List the constant on the package-configuration page** + +After the core-constants bullet list, add: + +```markdown +The [Convention plugin](../plugins/convention/) makes `struts.convention.result.path` scopable, see +[Result path per package](../plugins/convention/#result-path-per-package). +``` + +- [ ] **Step 2: Document it on the Convention page** + +After the last paragraph of "### ResultPath annotation": + +~~~markdown +#### Result path per package + +Since Struts 7.5.0 a parent package can set the result path for all actions placed under it with a +[scoped constant](../../core-developers/package-configuration#scoped-constants): + +```xml + + + +``` + +The result path of an action is taken from, in order: + +1. `@ResultPath` on the action class or its Java package +2. the `struts.convention.result.path` scoped constant of the action's package, including inherited ones +3. the global `struts.convention.result.path` constant + +A request without an action class, served straight from a template, uses the scoped constant of the default +parent package (`struts.convention.default.parent.package`). +~~~ + +- [ ] **Step 3: Check both anchors exist** + +```bash +grep -n "^## Scoped constants" ~/Projects/Apache/struts-site/source/core-developers/package-configuration.md +grep -n "^#### Result path per package" ~/Projects/Apache/struts-site/source/plugins/convention/index.md +``` +Expected: one hit each, matching the `#scoped-constants` and `#result-path-per-package` link targets. + +- [ ] **Step 4: Commit** + +```bash +git -C ~/Projects/Apache/struts-site add source/core-developers/package-configuration.md source/plugins/convention/index.md +git -C ~/Projects/Apache/struts-site commit -m "WW-5770 docs: document the scopable Convention result path + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +## After the tasks + +- Run `/security-review` on the struts branch diff before `gh pr create` (CLAUDE.md), reading `SECURITY.md` and + `THREAT_MODEL.md` first. +- PR titles: `WW-5770 Make struts.convention.result.path scopable per package` (struts) and + `WW-5770 docs: document the scopable Convention result path` (struts-site); body links + `Fixes [WW-5770](https://issues.apache.org/jira/browse/WW-5770)`. +- 7.5.0 migration notes (Version Notes, at release time): new default method + `ScopedConstantProvider.getValue(String, PackageConfig)`; `ConventionUnknownHandler.determinePath` signature change. diff --git a/docs/superpowers/specs/2026-10-14-WW-5770-scoped-convention-result-path-design.md b/docs/superpowers/specs/2026-10-14-WW-5770-scoped-convention-result-path-design.md new file mode 100644 index 0000000000..f0ef78243f --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-WW-5770-scoped-convention-result-path-design.md @@ -0,0 +1,121 @@ +# WW-5770 — Scopable `struts.convention.result.path` + +**Ticket:** [WW-5770](https://issues.apache.org/jira/browse/WW-5770) — *Make struts.convention.result.path scopable per package* +**Target:** 7.5.0 (ticket fix version) +**Date:** 2026-10-14 +**Status:** Implemented — [PR #2009](https://github.com/apache/struts/pull/2009) + +## Problem + +WW-3476 added ``; WW-5768 made the UI theme its first adopter. Convention needs more than a +`ScopableConstants` bean, because `ScopedConstantProvider.getValue(name)` resolves the package from the current +`ActionInvocation`, and Convention reads the result path where there is none: + +1. **Startup** — `DefaultResultMapBuilder.build(...)` → `ConventionsService.determineResultPath(Class)`. No + invocation exists, so `getValue(name)` always returns the global value. `build()` does receive the action's + `PackageConfig` (`PackageBasedActionConfigBuilder` passes `pkgCfg.build()`, created with `addParent(parentPkg)`), + and its `getAllScopedConstants()` already includes the parent's overrides. +2. **Runtime** — `ConventionUnknownHandler.determinePath(...)` → `determineResultPath(ActionConfig)`, where the + `ActionConfig` is `null` for an action-less request. + +## Goals + +- A parent package sets the Convention result path for every action under it: + + ```xml + + + + ``` + +- Precedence: `@ResultPath` on the action class or package → the package's scoped constant (inherited ones + included) → the global `struts.convention.result.path`. +- Custom `ConventionsService` implementations keep compiling and behaving as before. +- A swapped-in `struts.scopedConstantProvider` governs the result path too, as it does the UI theme. + +## Non-goals + +- No deprecation of the one-argument `determineResultPath` methods: they still mean "the path without package + scope". +- No namespace-based package lookup for action-less requests (see Decisions). + +## Decisions + +- **Action-less requests use the default parent package.** `handleUnknownAction` has no `ActionConfig`; it resolves + the scoped value against `struts.convention.default.parent.package`, the package it already uses for result + types. An override on another package does not apply to action-less JSPs under its namespace. Namespace lookup + was rejected: several packages can share a namespace and Convention generates one per parent + namespace. +- **Resolve through `ScopedConstantProvider`, not `PackageConfig` directly.** Reading + `getAllScopedConstants()` in Convention would bypass a custom provider and duplicate the lookup. +- **`ConventionsServiceImpl` keeps its constructor.** The injected global `resultPath` stays the fallback for a + `null` package; the provider is called only when there is a package. Test sites that pass a package + (all of `DefaultResultMapBuilderTest`) inject a lambda provider through a helper. +- **The new provider method is `default`.** `ScopedConstantProvider` is stubbed as a lambda in 23 test sites + (`name -> null`); an abstract method would break them and any lambda-based user provider. The default returns + the package's value when it declares one, else `getValue(name)`. `StrutsScopedConstantProvider` overrides it: + a non-null package without the constant gives the global value, never the current invocation's package — a + nested request (``, chain) reaches `handleUnknownAction` with the caller's invocation still bound. + A `null` package still means `getValue(name)`. + +## Design + +### Core — `org.apache.struts2.config` + +- `ScopedConstantProvider` gains `default String getValue(String name, PackageConfig packageConfig)`, + `@since 7.5.0`: the package's `getAllScopedConstants()` value when present, else `getValue(name)`. A `null` + package goes straight to `getValue(name)`. The interface stays functional. +- `StrutsScopedConstantProvider` overrides it: name check, then the package's value or the global value; a `null` + package delegates to `getValue(name)`. + +### Convention plugin + +- `ConventionScopableConstants implements ScopableConstants` returns `Set.of(CONVENTION_RESULT_PATH)`, registered + in `struts-plugin.xml` as a named `ScopableConstants` bean (pattern: `StrutsUiScopableConstants`). +- `ConventionsService` gains two `default` methods: + - `determineResultPath(Class actionClass, PackageConfig packageConfig)` → `determineResultPath(actionClass)` + - `determineResultPath(ActionConfig actionConfig, PackageConfig packageConfig)` → `determineResultPath(actionConfig)` +- `ConventionsServiceImpl`: + - `@Inject setScopedConstantProvider(ScopedConstantProvider)` — setter, so the constructor is unchanged. + - `@ResultPath` handling extracted to a private `findAnnotatedResultPath(Class)`. + - Two-argument `Class` form: annotation if present; else, with a package, + `scopedConstantProvider.getValue(CONVENTION_RESULT_PATH, packageConfig)`; with a `null` package, `resultPath`. + - Two-argument `ActionConfig` form: `null` action → the package's scoped value (or `resultPath` for a `null` + package); otherwise load the class and delegate, keeping the `ClassNotFoundException` handling. + - One-argument forms are unchanged in behaviour. +- `DefaultResultMapBuilder.build(...)` calls `determineResultPath(actionClass, packageConfig)`. +- `ConventionUnknownHandler.determinePath(ActionConfig, PackageConfig, String namespace)` — signature change + (protected): `handleUnknownAction` passes `parentPackage`; `handleUnknownResult` passes the `pkg` it already + resolves from `actionConfig.getPackageName()`. + +### Error handling + +Nothing new. A misspelled name, or the constant used without the Convention plugin, already fails at startup in +`DefaultConfiguration.validateScopedConstants`. `${...}` substitution applies via WW-5767. A scoped value is +treated exactly like the global one, including trailing-slash handling. + +### Startup ordering — verify, don't assume + +`PackageBasedActionConfigBuilder` runs inside `loadPackages`; `StrutsScopedConstantProvider.setContainer` fetches +`Configuration` and collects scopable names when injected. The integration test below proves both are available at +that point, with the Convention `ScopableConstants` bean already registered. + +## Testing + +Each test goes into the existing file, in that file's style. + +| File (style) | Cases | +|---|---| +| `StrutsScopedConstantProviderTest` (`XWorkTestCase`) | `getValue(name, pkg)` returns an inherited override; an explicit package wins over the invocation's; a package without the constant and a `null` package fall back to `getValue(name)`; non-scopable name throws; a lambda provider gets the package lookup | +| `ConventionsServiceImplTest` — new (JUnit 4) | `@ResultPath` beats the override; override beats global; `null` package → global | +| `DefaultResultMapBuilderTest` (JUnit 4) | parent-package override moves results located at startup; no override → global path | +| `ConventionUnknownHandlerTest` (`TestCase`, EasyMock) | action-less request uses the default parent package's override; `handleUnknownResult` uses the action package's override; existing `determineResultPath(actionConfig)` expectations move to the two-argument form | +| Startup integration (harness located during planning) | the real convention `struts-plugin.xml` plus an XML parent package declaring the constant boots; a Convention action under it gets the overridden locations | + +## Docs and migration + +- **struts-site:** list the constant in the scoped-constants section of `core-developers/package-configuration.md`; + add a section to the Convention plugin page showing the precedence. +- **7.5.0 migration notes:** new default method `ScopedConstantProvider.getValue(String, PackageConfig)` (custom + providers may override it); `ConventionUnknownHandler.determinePath` signature change (an override without + `@Override` silently stops being called); subclasses of `ConventionsServiceImpl` overriding the one-argument + `determineResultPath` methods must override the two-argument forms, which the framework now calls. diff --git a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java new file mode 100644 index 0000000000..624a76b00e --- /dev/null +++ b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java @@ -0,0 +1,36 @@ +/* + * 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.convention; + +import org.apache.struts2.config.ScopableConstants; + +import java.util.Set; + +/** + * Lets a package override the Convention result path with {@code }. + * + * @since 7.5.0 + */ +public class ConventionScopableConstants implements ScopableConstants { + + @Override + public Set getNames() { + return Set.of(ConventionConstants.CONVENTION_RESULT_PATH); + } +} diff --git a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java index fd95c87d55..b63b3a0f89 100644 --- a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java +++ b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java @@ -131,7 +131,7 @@ public ActionConfig handleUnknownAction(String namespace, String actionName) } Map resultsByExtension = conventionsService.getResultTypesByExtension(parentPackage); - String pathPrefix = determinePath(null, namespace); + String pathPrefix = determinePath(null, parentPackage, namespace); ActionConfig actionConfig = null; // Try /idx/action.jsp if actionName is not empty, otherwise it will just be /.jsp @@ -279,7 +279,7 @@ public Result handleUnknownResult(ActionContext actionContext, String actionName PackageConfig pkg = configuration.getPackageConfig(actionConfig.getPackageName()); String ns = pkg.getNamespace(); - String pathPrefix = determinePath(actionConfig, ns); + String pathPrefix = determinePath(actionConfig, pkg, ns); Result result = scanResultsByExtension(ns, actionName, pathPrefix, resultCode, actionContext); @@ -379,11 +379,13 @@ protected String string(String... parts) { * * @param actionConfig (Optional) The might be a ConventionActionConfig, from which we can get the * default base result location of that specific action. + * @param packageConfig The package whose scoped result path applies: the action's package, or the default + * parent package when there is no action. * @param namespace The current URL namespace. * @return The path prefix and never null. */ - protected String determinePath(ActionConfig actionConfig, String namespace) { - String finalPrefix = conventionsService.determineResultPath(actionConfig); + protected String determinePath(ActionConfig actionConfig, PackageConfig packageConfig, String namespace) { + String finalPrefix = conventionsService.determineResultPath(actionConfig, packageConfig); if (!finalPrefix.endsWith("/")) { finalPrefix += "/"; diff --git a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java index 7a3ef6ec82..816f97d1e4 100644 --- a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java +++ b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java @@ -54,6 +54,32 @@ public interface ConventionsService { */ String determineResultPath(ActionConfig actionConfig); + /** + * Locates the result location like {@link #determineResultPath(Class)}, using the package's scoped + * {@code struts.convention.result.path} when the action carries no {@code @ResultPath}. + * + * @param actionClass The action class. + * @param packageConfig The package the action belongs to, or null for the global default. + * @return The result location. + * @since 7.5.0 + */ + default String determineResultPath(Class actionClass, PackageConfig packageConfig) { + return determineResultPath(actionClass); + } + + /** + * Locates the result location like {@link #determineResultPath(ActionConfig)}, using the package's scoped + * {@code struts.convention.result.path} when the action carries no {@code @ResultPath}. + * + * @param actionConfig (Optional) The configuration for the action, or null when there is no action. + * @param packageConfig The package whose scoped value applies, or null for the global default. + * @return The result location. + * @since 7.5.0 + */ + default String determineResultPath(ActionConfig actionConfig, PackageConfig packageConfig) { + return determineResultPath(actionConfig); + } + /** * Returns a mapping between the result type strings and the {@link ResultTypeConfig} instances * based on the {@link PackageConfig} given. diff --git a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java index fb58c4dbdf..91b14f1492 100644 --- a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java +++ b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java @@ -19,6 +19,7 @@ package org.apache.struts2.convention; import org.apache.struts2.config.ConfigurationException; +import org.apache.struts2.config.ScopedConstantProvider; import org.apache.struts2.config.entities.ActionConfig; import org.apache.struts2.config.entities.PackageConfig; import org.apache.struts2.config.entities.ResultTypeConfig; @@ -39,6 +40,7 @@ */ public class ConventionsServiceImpl implements ConventionsService { private String resultPath; + private ScopedConstantProvider scopedConstantProvider; /** * Constructs a new instance. @@ -51,54 +53,84 @@ public ConventionsServiceImpl(@Inject(ConventionConstants.CONVENTION_RESULT_PATH this.resultPath = resultPath; } + @Inject + public void setScopedConstantProvider(ScopedConstantProvider scopedConstantProvider) { + this.scopedConstantProvider = scopedConstantProvider; + } + /** * {@inheritDoc} */ public String determineResultPath(Class actionClass) { - String localResultPath = resultPath; - ResultPath resultPathAnnotation = AnnotationUtils.findAnnotation(actionClass, ResultPath.class); - if (resultPathAnnotation != null) { - if (resultPathAnnotation.value().equals("") && resultPathAnnotation.property().equals("")) { - throw new ConfigurationException("The ResultPath annotation must have either" + - " a value or property specified."); - } - - String property = resultPathAnnotation.property(); - if (property.equals("")) { - localResultPath = resultPathAnnotation.value(); - } else { - try { - ResourceBundle strutsBundle = ResourceBundle.getBundle("struts"); - localResultPath = strutsBundle.getString(property); - } catch (Exception e) { - throw new ConfigurationException("The action class [" + actionClass + "] defines" + - " a @ResultPath annotation and a property definition however the" + - " struts.properties could not be found in the classpath using ResourceBundle" + - " OR the bundle exists but the property [" + property + "] is not defined" + - " in the file.", e); - } - } - } + return determineResultPath(actionClass, null); + } - return localResultPath; + /** + * {@inheritDoc} + */ + @Override + public String determineResultPath(Class actionClass, PackageConfig packageConfig) { + String annotatedResultPath = findAnnotatedResultPath(actionClass); + return annotatedResultPath != null ? annotatedResultPath : scopedResultPath(packageConfig); } /** * {@inheritDoc} */ public String determineResultPath(ActionConfig actionConfig) { + return determineResultPath(actionConfig, null); + } + + /** + * {@inheritDoc} + */ + @Override + public String determineResultPath(ActionConfig actionConfig, PackageConfig packageConfig) { if (actionConfig == null) { - return resultPath; + return scopedResultPath(packageConfig); } try { - return determineResultPath(ClassLoaderUtil.loadClass(actionConfig.getClassName(), this.getClass())); + return determineResultPath(ClassLoaderUtil.loadClass(actionConfig.getClassName(), this.getClass()), packageConfig); } catch (ClassNotFoundException e) { throw new RuntimeException("Invalid action class configuration that references an unknown " + "class named [" + actionConfig.getClassName() + "]", e); } } + private String scopedResultPath(PackageConfig packageConfig) { + if (packageConfig == null) { + return resultPath; + } + return scopedConstantProvider.getValue(ConventionConstants.CONVENTION_RESULT_PATH, packageConfig); + } + + private String findAnnotatedResultPath(Class actionClass) { + ResultPath resultPathAnnotation = AnnotationUtils.findAnnotation(actionClass, ResultPath.class); + if (resultPathAnnotation == null) { + return null; + } + if (resultPathAnnotation.value().equals("") && resultPathAnnotation.property().equals("")) { + throw new ConfigurationException("The ResultPath annotation must have either" + + " a value or property specified."); + } + + String property = resultPathAnnotation.property(); + if (property.equals("")) { + return resultPathAnnotation.value(); + } + try { + ResourceBundle strutsBundle = ResourceBundle.getBundle("struts"); + return strutsBundle.getString(property); + } catch (Exception e) { + throw new ConfigurationException("The action class [" + actionClass + "] defines" + + " a @ResultPath annotation and a property definition however the" + + " struts.properties could not be found in the classpath using ResourceBundle" + + " OR the bundle exists but the property [" + property + "] is not defined" + + " in the file.", e); + } + } + /** * {@inheritDoc} */ diff --git a/plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java b/plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java index 380b77994f..2eac72671c 100644 --- a/plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java +++ b/plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java @@ -156,7 +156,7 @@ public Map build(Class actionClass, PackageConfig packageConfig) { // Get the default result location from the annotation or configuration - String defaultResultPath = conventionsService.determineResultPath(actionClass); + String defaultResultPath = conventionsService.determineResultPath(actionClass, packageConfig); // Add a slash if (!defaultResultPath.endsWith("/")) { diff --git a/plugins/convention/src/main/resources/struts-plugin.xml b/plugins/convention/src/main/resources/struts-plugin.xml index afafe0f667..acffb1616b 100644 --- a/plugins/convention/src/main/resources/struts-plugin.xml +++ b/plugins/convention/src/main/resources/struts-plugin.xml @@ -38,6 +38,8 @@ class="org.apache.struts2.convention.DefaultInterceptorMapBuilder"/> + diff --git a/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java new file mode 100644 index 0000000000..2d836da4b0 --- /dev/null +++ b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java @@ -0,0 +1,40 @@ +/* + * 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.convention; + +import actions.MessageAction; +import org.apache.struts2.junit.StrutsJUnit4TestCase; +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class ConventionScopedResultPathTest extends StrutsJUnit4TestCase { + + @Test + public void testParentPackageScopedResultPath() throws Exception { + String output = executeAction("/message.action"); + + assertTrue(output, output.contains("This is the scoped view Hello World")); + } + + @Override + protected String getConfigPath() { + return "struts-plugin.xml,struts-convention-scoped-result-path.xml"; + } +} diff --git a/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java index ea33f10cd6..65360f262d 100644 --- a/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java +++ b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java @@ -23,14 +23,17 @@ import org.apache.struts2.config.entities.ActionConfig; import org.apache.struts2.config.entities.PackageConfig; import org.apache.struts2.config.entities.ResultTypeConfig; +import org.apache.struts2.convention.actions.NoAnnotationAction; import org.apache.struts2.inject.Container; import junit.framework.TestCase; import org.apache.struts2.result.ServletDispatcherResult; import jakarta.servlet.ServletContext; import java.net.URL; +import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Set; @@ -38,6 +41,8 @@ public class ConventionUnknownHandlerTest extends TestCase { + private static final String DEFAULT_PARENT = "convention-default"; + private PackageConfig packageConfiguration; public void testCanonicalizeShouldReturnNullWhenPathIsNull() throws Exception { @@ -130,6 +135,93 @@ public void testFindResourceShouldSetCanonicalizedPathOnResource() throws Except assertEquals("/canonicalized/path.default_extension", resource.path); } + public void testUnknownActionUsesScopedResultPathOfDefaultParentPackage() throws Exception { + List requested = new ArrayList<>(); + ConventionUnknownHandler handler = scopedHandler(recordingServletContext(requested), + packageWithResultPath(DEFAULT_PARENT, "", "/WEB-INF/scoped/"), null); + + handler.handleUnknownAction("", "does-not-exist"); + + assertFalse(requested.isEmpty()); + for (String path : requested) { + assertTrue(path, path.startsWith("/WEB-INF/scoped/does-not-exist")); + } + } + + public void testUnknownActionWithoutOverrideUsesGlobalResultPath() throws Exception { + List requested = new ArrayList<>(); + ConventionUnknownHandler handler = scopedHandler(recordingServletContext(requested), + new PackageConfig.Builder(DEFAULT_PARENT).build(), null); + + handler.handleUnknownAction("", "does-not-exist"); + + assertFalse(requested.isEmpty()); + for (String path : requested) { + assertTrue(path, path.startsWith("/WEB-INF/content/does-not-exist")); + } + } + + public void testUnknownResultUsesScopedResultPathOfActionPackage() throws Exception { + List requested = new ArrayList<>(); + PackageConfig actionPackage = packageWithResultPath("admin", "/admin", "/WEB-INF/scoped/"); + ConventionUnknownHandler handler = scopedHandler(recordingServletContext(requested), + new PackageConfig.Builder(DEFAULT_PARENT).build(), actionPackage); + ActionConfig actionConfig = new ActionConfig.Builder("admin", "foo", NoAnnotationAction.class.getName()).build(); + + handler.handleUnknownResult(null, "foo", actionConfig, "success"); + + assertFalse(requested.isEmpty()); + for (String path : requested) { + assertTrue(path, path.startsWith("/WEB-INF/scoped/admin/")); + } + } + + public void testDeterminePathAppendsSlashToScopedResultPath() { + PackageConfig parent = packageWithResultPath(DEFAULT_PARENT, "", "/WEB-INF/scoped"); + ConventionUnknownHandler handler = scopedHandler(null, parent, null); + + assertEquals("/WEB-INF/scoped/admin/", handler.determinePath(null, parent, "/admin")); + } + + private ServletContext recordingServletContext(List requested) throws Exception { + ServletContext servletContext = createNiceMock(ServletContext.class); + expect(servletContext.getResource(anyString())).andAnswer(() -> { + requested.add((String) getCurrentArguments()[0]); + return null; + }).anyTimes(); + replay(servletContext); + return servletContext; + } + + private static PackageConfig packageWithResultPath(String name, String namespace, String resultPath) { + PackageConfig parent = new PackageConfig.Builder(name + "-base") + .addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, resultPath) + .build(); + return new PackageConfig.Builder(name).namespace(namespace).addParent(parent).build(); + } + + private ConventionUnknownHandler scopedHandler(ServletContext servletContext, PackageConfig defaultParent, + PackageConfig actionPackage) { + Configuration configuration = createNiceMock(Configuration.class); + expect(configuration.getPackageConfig(DEFAULT_PARENT)).andStubReturn(defaultParent); + if (actionPackage != null) { + expect(configuration.getPackageConfig(actionPackage.getName())).andStubReturn(actionPackage); + } + RuntimeConfiguration runtime = createNiceMock(RuntimeConfiguration.class); + expect(configuration.getRuntimeConfiguration()).andStubReturn(runtime); + + ConventionsServiceImpl service = new ConventionsServiceImpl("/WEB-INF/content/"); + service.setScopedConstantProvider(name -> "/WEB-INF/content/"); + Container container = createNiceMock(Container.class); + expect(container.getInstance(String.class, ConventionConstants.CONVENTION_CONVENTIONS_SERVICE)).andStubReturn("test"); + expect(container.getInstance(ConventionsService.class, "test")).andStubReturn(service); + + replay(configuration, runtime, container); + + return new ConventionUnknownHandler(configuration, null, servletContext, container, DEFAULT_PARENT, + "false", "-"); + } + private Configuration configuration(final String packageName) { final Configuration mock = createNiceMock(Configuration.class); @@ -151,8 +243,7 @@ private Container container() { expect(mock.getInstance(String.class, ConventionConstants.CONVENTION_CONVENTIONS_SERVICE)).andReturn("test"); expect(mock.getInstance(ConventionsService.class, "test")).andStubReturn(service); - ActionConfig actionConfig = null; - expect(service.determineResultPath(actionConfig)).andReturn(""); + expect(service.determineResultPath((ActionConfig) null, packageConfiguration)).andReturn(""); Map results = new HashMap<>(); results.put("jsp", new ResultTypeConfig.Builder("dispatcher", ServletDispatcherResult.class.getName()).build()); expect(service.getResultTypesByExtension(packageConfiguration)).andReturn(results); diff --git a/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionsServiceImplTest.java b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionsServiceImplTest.java new file mode 100644 index 0000000000..228b99e757 --- /dev/null +++ b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionsServiceImplTest.java @@ -0,0 +1,151 @@ +/* + * 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.convention; + +import org.apache.struts2.config.entities.ActionConfig; +import org.apache.struts2.config.entities.PackageConfig; +import org.apache.struts2.config.entities.ResultTypeConfig; +import org.apache.struts2.convention.actions.NoAnnotationAction; +import org.apache.struts2.convention.actions.resultpath.ClassLevelResultPathAction; +import org.apache.struts2.convention.actions.resultpath.PackageLevelResultPathAction; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ConventionsServiceImplTest { + + private static final String GLOBAL = "/WEB-INF/content/"; + private static final String SCOPED = "/WEB-INF/admin/"; + + private ConventionsServiceImpl service; + private PackageConfig scopedPackage; + private PackageConfig plainPackage; + + @Before + public void setUp() { + service = new ConventionsServiceImpl(GLOBAL); + service.setScopedConstantProvider(name -> GLOBAL); + scopedPackage = childOf(SCOPED); + plainPackage = new PackageConfig.Builder("plain").build(); + } + + @Test + public void scopedValueOfParentPackageWinsOverGlobal() { + assertEquals(SCOPED, service.determineResultPath(NoAnnotationAction.class, scopedPackage)); + } + + @Test + public void classLevelResultPathWinsOverScopedValue() { + assertEquals("/class-level", service.determineResultPath(ClassLevelResultPathAction.class, scopedPackage)); + } + + @Test + public void packageLevelResultPathWinsOverScopedValue() { + assertEquals("/package-level", service.determineResultPath(PackageLevelResultPathAction.class, scopedPackage)); + } + + @Test + public void packageWithoutScopedValueGivesGlobal() { + assertEquals(GLOBAL, service.determineResultPath(NoAnnotationAction.class, plainPackage)); + } + + @Test + public void emptyScopedValueIsUsedAsIs() { + assertEquals("", service.determineResultPath(NoAnnotationAction.class, childOf(""))); + } + + @Test + public void nullPackageGivesGlobalWithoutConsultingProvider() { + ConventionsServiceImpl withoutProvider = new ConventionsServiceImpl(GLOBAL); + + assertEquals(GLOBAL, withoutProvider.determineResultPath(NoAnnotationAction.class, null)); + assertEquals(GLOBAL, withoutProvider.determineResultPath(NoAnnotationAction.class)); + assertEquals(GLOBAL, withoutProvider.determineResultPath((ActionConfig) null, null)); + assertEquals(GLOBAL, withoutProvider.determineResultPath((ActionConfig) null)); + } + + @Test + public void actionlessRequestUsesScopedValueOfPackage() { + assertEquals(SCOPED, service.determineResultPath((ActionConfig) null, scopedPackage)); + } + + @Test + public void actionConfigResolvesThroughItsClass() { + ActionConfig annotated = actionConfig(ClassLevelResultPathAction.class.getName()); + ActionConfig plain = actionConfig(NoAnnotationAction.class.getName()); + + assertEquals("/class-level", service.determineResultPath(annotated, scopedPackage)); + assertEquals(SCOPED, service.determineResultPath(plain, scopedPackage)); + } + + @Test + public void unknownActionClassStillFails() { + ActionConfig missing = actionConfig("com.example.MissingAction"); + + RuntimeException e = assertThrows(RuntimeException.class, + () -> service.determineResultPath(missing, scopedPackage)); + assertTrue(e.getMessage().contains("[com.example.MissingAction]")); + } + + @Test + public void customServiceWithOnlyOneArgumentMethodsKeepsItsBehaviour() { + ConventionsService custom = new ConventionsService() { + @Override + public String determineResultPath(Class actionClass) { + return "/custom/"; + } + + @Override + public String determineResultPath(ActionConfig actionConfig) { + return "/custom/"; + } + + @Override + public Map getResultTypesByExtension(PackageConfig packageConfig) { + return Collections.emptyMap(); + } + }; + + assertEquals("/custom/", custom.determineResultPath(NoAnnotationAction.class, scopedPackage)); + assertEquals("/custom/", custom.determineResultPath((ActionConfig) null, scopedPackage)); + } + + @Test + public void resultPathIsScopable() { + assertEquals(Set.of(ConventionConstants.CONVENTION_RESULT_PATH), new ConventionScopableConstants().getNames()); + } + + private static PackageConfig childOf(String scopedResultPath) { + PackageConfig parent = new PackageConfig.Builder("admin-base") + .addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, scopedResultPath) + .build(); + return new PackageConfig.Builder("admin-base#/admin").namespace("/admin").addParent(parent).build(); + } + + private static ActionConfig actionConfig(String className) { + return new ActionConfig.Builder("admin-base#/admin", "foo", className).build(); + } +} diff --git a/plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java b/plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java index 53e93f670c..0a4351e6e9 100644 --- a/plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java +++ b/plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java @@ -64,7 +64,7 @@ public void testBuild() { // Test with a slash PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(NoAnnotationAction.class, null, "action", packageConfig); verify(context, "/WEB-INF/location", results, false); @@ -72,7 +72,7 @@ public void testBuild() { // Test without a slash context = mockServletContext("/WEB-INF/location"); packageConfig = createPackageConfigBuilder("namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); results = builder.build(NoAnnotationAction.class, null, "action", packageConfig); verify(context, "/WEB-INF/location", results, false); @@ -84,7 +84,7 @@ public void testResultOverride() throws Exception { // Test with a slash PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Action actionAnn = OverrideResultAction.class.getMethod("execute").getAnnotation(Action.class); Map results = builder.build(OverrideResultAction.class, actionAnn, "action100", packageConfig); @@ -97,7 +97,7 @@ public void testResultOverride() throws Exception { public void testGlobalResult() { ServletContext context = mockServletContext("/WEB-INF/location"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); ResultTypeConfig resultType = new ResultTypeConfig.Builder("dispatcher", ServletDispatcherResult.class.getName()). @@ -131,7 +131,7 @@ public void testGlobalResultOverride() { EasyMock.expect(context.getResourcePaths(resultPath + "/namespace/")).andReturn(resources); EasyMock.replay(context); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); ResultTypeConfig resultType = new ResultTypeConfig.Builder("dispatcher", ServletDispatcherResult.class.getName()). @@ -161,7 +161,7 @@ public void testNull() { // Test with a slash PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(NoAnnotationAction.class, null, "action", packageConfig); assertEquals(0, results.size()); @@ -174,7 +174,40 @@ public void testResultPath() { // Test with a result path PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/not-used"); + this.conventionsService = newConventionsService("/not-used"); + DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); + Map results = builder.build(ClassLevelResultPathAction.class, null, "action", packageConfig); + verify(context, "/class-level", results, false); + } + + @Test + public void testScopedResultPathFromParentPackage() { + ServletContext context = mockServletContext("/WEB-INF/admin"); + + PackageConfig packageConfig = createPackageConfigBuilder("/namespace", "/WEB-INF/admin/"); + this.conventionsService = newConventionsService("/WEB-INF/location"); + DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); + Map results = builder.build(NoAnnotationAction.class, null, "action", packageConfig); + verify(context, "/WEB-INF/admin", results, false); + } + + @Test + public void testScopedResultPathWithoutTrailingSlash() { + ServletContext context = mockServletContext("/WEB-INF/admin"); + + PackageConfig packageConfig = createPackageConfigBuilder("/namespace", "/WEB-INF/admin"); + this.conventionsService = newConventionsService("/WEB-INF/location"); + DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); + Map results = builder.build(NoAnnotationAction.class, null, "action", packageConfig); + verify(context, "/WEB-INF/admin", results, false); + } + + @Test + public void testResultPathAnnotationWinsOverScopedResultPath() { + ServletContext context = mockServletContext("/class-level"); + + PackageConfig packageConfig = createPackageConfigBuilder("/namespace", "/WEB-INF/admin/"); + this.conventionsService = newConventionsService("/not-used"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(ClassLevelResultPathAction.class, null, "action", packageConfig); verify(context, "/class-level", results, false); @@ -193,7 +226,7 @@ public void testFromServletContextWithBadNames() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig); assertEquals(0, results.size()); @@ -215,7 +248,7 @@ public void testFromServletContext() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig); assertEquals(4, results.size()); @@ -250,7 +283,7 @@ public void testFromServletContextNotFlat() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); builder.setFlatResultLayout("false"); @@ -286,7 +319,7 @@ public void testIgnoreFilesWithoutName() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig); assertEquals(1, results.size()); @@ -309,7 +342,7 @@ public void testClassLevelSingleResultAnnotation() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(ClassLevelResultAction.class, null, "class-level-result", packageConfig); assertEquals(1, results.size()); @@ -333,7 +366,7 @@ public void testClassLevelInheritedSingleResultAnnotation() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(InheritedResultExtends.class, null, "result-inheritance-extends", packageConfig); assertEquals(1, results.size()); @@ -357,7 +390,7 @@ public void testClassLevelOverwriteInheritedSingleResultAnnotation() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(OverrideInheritedResultExtends.class, null, "result-inheritance-extends", packageConfig); assertEquals(1, results.size()); @@ -383,7 +416,7 @@ public void testClassLevelMultipleResultAnnotation() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(ClassLevelResultsAction.class, null, "class-level-results", packageConfig); assertEquals(4, results.size()); @@ -423,7 +456,7 @@ public void testClassLevelInheritanceMultipleResultAnnotation() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(InheritedResultsExtends.class, null, "class-level-results", packageConfig); assertEquals(4, results.size()); @@ -463,7 +496,7 @@ public void testActionLevelSingleResultAnnotation() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(ActionLevelResultAction.class, getAnnotation(ActionLevelResultAction.class, "execute", Action.class), "action-level-result", packageConfig); assertEquals(1, results.size()); @@ -487,7 +520,7 @@ public void testActionLevelMultipleResultAnnotation() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(ActionLevelResultsAction.class, getAnnotation(ActionLevelResultsAction.class, "execute", Action.class), "action-level-results", packageConfig); assertEquals(4, results.size()); @@ -523,7 +556,7 @@ public void testActionLevelMultipleResultNamesAnnotation() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(ActionLevelResultsNamesAction.class, getAnnotation(ActionLevelResultsNamesAction.class, "execute", Action.class), "action-level-results", packageConfig); assertEquals(4, results.size()); @@ -559,7 +592,7 @@ public void testActionLevelMultipleResultNamesAnnotationNoName() { PackageConfig packageConfig = createPackageConfigBuilder("/namespace"); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/location"); + this.conventionsService = newConventionsService("/WEB-INF/location"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(ActionLevelResultsNamesAction.class, getAnnotation(ActionLevelResultsNamesAction.class, "noname", Action.class), "action-level-results", packageConfig); assertEquals(1, results.size()); @@ -581,7 +614,7 @@ public void testClassPath() { PackageConfig packageConfig = new PackageConfig.Builder("package"). defaultResultType("dispatcher").addResultTypeConfig(resultType).build(); - this.conventionsService = new ConventionsServiceImpl("/WEB-INF/component"); + this.conventionsService = newConventionsService("/WEB-INF/component"); DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, container, "dispatcher,velocity,freemarker"); Map results = builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig); assertEquals(4, results.size()); @@ -599,7 +632,17 @@ public void testClassPath() { assertEquals("/WEB-INF/component/no-annotation-foo.ftl", results.get("foo").getParams().get("location")); } + private ConventionsService newConventionsService(String resultPath) { + ConventionsServiceImpl service = new ConventionsServiceImpl(resultPath); + service.setScopedConstantProvider(name -> resultPath); + return service; + } + private PackageConfig createPackageConfigBuilder(String namespace) { + return createPackageConfigBuilder(namespace, null); + } + + private PackageConfig createPackageConfigBuilder(String namespace, String scopedResultPath) { ResultTypeConfig resultType = new ResultTypeConfig.Builder("dispatcher", "org.apache.struts2.result.ServletDispatcherResult"). addParam("key", "value").addParam("key1", "value1").defaultResultParam("location").build(); @@ -609,12 +652,18 @@ private PackageConfig createPackageConfigBuilder(String namespace) { ResultTypeConfig ftlResultType = new ResultTypeConfig.Builder("freemarker", "org.apache.struts2.views.freemarker.FreemarkerResult").defaultResultParam("location").build(); - return new PackageConfig.Builder("package"). + PackageConfig.Builder builder = new PackageConfig.Builder("package"). namespace(namespace). defaultResultType("dispatcher"). addResultTypeConfig(resultType). addResultTypeConfig(redirect). - addResultTypeConfig(ftlResultType).build(); + addResultTypeConfig(ftlResultType); + if (scopedResultPath != null) { + builder.addParent(new PackageConfig.Builder("scoped-parent") + .addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, scopedResultPath) + .build()); + } + return builder.build(); } private ServletContext mockServletContext(String resultPath) { diff --git a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java index 1d21061b35..437610adcf 100644 --- a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java +++ b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java @@ -173,7 +173,7 @@ public Container getContainer() { of.setContainer(mockContainer); mockContainer.setActionNameBuilder(actionNameBuilder); - mockContainer.setConventionsService(new ConventionsServiceImpl("")); + mockContainer.setConventionsService(newConventionsService()); // Create the builder with a package that will trigger NoClassDefFoundError simulation PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder( @@ -229,7 +229,7 @@ public Container getContainer() { of.setContainer(mockContainer); mockContainer.setActionNameBuilder(actionNameBuilder); - mockContainer.setConventionsService(new ConventionsServiceImpl("")); + mockContainer.setConventionsService(newConventionsService()); PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder( configuration, mockContainer, of, "false", "struts-default", "false"); @@ -284,7 +284,7 @@ public Container getContainer() { of.setContainer(mockContainer); mockContainer.setActionNameBuilder(actionNameBuilder); - mockContainer.setConventionsService(new ConventionsServiceImpl("")); + mockContainer.setConventionsService(newConventionsService()); PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder( configuration, mockContainer, of, "false", "struts-default", "false"); @@ -332,7 +332,7 @@ public Container getContainer() { of.setContainer(mockContainer); mockContainer.setActionNameBuilder(actionNameBuilder); - mockContainer.setConventionsService(new ConventionsServiceImpl("")); + mockContainer.setConventionsService(newConventionsService()); PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder( configuration, mockContainer, of, "false", "struts-default", "false"); @@ -495,7 +495,7 @@ public Container getContainer() { of.setContainer(mockContainer); mockContainer.setActionNameBuilder(actionNameBuilder); - mockContainer.setConventionsService(new ConventionsServiceImpl("")); + mockContainer.setConventionsService(newConventionsService()); PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder( configuration, mockContainer, of, "false", "struts-default", "false"); @@ -547,7 +547,7 @@ private Map buildActions(String actionPackages, ObjectFact final DummyContainer mockContainer = new DummyContainer(); mockContainer.setActionNameBuilder(new SEOActionNameBuilder("true", "-")); - mockContainer.setConventionsService(new ConventionsServiceImpl("")); + mockContainer.setConventionsService(newConventionsService()); mockContainer.setResultMapBuilder((actionClass, annotation, actionName, packageConfig) -> new HashMap<>()); mockContainer.setInterceptorMapBuilder((actionClass, builder, actionName, annotation) -> new ArrayList<>()); Configuration configuration = new DefaultConfiguration() { @@ -839,7 +839,7 @@ public Container getContainer() { mockContainer.setActionNameBuilder(actionNameBuilder); mockContainer.setInterceptorMapBuilder(interceptorBuilder); mockContainer.setResultMapBuilder(resultMapBuilder); - mockContainer.setConventionsService(new ConventionsServiceImpl("")); + mockContainer.setConventionsService(newConventionsService()); PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder(configuration, mockContainer, of, "false", "struts-default", enableSmiInheritance); builder.setFileProtocols("jar"); @@ -1270,6 +1270,12 @@ private InterceptorConfig makeInterceptorConfig(String name) { return builder.build(); } + private ConventionsService newConventionsService() { + ConventionsServiceImpl service = new ConventionsServiceImpl(""); + service.setScopedConstantProvider(name -> ""); + return service; + } + private InterceptorStackConfig makeInterceptorStackConfig(String name, InterceptorMapping... interceptors) { InterceptorStackConfig.Builder builder = new InterceptorStackConfig.Builder(name); for (InterceptorMapping interceptor : interceptors) diff --git a/plugins/convention/src/test/resources/scoped/message-success.ftl b/plugins/convention/src/test/resources/scoped/message-success.ftl new file mode 100644 index 0000000000..bbcac88fa5 --- /dev/null +++ b/plugins/convention/src/test/resources/scoped/message-success.ftl @@ -0,0 +1,23 @@ +<#-- + 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. +--> + + +This is the scoped view ${message} + + diff --git a/plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml b/plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml new file mode 100644 index 0000000000..706c668f2f --- /dev/null +++ b/plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + +