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

RestActionMapper

The ActionMapper registered under the name rest: reads the HTTP method and the shape of the URL and decides the action, the method and the id

RestActionProxyFactory and RestActionInvocation

The proxy factory (name rest, also the default struts.actionProxyFactory) and the invocation that runs the stack without executing the result, then asks a content type handler to write the representation

ContentTypeInterceptor (alias rest)

Selects a handler from the request Content-Type or the URL extension and deserializes the body into the controller (or its model)

RestWorkflowInterceptor (alias restWorkflow)

Replaces workflow: when the controller has field or action errors it renders them with the configured failure status instead of the input result

HttpHeaders, DefaultHttpHeaders

The return type of controller methods that control status, ETag, Last-Modified, Location and caching

RestActionSupport

A base class (extends ActionSupport) with index(), options(), createContinue() and updateContinue() already implemented

handler.ContentTypeHandler and implementations

One handler per representation: JacksonJsonHandler, JacksonXmlHandler, HtmlHandler, FormUrlEncodedHandler, MultipartFormDataHandler; XStreamHandler and JuneauXmlHandler are in the jar but not registered

RestConstants

The names of the struts.rest.* and struts.mapper.* constants the plugin reads

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

/books

index

POST

/books

create

GET

/books/2

show

id=2

PUT

/books/2

update

id=2

DELETE

/books/2

destroy

id=2

GET

/books/2/edit or /books/2;edit

edit

id=2

GET

/books/new

editNew

none (new is not an id)

OPTIONS

any

options

POST with Expect: 100-continue

/books

createContinue

PUT with Expect: 100-continue

/books/2

updateContinue

id=2

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.

flowchart TD U["Request: PUT /books/2.json"] --> E["drop extension: json"] E --> N["split namespace and name: books/2"] N --> M{"method named by ! or method:?"} M -- "no" --> G["guess from HTTP method and id"] G --> P["PUT with id: method update, id=2 goes to mapping params"] M -- "yes (DMI on)" --> P P --> S["restDefaultStack: params, rest, validation, restWorkflow"] S --> I["BooksController.update()"] I --> H{"returns HttpHeaders?"} H -- "yes" --> R["apply status, ETag, Location, then render with the json handler"] H -- "String" --> R2["result code, json handler unless a result update.json exists"]

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 a getModel() that returns the list for index and 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).

  • @StrutsParameter on setId is required. The mapper hands the id to the action as a mapping parameter, and since Struts 7 an injection point without the annotation is rejected: without it show() ran with id == null and BookStore.find(null) failed with a NullPointerException. The same id was also set on the model (the body of a 404 for /books/3 contained "id":3), because the stack’s params interceptor sees the model on the value stack. The parameter injection page of this section explains the annotation.

  • The controller does not extend ActionSupport directly and does not need to: the stack of rest-default supplies validation, type conversion errors and i18n, as the official page says; RestActionSupport extends ActionSupport anyway, which gives addFieldError, getText and the ValidationAware contract.

  • @AllowedMethods("options") is needed for OPTIONS (see the pitfalls). The global-allowed-methods of rest-default are execute,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

JacksonJsonHandler (json)

json, application/json

Jackson ObjectMapper; @StrutsParameter is enforced per property while reading

JacksonXmlHandler (xml)

xml, application/xml

Needs jackson-dataformat-xml; the root element is the class name (<Book>, <ArrayList>)

HtmlHandler (html)

xhtml, application/xhtml+xml

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

FormUrlEncodedHandler (x-www-form-urlencoded)

no extension, application/x-www-form-urlencoded

Request bodies of HTML forms; the params interceptor binds them, the handler cannot write a response

MultipartFormDataHandler (multipart/form-data)

no extension, multipart/form-data

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

new DefaultHttpHeaders("show") or renderResult(String)

The result code that selects the view or, for JSON and XML, is passed to the handler

withStatus(int)

The HTTP status; default 200

withETag(Object) and withNoETag()

Sets the ETag header; when none is given and the target is not null, the header is the target’s hashCode() unless withNoETag() was called

lastModified(Date)

Sets Last-Modified

setLocationId(Object)

Builds the Location header from the request URL by appending /id before the extension and forces status 201

setLocation(String)

Sets Location as given and forces status 201

disableCaching()

Sets Cache-Control: no-cache (and skips the 304 logic)

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).

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 ModelDriven model is authorized as a whole: every property of Book can be populated from the body without the annotation, so the model should be a request contract, not a persistence entity (the Bookshelf Book is a plain bean; a Book that also had an adminNotes property would accept it from JSON);

  • since 7.2.0 the built-in handlers enforce @StrutsParameter per property while deserializing, and custom handlers should implement AuthorizationAwareContentTypeHandler to get the same checks;

  • since 7.3.0 the enforcement also covers constructor-bound properties (Java records, @JsonCreator constructors, @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

struts.mapper.class

rest

The mapper; set by the plugin itself

struts.mapper.idParameterName

id

Name of the request parameter that receives the id

struts.action.extension

xhtml,,xml,json

The extensions the mapper strips; add action for classic URLs, add every custom extension

struts.rest.defaultExtension

xhtml

Representation when neither extension nor a usable Accept is present

struts.rest.handlerOverride.EXTENSION

none

Bean name of a handler that replaces the built-in one of that extension

struts.rest.validationFailureStatusCode

400

Status of the response of restWorkflow

struts.rest.content.restrictToGET

true

Return a body only for GET (and for errors)

struts.rest.content.maxLength

2097152

Maximum number of characters read from a request body (7.4.0)

struts.rest.anySetter.requireAnnotations

false

Require @StrutsParameter(allowDynamicKeys = true) for any-setter keys (7.4.0)

struts.rest.namespace

unset

Only namespaces starting with this value get the REST invocation

struts.rest.logger

true

Log one INFO line per action (name, method, extension, status, timings)

struts.rest.defaultErrorResultName

default-error

Result looked up when the response has errors and no other result matches

struts.mapper.indexMethodName and the other ...MethodName constants

see the mapping table

Rename the controller methods

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

GET /legacy/shelf/

index

index

GET /legacy/shelf/Thrillers

view, id Thrillers

view, id Thrillers

GET /legacy/shelf/new

editNew

editNew

POST /legacy/shelf/

create

create

PUT /legacy/shelf/Thrillers

update, id Thrillers

update

DELETE /legacy/shelf/Thrillers

remove, id Thrillers

remove

POST /legacy/shelf/Thrillers with __http_method=DELETE

remove

simulated DELETE

GET /legacy/shelf/Thrillers!edit

view with id Thrillers!edit

edit (needs dynamic method invocation)

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.

Pitfalls

Symptom Cause Fix

The application does not start after adding the plugin; every URL answers 404; the log shows Unable to load bean ... JacksonXmlHandler and ClassNotFoundException: com.fasterxml.jackson.dataformat.xml.JacksonXmlModule

The plugin registers its XML handler unconditionally, but jackson-dataformat-xml is an optional dependency of the plugin pom

Add com.fasterxml.jackson.dataformat:jackson-dataformat-xml with the Jackson version of the plugin (2.22.2 for 7.4.0)

show() runs with id == null; the model of a 404 shows the id but the controller does not

The setter of the controller is not annotated; Struts 7 rejects the mapper’s id for an injection point without @StrutsParameter

Annotate setId with @StrutsParameter

GET /books/abc.json ends in HTTP 500 with a NullPointerException instead of 400

show, index, edit and editNew are excluded from validation and restWorkflow, so the id conversion error is not reported

Check the id in the method, or use a stack without the exclusion for the read methods

POST, PUT and DELETE answer null or an empty body

struts.rest.content.restrictToGET is true: content is returned only for GET

Set it to false to return the model, or return the representation of the resource from a follow-up GET using the Location header

OPTIONS answers 404 Method options for action books is not allowed!

options is not in the global-allowed-methods of rest-default

Add @AllowedMethods("options") (or <allowed-methods>) to the controller; extend RestActionSupport for the implementation

Old *.action URLs return 404 after the plugin is added; a classic action fails with NoSuchMethodException ...index()

The plugin removed action from struts.action.extension and made rest the only mapper, which guesses index for a plain GET

Re-add action to the extension list and use PrefixBasedActionMapper with struts.actionProxyFactory=prefix; struts.rest.namespace alone does not switch the mapper

books.csv or another custom extension returns 404 although the handler bean exists

The extension is not in struts.action.extension, so the mapper does not strip it

Add the extension to the constant

Accept: application/json;q=0.1, text/html;q=0.9 returns JSON

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 q

A malformed body or an unknown JSON property ends in HTTP 500 with a Jackson exception

The handler lets Jackson’s JsonEOFException and UnrecognizedPropertyException propagate; the validation failure status applies only to Struts validation errors

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

selectTarget serializes the Throwable pushed by the exception interceptor

Do not map exceptions to results of a REST controller; catch the failure in the method and return an HttpHeaders with the right status and a small error model, or use a servlet error page

An HTML form DELETE or PUT deletes or updates and then answers 404 No result defined ... and result destroy

The plugin does not synthesize HTML views for the result codes destroy and update

Declare @Result(name = "destroy", type = "redirectAction", ...) (it redirects with 303)

A client copy older than the resource gets 304 for If-Modified-Since

The date comparison in DefaultHttpHeaders is inverted in 7.4.0

Rely on ETag / If-None-Match, or compare dates in the controller

A large JSON or XML upload fails with 500 Request body exceeds maximum allowed length

struts.rest.content.maxLength (2,097,152 characters) was added in 7.4.0 and 6.12.0

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

fetchMetadata in restDefaultStack rejects cross-site non-navigation requests (7.4.0)

Disable it for the stack that serves that API (fetchMetadata.disabled), and configure CORS deliberately

/books!destroy does nothing

Dynamic method invocation is off; the mapper ignores !method

Use the HTTP verbs; keep DMI off

A client that sends Expect: 100-continue gets 404 Method createContinue for action books is not allowed!

createContinue and updateContinue are not in global-allowed-methods

Allow them with @AllowedMethods, or make the client stop sending the header

GET /books/2.json?_method=DELETE deletes the book

The mapper honours _method on any HTTP method

Put an authentication and CSRF layer in front of the API, and reject _method on GET at the gateway

What Changed Recently

  • 7.4.0 and 6.12.0 (2026-10-02): struts.rest.content.maxLength and the RequestBodyTooLargeException (WW-5723, bulletin S2-077); coep, coop and fetchMetadata in restDefaultStack (WW-5718); the property struts.rest.anySetter.requireAnnotations; the deprecation of RestfulActionMapper and Restful2ActionMapper (WW-5707) and the fix of the OGNL injection in the legacy mapper (S2-075). RestActionMapper also 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: @StrutsParameter enforcement covers constructor-bound properties (records, @JsonCreator, @ConstructorProperties) in the built-in handlers.

  • 7.2.0: the built-in handlers enforce @StrutsParameter per property when deserializing, and AuthorizationAwareContentTypeHandler is 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); @StrutsParameter required 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-xml as 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-Since is inverted.

  • The Accept header is described as selecting the content type; quality values are ignored.

  • The settings table omits struts.mapper.optionsMethodName, struts.mapper.postContinueMethodName and struts.mapper.putContinueMethodName, and describes struts.rest.namespace as 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’s Thrillers!edit row 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 @StrutsParameter on its id setter; on Struts 7 it is needed.

  • The rest-showcase struts.xml of 7.4.0 still says to uncomment a jacksonXml bean to "use Jackson XML bindings instead of the XStream library"; the default XML handler is already the Jackson one, and the sample package is org.demo.rest.example, not org.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