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>
Source: JSON Plugin — Installation
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.
| Action name | What it shows |
|---|---|
|
A result with no |
|
|
|
|
|
Records, |
|
|
|
The |
|
|
|
SMD and JSON-RPC |
|
An HTML fragment from the |
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:
| Kind | Name | Class or value |
|---|---|---|
Package |
|
Extends |
Result type |
|
|
Result type |
|
|
Interceptor |
|
|
Interceptor |
|
|
Interceptor stack |
|
A sample stack: |
Beans |
|
|
Constants |
|
|
Constants |
|
|
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
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.
Source: JSON Plugin — Accepting 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.
Source: JSON Plugin — Example
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>
Source: JSON Plugin — Root Object
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"}]
Source: JSON Plugin — Enumerations
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"}
Source: Javadoc — JSONFieldBridge
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"}
Source: JSON Plugin — Base Classes
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:
| Parameter | Effect | Observed |
|---|---|---|
|
Text written before and after the JSON; replaces the deprecated |
A prefix |
|
JSONP: the named request parameter becomes the function name that wraps the JSON |
|
|
Adds |
All three headers present |
|
Compresses the body when the request sends |
|
|
Overrides the content type (default |
|
|
The character encoding of the result; defaults to |
Appended to the content type |
|
|
See above: |
Source: JSON Plugin — Wrapping
Source: JSON Plugin — JSONP
Source: JSON Plugin — Content Type
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"]}
Source: JSON Plugin — Accepting JSON
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.titlehas depth one. For depth greater than zero the authorizer reads the annotation from the getter, and the intermediate nodebook(depth zero) is checked against the setter. In the scratch run an action with@StrutsParameter(depth = 1)on the setter only produced an emptyBook(the nodebookpassed, its children did not), and with the annotation on the getter only the wholebookwas 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
Bookhas no@StrutsParameterat all still populatedbookthroughadd-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"}
Source: JSON Plugin — Root Object
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:
| Interceptor parameter | Default | Effect |
|---|---|---|
|
none |
Comma-separated regular expressions; only JSON leaf values matching one are accepted |
|
none |
Leaf values matching any expression are removed |
|
|
The interceptor’s own |
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.
Source: Javadoc — JSONWriter
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"}}
Source: JSON Plugin — JSON RPC
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.
Source: JSON Plugin — Proxied objects
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>
Source: MDN — Using the Fetch API
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' } })
});
Source: MDN — Using the Fetch API
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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
}
}
<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>
Source: htmx — Documentation
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
sxtag 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
ajaxtheme of the UI tags was removed with it. The 7.4.0struts2-corejar contains thesimple,xhtml,css_xhtmlandhtml5templates 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-bom7.4.0 contains no plugin of that kind: the Ajax-related artifacts that still exist arestruts2-json-pluginandstruts2-rest-plugin. -
s:datetimepicker(the Dojo widget) has no direct successor: the nearest tag iss: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.
Source: Tag Developer Guide — Ajax Tags
Source: Tag Developer Guide — Ajax and JavaScript Recipes, Using the JSON plugin to generate the values
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:
| 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 |
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 |
Source: Security Bulletin S2-070
Source: Security Bulletin S2-071
Source: Security Bulletin S2-072
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
| 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 |
Extend |
The JSON contains far more than expected, or the serializer fails on a cyclic or lazy structure |
No |
Name the |
A property you annotated with |
|
Send the property name, or add a separate setter with a stable name |
Posting JSON does nothing: the action properties stay empty |
The |
List |
Nested object stays empty or the whole key is dropped (observed) |
|
Put |
A property with a public setter stays at its old value (observed with |
No |
Annotate the properties that may come from the client, nothing else |
|
The interceptor |
Initialize the root object (a final field or |
|
|
Map |
Malformed JSON returns the container’s HTML error page (observed) |
Exceptions of the |
Put |
|
|
Use |
A value pattern rejects a value and the request still succeeds with a |
|
Validate the action afterwards ( |
|
XML parameter values are trimmed |
Put the characters in one line; the client strips a fixed prefix |
The |
The getter threw (here a |
Fix the getter; read the log at debug level for the swallowed exception |
|
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 |
One user sees another’s data under load |
A singleton custom writer or reader keeps state in fields |
Keep the bean |
A cross-site |
|
Map the result in |
JSONP exposes an authenticated endpoint |
|
Do not enable JSONP for private data; use CORS |
The Dojo, |
They belong to Struts 2.0 to 2.3 |
Use |
What Changed Recently
| 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 |
2.5.14 |
|
6.11.0 |
|
7.0.0 |
The core packages moved from |
7.2.1 |
Release affected by S2-070 and S2-071 (shared parsing and serialization state) |
7.3.0 |
Records and |
7.4.0 |
Current release; every example of this page was run on it. The jar declares the five |
Source: Apache Struts — JSON Plugin
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.
| In the books | Struts 7.4.0 |
|---|---|
Dojo plugin tags ( |
Removed in 2.5; use |
A hand-built result class that serializes with a JSON or XStream library |
The |
Struts 1 |
A |
The |
|
|
|
Binding every request parameter to a setter, including JSON bodies |
|
A JSON response protected by a |
|
JSON-RPC and SMD with Dojo clients |
Still in the plugin, with |
AJAX validation through the Dojo |
|
Source: Core Developer Guide — AJAX
Source: Tag Developer Guide — Ajax Tags
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.ActionandActionSupport, and its SMD example importscom.googlecode.jsonplugin.annotations.SMDMethod; in 7.4.0 these areorg.apache.struts2.ActionSupport,org.apache.struts2.Actionandorg.apache.struts2.json.annotations.SMDMethod. -
The plugin page’s JSON-RPC client example uses the
dojo.rpc.JsonServicetoolkit, 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
@JSONtable saysnameis 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
jsonActionRedirectJavadoc speaks of a "JS/AJAX context" and the result switches to JSON only when the request parameterstruts.enableJSONValidation=trueis present; the response then declares a charset ofISO-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.StringBufferInputStreamwhile the code usesByteArrayInputStream(a missing import, sinceStringBufferInputStreamis 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
-
JSON Plugin — Preventing the browser from caching the response
-
Core Developer Guide — AJAX, Configuring the TextResult Action
-
Tag Developer Guide — Ajax and JavaScript Recipes, Using the JSON plugin to generate the values
-
CVE-2026-73631 — Apache Struts JSON plugin, shared parsing state
-
CVE-2026-73632 — Apache Struts JSON plugin, shared serialization state
-
CVE-2026-73633 — Apache Struts JSON plugin, unbounded read of a JSON request body