Validation
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Struts 7.4.0 (released 2026-10-02) running on Apache Tomcat 11.0.26 and Java 21, the
combination that every example was built, deployed and requested on; the facts were checked on 2026-10-08 against the
struts2-core 7.4.0 jar (class lists, validators/default.xml, default.properties, the DTDs), its Javadoc and the Core
Developer Guide pages on https://struts.apache.org/core-developers/. Validation in Struts is not one feature but a small
pipeline: the params interceptor copies request values onto the action, the conversionError interceptor remembers
the values that could not be converted, the validation interceptor runs the checks (a validate() method on the action,
XML rules, annotations) and records every failure as a field error or an action error on the action, and the workflow
interceptor looks at those errors and, if there is one, answers with the input result before the action method runs.
Everything is driven by configuration, and each piece is replaceable: this page follows the pipeline in order, goes through
every validator that ships with the framework, and then covers the parts that surprise people (the order in which
validators run, what short-circuiting really skips, how the files for a class and for an alias are combined, why a
ModelDriven form shows no error beside its field, and why a field that failed conversion collects two messages). The
running scenario is the Bookshelf, a catalogue of books exposed as Struts actions; here it is a form that adds a title,
and every output quoted below was produced by requests to that form’s actions. Related pages of this section:
ValueStack, ActionContext & OGNL,
Parameter Injection and @StrutsParameter,
Type Conversion, Bean Validation Plugin,
Localization and i18n,
ModelDriven, Preparable & CRUD and
UI & Form Tags.
The Validation Pipeline
Validation needs two interceptors, and both are already in the default stack. The validation interceptor (class
org.apache.struts2.interceptor.validation.AnnotationValidationInterceptor, a subclass of
org.apache.struts2.validator.ValidationInterceptor) does the checking and creates the errors. It does not change the
course of the request. The workflow interceptor (org.apache.struts2.interceptor.DefaultWorkflowInterceptor) is the one
that acts: it returns the input result when the action has an action error, a field error or a conversion error. The
Core Developer Guide states the dependency plainly: both interceptors belong to the default stack, and a stack that
leaves either out does not validate. It also warns about the consequence of a missing input result.
Source: Apache Struts — Validation
The figure follows one request through the stack. validation and workflow are positions 22 and 23 of the 24
interceptors of defaultStack (see the Interceptors page for the whole list), after every interceptor that
sets values on the action, because validators read the action’s properties.
The same interceptors also run on the way back out of the stack, but they do nothing there: all the work is done before
the action. The practical consequences are three. Validation never sees a value the params interceptor did not set
(a setter without @StrutsParameter leaves the property at its default and the validator checks that default). Validation
cannot protect an action whose mapping uses a stack without validation and workflow. And the action method does not
run when validation fails, so a null that the validator rejected can never reach execute().
The Bookshelf Used on This Page
All examples below are classes and mappings of one WAR, g9validation.war, deployed under the context path /g9validation
on Tomcat 11.0.26. It is the Struts Bookshelf of the page Struts Getting Started (the Struts BOM
org.apache.struts:struts2-bom:7.4.0 imported in dependencyManagement, struts2-core, the Servlet and Pages APIs with
scope provided, StrutsPrepareAndExecuteFilter mapped to /*) with extra actions for the experiments. Every action
extends one small base class that records which of its methods were called, so the outputs can show the order in which the
framework invoked them:
public abstract class TracedAction extends ActionSupport {
protected final StringBuilder called = new StringBuilder();
public String getCalled() {
return called.toString().trim();
}
}
All mappings answer with one plain-text view that prints the result name, the trace and the two error collections, so
that a curl request is enough to read the outcome:
<%@ page contentType="text/plain; charset=UTF-8" pageEncoding="UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
result = ${param.r}
called = <s:property value="called"/>
fieldErrors = <s:property value="fieldErrors"/>
actionErrors = <s:property value="actionErrors"/>
The mappings pass the result name to the view as /WEB-INF/views/report.jsp?r=input. Two constants matter for the whole
WAR: struts.allowlist.packageNames is set to org.example.bookshelf, because the OGNL allowlist of Struts 7.4.0 would
otherwise print nothing for the visited Book objects, and every @StrutsParameter is present on the setters, because
struts.parameters.requireAnnotations is true by default. The WAR was built twice, with the same code and mappings: once
with the defaults and once with the two 7.x constants described later in the sections on conversion errors and on HTML5
constraints. Which build produced an output is said where it matters.
| Class | Mappings | Concept |
|---|---|---|
|
|
|
|
|
Choosing the name of the input result from the action |
|
|
XML validation with every built-in validator; alias files; the form view |
|
|
A subclass of |
|
|
Plain against field validators and |
|
|
Visitor and conditional visitor validators, validation contexts |
|
|
|
|
|
Message keys, message parameters and the |
|
|
The parameter name |
|
|
Annotation validation and the custom |
|
|
HTML5 constraint attributes |
Turning Validation On
The default stack has validation turned on. struts-default.xml binds the names to the classes and the defaultStack
gives both interceptors the same exclusion list; the stack called validationWorkflowStack (basicStack, then validation
and workflow, with no exclusion) is the smallest stack that validates:
<interceptor name="validation"
class="org.apache.struts2.interceptor.validation.AnnotationValidationInterceptor"/>
<interceptor name="workflow" class="org.apache.struts2.interceptor.DefaultWorkflowInterceptor"/>
<!-- inside defaultStack: -->
<interceptor-ref name="validation">
<param name="excludeMethods">input,back,cancel,browse</param>
</interceptor-ref>
<interceptor-ref name="workflow">
<param name="excludeMethods">input,back,cancel,browse</param>
</interceptor-ref>
A stack of your own must list both interceptors, in that order, after the interceptors that set the values (params,
modelDriven, prepare in a paramsPrepareParamsStack). A stack with validation and no workflow records the errors
and then runs the action anyway; a stack with workflow and no validation has nothing to look at.
The two interceptors have these settings. The first four belong to validation, the last to workflow; both also inherit
excludeMethods and includeMethods from MethodFilterInterceptor.
| Parameter | Default | Effect |
|---|---|---|
|
|
When |
|
|
When |
|
|
When |
|
|
When |
|
see the stack |
Comma separated method names (a single |
|
|
The result returned when there is any error |
Parameters are set per mapping with the stack name in front of the interceptor name, as the Interceptors page explains for params.excludeParams. The mappings below change one parameter each and keep the rest of
defaultStack:
<action name="programmatic-nodefault" class="org.example.bookshelf.ProgrammaticAction" method="save">
<interceptor-ref name="defaultStack">
<param name="validation.alwaysInvokeValidate">false</param>
</interceptor-ref>
<result name="input">/WEB-INF/views/report.jsp?r=input</result>
<result>/WEB-INF/views/report.jsp?r=success</result>
</action>
<action name="programmatic-declarative-only" class="org.example.bookshelf.ProgrammaticAction" method="save">
<interceptor-ref name="defaultStack">
<param name="validation.programmatic">false</param>
</interceptor-ref>
<result name="input">/WEB-INF/views/report.jsp?r=input</result>
<result>/WEB-INF/views/report.jsp?r=success</result>
</action>
<action name="custom-workflow" class="org.example.bookshelf.ProgrammaticAction" method="save">
<interceptor-ref name="defaultStack">
<param name="workflow.inputResultName">invalid</param>
</interceptor-ref>
<result name="invalid">/WEB-INF/views/report.jsp?r=invalid</result>
<result>/WEB-INF/views/report.jsp?r=success</result>
</action>
The results of these mappings are in the next section, next to the action that they run.
Programmatic Validation: validate() and Its Relatives
The simplest validation is Java code in the action. ActionSupport (see the Actions page) implements two interfaces for it:
org.apache.struts2.Validateable, whose method validate() the validation interceptor calls, and
org.apache.struts2.interceptor.ValidationAware (the org.apache.struts2.interceptor interface, not the
com.opensymphony.xwork2 one that older pages name), which stores the errors. The methods an action uses are:
| Method | Effect |
|---|---|
|
Adds a message for one input field; the view shows it next to that field and the |
|
Adds a message that belongs to the action as a whole, not to one field |
|
Adds an informational message; it is not an error and does not trigger |
|
True when any error, any field error or any action error exists |
|
Read the map and the collection; empty both ( |
Besides validate(), the interceptor looks for a method named after the action method. The Default Workflow Interceptor
page states the rule: if the class has validateMethodName() it is called; otherwise, if it has
validateDoMethodName(), that is called; and either way validate() runs afterwards while alwaysInvokeValidate is
true. The first letter of the method name is capitalised (save gives validateSave). The Bookshelf action below has
three entry points, one with each kind of method, and one that is excluded from validation with @SkipValidation:
public class ProgrammaticAction extends TracedAction {
private String title;
private int pages;
@Override
public void validate() {
called.append("validate() ");
if (title == null || title.isBlank()) {
addFieldError("title", "Title is required");
}
}
public void validateSave() {
called.append("validateSave() ");
if (pages <= 0) {
addFieldError("pages", "Pages must be positive");
}
}
public void validateDoUpdate() {
called.append("validateDoUpdate() ");
}
public String save() {
called.append("save() ");
return SUCCESS;
}
public String update() {
called.append("update() ");
return SUCCESS;
}
@SkipValidation
public String list() {
called.append("list() ");
return SUCCESS;
}
public String getTitle() { return title; }
@StrutsParameter public void setTitle(String title) { this.title = title; }
public int getPages() { return pages; }
@StrutsParameter public void setPages(int pages) { this.pages = pages; }
}
The mappings programmatic-save, programmatic-update, programmatic-list and programmatic-input map this class with
method="save", "update", "list" and "input" and a result input; the other three are the mappings of the previous
section. The request is GET /g9validation/<mapping>?title=...&pages=... and the outcomes were:
| Request | Result | Methods called, in order | Field errors |
|---|---|---|---|
|
|
|
|
|
|
|
none |
|
|
|
|
|
|
|
none: |
|
|
none |
none: the method |
|
|
|
|
|
|
|
none: no programmatic method ran |
Three points follow from the table. The method-specific method runs before validate(), so validate() is the right place
for rules that apply to every entry point and the method-specific methods are for rules of one entry point.
validateDoUpdate() was called for update because the class has no validateUpdate(). And @SkipValidation (package
org.apache.struts2.interceptor.validation, which is why the stack’s interceptor is the Annotation... subclass) turns
off the whole validation of one method, programmatic and declarative, without touching the stack configuration.
The Workflow Interceptor and the input Result
workflow returns the name input whenever the action has any action error, field error or conversion error. The mapping
must therefore have a result called input. When it has not, Struts does not run the action and does not show the
errors; it answers with an error page:
HTTP/1.1 404
No result defined for action org.example.bookshelf.ProgrammaticAction and result input
That is the output of programmatic-noinput, a mapping of save with only a success result, on Struts 7.4.0 (also
the message of the no-input mapping of AddBookAction). The Basic Validation tutorial gives the same warning in its
fourth step. The missing result is a ConfigurationException that the dispatcher turns into an HTTP 404, as
the Exception Handling page explains.
Source: Apache Struts — Validation
The name of the result can be changed in four ways, from the broadest to the narrowest:
-
The stack parameter
workflow.inputResultName, for every action that uses the stack (the mappingcustom-workflowabove answered with the resultinvalid). -
The interface
org.apache.struts2.interceptor.ValidationWorkflowAware, whose only method,getInputResultName(), lets the action choose the name for its own errors. -
The interface
org.apache.struts2.interceptor.ValidationErrorAware, whose methodactionErrorOccurred(String currentResultName)is called with the nameinputand may return another one; it is the hook for an action that wants to log or to react to the failure. -
The annotation
@InputConfig(resultName = "form")on one action method (ormethodNameto call another method of the action instead of returning a result).
public class WorkflowAwareAction extends TracedAction implements ValidationWorkflowAware {
private String title;
@Override
public void validate() {
if (title == null || title.isBlank()) {
addFieldError("title", "Title is required");
}
}
@Override
public String getInputResultName() {
return "edit-form";
}
public String getTitle() { return title; }
@StrutsParameter public void setTitle(String title) { this.title = title; }
}
public class ErrorAwareAction extends TracedAction implements ValidationErrorAware {
private String title;
@Override
public void validate() {
if (title == null || title.isBlank()) {
addFieldError("title", "Title is required");
}
}
@Override
public String actionErrorOccurred(String currentResultName) {
called.append("actionErrorOccurred(" + currentResultName + ") ");
return "retry";
}
public String getTitle() { return title; }
@StrutsParameter public void setTitle(String title) { this.title = title; }
}
| Mapping, request with an empty title | Result | Remark |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
The interfaces are the ones that the Default Workflow Interceptor page lists next to ValidationAware; the page’s remark
that it applies "only when the action implements com.opensymphony.xwork2.Validateable`" uses the name from before Struts
7, today `org.apache.struts2.Validateable.
XML Validation Files
Declarative validation puts the rules in an XML file beside the action class. The file name decides which action the rules belong to:
| File name (in the package directory of the class) | Applies to |
|---|---|
|
Every mapping of the class |
|
Only the mapping whose name (its alias) is |
|
The mapping named |
|
The class |
|
The subclass; the parent’s file also applies |
The files are loaded from the class path, so they live under src/main/resources/org/example/bookshelf/ in a Maven
project. The root element is validators; its children are field elements (one per property, holding field-validator
children) and validator elements (plain validators). Both kinds hold optional param elements and exactly one message
element. The DTD that the 7.4.0 jar contains is xwork-validator-1.0.3.dtd; the header is:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE validators PUBLIC
"-//Apache Struts//XWork Validator 1.0.3//EN"
"https://struts.apache.org/dtds/xwork-validator-1.0.3.dtd">
The grammar of that DTD is short enough to quote as a table:
| Element | Attributes | Content |
|---|---|---|
|
none |
One or more |
|
|
One or more |
|
|
Zero or more |
|
|
Zero or more |
|
|
Text |
|
|
Text, and |
The Bookshelf rules for the "add a title" form are in one file. It has a plain validator (a rule that looks at several
fields), then one field per property. It is long on purpose: it uses every built-in validator once, and each
validator is explained in the next section.
<validators>
<!-- plain (non-field) validator: looks at several fields -->
<validator type="expression">
<param name="expression"><![CDATA[pages == null || pages >= 10 || title == null || title.length() <= 20]]></param>
<message>A short book needs a short title</message>
</validator>
<field name="title">
<field-validator type="requiredstring" short-circuit="true">
<message>Title is required</message>
</field-validator>
<field-validator type="stringlength">
<param name="minLength">2</param>
<param name="maxLength">40</param>
<message>Title must have ${minLength} to ${maxLength} characters</message>
</field-validator>
</field>
<field name="isbn">
<field-validator type="requiredstring" short-circuit="true">
<message>ISBN is required</message>
</field-validator>
<field-validator type="regex">
<param name="regex"><![CDATA[\d{3}-\d{10}]]></param>
<message>ISBN must look like 978-0441172719</message>
</field-validator>
<field-validator type="isbn">
<param name="allowHyphens">true</param>
<message>ISBN checksum is wrong</message>
</field-validator>
</field>
<field name="pages">
<field-validator type="required">
<message>Pages is required</message>
</field-validator>
<field-validator type="int">
<param name="parse">true</param>
<param name="minExpression">${minPages}</param>
<param name="maxExpression">${maxPages}</param>
<message>Pages must be between ${min} and ${max}, got ${pages}</message>
</field-validator>
</field>
<field name="edition">
<field-validator type="short">
<param name="min">1</param>
<param name="max">99</param>
<message>Edition must be between ${min} and ${max}</message>
</field-validator>
</field>
<field name="copies">
<field-validator type="long">
<param name="min">0</param>
<param name="max">100000</param>
<message>Copies must be between ${min} and ${max}</message>
</field-validator>
</field>
<field name="price">
<field-validator type="double">
<param name="minExclusive">0</param>
<param name="maxInclusive">500</param>
<message>Price must be above ${minExclusive} and at most ${maxInclusive}</message>
</field-validator>
</field>
<field name="published">
<field-validator type="conversion">
<message>Published must be a date such as 01/31/2024</message>
</field-validator>
<field-validator type="date">
<param name="min">01/01/1900</param>
<param name="max">12/31/2030</param>
<message>Published must be between ${min} and ${max}</message>
</field-validator>
</field>
<field name="contactEmail">
<field-validator type="email">
<message>Contact must be an email address</message>
</field-validator>
</field>
<field name="homepage">
<field-validator type="url">
<message>Homepage must be a URL</message>
</field-validator>
</field>
<field name="giftCard">
<field-validator type="creditcard">
<message>Gift card number is not valid</message>
</field-validator>
</field>
<field name="coverCode">
<field-validator type="fieldexpression">
<param name="expression"><![CDATA[coverCode == null || coverCode.isEmpty() || (title != null && !title.isEmpty() && coverCode.charAt(0) == title.charAt(0))]]></param>
<message>The cover code must start with the first letter of the title</message>
</field-validator>
</field>
</validators>
The action behind it is a plain JavaBean. Its setters carry @StrutsParameter, the two getters getMinPages() and
getMaxPages() feed the range expression of the pages field, and the form mapping has a result named input:
public class AddBookAction extends TracedAction {
private String title;
private String isbn;
private Integer pages;
private Double price;
private Short edition;
private Date published;
private String contactEmail;
private String homepage;
private String coverCode;
private Long copies;
private String giftCard;
private int minPages = 10;
private int maxPages = 800;
@Override
public String execute() {
called.append("execute() ");
return SUCCESS;
}
public Long getCopies() { return copies; }
@StrutsParameter public void setCopies(Long v) { copies = v; }
public String getGiftCard() { return giftCard; }
@StrutsParameter public void setGiftCard(String v) { giftCard = v; }
public int getMinPages() { return minPages; }
public int getMaxPages() { return maxPages; }
public String getTitle() { return title; }
@StrutsParameter public void setTitle(String v) { title = v; }
public String getIsbn() { return isbn; }
@StrutsParameter public void setIsbn(String v) { isbn = v; }
public Integer getPages() { return pages; }
@StrutsParameter public void setPages(Integer v) { pages = v; }
public Double getPrice() { return price; }
@StrutsParameter public void setPrice(Double v) { price = v; }
public Short getEdition() { return edition; }
@StrutsParameter public void setEdition(Short v) { edition = v; }
public Date getPublished() { return published; }
@StrutsParameter public void setPublished(Date v) { published = v; }
public String getContactEmail() { return contactEmail; }
@StrutsParameter public void setContactEmail(String v) { contactEmail = v; }
public String getHomepage() { return homepage; }
@StrutsParameter public void setHomepage(String v) { homepage = v; }
public String getCoverCode() { return coverCode; }
@StrutsParameter public void setCoverCode(String v) { coverCode = v; }
}
The first request, a valid one, passed through to execute():
GET /g9validation/addBook?title=Dune&isbn=978-0441172719&pages=412&edition=3&copies=10&price=9.99
&published=01/15/1990&contactEmail=a@b.org&homepage=https://example.org&giftCard=4111111111111111&coverCode=D
result = success
called = execute()
fieldErrors = {}
actionErrors = []
Source: Apache Struts — Basic Validation
The Built-in Validators
The framework registers its validators by name in org/apache/struts2/validator/validators/default.xml, a file inside the
jar. A project does not need to copy this list into its own validators.xml (only an application with custom validators
has that file, see below); the old rule that the file must contain a copy of the defaults is from before Struts 2.0.8.
default.xml of the 7.4.0 jar binds 17 names:
| Name | Class (org.apache.struts2.validator.validators) |
Kind | What it checks |
|---|---|---|---|
|
|
field |
The property is not |
|
|
field |
A |
|
|
field |
An integer within |
|
|
field |
The same for |
|
|
field |
The same for |
|
|
field |
A double within |
|
|
field |
A date within |
|
|
plain |
An OGNL expression over the value stack that must be true |
|
|
field |
An OGNL expression, reported against one field |
|
|
field |
A non-empty string that matches an e-mail regular expression |
|
|
field |
A string that matches the card-number pattern |
|
|
field |
A string that matches the URL pattern ( |
|
|
field |
Validates an object property with the object’s own rules |
|
|
field |
A |
|
|
field |
Reports a conversion error of the field with your message |
|
|
field |
A string length within |
|
|
field |
A string that matches |
The Core Developer Guide has a page for fifteen of them (the long and creditcard validators have none, and the Bundled
Validators list on the Validation page omits them as well); the 7.4.0 default.xml is the authority. Every validator that
works on a field accepts the parameter fieldName; in the field-validator syntax it is filled in from the enclosing
field, so it is only written in the validator (plain) syntax. The guide’s own examples spell it fieldName, and one tutorial writes
fieldname; both are accepted (the mapping legacy-names, which declares title with fieldName and isbn with
fieldname, rejected both empty values).
The validators in the table are combined in the file above, and the next subsections go through them in groups with the outputs that the Bookshelf form produced.
required and requiredstring
required fails only when the property is null. That is subtle for a String bound to a text field: a browser submits
an empty text input as an empty parameter, and the params interceptor sets the property to "", which is not null. The
note field of Html5BookAction has a required validator, and the requests showed it:
| Request | Result | Why |
|---|---|---|
|
|
The property is |
|
|
A blank is not |
|
|
The property stays |
requiredstring is the validator for text: it fails on null and on "", and, because trim defaults to true, on a
string of blanks. In AddBookAction-validation.xml the title field starts with a requiredstring that has
short-circuit="true", so an empty title reports only "Title is required" and not the length error that a stringlength after
it would add. For pages, an Integer, required is the right validator: an empty field converts to null and required reports
"Pages is required".
int, long, short, double and date: ranges
The int, long and short validators take min and max; each bound is optional and a bound that is missing is not
checked. For values known only at run time, set parse to true and give minExpression and maxExpression, OGNL
expressions written as ${name} that evaluate to a getter of the action. The Bookshelf uses this for the page count, whose
limits getMinPages() and getMaxPages() return (10 and 800 in the example):
<field name="pages">
<field-validator type="required">
<message>Pages is required</message>
</field-validator>
<field-validator type="int">
<param name="parse">true</param>
<param name="minExpression">${minPages}</param>
<param name="maxExpression">${maxPages}</param>
<message>Pages must be between ${min} and ${max}, got ${pages}</message>
</field-validator>
</field>
The documentation warns that ${minExpression} must not be used as the expression of itself (an infinite loop); the
parameter is the name of a getter. short and long take the same parameters, edition is a Short and copies a Long
in the example. double has four bounds, two inclusive and two exclusive: the example asks for a price above zero (exclusive
lower bound) and at most 500 (inclusive upper bound).
<field name="edition">
<field-validator type="short">
<param name="min">1</param>
<param name="max">99</param>
<message>Edition must be between ${min} and ${max}</message>
</field-validator>
</field>
<field name="price">
<field-validator type="double">
<param name="minExclusive">0</param>
<param name="maxInclusive">500</param>
<message>Price must be above ${minExclusive} and at most ${maxInclusive}</message>
</field-validator>
</field>
date takes min and max in the locale’s short date format, or the expression forms. The example restricts the
publication date of a book:
<field name="published">
<field-validator type="conversion">
<message>Published must be a date such as 01/31/2024</message>
</field-validator>
<field-validator type="date">
<param name="min">01/01/1900</param>
<param name="max">12/31/2030</param>
<message>Published must be between ${min} and ${max}</message>
</field-validator>
</field>
| Field | Value sent | Message |
|---|---|---|
|
|
Pages must be between 10 and 800, got 0 (the bounds come from the action’s getters) |
|
|
Edition must be between 1 and 99 |
|
|
Copies must be between 0 and 100000 |
|
|
Price must be above 0 and at most 500 |
|
|
Published must be between 1/1/00 and 12/31/30 |
|
|
Invalid field value for field "published". and Published must be a date such as 01/31/2024 (two messages) |
|
|
no error; the converter accepts more formats than the validator’s |
The ${min} and ${max} of the date message did not print the text of the parameters: they printed the Date values in the
short format of the locale, 1/1/00 for 01/01/1900, so a message that quotes date bounds is better written with the dates
as literal text.
stringlength, regex, email, url and creditcard
stringlength bounds the length of a string with minLength and maxLength, after trimming unless trim is false.
regex matches a regular expression written in the regex parameter. Wrap it in CDATA so that XML does not interpret
<, & or > inside it. The Bookshelf’s ISBN-13 pattern is \d{3}-\d{10}:
<field name="title">
<field-validator type="stringlength">
<param name="minLength">2</param>
<param name="maxLength">40</param>
<message>Title must have ${minLength} to ${maxLength} characters</message>
</field-validator>
</field>
<field name="isbn">
<field-validator type="regex">
<param name="regex"><![CDATA[\d{3}-\d{10}]]></param>
<message>ISBN must look like 978-0441172719</message>
</field-validator>
</field>
email, url and creditcard are regular-expression validators with the expression built in. email and creditcard
extend RegexFieldValidator; url accepts a different expression through urlRegex (or urlRegexExpression). They skip
an empty value, so a field that must be present needs a requiredstring in front.
<field name="contactEmail">
<field-validator type="email">
<message>Contact must be an email address</message>
</field-validator>
</field>
<field name="homepage">
<field-validator type="url">
<message>Homepage must be a URL</message>
</field-validator>
</field>
<field name="giftCard">
<field-validator type="creditcard">
<message>Gift card number is not valid</message>
</field-validator>
</field>
| Field | Value sent | Outcome |
|---|---|---|
|
|
Title must have 2 to 40 characters |
|
empty |
Title is required; the |
|
|
ISBN must look like 978-0441172719 and ISBN checksum is wrong |
|
|
Contact must be an email address |
|
|
Homepage must be a URL |
|
|
Gift card number is not valid |
|
|
no error: the validator only checks a pattern (prefix and length) and does not verify the check digit |
creditcard is therefore only a format check; the card number 4111111111111112 has a wrong last digit and passed. If
the checksum matters, validate it in code or write a validator (see below).
expression and fieldexpression
expression is the validator for rules that involve several properties. It is a plain validator: it is not attached to
one field, its failure becomes an action error, and its expression is OGNL evaluated against the value stack, so it reads
the action’s properties by name. fieldexpression has the same expression parameter but is a field validator: the error
becomes a field error of the field in which it is declared. Write the expression in CDATA, because <, > and && are not
safe in XML text (the Validation page reminds readers to escape them). The Bookshelf has one of each:
<validator type="expression">
<param name="expression"><![CDATA[pages == null || pages >= 10 || title == null || title.length() <= 20]]></param>
<message>A short book needs a short title</message>
</validator>
...
<field name="coverCode">
<field-validator type="fieldexpression">
<param name="expression"><![CDATA[coverCode == null || coverCode.isEmpty() || (title != null && !title.isEmpty() && coverCode.charAt(0) == title.charAt(0))]]></param>
<message>The cover code must start with the first letter of the title</message>
</field-validator>
</field>
A request with a 45-character title and 5 pages reported the plain validator’s message in actionErrors (the title’s
length error, a field error, is the stringlength rule and is separate):
GET /g9validation/addBook?title=ThisIsAVeryLongTitleForAShortBookWithFewPages&isbn=978-0441172719&pages=5
actionErrors = [A short book needs a short title]
The expression must cope with null, because the validators run even when nothing was submitted. The first version of the
coverCode rule in the scratch app was coverCode.isEmpty() || ...; a request without a coverCode parameter then failed
with the rule’s message, as though the cover code were wrong, because evaluating isEmpty() on null fails and the
validator treats a failed evaluation as a failed rule. Starting the expression with coverCode == null fixed it.
Field and Non-Field Validators, Order and Short-Circuiting
The Validation page names two "flavours". A plain validator (expression is the standard one) checks the action as a
whole. It has no field name, and its failure is an action error. A field validator checks one property, and its failure
is a field error. The flavour is a property of the validator class (the framework looks at whether it implements
org.apache.struts2.validator.FieldValidator); the XML only says which syntax to use:
| Syntax | Use |
|---|---|
|
Field validators only; the field name is inherited, and the DTD does not allow a plain validator inside |
|
Both kinds; a field validator written here needs |
The guide recommends the field syntax for every field validator because it groups all the rules of a property.
The order in which the validators run matters only for short-circuit. The page states two rules, and the scratch app
checked both with a small action (ShortCircuitAction) whose file lists the field validators first and the plain ones last:
<validators>
<field name="title">
<field-validator type="requiredstring">
<message>Title is required</message>
</field-validator>
</field>
<field name="isbn">
<field-validator type="requiredstring" short-circuit="true">
<message>ISBN is required</message>
</field-validator>
<field-validator type="regex">
<param name="regex"><![CDATA[\d{3}-\d{10}]]></param>
<message>ISBN must look like 978-0441172719</message>
</field-validator>
</field>
<validator type="expression" short-circuit="true">
<param name="expression"><![CDATA[pages == null || pages > 0]]></param>
<message>Pages must be positive</message>
</validator>
<validator type="expression">
<param name="expression"><![CDATA[pages == null || pages < 5000]]></param>
<message>Pages must be below 5000</message>
</validator>
</validators>
| Request | Field errors | Action errors |
|---|---|---|
|
none |
Pages must be positive |
|
|
Pages must be below 5000 |
In the first row the plain expression marked short-circuit="true" failed, and nothing else ran: not the title rule, not
the isbn rules, although the file lists them first; plain validators run before field validators. In the second row the
plain short-circuit validator passed, the second plain validator failed, and the field validators ran after it. The second
rule, that a short-circuited field validator stops only the later validators of its own field, showed in addBook with an
empty isbn: the requiredstring marked short-circuit="true" failed and the output was just "ISBN is required", without
"ISBN must look like …" from the regex that follows it.
That is the rule of the Validation page, in two sentences. A short-circuited plain validator stops the whole validation (and, being plain, runs before every field validator). A short-circuited field validator stops only the later validators of its own field, whichever of the two syntaxes declared them; the page says it does not stop a plain validator and does not stop another field.
How the Validators of an Action Are Found
The framework builds the list of validators for a request from several places and merges them. The Validation page names
them: the file of the action class, the file of the action alias, and the files of the parent classes and interfaces of the
class ("XWork searches up the inheritance tree of the action"). Annotations on the class are another source. The figure
shows the sources for the strict mapping of AddBookAction:
The merge keeps every validator; nothing overrides anything. Two requests showed it. The alias file
AddBookAction-strict-validation.xml adds a stricter edition rule and a required contact; the class file already has
a looser edition rule:
<validators>
<field name="contactEmail">
<field-validator type="requiredstring">
<message>Contact is required for strict books</message>
</field-validator>
</field>
<field name="edition">
<field-validator type="short">
<param name="min">1</param>
<param name="max">20</param>
<message>A strict edition is between ${min} and ${max}</message>
</field-validator>
</field>
</validators>
GET /g9validation/strict?title=Dune&isbn=978-0441172719&pages=412&edition=100&coverCode=D&contactEmail=a@b.org
fieldErrors = {edition=[Edition must be between 1 and 99, A strict edition is between 1 and 20]}
GET /g9validation/strict?title=Dune&isbn=978-0441172719&pages=412&edition=50&coverCode=D
fieldErrors = {contactEmail=[Contact is required for strict books], edition=[A strict edition is between 1 and 20]}
With edition=100 both rules failed and both messages were added to the same field. With edition=50 only the alias
file’s rule failed, and the alias file’s contactEmail rule applied too, which shows that the alias file is added to the
class file’s rules and does not replace them. The same mapping addBook, which has no alias file, never asked for
contactEmail. The Validation page warns of the consequence: with requiredstring in both files for the same field the user
sees the message twice.
A short-circuit does not cross the files in the way one might expect. The mapping order has an alias file whose only
rule is a plain expression with short-circuit="true" ("Pages must be positive"):
<validators>
<validator type="expression" short-circuit="true">
<param name="expression"><![CDATA[pages == null || pages > 0]]></param>
<message>Pages must be positive</message>
</validator>
</validators>
GET /g9validation/order?title=&isbn=bad&pages=0
fieldErrors = {title=[Title is required], isbn=[ISBN must look like ..., ISBN checksum is wrong], pages=[Pages must be between 10 and 800, got 0], ...}
actionErrors = [Pages must be positive]
The short-circuit of the plain validator did not suppress the field errors of the class file: the validators of the class
file ran before the alias file’s, so the short-circuit only affects what comes after it. A short-circuit that must cancel
every field rule belongs in the same file as the field rules (as in ShortCircuitAction above), or the rules belong in
one file.
Inheritance works as described. ReviewedBookAction extends AddBookAction and has a one-rule file for reviewer; the
class file of AddBookAction applied too, so the fields of the parent are validated without repeating the rules:
GET /g9validation/reviewed?title=Dune&isbn=978-0441172719&pages=412&coverCode=D
result = input
fieldErrors = {reviewer=[Reviewer is required]}
The Validation page cautions that when a class relies on validators declared higher in its inheritance tree, a
short-circuit attribute in a subclass file can switch off rules of the parent that were meant to run, so short-circuiting should
be used deliberately in a hierarchy.
Validation contexts and action names with slashes
A context in the Validation page is the alias of the action: the file ActionClass-alias-validation.xml holds the
rules of one mapping. The visitor validator also has a context parameter, so that the same model class can have rules
for different uses (see visitor validation below). A mapping whose name contains slashes (a feature behind
struts.enable.SlashesInActionNames) uses the name with the slashes replaced by hyphens in the file name. The scratch
app has catalog/add for AddBookAction and the file AddBookAction-catalog-add-validation.xml with one required
contact rule. The first attempt, with only the constant struts.enable.SlashesInActionNames set to true, answered
HTTP 404 "There is no Action mapped for namespace [/catalog] and action name [add]": with the default
struts.mapper.alwaysSelectFullNamespace=true of Struts 7 the part before the last slash is the namespace. With
struts.mapper.alwaysSelectFullNamespace set to false the mapping catalog/add was found and the file was applied:
<constant name="struts.enable.SlashesInActionNames" value="true"/>
<constant name="struts.mapper.alwaysSelectFullNamespace" value="false"/>
...
<action name="catalog/add" class="org.example.bookshelf.AddBookAction">
<result name="input">/WEB-INF/views/report.jsp?r=input</result>
<result>/WEB-INF/views/report.jsp?r=success</result>
</action>
GET /g9validation/catalog/add?title=Dune&isbn=978-0441172719&pages=412&coverCode=D
fieldErrors = {contactEmail=[Contact is required for the catalog]}
GET /g9validation/addBook?title=Dune&isbn=978-0441172719&pages=412&coverCode=D
result = success
Messages: Text, Keys and Parameters
Every validator or field validator has exactly one message element. Its body is the default text; its optional key
attribute names a key in the action’s resource bundles, looked up with getText() of the action (so the message
follows the locale of the request when the action implements LocaleAware, as ActionSupport does). When the validator
fails, the validator itself is pushed on the value stack and the text is scanned for ${...} sections, which are
replaced with the value of the OGNL expression between the braces. That is why ${min} and ${max} in an int
message print the validator’s own parameters, while ${pages} prints the action’s property, as in the Bookshelf message
"Pages must be between 10 and 800, got 0".
The key form moves the wording to a properties file. A second form adds param children to the message and uses the
MessageFormat placeholders of the properties file: the names of the params do not matter, their order does, and each is an
OGNL expression evaluated against the value stack. The Bookshelf uses it for a localized form with English and Spanish
bundles:
<validators>
<field name="title">
<field-validator type="requiredstring">
<message key="book.title.required">The title of the book is required (default)</message>
</field-validator>
</field>
<field name="pages">
<field-validator type="int">
<param name="min">1</param>
<param name="max">5000</param>
<message key="book.pages.range">Pages must be between {0} and {1} (default)
<param name="0">1</param>
<param name="1">5000</param>
</message>
</field-validator>
</field>
<field name="isbn">
<field-validator type="requiredstring">
<message key="errors.required">
<param name="0">getText('field.isbn')</param>
</message>
</field-validator>
</field>
</validators>
# LocalizedBookAction.properties
book.title.required=The title of the book is required
book.pages.range=Pages must be between {0} and {1}
field.title=Title
errors.required={0} is required.
field.isbn=ISBN
# LocalizedBookAction_es.properties
book.title.required=El titulo del libro es obligatorio
book.pages.range=Las paginas deben estar entre {0} y {1}
field.title=Titulo
errors.required={0} es obligatorio.
field.isbn=ISBN
| Language | Field errors |
|---|---|
none (English) |
|
|
|
The parameters 1 and 5000 are expressions that evaluate to numbers, and MessageFormat formats numbers with the
grouping of the locale, so the limit 5000 printed as 5,000 in English and 5.000 in Spanish. The bundle is found by the rules of the page
Localization and i18n: the action’s own LocalizedBookAction.properties, its base classes, its package and the global bundles.
The Validation page adds a security warning for the param form: the parameters are evaluated as OGNL against the value
stack, so a parameter must never be built from a value that the user controls or from request parameters. A message
expression is code. The text it prints may come from user input (${pages} prints the number that the user typed, as in
"got 0" above, and the framework treats that as data), but the expression itself must be written by the developer and name
properties of the action or of the validator.
Custom Validators
A validator is a class with a validate(Object action) method that adds an error when the check fails. Pick the base
class that fits: ValidatorSupport for a plain validator, FieldValidatorSupport for a validator of one field (it supplies
getFieldName(), getFieldValue(...) and the error methods), RangeValidatorSupport for ranges, and
RepopulateConversionErrorFieldValidatorSupport for validators that repopulate the field after a conversion error. In
Struts 7 they are in org.apache.struts2.validator.validators; the Validation page still lists the
com.opensymphony.xwork2.validator.validators names. The Bookshelf validates the ISBN-13 check digit:
public class IsbnValidator extends FieldValidatorSupport {
private boolean allowHyphens = true;
public void setAllowHyphens(boolean allowHyphens) {
this.allowHyphens = allowHyphens;
}
@Override
public void validate(Object object) throws ValidationException {
String fieldName = getFieldName();
Object value = getFieldValue(fieldName, object);
if (value == null || value.toString().isBlank()) {
return; // an empty value is the job of the required validators
}
String digits = allowHyphens ? value.toString().replace("-", "") : value.toString();
if (!isValidIsbn13(digits)) {
addFieldError(fieldName, object);
}
}
static boolean isValidIsbn13(String digits) {
if (digits.length() != 13 || !digits.chars().allMatch(Character::isDigit)) {
return false;
}
int sum = 0;
for (int i = 0; i < 13; i++) {
sum += (digits.charAt(i) - '0') * (i % 2 == 0 ? 1 : 3);
}
return sum % 10 == 0;
}
}
The validator returns without an error for an empty value; presence is the job of the required validators, and one rule
per validator keeps messages meaningful. addFieldError(fieldName, object) takes the message from the XML or the
annotation that used the validator. allowHyphens is a validator parameter: the setter is called with the value of the
param element, like any other parameter.
A custom validator is registered by name in a validators.xml at the root of the class path, with the definition DTD:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE validators PUBLIC
"-//Apache Struts//XWork Validator Definition 1.0//EN"
"https://struts.apache.org/dtds/xwork-validator-definition-1.0.dtd">
<validators>
<validator name="isbn" class="org.example.bookshelf.IsbnValidator"/>
</validators>
In AddBookAction-validation.xml the validator is written like a built-in one: <field-validator type="isbn"> with
<param name="allowHyphens">true</param>. With it, 978-0441172710 (the right shape and the wrong check digit) answered
"ISBN checksum is wrong" while 978-0441172719 passed. A validator registered under a name that no class answers fails
the whole mapping, and the framework reports it as a configuration problem, not as a validation error:
GET /g9validation/broken?title=x (BrokenAction-validation.xml has <field-validator type="isbm">)
HTTP/1.1 404
Invalid validation type: isbm
That is the Struts 7.4.0 answer for any misspelt type (a ConfigurationException that the dispatcher turns into a 404);
the typo appears in the server log, not in the form.
Visitor Validation, ModelDriven and Contexts
A form whose fields belong to a domain object (book.title, book.isbn) should not repeat the object’s rules in the
action’s file. The visitor validator forwards validation of a property to the validation file of the property’s own
class. The Bookshelf’s Book has two files: Book-validation.xml for every use and Book-shelf-validation.xml, which
is read only when the visitor names the context shelf:
<validators>
<field name="isbn">
<field-validator type="requiredstring">
<message>ISBN is required</message>
</field-validator>
</field>
</validators>
<validators>
<field name="title">
<field-validator type="requiredstring">
<message>A shelved book needs a title</message>
</field-validator>
</field>
<field name="pages">
<field-validator type="int">
<param name="min">1</param>
<message>A shelved book needs at least ${min} page</message>
</field-validator>
</field>
</validators>
<validators>
<field name="book">
<field-validator type="visitor">
<param name="context">shelf</param>
<param name="appendPrefix">true</param>
<message>Book:</message>
</field-validator>
</field>
<field name="books">
<field-validator type="conditionalvisitor">
<param name="expression">!draft</param>
<param name="context">shelf</param>
<param name="appendPrefix">true</param>
<message>Shelf:</message>
</field-validator>
</field>
</validators>
public class ShelfAction extends TracedAction {
private Book book = new Book();
private List<Book> books = new ArrayList<>();
private boolean draft;
@Override
public String execute() {
called.append("execute() ");
return SUCCESS;
}
@StrutsParameter(depth = 1)
public Book getBook() { return book; }
public void setBook(Book book) { this.book = book; }
@StrutsParameter(depth = 2)
public List<Book> getBooks() { return books; }
public void setBooks(List<Book> books) { this.books = books; }
public boolean isDraft() { return draft; }
@StrutsParameter public void setDraft(boolean draft) { this.draft = draft; }
}
The parameters of the visitor are context (the validation context of the visited object; when omitted, the context of
the action’s validation is passed on, which is the action alias) and appendPrefix (default true: the visited field’s
name is prefixed with the visitor’s field name). The conditionalvisitor forwards only when its expression is true; the
Bookshelf shelf validates the books list only when the draft flag is off. The visitor validator handles a single
object, an array or a collection. Note that the getters carry @StrutsParameter with a depth, because the nested
book.title and books[0].title are parameters too.
| Request | Field errors |
|---|---|
|
|
|
|
|
none: |
Three facts show in the table. The default file and the context file are both read for the context shelf
(isbn came from Book-validation.xml, the other two from the context file). The text of the visitor’s message is put
in front of the visited object’s message, as the annotation page says ("appended in front"), and a visitor message with
no trailing space joins directly ("Book:ISBN is required"). And an indexed element gets an indexed name, which the
s:fielderror tags and the form fields use.
ModelDriven actions
With ModelDriven, the model object is on the top of the value stack, the form fields are named title and isbn and
not book.title, and the validation rules for the model live next to the model class. The action’s own file delegates
to them with a visitor validator on the field named model, the name of the getModel() property:
public class ModelBookAction extends TracedAction implements ModelDriven<Book> {
private final Book book = new Book();
@Override
public Book getModel() {
return book;
}
@Override
public String execute() {
called.append("execute() ");
return SUCCESS;
}
}
<validators>
<field name="model">
<field-validator type="visitor">
<message>Book:</message>
</field-validator>
</field>
</validators>
The result:
GET /g9validation/model?title=&isbn=&pages=0
fieldErrors = {model.isbn=[Book:ISBN is required]}
The error is stored under the name model.isbn, because appendPrefix is true by default, but the form field of a
ModelDriven page is named isbn; <s:fielderror fieldName="isbn"/> and the error list of the xhtml theme look for
isbn and show nothing beside the field. Setting appendPrefix to false in the visitor gives the name that the form
uses. The second action, FlatModelAction, has the same class and a file that sets it:
<field name="model">
<field-validator type="visitor">
<param name="appendPrefix">false</param>
<message/>
</field-validator>
</field>
GET /g9validation/model-flat?title=&isbn=
fieldErrors = {isbn=[ISBN is required]}
The ModelDriven interface and the modelDriven interceptor, and the CRUD pattern around them, are the subject of the page
ModelDriven, Preparable & CRUD.
Annotation Validation
Everything the XML file can say can be written as annotations on the action’s setters or methods (the annotations are in
org.apache.struts2.validator.annotations). Each validator has its own annotation, with the members message, key,
messageParams, fieldName, shortCircuit and type (ValidatorType.FIELD for a validator of the annotated property,
the default, or ValidatorType.SIMPLE to name fieldName explicitly) plus its own parameters (min, max, regex,
expression and so on). Several annotations of one kind on the same member must be wrapped in @Validations, which has one array member
per validator:
| Annotation | @Validations member |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
groups the others on one method |
Source: Struts Javadoc — Validations
The Annotations page lists fourteen of the eighteen validator annotations; @CreditCardValidator, @LongRangeFieldValidator,
@ShortRangeFieldValidator and @ConditionalVisitorFieldValidator exist in the 7.4.0 jar (and as members of
@Validations) but have no page. The page Validation Annotation documents a class-level marker @Validation, deprecated
since Struts 2.1 according to that page; there is no such annotation in the 7.4.0 jar, and none is needed. The Bookshelf’s annotated action shows the main forms: property-level annotations on the
setter, @Validations for the validators that name a field explicitly, @CustomValidator for the isbn validator, key
and messageParams for a localized message, and the two method annotations @SkipValidation and @InputConfig:
public class AnnotatedBookAction extends TracedAction {
private String title;
private String isbn;
private Integer pages;
private String contactEmail;
private String reviewer;
private String publisher;
@Override
@Validations(
emails = @EmailValidator(type = ValidatorType.SIMPLE, fieldName = "reviewer",
message = "Reviewer must be an email address"),
expressions = @ExpressionValidator(
expression = "pages == null || pages >= 10 || title == null || title.length() <= 20",
message = "A short book needs a short title"))
public String execute() {
called.append("execute() ");
return SUCCESS;
}
@SkipValidation
public String preview() {
called.append("preview() ");
return SUCCESS;
}
@InputConfig(resultName = "form")
public String publish() {
called.append("publish() ");
return SUCCESS;
}
public String getTitle() { return title; }
@RequiredStringValidator(message = "Title is required", shortCircuit = true)
@StringLengthFieldValidator(minLength = "2", maxLength = "40", trim = true,
message = "Title must have ${minLength} to ${maxLength} characters")
@StrutsParameter
public void setTitle(String v) { title = v; }
public String getIsbn() { return isbn; }
@RegexFieldValidator(regex = "\\d{3}-\\d{10}", message = "ISBN must look like 978-0441172719")
@CustomValidator(type = "isbn", message = "ISBN checksum is wrong",
parameters = @ValidationParameter(name = "allowHyphens", value = "true"))
@StrutsParameter
public void setIsbn(String v) { isbn = v; }
public Integer getPages() { return pages; }
@IntRangeFieldValidator(min = "1", max = "5000", message = "Pages must be between ${min} and ${max}")
@StrutsParameter
public void setPages(Integer v) { pages = v; }
public String getContactEmail() { return contactEmail; }
@EmailValidator(message = "Contact must be an email address")
@StrutsParameter
public void setContactEmail(String v) { contactEmail = v; }
public String getPublisher() { return publisher; }
@RequiredStringValidator(key = "errors.required", messageParams = {"getText('field.publisher')"})
@StrutsParameter
public void setPublisher(String v) { publisher = v; }
public String getReviewer() { return reviewer; }
@StrutsParameter public void setReviewer(String v) { reviewer = v; }
}
| Request | Outcome |
|---|---|
|
|
|
|
|
|
|
|
|
result |
|
|
The validators that are annotated on a setter or on the class apply to every method of the action, and so do the ones
inside @Validations on execute(). The method publish() is validated by the setter annotations although it carries
none. The annotation page says the same ("those validators will be triggered for all the actions") and offers
validateAnnotatedMethodOnly. It changes more than one expects:
Mapping with validation.validateAnnotatedMethodOnly set to true |
Outcome |
|---|---|
|
|
|
|
So with the parameter set, only annotations placed on the invoked method count: use it only when every method that needs
validation carries its own @Validations. The annotation @InputConfig is documented on the Annotations page as the
way to pick the method or result "if there are validation errors"; it is processed by the workflow interceptor, and the
form result above proves it.
The four annotations that have no page work like the others. AnnotatedMoreAction uses them with @Validations
(creditCards, visitorFields) and on setters (@ShortRangeFieldValidator, @LongRangeFieldValidator,
@DoubleRangeFieldValidator, @DateRangeFieldValidator, @UrlValidator, @FieldExpressionValidator,
@RequiredFieldValidator, @ConversionErrorFieldValidator):
public class AnnotatedMoreAction extends TracedAction {
private Short edition;
private Long copies;
private Double price;
private Date published;
private String homepage;
private String giftCard;
private String coverCode;
private String title = "";
private Book book = new Book();
@Override
@Validations(
creditCards = @CreditCardValidator(type = org.apache.struts2.validator.annotations.ValidatorType.SIMPLE,
fieldName = "giftCard", message = "Gift card number is not valid"),
visitorFields = @VisitorFieldValidator(
fieldName = "book", context = "shelf", appendPrefix = true, message = "Book:"))
public String execute() {
called.append("execute() ");
return SUCCESS;
}
public Short getEdition() { return edition; }
@ShortRangeFieldValidator(min = "1", max = "99", message = "Edition must be between ${min} and ${max}")
@StrutsParameter public void setEdition(Short v) { edition = v; }
public Long getCopies() { return copies; }
@LongRangeFieldValidator(min = "0", max = "100000", message = "Copies must be between ${min} and ${max}")
@StrutsParameter public void setCopies(Long v) { copies = v; }
public Double getPrice() { return price; }
@DoubleRangeFieldValidator(minExclusive = "0", maxInclusive = "500", message = "Price must be above ${minExclusive} and at most ${maxInclusive}")
@ConversionErrorFieldValidator(message = "Price must be a number")
@StrutsParameter public void setPrice(Double v) { price = v; }
public Date getPublished() { return published; }
@DateRangeFieldValidator(min = "01/01/1900", max = "12/31/2030", message = "Published must be between ${min} and ${max}")
@StrutsParameter public void setPublished(Date v) { published = v; }
public String getHomepage() { return homepage; }
@UrlValidator(message = "Homepage must be a URL")
@StrutsParameter public void setHomepage(String v) { homepage = v; }
public String getGiftCard() { return giftCard; }
@StrutsParameter public void setGiftCard(String v) { giftCard = v; }
public String getCoverCode() { return coverCode; }
@FieldExpressionValidator(expression = "coverCode == null || coverCode.isEmpty() || coverCode.startsWith('X')", message = "Cover codes start with X")
@RequiredFieldValidator(message = "Cover code is required")
@StrutsParameter public void setCoverCode(String v) { coverCode = v; }
@StrutsParameter(depth = 1)
public Book getBook() { return book; }
public void setBook(Book b) { book = b; }
}
A request with a bad value for every field reported one message per field:
GET /g9validation/annotated-more?edition=100&copies=-1&price=0&published=1/1/1800&homepage=nope&giftCard=123&coverCode=Y&book.title=&book.isbn=&book.pages=0
fieldErrors = {published=[Published must be between 1/1/00 and 12/31/30], price=[Price must be above 0 and at most 500],
copies=[Copies must be between 0 and 100000], coverCode=[Cover codes start with X], homepage=[Homepage must be a URL],
edition=[Edition must be between 1 and 99], giftCard=[Gift card number is not valid],
book.isbn=[Book:ISBN is required], book.title=[Book:A shelved book needs a title], book.pages=[Book:A shelved book needs at least 1 page]}
Annotation validation and the XML files can be mixed in one action; the validators from both are merged, with the same
duplicates rule as for two XML files. The Struts 7 note for annotations is that the setter that carries the validator
needs @StrutsParameter too, or the parameter is dropped before the validator sees it.
Conversion Errors and Validation
Before validation runs, the conversionError interceptor adds a field error for every request value that failed type
conversion (the page Type Conversion explains the converters). The value is then null or the
property’s default, and the field’s validators run against it. That produces a second message for the same field. In the
default build the request with a wrong page count, edition and date gave:
GET /g9validation/addBook?title=Dune&isbn=978-0441172719&pages=abc&coverCode=D&published=notadate&edition=x
fieldErrors = {pages=[Invalid field value for field "pages"., Pages is required],
edition=[Invalid field value for field "edition".],
published=[Invalid field value for field "published"., Published must be a date such as 01/31/2024]}
pages collects the conversion message and the required message (the unconverted value is null); published has the
conversion message and the message of the conversion validator. edition has only one because short does not complain
about a missing value. The conversion validator exists to replace or to add to the standard message with your own text.
Struts 7.3.0 added a constant that removes the redundant second message. The Validation page’s "Skipping Validators on a
Conversion Error" section describes it: when set to true, the remaining validators of a field are skipped once that field
has a conversion error. The default is false, which keeps the older behaviour of reporting both messages.
<struts>
<constant name="struts.validators.skipValidatorsOnConversionError" value="true"/>
</struts>
The same request on the second build, with the constant set to true:
fieldErrors = {pages=[Invalid field value for field "pages".],
edition=[Invalid field value for field "edition".],
published=[Invalid field value for field "published"., Published must be a date such as 01/31/2024]}
The pages field lost its "Pages is required" message; the conversion validator of published still ran, because the
guide says that validator is never skipped and that action-level (non-field) validators are unaffected. The annotation
@ConversionErrorFieldValidator behaved the same way (in annotated-more?price=abc the conversion message and
"Price must be a number" were both reported).
Showing the Errors: the Form View
With the Struts form tags the errors appear without extra markup: the s:form tag renders the field errors next to each
control in the xhtml and css_xhtml themes, and s:fielderror and s:actionerror print them anywhere (those tags are
the subject of the Tags pages of this section). The "add a title" view of the Bookshelf uses the simple theme and
prints each error beside its field:
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html>
<body>
<s:actionerror/>
<s:form action="add" theme="simple">
<label>Title</label> <s:textfield name="title"/> <s:fielderror fieldName="title"/>
<label>ISBN</label> <s:textfield name="isbn"/> <s:fielderror fieldName="isbn"/>
<label>Pages</label> <s:textfield name="pages"/> <s:fielderror fieldName="pages"/>
<s:submit value="Add"/>
</s:form>
</body>
</html>
The form is shown by add-form (a mapping of AddBookAction with method="input", so validation is skipped) and
submitted to add, whose input result is the same view. A POST with an empty title, a wrong ISBN checksum and 2000 pages
gave:
<form id="add" name="add" action="/g9validation/add.action" method="post">
<label>Title</label> <input type="text" name="title" value="" id="add_title"/>
<ul class="errorMessage"><li><span>Title is required</span></li></ul>
<label>ISBN</label> <input type="text" name="isbn" value="978-0441172710" id="add_isbn"/>
<ul class="errorMessage"><li><span>ISBN checksum is wrong</span></li></ul>
<label>Pages</label> <input type="text" name="pages" value="2000" id="add_pages"/>
<ul class="errorMessage"><li><span>Pages must be between 10 and 800, got 2000</span></li></ul>
...
</form>
The value attributes are the values the user typed (they stay on the value stack), which is how a form repopulates after a
failed validation: the action is the same instance that received the parameters, and the view reads them from it. The
exception is a field that failed conversion: the wrong text is not on the action (the property is null), so the form
shows an empty field unless the conversion validator’s repopulateField setting or a text property keeps it, as the
conversion validator page explains. The Validation page lists the FAQ entry for repopulating controls.
Client-Side Validation
Server-side validation is the only one that counts; client-side validation only saves a round trip. Struts 7.4.0 has three mechanisms, and the first is new.
HTML5 constraint validation
Since Struts 7.4.0 the html5 theme can derive the HTML5 attributes required, minlength, maxlength, pattern, min and
max from the field’s server-side validators, so the browser refuses obviously invalid input before the form is sent.
It is off by default and has no per-form switch:
<constant name="struts.ui.html5.constraints" value="true"/>
The rule of the mapping is "never false-reject": a constraint is emitted only when the browser cannot reject input the
server would have accepted. Struts never sets or changes the type of an input (a plain text field is not promoted to
number because an int validator is attached), and the mapping is conservative:
| Validator | Emits | Condition |
|---|---|---|
|
|
on text-entry controls and |
|
|
only on |
|
|
on text-entry or |
|
|
on text-entry controls, with |
|
|
only when the control is already |
|
|
same; only inclusive bounds |
|
none |
never emitted |
|
none for the visitor itself |
the visited object’s validators apply to its nested fields |
any validator with a message |
|
always, on a control that submits a value |
The Bookshelf’s Html5BookAction has validators chosen to show the rules (with trim="false" where the table asks for
it), and a view with the html5 theme:
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html><body>
<s:form action="html5" theme="html5">
<s:textfield name="title" label="Title"/>
<s:textfield name="isbn" label="ISBN"/>
<s:textfield name="pages" type="number" label="Pages"/>
<s:textfield name="contactEmail" label="Contact"/>
<s:textarea name="note" label="Note"/>
<s:submit/>
</s:form>
</body></html>
<validators>
<field name="title">
<field-validator type="requiredstring">
<message>Title is required</message>
</field-validator>
<field-validator type="stringlength">
<param name="minLength">2</param>
<param name="maxLength">40</param>
<param name="trim">false</param>
<message>Title must have ${minLength} to ${maxLength} characters</message>
</field-validator>
</field>
<field name="isbn">
<field-validator type="regex">
<param name="regex"><![CDATA[\d{3}-\d{10}]]></param>
<param name="trim">false</param>
<message>ISBN must look like 978-0441172719</message>
</field-validator>
</field>
<field name="pages">
<field-validator type="int">
<param name="min">1</param>
<param name="max">5000</param>
<message>Pages must be between ${min} and ${max}</message>
</field-validator>
</field>
<field name="contactEmail">
<field-validator type="email">
<message>Contact must be an email address</message>
</field-validator>
</field>
<field name="note">
<field-validator type="required">
<message>Note is required</message>
</field-validator>
</field>
</validators>
With struts.ui.html5.constraints at its default (false) the page rendered plain inputs; with true the same view
produced:
<input type="text" name="title" value="" id="html5_title" required="required" data-msg-requiredstring="Title is required"
minlength="2" maxlength="40" data-msg-stringlength="Title must have 2 to 40 characters"/>
<input type="text" name="isbn" value="" id="html5_isbn" pattern="(?:\d{3}-\d{10})|[\x00-\x20]*"
data-msg-regex="ISBN must look like 978-0441172719"/>
<input type="number" name="pages" value="" id="html5_pages" min="1" max="5000" data-msg-int="Pages must be between 1 and 5000"/>
<input type="text" name="contactEmail" value="" id="html5_contactEmail" data-msg-email="Contact must be an email address"/>
<textarea name="note" id="html5_note" data-msg-required="Note is required"></textarea>
Each rule of the table is visible. title gets required (from requiredstring) and minlength/maxlength (the
stringlength has trim="false"). isbn has no requiredstring that trims, so the pattern is wrapped to accept a
whitespace-only value that the server accepts too ((?:...)|[\x00-\x20]*). pages already has type="number", so it
gets min and max. contactEmail has no attribute but a data-msg-email: Struts ships no JavaScript that reads the
data-msg-* attributes; they are a hook for the application’s own script. note has a required validator that is
not emitted on a textarea, only the message. struts.htmlConstraintProvider selects the provider (default
struts); an application that wants another mapping implements HtmlConstraintProvider and registers it under that
constant. requiredLabel on a tag only draws the asterisk and has nothing to do with the required attribute.
Generated JavaScript validation (deprecated)
<s:form validate="true"> in the xhtml and css_xhtml themes generates JavaScript from the same validation files.
It supports only a subset of the validators (required, requiredstring, stringlength, regex, email, url, int,
double), not the visitor validator, and needs the action name and namespace to be resolvable. It is deprecated since
Struts 7.4.0 (WW-5694) and is announced for removal in Struts 8.0.0 (WW-5696); new code should use the HTML5 constraints
or the AJAX validation below.
AJAX validation
The third mechanism sends the form to the server on each onblur event and shows the errors without a page reload. The server
side is the jsonValidation interceptor and a JSON result of the JSON plugin (see JSON Plugin & AJAX); the
client side is the application’s own script. The whole stack, including the visitor validators and validate(), runs,
so every rule that works on submit works in the browser. The guide notes that the former Dojo client of the framework is gone.
Bean Validation as an Alternative
The Validation page points out that Struts 2.5 added the Bean Validation plugin, an alternative to the classic validation
described here. The plugin runs Jakarta Validation constraints (annotations such as @NotNull and @Size); the page
Bean Validation Plugin covers it. Use
the classic framework described here when the rules belong to the web form and need Struts features (visitor contexts,
validate(), message keys of the action); use Bean Validation when the same constraints must also apply to the
persistence layer.
From the Struts 1 Validator
Struts in Action (2002) describes the Commons Validator of Struts 1.1: rules in validator-rules.xml and
validation.xml, a ValidatorForm or ValidatorActionForm base class, html:javascript for the client, and
localized and pluggable validators. The Struts 2 concepts that replace each part:
| Struts 1 (book) | Struts 7.4 |
|---|---|
|
|
|
|
|
No base class: the action implements nothing, |
|
The |
Messages in the |
|
|
HTML5 constraints (7.4); generated JavaScript is deprecated; AJAX validation through the JSON plugin |
Multi-page forms with the |
Validation contexts (action alias files) and visitor contexts; the page for each step is a mapping |
Validation of the |
Any property of the action, nested objects (visitor) and the model ( |
Source: Apache Struts — Validation
The Bookshelf End to End: Validating the Add Title Form
The pieces fit together in one small feature. The user opens add-form, which maps AddBookAction to input and so skips
validation; the form posts to add, a mapping of the same class with a result input that shows the form again.
AddBookAction-validation.xml holds the rules; workflow returns input when any rule fails; and the view prints
each error next to its field.
<action name="add-form" class="org.example.bookshelf.AddBookAction" method="input">
<result name="input">/WEB-INF/views/add.jsp</result>
</action>
<action name="add" class="org.example.bookshelf.AddBookAction">
<result name="input">/WEB-INF/views/add.jsp</result>
<result>/WEB-INF/views/report.jsp?r=success</result>
</action>
The requests of the walk-through and what each one showed:
| # | Request | Result |
|---|---|---|
1 |
|
the empty form, no errors: the method |
2 |
|
the form again, with "Title is required" (the |
3 |
|
|
4 |
|
|
Nothing in AddBookAction itself refers to validation: it has no validate() method and no annotation. Adding the
rule "a book with fewer than ten pages must not have a title longer than 20 characters" took one validator element in
the XML file, and the action method still receives only valid data.
Source: Apache Struts — Basic Validation
Pitfalls
| Symptom or error | Cause | Fix |
|---|---|---|
HTTP 404 "No result defined for action … and result input" (observed) |
An error was recorded and |
Add |
The first visit to the form shows validation errors |
The form and the submission share a mapping; the missing parameters fail the rules |
Map the form to |
A validator does nothing and the file is ignored |
The file name does not match the class ( |
Name it |
Every field is "valid" although the values are wrong |
The setter has no |
Annotate the setter; remember |
HTTP 404 "Invalid validation type: isbm" (observed) |
A |
Fix the type or register the class |
|
An empty request parameter sets the property to |
Use |
Two messages for one field (observed) |
A rule in the class file and another in an alias or parent file; or a conversion error and a validator |
Keep a rule in one file; set |
A plain validator with |
It is in an alias file; the class file’s validators ran first |
Put the short-circuit and the rules it guards in the same file |
A false error from a |
The expression calls a method on a property that is |
Start the expression with a test for |
A |
The visitor on |
Set |
|
Annotations apply to every method unless the parameter restricts them to the invoked method |
Use |
|
It checks a pattern, not the check digit |
Write a validator, or validate the Luhn digit in |
A date message shows |
|
Write the bounds in the text |
A validator message evaluates user input |
|
Reference only action properties that the application sets; never echo parameters into an OGNL expression |
A |
XML special characters in a parameter |
Wrap the pattern in |
HTTP 404 for an action named with a slash (observed) |
|
Set |
The browser never rejects an invalid value although HTML5 constraints are on |
The mapping is conservative: |
Set |
Source: Apache Struts — Validation
What Changed Recently
| Release | Change |
|---|---|
Struts 2.0.8 |
The |
Struts 2.1 |
The |
Struts 2.5 |
The Bean Validation plugin, as an alternative to the classic validators |
Struts 7.0 |
The package |
Struts 7.3.0 |
|
Struts 7.4.0 |
HTML5 constraint validation ( |
Source: Apache Struts — Validation
Where the Books Are Outdated
Struts 2 in Action (Manning, 2008, chapter 10, the validation framework) is written for Struts 2.0 and 2.1 on Java 5,
and Struts in Action (Manning, 2002, chapter 12) covers the Struts 1 Validator. The concepts survive in the first
(interceptor pair, -validation.xml, validators, short-circuit, visitor, contexts, annotations); the details moved.
| The book’s era | Today (Struts 7.4) |
|---|---|
Classes in |
|
The DOCTYPE |
|
A |
Only custom validators; |
Setters populate the action without any annotation |
|
|
Not needed; annotations on setters and methods work |
Client-side validation with |
HTML5 constraints in the |
Struts 1 |
Struts 2 files per action and per alias, validators registered by name, no form bean; see the comparison above |
Parameters such as |
Still available, but message and |
Notes on the Official Documentation
Reading the official pages against the 7.4.0 jar showed inconsistencies that a reader will meet; they are listed so that a mismatch is not mistaken for a mistake in the example code.
-
The Validation page’s "Writing custom validators" list and the Default Workflow Interceptor page still name classes in
com.opensymphony.xwork2; in 7.4.0 they are inorg.apache.struts2.validator.validatorsandorg.apache.struts2. -
The "Bundled Validators" list and the annotation index omit
longandcreditcard(with no page) and the annotations@CreditCardValidator,@LongRangeFieldValidator,@ShortRangeFieldValidatorand@ConditionalVisitorFieldValidator. -
The regex validator page lists the parameter as
regexp; the class hassetRegexand every example usesregex. -
The Form Validation Using XML tutorial spells the parameter
fieldname; the other pages spell itfieldName. Both work. -
The Validation page says
alwaysInvokeValidateis an option of validation; on the Default Workflow Interceptor page it is mentioned in the workflow text, but the 7.4.0 jar has the setter only on the validation interceptor. -
The section on slashes in action names does not mention
struts.mapper.alwaysSelectFullNamespace, without which the mapping is not found. -
The Validation page says a short-circuited plain validator stops the field validators; verified within one file, but across files the class file’s validators run first.
References
-
Apache Struts — Client Side Validation, HtmlConstraintProvider
-
Apache Struts — Client Side Validation, HTML5 Constraint Validation
-
Apache Struts — Client Side Validation, Pure JavaScript Client Side Validation (deprecated)
-
Apache Struts — Client Side Validation, The governing rule: never false-reject
-
Apache Struts — Validation, How Validators of an Action are Found
-
Apache Struts — Validation, Localizing and Parameterizing Messages
-
Apache Struts — Validation, Non-Field Validator Vs Field-Validator
-
Apache Struts — Validation, Skipping Validators on a Conversion Error
-
Apache Struts — Validation, Validation Files for Actions with Slashes
-
Struts Javadoc — ValidationWorkflowAware.getInputResultName()