JSON Plugin & AJAX

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) and its struts2-json-plugin 7.4.0, running on Apache Tomcat 11.0.26 and Java 21, the combination every example was built, deployed and requested on; the facts were checked on 2026-10-08 against the JSON plugin page, the Core Developer Guide AJAX page, the Tag Developer Guide AJAX pages, the Javadoc, the plugin jar (struts-plugin.xml and javap on its classes) and its source jar. The plugin does two jobs. As a result type (json) it writes an action, or one part of it, as a JSON document; as an interceptor (json) it reads a JSON request body and fills the action from it. Together they turn a Struts action into the endpoint of a JavaScript client, which is what AJAX meant for Struts from the beginning: the request is an ordinary request, only the response (or the body) is not a page. The running scenario is the Bookshelf catalogue exposed as a JSON API under /api, and the client side uses fetch and htmx because the Dojo, jQuery and YUI tag plugins that older material teaches no longer exist for Struts 7. The neighbouring pages are the REST plugin, security hardening and testing.

The Bookshelf JSON API Used on This Page

All examples are classes and mappings of one WAR, g11json.war, deployed under the context path /g11json on Tomcat 11.0.26. It is the Struts Bookshelf of Getting Started (the struts2-bom 7.4.0 and struts2-core) with one more dependency and one more package:

<dependency>
    <groupId>org.apache.struts</groupId>
    <artifactId>struts2-json-plugin</artifactId>
</dependency>

The version comes from the BOM. Copying the jar into WEB-INF/lib is all the plugin needs: it has no other files to add. The domain classes are small. Book is a bean with id, title, author, subtitle, genre (an enum with a label), published (a Date) and an internalNote that must never reach the client; BookStore keeps three books in memory (Dune, Neuromancer and The Hobbit); BookRecord is a Java record used to show the 7.3 record support. The actions that matter are named where they appear.

Table 1. The Bookshelf JSON actions of the scratch WAR, all in package api (namespace /api)
Action name What it shows

all-default, hier, hier-default

A result with no root, and the effect of ignoreHierarchy

books, book

root, a search parameter and a statusCode for a missing book

books-excl, books-incl, books-nonull, books-enum-bean

excludeProperties, includeProperties, excludeNullProperties and enumAsBean

records, records-nonull, shelf

Records, Optional, @JSON on record components and a @JSONFieldBridge

books-prefix, books-jsonp, books-nocache-gzip, books-error, books-ct

wrapPrefix, callbackParameter, noCache, enableGZIP, errorCode, contentType

add, add-all, add-root, add-filtered, add-safe, add-small

The json interceptor, @StrutsParameter, root, input filtering, exception mapping and maxLength

validated, go

jsonValidation and jsonActionRedirect

smd

SMD and JSON-RPC

fragment

An HTML fragment from the stream result, for htmx

What the Plugin Registers

The plugin jar contains a struts-plugin.xml, which the framework loads automatically (see the page on struts.xml and constants). It defines one package, json-default, that extends struts-default, and a few beans and constants. Reading the file of the 7.4.0 jar gives the whole surface of the plugin:

Table 2. What struts-plugin.xml of struts2-json-plugin 7.4.0 declares
Kind Name Class or value

Package

json-default

Extends struts-default; the package your JSON actions must extend (or contain a result type of their own)

Result type

json

org.apache.struts2.json.JSONResult

Result type

jsonActionRedirect

org.apache.struts2.json.JSONActionRedirectResult

Interceptor

json

org.apache.struts2.json.JSONInterceptor, reads a JSON body into the action and serves SMD

Interceptor

jsonValidation

org.apache.struts2.json.JSONValidationInterceptor, sends validation errors as JSON

Interceptor stack

jsonValidationWorkflowStack

A sample stack: basicStack, validation (excluding input,back,cancel), jsonValidation, workflow

Beans

JSONWriter and JSONReader, name struts

StrutsJSONWriter and StrutsJSONReader, both scope="prototype"

Constants

struts.json.writer, struts.json.reader

struts (the built-in implementations)

Constants

struts.json.maxElements, maxDepth, maxLength, maxStringLength, maxKeyLength

10000, 64, 2097152, 262144, 512

The json interceptor is not part of defaultStack and no action uses it unless a mapping says so. That is deliberate: a JSON request body is only read for the actions that opt in, which keeps ordinary form actions unaffected. The result type, by contrast, is available to every package that extends json-default, and a package can list several parents (extends="struts-default,json-default"), which is how one package serves both pages and JSON (see Results and Result Types, which also lists the json result next to the other plugin results).

The Round Trip: Request, Interceptor, Action, Result

sequenceDiagram participant C as fetch or curl participant F as Struts filter participant J as json interceptor participant S as defaultStack participant A as AddBookAction participant R as json result C->>F: POST /api/add-all (Content-Type application/json) F->>J: invoke J->>J: read the body, check limits, filter and authorize keys J->>A: populate the action from the JSON object J->>S: invoke the rest of the stack S->>A: params, validation, workflow A-->>R: execute returns success R-->>C: 200 application/json (serialized action or root)

The interceptor acts only when the request Content-Type equals the configured jsonContentType (application/json by default) or jsonRpcContentType (application/json-rpc, for SMD). Any other content type, for example a form post or a GET, passes through untouched, which the scratch run confirmed: a form post to add-all filled note from the form fields and ignored the JSON machinery. The result then serializes whatever the mapping selects. The two halves are independent: an action can read JSON and answer with a page, or read a form and answer with JSON.

The json Result

The smallest JSON endpoint is an action and a json result without parameters. The mapping needs a package that extends json-default:

<package name="api" extends="json-default" namespace="/api">
    <action name="all-default" class="org.example.bookshelf.DefaultJsonAction">
        <result type="json"/>
    </action>
</package>

DefaultJsonAction extends ActionSupport and has two getters, getBooks() and getCount(). The response contains exactly those two properties:

$ curl -s http://localhost:18080/g11json/api/all-default
{"books":[{"author":"Frank Herbert","genre":"SCIENCE_FICTION","id":1,"publishedOn":"1965-08-01","subtitle":null,"title":"Dune"}, ... ],"count":3}

Three things are visible. Property names are sorted alphabetically. A null is written as null. And none of the getters that ActionSupport brings (getActionErrors(), getLocale(), getTexts() and so on) were serialized, because by default the writer looks only at the getters declared on the root object’s own class and ignores those of its base classes; the ignoreHierarchy parameter, covered below, changes that. Serialization is by getters: the plugin page says a field without a getter and a transient field are not serialized, and @JSON(serialize = false) removes a getter from the output.

The response headers of such a result are the ones of any Struts response (the stack adds Vary: Sec-Fetch-*, Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy-Report-Only, see the page on interceptors) plus Content-Type: application/json;charset=UTF-8 and a Content-Length. The headers other than those two are left out of the transcripts below.

Choosing the Root

A whole action is rarely what an API wants to return. The root parameter is an OGNL expression that names the object to serialize, evaluated against the value stack (see the Value Stack and OGNL):

public class BooksAction extends ActionSupport {
    private String q;
    private int id;
    private List<Book> books;
    private Book book;

    @Override
    public String execute() {
        books = BookStore.all().stream()
                .filter(b -> q == null || b.getTitle().toLowerCase().contains(q.toLowerCase()))
                .collect(Collectors.toList());
        return SUCCESS;
    }

    public String show() {
        book = BookStore.find(id);
        return book == null ? "missing" : SUCCESS;
    }

    public List<Book> getBooks() { return books; }
    public Book getBook() { return book; }
    public String getQ() { return q; }

    @StrutsParameter
    public void setQ(String q) { this.q = q; }

    @StrutsParameter
    public void setId(int id) { this.id = id; }
}
<action name="books" class="org.example.bookshelf.BooksAction">
    <result type="json">
        <param name="root">books</param>
    </result>
</action>
<action name="book" class="org.example.bookshelf.BooksAction" method="show">
    <result type="json">
        <param name="root">book</param>
    </result>
    <result name="missing" type="json">
        <param name="statusCode">404</param>
        <param name="root">q</param>
    </result>
</action>

The two setters carry @StrutsParameter because the q and id request parameters are bound by the params interceptor, which accepts only annotated setters in Struts 7 (see Parameter Injection and @StrutsParameter).

$ curl -s 'http://localhost:18080/g11json/api/books?q=hobbit'
[{"author":"J. R. R. Tolkien","genre":"FANTASY","id":3,"publishedOn":"1937-09-21","subtitle":null,"title":"The Hobbit"}]

$ curl -s 'http://localhost:18080/g11json/api/book?id=2'
{"author":"William Gibson","genre":"SCIENCE_FICTION","id":2,"publishedOn":"1984-07-01","subtitle":null,"title":"Neuromancer"}

$ curl -si 'http://localhost:18080/g11json/api/book?id=99' | head -1
HTTP/1.1 404

$ curl -s 'http://localhost:18080/g11json/api/book?id=99'
null

A root that is a list serializes as a JSON array, so the response is [...], not an object. The statusCode parameter sets the HTTP status of the response (here 404 for a missing book) while the body is still written by the result; the example uses the not-set q as the root, which is why the body is null. A real API would return a small error object as the root of that result. The related errorCode parameter calls sendError instead and so hands the response to the servlet container, which writes its own HTML error page: in the scratch run an action with errorCode 503 answered Content-Type: text/html;charset=ISO-8859-1 and the Tomcat status page, not JSON. Use statusCode for JSON APIs.

Including and Excluding Properties

excludeProperties and includeProperties take comma-separated regular expressions matched against the path of each property. For a root that is a list, the path of the title of the first element is [0].title, so the expressions below carry the index and the property name:

<action name="books-excl" class="org.example.bookshelf.BooksAction">
    <result type="json">
        <param name="root">books</param>
        <param name="excludeProperties">\[\d+\]\.genre,\[\d+\]\.subtitle</param>
    </result>
</action>
<action name="books-incl" class="org.example.bookshelf.BooksAction">
    <result type="json">
        <param name="root">books</param>
        <param name="includeProperties">\[\d+\]\.id,\[\d+\]\.title</param>
    </result>
</action>
$ curl -s http://localhost:18080/g11json/api/books-excl
[{"author":"Frank Herbert","id":1,"publishedOn":"1965-08-01","title":"Dune"}, ... ]

$ curl -s http://localhost:18080/g11json/api/books-incl
[{"id":1,"title":"Dune"},{"id":2,"title":"Neuromancer"},{"id":3,"title":"The Hobbit"}]

In this and the following transcripts an ellipsis marks output shortened for the page.

The page states that exclude expressions win when both are present: an include expression is not applied once an exclude expression matched first. Both parameters exist on the result and on the interceptor; on the interceptor they shape the output of SMD calls, and (see the input filtering section below) they can also be made to gate the keys of an incoming body. Excluding is the safer default for secrets (a password, a national identification number) only if the list is complete; an include list is an allow-list and survives new getters being added later.

Annotating the Bean: @JSON

The @JSON annotation (org.apache.struts2.json.annotations.JSON) customizes one property. It has four members: name (the key to use), serialize (default true), deserialize (default true) and format (a date pattern, default yyyy-MM-dd'T'HH:mm:ss). Book uses all but deserialize:

public class Book {
    private Date published;
    private String internalNote = "reviewed by Ana";

    @JSON(name = "publishedOn", format = "yyyy-MM-dd")
    public Date getPublished() { return published; }

    @StrutsParameter
    @JSON(name = "publishedOn", format = "yyyy-MM-dd")
    public void setPublished(Date published) { this.published = published; }

    @JSON(serialize = false, deserialize = false)
    public String getInternalNote() { return internalNote; }
    // ... the other properties are plain getters and setters
}

published appears in every response above as "publishedOn":"1965-08-01" and internalNote never appears. The plugin page’s table says name applies to serialization only, and the scratch run confirms it: a request body that uses the key publishedOn leaves published unset, while the key published (the bean property name) is read, parsed with the same format, and written back as publishedOn (shown in the next section). The asymmetry catches clients that echo a response back as a request body.

Nulls, Enums, Records and Optional

By default a property with value null is written as null. Setting excludeNullProperties to true drops it:

<action name="books-nonull" class="org.example.bookshelf.BooksAction">
    <result type="json">
        <param name="root">books</param>
        <param name="excludeNullProperties">true</param>
    </result>
</action>

The subtitle key, present as null in books, is gone from the output of books-nonull. An enum is written as its name() ("genre":"FANTASY"); enumAsBean writes it as an object with all its properties plus a _name entry:

<result type="json">
    <param name="root">books</param>
    <param name="enumAsBean">true</param>
    <param name="includeProperties">\[\d+\]\.title,\[\d+\]\.genre.*</param>
</result>
$ curl -s http://localhost:18080/g11json/api/books-enum-bean
[{"genre":{"label":"Science fiction","_name":"SCIENCE_FICTION"},"title":"Dune"}, ... ,{"genre":{"label":"Fantasy","_name":"FANTASY"},"title":"The Hobbit"}]

Since Struts 7.3.0 a Java record is serialized by its components, so a record no longer needs a bean wrapper, and java.util.Optional is written as its content or as null when empty. @JSON and @JSONFieldBridge can sit on a record component:

public record BookRecord(int id, String title, @JSON(name = "writer") String author, Optional<String> subtitle) {
}
$ curl -s http://localhost:18080/g11json/api/records
[{"id":1,"title":"Dune","writer":"Frank Herbert","subtitle":null},{"id":2,"title":"Neuromancer","writer":"William Gibson","subtitle":"Sprawl trilogy, book 1"}]

$ curl -s http://localhost:18080/g11json/api/records-nonull
[{"id":1,"title":"Dune","writer":"Frank Herbert"},{"id":2,"title":"Neuromancer","writer":"William Gibson","subtitle":"Sprawl trilogy, book 1"}]

The empty Optional is null and is therefore removed by excludeNullProperties, as the second request shows. A @JSONFieldBridge converts a value to a string with a class implementing FieldBridge (and optionally ParameterizedBridge to receive @JSONParameter values). The Bookshelf bridge writes the Genre enum as its label with a prefix passed in the annotation:

public class LabelBridge implements FieldBridge, ParameterizedBridge {
    private String prefix = "";

    @Override
    public void setParameterValues(Map<String, String> parameters) {
        prefix = parameters.getOrDefault("prefix", "");
    }

    @Override
    public String objectToString(Object object) {
        return object == null ? null : prefix + ((Genre) object).getLabel();
    }
}

public class ShelfAction extends ActionSupport {
    @JSON(name = "shelfGenre")
    @JSONFieldBridge(impl = LabelBridge.class, params = @JSONParameter(name = "prefix", value = "Genre: "))
    public Genre getGenre() {
        return Genre.FANTASY;
    }
}
$ curl -s http://localhost:18080/g11json/api/shelf
{"shelfGenre":"Genre: Fantasy"}

Base Classes and ignoreHierarchy

The default of ignoreHierarchy is true: only the getters of the root object’s own class are serialized. Setting it to false serializes the getters of every superclass up to Object, and for an action that extends ActionSupport that means all the framework getters:

<action name="hier" class="org.example.bookshelf.HierAction">
    <result type="json">
        <param name="ignoreHierarchy">false</param>
    </result>
</action>
$ curl -s http://localhost:18080/g11json/api/hier
{"actionErrors":[],"actionMessages":[],"fieldErrors":{},"greeting":"hello","locale":"en_US","texts":null}

$ curl -s http://localhost:18080/g11json/api/hier-default
{"greeting":"hello"}

HierAction has a single getter, getGreeting(). The first response adds the locale, the (empty) error and message collections and a texts entry, all inherited from ActionSupport; that is the reason the default is true. Leave it alone and name the root.

Shaping the Response

Several result parameters control the envelope rather than the data:

Table 3. The response-shaping parameters of the json result, with what the scratch run produced
Parameter Effect Observed

wrapPrefix, wrapSuffix

Text written before and after the JSON; replaces the deprecated wrapWithComments and prefix

A prefix )]}', gave )]}',[{"id":1, ... }]; the client must strip it. Whitespace around the value in the XML is trimmed, so a line break in the parameter is lost

callbackParameter

JSONP: the named request parameter becomes the function name that wraps the JSON

?callback=render gave render([{"id":1,"title":"Dune"}, ... ])

noCache

Adds Cache-Control: no-cache, Expires: 0 and Pragma: No-cache

All three headers present

enableGZIP

Compresses the body when the request sends Accept-Encoding: gzip

Content-Encoding: gzip, 208 bytes for the three-book list; curl --compressed shows the plain JSON

contentType

Overrides the content type (default application/json, or application/json-rpc when SMD is on)

application/vnd.bookshelf+json;charset=UTF-8

encoding

The character encoding of the result; defaults to struts.i18n.encoding

Appended to the content type

statusCode, errorCode

setStatus and sendError

See above: statusCode keeps the JSON body, errorCode gives the container’s HTML page

The mappings that produced these results differ from books only in the extra parameters, for example:

<action name="books-jsonp" class="org.example.bookshelf.BooksAction">
    <result type="json">
        <param name="root">books</param>
        <param name="callbackParameter">callback</param>
        <param name="includeProperties">\[\d+\]\.id,\[\d+\]\.title</param>
    </result>
</action>
<action name="books-nocache-gzip" class="org.example.bookshelf.BooksAction">
    <result type="json">
        <param name="root">books</param>
        <param name="noCache">true</param>
        <param name="enableGZIP">true</param>
    </result>
</action>
$ curl -si -H 'Accept-Encoding: gzip' http://localhost:18080/g11json/api/books-nocache-gzip | head -12
HTTP/1.1 200
Cache-Control: no-cache
Expires: 0
Pragma: No-cache
Content-Type: application/json;charset=UTF-8
Content-Encoding: gzip
...

JSONP is a pre-CORS device for reading JSON from another origin through a <script> element, and it hands the page to whoever controls the callback name; do not enable it for an authenticated endpoint. The two deprecated cousins, a wrapWithComments wrapper and the prefix option that writes {}&&, are documented with the warning that comment wrapping can turn safe JSON into dangerous text (the example is a string containing */ followed by script), so use wrapPrefix if a hijacking-defence prefix is wanted at all.

Accepting JSON: the json Interceptor

An action receives a JSON body when its stack contains the json interceptor. The interceptor reads the request body (application/json), parses it into maps, lists and primitives, filters the keys, and then sets the properties of the action by reflection on its setters. Place it before defaultStack (or use the stack that suits the action):

public class AddBookAction extends JsonSupport {
    private Book book;
    private String note;
    private List<String> tags = new ArrayList<>();
    private String secret = "untouched";

    @Override
    public String execute() {
        if (book != null) {
            BookStore.add(book);
        }
        return SUCCESS;
    }

    @StrutsParameter(depth = 1)
    public Book getBook() { return book; }

    @StrutsParameter(depth = 1)
    public void setBook(Book book) { this.book = book; }

    public String getNote() { return note; }

    @StrutsParameter
    public void setNote(String note) { this.note = note; }

    @StrutsParameter(depth = 1)
    public List<String> getTags() { return tags; }

    @StrutsParameter(depth = 1)
    public void setTags(List<String> tags) { this.tags = tags; }

    public String getSecret() { return secret; }
    public void setSecret(String secret) { this.secret = secret; }
}
<action name="add-all" class="org.example.bookshelf.AddBookAction">
    <interceptor-ref name="json"/>
    <interceptor-ref name="defaultStack"/>
    <result type="json"/>
</action>
$ curl -s -X POST -H 'Content-Type: application/json' -d '{"book":{"title":"Hyperion","author":"Dan Simmons","genre":"SCIENCE_FICTION","published":"1989-05-26","internalNote":"changed"},"tags":["classic","space-opera"],"secret":"pwned","note":"first"}' http://localhost:18080/g11json/api/add-all
{"book":{"author":"Dan Simmons","genre":"SCIENCE_FICTION","id":4,"publishedOn":"1989-05-26","subtitle":null,"title":"Hyperion"},"note":"first","secret":"untouched","tags":["classic","space-opera"]}

The response shows the rules at work. book was populated, including the nested genre (a string converted to the enum) and published (parsed with the @JSON format), tags was filled with the list, and note was set. secret has a public setter but no @StrutsParameter, so it stayed untouched although the body asked for pwned: the interceptor applies the same annotation rule as the params interceptor to every key of the JSON tree, before anything is set (a rejected key is dropped and logged, not an error). The plugin page says the annotation is enforced per property during deserialization, so unauthorized fields are never set on the target object.

Two details of that rule are not spelled out in the page and cost time:

  • A nested key such as book.title has depth one. For depth greater than zero the authorizer reads the annotation from the getter, and the intermediate node book (depth zero) is checked against the setter. In the scratch run an action with @StrutsParameter(depth = 1) on the setter only produced an empty Book (the node book passed, its children did not), and with the annotation on the getter only the whole book was dropped. Annotating both, as above, works.

  • The classes of the nested objects are not asked for annotations when the root is the action: a variant of the WAR whose Book has no @StrutsParameter at all still populated book through add-all.

The internalNote key of the body above was also ignored, but that is the effect of @JSON(deserialize = false) on the bean, as the plugin page’s table describes; it does not appear in any response to prove it, so the table rather than the run is the source for that statement.

A Root for the Incoming Object

The interceptor’s root parameter has the opposite meaning to the result’s: it names the object to populate, so the body can be a bare object instead of an object with one key per property. The object must exist before the interceptor runs; a null root fails with Invalid root expression:

public class AddRootAction extends ActionSupport {
    private final Book book = new Book();

    @Override
    public String execute() {
        BookStore.add(book);
        return SUCCESS;
    }

    public Book getBook() { return book; }
}
<action name="add-root" class="org.example.bookshelf.AddRootAction">
    <interceptor-ref name="json">
        <param name="root">book</param>
    </interceptor-ref>
    <interceptor-ref name="defaultStack"/>
    <result type="json">
        <param name="root">book</param>
    </result>
</action>
$ curl -s -X POST -H 'Content-Type: application/json' -d '{"title":"Foundation","author":"Isaac Asimov","published":"1951-06-01"}' http://localhost:18080/g11json/api/add-root
{"author":"Isaac Asimov","genre":null,"id":5,"publishedOn":"1951-06-01","subtitle":null,"title":"Foundation"}

The first attempt with an action whose book field was not initialized answered 500 with the message Invalid root expression: 'book'. Because the root is the Book itself, the authorization check runs against the setters of Book, which is why Book carries @StrutsParameter on its setters in the Bookshelf code (the published setter above shows one); in the variant of the WAR without those annotations the same request answered 200 with a book whose title and author were null. The page also states that a body that is a JSON array needs a root on the interceptor, because the array has to land in a property; an array body without a root is rejected (the scratch run answered 500 with Unable to deserialize JSON object from request).

Which Values Are Accepted

The plugin page lists what populates: primitives, Date, List, Map, primitive arrays, other classes and arrays of them; objects inside lists and maps arrive as Map, whole numbers as Long, decimals as Double and arrays as List. Since Struts 7.3.0 the keys of the body are filtered with the same acceptability controls as ordinary request parameters, using the dotted and indexed paths (address.city, items[0].name): the framework-wide excluded and accepted name patterns, a maximum path length (paramNameMaxLength, 100 by default), the ParameterNameAware and ParameterValueAware callbacks, and @StrutsParameter. Three more controls are opt-in:

Table 4. The opt-in input filters of the json interceptor
Interceptor parameter Default Effect

acceptedValuePatterns

none

Comma-separated regular expressions; only JSON leaf values matching one are accepted

excludedValuePatterns

none

Leaf values matching any expression are removed

applyPropertyFiltersToInput

false

The interceptor’s own excludeProperties and includeProperties also decide which keys are populated

The Bookshelf mapping add-filtered uses two of them, so a title may contain only letters, digits, whitespace, dots and hyphens, and the internalNote and subtitle keys cannot be set from outside:

<action name="add-filtered" class="org.example.bookshelf.AddBookAction">
    <interceptor-ref name="json">
        <param name="excludeProperties">book\.internalNote,book\.subtitle</param>
        <param name="applyPropertyFiltersToInput">true</param>
        <param name="acceptedValuePatterns">[\w\s.-]+</param>
    </interceptor-ref>
    <interceptor-ref name="defaultStack"/>
    <result type="json">
        <param name="root">book</param>
    </result>
</action>
$ curl -s -X POST -H 'Content-Type: application/json' -d '{"book":{"title":"Children of Time","internalNote":"x","subtitle":"y"}}' http://localhost:18080/g11json/api/add-filtered
{"author":null,"genre":null,"id":6,"publishedOn":null,"subtitle":null,"title":"Children of Time"}

$ curl -s -X POST -H 'Content-Type: application/json' -d '{"book":{"title":"Bad;DROP"}}' http://localhost:18080/g11json/api/add-filtered
{"author":null,"genre":null,"id":7,"publishedOn":null,"subtitle":null,"title":null}

The first request keeps title and drops subtitle (the response root is the book, whose subtitle is null). The second request has a value that fails the accepted pattern: the leaf is silently removed, the action still runs, and a book with a null title is stored (id 7). Value patterns are therefore a filter, not a validation: the rejected key produces no error, and the action must validate what is left (see Validation).

Limits on the Body

Five limits protect the parser: elements per array or object (struts.json.maxElements, 10000), nesting depth (maxDepth, 64), total input length (maxLength, 2 MB), the length of one string value (maxStringLength, 256 KB) and the length of an object key (maxKeyLength, 512). Each can be set as a constant or as a parameter of one interceptor. Since Struts 6.11.0 and 7.3.0 the input length is counted while the body is read, in fixed-size chunks, instead of line by line, so the way a client splits the payload into lines no longer matters.

<constant name="struts.json.maxLength" value="1048576"/>
<interceptor-ref name="json">
    <param name="maxLength">100</param>
</interceptor-ref>

The add-small mapping uses the second form with a limit of 100 characters, and an exception mapping (next section) to turn the failure into a JSON 400. A body longer than 100 characters is rejected and a short one is accepted:

$ curl -si -X POST -H 'Content-Type: application/json' -d '{"book":{"title":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}' http://localhost:18080/g11json/api/add-small
HTTP/1.1 400
...
{"error":"Malformed JSON request body"}

The security bulletins of 2026 make the limits more than a tuning knob: S2-072 (CVE-2026-73633) describes a JSON request body read into memory without a bound, so the configured length limit did not reduce exposure; it was fixed in 7.3.0 and 6.11.0 (see the security section below).

Errors in a JSON API

Without any configuration, a malformed body is a server error with the container’s HTML page: the body {"book": made the interceptor throw JSONException ("Input string is not well formed JSON") and Tomcat answered 500 with an HTML report. A value that cannot be converted fails differently: the key "genre":"NOPE" raised a plain IllegalArgumentException from Enum.valueOf (also 500), not a JSONException, so an exception mapping for JSONException alone does not catch it. The page on exception handling describes the machinery; here it is used to answer in JSON. The exception interceptor must wrap the json interceptor, so it is listed before it:

public class JsonSupport extends ActionSupport {
    public Map<String, String> getError() {
        return Map.of("error", "Malformed JSON request body");
    }
}
<action name="add-safe" class="org.example.bookshelf.AddBookAction">
    <interceptor-ref name="exception"/>
    <interceptor-ref name="json"/>
    <interceptor-ref name="defaultStack"/>
    <exception-mapping exception="org.apache.struts2.json.JSONException" result="badJson"/>
    <result type="json">
        <param name="root">book</param>
    </result>
    <result name="badJson" type="json">
        <param name="statusCode">400</param>
        <param name="root">error</param>
    </result>
</action>
$ curl -si -X POST -H 'Content-Type: application/json' -d '{"book":' http://localhost:18080/g11json/api/add-safe
HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
...
{"error":"Malformed JSON request body"}

The exception interceptor appears twice in the resulting stack (once explicitly and once inside defaultStack), which is harmless. The message is a constant: copying exception.message into the response would put parser internals, class names and paths in front of the client.

JSON Validation: jsonValidation and jsonActionRedirect

The jsonValidation interceptor turns the field errors of the action into a JSON document so that a form can validate through AJAX without a page reload (the plugin lists "JSON Ajax Validation" as a feature). It acts when the request carries struts.enableJSONValidation=true, answers 400 with the errors when there are any, and with struts.validateOnly=true stops before the action runs. The sample stack jsonValidationWorkflowStack of the plugin wires it between validation and workflow:

public class ValidatedAction extends ActionSupport {
    private String title;
    private String author;

    @Override
    public String execute() { return SUCCESS; }

    @StrutsParameter
    public void setTitle(String title) { this.title = title; }
    public String getTitle() { return title; }

    @StrutsParameter
    public void setAuthor(String author) { this.author = author; }
    public String getAuthor() { return author; }

    @Override
    public void validate() {
        if (title == null || title.isBlank()) {
            addFieldError("title", "Title is required");
        }
        if (author == null || author.isBlank()) {
            addFieldError("author", "Author is required");
        }
    }
}
<action name="validated" class="org.example.bookshelf.ValidatedAction">
    <interceptor-ref name="jsonValidationWorkflowStack"/>
    <result type="json">
        <param name="includeProperties">title,author</param>
    </result>
</action>
$ curl -si 'http://localhost:18080/g11json/api/validated?title=&author=&struts.enableJSONValidation=true'
HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
...
{ "fieldErrors": {"title":["Title is required"],"author":["Author is required"]}}

$ curl -s 'http://localhost:18080/g11json/api/validated?title=Dune&author=Frank&struts.enableJSONValidation=true&struts.validateOnly=true'
{}

$ curl -s 'http://localhost:18080/g11json/api/validated?title=Dune&author=Frank'
{"author":"Frank","title":"Dune"}

The status for a failure comes from the interceptor parameter validationFailedStatus (default 400). Without the struts.enableJSONValidation parameter, the same stack behaves as an ordinary validation stack: with empty fields the request ended in 404 ("No result defined for action …​ and result input"), because this mapping defines no input result. The jsonActionRedirect result type is the companion for a successful action: a browser follows a redirect without telling the script, so for a request that carries struts.enableJSONValidation=true the result writes {"location": "..."} with status 200 instead of redirecting. Without that request parameter, it is an ordinary action redirect:

<action name="go" class="org.example.bookshelf.BooksAction">
    <result type="jsonActionRedirect">
        <param name="actionName">books</param>
        <param name="namespace">/api</param>
        <param name="q">hobbit</param>
    </result>
</action>
$ curl -si http://localhost:18080/g11json/api/go
HTTP/1.1 302
Location: /g11json/api/books.action?q=hobbit

$ curl -si 'http://localhost:18080/g11json/api/go?struts.enableJSONValidation=true'
HTTP/1.1 200
Location: /g11json/api/books.action?q=hobbit
Content-Type: application/json;charset=ISO-8859-1

{"location": "/g11json/api/books.action?q=hobbit"}

An X-Requested-With: XMLHttpRequest header or an Accept: application/json header did not switch the result to JSON in the scratch run; only the request parameter did. Note also the content type of that response: the result writes application/json and the container adds ISO-8859-1, so a non-ASCII location would be misread.

Customizing the Serializer: JSONWriter and JSONReader

The writer and reader are Struts beans. The default struts implementations are registered with scope="prototype", and struts.json.writer and struts.json.reader select the implementation by bean name, so a custom one is a class, a bean declaration and a constant (the plugin page names the interface org.apache.struts2.json.JSONWriter and a Flexjson-based sample in the json-customize project of the struts-examples repository). The Bookshelf variant wraps every response in an envelope by extending the built-in writer:

public class EnvelopeJSONWriter extends StrutsJSONWriter {
    @Override
    public String write(Object object, Collection<Pattern> excludeProperties,
                        Collection<Pattern> includeProperties, boolean excludeNullProperties) throws JSONException {
        return "{\"data\":" + super.write(object, excludeProperties, includeProperties, excludeNullProperties) + "}";
    }
}
<struts>
    <bean type="org.apache.struts2.json.JSONWriter" name="envelope"
          class="org.example.bookshelf.EnvelopeJSONWriter" scope="prototype"/>
    <constant name="struts.json.writer" value="envelope"/>
    ...
</struts>
$ curl -s http://localhost:18080/g11jsonw/api/books-incl
{"data":[{"id":1,"title":"Dune"},{"id":2,"title":"Neuromancer"},{"id":3,"title":"The Hobbit"}]}

The example was deployed as a second scratch WAR (g11jsonw) because the constant is global to the application. The bean must be prototype-scoped. Since 7.3.0 the interceptor gets a fresh reader and writer per request, and the built-in implementations keep their parse and write state in instance fields, so cross-request safety relies on the scope: a singleton implementation that holds per-request state leaks it between concurrent requests, which is exactly the mistake the next section’s bulletins describe in the framework’s own code. The same page records that the struts.json.writer and struts.json.reader overrides were ignored in 7.2.x (WW-5641, fixed in 7.3.0), so a workaround carried from that release can be removed.

SMD and JSON-RPC

The plugin can also expose action methods to a script. A Simple Method Definition (SMD) is a JSON description of the remote methods; a client that understands it generates a proxy, and each call is a JSON-RPC request. It was built for the Dojo toolkit and needs two things, both required together: enableSMD on the interceptor and on the result, and the @SMDMethod annotation on each method that may be called (a security measure: only annotated methods are reachable). The package of the annotation in 7.4.0 is org.apache.struts2.json.annotations:

public class SmdAction extends ActionSupport {
    public String smd() { return SUCCESS; }

    @SMDMethod
    public Book rename(Book book, int number) {
        book.setTitle(book.getTitle() + " #" + number);
        return book;
    }
}
<action name="smd" class="org.example.bookshelf.SmdAction" method="smd">
    <interceptor-ref name="json">
        <param name="enableSMD">true</param>
    </interceptor-ref>
    <result type="json">
        <param name="enableSMD">true</param>
    </result>
</action>
$ curl -s http://localhost:18080/g11json/api/smd
{"methods":[{"name":"rename","parameters":[{"name":"p0"},{"name":"p1"}]}],"objectName":null,"serviceType":"JSON-RPC","serviceUrl":"\/g11json\/api\/smd","version":".1"}

$ curl -s -X POST -H 'Content-Type: application/json-rpc' -d '{"id":1,"method":"rename","params":[{"title":"Dune"},2]}' http://localhost:18080/g11json/api/smd
{"debug":null,"error":null,"id":"1","result":{"author":null,"genre":null,"id":0,"publishedOn":null,"subtitle":null,"title":"Dune #2"}}

A GET returns the SMD (the smd method only exists to give the result something to serialize) and a POST with the application/json-rpc content type invokes the method. Parameters are positional (p0, p1) unless @SMDMethodParameter names them. If the action could be a proxy (for instance when AOP interceptors wrap it), annotations on its methods are not inherited; the result parameter ignoreInterfaces and the interceptor parameter ignoreSMDMethodInterfaces, both set to false, make the plugin inspect interfaces and superclasses at a performance cost.

SMD is the weakest part of the plugin for a 2026 application. The documentation’s client is dojo.rpc.JsonService, and Dojo has not been part of Struts since 2.5; nothing else generates a client from an SMD document with any frequency; and the feature is the one surface of the plugin that a security bulletin touched (S2-071, below). A new API is better served by plain JSON endpoints, as in the earlier sections, or by the REST plugin of this section.

AJAX Without Dojo: fetch and htmx

AJAX needs nothing from Struts. The Core Developer Guide says the point: an Ajax request is an ordinary request, the framework cannot tell it was sent by a script, and the Ajax response is simply a portion of a page, a fragment, a script or JSON. In Struts 7 the work of the old tag libraries is done by two plain mechanisms. The first is an action that returns JSON, called from fetch. The page below is ajax.html of the scratch WAR, served from the web root:

<button id="load" type="button">Load JSON</button>
<ul id="titles"></ul>
<script>
  document.getElementById('load').addEventListener('click', async () => {
    const response = await fetch('api/books', { headers: { 'Accept': 'application/json' } });
    if (!response.ok) {
      throw new Error('HTTP ' + response.status);
    }
    const books = await response.json();
    const list = document.getElementById('titles');
    list.replaceChildren(...books.map(book => {
      const li = document.createElement('li');
      li.textContent = book.title + ' (' + book.author + ')';
      return li;
    }));
  });
</script>

The title and author are inserted with textContent, so a title that contains markup is shown as text; building HTML from JSON by string concatenation and innerHTML is the cross-site scripting mistake of this style. The browser itself was not driven in this verification; the same fetch calls were run against the scratch Tomcat with Node 24 (whose fetch is the same API) and returned the three books, the created book (status 200), the 400 for a malformed body and the 400 validation document:

$ node fetch-test.mjs
[ 'Dune (Frank Herbert)', 'Neuromancer (William Gibson)', 'The Hobbit (J. R. R. Tolkien)' ]
200 { author: 'Dan Simmons', genre: null, id: 4, publishedOn: '1989-05-26', subtitle: null, title: 'Hyperion' }
400 { error: 'Malformed JSON request body' }
400 { fieldErrors: { title: [ 'Title is required' ] } }

Posting JSON is the same call with a method, a header and a body, and it is what the json interceptor expects:

const created = await fetch('api/add-safe', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ book: { title: 'Hyperion', author: 'Dan Simmons', published: '1989-05-26' } })
});

The second mechanism is the one the Core Developer Guide’s AJAX page itself describes for responses that are not JSON: a stream result that returns an HTML fragment (the page names JSON as the preferred route, but the stream result is the direct way to return text, HTML or a script). The fragment goes to a library such as htmx, which swaps it into the page by attributes alone:

public class HtmlFragmentAction extends ActionSupport {
    private String q = "";

    public InputStream getInputStream() {
        StringBuilder sb = new StringBuilder("<ul>");
        for (Book b : BookStore.all()) {
            if (b.getTitle() != null && b.getTitle().toLowerCase().contains(q.toLowerCase())) {
                sb.append("<li>").append(escape(b.getTitle())).append("</li>");
            }
        }
        sb.append("</ul>");
        return new ByteArrayInputStream(sb.toString().getBytes(StandardCharsets.UTF_8));
    }

    @StrutsParameter
    public void setQ(String q) { this.q = q == null ? "" : q; }

    @Override
    public String execute() { return SUCCESS; }

    private static String escape(String s) {
        return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
    }
}
<action name="fragment" class="org.example.bookshelf.HtmlFragmentAction">
    <result type="stream">
        <param name="contentType">text/html;charset=UTF-8</param>
        <param name="inputName">inputStream</param>
    </result>
</action>
<script src="https://unpkg.com/htmx.org@2.0.11/dist/htmx.min.js"></script>
<input type="search" name="q" placeholder="Search titles"
       hx-get="api/fragment" hx-trigger="input changed delay:300ms" hx-target="#results">
<div id="results"></div>
$ curl -si 'http://localhost:18080/g11json/api/fragment?q=du'
HTTP/1.1 200
Content-Disposition: inline
Content-Type: text/html
...
<ul><li>Dune</li></ul>

htmx sends the value of the input as the query parameter q on each change (after the 300 ms delay) and puts the returned fragment inside the results element. The browser behaviour of htmx was not exercised here, only the endpoint it calls (verified with curl, including a query for <b> that returns an empty list); the attributes shown are those of the htmx documentation. Two observations from the run belong in the pitfalls table: the contentType parameter text/html;charset=UTF-8 produced a plain text/html response (the stream result takes the character set from its separate contentCharSet parameter), and a getter that threw a NullPointerException (a book with a null title in the store) made the result fail with "Can not find a java.io.InputStream with the name [inputStream] in the invocation stack", because OGNL swallows the exception and the stream result then sees no value. The fix was the null check in the code above.

Same-Origin Calls, Cross-Origin Calls and CSRF

A script on the same origin as the application sends cookies and needs nothing special. The default stack of Struts 7.4.0 includes the fetchMetadata interceptor (see the page on interceptors), which inspects the Sec-Fetch-* headers that browsers attach. With curl setting those headers by hand the scratch run showed: a cross-site GET with Sec-Fetch-Mode: cors answered 200, a same-origin one 200, and a cross-site POST with a JSON body and Sec-Fetch-Mode: cors answered 404 (the interceptor’s bad-request-style result has no mapping, see the pitfalls of the interceptors page). That is an additional layer and not a substitute for a CSRF defence: a JSON endpoint authenticated by a cookie is still subject to cross-site request forgery. Setting Content-Type: application/json on a cross-origin fetch forces a preflight, which does protect an endpoint that never accepts form content types, but an action that also accepts a form post (every action does) is open to a plain <form> post. Use tokens (see Double-Submit Tokens), SameSite cookies and CORS rules on the container or reverse proxy. A cross-origin call also needs CORS headers from the server, which Struts does not add; the page on CORS explains the mechanism.

The Old AJAX Material: Dojo, the Ajax Theme and Tag Plugins

The Core Developer Guide AJAX page, the Tag Developer Guide’s "Ajax Tags" and "Ajax and JavaScript Recipes" pages and the second edition of the Manning book all describe a generation of Ajax support that is gone:

  • The Dojo plugin (the sx tag library, <%@ taglib prefix="sx" uri="/struts-dojo-tags" %>) was a theme in 2.0, a bundled plugin in 2.1 to 2.3.x and "not part of the Struts2 distribution anymore" since 2.5. The Ajax Tags page itself begins with the warning that the Ajax template (Dojo plugin) is deprecated and will not be supported. None of its tags (sx:a, sx:autocompleter, sx:bind, sx:datetimepicker, sx:div, sx:head, sx:submit, sx:tabbedpanel, sx:textarea, sx:tree, sx:treenode) exist for Struts 7.

  • The ajax theme of the UI tags was removed with it. The 7.4.0 struts2-core jar contains the simple, xhtml, css_xhtml and html5 templates only (see the page on themes and templates).

  • The jQuery, YUI, Ajax Parts, GWT and Ajax File Upload plugins listed on the AJAX page are third-party or retired. The struts2-bom 7.4.0 contains no plugin of that kind: the Ajax-related artifacts that still exist are struts2-json-plugin and struts2-rest-plugin.

  • s:datetimepicker (the Dojo widget) has no direct successor: the nearest tag is s:datetextfield, whose limits are described in UI and Form Tags, and a plain HTML <input type="date"> covers most needs.

The recipes page is still worth reading for what it taught: request triggers and targets (bind), update of a region (div with href and reloadTopics), an indicator during the request, error handling with a message, and an autocompleter that loads its options from an action. Every one has a direct translation: a fetch or an htmx attribute for the request, a fragment or JSON for the response, the browser’s input event and datalist for autocompletion. The recipe’s JSON autocompleter in particular used the json result with root set to a map (<param name="root">options</param>), which is the same mapping as the books action above with another root.

An action can also return plain JSON without the plugin, through the plain or stream results or a JSP that prints JSON by hand (the AJAX page shows the JSP variant with a book.jsp scriptlet). It works, but hand-built JSON is the source of broken escaping; use the plugin or a JSON library, and write the result with application/json.

Security of the JSON Plugin

Three bulletins of August 2026 concern the plugin, and every fix is in 7.3.0 or later, so an application on 7.4.0 has them:

Table 5. Security bulletins that name the JSON plugin
Bulletin Affected Problem Fixed in

S2-070 (CVE-2026-73631), rated Moderate

Struts 7.2.1 only

A component holding per-request parsing state could be shared between requests; data of one request could be observable in another and parsing limits not enforced as intended. Concerns the population of actions from a JSON body (not enabled by default)

7.3.0

S2-071 (CVE-2026-73632), rated Low

Struts 7.2.1 only

Per-response serialization state shared between requests. Only the SMD and JSON-RPC handling of the JSON interceptor is affected; the json result type is not. Workaround: leave enableSMD at its default false

7.3.0

S2-072 (CVE-2026-73633), rated Moderate

2.1.8 to 2.3.37, 2.5.0 to 2.5.33, 6.0.0 to 6.10.0, 7.0.0 to 7.2.1

The request body was read into memory without a bound when an action is populated from JSON; the length limit did not bound the read. Denial of service

7.3.0, or 6.11.0 on the 6.x line

The lesson common to the three is the lifetime of the reader and writer objects, and the 7.3.0 design (fresh reader and writer per request, prototype beans) is what a custom writer must preserve. For an application the controls are: use the plugin only where an action needs it, put the json interceptor only on actions that accept a body, annotate the properties that may be set with @StrutsParameter, leave enableSMD off, set body limits that fit the API (maxLength of a few kilobytes is usually enough) and keep Struts patched. The page on security hardening in this section covers the rest of the stack, including the request-size limit at the container, which is the only bound that does not depend on Struts code.

Pitfalls

Table 6. JSON plugin and AJAX pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26 with Struts 7.4.0)
Symptom Cause Fix

The result is HTML or an error: "No result defined" or the page is not found (observed in a variation)

The package does not extend json-default, so the json result type is unknown

Extend json-default (or struts-default,json-default)

The JSON contains far more than expected, or the serializer fails on a cyclic or lazy structure

No root and ignoreHierarchy=false, or an entity graph with back references is serialized as is

Name the root, serialize a view object, set includeProperties

A property you annotated with @JSON(name = ...) arrives under the old name when you send it back (observed)

name applies to serialization only; the bean property name is read

Send the property name, or add a separate setter with a stable name

Posting JSON does nothing: the action properties stay empty

The json interceptor is not in the stack, the Content-Type is not application/json, or the interceptor is listed after defaultStack and the action reads values too early

List json before defaultStack and send Content-Type: application/json (the charset suffix was accepted in the scratch run)

Nested object stays empty or the whole key is dropped (observed)

@StrutsParameter is on one accessor only; a depth of one needs the getter and the setter of the node

Put @StrutsParameter(depth = 1) on both the getter and the setter of a nested bean or list

A property with a public setter stays at its old value (observed with secret)

No @StrutsParameter: the interceptor applies the params rule to JSON keys, and a rejected key is only logged

Annotate the properties that may come from the client, nothing else

500 "Invalid root expression" (observed)

The interceptor root evaluated to null

Initialize the root object (a final field or prepare())

500 for a body with an unknown enum constant, JSONException mapping does not fire (observed)

Enum.valueOf throws IllegalArgumentException, not JSONException

Map java.lang.Exception or both types in the exception-mapping, and keep the exception interceptor first

Malformed JSON returns the container’s HTML error page (observed)

Exceptions of the json interceptor are not mapped

Put exception before json and map JSONException to a json result with statusCode 400

errorCode replaced the JSON body with an HTML page (observed)

errorCode uses sendError, which the container answers

Use statusCode with a JSON body

A value pattern rejects a value and the request still succeeds with a null field (observed)

acceptedValuePatterns removes the leaf quietly

Validate the action afterwards (validate(), annotations) and treat patterns as an extra filter

wrapPrefix with a line break lost its line break (observed)

XML parameter values are trimmed

Put the characters in one line; the client strips a fixed prefix

The stream result reports that inputStream cannot be found (observed)

The getter threw (here a NullPointerException) and OGNL hid it

Fix the getter; read the log at debug level for the swallowed exception

struts.json.writer has no effect

Struts 7.2.x ignored it (WW-5641), or the bean is not declared or has another name

Use 7.3.0 or later; declare the bean with scope="prototype"

One user sees another’s data under load

A singleton custom writer or reader keeps state in fields

Keep the bean prototype; keep per-call state in locals

A cross-site POST returns 404 (observed)

fetchMetadata rejected it and the bad-request result is not mapped

Map the result in global-results if a JSON body is wanted, and keep CSRF tokens

JSONP exposes an authenticated endpoint

callbackParameter lets any page read it through a <script> tag

Do not enable JSONP for private data; use CORS

The Dojo, sx: or ajax theme examples do not compile or render

They belong to Struts 2.0 to 2.3

Use fetch or htmx with a JSON or HTML-fragment action

What Changed Recently

Table 7. JSON plugin changes by version (from the plugin page, the bulletins and the 7.4.0 jar)
Version Change

2.1.8

The JSON plugin is bundled with Struts (the oldest line named in S2-072)

2.5

The Dojo plugin is no longer part of the Struts distribution (the ajax theme is absent from the 7.4.0 jar)

2.5.14

JSONWriter can be replaced by a custom implementation (struts.json.writer)

6.11.0

struts.json.maxLength is checked while the body is read in chunks (S2-072 fix on the 6.x line)

7.0.0

The core packages moved from com.opensymphony.xwork2.* to org.apache.struts2.*; the plugin’s own classes were already in org.apache.struts2.json

7.2.1

Release affected by S2-070 and S2-071 (shared parsing and serialization state)

7.3.0

Records and Optional are serialized; @JSON and @JSONFieldBridge work on record components; a fresh reader and writer per request; struts.json.writer and struts.json.reader overrides work again (WW-5641); the name and value filters of the params interceptor apply to keys of a JSON body; S2-070 to S2-072 fixed

7.4.0

Current release; every example of this page was run on it. The jar declares the five struts.json.max* limits as constants

Where the Books Are Outdated

Struts 2 in Action (2008) builds its AJAX chapters on the Dojo-based tags and the ajax theme, and its result chapter shows a hand-written JSON result. Struts in Action (Struts 1) wrote the response from the action itself. The ideas survive (the response of an Ajax call is not a page; a custom result can write anything), the tools do not.

Table 8. AJAX and JSON material in the books and what is current
In the books Struts 7.4.0

Dojo plugin tags (sx:div, sx:autocompleter, sx:datetimepicker, sx:tabbedpanel, sx:tree) and the ajax theme

Removed in 2.5; use fetch or htmx and the simple, xhtml, css_xhtml or html5 themes

A hand-built result class that serializes with a JSON or XStream library

The json result (org.apache.struts2.json.JSONResult) with root, includeProperties and excludeProperties, or a PlainResult for tiny documents

Struts 1 response.getWriter().println(...) followed by return null

A stream or json result; an action that writes to the response directly is hard to test and bypasses interceptors' result handling

The com.googlecode.jsonplugin package of the original JSON plugin

org.apache.struts2.json

com.opensymphony.xwork2.ActionSupport and Action in plugin examples

org.apache.struts2.ActionSupport and Action

Binding every request parameter to a setter, including JSON bodies

@StrutsParameter is required in 7.x for form parameters and for JSON keys

A JSON response protected by a {}&& prefix or comment wrapper to stop hijacking

wrapPrefix and wrapSuffix; the two older options are deprecated, and modern browsers no longer let a page read a cross-origin array through a script tag redefining Array, so the prefix is mostly a historical measure

JSON-RPC and SMD with Dojo clients

Still in the plugin, with @SMDMethod required and no maintained client; a plain JSON API or the REST plugin is the supported route

AJAX validation through the Dojo ajax theme

jsonValidation and jsonActionRedirect with any script that sets struts.enableJSONValidation

Inconsistencies in the Official Documentation

Several details of the official pages disagree with the 7.4.0 jar or with themselves. They are listed so that a reader who meets them is not misled.

  • The plugin page’s examples import com.opensymphony.xwork2.Action and ActionSupport, and its SMD example imports com.googlecode.jsonplugin.annotations.SMDMethod; in 7.4.0 these are org.apache.struts2.ActionSupport, org.apache.struts2.Action and org.apache.struts2.json.annotations.SMDMethod.

  • The plugin page’s JSON-RPC client example uses the dojo.rpc.JsonService toolkit, which Struts no longer ships.

  • The "Parameter authorization" paragraph says to annotate the action properties that may be populated. For a nested bean both accessors need the annotation in 7.4.0 (see above); the page does not say so.

  • The @JSON table says name is not used for deserialization, which agrees with the run, but the sample action returns a property with @JSON(name = "newName") and the page’s text calls it "custom name", which readers take to be symmetric.

  • The jsonActionRedirect Javadoc speaks of a "JS/AJAX context" and the result switches to JSON only when the request parameter struts.enableJSONValidation=true is present; the response then declares a charset of ISO-8859-1.

  • The Core Developer Guide AJAX page names the standard Struts 2 tag library as containing "several Ajax JSP tags", which is no longer true, and its Stream result example imports java.io.StringBufferInputStream while the code uses ByteArrayInputStream (a missing import, since StringBufferInputStream is deprecated).

  • The AJAX page lists a jQuery plugin, an Ajax Parts taglib, a YUI plugin and a GWT plugin as Struts plugins; none of them is in the 7.4.0 BOM.

Summary

The JSON plugin makes JSON a first-class result and body format: map the action in a package that extends json-default, name the root, shape the output with includeProperties, excludeProperties, excludeNullProperties and the @JSON annotation, and use statusCode for a JSON error. To accept JSON, put the json interceptor before defaultStack, annotate every property that may be set (both accessors of a nested bean), bound the body with maxLength and the other limits, and map the exceptions of the interceptor to a JSON 400 with the exception interceptor in front. Use jsonValidation for AJAX validation, a prototype-scoped JSONWriter for custom output, and leave SMD alone. For the client, use fetch for JSON and a stream result for HTML fragments with htmx, and treat the Dojo, jQuery and YUI material as history. Keep CSRF defences, because JSON endpoints are reached by cookies like any other action.

References