WW-3784 Order annotated wildcard actions most-specific-first (#1813)

* WW-3784 docs: design for specificity-ordered wildcard matching in annotated actions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-3784 docs: implementation plan for annotated wildcard specificity ordering

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-3784 feat(convention): add action-name specificity comparator

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-3784 fix(convention): add Apache License header to test file

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-3784 feat(core): add PackageConfig.Builder.reorderActionConfigs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-3784 docs: add javadoc for PackageConfig.Builder.reorderActionConfigs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-3784 feat(convention): order annotated wildcard actions most-specific-first

Sorts each convention-built package's action configs by pattern specificity so a
specific pattern (some/usefull/*) is matched before a general one (some/*),
regardless of class-scan order. Also makes convention action ordering deterministic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-3784 docs: correct wildcard cross-segment claims and note comparator limitations

The spec incorrectly stated that WildcardHelper's single `*` is greedy
and crosses `/`, and that `some/*` shadows `some/usefull/*`. Verified
against WildcardHelper.java and NamedVariablePatternMatcher.java: only
`**` crosses `/`, so those two patterns are actually disjoint (different
segment counts) and never compete for the same request. Correct the
Problem narrative, ticket example, and matcher bullets to state this
accurately, and document two known limitations of the specificity
comparator (raw wildcard-token-count key can misrank `**` ahead of
narrower multi-token patterns; parent-package actions bypass sorting).
Also add a test asserting the natural-order alphabetical tiebreak key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-3784 test(convention): prove specificity ordering fixes wildcard shadowing end-to-end

Adds an end-to-end routing test driving the production reorder
(PackageConfig.Builder.reorderActionConfigs + ActionNameSpecificityComparator)
through the real ActionConfigMatcher/WildcardHelper. some/** and some/usefull/*
genuinely overlap for some/usefull/sleeping (** crosses '/'), so the test asserts
the general pattern shadows the specific one when registered first, and that
specificity ordering makes the specific action reachable again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lukasz Lenart
2026-07-29 07:56:58 +02:00
committed by GitHub
parent 6f802987f5
commit 94a8fcb26c
9 changed files with 1084 additions and 0 deletions
@@ -24,6 +24,7 @@ import org.apache.struts2.util.location.Location;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -517,6 +518,26 @@ public class PackageConfig extends Located implements Comparable<PackageConfig>,
return this;
}
/**
* Re-inserts this package's action configs into a new insertion-ordered map,
* ordered by the supplied comparator over the action-name keys. Must be called
* before {@link #build()}.
*
* @param byActionName comparator over action names determining match precedence
* @return this builder
* @since 7.3.0 (WW-3784)
*/
public Builder reorderActionConfigs(Comparator<String> byActionName) {
List<Map.Entry<String, ActionConfig>> entries = new ArrayList<>(target.actionConfigs.entrySet());
entries.sort(Map.Entry.comparingByKey(byActionName));
Map<String, ActionConfig> reordered = new LinkedHashMap<>();
for (Map.Entry<String, ActionConfig> entry : entries) {
reordered.put(entry.getKey(), entry.getValue());
}
target.actionConfigs = reordered;
return this;
}
public Builder addParents(List<PackageConfig> parents) {
for (PackageConfig config : parents) {
addParent(config);
@@ -0,0 +1,44 @@
/*
* 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.config.entities;
import junit.framework.TestCase;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class PackageConfigBuilderReorderTest extends TestCase {
public void testReorderActionConfigsAppliesComparator() {
PackageConfig.Builder builder = new PackageConfig.Builder("test");
builder.addActionConfig("some/*", action("some/*"));
builder.addActionConfig("some/usefull/*", action("some/usefull/*"));
// reverse-alphabetical proves the map is genuinely reordered, not left as-inserted
builder.reorderActionConfigs(Comparator.reverseOrder());
List<String> keys = new ArrayList<>(builder.build().getActionConfigs().keySet());
assertEquals(List.of("some/usefull/*", "some/*"), keys);
}
private ActionConfig action(String name) {
return new ActionConfig.Builder("test", name, "com.example.Action").build();
}
}