Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3198a8f
WW-4164 docs: add design for per-package and per-action extensions
lukaszlenart Nov 2, 2026
48b375f
WW-4164 docs: add implementation plan for per-action extensions
lukaszlenart Nov 2, 2026
f91d39a
WW-4164 feat(core): add ActionExtensions list parsing
lukaszlenart Nov 2, 2026
4701293
WW-4164 feat(core): parse extension attribute on package and action
lukaszlenart Nov 2, 2026
d52eb49
WW-4164 feat(core): reject disallowed extensions with ActionExtension…
lukaszlenart Nov 2, 2026
3e06839
WW-4164 feat(core): map declared extensions to their actions
lukaszlenart Nov 2, 2026
77d06c3
WW-4164 feat(core): build action URLs with the target action's extension
lukaszlenart Nov 2, 2026
5bbd77c
WW-4164 fix(core): skip extension resolution when nothing declares ex…
lukaszlenart Nov 2, 2026
4823d10
WW-4164 test(core): pin extension behaviour with one declaring package
lukaszlenart Nov 2, 2026
c01e0fd
WW-4164 fix(core): ignore default-action-ref when claiming declared e…
lukaszlenart Nov 2, 2026
b75fbaa
WW-4164 fix(core): resolve query-string action names and declared emp…
lukaszlenart Nov 2, 2026
26dfd04
WW-4164 chore(core): polish extension config validation and warnings
lukaszlenart Nov 2, 2026
3c9ace2
WW-4164 docs: correct action-prefix enforcement in design
lukaszlenart Nov 2, 2026
037d61f
WW-4164 refactor(core): use an empty list for undeclared extensions
lukaszlenart Nov 2, 2026
fdc8bc0
WW-4164 fix(core): parse probe names like the mapper and keep allowed…
lukaszlenart Nov 2, 2026
5ae23ce
WW-4164 fix(core): match the full requested name when slashes are all…
lukaszlenart Nov 2, 2026
9012a4c
WW-4164 docs: describe the extensionless-request rule for generated URLs
lukaszlenart Nov 2, 2026
2de5cc6
WW-4164 fix(core): address Sonar findings on extension support
lukaszlenart Nov 2, 2026
72875dd
WW-4164 feat(core): let action mappers declare support for declared e…
lukaszlenart Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions core/src/main/java/org/apache/struts2/components/Component.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import org.apache.logging.log4j.Logger;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.StrutsException;
import org.apache.struts2.dispatcher.mapper.ActionExtensionResolver;
import org.apache.struts2.dispatcher.mapper.ActionMapper;
import org.apache.struts2.dispatcher.mapper.ActionMapping;
import org.apache.struts2.util.ComponentUtils;
Expand Down Expand Up @@ -81,6 +82,7 @@ public static void clearStandardAttributesMap() {
protected ValueStack stack;
protected Map<String, Object> attributes;
protected ActionMapper actionMapper;
protected ActionExtensionResolver actionExtensionResolver;
protected boolean throwExceptionOnELFailure;
protected boolean performClearTagStateForTagPoolingServers = false;
private UrlHelper urlHelper;
Expand Down Expand Up @@ -121,6 +123,15 @@ public void setActionMapper(ActionMapper mapper) {
this.actionMapper = mapper;
}

/**
* @param actionExtensionResolver chooses the extension of generated action URLs
* @since 7.5.0
*/
@Inject
public void setActionExtensionResolver(ActionExtensionResolver actionExtensionResolver) {
this.actionExtensionResolver = actionExtensionResolver;
}

@Inject(StrutsConstants.STRUTS_EL_THROW_EXCEPTION)
public void setThrowExceptionsOnELFailure(String throwException) {
this.throwExceptionOnELFailure = BooleanUtils.toBoolean(throwException);
Expand Down Expand Up @@ -422,6 +433,7 @@ protected String determineActionURL(String action, String namespace, String meth
String finalMethod = method != null ? findString(method) : null;
String finalNamespace = determineNamespace(namespace, getStack(), req);
ActionMapping mapping = new ActionMapping(finalAction, finalNamespace, finalMethod, parameters);
actionExtensionResolver.applyTo(mapping);
String uri = actionMapper.getUriFromActionMapping(mapping);
return urlHelper.buildUrl(uri, req, res, parameters, scheme, includeContext, encodeResult, forceAddSchemeHostAndPort, escapeAmp);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ public void renderFormUrl(Form formComponent) {
if (actionConfig != null) {

ActionMapping mapping = new ActionMapping(actionName, namespace, actionMethod, formComponent.attributes);
formComponent.actionExtensionResolver.applyTo(mapping);
String result = urlHelper.buildUrl(formComponent.actionMapper.getUriFromActionMapping(mapping),
formComponent.request, formComponent.response, queryStringResult.getQueryParams(), scheme, formComponent.includeContext, true, false, false);
formComponent.addParameter("action", result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ public interface RuntimeConfiguration extends Serializable {
*/
ActionConfig getActionConfig(String namespace, String name);

/**
* Same lookup as {@link #getActionConfig(String, String)}, but returns {@code null} when the result comes only from
* a namespace's {@code default-action-ref}, so only exact and wildcard matches of {@code name} are returned.
*
* @param namespace the namespace of the Action, {@code null} means the empty namespace
* @param name the name of the Action
* @return the matched ActionConfig, or {@code null}
* @since 7.5.0
*/
default ActionConfig getMatchedActionConfig(String namespace, String name) {
return getActionConfig(namespace, name);
}

/**
* returns a Map of all the registered ActionConfigs. Again, these ActionConfigs are fully expanded so that any
* inherited interceptors, results, etc. will be included
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public class ActionConfig extends Located implements Serializable {
protected boolean strictMethodInvocation = true;
protected AllowedMethods allowedMethods;
private Set<HttpMethod> httpMethods;
private List<String> extensions = List.of();

protected ActionConfig(String packageName, String name, String className) {
this.packageName = packageName;
Expand Down Expand Up @@ -98,6 +99,7 @@ protected ActionConfig(ActionConfig orig) {
this.allowedMethods = orig.allowedMethods;
this.httpMethods = EnumSet.noneOf(HttpMethod.class);
this.httpMethods.addAll(orig.httpMethods);
this.extensions = orig.extensions;
this.location = orig.location;
this.strictMethodInvocation = orig.strictMethodInvocation;
}
Expand Down Expand Up @@ -130,6 +132,14 @@ public Set<HttpMethod> getHttpMethods() {
return httpMethods;
}

/**
* @return action extensions in declared order, empty when neither the action nor its package declares any
* @since 7.5.0
*/
public List<String> getExtensions() {
return extensions;
}

/**
* Returns name of the action method
*
Expand Down Expand Up @@ -200,7 +210,11 @@ public boolean equals(Object o) {
return false;
}

return Objects.equals(httpMethods, actionConfig.httpMethods);
if (!Objects.equals(httpMethods, actionConfig.httpMethods)) {
return false;
}

return extensions.equals(actionConfig.extensions);
}

@Override
Expand All @@ -216,6 +230,7 @@ public int hashCode() {
result = 31 * result + (name != null ? name.hashCode() : 0);
result = 31 * result + (allowedMethods != null ? allowedMethods.hashCode() : 0);
result = 31 * result + (httpMethods != null ? httpMethods.hashCode() : 0);
result = 31 * result + extensions.hashCode();
return result;
}

Expand All @@ -234,6 +249,9 @@ public String toString() {
if (!httpMethods.isEmpty()) {
sb.append(" - ").append(httpMethods);
}
if (!extensions.isEmpty()) {
sb.append(" - extensions ").append(extensions);
}
sb.append("}");
return sb.toString();
}
Expand Down Expand Up @@ -368,6 +386,14 @@ public Builder addHttpMethods(Collection<HttpMethod> methods) {
return this;
}

/**
* @since 7.5.0
*/
public Builder extensions(List<String> extensions) {
target.extensions = extensions != null ? List.copyOf(extensions) : List.of();
return this;
}

public Builder location(Location loc) {
target.location = loc;
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public class PackageConfig extends Located implements Comparable<PackageConfig>,
protected boolean isFinal = false; // a final package is unextendable
protected boolean needsRefresh;
protected boolean strictMethodInvocation = true;
private List<String> extensions = List.of();
private Map<String, String> scopedConstants;
private Map<String, String> allScopedConstants;

Expand Down Expand Up @@ -94,6 +95,7 @@ protected PackageConfig(PackageConfig orig) {
this.parents = new ArrayList<>(orig.parents);
this.location = orig.location;
this.strictMethodInvocation = orig.strictMethodInvocation;
this.extensions = orig.extensions;
this.scopedConstants = new LinkedHashMap<>(orig.scopedConstants);
this.allScopedConstants = orig.allScopedConstants;
}
Expand Down Expand Up @@ -321,6 +323,31 @@ public String getFullDefaultResultType() {
return defaultResultType;
}

/**
* @return extensions declared on this package, or empty when it declares none
* @since 7.5.0
*/
public List<String> getExtensions() {
return extensions;
}

/**
* @return extensions declared on this package, else on the first parent that declares them, else empty
* @since 7.5.0
*/
public List<String> getFullExtensions() {
if (!extensions.isEmpty()) {
return extensions;
}
for (PackageConfig parent : parents) {
List<String> parentExtensions = parent.getFullExtensions();
if (!parentExtensions.isEmpty()) {
return parentExtensions;
}
}
return List.of();
}

/**
* gets the global ResultConfigs local to this package
*
Expand Down Expand Up @@ -422,6 +449,7 @@ public boolean equals(Object o) {
return false;
if (!Objects.equals(defaultClassRef, that.defaultClassRef))
return false;
if (!extensions.equals(that.extensions)) return false;
if (!name.equals(that.name)) return false;
return Objects.equals(namespace, that.namespace);
}
Expand All @@ -446,6 +474,7 @@ public int hashCode() {
result = 31 * result + (isFinal ? 1 : 0);
result = 31 * result + (needsRefresh ? 1 : 0);
result = 31 * result + (strictMethodInvocation ? 1 : 0);
result = 31 * result + extensions.hashCode();
return result;
}

Expand Down Expand Up @@ -676,6 +705,14 @@ public Builder strictMethodInvocation(boolean strict) {
return this;
}

/**
* @since 7.5.0
*/
public Builder extensions(List<String> extensions) {
target.extensions = extensions != null ? List.copyOf(extensions) : List.of();
return this;
}

public boolean isStrictMethodInvocation() {
return target.strictMethodInvocation;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ public ActionConfig convert(String path, ActionConfig orig,
.setStrictMethodInvocation(orig.isStrictMethodInvocation())
.addAllowedMethod(orig.getAllowedMethods())
.addHttpMethods(orig.getHttpMethods())
.extensions(orig.getExtensions())
.addInterceptors(orig.getInterceptors())
.addExceptionMappings(exs)
.location(orig.getLocation())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
import org.apache.struts2.ognl.accessor.CompoundRootAccessor;
import org.apache.struts2.ognl.accessor.RootAccessor;
import org.apache.struts2.ognl.accessor.XWorkMethodAccessor;
import org.apache.struts2.interceptor.ActionExtensionInterceptor;
import org.apache.struts2.interceptor.httpmethod.HttpMethodInterceptor;
import org.apache.struts2.interceptor.parameter.OgnlParameterAllowlister;
import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
Expand Down Expand Up @@ -562,6 +563,11 @@ private ActionConfig buildFullActionConfig(PackageConfig packageContext, ActionC

warnIfHttpMethodNotEnforced(baseConfig, interceptors);

List<String> extensions = baseConfig.getExtensions().isEmpty()
? packageContext.getFullExtensions()
: baseConfig.getExtensions();
warnIfExtensionNotEnforced(baseConfig, extensions, interceptors);

String methodRegex = container.getInstance(String.class, StrutsConstants.STRUTS_SMI_METHOD_REGEX);
if (methodRegex == null) {
methodRegex = ActionConfig.DEFAULT_METHOD_REGEX;
Expand All @@ -570,6 +576,7 @@ private ActionConfig buildFullActionConfig(PackageConfig packageContext, ActionC
LOG.debug("Using pattern [{}] to match allowed methods when SMI is disabled!", methodRegex);

return new ActionConfig.Builder(baseConfig)
.extensions(extensions)
.addParams(params)
.addResultConfigs(results)
.defaultClassName(packageContext.getDefaultClassRef()) // fill in default if non class has been provided
Expand All @@ -588,6 +595,14 @@ private void warnIfHttpMethodNotEnforced(ActionConfig baseConfig, List<Intercept
}
}

private void warnIfExtensionNotEnforced(ActionConfig baseConfig, List<String> extensions, List<InterceptorMapping> interceptors) {
if (!extensions.isEmpty()
&& interceptors.stream().noneMatch(mapping -> mapping.getInterceptor() instanceof ActionExtensionInterceptor)) {
LOG.warn("Action [{}] in package [{}] declares extension {} but its interceptors do not include {}, the restriction will not be enforced",
baseConfig.getName(), baseConfig.getPackageName(), extensions, ActionExtensionInterceptor.class.getSimpleName());
}
}

private static class RuntimeConfigurationImpl implements RuntimeConfiguration {

private final Map<String, Map<String, ActionConfig>> namespaceActionConfigs;
Expand Down Expand Up @@ -625,36 +640,47 @@ public RuntimeConfigurationImpl(Map<String, Map<String, ActionConfig>> namespace
*/
@Override
public ActionConfig getActionConfig(String namespace, String name) {
ActionConfig config = findActionConfigInNamespace(namespace, name);
Lookup lookup = lookup(namespace, name);
return lookup == null ? null : lookup.config;
}

@Override
public ActionConfig getMatchedActionConfig(String namespace, String name) {
Lookup lookup = lookup(namespace, name);
return lookup == null || lookup.defaultAction ? null : lookup.config;
}

private Lookup lookup(String namespace, String name) {
Lookup lookup = lookupInNamespace(namespace, name);

// try wildcarded namespaces
if (config == null) {
if (lookup == null) {
NamespaceMatch match = namespaceMatcher.match(namespace);
if (match != null) {
config = findActionConfigInNamespace(match.getPattern(), name);
lookup = lookupInNamespace(match.getPattern(), name);

// If config found, place all the matches found in the namespace processing in the action's parameters
if (config != null) {
config = new ActionConfig.Builder(config)
if (lookup != null) {
lookup = lookup.withConfig(new ActionConfig.Builder(lookup.config)
.addParams(match.getVariables())
.build();
.build());
}
}
}

// fail over to empty namespace
if (config == null && shouldFallbackToEmptyNamespace(namespace)) {
config = findActionConfigInNamespace("", name);
if (lookup == null && shouldFallbackToEmptyNamespace(namespace)) {
lookup = lookupInNamespace("", name);
}

return config;
return lookup;
}

private boolean shouldFallbackToEmptyNamespace(String namespace) {
return StringUtils.isNotBlank(namespace) && ("/".equals(namespace) || fallbackToEmptyNamespace);
}

private ActionConfig findActionConfigInNamespace(String namespace, String name) {
private Lookup lookupInNamespace(String namespace, String name) {
if (namespace == null) {
namespace = "";
}
Expand All @@ -665,15 +691,16 @@ private ActionConfig findActionConfigInNamespace(String namespace, String name)

ActionConfig config = actions.get(name);
if (config != null) {
return config;
return Lookup.matched(config);
}

config = namespaceActionConfigMatchers.get(namespace).match(name);
if (config != null) {
return config;
return Lookup.matched(config);
}

return findDefaultActionConfig(namespace, actions);
config = findDefaultActionConfig(namespace, actions);
return config == null ? null : Lookup.defaultAction(config);
}

private ActionConfig findDefaultActionConfig(String namespace, Map<String, ActionConfig> actions) {
Expand Down Expand Up @@ -714,6 +741,28 @@ public String toString() {

return buff.toString();
}

private static final class Lookup {
private final ActionConfig config;
private final boolean defaultAction;

private Lookup(ActionConfig config, boolean defaultAction) {
this.config = config;
this.defaultAction = defaultAction;
}

private static Lookup matched(ActionConfig config) {
return new Lookup(config, false);
}

private static Lookup defaultAction(ActionConfig config) {
return new Lookup(config, true);
}

private Lookup withConfig(ActionConfig other) {
return new Lookup(other, defaultAction);
}
}
}

class ContainerProperties extends LocatableProperties {
Expand Down
Loading
Loading