This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new 362151425 WW-5770 Make struts.convention.result.path scopable per
package (#2009)
362151425 is described below
commit 3621514250368aa1d762558d9be7462dc7bcfcc8
Author: Lukasz Lenart <[email protected]>
AuthorDate: Wed Oct 14 11:17:47 2026 +0200
WW-5770 Make struts.convention.result.path scopable per package (#2009)
* WW-5770 docs: add design for scopable Convention result path
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* WW-5770 docs: add implementation plan, make the provider method default
in the design
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* WW-5770 feat(core): resolve a scoped constant against an explicit package
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* WW-5770 feat(convention): resolve the result path against the action's
package
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* WW-5770 feat(convention): honour the scoped result path when building
results
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* WW-5770 feat(convention): honour the scoped result path for unknown
actions and results
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* WW-5770 fix(core): resolve an explicit package without falling back to
the current invocation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* WW-5770 chore: address review comments
Drop the inert convention-default redeclaration from the test fixture,
narrow the @throws of the default getValue(String, PackageConfig),
and mark the design as implemented.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../struts2/config/ScopedConstantProvider.java | 18 +-
.../providers/StrutsScopedConstantProvider.java | 22 +-
.../StrutsScopedConstantProviderTest.java | 47 +
...-10-14-WW-5770-scoped-convention-result-path.md | 986 +++++++++++++++++++++
...WW-5770-scoped-convention-result-path-design.md | 121 +++
.../convention/ConventionScopableConstants.java | 21 +-
.../convention/ConventionUnknownHandler.java | 10 +-
.../struts2/convention/ConventionsService.java | 26 +
.../struts2/convention/ConventionsServiceImpl.java | 86 +-
.../convention/DefaultResultMapBuilder.java | 2 +-
.../src/main/resources/struts-plugin.xml | 2 +
.../convention/ConventionScopedResultPathTest.java | 33 +-
.../convention/ConventionUnknownHandlerTest.java | 95 +-
.../convention/ConventionsServiceImplTest.java | 151 ++++
.../convention/DefaultResultMapBuilderTest.java | 95 +-
.../PackageBasedActionConfigBuilderTest.java | 20 +-
.../src/test/resources/scoped/message-success.ftl | 23 +
.../struts-convention-scoped-result-path.xml | 29 +-
18 files changed, 1680 insertions(+), 107 deletions(-)
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 53e655483..5148cfce5 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 <scoped-constant>}, inherited
+ * Resolves a scopable constant for the current action invocation or a given
package: the package's {@code <scoped-constant>}, 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 ebfc968f5..647fa9eb6 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 class StrutsScopedConstantProvider implements
ScopedConstantProvider {
@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<PackageConfig> 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 c00ded1c5..b6bde8657 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.XWorkTestCase;
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 class StrutsScopedConstantProviderTest extends
XWorkTestCase {
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 000000000..e6e2f75d8
--- /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 `<scoped-constant name="struts.convention.result.path">` 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 <type>(<scope>): ...`, ending with
`Co-Authored-By: Claude Opus 5.5 <[email protected]>`;
+ 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 <[email protected]>"
+```
+
+---
+
+### 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<String, ResultTypeConfig>
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
<scoped-constant>}.
+ *
+ * @since 7.5.0
+ */
+public class ConventionScopableConstants implements ScopableConstants {
+
+ @Override
+ public Set<String> getNames() {
+ return Set.of(ConventionConstants.CONVENTION_RESULT_PATH);
+ }
+}
+```
+
+In `struts-plugin.xml`, directly after the `ConventionsService` bean:
+
+```xml
+ <bean type="org.apache.struts2.config.ScopableConstants" name="convention"
+ class="org.apache.struts2.convention.ConventionScopableConstants"/>
+```
+
+- [ ] **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 <[email protected]>"
+```
+
+---
+
+### 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<String, ResultConfig> 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<String, ResultConfig> 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<String, ResultConfig> 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
+<!DOCTYPE struts PUBLIC
+ "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+ "https://struts.apache.org/dtds/struts-7.5.dtd">
+
+<struts>
+ <constant name="struts.convention.result.path" value="/"/>
+
+ <package name="convention-default" extends="struts-default">
+ <scoped-constant name="struts.convention.result.path"
value="/scoped/"/>
+ </package>
+</struts>
+```
+
+`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.
+-->
+<html>
+<body>
+This is the scoped view ${message}
+</body>
+</html>
+```
+
+`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<MessageAction> {
+
+ @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 <[email protected]>"
+```
+
+---
+
+### 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<String> 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<String> 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<String> 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<String> 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 <[email protected]>"
+```
+
+---
+
+### 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
+<package name="admin-base" extends="convention-default">
+ <scoped-constant name="struts.convention.result.path"
value="/WEB-INF/admin/"/>
+</package>
+```
+
+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 <[email protected]>"
+```
+
+---
+
+## 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 000000000..f0ef78243
--- /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 `<scoped-constant>`; 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
+ <package name="admin-base" extends="convention-default">
+ <scoped-constant name="struts.convention.result.path"
value="/WEB-INF/admin/"/>
+ </package>
+ ```
+
+- 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 (`<s:action>`, 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/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java
similarity index 60%
copy from
core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
copy to
plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java
index 53e655483..624a76b00 100644
--- a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
+++
b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java
@@ -16,20 +16,21 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.struts2.config;
+package org.apache.struts2.convention;
+
+import org.apache.struts2.config.ScopableConstants;
+
+import java.util.Set;
/**
- * Resolves a scopable constant for the current action invocation: the
package's {@code <scoped-constant>}, inherited
- * through {@code extends}, or else the global constant.
+ * Lets a package override the Convention result path with {@code
<scoped-constant>}.
*
* @since 7.5.0
*/
-public interface ScopedConstantProvider {
+public class ConventionScopableConstants implements ScopableConstants {
- /**
- * @param name a constant registered through a {@link ScopableConstants}
bean
- * @return the scoped value, or the global value ({@code null} when there
is none)
- * @throws IllegalArgumentException when {@code name} is not scopable
- */
- String getValue(String name);
+ @Override
+ public Set<String> 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 fd95c87d5..b63b3a0f8 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 class ConventionUnknownHandler implements
UnknownHandler {
}
Map<String, ResultTypeConfig> 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 class ConventionUnknownHandler implements
UnknownHandler {
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 @@ public class ConventionUnknownHandler implements
UnknownHandler {
*
* @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 7a3ef6ec8..816f97d1e 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 fb58c4dbd..91b14f149 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 @@ import java.util.ResourceBundle;
*/
public class ConventionsServiceImpl implements ConventionsService {
private String resultPath;
+ private ScopedConstantProvider scopedConstantProvider;
/**
* Constructs a new instance.
@@ -51,54 +53,84 @@ public class ConventionsServiceImpl implements
ConventionsService {
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 380b77994..2eac72671 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 class DefaultResultMapBuilder implements
ResultMapBuilder {
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 afafe0f66..acffb1616 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"/>
<bean type="org.apache.struts2.convention.ConventionsService"
name="convention"
class="org.apache.struts2.convention.ConventionsServiceImpl"/>
+ <bean type="org.apache.struts2.config.ScopableConstants" name="convention"
+ class="org.apache.struts2.convention.ConventionScopableConstants"/>
<bean type="org.apache.struts2.config.PackageProvider"
name="convention.packageProvider"
class="org.apache.struts2.convention.ClasspathPackageProvider"/>
diff --git
a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java
similarity index 55%
copy from
core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
copy to
plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java
index 53e655483..2d836da4b 100644
--- a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
+++
b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java
@@ -16,20 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.struts2.config;
+package org.apache.struts2.convention;
-/**
- * Resolves a scopable constant for the current action invocation: the
package's {@code <scoped-constant>}, inherited
- * through {@code extends}, or else the global constant.
- *
- * @since 7.5.0
- */
-public interface ScopedConstantProvider {
+import actions.MessageAction;
+import org.apache.struts2.junit.StrutsJUnit4TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.assertTrue;
+
+public class ConventionScopedResultPathTest extends
StrutsJUnit4TestCase<MessageAction> {
+
+ @Test
+ public void testParentPackageScopedResultPath() throws Exception {
+ String output = executeAction("/message.action");
+
+ assertTrue(output, output.contains("This is the scoped view Hello
World"));
+ }
- /**
- * @param name a constant registered through a {@link ScopableConstants}
bean
- * @return the scoped value, or the global value ({@code null} when there
is none)
- * @throws IllegalArgumentException when {@code name} is not scopable
- */
- String getValue(String name);
+ @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 ea33f10cd..65360f262 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.RuntimeConfiguration;
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 @@ import static org.easymock.EasyMock.*;
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 class ConventionUnknownHandlerTest extends TestCase
{
assertEquals("/canonicalized/path.default_extension", resource.path);
}
+ public void testUnknownActionUsesScopedResultPathOfDefaultParentPackage()
throws Exception {
+ List<String> 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<String> 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<String> 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<String> 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 @@ public class ConventionUnknownHandlerTest extends TestCase {
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<String, ResultTypeConfig> 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 000000000..228b99e75
--- /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<String, ResultTypeConfig>
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 53e93f670..0a4351e6e 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 class DefaultResultMapBuilderTest {
// 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<String, ResultConfig> results =
builder.build(NoAnnotationAction.class, null, "action", packageConfig);
verify(context, "/WEB-INF/location", results, false);
@@ -72,7 +72,7 @@ public class DefaultResultMapBuilderTest {
// 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 class DefaultResultMapBuilderTest {
// 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<String, ResultConfig> results =
builder.build(OverrideResultAction.class, actionAnn, "action100",
packageConfig);
@@ -97,7 +97,7 @@ public class DefaultResultMapBuilderTest {
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 class DefaultResultMapBuilderTest {
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 class DefaultResultMapBuilderTest {
// 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<String, ResultConfig> results =
builder.build(NoAnnotationAction.class, null, "action", packageConfig);
assertEquals(0, results.size());
@@ -174,7 +174,40 @@ public class DefaultResultMapBuilderTest {
// 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<String, ResultConfig> 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<String, ResultConfig> 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<String, ResultConfig> 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<String, ResultConfig> results =
builder.build(ClassLevelResultPathAction.class, null, "action", packageConfig);
verify(context, "/class-level", results, false);
@@ -193,7 +226,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig);
assertEquals(0, results.size());
@@ -215,7 +248,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig);
assertEquals(4, results.size());
@@ -250,7 +283,7 @@ public class DefaultResultMapBuilderTest {
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 class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig);
assertEquals(1, results.size());
@@ -309,7 +342,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(ClassLevelResultAction.class, null, "class-level-result",
packageConfig);
assertEquals(1, results.size());
@@ -333,7 +366,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(InheritedResultExtends.class, null, "result-inheritance-extends",
packageConfig);
assertEquals(1, results.size());
@@ -357,7 +390,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(OverrideInheritedResultExtends.class, null,
"result-inheritance-extends", packageConfig);
assertEquals(1, results.size());
@@ -383,7 +416,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(ClassLevelResultsAction.class, null, "class-level-results",
packageConfig);
assertEquals(4, results.size());
@@ -423,7 +456,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(InheritedResultsExtends.class, null, "class-level-results",
packageConfig);
assertEquals(4, results.size());
@@ -463,7 +496,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(ActionLevelResultAction.class,
getAnnotation(ActionLevelResultAction.class, "execute", Action.class),
"action-level-result", packageConfig);
assertEquals(1, results.size());
@@ -487,7 +520,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(ActionLevelResultsAction.class,
getAnnotation(ActionLevelResultsAction.class, "execute", Action.class),
"action-level-results", packageConfig);
assertEquals(4, results.size());
@@ -523,7 +556,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(ActionLevelResultsNamesAction.class,
getAnnotation(ActionLevelResultsNamesAction.class, "execute", Action.class),
"action-level-results", packageConfig);
assertEquals(4, results.size());
@@ -559,7 +592,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(ActionLevelResultsNamesAction.class,
getAnnotation(ActionLevelResultsNamesAction.class, "noname", Action.class),
"action-level-results", packageConfig);
assertEquals(1, results.size());
@@ -581,7 +614,7 @@ public class DefaultResultMapBuilderTest {
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<String, ResultConfig> results =
builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig);
assertEquals(4, results.size());
@@ -599,7 +632,17 @@ public class DefaultResultMapBuilderTest {
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 @@ public class DefaultResultMapBuilderTest {
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 1d21061b3..437610adc 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 class PackageBasedActionConfigBuilderTest extends
TestCase {
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 class PackageBasedActionConfigBuilderTest extends
TestCase {
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 class PackageBasedActionConfigBuilderTest extends
TestCase {
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 class PackageBasedActionConfigBuilderTest extends
TestCase {
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 class PackageBasedActionConfigBuilderTest extends
TestCase {
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 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
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 class PackageBasedActionConfigBuilderTest extends
TestCase {
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 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
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 000000000..bbcac88fa
--- /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.
+-->
+<html>
+<body>
+This is the scoped view ${message}
+</body>
+</html>
diff --git
a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
b/plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml
similarity index 58%
copy from
core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
copy to
plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml
index 53e655483..706c668f2 100644
--- a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
+++
b/plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml
@@ -1,3 +1,5 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -16,20 +18,17 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.struts2.config;
+-->
-/**
- * Resolves a scopable constant for the current action invocation: the
package's {@code <scoped-constant>}, inherited
- * through {@code extends}, or else the global constant.
- *
- * @since 7.5.0
- */
-public interface ScopedConstantProvider {
+<!DOCTYPE struts PUBLIC
+ "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+ "https://struts.apache.org/dtds/struts-7.5.dtd">
+
+<struts>
+ <constant name="struts.convention.result.path" value="/"/>
+ <constant name="struts.convention.default.parent.package"
value="scoped-default"/>
- /**
- * @param name a constant registered through a {@link ScopableConstants}
bean
- * @return the scoped value, or the global value ({@code null} when there
is none)
- * @throws IllegalArgumentException when {@code name} is not scopable
- */
- String getValue(String name);
-}
+ <package name="scoped-default" extends="convention-default">
+ <scoped-constant name="struts.convention.result.path"
value="/scoped/"/>
+ </package>
+</struts>