Convention Plugin & Annotations
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Struts 7.4.0 (the current 7.x release, verified on 2026-10-08) with the struts2-convention-plugin
7.4.0, running on Apache Tomcat 11.0.26 and Java 21; Struts 7 needs Java 17 and Jakarta Servlet 6 at least. The facts were checked against
the plugin page of the Struts site, the Core Developers Guide annotations reference, the Getting Started "Annotations" tutorial and the 7.4.0
plugin jar itself (struts-plugin.xml, javap on the annotations and on ConventionConstants), and every example was built into a Maven
WAR with the struts2-bom 7.4.0, deployed to the scratch Tomcat and exercised with curl; the outputs shown are the outputs that run
produced. The Convention plugin removes the need to write one <action> element per action: it scans the class path for action classes,
derives each URL from the class name and the package name, finds the view page by file name and lets annotations override any default.
The running scenario is the Bookshelf catalogue of Part II: a list of titles, a detail page, an /admin area, a CSV export and a report. The
other pages of this part (for instance Spring Integration, the JSON and REST plugins and Testing) are linked where they come up.
The Idea: Configuration by Convention
In Struts 2 the plain route from a URL to a page is three pieces of configuration: an action name, an action class and a result (see
Actions and Results and Result Types).
The XML of struts.xml writes the three pieces down. The Convention plugin derives them instead:
-
the class is found by scanning: every class in a package whose name ends with one of a few agreed words (
action,actions,struts,struts2) and that either implementsorg.apache.struts2.action.Actionor has a name ending inActionis an action; -
the action name and namespace come from the class name and the package name:
BookListActioninorg.example.bookshelf.actionsis/book-list, andImportBooksActioninorg.example.bookshelf.actions.adminis/admin/import-books; -
the result is found by file name: the view of
/book-listfor the result codesuccessis/WEB-INF/content/book-list.jsp(orbook-list-success.jsp), and forinputit isbook-list-input.jsp.
Everything that does not fit the convention is declared with annotations placed on the class or on its methods: a different URL (@Action),
a different view (@Result), a different namespace (@Namespace), a different parent package (@ParentPackage), interceptors
(@InterceptorRef), exception mappings (@ExceptionMapping) and the location of the views (@ResultPath). The plugin is the successor of two
older mechanisms: the zero configuration actionPackages of Struts 2.0 and the Codebehind plugin. The Struts documentation says that the Zero
Config and Codebehind plugins are deprecated from Struts 2.1 on, and the plugin list of the site marks Codebehind as removed since 2.5; the 7.4.0
struts2-core jar does not contain the string actionPackages at all.
Source: Struts Convention Plugin
The plugin does not replace the rest of Struts. The filter is still declared in web.xml, the generated mappings still run the defaultStack
interceptors, parameters still need @StrutsParameter (see Parameter Injection and @StrutsParameter)
and a package declared in struts.xml can still coexist with the generated ones. What the plugin changes is who writes the <package> and
<action> elements: at start-up the plugin’s ClasspathPackageProvider builds them in memory from the scan.
Setup and the Bookshelf Used on This Page
The plugin is one dependency, and its version is managed by the struts2-bom that the Bookshelf POM already imports:
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-convention-plugin</artifactId>
</dependency>
Source: Struts Convention Plugin, Setup
The artifact is published at 7.4.0 on Maven Central (Maven Central listing). Its
struts-plugin.xml has order="20", so its constants and beans are loaded after the core ones; no file of the application has to name it.
The verification application used by every example of this page, g11conv, has this layout. It contains no struts.xml in its first
form: Struts starts, scans and serves the actions below from the plugin alone. A struts.xml is added later only where an example needs a
package or a constant.
src/main/java/org/example/bookshelf/
model/Catalogue.java not scanned (package 'model' is no locator)
actions/BookListAction.java /book-list
actions/BookDetailsAction.java /book, /book-info, /book-form, /go ...
actions/Hello.java /hello (implements Action, no suffix)
actions/ISBNLookupAction.java /i-sbnlookup (see the naming rules)
actions/admin/ImportBooksAction.java /admin/import-books
actions/admin/IndexAction.java /admin/index, /admin/
actions/export/BookExportAction.java namespace /csv by annotation
actions/reports/package-info.java @Namespace and @ResultPath for the package
actions/reports/SalesAction.java /reports/sales
actions/timed/package-info.java @ParentPackage and @DefaultInterceptorRef
actions/timed/StatsAction.java /timed/stats
src/main/webapp/WEB-INF/
web.xml the StrutsPrepareAndExecuteFilter on /*
content/about.jsp a view with no action class at all
content/book-list.jsp, hello-success.jsp ... the views, by file name
reports/reports/sales.jsp views of the reports package (see @ResultPath)
The Catalogue class is the same thread-safe in-memory list that the other Part II pages use: two titles, Dune and Neuromancer.
The filter declaration of web.xml is the one of Getting Started with Struts; the plugin does not change it.
The Maven dependencies of the verification application are struts2-core and struts2-convention-plugin (both from the struts2-bom
7.4.0), the Servlet 6.1 and Pages 4.0 APIs as provided, and log4j-slf4j2-impl. The Convention plugin’s class files are
built against the org.apache.struts2 package names, so the javax-era and com.opensymphony.xwork2 imports that appear in the plugin’s
documentation (see Where the Books Are Outdated) do not compile in 7.4.0.
A View With No Action Class
The smallest use of the plugin is a page with no code at all. A JSP placed under /WEB-INF/content/ answers the URL that has its name, even though
no action exists for that URL:
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<html><body><p>About the Bookshelf (no action class)</p></body></html>
saved as src/main/webapp/WEB-INF/content/about.jsp. The plugin installs an UnknownHandler (ConventionUnknownHandler, registered in
struts-plugin.xml) that Struts calls when no action matches the URL; the handler looks for a result file with that name and, if there is
one, builds the action configuration on the fly.
$ curl -s -w '%{http_code}\n' http://localhost:18080/g11conv/about
<html><body><p>About the Bookshelf (no action class)</p></body></html>
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g11conv/about.action
200
Both /about and /about.action work because the 7.4.0 default is struts.action.extension=action,,. The files are under WEB-INF, so
they are not reachable directly: the only way to a view is through Struts. The page of the official documentation that shows this is
the "Hello world" section; the Getting Started tutorial uses the same default location.
How Action Classes Are Found: Package Locators
A class is turned into an action in two steps. First the plugin decides which packages to scan; then it decides which classes of those packages are actions.
Which Packages: Locators and Root Packages
The plugin looks for packages whose name ends with one of the package locators, the words of the constant
struts.convention.package.locators; the default is action,actions,struts,struts2. A package found that way is a root package, and the
plugin scans it and all of its sub-packages. The part of the package name that follows the root package becomes the namespace. For the
Bookshelf:
| Java package | Scanned? | Namespace of its actions |
|---|---|---|
|
Yes, it ends with |
|
|
Yes, sub-package of a root package |
|
|
Yes, sub-package; the class carries |
|
|
No: no locator matches |
none |
|
No: the name does not end with a locator |
none |
The verification run printed the decision of the scan at DEBUG level (the logger org.apache.struts2.convention; see
Debugging), one line for each action created:
Creating action config for class [class org.example.bookshelf.actions.BookListAction], name [book-list] and package name [org.example.bookshelf.actions#convention-default#] in namespace []
Creating action config for class [class org.example.bookshelf.actions.admin.ImportBooksAction], name [import-books] and package name [org.example.bookshelf.actions.admin#convention-default#/admin] in namespace [/admin]
The root namespace is shown as an empty string in this log line and is / in the URL. The generated package name,
<java-package>#<namespace>#<parent-package>, is explained in The Generated Package.
A package that is not a locator match can still be scanned by naming it in struts.convention.action.packages, a comma-separated list. The
verification run set struts.convention.package.locators.disable to true and struts.convention.action.packages to
org.example.bookshelf.actions.admin: only the two admin classes were configured, and their namespace was the root, because the namespace is
computed from the package below the named one (here nothing):
<constant name="struts.convention.package.locators.disable" value="true"/>
<constant name="struts.convention.action.packages" value="org.example.bookshelf.actions.admin"/>
The result was name [import-books] and package name [org.example.bookshelf.actions.admin#convention-default#] in namespace [], so
/import-books and not /admin/import-books. Setting struts.convention.package.locators.basePackage instead (to the same package)
kept the locator search but limited it to packages that start with that value, and the namespace stayed /admin.
Replacing the locator words (struts.convention.package.locators=screens) scanned only org.example.bookshelf.screens.* and ignored the
actions packages. Three more constants shape the scan:
| Constant | Default (7.4.0 struts-plugin.xml) |
Effect |
|---|---|---|
|
|
Package-name endings that mark a root package |
|
|
|
|
empty |
If set, only packages that start with its value are searched for locators |
|
not set |
Packages scanned whether or not they match a locator |
|
|
Packages (and sub-packages) never scanned; an excluded package cannot be re-included by another constant |
|
|
|
Which Classes: Suffix or the Action Interface
Inside a scanned package a class is an action if it implements org.apache.struts2.action.Action (constant
struts.convention.action.checkImplementsAction, default true) or its simple name ends with the suffix of
struts.convention.action.suffix (default Action). ActionSupport implements Action, so every class that extends it qualifies, whatever its
name. The verification application shows both rules and a class that is neither:
package org.example.bookshelf.actions;
import org.apache.struts2.action.Action;
public class Hello implements Action {
@Override
public String execute() {
return SUCCESS;
}
}
Hello has no suffix but implements Action, so it is configured as /hello and answered with hello-success.jsp:
$ curl -s http://localhost:18080/g11conv/hello
<html><body><p>Hello, reader (hello-success.jsp)</p></body></html>
A plain class NotAnAction (no interface, name ends with Action) was configured as /not-an, because the plugin removes the suffix from
the end of the name, so a word the author meant as part of the name is cut. The class Catalogue (package model, no locator) never appears in the log. With the constant struts.convention.action.checkImplementsAction set
to false, /hello disappeared from the config browser; with struts.convention.action.suffix set to Controller, a class
ReaderController became /reader, while the ActionSupport subclasses stayed mapped under names that keep the old suffix
(book-list-action), because they still implement Action.
Actions in jar files are not scanned unless the jar URL matches one of the regular expressions of struts.convention.action.includeJars
(default: no jar is scanned; DEBUG log: "struts.convention.action.includeJars" is not defined, excluding all JAR files!). The
verification built a one-class jar bookshelf-extras-0.1.jar with org.example.extras.actions.ExtrasReportAction, put it in
WEB-INF/lib and set:
<constant name="struts.convention.action.includeJars" value=".*?/bookshelf-extras.*?jar(!/)?"/>
Without the constant the class was ignored; with it the log printed Creating action config for class [class org.example.extras.actions.ExtrasReportAction], name
[extras-report]. The expression is matched against the jar URL (it can contain the path and a trailing !/), not the file name.
The plugin page says a match on external jars can fill the log with errors such as "Could not load …/ClasspathResourceLoader.class"; keep
the expression as narrow as the example.
Naming Rules: From Class Name to URL
The URL of an action is built from the namespace (the package, see above) and an action name derived from the class name by the
ActionNameBuilder bean, SEOActionNameBuilder by default ("SEO" because my-action is a better URL than MyAction). The rules, which
were checked by calling the builder directly with each name below:
-
the suffix (
Actionby default) is removed from the end of the name; a class named exactlyActionfails withIllegalStateException: The action name cannot be the same as the action suffix [Action]; -
the camel-case name is split into words at each upper-case letter, and the words are joined with the separator (
-by default, constantstruts.convention.action.name.separator); -
the result is converted to lower case (constant
struts.convention.action.name.lowercase, defaulttrue).
| Class name | Action name | Comment |
|---|---|---|
|
|
Suffix removed, camel case split |
|
|
The example of the plugin page |
|
|
A trailing acronym stays together |
|
|
Digits do not start a word |
|
|
A leading acronym is split after its first letter: the pitfall below |
|
|
The suffix is removed from the end of the name, |
|
|
The first letter’s case does not matter |
|
|
The suffix is removed only from the end, so it stays |
With the constants changed (lowercase=false, separator _, suffix Controller) the same builder gave Book-List for BookListAction,
book_list, and book-list for BookListController; with the suffix Controller the class BookListAction kept its suffix
(book-list-action). The same separator is the one used between the action name and the result code in view file names
(next section), so changing it changes the view names too.
The acronym case is a real pitfall for the Bookshelf: ISBNLookupAction is published as /i-sbnlookup. Two fixes were verified. The simple one is an
explicit name with @Action("isbn-lookup") on the method (an earlier version of the verification class carried that annotation and /isbn-lookup
answered with the view isbn-lookup.jsp). The general one replaces the name builder, which the plugin supports through its extension points:
package org.example.bookshelf.convention;
import org.apache.struts2.convention.SEOActionNameBuilder;
import org.apache.struts2.inject.Inject;
public class AcronymAwareNameBuilder extends SEOActionNameBuilder {
@Inject
public AcronymAwareNameBuilder(
@Inject("struts.convention.action.name.lowercase") String lowerCase,
@Inject("struts.convention.action.name.separator") String separator) {
super(lowerCase, separator);
}
@Override
public String build(String className) {
String name = super.build(className);
return name.replace("i-sbnlookup", "isbn-lookup");
}
}
registered in struts.xml as a bean and selected by constant (the bean name is the value of the constant):
<bean type="org.apache.struts2.convention.ActionNameBuilder" name="bookshelf"
class="org.example.bookshelf.convention.AcronymAwareNameBuilder"/>
<constant name="struts.convention.actionNameBuilder" value="bookshelf"/>
After the restart the DEBUG line read name [isbn-lookup] for ISBNLookupAction and curl /isbn-lookup answered 200 with the page
isbn-lookup.jsp. The plugin’s other replaceable beans are listed in Replacing the Plugin’s Beans. The Struts constructor
injection of the two constants uses org.apache.struts2.inject.Inject, which is the 7.x package of the container annotation.
Finding the View: The WEB-INF/content Convention
When an action returns a result code, the plugin looks for a file in the result path (constant struts.convention.result.path, default
/WEB-INF/content/) followed by the namespace and the action name, and optionally the result code:
<result path><namespace>/<action name>[<separator><result code>].<extension>
For a request to /admin/import-books that returns success, either /WEB-INF/content/admin/import-books.jsp or
/WEB-INF/content/admin/import-books-success.jsp is the view. The Bookshelf BookListAction returns success normally and the string empty when
the filter parameter is blank; with two files the plugin chose the view by the code:
package org.example.bookshelf.actions;
import java.util.List;
import org.apache.struts2.ActionSupport;
import org.apache.struts2.interceptor.parameter.StrutsParameter;
import org.example.bookshelf.model.Catalogue;
public class BookListAction extends ActionSupport {
private String filter;
public String getFilter() {
return filter;
}
@StrutsParameter
public void setFilter(String filter) {
this.filter = filter;
}
public List<String> getTitles() {
return Catalogue.titles();
}
@Override
public String execute() {
return filter != null && filter.isBlank() ? "empty" : SUCCESS;
}
}
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<html><body><h1>Bookshelf</h1><ul><s:iterator value="titles"><li><s:property/></li></s:iterator></ul><p>filter=<s:property value="filter"/></p></body></html>
(WEB-INF/content/book-list.jsp; the file book-list-empty.jsp holds <p>Empty filter</p>.)
$ curl -s http://localhost:18080/g11conv/book-list
<html><body><h1>Bookshelf</h1><ul><li>Dune</li><li>Neuromancer</li></ul><p>filter=</p></body></html>
$ curl -s 'http://localhost:18080/g11conv/book-list?filter=x'
<html><body><h1>Bookshelf</h1><ul><li>Dune</li><li>Neuromancer</li></ul><p>filter=x</p></body></html>
$ curl -s 'http://localhost:18080/g11conv/book-list?filter=%20'
<html><body><p>Empty filter</p></body></html>
So book-list.jsp serves success and book-list-empty.jsp serves empty. The result type comes from the file extension:
| Extension | Result type | Observed |
|---|---|---|
|
|
|
|
|
|
|
|
Ignored by default (DEBUG: |
The .vm case is a trap that no document mentions: the plugin builds its result types from the parent package, and the default parent
convention-default extends only struts-default, which has the dispatcher and freemarker types and not velocity. The Velocity plugin
declares the type in its own package velocity-default (from its struts-plugin.xml). With the constant
<constant name="struts.convention.default.parent.package" value="velocity-default"/> the request /formats?code=vm returned
<p>Hello from the action (formats-vm.vm)</p>. See FreeMarker and Velocity Views for the views themselves.
Four more rules complete the lookup. They were all observed:
-
Unknown result code with no matching file.
/formats?code=zzzhas noformats-zzz.*file, and the plugin answered with the plainformats.jsp(the view without a code) and not with a 404. -
No view at all. An action whose view file is missing is mapped but fails when rendered: Struts logs
ConfigurationException: No result defined for action org.example.bookshelf.actions.reports.SalesAction and result successat WARN and the browser receives a 404. (That is the situation of the first attempt at/reports/sales, before the file was in the right directory, see @ResultPath.) -
Chaining. If an action returns the name of another action of the same package and has no result for that code, the plugin chains to that action. The Bookshelf
/gomethod returns"go-home"; the actiongo-homeexists, so the answer was its view (go-home.jsp) with no result declared. Thechainresult is the one used; see Action Chaining, Sessions and Scopes for what chaining copies. -
Nested layout. With
<constant name="struts.convention.result.flatLayout" value="false"/>the file forbook-listandsuccessis/WEB-INF/content/book-list/success.jsp(a directory per action, a file per result code). With the flat layout removed and the nested files in place,/book-listanswered 200 frombook-list/success.jsp.
The table that follows shows the default layout and its variants (the nested layout and the result path were run with the Bookshelf; the plugin page has a longer table of the same lookup).
| Layout | File |
|---|---|
Flat (default), code in the name |
|
Flat, no code (the default result of the action) |
|
|
|
|
|
The Getting Started "Annotations" tutorial follows the same rule: the action hello.action runs HelloAction.execute, and the view for success is
hello-success.jsp in WEB-INF/content (hello-input.jsp for input). The struts.convention.relative.result.types constant (default
dispatcher,velocity,freemarker) lists the result types whose location is resolved against the result path and the namespace when it is
relative; a location that starts with / is used as written.
The Annotations of the Plugin
All annotations of the plugin are in the package org.apache.struts2.convention.annotation and have RUNTIME retention. The plugin jar of 7.4.0
contains these: Action, Actions, AllowedMethods, DefaultInterceptorRef, ExceptionMapping, ExceptionMappings, InterceptorRef,
InterceptorRefs, Namespace, Namespaces, ParentPackage, Result, ResultPath and Results. Their targets and attributes, read with javap on
the 7.4.0 jar:
| Annotation | Where | Attributes |
|---|---|---|
|
Method or class |
|
|
Class, or inside |
|
|
Class or package |
|
|
Class or package |
|
|
Class, or inside |
|
|
Package (in |
|
|
Class, or inside |
|
|
Class or package |
|
|
Class or package |
|
The next sections go through them with Bookshelf examples. @DefaultInterceptorRef, @AllowedMethods and @Namespaces are in the 7.4.0
jar but are not described on the plugin page or on the Core Developers Guide annotations page.
@Action and @Actions: Names and Several Actions per Class
@Action on a method makes that method the action. The value is the action name. A name that starts with / is absolute: its namespace is
the part before the last slash and the plugin ignores the package. A name without a leading slash is relative to the namespace of the class.
The Bookshelf BookDetailsAction has four relevant methods:
@Action("book")
public String show() {
if (title == null || !Catalogue.titles().contains(title)) {
message = "No such title";
return "failure";
}
return SUCCESS;
}
@Actions({
@Action("book-info"),
@Action("/legacy/book-details")
})
public String info() {
return SUCCESS;
}
show() is /book. info() is two actions: the relative book-info in the namespace of the class, and the absolute
/legacy/book-details in the namespace /legacy. The views come from the result lookup of the previous section:
$ curl -s 'http://localhost:18080/g11conv/book?title=Dune'
<html><body><p>Book: Dune</p></body></html>
$ curl -s 'http://localhost:18080/g11conv/book?title=Nope'
<html><body><p>failure: No such title</p></body></html>
$ curl -s http://localhost:18080/g11conv/book-info
<html><body><p>info</p></body></html>
$ curl -s http://localhost:18080/g11conv/legacy/book-details # needs content/legacy/book-details.jsp
<html><body><p>legacy book details</p></body></html>
The absolute action found its view in /WEB-INF/content/legacy/book-details.jsp: the namespace is part of the file path, so a name
with a slash needs a matching directory (the first attempt, with the file in WEB-INF/content/, returned a 404 with No result defined).
Three behaviours of @Action are worth knowing:
-
The
executemethod is mapped too. By default (struts.convention.action.alwaysMapExecute,true) a class that has other@Actionmethods is also mapped under the name derived from the class name, usingexecute(inherited fromActionSupportif you do not write one). The config browser listedbook-detailsforBookDetailsActionfor that reason. Setting the constant tofalseremoved the extra mapping-
Keep the default to catch the unexpected URL in review, or set
falseto expose only what is annotated.
-
-
Annotated methods are allowed methods. The 6.x line made method invocation strict (an action may be called only for the methods listed in
allowed-methods; see Wildcard Mappings and Method Invocation). The plugin adds the annotated methods to that list itself:/book(the methodshow, notexecute) worked without anyallowed-methodselement. The class-level annotation@AllowedMethods(below) is for the dynamic method invocation case. -
Duplicate names are an error. Two methods of one class annotated with the same name stop the whole application at start-up (since 7.2.0, WW-4421). The verification run answered every URL with 404 and logged
The action class [class org.example.bookshelf.actions.DupAction] contains two methods with an action name annotation whose value is the same.
The plugin page also allows @Action and @Actions at the class level (useful with dynamic method invocation: the execute method is used if it exists, otherwise the method is
decided by the request), and says the className attribute is mainly useful when Spring creates the actions, which belongs to Spring Integration. Neither was run here.
Wildcard names. An @Action value may contain a wildcard, as an XML action name can. The Bookshelf GenreAction maps any genre and gives the
single genre fiction its own view; the wildcard is declared first on purpose:
public class GenreAction extends ActionSupport {
@Action(value = "genre-*", results = @Result(location = "genre.jsp"))
public String any() {
return SUCCESS;
}
@Action(value = "genre-fiction", results = @Result(location = "genre-fiction.jsp"))
public String fiction() {
return SUCCESS;
}
}
/genre-poetry answered <p>any genre</p> and /genre-fiction answered <p>fiction</p>: the more specific name wins even though it was declared
second. That ordering is a 7.x change (WW-3784, in 7.3.0; WW-5743 in 7.4.0 ranks ** patterns after single-segment ones), so a Struts 6
application that relied on declaration order must be re-read; for XML wildcard mappings the rules of the
wildcard page apply.
The Getting Started tutorial uses @Action("register-input") on a method input() that returns INPUT so that the link register-input.action shows the
form (view register-input.jsp) while register.action runs execute(); the Bookshelf /book-form follows the same pattern below.
@Result and @Results: Local and Class-Level Results
@Result declares a result. Inside @Action(results = ...) it is local: it applies to that action only. On the class (alone or in
@Results) it is global to the class: every action method of the class inherits it. The Bookshelf BookDetailsAction shares one failure
view on the class, and the form action has local results, one with a name array (since 2.5) and one that redirects:
@Results({
@Result(name = "failure", location = "book-failure.jsp")
})
public class BookDetailsAction extends ActionSupport {
@Action(value = "book-form", results = {
@Result(name = {"error", "input"}, location = "book-form-input.jsp"),
@Result(name = "success", type = "redirectAction", params = {"actionName", "book-list"})
})
public String form() {
return title == null ? INPUT : SUCCESS;
}
// ...
}
$ curl -s http://localhost:18080/g11conv/book-form
<html><body><p>input form</p></body></html>
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'http://localhost:18080/g11conv/book-form?title=Dune'
302 http://localhost:18080/g11conv/book-list.action
Notes on the attributes, all confirmed in the config browser (showConfig for book-form: input and error to book-form-input.jsp, success to
ServletActionRedirectResult with actionName = book-list, and the class-level failure inherited):
-
locationis relative to the result path and the namespace when the result type is instruts.convention.relative.result.types; an absolute location (/WEB-INF/content/export-unavailable.jsp) is used as it is. -
typeis the name of a result type of the parent package (redirectAction,httpheader,chain,stream…). Without a type the plugin chooses it from the file extension. -
paramsis a string array of key and value pairs, in an even number:{"actionName", "book-list"}above,{"status", "503"}below. -
Class-level results are inherited by sub-classes, and a result defined at the method level overrides one with the same name at the class level (documented since 2.1.7).
-
The redirect target was
book-list.action: a redirect to an action name gets the action extension.
@Namespace: A Class or a Whole Package
@Namespace replaces the namespace that the package name gives. On a class it applies to every action of the class that is not absolute. The Bookshelf CSV export lives
in the package ...actions.export but is published under /csv:
@Namespace("/csv")
public class BookExportAction extends ActionSupport {
@Action("absolute")
public String absolute() {
return SUCCESS;
}
// ...
}
/csv/absolute answered <p>csv absolute</p> from WEB-INF/content/csv/absolute.jsp, and /export/books (the namespace of the Java package) was a 404 with the WARN There is no Action mapped for namespace [/export]. In a package, the annotation goes in
package-info.java and changes the default namespace of every action in that Java package (not its sub-packages):
@org.apache.struts2.convention.annotation.Namespace("/reports")
@org.apache.struts2.convention.annotation.ResultPath("/WEB-INF/reports")
package org.example.bookshelf.actions.reports;
@ResultPath: Where the Views Live
@ResultPath replaces the result path of the class or package (the constant gives the global default). The package-info above sets /WEB-INF/reports
for SalesAction; the namespace of the class is /reports, so the view is not /WEB-INF/reports/sales.jsp but
/WEB-INF/reports/reports/sales.jsp: the namespace is still appended to the result path. The first attempt with the file at /WEB-INF/reports/sales.jsp was
answered with a 404 and the WARN No result defined for action org.example.bookshelf.actions.reports.SalesAction and result success; after the file
was moved:
$ curl -s http://localhost:18080/g11conv/reports/sales
<html><body><p>Sales report</p></body></html>
The second attribute, property, names a property of the action that supplies the path; this page did not exercise it.
@ParentPackage, @InterceptorRef and @DefaultInterceptorRef
A generated package extends convention-default, which extends struts-default. To use interceptors, result types or global results declared in your
struts.xml, the action must live in a package that extends the one that declares them: that is what @ParentPackage selects (the equivalent of
extends). The Bookshelf declares a stack in XML:
<struts>
<package name="bookshelf-timed" extends="struts-default">
<interceptors>
<interceptor-stack name="timedStack">
<interceptor-ref name="logger"/>
<interceptor-ref name="defaultStack"/>
</interceptor-stack>
</interceptors>
</package>
</struts>
(The stack uses logger because there is no timer interceptor in the 7.4.0 struts-default.xml; see the errata of
Interceptors.) A class that applies the stack must also extend the package:
@ParentPackage("bookshelf-timed")
@InterceptorRefs({@InterceptorRef("timedStack")})
public class StockOkAction extends ActionSupport {
@Override
public String execute() {
return SUCCESS;
}
}
@InterceptorRef takes value (an interceptor or a stack) and params (pairs, for example params = {"programmatic", "false"}). On a class, @InterceptorRefs
applies to every action of the class; inside @Action(interceptorRefs = ...) it is added for that action. The plugin page states that the
interceptors given on the method are added to those of the class, and that "if interceptors are not specified, the default stack is applied". To apply a default to
a whole package, package-info.java takes @ParentPackage plus @DefaultInterceptorRef:
@org.apache.struts2.convention.annotation.ParentPackage("bookshelf-timed")
@org.apache.struts2.convention.annotation.DefaultInterceptorRef("timedStack")
package org.example.bookshelf.actions.timed;
With the logger interceptor raised to INFO (org.apache.struts2.interceptor.LoggingInterceptor), the requests to /timed/stats (package default) and to
/stock-ok (class annotations) both logged Starting execution stack for action /timed/stats and .../stock-ok and the matching
Finishing execution stack, so both routes applied the stack. The generated package of the first was
org.example.bookshelf.actions.timed#bookshelf-timed#/timed: the third part of the name is the parent package.
The failure mode is the most dangerous one of the plugin. The Bookshelf class StockAction had @InterceptorRefs({@InterceptorRef("timedStack")}) and no
@ParentPackage. The generated package extends convention-default, which does not know timedStack, so the start-up failed for the whole application:
ERROR Dispatcher - Dispatcher initialization failed
Caused by: org.apache.struts2.config.ConfigurationException: Unable to find interceptor class referenced by ref-name timedStack
at org.apache.struts2.convention.DefaultInterceptorMapBuilder.buildInterceptorList(DefaultInterceptorMapBuilder.java:87)
and every URL of the application answered 404 (including /book-list). The plugin page gives the two fixes: use @ParentPackage (or
struts.convention.default.parent.package) with the package that declares the interceptor, or declare an XML package that extends it and point at
that.
See Interceptors and Custom Interceptors for the interceptors themselves.
@ExceptionMapping and @ExceptionMappings
An exception mapping turns an exception into a result code, as the XML exception-mapping element does (see
Exception Handling and Global Results). On a class it applies to every action of the
class; in @Action(exceptionMappings = ...) to one. The Bookshelf export fails when the exporter is offline, and two ways of answering were verified:
@Namespace("/csv")
@ExceptionMappings({
@ExceptionMapping(exception = "java.lang.IllegalStateException", result = "unavailable")
})
public class BookExportAction extends ActionSupport {
@Action(value = "books", results = @Result(name = "unavailable", location = "/WEB-INF/content/export-unavailable.jsp"))
public String books() {
throw new IllegalStateException("exporter offline");
}
@Action(value = "titles", exceptionMappings = {
@ExceptionMapping(exception = "java.lang.IllegalStateException", result = "offline")
}, results = @Result(name = "offline", type = "httpheader", params = {"status", "503"}))
public String titles() {
throw new IllegalStateException("exporter offline");
}
// ...
}
$ curl -s -w '%{http_code}\n' http://localhost:18080/g11conv/csv/books
<html><body><p>export unavailable</p></body></html>
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g11conv/csv/titles
503
/csv/books used the class-level mapping and a result for the code unavailable; /csv/titles used a method-level mapping and a httpheader result
with status 503. The exception-mapping annotations need the exception interceptor of the stack (part of defaultStack), and the result
name must exist for the action, as in XML. The params attribute (not used here) passes values to the result, as the plugin page’s example does
with params = {"param1", "val1"}.
@AllowedMethods and Dynamic Method Invocation
The allowed-methods of the generated action covers the annotated method. Dynamic method invocation (/book-details!info) is off by default
(struts.enable.DynamicMethodInvocation=false) and is discouraged (see Wildcard Mappings and Method Invocation). When it is
switched on for a class that uses the plugin, @AllowedMethods lists the methods that may be called. The Bookshelf BookDetailsAction carries
@AllowedMethods({"info", "go"}); with the constant struts.enable.DynamicMethodInvocation set to true:
$ curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:18080/g11conv/book-details!info' # 200
$ curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:18080/g11conv/book-details!go' # 200
$ curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:18080/g11conv/book-details!show' # 404, not listed
Before the annotation was added, every !method request was a 404 with the WARN Could not find action or result. Leave the constant false
unless there is a reason; the annotation is the narrow way to open a method when there is one.
The Other Annotations of the Reference
The Core Developers Guide page "Annotations" is a catalogue of annotations across the framework; only the Action group belongs to this plugin. The rest are documented on the pages that use them:
| Group | Annotations (7.4.0 package) | Page |
|---|---|---|
Security |
|
|
Action |
The table above ( |
This page |
Workflow |
|
|
Interceptor |
|
|
Validation |
|
|
Type conversion |
|
|
Tiles |
|
The Generated Package
Each combination of Java package, namespace and parent package becomes one generated Struts package, so actions never collide with those of other
packages. The name is <java-package>#<namespace>#<parent-package>, where the parent defaults to the value of
struts.convention.default.parent.package (convention-default). From the Bookshelf run:
| Action | Namespace | Package name |
|---|---|---|
|
root |
|
|
|
|
|
|
|
|
|
|
The root namespace is written as an empty string inside the name, not as /, so the plugin page’s example com.example.actions#/#conventionDefault
differs from what 7.4.0 produces. The namespace rules of Packages and Namespaces apply to these
packages unchanged. The plugin sets struts.mapper.alwaysSelectFullNamespace to true in its own struts-plugin.xml (it is the default in 7.0 and later too).
Discovering Mappings: The Config Browser
The question "what did the plugin actually generate?" is answered by the Config Browser plugin, a development tool that lists the loaded configuration. The Getting Started tutorial recommends it with the Convention plugin. Add the dependency (version from the BOM):
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-config-browser-plugin</artifactId>
</dependency>
and open /config-browser/index.action of the application (the Config Browser page says /config-browser/index also works with the default action mapper). In the verification run the request was redirected (302) to
/g11conv/config-browser/actionNames.action, which listed the namespaces of the Bookshelf (default, /config-browser, /admin, /timed, /legacy, /csv, /reports) and the
actions of each; showConfig.action?namespace=&actionName=book-form printed the class (BookDetailsAction), the method (form), the default location
(/g11conv/book-form.action) and the results shown earlier; showConstants.action listed the struts.convention.* constants in effect, and showBeans.action the beans.
The tool answered without any authentication. The plugin page says it "should be used only during development phase and access to it should be strictly
restricted"; do not ship the jar to production.
Source: Struts Config Browser Plugin
Debugging What the Scan Does
The scan logs through the category org.apache.struts2.convention. With Log4j 2 (the Struts 7 default), a file log4j2.xml in src/main/resources
shows every decision:
<Configuration status="WARN">
<Appenders>
<File name="F" fileName="/tmp/bookshelf-convention.log" append="false">
<PatternLayout pattern="%-5p %c{1} - %m%n"/>
</File>
</Appenders>
<Loggers>
<Logger name="org.apache.struts2.convention" level="debug"/>
<Root level="warn"><AppenderRef ref="F"/></Root>
</Loggers>
</Configuration>
(The file name here is a placeholder; the verification wrote to a scratch file.) The DEBUG lines used throughout this page come from it:
Processing class [...] in package [...], Creating action config for class [...], name [...] and package name [...] in namespace [...],
No result type defined for file suffix : [vm]. Ignoring file ... and "struts.convention.action.includeJars" is not defined, excluding all JAR files!.
Source: Struts Core Developers Guide — Logging
The plugin page’s tip for Log4j 1 (log4j.logger.org.apache.struts2.convention=DEBUG) is the same category. The common errors of the plugin page map to the following:
| Message | Cause | Fix |
|---|---|---|
|
The URL does not match a mapping: wrong namespace or name (the verification used |
Check the config browser; fix the URL or the namespace |
|
The action is mapped but no view file was found for the code |
Create the file at result path plus namespace plus name, or declare a |
|
A class references an interceptor that its generated package does not inherit |
|
|
Two |
Rename one |
Every URL answers 404 after the start |
Start-up failed; look for |
Read the first ERROR of the log |
|
|
Narrow the expression |
Replacing the Plugin’s Beans
Five beans do the work, all registered under the name convention in the plugin’s struts-plugin.xml, and each selected by a constant of the same name. Any of them can be
replaced by defining a bean of the same type under another name and pointing the constant at it, exactly as the name builder was replaced above:
| Constant | Bean type | Default implementation |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Two more beans are registered but are not extension points of the same kind: the UnknownHandler named convention and the two package providers
convention.packageProvider and convention.containerProvider (ClasspathPackageProvider, ClasspathConfigurationProvider). The ConventionUnknownHandler is the class that answers
the zero-action URLs, the fallback to the plain view and the redirect of /admin to /admin/.
The Redirect to the Slash
When no action answers /admin but one answers /admin/ (the default IndexAction of the package admin), the plugin redirects with a 302, as a
web server does for a directory. The Bookshelf admin/IndexAction produced:
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://localhost:18080/g11conv/admin
302 http://localhost:18080/g11conv/admin/
$ curl -s http://localhost:18080/g11conv/admin/
<html><body><p>admin index</p></body></html>
The constant struts.convention.redirect.to.slash (true) controls it; the Bookshelf action named index of the package answered the URL that ends with a slash.
All the Constants
The 7.4.0 struts-plugin.xml declares the values below; the class ConventionConstants has one more name (eagerLoading). Defaults are those of the
jar, which differ from the plugin page in the places marked.
| Constant | Default | Meaning |
|---|---|---|
|
|
Names of the replaceable beans (see above) |
|
|
Directory of the views; trailing slashes are handled |
|
|
|
|
|
Class-name suffix that marks an action and is removed from the name |
|
|
Turns the class path scan off |
|
|
Create mappings for matching classes even when they have no |
|
|
A class that implements |
|
|
Parent of the generated packages (the page’s table shows the same) |
|
|
Lower-case action names |
|
|
Word separator of names and between the name and the result code |
|
|
Locator words (see above) |
|
|
Turn the locator search off |
|
empty |
Limit the locator search to this package |
|
|
Never scanned |
|
|
Result types whose relative |
|
|
302 from |
|
|
Also map |
|
not set |
Regular expressions of jar URLs to scan |
|
|
URL protocols the scan accepts (JBoss and embedded Jetty change it) |
|
|
Reload the actions when classes change (experimental) |
|
|
Exclude URLs of the parent class loader from the scan; the plugin page’s table writes it |
|
|
Not described by the plugin page; declared in the jar (strict method invocation and inheritance of allowed methods) |
|
not declared |
Instantiate found classes early; the page says it "can clash with Spring managed beans" |
The constant struts.mapper.alwaysSelectFullNamespace=true is also set by the plugin. For the two platform notes of the plugin page: JBoss needs
struts.convention.exclude.parentClassLoader=true and struts.convention.action.fileProtocols=jar,vfsfile,vfszip; embedded Jetty needs
exclude.parentClassLoader=false and fileProtocols=jar,code-source. Neither platform was used here (the application ran on Tomcat 11), so those
two lines are quoted from the plugin page. The plugin page also describes automatic reload
(struts.devMode=true plus struts.convention.classes.reload=true), which it calls experimental and "strongly advised not to use … in production environments"; this was
not exercised either.
Source: Struts Convention Plugin, JBoss
XML or Annotations: Choosing for the Bookshelf
The first Struts 2 tutorial of every book asks the question "XML or annotations?". The answer of the verification is that the two are not exclusive: a
single application can keep the struts.xml for the cross-cutting things (the package with the interceptor stack, global results, constants) and let the
plugin generate the actions. The table compares the concerns:
| Concern | struts.xml |
Convention plugin |
|---|---|---|
Where the URL is declared |
One |
The class and package name, or |
Discoverability |
The file is the list of actions |
The class path is the list; use the config browser or the DEBUG log |
View location |
|
File name under |
Cross-cutting configuration |
Packages, stacks, global results, constants |
Still in XML; selected with |
Refactoring |
A rename leaves the XML out of date |
A rename of a class changes the URL: a silent API change |
Failure mode |
An error in the file stops start-up with its line |
A scan error (unknown interceptor, duplicate |
Review |
One place to read |
Annotations are spread over the classes |
The Convention plugin is also the base of the REST plugin and of the Getting Started "Annotations" tutorial, and it is a good fit for new
applications with many small actions; XML stays the better tool when an auditor needs one file that lists every URL. A rename of
BookListAction to ShelfAction moves /book-list to /shelf, so pin the URLs that other systems call with an explicit @Action.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
An action class is ignored (observed) |
The package does not end with a locator, or the class neither implements |
Move it under an |
The whole application answers 404 after a change (observed) |
A start-up failure of the scan: unknown interceptor, duplicate |
Read the first ERROR of the log; fix the annotation |
|
The generated package does not extend the package that declares the interceptor |
|
|
Acronym splitting of |
|
A class named |
The suffix is removed from the end of the name |
Rename the class or give it an |
404 with |
The view file is missing, or it is in a directory that omits the namespace |
Create the file at path, namespace, name; mind that |
|
The parent package has no |
|
An unexpected URL answers (observed) |
|
Set |
A view for an absolute |
The file must be under the namespace: |
Put the file in the directory of the namespace |
A parameter is never set |
The setter lacks |
Annotate the setter |
A rename of a class moves a public URL |
The URL derives from the class name |
Pin with |
|
|
Prefer one |
The config browser is reachable in production |
The jar was left in |
Remove it from the production build; the plugin page says access must be strictly restricted |
What Changed Recently
| Version | Change |
|---|---|
Struts 2.1.6 |
The plugin is introduced; the Codebehind and Zero Config plugins are deprecated from 2.1 and Codebehind is removed in the 2.5 line |
Struts 2.1.7 |
Class-level |
Struts 2.5 |
|
Struts 6.0 |
The newest configuration DTD is |
Struts 7.0 |
|
Struts 7.2.0 |
Duplicate |
Struts 7.3.0 |
Annotated wildcard actions are ordered most-specific-first (WW-3784) |
Struts 7.4.0 |
|
Source: Struts 6.x.x to 7.x.x migration
Source: Struts Version Notes 7.4.0
The release of each WW ticket was found by comparing the commit with the release tags of the source repository (STRUTS_7_2_0, STRUTS_7_3_0, STRUTS_7_4_0).
Where the Books Are Outdated
The Struts 2 book of the bibliography introduces annotations in its Hello World chapter and again in the chapter on configuration; the Struts 1 books
have no equivalent (everything is in struts-config.xml).
| In the books | Today |
|---|---|
Zero configuration with the filter parameter |
Both are gone: the Zero Config plugin was deprecated in 2.1, and |
The Codebehind plugin (conventions on class names and views) |
Removed in the 2.5 line; the Convention plugin took its place with different defaults ( |
|
|
Imports of |
|
Result file |
The |
Namespace and class names taken from the books' examples in |
Keep the locator word ( |
The name |
|
Setters bound from any request parameter |
|
URLs ending in |
7.4.0 accepts |
Inconsistencies in the Official Documentation
Collected while verifying this page against the 7.4.0 jar:
-
The plugin page and its examples import
com.opensymphony.xwork2.Actionandcom.opensymphony.xwork2.ActionSupport; the 7.4.0 names areorg.apache.struts2.action.Actionandorg.apache.struts2.ActionSupport. ThecheckImplementsActionrow also names the old interface. -
The configuration table writes
struts.convention.classLoader.excludeParent; the constant of the jar (and of the JBoss and Jetty sections of the same page) isstruts.convention.exclude.parentClassLoader. -
The table omits
struts.convention.action.fileProtocols,struts.convention.classes.reloadandstruts.convention.enable.smi.inheritance, which the 7.4.0struts-plugin.xmldeclares, and listsstruts.convention.action.eagerLoading, which it does not declare. -
The XWork package example is
com.example.actions#/#conventionDefault; 7.4.0 buildscom.example.actions#convention-default#(an empty namespace for the root). -
The result-extension sentence reads "jsp,ftl,vm,html,html" and does not say that
.vmneeds a parent package with thevelocityresult type. -
The
@AllowedMethods,@DefaultInterceptorRefand@Namespacesannotations of the 7.4.0 jar are described neither on the plugin page nor on the Core Developers Guide annotations page, which also lists aValidationmarker annotation that the 7.4.0 jar does not contain and omits some validators of the jar (CreditCardValidator,LongRangeFieldValidator,ShortRangeFieldValidator,ConditionalVisitorFieldValidator). -
The
includeJarsexample<constant name="..." value="..."is not closed. -
The plugin page does not say that an unknown result code falls back to the view without a code.
-
The Config Browser page footer still reads "Struts (c) 2003 - 2015" and the tool uses a Tigris template; it works with 7.4.0.
Summary
-
Add
struts2-convention-plugin; an action is a class in a package that ends withaction,actions,strutsorstruts2, that implementsActionor whose name ends withAction. -
The URL is the namespace (package below the root) plus the class name without the suffix, in lower-case words joined by
-; check acronyms. -
The view is
/WEB-INF/content/<namespace>/<action>[-<code>].<ext>; the extension selects the result type;.vmneedsvelocity-defaultas parent. -
Annotations override:
@Action(s),@Result(s),@Namespace,@ParentPackage,@InterceptorRef(s),@DefaultInterceptorRef,@ExceptionMapping(s),@ResultPath,@AllowedMethods. -
A bad annotation stops the whole application; the config browser and the DEBUG log show what was generated.