Tags Overview & Generic Tags

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Struts 7.4.0 (released 2026-10-02) running on Apache Tomcat 11.0.26 and Java 21 with Jakarta Pages 4.0 and Servlet 6.1; the facts were checked on 2026-10-08 against the 7.4.0 struts2-core jar (its struts-tags.tld, the tag and component classes and default.properties) and the Tag Developer Guide, and every example on it was built into a WAR, deployed on Tomcat 11 and requested. The Struts tag library is the part of the framework that a JSP page talks to: a view does not call the action, it writes <s:property value="filter"/> and the tag looks the name up on the value stack, formats the result and writes it, escaped. The library has 53 tags in the 7.4.0 descriptor, 27 generic tags (control flow, data access, URLs, assets) and 26 UI tags (form controls and their layout); this page is about the generic ones and about the rules that every tag attribute obeys, the UI and form tags and the themes that render them are the topic of the pages UI & Form Tags and Themes & Templates. The running scenario is the Bookshelf, a catalogue of books exposed as Struts actions: the page reads its list of books, loops over them, builds links to other actions, formats prices and dates and links the CSS and JavaScript of the page, and every output shown is real. The related pages of this section are linked where they help: FreeMarker & Velocity Views, Tiles & Page Layouts, Security Hardening, JSON Plugin & AJAX and Testing.

The Big Picture

A Struts tag is a normal JSP tag handler (a classic tag, see Custom Tags: Classic Handlers) with one extra step. The handler class lives in org.apache.struts2.views.jsp (PropertyTag, IteratorTag, URLTag and so on), receives each attribute as a String through its setters and hands them to a component in org.apache.struts2.components (Property, IteratorComponent, URL and so on). The component holds the logic: it evaluates the attributes against the value stack (which the framework put in the request before the JSP ran, see Architecture and Request Lifecycle), pushes and pops objects on the stack, and writes to the page. The same components serve the FreeMarker and Velocity tags, which is why the Tag Developer Guide says the library is decoupled from the view technology: <s:property .../> in a JSP and <@s.property .../> in a FreeMarker template call the same Property component.

flowchart TD R["Request reaches StrutsPrepareAndExecuteFilter"] --> A["Action runs, ValueStack and ActionContext exist"] A --> V["Result renders the JSP"] V --> T["JSP calls a tag handler: PropertyTag, IteratorTag, URLTag ..."] T --> S["Handler receives every attribute as a String"] S --> C["Component evaluates attributes against the ValueStack"] C --> O["Component writes HTML text or pushes objects and variables"] O --> V

The tags divide into three families on the Tag Reference page, plus the UI tags that are the other half of the library.

Table 1. The 27 generic tags of the 7.4.0 TLD (the Tag Reference page lists 26 of them, number is missing there)
Family Tags What they do

Control tags

if, elseif, else, iterator, append, merge, generator, sort, subset

Conditional output; loops; building, joining, ordering and slicing iterators

Data tags

property, set, push, bean, param, action, text, i18n, date, number, include, url, a, debug

Read and write values, create objects, run an action from the view, localized text, formatting, includes, URLs and links

Other tags

compress, script, link, webjar

Whitespace compression, script and link elements with a CSP nonce, version-less WebJar URLs

The 26 UI tags are actionerror, actionmessage, checkbox, checkboxlist, combobox, component, datetextfield, doubleselect, fielderror, file, form, head, hidden, inputtransferselect, label, optgroup, optiontransferselect, password, radio, reset, select, submit, textarea, textfield, token and updownselect; they differ from the generic tags in one way that matters: they render through templates and themes.

The Bookshelf Used on This Page

All examples are classes and pages of one WAR, g10tags.war, deployed under the context path /g10tags on Tomcat 11.0.26: the Struts BOM org.apache.struts:struts2-bom:7.4.0 imported in dependencyManagement, struts2-core, the Servlet and Pages APIs with scope provided, the Jakarta Tags 3.0 API and implementation (jakarta.servlet.jsp.jstl-api 3.0.2 and org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1) for the pages that mix JSTL, and two WebJars (org.webjars:bootstrap 5.3.8, org.webjars:jquery 3.7.1) for the webjar tag. StrutsPrepareAndExecuteFilter is mapped to /*. Action URLs use the default extension .action, which is optional, so /g10tags/control and /g10tags/control.action are the same request. Imports and package lines are left out of the listings; the packages are org.example.bookshelf and org.example.bookshelf.model.

Table 2. The classes and pages of the example WAR
Class or page Mapping Concept

ShelfAction

* (wildcard, result /WEB-INF/views/{1}.jsp)

One action behind every page of this document: a filter property, a list of `Book`s, a map of genres, strings with markup, a date, numbers, comparators and deciders for the iterator tags

syntax.jsp, control.jsp, data.jsp, assets.jsp, jstl.jsp, catalogue.jsp

syntax, control, data, assets, jstl, catalogue

The pages that print the outputs quoted below

StatsAction, stats.jsp

stats, stats-featured, boom

A second action that the action and url tags call; featured() writes a request attribute, boom() throws

ShelfAction.properties, Messages.properties

(bundles)

Texts for text and i18n

Book, Reader

(model classes)

Book has title, author, year, genre, price and getLabel(); Reader is a bean with a no-argument constructor for s:bean

The only non-default setting of the app is the allowlist for the model package; the reason is a rule of 7.x that shows up in many outputs below, so it is explained once here. OGNL, which evaluates every expression of every tag, refuses to read a class that is not on the allowlist; Struts allows its own classes and the classes named in struts.xml, and the application’s model classes need struts.allowlist.packageNames (or struts.allowlist.classes). Without the line, every <s:property value="title"/> on a Book prints nothing and the log says is not allowlisted.

struts.xml of the example WAR (shortened to the mappings that matter)
<struts>
    <constant name="struts.allowlist.packageNames" value="org.example.bookshelf.model"/>
    <package name="default" extends="struts-default" namespace="/">
        <action name="stats" class="org.example.bookshelf.StatsAction">
            <result>/WEB-INF/views/stats.jsp</result>
        </action>
        <action name="stats-featured" class="org.example.bookshelf.StatsAction" method="featured">
            <result>/WEB-INF/views/stats.jsp</result>
        </action>
        <action name="*" class="org.example.bookshelf.ShelfAction">
            <result>/WEB-INF/views/{1}.jsp</result>
        </action>
    </package>
</struts>
ShelfAction.java, the action behind the pages (the getters the tags read; the rest are listed in the table above)
public class ShelfAction extends ActionSupport {
    private String filter = "all";
    private final List<Book> books = List.of(
            new Book("Dune", "Frank Herbert", 1965, "scifi", 9.5),
            new Book("Neuromancer", "William Gibson", 1984, "cyberpunk", 8.25),
            new Book("Hyperion", "Dan Simmons", 1989, "scifi", 11.0),
            new Book("Emma", "Jane Austen", 1815, "classic", 6.0));

    public String getFilter() { return filter; }

    @StrutsParameter
    public void setFilter(String filter) { this.filter = filter; }

    public List<Book> getBooks()    { return books; }
    public List<String> getTitles() { return books.stream().map(Book::getTitle).toList(); }
    public int getCount()           { return books.size(); }
    public String getMarkup()       { return "<b>Dune</b> & \"more\""; }
    public String getNothing()      { return null; }
    public String getOne()          { return "a"; }
    public Map<String, String> getGenres() { /* scifi, cyberpunk, classic */ }
    public Date getPublished()      { /* 1965-09-13 */ }
}

Using the Library in a JSP

The taglib Directive and the TLD

The Struts TLD is inside struts2-core-7.4.0.jar as META-INF/struts-tags.tld, so a page only needs the directive; no web.xml entry and no WEB-INF/tlds copy:

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html>
<body>
<h1>Bookshelf (Struts)</h1>
<p>Filter: <s:property value="filter"/></p>
<ul>
<s:iterator value="titles"><li><s:property/></li></s:iterator>
</ul>
</body>
</html>

Requested through the shelf action this prints Filter: all and one <li> per title. The facts of the descriptor that decide how the tags behave, read from the 7.4.0 jar:

Table 3. What the 7.4.0 struts-tags.tld declares
Item Value

<uri> and <short-name>

/struts-tags and s; the directive’s uri is that path, not a URL, and the s prefix is a convention that the Struts documentation, the FreeMarker tags (@s.) and the books all use

<tlib-version>

6.0 (it was not raised for 7.x), version="2.0" TLD schema

Tags

53: 27 generic and 26 UI; each tag’s <tag-class> is a classic tag handler of org.apache.struts2.views.jsp (for example PropertyTag, IteratorTag, NumberTag)

<rtexprvalue> of every attribute

false: no attribute accepts a JSP expression (${...} or <%= ... %>); the translation fails with attribute [value] does not accept any expressions. Values are plain strings that the component evaluates, see the section on tag syntax

<body-content>

empty for property, date, number, head, actionerror and actionmessage, JSP for every other tag (so include, url, bean and the rest can contain param tags)

<dynamic-attributes>

true for a, link, script, head and the HTML form controls (textfield, select, submit and so on), false for the data and control tags; a dynamic attribute such as data-role or onclick is copied to the element

performClearTagStateForTagPoolingServers

An attribute of every tag, default false: whether to clear all tag state at doEndTag(). It exists for containers that pool tag handlers and is not needed on Tomcat 11

The attribute rtexprvalue=false explains the most frequent question about Struts tags: <s:property value="${filter}"/> fails at translation, because ${...} is JSP EL and the attribute accepts no JSP expressions. The expression is OGNL, and a %{...} marks it when the attribute is a string. The ValueStack, ActionContext & OGNL page shows the failure and the reason in detail.

Where the Tags Read From

Every expression of every tag is OGNL evaluated against the value stack of the request: a bare name is a property looked up from the top of the stack downwards (the action first, then whatever push, bean or iterator put above it), and a name with a # in front is a key of the context map (#session, #request, #attr, #parameters and the variables created by set, iterator and the other tags with a var attribute). The ValueStack, the ActionContext and OGNL are the subject of the ValueStack, ActionContext and OGNL page; the short version that the tags need is the next table.

Table 4. The forms of expression that the tags need, with the output of the example WAR
Expression in a tag Looks up Output of the example action

<s:property value="filter"/>

property filter from the top of the stack

all

<s:property value="books[1].label"/>

index into a list, then a property

Neuromancer (1984)

<s:property value="genres['scifi']"/> and genres.scifi

a key of a map

Science fiction (both)

<s:property value="#session.user"/>

the session map of the context

alice

<s:property value="#request.requestId"/> and #attr.requestId

a request attribute; attr searches page, request, session, application

42 (both)

<s:property value="#parameters.q"/> for data?q=zz

the request parameters (an array, printed as its element)

zz

<s:property value="top.label"/> inside s:push

the top of the stack explicitly

Dune (1965)

<s:property value="[1].filter"/> inside s:push

the stack cut one element down from the top

all

Tag Syntax: How Attributes Are Evaluated

The Tag Syntax page boils the whole subject down to three rules, and every surprise with a Struts tag is one of them being applied:

  1. All String attribute types are parsed for the %{ ... } notation; text outside the notation is literal.

  2. All non-String attribute types (a boolean, an int, a List or a Collection, an Object) are not parsed for the notation but evaluated directly as an OGNL expression.

  3. The exception to rule 2: if a non-String attribute does use the %{ } notation, the notation is ignored as redundant and its content is evaluated, so %{true} and true mean the same.

flowchart TD A["Attribute value as written in the page"] --> B{"Is the tag property a String?"} B -->|"yes"| C["Parse for percent-brace expressions"] C --> D["Text outside the braces stays literal, text inside is evaluated as OGNL"] B -->|"no: boolean, int, List, Object"| E["Evaluate the whole value as an OGNL expression"] E --> F{"Was it written with the percent-brace notation?"} F -->|"yes"| G["Strip the notation, evaluate the content"] F -->|"no"| H["Evaluate as written"] D --> R["Result goes to the component"] G --> R H --> R

String Attributes

The experiment that shows rule 1 uses s:textfield with theme="simple", which renders just the <input> element and no table row (themes belong to the page on themes and templates). The same action property filter (value all) is written five ways, and the HTML of each is quoted:

<s:textfield name="filter" theme="simple"/>
<s:textfield name="filter" value="filter" theme="simple"/>
<s:textfield name="filter" value="%{filter}" theme="simple"/>
<s:textfield name="filter" value="%{getText('book.heading')}" theme="simple"/>
<s:textfield name="filter" value="%{filter} and %{count}" theme="simple"/>
<input type="text" name="filter" value="all" id="filter"/>
<input type="text" name="filter" value="filter" id="filter"/>
<input type="text" name="filter" value="all" id="filter"/>
<input type="text" name="filter" value="Books on the shelf" id="filter"/>
<input type="text" name="filter" value="all and 4" id="filter"/>

The first line gets its value without a value attribute at all, because name tells the tag which property to read (and to write back on submission). The second prints the literal text filter: value of a form tag is a String attribute, so filter is a literal, not a property name. The third reads the property with the notation. The fourth calls a method of the action (getText is inherited from ActionSupport) and the fifth mixes literal text with two expressions, which is what the notation is for.

The most common use of the same rule is a label that comes from a message bundle or contains data:

<s:textfield name="filter" label="%{#request.requestId}" theme="xhtml"/>
<s:textfield name="filter" label="getText('book.heading')" theme="xhtml"/>
<s:textfield name="filter" label="%{getText('book.heading')}" theme="xhtml"/>
<s:textfield name="filter" label="Cart item No. %{count} note" theme="xhtml"/>

The labels rendered (the xhtml theme wraps each field in a table row; only the <label> element is quoted):

<label for="filter" class="label">42:</label>
<label for="filter" class="label">getText(&#39;book.heading&#39;):</label>
<label for="filter" class="label">Books on the shelf:</label>
<label for="filter" class="label">Cart item No. 4 note:</label>

The second label is the trap: label is a String attribute, so without the notation getText('book.heading') is literal text and prints as it was written. The Text tag page of the Tag Developer Guide shows exactly that form, label="getText('person.lastName')", as the way to use i18n in an attribute; on Struts 7.4.0 it prints the call, and the working form needs %{...} around it.

Non-String Attributes

Rule 2 is the reason that test=count  3 works without any notation on s:if, and that multiple="true" on a select is a boolean. The Tag Syntax page gives four spellings of the same boolean; the example WAR renders them with a s:select (the multi-select attribute multiple is a boolean), and the HTML shows that the first two are multiple selects and the last two follow the action property allowMulti, which is false:

<s:select name="g" list="genres" multiple="true" theme="simple"/>
<s:select name="g" list="genres" multiple="%{true}" theme="simple"/>
<s:select name="g" list="genres" multiple="allowMulti" theme="simple"/>
<s:select name="g" list="genres" multiple="%{allowMulti}" theme="simple"/>
<select name="g" id="g" multiple="multiple">  <!-- multiple="true" and multiple="%{true}" -->
<select name="g" id="g">                      <!-- multiple="allowMulti" and multiple="%{allowMulti}" -->

The list attribute of the same tag is a non-String attribute too: list="genres" is the OGNL expression genres, the Map of the action. Because it is easy to forget which attributes are Strings, the notation is harmless on the non-String ones, and most Struts code writes %{...} everywhere an expression is meant; the official examples of the generic tags, though, write test="#rowstatus.odd == true" and value="users" without it, and so does this page.

The Same Attribute in Both Worlds: property and set

The attributes of the data tags are the ones that show rule 1 and rule 2 side by side. s:property takes value as an Object (rule 2: an expression), s:set also treats value as an expression, as every set example of the guide shows. The six lines below were requested and printed on the page syntax:

C1 [<s:property value="filter"/>]
C2 [<s:property value="'filter'"/>]
C3 [<s:property value="%{filter}"/>]
C4 [<s:property value="'Hello ' + filter"/>]
C5 [<s:property value="%{'Hello ' + filter}"/>]
C6 [<s:property value="count + 1"/>]
C1 [all]
C2 [filter]
C3 [all]
C4 [Hello all]
C5 [Hello all]
C6 [5]

The pair C1 and C2 is the difference between a property name and an OGNL string literal: single quotes make a literal, exactly as they did in the pre-2.2 syntax described next. s:set shows the same behaviour with the var: <s:set var="lit" value="filter"/> stores all, value="'filter'" stores the text filter and value="%{filter}" stores all again (page data, line S7).

The Alt Syntax, History Only

The page named Alt Syntax in the Tag Developer Guide is now a history page, and it is the reason that older books and blog posts look different. Before WebWork 2.1.4 every attribute was evaluated as an OGNL expression, so a literal needed single quotes; the altSyntax option made only %{...} evaluate. It was on by default from WebWork 2.2 and a struts.xml constant and a per-request flag could switch it off. Struts 2.6 removed both (WW-3877), so the %{...} notation is the only syntax. The guide’s own before-and-after example is the clearest summary:

<%-- old syntax, expression everywhere --%>
<s:textfield label="'Cart item No.' + #rowstatus.index + ' note'"
             name="'cart.items[' + #rowstatus.index + '].note'"
             value="note" />

<%-- today: literal text, %{...} marks the expressions --%>
<s:textfield label="Cart item No. %{#rowstatus.index} note"
             name="cart.items[%{#rowstatus.index}].note"
             value="%{note}" />

A page copied from an old example in which value="note" was supposed to read the property note prints the literal text note on Struts 7.4.0 (the third line of the experiment above), and a ${...} of the same era is JSP EL, which a Struts attribute does not accept.

Property Names That Cannot Be Used

The Tag Syntax page lists seven property names as disallowed: parameters, application, session, struts, request, servletRequest and servletResponse; its example, <s:iterator value="parameters"/> on an action with a getParameters() getter, "will not work". On Tomcat 11.0.26 with Struts 7.4.0 an action with String[] getParameters() was read normally:

D1 [<s:iterator value="parameters"><s:property/>,</s:iterator>]
D2 [<s:iterator value="getParameters()"><s:property/>,</s:iterator>]
D1 [x,y,]
D2 [x,y,]

The bare name resolves on the stack, the action is on the stack, so its property wins. The names are still a poor choice: they are the names of the context map’s own objects, and #parameters, #session and #request with a # mean something else on the same page; the rule to keep is not to give an action a property that has the name of a context object.

The Characters of OGNL: the One-Character Trap

A quirk of OGNL deserves its own paragraph because it is the oldest question about the if tag (the guide’s FAQ has it). In OGNL a single-quoted literal of one character is a char, not a String. Comparing the string a of the action to the literal 'a' is therefore false:

<s:if test="one == 'a'">one is a</s:if><s:else>one is not a</s:else>
<s:if test='one == "a"'>one is a</s:if><s:else>one is not a</s:else>
<s:if test="one.length == 1">len</s:if>
<s:if test="one in {'a'}">in</s:if>
one is not a
one is a
len
(nothing)

The cure given by the FAQ is to flip the quotes: double quotes inside single quotes make a String. The same applies to every test or value with a one-character literal; a literal of two or more characters is a String and needs no care.

Expressions Outside Tag Attributes

JSP EL and OGNL live side by side on a page and do not read the same objects: ${...} is evaluated by the JSP container and works in template text and in the attributes of JSP or JSTL tags, while OGNL works only in Struts tag attributes. The Tag Syntax page lists the forms: ${user.username} reads a JavaBean in a standard context (JSTL, FreeMarker or Velocity), <s:textfield name="username"/> reads a property on the stack, and #{'a':'b'} is a literal map. How ${filter} reaches the value stack (the request wrapper that falls back to the stack), why <s:property value="${x}"/> fails and what changes with escaping is the subject of the value stack page; the section on JSTL below shows the combinations that work.

Escaping the Body of a Tag

Since Struts 2.6 and the FreeMarker version that enables auto-escaping by default, the guide says to stop using ?html in custom tags and FreeMarker pages. For the tags whose body is markup the library offers escapeHtmlBody: it exists on a, submit and component, and the global constant struts.ui.escapeHtmlBody sets it for all three, with the attribute taking precedence over the constant. On the example page:

<s:a href="%{#attr.nothing}" escapeHtmlBody="true"><b>bold</b> & more</s:a>
<s:a action="stats" escapeHtmlBody="false"><b>bold</b> & more</s:a>
<a href="">&lt;b&gt;bold&lt;/b&gt; &amp; more</a>
<a href="/g10tags/stats.action"><b>bold</b> & more</a>

Control Tags

if, elseif and else

if performs basic condition flow. It can stand alone or be followed by any number of elseif tags and one else; an elseif is only valid after an if and an else only after an if or elseif. The tag has one attribute besides the pooling one: test (required), a Boolean expression that decides whether the body is written.

Table 5. Attributes of the if and elseif tags (7.4.0 TLD; else has no attribute)
Attribute Required Default Description

test

yes

-

Expression to determine if the body of the tag is displayed; evaluated as OGNL and converted to a boolean

The conversion to a boolean is the one of OGNL, and it is where the surprises are. Every line below was requested on the page control; the expressions and the output are:

IF1 [<s:if test="count > 3">many</s:if><s:elseif test="count > 1">some</s:elseif><s:else>none</s:else>]
IF2 [<s:if test="filter == 'all'">all</s:if>]
IF7 [<s:if test="nothing == null">nothing is null</s:if>]
IF8 [<s:if test="'foo' in {'foo','bar'}">in</s:if><s:if test="'foo' not in {'foo','bar'}">notin</s:if>]
IF10 [<s:if test="filter">str</s:if><s:else>strfalse</s:else>]
IF11 [<s:if test="'a'">lit-a</s:if><s:else>lit-a-false</s:else>]
IF12 [<s:if test="'true'">lit-true</s:if><s:else>lit-true-false</s:else>]
IF13 [<s:if test="'aa'">lit-aa</s:if><s:else>lit-aa-false</s:else>]
IF1 [many]
IF2 [all]
IF7 [nothing is null]
IF8 [in]
IF10 [strfalse]
IF11 [lit-a]
IF12 [lit-true]
IF13 [lit-aa-false]

Read the outputs against the conversion rule of OGNL: a boolean expression is used as it is (IF1, IF2, IF7), in and not in test membership of a collection literal (IF8), a string that is not true converts to false, so a property that holds the text all is false (IF10), a single-quoted one-character literal is a char, which converts to true (IF11), the string 'true' converts to true (IF12) and any other string, 'aa', to false (IF13). The practical rule is to write a real boolean expression, a comparison or a boolean property, and never to rely on the truthiness of a string. The Getting Started tutorial on control tags uses the same tag with personBean.over21, a boolean getter, and with personBean.carModels.length > 1 to choose between "Car model" and "Car models".

A second trap is the method call on a JDK collection. These tests were written the way a Java programmer would write them, and then the way OGNL needs them (page control, lines IF6 and IF9):

IF6 [<s:if test="books.size() > 2">big</s:if>] [<s:if test="books.size > 2">big-prop</s:if>]
IF9 [<s:if test="noBooks.isEmpty()">empty</s:if>] [<s:if test="noBooks.empty">empty-prop</s:if>] [<s:if test="!books.empty">notempty-prop</s:if>]
IF6 [] [big-prop]
IF9 [] [empty-prop] [notempty-prop]

The method forms print nothing and the log has Declaring class [class java.util.AbstractCollection] of member type [public abstract int java.util.AbstractCollection.size()] is not allowlisted; the property forms (books.size, noBooks.empty, books[0]) read the same data through the bean introspection that the allowlist permits. The reason and the alternative of allowlisting JDK classes are on the value stack page, which also explains why ! and not work as negation.

The if tag is the cure for logic in the page, not an invitation to put it there: a condition that is more than a comparison belongs in a boolean getter of the action (isOver21(), isEmpty()), which keeps the page readable and testable.

iterator

iterator loops over a value. The iterable can be any java.util.Collection, java.util.Iterator, java.util.Enumeration, java.util.Map or an array; the tag documentation adds that a value that is none of them is put into a newly created list (the loop then runs once). For each pass the current element is pushed on the value stack, so inside the loop the properties of the element are the bare names, and it is popped at the end of the pass.

Table 6. Attributes of the iterator tag (7.4.0 TLD and Tag Developer Guide)
Attribute Required Default Description

value

no

the object itself, in a new list

The iterable source to iterate over (Collection, Iterator, Enumeration, Map, array); an expression

var

no

-

Name under which the current element is also stored in the context (read it with #name); id was deprecated in 2.1.x in favour of var

status

no

false

If specified, an IteratorStatus is pushed on the stack for each pass and stored in the context under that name

begin

no

0

The index to start at

end

no

size of the source, or 0 if step is negative

The index to end at, inclusive

step

no

1

The increment; it may be negative, in which case begin must be greater than end

The basic loops, with the output of the example action (page control, lines IT1 to IT4):

IT1 [<s:iterator value="titles"><s:property/>,</s:iterator>]
IT2 [<s:iterator value="books"><s:property value="label"/>;</s:iterator>]
IT3 [<s:iterator value="books" var="b"><s:property value="#b.title"/>/<s:property value="title"/>;</s:iterator>]
IT1 [Dune,Neuromancer,Hyperion,Emma,]
IT2 [Dune (1965);Neuromancer (1984);Hyperion (1989);Emma (1815);]
IT3 [Dune/Dune;Neuromancer/Neuromancer;Hyperion/Hyperion;Emma/Emma;]

IT1 iterates over a list of strings: <s:property/> with no value prints the top of the stack, which is the current string. IT2 iterates over beans and reads a property of the current one (label is getLabel() of Book, evaluated on the element because the element is on the stack). IT3 shows that var gives the element a second name: #b.title and title are the same book. The Getting Started tutorial on control tags builds a table the same way from the array personBean.carModels, and says that if the collection holds objects with several fields, value of the nested property selects the field.

The Status Object

With status the loop gets an IteratorStatus (org.apache.struts2.views.jsp.IteratorStatus), the object behind the alternate-row-colour idiom of the guide. Its properties and method, from the 7.4.0 class and the output of the example action (page control, line IT4, which prints index:count:first:last:odd:even:title for each of the four books):

Table 7. The IteratorStatus properties
Property Meaning Observed for the four books

index

Zero-based index of the current element

0, 1, 2, 3

count

One-based count, index + 1

1, 2, 3, 4

first

True for the first element

true, false, false, false

last

True for the last element

false, false, false, true

odd

True when the count is odd

true, false, true, false

even

True when the count is even

false, true, false, true

indexStr, countStr

The index and count as Strings (since 6.4.0)

0:1, 1:2, 2:3, 3:4 (see the conversion paragraph)

modulus(int)

The remainder of the count divided by the argument, for an "every third row" rule

#st.modulus(2) and #st.modulus(3) printed 11,02,10,01 (count 1 to 4)

IT4 [<s:iterator value="books" status="st"><s:property value="#st.index"/>:<s:property value="#st.count"/>:<s:property value="#st.first"/>:<s:property value="#st.last"/>:<s:property value="#st.odd"/>:<s:property value="#st.even"/>:<s:property value="title"/>;</s:iterator>]
IT4 [0:1:true:false:true:false:Dune;1:2:false:false:false:true:Neuromancer;2:3:false:false:true:false:Hyperion;3:4:false:true:false:true:Emma;]

The idiom the guide uses is <s:if test="#rowstatus.odd == true"> for the zebra stripes and <s:if test="!#userStatus.last">,</s:if> to put a comma between the items and not after the last; both are pure expressions on status. Note that odd follows the count, so the first row (index 0, count 1) is odd.

begin, end and step

The three attributes select a part of the source (page control, lines IT5 to IT8; end is inclusive):

IT5 [<s:iterator value="books" begin="1" end="2"><s:property value="title"/>,</s:iterator>]
IT6 [<s:iterator value="books" begin="0" step="2"><s:property value="title"/>,</s:iterator>]
IT7 [<s:iterator value="books" begin="3" end="0" step="-1"><s:property value="title"/>,</s:iterator>]
IT8 [<s:iterator begin="1" end="5" var="i"><s:property value="top"/>,</s:iterator>]
IT5 [Neuromancer,Hyperion,]
IT6 [Dune,Hyperion,]
IT7 [Emma,Hyperion,Neuromancer,Dune,]
IT8 [1,2,3,4,5,]

IT8 has no value: with only begin and end the tag counts, which is the Struts counterpart of c:forEach begin= end= (the Part I page JSTL Iteration has the JSTL tag), and top is the current number. A second way to count to n is an OGNL trick that the guide mentions, (n).{ #this }, a projection over the integers from 0 to n-1 (IT9):

IT9 [<s:iterator value="(3).{ #this }" status="stat"><s:property value="#stat.count"/>/<s:property/>,</s:iterator>]
IT9 [1/0,2/1,3/2,]

count is one-based and index zero-based, as the guide notes in its comment.

Maps, Literals, Selection and Projection

The value is an OGNL expression, so the collection operators of OGNL work in it (the page control, lines IT10 to IT17):

IT10 [<s:iterator value="{'a','b','c'}"><s:property/>,</s:iterator>]
IT11 [<s:iterator value="genres"><s:property value="key"/>=<s:property value="value"/>;</s:iterator>]
IT15 [<s:iterator value="books.{? #this.year > 1960}"><s:property value="title"/>,</s:iterator>]
IT16 [<s:iterator value="books.{title}"><s:property/>,</s:iterator>]
IT17 [<s:iterator value="books.{^ #this.genre == 'scifi'}"><s:property value="title"/>,</s:iterator>]
IT10 [a,b,c,]
IT11 [scifi=Science fiction;cyberpunk=Cyberpunk;classic=Classics;]
IT15 [Dune,Neuromancer,Hyperion,]
IT16 [Dune,Neuromancer,Hyperion,Emma,]
IT17 [Dune,]

A Map iterates over its entries, so key and value are the properties of each Map.Entry (IT11); a list literal {'a','b','c'} is a List (IT10); .{? condition} is a selection of all matching elements (IT15, the books after 1960), .{^ condition} the first match and .{$ condition} the last (IT17); .{title} is a projection, the list of one property (IT16), the guide’s more compact alternative to a loop over persons that prints person.firstName. A source that is empty or null writes nothing and is not an error (IT12 and IT13 printed an empty line).

Nested Loops and the [n] Operator

Inside a loop the top of the stack is the element, below it is the action. When two nested loops need the outer element, the [n] operator cuts the stack: [1] is the stack starting one element below the top. The guide uses it to pass the current element to another action and to read a property that the inner element hides. The example (page control, IT14) prints, for each outer book and inner genre, the outer book’s title read as [1].title:

<s:iterator value="books" status="o">
  <s:iterator value="genres" status="i">
    <s:property value="[1].title"/>-<s:property value="key"/>-<s:property value="#o.index"/><s:property value="#i.index"/>;
  </s:iterator>
</s:iterator>
Dune-scifi-00;Dune-cyberpunk-01;Dune-classic-02;Neuromancer-scifi-10;Neuromancer-cyberpunk-11; ...

Giving each loop a status or a var (#o, #b) is more readable than counting levels with [n].

Conversion of Generated Numbers

Values that a tag generates (the numbers of a counting loop, count, index) go through the framework’s conversion when a property tag prints them, and are formatted for the locale (the guide’s example uses fa_IR and prints Persian digits). Since Struts 6.4.0 the status object has countStr and indexStr, which are Strings and are written as they are; use them wherever the number is part of an id or a parameter name (id="text_%{#status.countStr}", name="test[%{#status.indexStr}]"), as in the guide’s example. On the en_US example page countStr and indexStr printed 1:0,2:1,3:2,4:3 (IT21), the same digits as the numeric properties.

Building and Reshaping Iterators: append, merge, generator, sort, subset

Five control tags create or transform iterators. Each one has a var that stores the result in the context (read with #name, and for sort, subset and generator also in the page context as the documentation says), and each one pushes the result on the stack for the duration of its body, so an iterator without a value inside it loops over the result.

Table 8. The iterator tags and their attributes (7.4.0 TLD)
Tag Attributes What it does

append

var; nested param tags give the iterables

Appends iterators: the entries of the first, then of the second and so on

merge

var; nested param tags

Merges iterators: one entry of each in turn (first of the first, first of the second, second of the first and so on)

generator

val (required), separator (required), count, converter, var

Splits a string into an iterator

sort

comparator (required), source, var

Sorts an iterable with a java.util.Comparator

subset

source, start, count, decider, var

A slice of an iterable: from index start, at most count entries, optionally filtered by a Decider

The example action supplies a Comparator<Book> by year (getByYear()), a Decider for the genre scifi (getScifiOnly()) and a Converter that upper-cases (getUpper()); the output of each tag (page control):

AP2 [<s:append var="all2"><s:param value="%{titles}"/><s:param value="%{{'x','y'}}"/></s:append><s:iterator value="#all2"><s:property/>,</s:iterator>]
MG1 [<s:merge var="m"><s:param value="%{titles}"/><s:param value="%{{'x','y'}}"/></s:merge><s:iterator value="%{#m}"><s:property/>,</s:iterator>]
GN1 [<s:generator val="%{'aaa,bbb,ccc,ddd'}" separator=","><s:iterator><s:property/>,</s:iterator></s:generator>]
GN2 [<s:generator val="%{'aaa,bbb,ccc,ddd'}" separator="," count="2"><s:iterator><s:property/>,</s:iterator></s:generator>]
GN3 [<s:generator val="%{'aaa,bbb,ccc,ddd'}" separator="," converter="%{upper}"><s:iterator><s:property/>,</s:iterator></s:generator>]
SO1 [<s:sort comparator="byYear" source="books"><s:iterator><s:property value="year"/>,</s:iterator></s:sort>]
SU2 [<s:subset source="books" count="2"><s:iterator><s:property value="title"/>,</s:iterator></s:subset>]
SU3 [<s:subset source="books" start="1" count="13"><s:iterator><s:property value="title"/>,</s:iterator></s:subset>]
SU4 [<s:subset source="books" decider="scifiOnly"><s:iterator><s:property value="title"/>,</s:iterator></s:subset>]
AP2 [Dune,Neuromancer,Hyperion,Emma,x,y,]
MG1 [Dune,x,Neuromancer,y,Hyperion,Emma,]
GN1 [aaa,bbb,ccc,ddd,]
GN2 [aaa,bbb,]
GN3 [AAA,BBB,CCC,DDD,]
SO1 [1815,1965,1984,1989,]
SU2 [Dune,Neuromancer,]
SU3 [Neuromancer,Hyperion,Emma,]
SU4 [Dune,Hyperion,]

Details that the examples settle:

  • append places whole iterables one after the other, merge interleaves them: with the four titles and x, y the results are Dune,...,Emma,x,y and Dune,x,Neuromancer,y,Hyperion,Emma. A merge ends each source as it runs out.

  • generator splits with separator (a missing separator is a translation error, because the TLD marks it required; the guide’s own table says so) and count limits the number of entries. A Converter (a lambda in the example action, org.apache.struts2.util.IteratorGenerator.Converter) turns each string into an object. The documentation’s example of separator in Example 3 combines var with a scriptlet to read the iterator from the page context.

  • sort needs a Comparator that the action provides; the iterable itself is source. subset has no condition language of its own, a Decider (SubsetIteratorFilter.Decider) provides the filter, and start plus count beyond the size is not an error (SU3 asked for 13 and got the 3 that remain).

  • With var and no body the result is stored in the page scope: <s:sort ... var="sorted"/> then #attr.sorted (or ${sorted}) reads it, while #sorted does not (page control, lines SO2 and SO3: #sorted printed nothing and #attr.sorted printed 1815,1965,1984,1989). The append and merge tags, which document a context var, are read as #all2 (AP2 and MG1 above).

  • An empty iterable in the middle of an append ends the append: append of the titles, an empty list and x,y printed only the four titles (AP1), append of an empty list first printed nothing (AP3, AP5), whereas the same append without the empty list printed the four titles and x,y (AP2, AP4 with the empty list last). Filter out empty lists before append, or use merge, which skipped the empty list in the same test (merge of the titles, an empty list and x, y printed Dune,x,Neuromancer,y,Hyperion,Emma).

Which Tag to Use

The guide does not rank the iterator tags; this table does, from the experiments above and the Bookshelf’s needs.

Table 9. Choosing a control tag
Need Tag Comment

Show a block only if a condition holds

if (with elseif and else)

Put the condition in a boolean getter when it is more than a comparison

Loop over a collection, map or array

iterator with status

status gives the first, last and odd/even rules; var names the element

Count from a to b

iterator begin end

There is no step-less alternative needed; step="-1" counts down

Show one page of a list

subset start count or iterator begin end

A real paging needs the slice in the action or the repository, not in the view

Show a list in a new order

sort with a comparator, or sort in the action

A sort in the view re-sorts on every render

Join two lists

append (no empty lists), merge

Prefer building the combined list in the action

Split a delimited string

generator

The one place where a string is data in the view; localised lists belong in a bundle

Data Tags

property

property writes the value of an expression, converted to a string and HTML-escaped. It is the tag of every page of this section and the one with the most useful attributes.

Table 10. Attributes of the property tag (7.4.0 TLD)
Attribute Required Default Description

value

no

the top of the stack

The expression whose value is written

default

no

-

Text written if the value is null

escapeHtml

no

true

Escape HTML

escapeXml

no

false

Escape XML

escapeJavaScript

no

false

Escape JavaScript

escapeCsv

no

false

Escape CSV (a value for a column)

Several lines of the page data show the attributes at work. The action’s markup property is <b>Dune</b> & "more":

P1 [<s:property value="filter"/>] [<s:property value="markup"/>] [<s:property value="markup" escapeHtml="false"/>]
P3 [<s:property value="nothing"/>] [<s:property value="nothing" default="n/a"/>] [<s:property value="nonexistent" default="n/a"/>]
P8 [<s:property/>]
P11 [<s:property value="books[0]"/>]
P1 [all] [&lt;b&gt;Dune&lt;/b&gt; &amp; &quot;more&quot;] [<b>Dune</b> & "more"]
P3 [] [n/a] [n/a]
P8 [org.example.bookshelf.ShelfAction@baa2886]
P11 [org.example.bookshelf.model.Book@4b0f8ea9]

The default escaping is the first line of defence against cross-site scripting: the markup of the property is shown as text. escapeHtml="false" writes it raw and must be reserved for values that the application itself produced as markup. default replaces a null (a property that returns null, or one that does not exist, as P3 shows); an empty string is a value and is not replaced. A property tag with no value prints the top of the stack, which outside any loop is the action, hence P8, and an object that has no toString() of its own prints the class name and a hash code (P11). The Getting Started tutorial recommends overriding toString() of the model classes for this reason; property converts numbers, booleans and dates to strings by itself (the count, balance and featured properties printed 4, 1234.5 and true; a Date printed 9/13/65, the short date format of the locale, not a format of your choice, which is what s:date is for).

The three extra escapes stack on top of the default escapeHtml="true", which makes the combined output surprising; switch the HTML escape off when one of the others is the one needed (page data, lines P2 and PE1):

PE1 [<s:property value="markup" escapeHtml="false" escapeXml="true"/>]
    [<s:property value="markup" escapeHtml="false" escapeJavaScript="true"/>]
    [<s:property value="markup" escapeHtml="false" escapeCsv="true"/>]
    [<s:property value="markup" escapeXml="true"/>]
[&lt;b&gt;Dune&lt;/b&gt; &amp; &quot;more&quot;]
[<b>Dune<\/b> & \"more\"]
["<b>Dune</b> & ""more"""]
[&amp;lt;b&amp;gt;Dune&amp;lt;/b&amp;gt; &amp;amp; &amp;quot;more&amp;quot;]

The last line is the double escape: HTML escaping and then XML escaping turns < into &lt;. Each escape is for a different context: escapeHtml for HTML text, escapeJavaScript for a value inside a JavaScript string (and the quotes and the slash are then backslash-escaped), escapeCsv for a CSV column (the value is quoted and a quote is doubled). Pick one, set the others off, and never write a property inside a <script> block with escapeHtml alone.

set

set assigns a value to a variable in a scope. It is the tag for a complex expression that a page uses several times: the expression is evaluated once and the rest of the page reads the variable, which is both faster and more readable.

Table 11. Attributes of the set tag (7.4.0 TLD)
Attribute Required Default Description

var

yes

-

The name of the variable

value

no

the body

The expression to assign; if the tag has a body, the value attribute is not evaluated and the body’s text is the value

scope

no

action

application, session, request, page or action

trimBody

no

true

Set to false to keep the whitespace around the body

S1 [<s:set var="first" value="books[0]"/><s:property value="#first.title"/>] [<s:property value="#attr.first.title"/>]
S2 [<s:set var="h">Hello <s:property value="filter"/>  </s:set>"<s:property value="#h"/>"]
S3 [<s:set var="h2" trimBody="false">  Hello  </s:set>"<s:property value="#h2"/>"]
S6 [<s:set var="cnt" value="count"/><s:property value="#cnt + 1"/>]
S1 [Dune] [Dune]
S2 ["Hello all"]
S3 ["  Hello  "]
S6 [5]

With a body, the body’s text (with the tags it contains evaluated) is the value (S2), and its leading and trailing whitespace is trimmed unless trimBody="false" (S3). Where the variable can be read depends on the scope, and the visibility differs for each way of reading it. The table summarises the observation of the page scope, which reads one variable of each kind with #name, #attr.name, #request.name and the JSP EL implicit objects pageScope, requestScope and ${name}:

Table 12. Where a variable of the set tag is visible (observed on Tomcat 11.0.26)
Scope of the set tag #x #attr.x ${x} Comment

Default scope action

yes

yes

yes

Stored in the context map and in the page scope; ${pageScope.x} and ${requestScope.x} both print it (the request wrapper falls back to the value stack)

scope="request"

no

yes

yes

Only a request attribute: #x does not find it, #request.x, #attr.x and ${requestScope.x} do

scope="page"

no

yes

yes

A page-scope variable: ${pageScope.x} and #attr.x; #request.x does not

scope="session" or "application"

no

yes

yes

Read with #session.x and #application.x (also #attr.x and ${x}, observed); it lives as long as the session or the application

The var of the other tags that have one (iterator, bean, url, text, date, number, action) is stored in the context map only and is read with #name (${name} also works through the request wrapper, as the loops with ${b2.title} in the JSTL section show). The Struts docs of the url tag add that, with var, the generated URL is placed in the request scope in addition to the action scope, and that a text tag with var writes nothing to the page.

push

push pushes a value onto the value stack for the duration of its body, so the properties of that object become the bare names inside it; it is the tag form of "work with this object". It has one required attribute, value.

PU1 [<s:push value="books[0]"><s:property value="title"/> by <s:property value="author"/> / top=<s:property value="top.label"/> / [1]=<s:property value="[1].filter"/></s:push>]
PU2 [<s:push value="books[0]"><s:push value="books[1]"><s:property value="title"/>|<s:property value="[1].title"/></s:push></s:push>]
PU1 [Dune by Frank Herbert / top=Dune (1965) / [1]=all]
PU2 [Neuromancer|Dune]

Inside the body title and author are properties of the pushed Book, top is that book and [1] is the stack without it, so [1].filter reads the action’s property. The nested push of PU2 shows the order of the stack: the inner element is on top, and [1].title reads the outer book. The guide’s examples use [1].top and top.property the same way to explain what each param of a bean sees. push is a convenience: a loop over one element is rarely needed in a page, and a model object is better exposed by ModelDriven (see ModelDriven, Preparable & CRUD) than pushed in the view.

bean and param

bean instantiates a class that conforms to the JavaBeans specification, sets properties through nested param tags and pushes the bean on the stack for the body; with var it is also stored in the context, for later use. The class needs a public no-argument constructor and setters for the parameters, and, as for every object that OGNL touches, its package must be allowlisted: with a helper class in a package that was not (org.example.other.Helper), the tag created the object but its param setter and its getter were refused (is not allowlisted in the log) and the properties printed nothing. The attributes are name (required, the class name) and var.

BE1 [<s:bean name="org.example.bookshelf.model.Reader" var="reader"><s:param name="name" value="'Ana'"/><s:param name="city">Lima</s:param><s:property value="name"/>/<s:property value="city"/></s:bean>] [<s:property value="#reader.name"/>] [<s:property value="name"/>]
BE3 [<s:bean name="org.example.bookshelf.model.Reader" var="r2"><s:param name="name" value="%{filter}"/></s:bean><s:property value="#r2.name"/>]
BE1 [Ana/Lima] [Ana] []
BE3 [all]

Inside the body the bean is on top of the stack (name and city read its properties); outside, the stack is popped (the third value of BE1 is empty) and the var is the way back (#reader.name). The bean tag is a view-side object factory and it creates objects in the view that no action tests; use it for a helper such as a formatter or a counter, not for domain objects. A class that cannot be loaded is a silent failure: with name="org.example.bookshelf.NoSuch" the log had a ClassNotFoundException, the tag wrote nothing, and every expression after it on the page, even <s:property value="filter"/>, printed nothing (page bd), because the failed bean was left on the stack. A page that suddenly goes blank after a bean tag has a mistyped class name.

The param tag parameterises the tag that contains it: bean, include, url, a, text, action, append, merge and component are the tags that read it. It has name, value and suppressEmptyParameters. The guide stresses a difference between the two ways to give a value, which is rule 1 versus rule 2 in a new place:

<s:param name="color">blue</s:param>       <%-- (A) body: the text "blue", a java.lang.String --%>
<s:param name="color" value="blue"/>       <%-- (B) attribute: the OGNL expression "blue", an Object --%>

The second form is the one that catches people out. value is an expression, blue is not a property, so the value is null; to pass the text it must be a literal, value="'blue'", or the body form. On the example page:

PA3 [<s:bean name="org.example.bookshelf.model.Reader" var="r5"><s:param name="city" value="Lima"/></s:bean><s:property value="#r5.city"/>]
    [<s:bean name="org.example.bookshelf.model.Reader" var="r6"><s:param name="city" value="'Lima'"/></s:bean><s:property value="#r6.city"/>]
    [<s:bean name="org.example.bookshelf.model.Reader" var="r7"><s:param name="city" value="%{'Lima'}"/></s:bean><s:property value="#r7.city"/>]
PA3 [] [Lima] [Lima]

The empty first value is null from the unquoted Lima. The same mistake appears in the FAQ of the guide about locale links (<s:param name="request_locale" value="en" />), where the quotes are missing. suppressEmptyParameters leaves an empty parameter out of the URL, see the url tag below.

action

action executes an action from within a view, and optionally renders its result in place: a way to compose a page from the output of several actions (a "portlet" of the shelf’s statistics on every page). Its attributes are name (required, the action name without extension), namespace (default: the namespace of the page), executeResult (default false), var, flush (default true), ignoreContextParams (default false, so the request parameters of the page are passed to the inner action) and rethrowException (default false). Nested param tags give parameters to the action.

AC1 [<s:action name="stats" executeResult="false" var="st1"/><s:property value="#st1.total"/>] [<s:property value="#st1.genre"/>]
AC2 [<s:action name="stats" executeResult="true"/>]
AC4 [<s:action name="stats" var="st4" executeResult="false"><s:param name="genre" value="'scifi'"/>inside=<s:property value="#st4 == null"/></s:action> outside=<s:property value="#st4 == null"/> genre=<s:property value="#st4.genre"/>]
AC8 [<s:action name="stats-featured" var="st8" executeResult="false"/><s:property value="#attr.note"/>]
AC1 [4] [none]
AC2 [STATS total=4 genre=none heading=Shelf statistics note=]
AC4 [inside=true outside=false genre=scifi]
AC8 [set by featured()]

AC1 runs the action and reads its properties through the var; AC2 runs it and writes the result (the JSP stats.jsp) into the page; AC4 confirms the guide’s "Placement in context" rule, the action is published to the context only when the whole tag has finished, so inside the body #st4 is still null and outside it is not; AC8 shows a method call that works.

Three behaviours of this tag differ from what the guide’s text implies:

  • The guide and its FAQ say that a method other than execute is selected with the "bang" syntax, <s:action name="actionTagAction!specialMethod" executeResult="true"/>. In Struts 7.4.0 dynamic method invocation is off (struts.enable.DynamicMethodInvocation=false in default.properties), and stats!featured did not call featured() (the note attribute stayed empty, AC3); a mapping with method="featured" (the stats-featured action of the example) is the form that works (AC8). Wildcard mappings with {1} give the same effect for many methods; the page Wildcard Mappings & Method Invocation has the details.

  • An exception thrown by the inner action does not stop the page by default: with rethrowException left at false the tag logs the error and goes on (AC5, the boom action: the variable was set and the page continued). With rethrowException="true" the exception propagates out of the tag, the page stops where it is (the browser received the text before the tag with status 200 and the container then reported that sendError() could not be called on a committed response).

  • An inner action runs the full interceptor stack, including validation and the workflow interceptor, which is why the FAQ says that an action tag whose action has validation errors does not execute its method; the default stack excludes input from validation, so !input is the documented way around it.

The action tag is a convenience with a cost: it executes a whole request cycle inside the page, so a page with ten of them runs ten action stacks. For a reusable fragment of a page a JSP include or a tag file is lighter (see the include tag below, and the Part I page Tag Files & Layouts).

text and i18n

text renders an internationalised message from a resource bundle, i18n puts another bundle on the stack for its body. The lookup order of the bundles and the locale selection are the subject of the Localization and i18n page, which also describes the key attribute of the form tags; this section is the tag side. text has name (required, the key), var and the four escape flags (all false by default, which differs from property: messages are not HTML-escaped).

TX1 [<s:text name="book.heading"/>] [<s:text name="shelf.count"><s:param value="count"/></s:text>] [<s:text name="shelf.price"><s:param value="balance"/></s:text>] [<s:text name="no.such.key"/>] [<s:text name="no.such.key">fallback body</s:text>]
TX2 [<s:text name="book.heading" var="hd"/>after-var:<s:property value="#hd"/>]
TX3 [<s:i18n name="org.example.bookshelf.Messages"><s:text name="welcome"/> | <s:text name="shop.hours"><s:param>9</s:param><s:param>17</s:param></s:text></s:i18n>]
# ShelfAction.properties
book.heading=Books on the shelf
shelf.count=The shelf holds {0} books
shelf.price=Price: {0,number,currency}
# Messages.properties
welcome=Welcome to the Bookshelf
shop.hours=Open from {0} to {1}
TX1 [Books on the shelf] [The shelf holds 4 books] [Price: $1,234.50] [no.such.key] [fallback body]
TX2 [after-var:Books on the shelf]
TX3 [Welcome to the Bookshelf | Open from 9 to 17]

Nested param tags fill the {0}, {1} placeholders of the MessageFormat pattern, a missing key prints the key, or the body when the tag has one, and a text with a var writes nothing and stores the text. The text is written without HTML escaping (add escapeHtml="true" if a bundle can contain markup you did not write). Three variations are different from the guide’s text:

  • In the Text tag page, <s:property value="text('aaa.bbb.ccc')"/> inside s:i18n is shown as the way to read the pushed bundle; on 7.4.0 text('...') printed nothing, getText('welcome') printed the key welcome (the action does not know the pushed bundle) and s:text is the tag that works.

  • A tag attribute cannot contain a tag: <s:textfield label="<s:text name="person.lastName"/>"/> is invalid JSP. Use label="%{getText('person.lastName')}", with the notation, as the previous section showed.

  • In the example the shelf.price pattern uses the default locale ($ on this JVM); a pattern like {0,number,currency} follows the locale of the request.

date

date formats a Date (or java.time type) that is on the stack, in one of three ways: a format pattern, the nice relative form ("in 2 hours, 14 minutes"), or, with neither, the struts.date.format key of the bundles and finally the DateFormat.MEDIUM format of the locale. name (required) is the date expression, format, nice (default false), timezone and var are optional. If the value is missing from the stack the tag writes nothing.

DT1 [<s:date name="published" format="dd/MM/yyyy" timezone="UTC"/>] [<s:date name="published" format="yyyy-MM-dd'T'HH:mm" timezone="UTC"/>] [<s:date name="nothing"/>]
DT2 [<s:date name="published" nice="true"/>]
DT3 [<s:date name="published" format="yyyy" var="py"/>var:<s:property value="#py"/>]
DT1 [13/09/1965] [1965-09-13T00:00] []
DT2 [61 years, 42 days ago]
DT3 [var:1965]

nice="true" produces the relative text with the struts.date.format.past, future, seconds, minutes, hours, days and years keys ({0} ago, in {0} and the choice patterns of the table in the guide), which are the keys to override in a bundle to translate it; the DT2 output changes with the day it is requested. The formatter is selected by the constant struts.date.formatter (default dateTimeFormatter, the java.time formatter; simpleDateFormatter selects the legacy one), and the patterns of the two differ slightly in letters, so a pattern that worked with SimpleDateFormat needs a check when the formatter is changed. The Localization and i18n page has the traps of a date pattern placed in a bundle (a MessageFormat passes over it first and swallows a single quote).

number

The number tag formats a number. It is in the 7.4.0 tag library descriptor and the org.apache.struts2.components.Number class, and it is not in the Tag Reference’s list of generic tags or on a page of its own (the address tag-developers/number-tag.html answers 404), so the TLD is the documentation. Its attributes are name (required, the number expression), type (currency, integer, number or percent, default number), currency (a currency code), groupingUsed, minimumFractionDigits, maximumFractionDigits, minimumIntegerDigits, maximumIntegerDigits, parseIntegerOnly, roundingMode (ceiling, down, floor, half-down, half-even, half-up, unnecessary, up) and var.

NU1 [<s:number name="balance"/>] [<s:number name="balance" type="currency" currency="EUR"/>] [<s:number name="0.256" type="percent"/>]
    [<s:number name="balance" maximumFractionDigits="0" roundingMode="half-up"/>] [<s:number name="balance" type="integer"/>]
    [<s:number name="balance" groupingUsed="false"/>]
NU1 [1,234.5] [€1,234.50] [26%] [1,235] [1,234] [1234.5]

The formatting is locale-aware (en_US here); the integer type truncates by the RoundingMode of the format (1,234 from 1234.5 with the default half-even rule), while roundingMode="half-up" with maximumFractionDigits="0" printed 1,235.

include

include writes the output of a servlet or a JSP page into the page; value (required) is the path. Parameters are nested param tags and become request parameters of the included resource.

IN1 [<s:include value="/WEB-INF/views/included.jsp"/>]
IN2 [<s:include value="/WEB-INF/views/included.jsp"><s:param name="title" value="books[0].title"/><s:param name="who">Ana</s:param></s:include>]
INC title=${param.title} who=${param.who} / stackfilter=[<s:property value="filter"/>] / nostack=[<s:property value="#attr.title"/>]
IN1 [INC title= who= / stackfilter=[all] / nostack=[]]
IN2 [INC title=Dune who=Ana / stackfilter=[all] / nostack=[]]

Parameters are read in the included page with ${param.title}, as the guide explains in "How to access parameters" (and its s:set var=... scope="page" examples copy them into the OGNL world): they are not properties on the stack and <s:property value="#attr.title"/> is empty. The guide says no value stack is created for the included page, so that s:property cannot read the parameters; on 7.4.0 the page still sees the request’s value stack (filter of the action printed all inside the included JSP, which is the same request), so the sentence is about the parameters, not about the stack. Compare with the standard jsp:include and c:import of the Part I page Includes, Forwards & Page Composition, which have the same request but not the OGNL view; a tag file is the better unit of reuse when the fragment has parameters of its own.

url and a: Building Links

url creates a URL, a creates an HTML <a> with the same attributes. They know the action mapping: action (a name, with optional namespace and method) is turned into the right path with the context path and the extension, and the parameters are URL-encoded.

Table 13. Attributes of the url tag (7.4.0 TLD; the a tag has the same ones plus href and the HTML attributes)
Attribute Required Default Description

action

no

-

The action to generate the URL for, if not using value

value

no

-

The target, if not using action; a path, resolved against the context

namespace, method

no

the namespace of the page, none

The namespace and the method of the action

var

no

-

Variable that receives the URL; also in the request scope

anchor

no

-

The #fragment

includeParams

no

none

none, get or all: which request parameters of the current request are added

includeContext

no

true

Whether the context path is included

encode

no

true

Whether to encode (session id rewriting)

escapeAmp

no

true

Whether & in the query string is written as &

forceAddSchemeHostAndPort, scheme

no

false, none

Make the URL absolute

portletMode, portletUrlType, windowState

no

-

Portlet support, ignored in a servlet container

The page data (lines UR1 to UR8, requested with a cookie jar, so no ;jsessionid= is appended) prints:

UR1 [<s:url action="stats"/>] [<s:url value="/img/logo.png"/>] [<s:url value="logo.png"/>]
UR2 [<s:url action="stats"><s:param name="genre" value="'sci fi'"/></s:url>]
UR3 [<s:url action="stats" var="u"><s:param name="id" value="1"/><s:param name="x" value="2"/></s:url><s:property value="#u"/>] [<s:property value="#u" escapeHtml="false"/>] [${u}] [<s:url action="stats" escapeAmp="false"><s:param name="id" value="1"/><s:param name="x" value="2"/></s:url>]
UR4 [<s:url action="stats" anchor="top"/>] [<s:url action="stats" includeContext="false"/>] [<s:url action="stats" forceAddSchemeHostAndPort="true"/>] [<s:url action="stats" scheme="https" forceAddSchemeHostAndPort="true"/>]
UR5 [<s:url includeParams="get"><s:param name="id" value="'22'"/></s:url>] [<s:url includeParams="all" action="stats"/>] [<s:url includeParams="none"/>]
UR6 [<s:url action="stats"><s:param name="g" value="titles"/></s:url>]
UR8 [<s:url action="stats"><s:param name="a" value="nothing"/><s:param name="b" value="nothing" suppressEmptyParameters="true"/><s:param name="d" value="''"/></s:url>]
UR1 [/g10tags/stats.action] [/g10tags/img/logo.png] [logo.png]
UR2 [/g10tags/stats.action?genre=sci+fi]
UR3 [/g10tags/stats.action?id=1&amp;amp;x=2] [/g10tags/stats.action?id=1&amp;x=2] [/g10tags/stats.action?id=1&amp;x=2] [/g10tags/stats.action?id=1&x=2]
UR4 [/g10tags/stats.action#top] [/stats.action] [http://localhost:18080/g10tags/stats.action] [https://localhost/g10tags/stats.action]
UR5 [/g10tags/data.action?q=zz&amp;id=22] [/g10tags/stats.action?q=zz] [/g10tags/data.action]
UR6 [/g10tags/stats.action?g=Dune&amp;g=Neuromancer&amp;g=Hyperion&amp;g=Emma]
UR8 [/g10tags/stats.action?a=&amp;d=]

What the outputs teach:

  • action produces the context path, the action name and the extension (.action); value with a leading slash is context-relative and without it is left as written (UR1, logo.png). A value that starts with a scheme (http://example.org/p) is used as is.

  • The parameters are encoded (a space is +) and, by default, the & between parameters is written as &, correct inside an HTML attribute (UR3). When the URL goes through a s:property that escapes again, the result is double-escaped (&amp;); the guide says to set escapeAmp="false" for that case, and the equivalent is to print the variable with escapeHtml="false" or to read it as ${u}, which is not escaped by the tag at all. For a URL in a JavaScript string or a <meta refresh> choose the flag that matches the context.

  • includeParams is the cause of the question of the FAQ, "why are request parameters appended to our hyperlinks": it copies the parameters of the current request into the generated URL. The default is none since Struts 2.1.3 (constant struts.url.includeParams; the FAQ still says the default is GET), get adds the query-string parameters (UR5: q=zz) and all adds all of them; a parameter given with a nested param takes precedence over an included one (the guide’s Example 3, id=22). Never build a URL from user input with includeParams unless the values are meant to be passed on.

  • A parameter whose value is an array or an Iterable is repeated for each element (UR6), and a null value is written as an empty parameter (UR8, a=) unless suppressEmptyParameters="true" on the param (the parameter b is gone).

  • forceAddSchemeHostAndPort makes the URL absolute (UR4); the port of scheme="https" is the one of the constant struts.url.https.port (443, so it is omitted), and struts.url.http.port is 80.

  • The encode attribute removes the session-id rewriting of response.encodeURL(): a first request without a cookie got ;jsessionid=... on every action URL and on /img/logo.png, and a <s:url value="/x" encode="false"> got none, while the parameters were still encoded.

A1 [<s:a action="stats">Stats</s:a>] [<s:a href="/x/y.html">Plain</s:a>] [<s:a action="stats" cssClass="btn" id="st" title="Go" anchor="top"><s:param name="genre" value="'scifi'"/>Go</s:a>]
A2 [<s:url var="du" action="stats"><s:param name="genre" value="'x'"/></s:url><s:a href="%{#du}">By var</s:a>]
A4 [<s:a action="stats" onclick="alert(1)" data-role="x" cssStyle="color:red">dyn</s:a>]
A1 [<a href="/g10tags/stats.action">Stats</a>] [<a href="/x/y.html">Plain</a>] [<a id="st" href="/g10tags/stats.action?genre=scifi#top" class="btn" title="Go">Go</a>]
A2 [<a href="/g10tags/stats.action?genre=x">By var</a>]
A4 [<a href="/g10tags/stats.action" style="color:red" onclick="alert(1)" data-role="x">dyn</a>]

The a tag takes the attributes of url (A1 and A2), an explicit href (taken literally, with no context path, A1) and the HTML ones: id, cssClass, cssStyle, title, the on... events and any dynamic attribute (data-role, A4). The guide’s recommendation is to build a URL with several parameters in a s:url with a var and give the variable as href="%{#du}" (A2), which keeps the parameters readable. The Getting Started tutorial introduces the tag the simple way (a plain HTML a with the URL of an s:url inside the href):

<p><a href="<s:url action='hello'/>">Hello World</a></p>
<s:url action="hello" var="helloLink">
  <s:param name="userName">Bruce Phillips</s:param>
</s:url>
<p><a href="${helloLink}">Hello Bruce Phillips</a></p>

The second link reads the variable with the JSP EL ${helloLink} because url with var stores the URL in the request scope too. The tutorial notes that the query-string parameter is URL-encoded (Bruce+Phillips).

debug

debug prints the contents of the value stack and the context, but only if struts.devMode is on; otherwise it writes nothing. On the example WAR in production mode the output of <s:debug/> was empty (page data, line DB1); a second build of the same WAR with struts.devMode=true printed a [Debug] link, a hidden div and a "Struts ValueStack Debug" table with the stack contents and the property names, values and classes of the action. The script that toggles the div carries a CSP nonce.

The tag shows the stack exactly as OGNL sees it, which is the quickest way to find out why an expression prints nothing; it must never be left on a deployed page, because the table lists every property of the action. The struts.devMode constant is documented on the Configuration: struts.xml & Constants page of this section.

Asset Tags: compress, script, link and webjar

The newest generic tags look after the static parts of a page: its whitespace, its scripts and styles and the third-party libraries. They are what a Struts page needs to stay compatible with a Content Security Policy.

script writes a <script> element and link writes a <link> element; both automatically add a nonce attribute when one is available, and are meant to be used with Struts' CSP interceptor. In Struts 7.4.0 the interceptor csp is part of the defaultStack (the stack trace of an error page lists CspInterceptor between i18n and prepare), so the page below, with no configuration at all, got a nonce, and the response had the header:

Content-Security-Policy-Report-Only: object-src 'none'; script-src 'nonce-HtixKojr6dS0VH8PCp552muX' 'strict-dynamic' http: https:; base-uri 'none';

The default mode of the interceptor is report only, so the browser reports violations and does not block; the constants struts.csp.nonce.source (session, the default, or request) and struts.csp.report.maxSize (8192) are in default.properties (since 7.4.0 the older name struts.csp.nonceSource is deprecated), and the interceptor’s enforcingMode parameter switches to blocking. The page Security Headers: CSP, COOP, COEP & Fetch Metadata has the complete treatment of the CSP, Fetch Metadata and COOP/COEP interceptors.

<s:url var="myCss" value="/styles/my-style.css" encode="false"/>
<s:link href="%{myCss}" rel="stylesheet" type="text/css" media="all"/>
<s:webjar var="jq" path="jquery/jquery.min.js"/>
<s:script src="%{jq}"/>
<s:script>
  var shelf = '<s:property value="filter"/>';
</s:script>
<s:script async="true" defer="true" type="module" src="/app.js" id="appjs"/>
<link rel="stylesheet" type="text/css" href="/g10tags/styles/my-style.css" media="all" nonce="3E9WuxhDqlqNwhvpWf2Jg7ov"/>
<script src="/g10tags/static/webjars/jquery/3.7.1/jquery.min.js" nonce="3E9WuxhDqlqNwhvpWf2Jg7ov">
</script>
<script nonce="3E9WuxhDqlqNwhvpWf2Jg7ov">
var shelf = 'all';</script>
<script async defer type="module" src="/app.js" nonce="3E9WuxhDqlqNwhvpWf2Jg7ov">
</script>

The attributes of script are the HTML ones (src, async, defer, type, nomodule, integrity, crossorigin, referrerpolicy, charset) plus id and dynamic attributes; those of link are href, rel, type, media, as, crossorigin, hreflang, referrerpolicy, sizes and the dynamic ones. The page notes that both inherit all the fields of the Component class, so some attributes the TLD lists (theme, template) have no effect on them. Two details in the output: the s:property inside the script body is expanded and escaped for HTML (a title with a quote would show as ", so a value for JavaScript needs escapeJavaScript="true" and escapeHtml="false"), and the element is written with its body on separate lines. The nonce is the same for every element of a page and is new for every request: three requests with the same session cookie printed three different nonces. With the default source session it is generated on each request and stored in the session attribute nonce (only for a session that already exists), with request in a request attribute.

webjar

webjar (since Struts 7.3.0) resolves a version-less WebJar path to a servable URL. A WebJar packages client-side libraries as a JAR that has its assets under META-INF/resources/webjars/<name>/<version>/; with the library on the class path as a Maven dependency, the tag writes the URL with the version that is present, so the page never repeats it and a dependency upgrade needs no change in the templates. Struts serves the assets under /static/webjars/** (the constant struts.ui.staticContentPath and the master switch struts.webjars.enabled, default true, an optional struts.webjars.allowlist of names), and resolves the version with webjars-locator-lite, which struts2-core bundles.

<link rel="stylesheet" href="<s:webjar path="bootstrap/css/bootstrap.min.css"/>"/>
W1 [<s:webjar path="bootstrap/css/bootstrap.min.css"/>] [<s:webjar path="jquery/jquery.min.js"/>] [<s:webjar path="nosuch/x.js"/>] [<s:webjar path="../etc/passwd"/>]
<link rel="stylesheet" href="/g10tags/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css"/>
W1 [/g10tags/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css] [/g10tags/static/webjars/jquery/3.7.1/jquery.min.js] [] []

An unknown WebJar and a path with .. both produced an empty string (the page of the Core Developer Guide says unresolved, traversal and allowlist-blocked requests all fail closed: a 404 when serving and empty output when building URLs). The tag has the attributes path (required) and var, and composes with script, link and a raw <link> or <script>, as in the listing above.

compress

compress (since Struts 7.2.0) removes the whitespace between the tags of its body while keeping the content, to reduce the size of generated HTML. It is enabled globally by struts.tag.compress.enabled (default true) and is switched off automatically in development mode so that the output stays readable; force="true" overrides that. The body size is limited by struts.tag.compress.maxSize (default 10485760, 10 MB, content above it is skipped without compression) and the log lines show at most struts.tag.compress.log.maxLength (200) characters of the body, so sensitive text is not written to the log.

<s:compress>
   <div>
      <p>Compressed    text</p>
      <span>between tags</span>
   </div>
   <pre>
     keep   this
   </pre>
</s:compress>
<s:compress force="true"><ul>
   <li>one</li>
   <li>two</li>
</ul></s:compress>
<div><p>Compressed    text</p><span>between tags</span></div><pre>
     keep   this
   </pre>
<ul><li>one</li><li>two</li></ul>

Whitespace between tags is removed and whitespace inside text and inside <pre> is kept, so the tag does not change what a browser renders. In the devMode build the first block was left alone and the second, with force="true", was compressed. The documentation lists the safeguards: bodies above the size limit are left as they are, the regular expressions have a ReDoS guard with a hard 50 MB limit and the log text is truncated. The saving is small for pages that are served compressed by the container or a proxy; the tag is for pages generated with much indentation.

Mixing Struts Tags with JSTL and EL

Struts tags and JSTL (Jakarta Tags 3.0, jakarta.tags.core) coexist on a page, and the Getting Started tutorial uses plain ${...} in the same page as an s:url. What makes it work is that Struts wraps the request in StrutsRequestWrapper whose getAttribute() falls back to the value stack, so JSP EL names resolve to action properties. The experiments of the page jstl (the Tags 3.0 jars of the example WAR, prefix c):

<%@ taglib prefix="s" uri="/struts-tags" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
J1 [<c:forEach items="${books}" var="b">${b.title};</c:forEach>]
J2 [${filter}] [${count}] [${books[0].label}] [<c:out value="${markup}"/>] [${markup}]
J3 [<s:set var="sv" value="'from-s-set'"/>${sv}] [<c:set var="cv" value="from-c-set" scope="request"/><s:property value="#request.cv"/>] [<s:property value="#attr.cv"/>] [<s:property value="cv"/>] [<s:property value="#cv"/>]
J5 [<s:iterator value="books" var="b2">${b2.title}/<s:property value="title"/>;</s:iterator>]
J6 [<s:iterator value="books"><c:out value="${title}"/>,</s:iterator>]
J8 [<c:if test="${count > 3}">c-if</c:if>] [<s:if test="#attr.cv == 'from-c-set'">s-if</s:if>]
J1 [Dune;Neuromancer;Hyperion;Emma;]
J2 [all] [4] [Dune (1965)] [&lt;b&gt;Dune&lt;/b&gt; &amp; &#034;more&#034;] [<b>Dune</b> & "more"]
J3 [from-s-set] [from-c-set] [from-c-set] [] []
J5 [Dune/Dune;Neuromancer/Neuromancer;Hyperion/Hyperion;Emma/Emma;]
J6 [Dune,Neuromancer,Hyperion,Emma,]
J8 [c-if] [s-if]

The combinations that were verified and those that were not:

Table 14. JSP EL, JSTL and Struts tags compared (page jstl)
Goal Struts JSTL and EL

Read an action property

<s:property value="filter"/>

${filter} works through the request wrapper

Loop over the action’s list

<s:iterator value="books">

<c:forEach items="${books}" var="b"> works (J1)

Variable set by a Struts tag, read in EL

<s:set var="sv" .../> then ${sv} (J3)

works

Variable set by c:set, read in OGNL

#request.cv or #attr.cv (J3)

bare cv and #cv printed nothing: the JSTL variable is an attribute, not a property of the stack

Variable or element of a Struts loop in EL

var="b2", then ${b2.title} (J5); the current element’s properties as bare names

works; inside the loop ${title} read the property of the current element (J6)

Escaping

property escapes by default

${markup} does not escape, c:out does (" for ")

Expression in a tag attribute

%{...} (OGNL)

${...} (EL) in JSTL and plain tags only; in a Struts attribute it is a translation error

Two rules keep the mixture sane. First, escape on output: property escapes by default and ${x} does not, so a user-supplied value read with EL needs c:out or fn:escapeXml, whereas a property is already safe. Second, pick one language per expression: do not try to pass EL into a Struts attribute, and when a JSTL variable is needed by a Struts tag read it with #attr.name or %{#attr.name}. The OGNL page of the Tag Developer Guide has the same example in the other direction: <c:set var="foo" value="bar" scope="request"/> followed by <s:textfield name="username" label="%{#request.foo}"/> (which also shows how to give a Struts tag a value from EL). The Part I pages JSTL Core and Expression Language Basics are the reference for the JSTL side.

The Same Tags in FreeMarker and Velocity

The JSP tag library is one of three bindings of the same components. In a FreeMarker template the tags are called as user-defined directives with the s namespace, <@s.tag attributes>...</@s.tag>, and in a Velocity template as macros with an s prefix (#sproperty(...), see the Velocity Tags page); the attributes and the evaluation are those of this page. The page FreeMarker & Velocity Views has the FreeMarker and Velocity results, the attributes map that replaced parameters in 7.x and how to call third-party JSP tag libraries from FreeMarker. A view rendered through the freemarker result on the example WAR (the ftl action) printed the same values as the JSP tags:

<#-- FreeMarker view using the Struts tags -->
F1 [<@s.property value="filter"/>]
F2 [<@s.iterator value="titles"><@s.property/>,</@s.iterator>]
F3 [<@s.if test="count > 3">many<@s.else>few</@s.else></@s.if>]
F4 [<@s.url action="stats" var="u"><@s.param name="genre" value="'scifi'"/></@s.url>${u}]
F5 [<@s.text name="book.heading"/>] [${filter}] [${books[0].title}]
F7 [<#list books as b>${b.title};</#list>]
F9 [<@s.property value="markup"/>] [${markup}] [${markup?no_esc}]
F1 [all]
F2 [Dune,Neuromancer,Hyperion,Emma,]
F3 [many]
F4 [/g10tags/stats.action?genre=scifi]
F5 [Books on the shelf] [all] [Dune]
F7 [Dune;Neuromancer;Hyperion;Emma;]
F9 [&lt;b&gt;Dune&lt;/b&gt; &amp; &quot;more&quot;] [&lt;b&gt;Dune&lt;/b&gt; &amp; &quot;more&quot;] [<b>Dune</b> & "more"]

(FreeMarker auto-escapes ${markup}, so the three escape forms line up with the JSP ones: property and the FreeMarker interpolation are escaped, ?no_esc is not. The value of ${filter} in F5 printed the action’s filter as all.) The Velocity binding is the topic of the Velocity Tags page, Tag Developer Guide — Velocity Tags.

Worked Example: The Bookshelf Catalogue Page

The pieces fit together in one page, catalogue.jsp, which shows the shelf: a heading and a count from the bundle, the books in a striped table with their prices, a link per row to the statistics of its genre, the date of the last update and a reload link that keeps the request parameters. Only generic tags are used, and every concept of the page was introduced above.

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html>
<head>
<title><s:text name="book.heading"/></title>
</head>
<body>
<h1><s:text name="book.heading"/></h1>
<p><s:text name="shelf.count"><s:param value="books.size"/></s:text>
   (filter: <s:property value="filter" default="none"/>)</p>
<s:if test="books.empty">
  <p>The shelf is empty.</p>
</s:if>
<s:else>
  <table>
    <s:iterator value="books" status="st">
      <tr class="<s:if test="#st.odd">odd</s:if><s:else>even</s:else>">
        <td><s:property value="#st.count"/><s:if test="#st.last"> (last)</s:if></td>
        <td><s:property value="title"/></td>
        <td><s:property value="author"/></td>
        <td><s:number name="price" type="currency" currency="EUR"/></td>
        <td><s:a action="stats" cssClass="more"><s:param name="genre" value="genre"/>more</s:a></td>
      </tr>
    </s:iterator>
  </table>
</s:else>
<p>Updated <s:date name="published" format="yyyy-MM-dd" timezone="UTC"/>
 - <s:url var="self" includeParams="get"/><s:a href="%{#self}">reload</s:a></p>
</body>
</html>

Requested with curl on /g10tags/catalogue?filter=scifi (with a cookie jar) the page printed (the first two rows and the end are quoted):

<h1>Books on the shelf</h1>
<p>The shelf holds 4 books
   (filter: scifi)</p>
  <table>
      <tr class="odd">
        <td>1</td>
        <td>Dune</td>
        <td>Frank Herbert</td>
        <td>€9.50</td>
        <td><a href="/g10tags/stats.action?genre=scifi" class="more">more</a></td>
      </tr>
      <tr class="even">
        <td>2</td>
        <td>Neuromancer</td>
        <td>William Gibson</td>
        <td>€8.25</td>
        <td><a href="/g10tags/stats.action?genre=cyberpunk" class="more">more</a></td>
      </tr>
      ...
      <tr class="even">
        <td>4 (last)</td>
        <td>Emma</td>
        <td>Jane Austen</td>
        <td>€6.00</td>
        <td><a href="/g10tags/stats.action?genre=classic" class="more">more</a></td>
      </tr>
  </table>
<p>Updated 1965-09-13
 - <a href="/g10tags/catalogue.action?filter=scifi">reload</a></p>

Reading the page against the page’s rules: the heading and the count use text (no escaping, from a bundle); the count is books.size, the property form that the allowlist permits; books.empty guards the table; the iterator’s status gives the stripes (odd, which follows the count) and the "(last)" mark; the param inside a is evaluated with the book on the stack, so value="genre" is the genre of the current row; the number tag formats the price with a currency code; the date has an explicit format and a time zone (the same text on every server); and url with includeParams="get" carries the filter parameter on to the reload link. Nothing in the page is a scriptlet and no value is written without escaping.

Pitfalls

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

attribute [value] does not accept any expressions at translation (observed)

A JSP EL ${...} in an attribute of a Struts tag: every TLD attribute has rtexprvalue=false

Use %{...} or a plain OGNL expression

A form field or label shows the literal text that was written, for example getText('x') or filter (observed)

A String attribute (value, label) is literal without the %{...} notation

Write value="%{filter}", label="%{getText('x')}"

s:if is false for one == 'a' when one is the string a (observed)

A single-quoted literal of one character is a char in OGNL

test='one == "a"', or one.length == 1

s:if prints the wrong branch for a property that holds text (observed with filter)

A string that is not true converts to false; a one-character literal converts to true

Test a boolean property or a comparison

books.size(), books.isEmpty() or books.get(0) print nothing (observed)

The declaring JDK class is not allowlisted

Use books.size, books.empty, books[0]

A property of the model prints nothing and the log says is not allowlisted

The model package is not in struts.allowlist.packageNames

Allowlist the model package or class

<s:param name="x" value="blue"/> passes null (observed)

value is an expression; blue is not a property

value="'blue'" or the body <s:param name="x">blue</s:param>

Every expression after an s:bean prints nothing (observed)

The class name of the bean cannot be loaded (ClassNotFoundException in the log); the failed bean stays on the stack

Fix the class name; look at the log

<s:append> stops at an empty list (observed)

An empty iterable in the sequence ends the append

Drop empty lists, or use merge

A sort or subset result is empty with #name (observed)

With var and no body the result is in the page scope

Read it with #attr.name or ${name}

<s:set scope="request" var="x"> is not found by #x (observed)

#x reads the context map; a request-scope variable is a request attribute

#request.x or #attr.x

The URL printed through s:property shows &amp; (observed)

url already escapes & and property escapes again

escapeAmp="false" on url, escapeHtml="false" on property, or ${u}

Extra parameters in every link, or request_locale=en&request_locale=en

includeParams copies the parameters of the current request

Leave the default none; do not put a query string in action

<s:action name="a!method"> does not call the method (observed)

Dynamic method invocation is off in 7.x

Map the method (method="featured") or use a wildcard

The page stops half way after an s:action (observed with rethrowException="true")

The inner action threw and the exception propagated

Handle the error in the action, or leave rethrowException at false

s:include page cannot read <s:property value="#attr.title"/> (observed)

Include parameters are request parameters

${param.title} in the included page

s:date prints nothing

The value is null or not on the stack, or the pattern is invalid (an invalid pattern fails the page)

Check the property; test the pattern with java.time

s:text shows the key (observed)

The key is in no bundle of the lookup order

Add the key, or give a body as the default

<s:debug/> prints nothing

Not in devMode

Turn struts.devMode on in development only

s:iterator over a user-supplied value that is not a collection loops once

A non-iterable value is wrapped into a one-element list

Test the type in the action

s:script has no nonce (when it is expected)

The csp interceptor is not in the stack of the action

Keep defaultStack, or add the interceptor to a custom stack

An action property with the name session, request, parameters or struts confuses the page

The names are the context map’s

Rename it

What Changed Recently

Table 16. Tag library changes by Struts version (from the Tag Developer Guide pages, the 7.4.0 jar and the migration notes)
Version Change

2.1.x

id replaced by var on the tags that store a variable (iterator, url, …​); begin, end and step of iterator from 2.1.7

2.1.3

struts.url.includeParams defaults to none

WebWork 2.2

The %{...} notation (altSyntax) on by default

2.6

The old syntax and its switches removed (WW-3877); FreeMarker auto-escaping on, escapeHtmlBody for a, submit and component and the constant struts.ui.escapeHtmlBody

6.4.0

IteratorStatus.countStr and indexStr for locale-independent digits

7.x

FreeMarker templates read attributes where they read parameters before (see the page on FreeMarker and Velocity views); the Struts classes are in org.apache.struts2.*

7.2.0

The compress tag and the struts.tag.compress.* constants

7.3.0

The webjar tag and the struts.webjars.* constants; static content serves WebJars under /static/webjars/**

7.4.0

struts.csp.nonce.source replaces the deprecated struts.csp.nonceSource (the nonce that script and link write); struts.ui.html5.constraints for the html5 theme (see the UI tags page); the TLD still declares tlib-version 6.0 and has the number tag that the reference page does not list

Where the Books Are Outdated

The books of this section teach the right model for the tags: the stack is the root, # reaches the context, %{} forces evaluation, the control tags drive a loop, the data tags read and write values. What changed since they were written:

Table 17. Tag material in the books and what is current
In the books Struts 7.4.0

The "OGNL everywhere" syntax with value="'literal'" on every attribute (the pre-2.2 syntax), or the %{} syntax described as an option (altSyntax)

The notation %{...} is the only syntax since 2.6; String attributes are literal text with embedded expressions, non-String attributes are expressions

<s:iterator id="x" value="..."> and <s:url id="u">

var; id was deprecated in 2.1.x

Struts dojo/ajax tags (s:div, s:autocompleter, s:datetimepicker), the ajax theme and the Dojo plugin

Not in the 7.4.0 TLD; plain fetch, htmx or the JSON plugin do the Ajax work (see the page on the JSON plugin and Ajax)

<s:action name="x!method"/> and <s:a action="x!method">

Dynamic method invocation is off by default; map the method or use a wildcard

<s:if test="%{...}"> with a method call on a collection (size(), isEmpty())

Refused by the OGNL allowlist for JDK collection classes; use the property forms (size, empty, [0])

<s:url action="x?a=1">, a query string inside action

Use param tags; the Tag Developer Guide FAQ says the URL tag is not meant for a query string

<s:include> and <s:component> as the mechanisms of page composition

Still present; a JSP tag file or a FreeMarker macro is the lighter unit of reuse, and layouts belong to a layout engine (see the page on Tiles and page layouts)

No mention of a nonce, a CSP or a WebJar

s:script, s:link and s:webjar for pages that live under a Content Security Policy

Inconsistencies in the Official Documentation

Collected while verifying this page against the 7.4.0 jar, default.properties and the running examples; none of them changes behaviour, but each can mislead:

  • The Tag Reference page lists 26 generic tags; the 7.4.0 TLD has 27: number (class NumberTag, component org.apache.struts2.components.Number) has no reference page, and tag-developers/number-tag.html answers 404.

  • The Tag Syntax page says the property names parameters, session, request, application, struts, servletRequest and servletResponse are disallowed and that <s:iterator value="parameters"/> "will not work"; an action with getParameters() was read normally on Struts 7.4.0 (page syntax, D1).

  • The Text tag page shows <s:textfield name="lastName" label="getText('person.lastName')"/> as the way to localise a label; without the %{...} notation the attribute is the literal text getText('person.lastName').

  • The Text tag and i18n tag pages read a pushed bundle with <s:property value="text('aaa.bbb.ccc')"/>; on 7.4.0 text('...') printed nothing and only s:text reads the bundle of s:i18n.

  • The URL tag page says that the includeParams constant defaults to none since 2.1.3 (as default.properties does), and the FAQ "Why are request parameters appended to our hyperlinks" says the default is GET; the FAQ’s repair, <s:param name="request_locale" value="en" />, passes null because en is an expression, not a string.

  • The Action tag page and its FAQ select a method with name!method, which requires dynamic method invocation; the constant defaults to false and stats!featured did nothing on 7.4.0.

  • The Include tag page says no value stack is created for the included page, so s:property cannot read the parameters; the request’s value stack is visible in the included JSP (the parameters are still only request parameters).

  • default.properties comments the global constant as escapeHtmlTag (“<s:a ... escapeHtmlTag="true">”); the attribute of the tag is escapeHtmlBody.

  • The iterator page documents status as a Boolean attribute (it takes a name, and the docs' own examples write status="rowstatus"), its var description as the name "used to reference the value pushed into the Value Stack (scope: action)", and its example of the <s:iterator var="counter" begin="1" end="5"> comments as if counter held the number (it prints the loop number only through top).

  • The Push tag examples spell the tag s:propery (missing letter) and close it with </push>; the Iterator tag example has the same unclosed </push> and the action tag example ends a span that was never opened. The a tag page’s usage example calls errorText and preInvokeJS, attributes that are not in the 7.4.0 TLD (they belonged to the removed Ajax tags) and uses ${id} inside a Struts attribute.

  • The append tag page’s example of <s:param value="%{myList1}"/> does not say that an empty list in the sequence ends the append.

  • The Generator tag page’s "Example 4" is titled "generate an iterator with comparator attribute" and uses converter.

  • The Text tag page links to java.sun.com pages for Java 1.4.2 for MessageFormat and DecimalFormat.

Summary

  • The Struts tags are classic JSP tag handlers over shared components; the TLD is in struts2-core (uri="/struts-tags", 53 tags: 27 generic, 26 UI) and no attribute accepts a JSP expression.

  • Attributes obey three rules: a String attribute is literal text with %{...} expressions embedded, a non-String attribute is itself an expression, and %{} around a non-String attribute is redundant. The pre-2.2 syntax with 'literal' everywhere is gone since 2.6.

  • A bare name is read from the value stack, #name from the context map; OGNL’s one-character literal is a char, and the JDK collection methods are blocked, so use books.size, books.empty, books[0].

  • The control tags: if (a real boolean test), iterator (var, status with index, count, first, last, odd, even, begin/end/step), append, merge, generator, sort and subset for iterators.

  • The data tags: property (escapes HTML by default), set and its scopes, push, bean and param, action, text and i18n, date, number, include, url and a (including includeParams, escapeAmp and the encoding of parameters) and debug.

  • script, link (CSP nonce), webjar (since 7.3.0) and compress (since 7.2.0) are the asset tags.

  • JSTL and EL work beside the tags: ${filter} reads the stack through the request wrapper; escape EL output with c:out; read a JSTL variable from OGNL with #attr.name.

References