REST Plugin
|
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 (released 2026-10-02) running on Apache Tomcat 11.0.26 and Java 21; the
facts were checked on 2026-10-08 against the REST plugin page of the official documentation, the Javadoc and the source of
the plugin at the STRUTS_7_4_0 tag, the struts-plugin.xml inside the plugin jar and the security bulletins S2-075 and
S2-077, and every example was built into a Maven WAR (context path /g11rest), deployed to the scratch Tomcat and
requested with curl. The REST plugin turns a Struts application into a resource-oriented web service: URLs name
resources (/books, /books/2), the HTTP method chooses the operation, and the same controller answers in HTML, JSON
or XML depending on the extension or the Accept header of the request. It does this with an alternative action mapper,
RestActionMapper, an interceptor that reads the request body into the controller, a content type handler per
representation and an HttpHeaders return type that lets a controller method decide status code, ETag,
Last-Modified and Location without touching the servlet API. The running scenario is the Bookshelf, a catalogue of
books; this page exposes it as /books with one controller class and shows the table that maps verbs to methods, the
three representations, creation with 201 Created and a Location header, validation errors as a 400 body, conditional
requests, a custom CSV representation, the size limit and Fetch Metadata check added in 7.4.0, and the two older RESTful
mappers of the core, which are deprecated. Related pages of this section are linked where they come up: JSON Plugin and Ajax, Convention Plugin and Annotations, Spring Integration and Other Plugins.
What the Plugin Is
The official page describes the plugin as high-level support for RESTful, resource-based web applications that is meant to be used with the Convention plugin, and lists its features: Ruby on Rails style REST URLs, zero XML configuration when used with Convention, built-in serialization and deserialization for XML and JSON, automatic error handling, type-safe configuration of the HTTP response and automatic conditional GET support. The plugin jar of 7.4.0 contains a small set of classes that account for all of it:
Class (package org.apache.struts2.rest) |
Role |
|---|---|
|
The |
|
The proxy factory (name |
|
Selects a handler from the request |
|
Replaces |
|
The return type of controller methods that control status, |
|
A base class (extends |
|
One handler per representation: |
|
The names of the |
Source: Struts Plugins — REST, Overview
Source: Struts Plugins — REST, Features
The package it declares, rest-default, extends struts-default, redefines the redirect and redirectAction result
types so that they answer 303 See Other, and defines restDefaultStack, which becomes the default interceptor stack of
that package. Controllers that live in rest-default get that stack; controllers in any other package do not, which is
the reason a controller must not "willfully break its membership" of the package, as the official page puts it.
Setup: the Plugin, the Convention Plugin and an XML Library
The Maven coordinates of every Struts plugin come from the struts2-bom, so no version is repeated. The official page
asks for two plugins; the verified scenario needs a third dependency, because the plugin registers its XML handler
unconditionally and declares the XML library as an optional dependency of its own.
<dependencies>
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-core</artifactId>
</dependency>
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-rest-plugin</artifactId>
</dependency>
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-convention-plugin</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.22.2</version>
</dependency>
</dependencies>
With only the first three dependencies the application did not start on Tomcat 11.0.26: the log said that the bean of type
ContentTypeHandler with the class JacksonXmlHandler could not be loaded, with the root cause
ClassNotFoundException: com.fasterxml.jackson.dataformat.xml.JacksonXmlModule, and every URL answered 404. The plugin
brings jackson-core and jackson-databind (2.22.2 for 7.4.0) and XStream 1.4.21 transitively; jackson-dataformat-xml,
Apache Juneau and commons-beanutils are optional dependencies of the plugin pom, and the shipped struts-plugin.xml
needs the first of them. The version of jackson-dataformat-xml should match the Jackson version of the plugin. The
servlet and Pages APIs stay provided, as in the other pages of this section.
Configuration: REST Only, or REST Next to Classic Actions
Installing the jar already changes the configuration, because the plugin’s struts-plugin.xml sets several constants. The
ones that matter at once are struts.mapper.class=rest (every URL is now interpreted as a REST URL),
struts.action.extension=xhtml,,xml,json (the extension action is gone) and struts.rest.defaultExtension=xhtml.
REST only
For an application that is entirely RESTful the only decisions are how the controllers are found and which package they belong to. The Convention plugin scans for classes whose name ends with a suffix; the REST package is the parent:
<struts>
<constant name="struts.convention.action.suffix" value="Controller"/>
<constant name="struts.convention.action.mapAllMatches" value="true"/>
<constant name="struts.convention.default.parent.package" value="rest-default"/>
<constant name="struts.convention.package.locators" value="rest"/>
</struts>
With the package locator rest, the class org.example.bookshelf.rest.BooksController is mapped to the root namespace as
the action books (the suffix is removed, the name is lower-cased). The Convention plugin is not required: the mapper does
not care how an action became known, and a <package name="api" extends="rest-default"> with <action> elements works the
same way. The Convention page of this section explains the naming rules, the @Action, @Result,
@Namespace and @AllowedMethods annotations used below, and struts.convention.result.path, which makes
/WEB-INF/content/books-index.jsp the view of the result index of the action books.
REST next to classic actions
When an existing application keeps its *.action URLs, one mapper must serve both. The core class
PrefixBasedActionMapper chooses a mapper from the URL prefix, and PrefixBasedActionProxyFactory does the same for the
proxy factory. The official page lists three constants; because the plugin removed action from the extension list, the
page also asks to re-assert it:
<struts>
<constant name="struts.action.extension" value="xhtml,,xml,json,action"/>
<constant name="struts.mapper.class"
value="org.apache.struts2.dispatcher.mapper.PrefixBasedActionMapper"/>
<constant name="struts.mapper.prefixMapping" value="/rest:rest,:struts"/>
<constant name="struts.actionProxyFactory" value="prefix"/>
<!-- Convention constants as above; BooksController carries @Namespace("/rest") -->
<package name="classic" extends="struts-default" namespace="/">
<action name="hello" class="org.example.bookshelf.rest.HelloAction">
<result type="plainText">/WEB-INF/hello.txt</result>
</action>
</package>
</struts>
With this file, /hello and /hello.action were answered by the classic action (200, text), and the controller answered
under the prefix:
$ curl -i http://localhost:18080/g11rest/rest/books.json
HTTP/1.1 200
Cache-Control: no-cache
Content-Type: application/json;charset=UTF-8
[{"id":1,"title":"Dune","author":"Frank Herbert"},{"id":2,"title":"Neuromancer","author":"William Gibson"}]
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"title":"Hyperion","author":"Dan Simmons"}' \
http://localhost:18080/g11rest/rest/books.json
HTTP/1.1 201
Location: http://localhost:18080/g11rest/rest/books/3.json
The tempting shortcut is the constant struts.rest.namespace. In 7.4.0 it only limits the namespaces for which
RestActionProxyFactory creates a RestActionInvocation; the mapper stays rest for every URL. With
struts.rest.namespace=/rest and no prefix mapper, /hello was still mapped as GET on the index method and failed with
HTTP 500, java.lang.NoSuchMethodException: org.example.bookshelf.rest.HelloAction.index(), and /hello.action was a
404 because the extension is gone. Use the prefix mapper for coexistence, and the namespace constant only to keep the REST
invocation away from some namespaces.
Mapping Verbs and URLs to Controller Methods
RestActionMapper implements the logic of the Rails REST convention. It drops the extension, splits the URI into a
namespace and an action name (the longest matching package namespace wins), and, unless a method is already named,
guesses the method from the HTTP method and from whether the last path segment is an id. The id is removed from the
action name and passed to the action as the request parameter named by struts.mapper.idParameterName (default id).
| HTTP method | URL | Controller method (default) | Parameters |
|---|---|---|---|
GET |
|
|
|
POST |
|
|
|
GET |
|
|
|
PUT |
|
|
|
DELETE |
|
|
|
GET |
|
|
|
GET |
|
|
none ( |
OPTIONS |
any |
|
|
POST with |
|
|
|
PUT with |
|
|
|
The method names are constants of the plugin (struts.mapper.indexMethodName, getMethodName (default show),
postMethodName, putMethodName, deleteMethodName (default destroy), editMethodName, newMethodName,
optionsMethodName, postContinueMethodName and putContinueMethodName), so a team that prefers list and get can
rename them. HTML forms cannot send PUT or DELETE; the mapper therefore accepts the request parameter _method: a POST
with _method=PUT is a PUT, and any request with _method=DELETE is a DELETE, whatever its HTTP method: GET /books/2.json?_method=DELETE deleted book 2 and answered 204, which makes a state-changing GET that a link prefetcher or a CSRF image tag can trigger (the parameter is read with getParameter, so a query string suffices).
The mapper also handles /books/2/edit through the "fun trickery" of its own comment: when the URI ends in
action/id/method, the last segment becomes the method name and the previous one the id.
Two details are worth knowing. A name!method URL is parsed, but dynamic method invocation is off by default, so
/books!show.json ignored the !show and ran index; the REST convention replaces ! rather than extending it. And a
URL with an extension that no handler knows (/books/1.yaml) is not mapped at all: the extension must be in
struts.action.extension for the mapper to strip it, and a handler must exist for it to be served.
Writing the Controller
A controller is an ordinary action whose methods are named after the table above. The Bookshelf controller implements the
five core operations and the two form views, extends RestActionSupport to inherit index() and options() (the class
below redefines index()), and implements ModelDriven so that only the book, not the whole controller, is serialized.
Book is a bean with id, title and author; BookStore is a static in-memory map with two seeded books.
@AllowedMethods("options")
@Results({
@Result(name = "destroy", type = "redirectAction", params = {"actionName", "books"}),
@Result(name = "update", type = "redirectAction", params = {"actionName", "books"})
})
public class BooksController extends RestActionSupport implements ModelDriven<Object> {
private Long id;
private Book model = new Book();
private List<Book> list;
// GET /books
public HttpHeaders index() {
list = BookStore.all();
return new DefaultHttpHeaders("index").disableCaching();
}
// GET /books/{id}
public HttpHeaders show() {
Book found = BookStore.find(id);
if (found == null) {
return new DefaultHttpHeaders("error").withStatus(HttpServletResponse.SC_NOT_FOUND);
}
model = found;
return new DefaultHttpHeaders("show")
.withETag("\"b" + found.getId() + "-" + found.getTitle().hashCode() + "\"")
.lastModified(new Date(1_700_000_000_000L));
}
// GET /books/new
public String editNew() { model = new Book(); return "editNew"; }
// POST /books
public HttpHeaders create() {
BookStore.add(model);
return new DefaultHttpHeaders("create").setLocationId(model.getId());
}
// PUT /books/{id}
public HttpHeaders update() {
model.setId(id);
BookStore.put(model);
return new DefaultHttpHeaders("update").withStatus(HttpServletResponse.SC_OK);
}
// DELETE /books/{id}
public HttpHeaders destroy() {
BookStore.remove(id);
return new DefaultHttpHeaders("destroy").withStatus(HttpServletResponse.SC_NO_CONTENT);
}
@Override
public void validate() {
String method = ActionContext.getContext().getActionMapping().getMethod();
if (("create".equals(method) || "update".equals(method))
&& (model.getTitle() == null || model.getTitle().isBlank())) {
addFieldError("title", "title is required");
}
}
@StrutsParameter
public void setId(Long id) { this.id = id; }
public Long getId() { return id; }
@Override
public Object getModel() { return list != null ? list : model; }
}
The official example returns HttpHeaders from show() and a String from update(), and the controller above does the
same; the method edit() of the scenario is analogous to editNew() and omitted. Several points deserve a comment:
-
ModelDriven<Object>with agetModel()that returns the list forindexand the book for everything else lets one controller serialize collections and items; the official example keeps one model type. The model is also the target of the request body (see the section on bodies below). -
@StrutsParameteronsetIdis required. The mapper hands theidto the action as a mapping parameter, and since Struts 7 an injection point without the annotation is rejected: without itshow()ran withid == nullandBookStore.find(null)failed with aNullPointerException. The sameidwas also set on the model (the body of a404for/books/3contained"id":3), because the stack’sparamsinterceptor sees the model on the value stack. The parameter injection page of this section explains the annotation. -
The controller does not extend
ActionSupportdirectly and does not need to: the stack ofrest-defaultsupplies validation, type conversion errors and i18n, as the official page says;RestActionSupportextendsActionSupportanyway, which givesaddFieldError,getTextand theValidationAwarecontract. -
@AllowedMethods("options")is needed forOPTIONS(see the pitfalls). Theglobal-allowed-methodsofrest-defaultareexecute,input,back,cancel,browse,save,delete,list,index,show,create,update,destroy,edit,editNew; any other method name is refused by strict method invocation.
The two results of destroy and update are redirects used only by the HTML representation; the JSON and XML
representations ignore them, as the next sections show.
Representations: Extension, Accept Header and Content-Type
The representation of a response is chosen in this order: the extension of the URL (.json, .xml, .xhtml), otherwise
the first media type of the Accept header that has a handler, otherwise struts.rest.defaultExtension (xhtml, which
means HTML rendered through the Convention results). The representation of a request body is chosen by its
Content-Type, and by the extension when the content type is unknown. The five handlers registered by the plugin are:
| Handler (bean name) | Extension and content type | Notes |
|---|---|---|
|
|
Jackson |
|
|
Needs |
|
|
Does not serialize; the Struts result of the result code is executed (a JSP or a FreeMarker template); it is also what runs when nothing selects a representation |
|
no extension, |
Request bodies of HTML forms; the |
|
no extension, |
Request bodies with files; request only |
$ curl -i http://localhost:18080/g11rest/books/1.json
HTTP/1.1 200
Last-Modified: Tue, 14 Nov 2023 22:13:20 GMT
ETag: "b1-2141736"
Content-Type: application/json;charset=UTF-8
{"id":1,"title":"Dune","author":"Frank Herbert"}
$ curl -i http://localhost:18080/g11rest/books/1.xml
HTTP/1.1 200
Last-Modified: Tue, 14 Nov 2023 22:13:20 GMT
ETag: "b1-2141736"
Content-Type: application/xml;charset=ISO-8859-1
<Book><id>1</id><title>Dune</title><author>Frank Herbert</author></Book>
$ curl -i -H 'Accept: application/json' http://localhost:18080/g11rest/books/2
HTTP/1.1 200
ETag: "b2-1939023025"
Content-Type: application/json;charset=UTF-8
{"id":2,"title":"Neuromancer","author":"William Gibson"}
$ curl -i http://localhost:18080/g11rest/books.json
HTTP/1.1 200
Cache-Control: no-cache
Content-Type: application/json;charset=UTF-8
[{"id":1,"title":"Dune","author":"Frank Herbert"},{"id":2,"title":"Neuromancer","author":"William Gibson"}]
Behaviour that the official page does not state and that was observed: the Accept header is split on commas and the
first media type with a handler wins, quality values are ignored (Accept: application/json;q=0.1, text/html;q=0.9
returned JSON); an extension in the URL always beats Accept; and the XML response carries
charset=ISO-8859-1 in its Content-Type although the manager writes UTF-8 bytes (the HTML and JSON answers declare UTF-8 and ISO-8859-1 respectively as in the transcripts), which a strict XML client may notice. The official page also
says the content types "are supported as incoming data types as well"; a POST of Content-Type: application/xml to a
.json URL created a book from <Book><title>XML Book</title><author>Me</author></Book>, and a POST of JSON to a
.xml URL created one too and answered an empty body (see restrictToGET below), so the request and response
representations are independent.
Controlling the Response: HttpHeaders and DefaultHttpHeaders
A controller method may return a String result code, a Result, or an HttpHeaders. The interface has four methods
(apply, getStatus, setStatus, getResultCode); DefaultHttpHeaders is the fluent implementation every example uses.
DefaultHttpHeaders member |
Effect on the response |
|---|---|
|
The result code that selects the view or, for JSON and XML, is passed to the handler |
|
The HTTP status; default 200 |
|
Sets the |
|
Sets |
|
Builds the |
|
Sets |
|
Sets |
The full CRUD session of the Bookshelf, abridged: the Date, Vary, cross-origin and ETag headers of some answers are
left out.
$ curl -i -X POST -H 'Content-Type: application/json' \
-d '{"title":"Hyperion","author":"Dan Simmons"}' http://localhost:18080/g11rest/books.json
HTTP/1.1 201
Location: http://localhost:18080/g11rest/books/3.json
Content-Type: application/json;charset=UTF-8
Content-Length: 4
null
$ curl -i http://localhost:18080/g11rest/books/3.json
HTTP/1.1 200
ETag: "b3--49670596"
Content-Type: application/json;charset=UTF-8
{"id":3,"title":"Hyperion","author":"Dan Simmons"}
$ curl -i -X PUT -H 'Content-Type: application/json' \
-d '{"title":"Dune Messiah","author":"Frank Herbert"}' http://localhost:18080/g11rest/books/1.json
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 4
null
$ curl -i -X DELETE http://localhost:18080/g11rest/books/3.json
HTTP/1.1 204
Content-Type: application/json;charset=UTF-8
$ curl -i http://localhost:18080/g11rest/books/3.json
HTTP/1.1 404
Content-Type: application/json;charset=UTF-8
{"id":3,"title":null,"author":null}
Three observations. The null body of the POST and PUT answers is the JSON handler serializing nothing: by default
(struts.rest.content.restrictToGET=true) the plugin returns content only for GET, and for a successful
POST, PUT or DELETE the client gets the status and headers alone (with false the same POST returned
{"id":3,"title":"Hyperion","author":"Dan Simmons"} with status 201). The Location header reuses the extension of the
request (books/3.json); a client that asked for JSON follows it to JSON. And the 404 body above is the model, not an
error document: a show() that finds nothing should say so explicitly, for instance by returning a different model.
Conditional GET
ETag and Last-Modified are applied by DefaultHttpHeaders.apply, which also answers 304 Not Modified when the status
is 200, caching is not disabled and the request carries a matching If-None-Match or an If-Modified-Since:
$ curl -i -H 'If-None-Match: "b2-1939023025"' http://localhost:18080/g11rest/books/2.json
HTTP/1.1 304
Last-Modified: Tue, 14 Nov 2023 22:13:20 GMT
ETag: "b2-1939023025"
The ETag path is reliable. The If-Modified-Since path is not in 7.4.0: with Last-Modified set to 2023-11-14 22:13:20
GMT and no If-None-Match, If-Modified-Since: Wed, 01 Jan 2020 00:00:00 GMT (a client copy older than the resource)
answered 304 and If-Modified-Since: Fri, 01 Jan 2027 00:00:00 GMT answered 200, the reverse of RFC 9110; only an equal
date behaves correctly. Use ETag for validators, or do the date comparison in the controller and return
withStatus(304).
Source: RFC 9110 — If-Modified-Since
OPTIONS and the Expect Header
RestActionSupport.options() sets an Allow header built from the methods that the controller class declares (it checks
for index, show, edit, editNew, create, update and destroy) and answers 200. The mapper chooses options
for any OPTIONS request, but the name is not in the global-allowed-methods of rest-default, so without a declaration the
request ends in a 404:
$ curl -i -X OPTIONS http://localhost:18080/g11rest/books
HTTP/1.1 404
Message: Method options for action books is not allowed!
$ curl -i -X OPTIONS http://localhost:18080/g11rest/books # after @AllowedMethods("options")
HTTP/1.1 200
Allow: OPTIONS, GET, PUT, DELETE, POST
Cache-Control: no-cache
Content-Length: 0
The Allow value is the same for /books and /books/1, because it is computed from the class, not from the URL, and
it lists a verb for every method the class declares even if that verb makes no sense for the URL. createContinue() and
updateContinue() of RestActionSupport answer 100 Continue; the mapper selects them for a POST or PUT that sends
Expect: 100-continue. They are not in the global-allowed-methods either: a POST with that header answered the
interim 100 and then 404 Method createContinue for action books is not allowed! until createContinue was allowed, so
an API whose clients send the header needs @AllowedMethods({"options", "createContinue", "updateContinue"}).
Request Bodies and What They May Bind
ContentTypeInterceptor runs after params in restDefaultStack. When the request has a body, it opens a reader
(with the character encoding of the request, or the JVM default charset when it declares none), selects the handler, and asks it to populate the action, or, for a
ModelDriven action, the model. That interceptor is the entry point of the Struts 7 parameter security for REST: with
struts.parameters.requireAnnotations=true (the 7.4.0 default) a handler that implements
AuthorizationAwareContentTypeHandler (JacksonJsonHandler and JacksonXmlHandler do) reads the body inside an
authorization context and every property is checked against @StrutsParameter, with the same rules as for
form parameters; any other handler gets a two-phase treatment where the body is read into a fresh instance of the
target class and only the authorized properties are copied. The official page documents three consequences:
-
a
ModelDrivenmodel is authorized as a whole: every property ofBookcan be populated from the body without the annotation, so the model should be a request contract, not a persistence entity (the BookshelfBookis a plain bean; aBookthat also had anadminNotesproperty would accept it from JSON); -
since 7.2.0 the built-in handlers enforce
@StrutsParameterper property while deserializing, and custom handlers should implementAuthorizationAwareContentTypeHandlerto get the same checks; -
since 7.3.0 the enforcement also covers constructor-bound properties (Java records,
@JsonCreatorconstructors,@ConstructorProperties).
In the Bookshelf, a POST body that names an id is harmless because create() assigns the id itself: the body
{"title":"Y","author":"Z","id":99} created book 4, not 99. A property that the model does not have is not harmless but
loud: Jackson’s default of failing on unknown properties turned {"title":"A","author":"B","isbn":"1"} into
HTTP 500 (UnrecognizedPropertyException ... Book["isbn"]), and a truncated body ({"title":) into HTTP 500
(JsonEOFException), not a 400.
struts.rest.anySetter.requireAnnotations (7.4.0, default false) decides whether keys bound through a Jackson
any-setter need @StrutsParameter(allowDynamicKeys = true); set it to true for any controller with a
@JsonAnySetter method.
Validation Errors as a Response
The stack’s validation interceptor calls validate() and the validators of the action; restWorkflow then checks
hasErrors(). If there are errors it does not return the input result: it renders the field and action errors through
the handler of the request, with the status of struts.rest.validationFailureStatusCode (default 400) and no-cache
headers. When the failed method was create it renders the result code editNew, and for update the result code
edit, which is what an HTML form needs (the form is redisplayed); for JSON and XML the code is not used.
$ curl -i -X POST -H 'Content-Type: application/json' \
-d '{"title":"","author":"Dan Simmons"}' http://localhost:18080/g11rest/books.json
HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
Content-Length: 65
{"actionErrors":[],"fieldErrors":{"title":["title is required"]}}
The validation and workflow interceptors of restDefaultStack exclude the methods
input,back,cancel,browse,index,show,edit,editNew: they are the read operations and the form views. A consequence the
official page does not warn about: a conversion error in the id of a read request is not reported. GET /books/abc.json
reached show() with id == null (the conversion failed, and the exclusion kept restWorkflow from turning it into a
400) and the controller’s NullPointerException surfaced as an HTML 500. A controller that takes an id should check it,
or the package should define its own stack without the exclusion.
The "automatic error handling" of the feature list is narrower than it sounds. An exception thrown by a controller method on
a .json URL produced Tomcat’s HTML 500 page, also for Accept: application/json. When the controller mapped the
exception to a result with the Convention annotation @ExceptionMapping, the plugin serialized the Throwable itself
with status 200: the JSON had cause, message, suppressed and a stackTrace array with class, method and line of
every frame (about 34 KB for a three-line failure). That is an information leak and a wrong status; map exceptions to
a controller-owned result that returns an HttpHeaders with the right status and a small error model instead, or let
a servlet error page handle the error.
The HTML Representation
The extension xhtml (and the default when nothing is asked) renders a Struts result, so the same controller serves
browsers. With the Convention plugin the view of the result code index of the action books is
/WEB-INF/content/books-index.jsp, of show is books-show.jsp, of editNew books-editNew.jsp:
<%@ taglib prefix="s" uri="/struts-tags" %>
<h1>Books</h1>
<ul>
<s:iterator value="model">
<li><a href="<s:url value='/books/%{id}'/>"><s:property value="title"/></a> by <s:property value="author"/></li>
</s:iterator>
</ul>
<p><a href="<s:url value='/books/new'/>">Add a book</a></p>
$ curl -i -b cookies.txt http://localhost:18080/g11rest/books
HTTP/1.1 200
Cache-Control: no-cache
Content-Type: text/html;charset=ISO-8859-1
<h1>Books</h1>
<ul>
<li><a href="/g11rest/books/1">Dune</a> by Frank Herbert</li>
<li><a href="/g11rest/books/2">Neuromancer</a> by William Gibson</li>
</ul>
<p><a href="/g11rest/books/new">Add a book</a></p>
(The cookie jar only keeps the session id out of the links; without it Tomcat appends ;jsessionid=... to every
s:url.) Form handling follows from the mapping table. The form of books-editNew.jsp posts to /books, creating a
book; when the title is empty, restWorkflow renders the result editNew again with status 400 and the field error:
$ curl -i -b cookies.txt -d 'title=Hyperion&author=Dan+Simmons' http://localhost:18080/g11rest/books
HTTP/1.1 201
Location: http://localhost:18080/g11rest/books/3
Content-Length: 8
created
$ curl -i -b cookies.txt -d 'title=&author=Dan+Simmons' http://localhost:18080/g11rest/books
HTTP/1.1 400
Cache-Control: no-cache
Content-Type: text/html;charset=ISO-8859-1
<h1>New book</h1>
<form id="books" name="books" action="/g11rest/books" method="post">
<input type="text" name="title" value="" id="books_title"/>
<input type="text" name="author" value="Dan Simmons" id="books_author"/>
...
</form>
<ul class="errorMessage"><li><span>title is required</span></li></ul>
An HTML POST that creates answers 201 with a Location header and the body of the result (here a one-word view), not a
redirect, because setLocationId forces 201; a browser will show the body. For PUT and DELETE from a form, the parameter
_method is used, and the redirect results declared on the controller apply to the HTML representation only:
$ curl -i -X POST -d '_method=DELETE' http://localhost:18080/g11rest/books/1
HTTP/1.1 303
Location: /g11rest/books.xhtml
$ curl -i -X POST -d '_method=PUT&title=Dune&author=Frank+Herbert&id=1' http://localhost:18080/g11rest/books/1
HTTP/1.1 303
Location: /g11rest/books.xhtml
The redirect is a 303 See Other (from the redefined redirectAction of rest-default) to a URL that carries the default
extension. Without the @Result annotations the same requests executed the operation and then answered 404,
No result defined for action ...BooksController and result destroy: the book was deleted and the message said it had
not been found, because the plugin does not synthesize views for HTML. The PUT with a JSON content type on a .json
URL, in contrast, did not redirect: the JSON handler is used for the response and the redirect result is only used for
xhtml.
A Custom Content Type Handler
A handler implements ContentTypeHandler (toObject, fromObject, getContentType, getExtension) and is declared as
a bean with the name that identifies it. The Bookshelf offers a read-only CSV representation of the list and of a book:
public class CsvHandler implements ContentTypeHandler {
protected String separator = ",";
@Override
public void toObject(ActionInvocation invocation, Reader in, Object target) throws IOException {
throw new UnsupportedOperationException("CSV is write-only");
}
@Override
public String fromObject(ActionInvocation invocation, Object obj, String resultCode, Writer stream)
throws IOException {
List<?> rows = obj instanceof Collection<?> c ? List.copyOf(c) : List.of(obj);
stream.write("id" + separator + "title" + separator + "author\n");
for (Object row : rows) {
Book b = (Book) row;
stream.write(b.getId() + separator + b.getTitle() + separator + b.getAuthor() + "\n");
}
return resultCode;
}
@Override
public String getContentType() { return "text/csv"; }
@Override
public String getExtension() { return "csv"; }
}
<constant name="struts.action.extension" value="xhtml,,xml,json,csv"/>
<bean name="csv" type="org.apache.struts2.rest.handler.ContentTypeHandler"
class="org.example.bookshelf.rest.CsvHandler"/>
$ curl -i http://localhost:18080/g11rest/books.csv
HTTP/1.1 200
Content-Type: text/csv;charset=ISO-8859-1
id,title,author
1,Dune,Frank Herbert
2,Neuromancer,William Gibson
$ curl -i -H 'Accept: text/csv' http://localhost:18080/g11rest/books/1
HTTP/1.1 200
Content-Type: text/csv;charset=ISO-8859-1
id,title,author
1,Dune,Frank Herbert
Two steps are easy to forget: the extension must be added to struts.action.extension (otherwise the mapper does not strip
it and books.csv is looked up as an action name), and the Accept: text/csv request works because the handler declares
that content type. To replace a built-in handler for an extension instead of adding one, declare a second bean for the same
extension and name it in struts.rest.handlerOverride.EXTENSION; with a csvSemi bean (a subclass whose separator is
;), <constant name="struts.rest.handlerOverride.csv" value="csvSemi"/> made books.csv answer
id;title;author. A handler that reads bodies and should honour @StrutsParameter implements
AuthorizationAwareContentTypeHandler, a marker sub-interface. The official page also describes customising XStream
(XStreamProvider, XStreamAllowedClasses, XStreamAllowedClassNames, XStreamPermissionProvider, since 6.1.0); those
interfaces are implemented by an action and apply to XStreamHandler, which is not one of the handlers registered by
the plugin in 7.4.0: to use it, register it yourself as a ContentTypeHandler bean.
Settings
The constants of the plugin, with the defaults of struts-plugin.xml and RestConstants at 7.4.0:
| Constant | Default | Meaning |
|---|---|---|
|
|
The mapper; set by the plugin itself |
|
|
Name of the request parameter that receives the id |
|
|
The extensions the mapper strips; add |
|
|
Representation when neither extension nor a usable |
|
none |
Bean name of a handler that replaces the built-in one of that extension |
|
|
Status of the response of |
|
|
Return a body only for GET (and for errors) |
|
|
Maximum number of characters read from a request body (7.4.0) |
|
|
Require |
|
unset |
Only namespaces starting with this value get the REST invocation |
|
|
Log one INFO line per action (name, method, extension, status, timings) |
|
|
Result looked up when the response has errors and no other result matches |
|
see the mapping table |
Rename the controller methods |
Source: Struts Plugins — REST, Settings
The official table omits the method-name constants for options and the two Continue methods and lists the namespace
constant without its effect; the table above follows the sources.
Request Size Limit and Resource Isolation (7.4.0)
Two changes of 7.4.0 and 6.12.0 matter for any REST API. The first closes a denial-of-service hole: before, the request
body was read into memory with no bound. ContentTypeInterceptor now stops reading once the body exceeds
struts.rest.content.maxLength characters (2 MB by default) and fails the request with RequestBodyTooLargeException
before the action runs. The limit applies where the handler reads the body, so form-urlencoded and multipart requests are
unaffected. With the constant set to 100 for the test:
$ curl -i -X POST -H 'Content-Type: application/json' -d @big.json http://localhost:18080/g11rest/books.json # about 220 characters
HTTP/1.1 500
...
org.apache.struts2.rest.ContentTypeInterceptor.requestBodyTooLarge(ContentTypeInterceptor.java:140)
$ curl -i -d @big.txt http://localhost:18080/g11rest/books # title=xxxx... (300 characters)&author=A
HTTP/1.1 201
Location: http://localhost:18080/g11rest/books/4
<constant name="struts.rest.content.maxLength" value="10485760"/>
The answer is a 500, not a 413, so a gateway in front of the application should enforce its own size limits too. An
API that accepts JSON or XML payloads above 2 MB must raise the constant when upgrading, or those requests start failing.
The bulletin S2-077 rates the issue Important and affects 2.1.8 to 2.3.37, 2.5.0 to 2.5.33, 6.0.0 to 6.11.0 and 7.0.0 to
7.3.0. The fixed releases are 7.4.0 and 6.12.0.
The second change: restDefaultStack now contains the coep, coop and fetchMetadata interceptors, configured as in
the core defaultStack (WW-5718). Before, a package extending rest-default sent no COOP and COEP headers and skipped the
Fetch Metadata check. The check rejects browser requests with Sec-Fetch-Site: cross-site that use a method other than
GET and are not navigations; the rejection is HTTP 403 without a body:
$ curl -i -H 'Sec-Fetch-Site: cross-site' -X POST -H 'Content-Type: application/json' \
-d '{"title":"X"}' http://localhost:18080/g11rest/books.json
HTTP/1.1 403
Content-Type: application/json;charset=UTF-8
Content-Length: 4
null
$ curl -i -H 'Sec-Fetch-Site: same-origin' -X POST -H 'Content-Type: application/json' \
-d '{"title":"Y","author":"Z"}' http://localhost:18080/g11rest/books.json
HTTP/1.1 201
A single-page application served from another site that calls the API with fetch() needs the check disabled for the
stack that serves it, which is a deliberate decision (CORS is configured elsewhere):
<interceptor-ref name="restDefaultStack">
<param name="fetchMetadata.disabled">true</param>
</interceptor-ref>
Requests from non-browser clients carry no Sec-Fetch-Site header and are not affected, which is why the curl
examples above work unmodified.
The Legacy RESTful Mappers of the Core
Before the plugin, the core had two mappers that predate it: RestfulActionMapper (URLs of the form
/action/param/value/param/value) and Restful2ActionMapper (an earlier design with
method guessing and the parameter __http_method to simulate PUT and DELETE). Both are deprecated since 7.4.0 and
6.12.0 and are to be removed (WW-5707); the official page of the core guide says to use the REST plugin instead. The
reason to know them is the security bulletin S2-075 (see Security Bulletins; CVE-2026-104711): if an application is configured to use the
legacy RestfulActionMapper, a crafted request can inject an OGNL expression that may lead to remote code execution;
Struts 7 is affected up to 7.3.0 only when the OGNL allowlist is disabled, which it is not by default, and applications
using the default mapper, the restful2 mapper or the REST plugin are not affected. The fix is in 7.4.0 and 6.12.0; the
workaround is to stop using RestfulActionMapper.
An application that still has the restful2 mapper can keep it per namespace with the same prefix technique as above.
The verified configuration (a classic action with wildcard and the id taken from the name):
<constant name="struts.mapper.class"
value="org.apache.struts2.dispatcher.mapper.PrefixBasedActionMapper"/>
<constant name="struts.mapper.prefixMapping" value="/legacy:restful2,:struts"/>
<constant name="struts.enable.SlashesInActionNames" value="true"/>
<constant name="struts.mapper.alwaysSelectFullNamespace" value="false"/>
<package name="legacy" namespace="/legacy" extends="struts-default">
<global-allowed-methods>index,view,editNew,edit,create,update,remove</global-allowed-methods>
<action name="shelf/*" class="org.example.legacy.ShelfAction">
<param name="id">{1}</param>
<result type="httpheader">
<param name="status">200</param>
<param name="headers.Called">${methodCalled}</param>
<param name="headers.Ident">${id}</param>
</result>
</action>
</package>
| Request | Method called (Restful2ActionMapper, verified) | Documented |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
simulated DELETE |
|
|
|
A migration from Restful2ActionMapper to the plugin renames view to show and remove to destroy, replaces
__http_method by _method, and moves from wildcard actions with {1} parameters to controllers whose id is injected by
the mapper (@StrutsParameter on the setter).
The rest-showcase Application
The Struts repository ships apps/rest-showcase, the application behind the official page: a Maven project that depends
on the REST, Convention and config browser plugins plus jackson-dataformat-xml, with the package org.demo.rest.example
holding OrdersController, Order, OrdersService and IndexController, and five JSPs under WEB-INF/content
(orders-index.jsp, orders-show.jsp, orders-edit.jsp, orders-editNew.jsp, orders-deleteConfirm.jsp). Its
struts.xml is the REST-only configuration of this page (suffix Controller, mapAllMatches, a locator) with a package
rest-showcase that extends rest-default and lists deleteConfirm among the allowed methods. The controller follows
the same pattern as the Bookshelf one: ModelDriven<Object> returning a list for index and the order otherwise, an
@StrutsParameter on setId, HttpHeaders from show, index and create, and @Result(name="success",
type="redirectAction") for the HTML flow. It also shows a custom method name reached through the URL trick of the
mapper: GET /orders/1/deleteConfirm calls deleteConfirm() because the last segment of action/id/method becomes the
method. It is the quickest way to see the Convention naming of the views and the restDefaultStack in a running
application; the struts-examples repository keeps the larger collection of Struts sample projects.
Source: Apache Struts Examples
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
The application does not start after adding the plugin; every URL answers 404; the log shows |
The plugin registers its XML handler unconditionally, but |
Add |
|
The setter of the controller is not annotated; Struts 7 rejects the mapper’s |
Annotate |
|
|
Check the id in the method, or use a stack without the exclusion for the read methods |
|
|
Set it to |
|
|
Add |
Old |
The plugin removed |
Re-add |
|
The extension is not in |
Add the extension to the constant |
|
Quality values are ignored; the first media type with a handler wins, and an extension in the URL beats the header |
Put the wanted type first or use the extension; do not rely on |
A malformed body or an unknown JSON property ends in HTTP 500 with a Jackson exception |
The handler lets Jackson’s |
Catch it with a servlet error page or a servlet filter that returns a small error model; do not map it to a result that serializes the exception |
A controller exception mapped to a result returns status 200 with a JSON stack trace |
|
Do not map exceptions to results of a REST controller; catch the failure in the method and return an |
An HTML form |
The plugin does not synthesize HTML views for the result codes |
Declare |
A client copy older than the resource gets 304 for |
The date comparison in |
Rely on |
A large JSON or XML upload fails with 500 |
|
Raise the constant for the API that needs it; also limit the size in the web server or gateway |
A browser SPA on another site gets 403 with an empty body for POST, PUT and DELETE |
|
Disable it for the stack that serves that API ( |
|
Dynamic method invocation is off; the mapper ignores |
Use the HTTP verbs; keep DMI off |
A client that sends |
|
Allow them with |
|
The mapper honours |
Put an authentication and CSRF layer in front of the API, and reject |
What Changed Recently
-
7.4.0 and 6.12.0 (2026-10-02):
struts.rest.content.maxLengthand theRequestBodyTooLargeException(WW-5723, bulletin S2-077);coep,coopandfetchMetadatainrestDefaultStack(WW-5718); the propertystruts.rest.anySetter.requireAnnotations; the deprecation ofRestfulActionMapperandRestful2ActionMapper(WW-5707) and the fix of the OGNL injection in the legacy mapper (S2-075).RestActionMapperalso selects the root namespace when it is declared, so an id-bearing URL lands in the same namespace as the one without it (WW-5688 in the source comment). -
7.3.0:
@StrutsParameterenforcement covers constructor-bound properties (records,@JsonCreator,@ConstructorProperties) in the built-in handlers. -
7.2.0: the built-in handlers enforce
@StrutsParameterper property when deserializing, andAuthorizationAwareContentTypeHandleris the interface for custom handlers that need the same checks. -
7.0: the Jakarta namespace and the repackaging into
org.apache.struts2(org.apache.struts2.ActionSupport,org.apache.struts2.ModelDriven,org.apache.struts2.ActionContext);@StrutsParameterrequired by default (struts.parameters.requireAnnotations). -
6.1.0: the XStream customization interfaces of the REST plugin.
-
The plugin itself exists since Struts 2.1.1 (the plugin page: "only available with Struts 2.1.1 or later"); in 7.4.0 the registered XML and JSON handlers are the Jackson ones.
Where the Official Page and the Older Books Are Outdated
The older Struts 2 books were written before the REST plugin matured and before the Jakarta, Java 17 and Struts 7
changes. Following the concept table of this documentation, the Ajax chapters of the era teach hand-built XStream JSON
results and the Dojo plugin, which are replaced by the JSON plugin, the REST plugin and plain fetch calls; examples
import com.opensymphony.xwork2.* (now org.apache.struts2.*), call actions as action!method (dynamic method
invocation is off by default and the REST mapper ignores it) and map *.action URLs, which the plugin removes from the
extension list. The official page and the plugin of 7.4.0 disagree with the behaviour verified on Tomcat 11:
-
"Setting Up" lists two plugins; the application needs
jackson-dataformat-xmlas well. -
"Automatic error handling" and "Automatic conditional GET support" are only partly true: exceptions are not converted to error documents (a mapped exception is serialized with its stack trace and status 200), and
If-Modified-Sinceis inverted. -
The
Acceptheader is described as selecting the content type; quality values are ignored. -
The settings table omits
struts.mapper.optionsMethodName,struts.mapper.postContinueMethodNameandstruts.mapper.putContinueMethodName, and describesstruts.rest.namespaceas a namespace for REST services when it only restricts the REST invocation. -
The mapping logic names
_method; the Restful action mapper page names__http_method; both are right for their own mapper. The Restful mapper page’sThrillers!editrow needs dynamic method invocation, which is off. -
The REST page sample
package org.apache.struts2.rest.example; public class OrdersController implements ModelDriven<Order>has no@StrutsParameteron its id setter; on Struts 7 it is needed. -
The
rest-showcasestruts.xmlof 7.4.0 still says to uncomment ajacksonXmlbean to "use Jackson XML bindings instead of the XStream library"; the default XML handler is already the Jackson one, and the sample package isorg.demo.rest.example, notorg.apache.struts2.rest.example.
When to Choose the Plugin
The REST plugin fits a Struts application that needs a handful of resource endpoints beside its pages, or a team that
already owns Struts controllers and wants JSON and XML for the same logic with the Rails-style URLs. For a JSON endpoint
attached to a classic action (a json result, root, includeProperties), the JSON plugin of this section is simpler; it has its own struts.json.maxLength limit. A new API-first service is better built with
Spring MVC or another API framework, because the plugin has no content negotiation beyond the extension, no error
document, no OpenAPI integration and no automatic CORS; see
REST APIs with Spring MVC and WebFlux for that style. The security pages of this section
(Security Hardening and Security Headers and Fetch Metadata) cover the surrounding hardening: size limits at the gateway, CORS, authentication, and the OGNL
allowlist. The classic actions and interceptors that the controllers build on are in
Actions and Interceptors, the
action configuration in
Configuration: struts.xml and Constants and the
mapper in
Packages and Namespaces; model-driven controllers are
covered in Model Driven, Preparable and CRUD, and the
form side of the validation errors in Validation.
References
-
Apache Struts — REST Plugin, REST and non-RESTful URLs Together Configuration
-
Apache Struts — REST Plugin, Resource isolation in restDefaultStack
-
Apache Struts — RestfulActionMapper and Restful2ActionMapper
-
Apache Struts 7.4.0 — plugins/rest (sources, struts-plugin.xml)
-
CVE-2026-104711 — OGNL injection in the legacy RESTful action mapper
-
CVE-2026-104713 — Unbounded request body read in the REST plugin
-
Apache JIRA — WW-5707 (deprecate the legacy RESTful mappers)
-
Apache JIRA — WW-5718 (security interceptors in restDefaultStack)