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.

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.

flowchart TD R["Request for the action"] --> P["params: request values copied onto the action"] P --> C["conversionError: values that failed conversion are remembered as field errors"] C --> X{"Method named in excludeMethods or marked with SkipValidation?"} X -- "yes, validation is skipped" --> A X -- "no" --> M["validateMethodName() or validateDoMethodName(), if the action has one"] M --> V["validate(), unless alwaysInvokeValidate is false"] V --> D["XML files and annotations: plain validators, then field validators"] D --> W{"workflow: any action error, field error or conversion error?"} W -- "yes" --> I["input result, or the name chosen by ValidationWorkflowAware, ValidationErrorAware or InputConfig"] W -- "no" --> A["The action method runs, then the result"]

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.

Table 1. The classes of the example WAR
Class Mappings Concept

ProgrammaticAction

programmatic-save, -update, -list, -input, -nodefault, -declarative-only, -noinput

validate(), validateSave(), validateDoUpdate(), @SkipValidation and the parameters of the two interceptors

WorkflowAwareAction, ErrorAwareAction

workflow-aware, error-aware, custom-workflow

Choosing the name of the input result from the action

AddBookAction

addBook, strict, order, catalog/add, add, add-form, no-input

XML validation with every built-in validator; alias files; the form view

ReviewedBookAction

reviewed

A subclass of AddBookAction: validation files and inheritance

ShortCircuitAction

shortcircuit

Plain against field validators and short-circuit

ShelfAction, Book

shelf

Visitor and conditional visitor validators, validation contexts

ModelBookAction, FlatModelAction

model, model-flat

ModelDriven with the visitor validator

LocalizedBookAction

localized

Message keys, message parameters and the _es bundle

LegacyNameAction, BrokenAction

legacy-names, broken

The parameter name fieldName and an unknown validator type

AnnotatedBookAction, AnnotatedMoreAction, IsbnValidator

annotated, annotated-preview, annotated-publish, annotated-only, annotated-only-publish, annotated-more

Annotation validation and the custom isbn validator

Html5BookAction

html5-form, html5

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.

Table 2. Parameters of the validation and workflow interceptors (7.4.0 jar)
Parameter Default Effect

validation.alwaysInvokeValidate

true

When true, validate() is called even when a method-specific validateMethodName() was found and called

validation.programmatic

true

When true and the action implements Validateable, call validate() and the method-specific methods

validation.declarative

true

When true, run the XML and annotation validators

validation.validateAnnotatedMethodOnly

false

When true, use only the validation annotations that sit on the invoked method

excludeMethods, includeMethods

see the stack

Comma separated method names (a single * means all); includeMethods wins over excludeMethods

workflow.inputResultName

input

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

addFieldError(String field, String message)

Adds a message for one input field; the view shows it next to that field and the workflow interceptor treats it as an error

addActionError(String message)

Adds a message that belongs to the action as a whole, not to one field

addActionMessage(String message)

Adds an informational message; it is not an error and does not trigger input

hasErrors(), hasFieldErrors(), hasActionErrors()

True when any error, any field error or any action error exists

getFieldErrors(), getActionErrors(); clearErrors() of ActionSupport

Read the map and the collection; empty both (clearErrors() is a method of ActionSupport, not of the interface)

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:

ProgrammaticAction: validate(), validateSave(), validateDoUpdate() and @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:

Table 3. What ran and what came back (observed, Struts 7.4.0 on Tomcat 11.0.26)
Request Result Methods called, in order Field errors

programmatic-save?title=&pages=0

input

validateSave(), validate()

pages: Pages must be positive; title: Title is required

programmatic-save?title=Dune&pages=5

success

validateSave(), validate(), save()

none

programmatic-update?title=

input

validateDoUpdate(), validate()

title

programmatic-list?title=

success

list()

none: @SkipValidation on the method

programmatic-input?title=

input

none

none: the method input is excluded, and the result input comes from the method itself

programmatic-nodefault?title=&pages=0 (alwaysInvokeValidate false)

input

validateSave()

pages only

programmatic-declarative-only?title=&pages=0 (programmatic false)

success

save()

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.

The name of the result can be changed in four ways, from the broadest to the narrowest:

  1. The stack parameter workflow.inputResultName, for every action that uses the stack (the mapping custom-workflow above answered with the result invalid).

  2. The interface org.apache.struts2.interceptor.ValidationWorkflowAware, whose only method, getInputResultName(), lets the action choose the name for its own errors.

  3. The interface org.apache.struts2.interceptor.ValidationErrorAware, whose method actionErrorOccurred(String currentResultName) is called with the name input and may return another one; it is the hook for an action that wants to log or to react to the failure.

  4. The annotation @InputConfig(resultName = "form") on one action method (or methodName to call another method of the action instead of returning a result).

WorkflowAwareAction and ErrorAwareAction
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; }
}
Table 4. The result names that came back (observed)
Mapping, request with an empty title Result Remark

workflow-aware?title=

edit-form

getInputResultName() of the action

error-aware?title=

retry

actionErrorOccurred(input) was called (the trace shows it) and returned retry

custom-workflow?title=&pages=0

invalid

workflow.inputResultName

annotated-publish?title=

form

@InputConfig(resultName = "form") on publish(); shown with the annotation action below

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

AddBookAction-validation.xml

Every mapping of the class AddBookAction, whatever its name

AddBookAction-strict-validation.xml

Only the mapping whose name (its alias) is strict; merged with the class file

AddBookAction-catalog-add-validation.xml

The mapping named catalog/add: slashes in the alias become hyphens (requires struts.enable.SlashesInActionNames)

Book-validation.xml, Book-shelf-validation.xml

The class Book when it is validated as a property (visitor validator), in the default context and in the context shelf

ReviewedBookAction-validation.xml

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:

Table 5. The elements and attributes of xwork-validator-1.0.3.dtd
Element Attributes Content

validators

none

One or more field or validator

field

name (required)

One or more field-validator

field-validator

type (required), short-circuit (true or false, default false)

Zero or more param, then one message

validator

type (required), short-circuit

Zero or more param, then one message

param

name (required)

Text

message

key (optional)

Text, and param elements for message parameters

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.

AddBookAction-validation.xml (header left out)
<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:

AddBookAction
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 = []

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:

Table 6. The 17 validators of Struts 7.4.0 (default.xml)
Name Class (org.apache.struts2.validator.validators) Kind What it checks

required

RequiredFieldValidator

field

The property is not null (an empty string passes)

requiredstring

RequiredStringValidator

field

A String that is not null and not empty; trim (default true) decides whether blanks count

int

IntRangeFieldValidator

field

An integer within min and max, or minExpression and maxExpression when parse is true

long

LongRangeFieldValidator

field

The same for long

short

ShortRangeFieldValidator

field

The same for short

double

DoubleRangeFieldValidator

field

A double within minInclusive, maxInclusive, minExclusive and maxExclusive (or the ...Expression forms)

date

DateRangeFieldValidator

field

A date within min and max

expression

ExpressionValidator

plain

An OGNL expression over the value stack that must be true

fieldexpression

FieldExpressionValidator

field

An OGNL expression, reported against one field

email

EmailValidator

field

A non-empty string that matches an e-mail regular expression

creditcard

CreditCardValidator

field

A string that matches the card-number pattern

url

URLValidator

field

A string that matches the URL pattern (urlRegex)

visitor

VisitorFieldValidator

field

Validates an object property with the object’s own rules

conditionalvisitor

ConditionalVisitorFieldValidator

field

A visitor that runs only when an expression is true

conversion

ConversionErrorFieldValidator

field

Reports a conversion error of the field with your message

stringlength

StringLengthFieldValidator

field

A string length within minLength and maxLength; trim (default true)

regex

RegexFieldValidator

field

A string that matches regex; caseSensitive and trim default to true

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:

Table 7. required on a String (observed)
Request Result Why

html5?...&note= (empty value)

success

The property is "", not null: required passes

html5?...&note=%20 (one blank)

success

A blank is not null either

html5?... (no note parameter)

input

The property stays null: required fails

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>
Table 8. The messages that the range validators produced (observed)
Field Value sent Message

pages

0

Pages must be between 10 and 800, got 0 (the bounds come from the action’s getters)

edition

100

Edition must be between 1 and 99

copies

-1

Copies must be between 0 and 100000

price

0

Price must be above 0 and at most 500

published

01/01/1800

Published must be between 1/1/00 and 12/31/30

published

notadate

Invalid field value for field "published". and Published must be a date such as 01/31/2024 (two messages)

published

2000-01-15

no error; the converter accepts more formats than the validator’s min and max strings

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>
Table 9. Results of the string validators (observed)
Field Value sent Outcome

title

D

Title must have 2 to 40 characters

title

empty

Title is required; the stringlength after it did not report, because the requiredstring short-circuits

isbn

abc

ISBN must look like 978-0441172719 and ISBN checksum is wrong

contactEmail

nope

Contact must be an email address

homepage

nope

Homepage must be a URL

giftCard

123

Gift card number is not valid

giftCard

4111111111111112

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 name="x"><field-validator type="...">

Field validators only; the field name is inherited, and the DTD does not allow a plain validator inside field

<validator type="...">

Both kinds; a field validator written here needs <param name="fieldName">x</param>

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:

ShortCircuitAction-validation.xml (header left out)
<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>
Table 10. Short-circuiting (observed)
Request Field errors Action errors

shortcircuit?title=&isbn=&pages=0

none

Pages must be positive

shortcircuit?title=&isbn=bad&pages=6000

title: Title is required; isbn: ISBN must look like 978-0441172719

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:

flowchart LR subgraph sources["Sources for action name strict, class AddBookAction"] S1["Parent classes and interfaces: Parent-validation.xml"] S2["AddBookAction-validation.xml: every mapping of the class"] S3["AddBookAction-strict-validation.xml: only the mapping named strict"] S4["Annotations on the action class and its methods"] end sources --> L["One merged list of validators: duplicates are all kept"] L --> O["Each file: plain validators first, then field validators in file order"] O --> E["Errors: field errors by field name, action errors for plain validators"]

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:

AddBookAction-strict-validation.xml (header left out)
<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"):

AddBookAction-order-validation.xml (header left out)
<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:

LocalizedBookAction-validation.xml (header left out)
<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 and LocalizedBookAction_es.properties
# 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
Table 11. The same request in two locales (observed, localized?title=&isbn=&pages=0, header Accept-Language)
Language Field errors

none (English)

title: The title of the book is required; pages: Pages must be between 1 and 5,000; isbn: ISBN is required.

es

title: El titulo del libro es obligatorio; pages: Las paginas deben estar entre 1 y 5.000; isbn: ISBN es obligatorio.

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:

IsbnValidator: a field validator
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:

src/main/resources/validators.xml
<?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:

Book-validation.xml and Book-shelf-validation.xml (headers left out)
<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>
ShelfAction-validation.xml (header left out) and the action
<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.

Table 12. Visitor results (observed)
Request Field errors

shelf?book.title=&book.isbn=&book.pages=0&draft=false

book.isbn: Book:ISBN is required (from Book-validation.xml); book.title: Book:A shelved book needs a title; book.pages: Book:A shelved book needs at least 1 page (both from the context file)

shelf?book.title=Dune&book.isbn=978-0441172719&book.pages=3&books[0].title=&books[0].isbn=978-0441172719&books[0].pages=0&...

books[0].title: Shelf:A shelved book needs a title; books[0].pages: Shelf:A shelved book needs at least 1 page (the second element was valid)

shelf?...&draft=true&books[0].title=&books[0].pages=0

none: conditionalvisitor skipped the list

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:

ModelBookAction and ModelBookAction-validation.xml
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:

Table 13. The 18 validator annotations of the Struts 7.4.0 jar, and @Validations
Annotation @Validations member

@RequiredFieldValidator, @RequiredStringValidator

requiredFields, requiredStrings

@IntRangeFieldValidator, @LongRangeFieldValidator, @ShortRangeFieldValidator, @DoubleRangeFieldValidator, @DateRangeFieldValidator

intRangeFields, longRangeFields, shortRangeFields, doubleRangeFields, dateRangeFields

@StringLengthFieldValidator, @RegexFieldValidator, @EmailValidator, @CreditCardValidator, @UrlValidator

stringLengthFields, regexFields, emails, creditCards, urls

@ExpressionValidator, @FieldExpressionValidator

expressions, fieldExpressions

@VisitorFieldValidator, @ConditionalVisitorFieldValidator, @ConversionErrorFieldValidator

visitorFields, conditionalVisitorFields, conversionErrorFields

@CustomValidator (with @ValidationParameter for its parameters)

customValidators

@Validations itself

groups the others on one method

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:

AnnotatedBookAction
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; }
}
Table 14. Annotation results (observed)
Request Outcome

annotated?title=Dune&isbn=978-0441172719&pages=5&publisher=X

success, execute() called

annotated?title=&isbn=978-0441172710&pages=0&contactEmail=bad&reviewer=bad&publisher=

input; title: Title is required (the stringlength after the short-circuiting @RequiredStringValidator did not report); isbn: ISBN checksum is wrong; pages: Pages must be between 1 and 5000; contactEmail: Contact must be an email address; reviewer: Reviewer must be an email address; publisher: Publisher is required.

annotated?title=<34 characters>&isbn=978-0441172719&pages=5&publisher=X&reviewer=a@b.org

input; action error "A short book needs a short title" (the @ExpressionValidator inside @Validations on execute())

annotated-preview?title=&isbn=bad&pages=0

success, preview() called: @SkipValidation

annotated-publish?title=&isbn=bad&pages=0&publisher=P

result form (not input) with the title, ISBN and pages errors: @InputConfig(resultName = "form")

annotated in Spanish (Accept-Language: es, empty publisher)

publisher: Editorial es obligatorio. (from AnnotatedBookAction_es.properties through key and messageParams)

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:

Table 15. validateAnnotatedMethodOnly (observed, AnnotatedBookAction)
Mapping with validation.validateAnnotatedMethodOnly set to true Outcome

annotated-only-publish?title=&isbn=bad&pages=0&publisher=P (method publish(), no validation annotation on it)

success, publish() called, no error at all: the setter annotations were ignored

annotated-only?title=<34 characters>&pages=5&... (method execute(), which carries @Validations)

input with the action error from the @ExpressionValidator; the field errors of the setter annotations are not applied either

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:

add.jsp
<%@ 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:

Table 16. Which validators become which attributes (summary of the Client Side Validation mapping table)
Validator Emits Condition

requiredstring

required

on text-entry controls and textarea

required

required

only on radio and file, and only while the bound property holds a value the validator would reject

stringlength

minlength / maxlength

on text-entry or textarea, and only with trim="false"

regex

pattern

on text-entry controls, with caseSensitive="true", trim="false" and an ECMAScript-safe regex; not for email or creditcard

int, short, long

min / max

only when the control is already type="number" or type="range"

double

min / max

same; only inclusive bounds

date, email, url, creditcard, expression, fieldexpression, conversion

none

never emitted

visitor

none for the visitor itself

the visited object’s validators apply to its nested fields

any validator with a message

data-msg-<validatorType>

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:

Table 17. Struts 1 Validator against Struts 7.4 validation
Struts 1 (book) Struts 7.4

validator-rules.xml (rule definitions)

validators/default.xml in the jar and an optional validators.xml for custom ones

validation.xml (forms, fields, rules)

ActionClass-validation.xml per action (and per alias), one field element per property

ValidatorForm, ValidatorActionForm

No base class: the action implements nothing, ActionSupport offers validate(); the interceptor reads the files

validate="true" and input in the action mapping

The validation and workflow interceptors of the stack, and the input result

Messages in the ApplicationResources bundle, keyed by rule

message with key in the XML file, or key and messageParams in annotations, resolved through the action’s bundles

html:javascript generating JavaScript

HTML5 constraints (7.4); generated JavaScript is deprecated; AJAX validation through the JSON plugin

Multi-page forms with the page attribute

Validation contexts (action alias files) and visitor contexts; the page for each step is a mapping

Validation of the ActionForm fields only

Any property of the action, nested objects (visitor) and the model (ModelDriven)

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.

add and add-form in struts.xml
<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:

Table 18. One feature, four requests (observed)
# Request Result

1

GET add-form

the empty form, no errors: the method input is excluded from validation and workflow

2

POST add with title empty, isbn=978-0441172710, pages=2000

the form again, with "Title is required" (the stringlength was short-circuited), "ISBN checksum is wrong" (custom validator) and "Pages must be between 10 and 800, got 2000" (int with expression bounds)

3

POST add with title=Dune, isbn=978-0441172719, pages=412

success: execute() ran

4

POST add with title=D, isbn=978-0441172719, pages=5

input: "Title must have 2 to 40 characters" and "Pages must be between 10 and 800, got 5"; the plain expression passed, because a one-character title is below the length at which the short-book rule applies

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.

Pitfalls

Table 19. Validation pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Struts 7.4.0 and Tomcat 11.0.26)
Symptom or error Cause Fix

HTTP 404 "No result defined for action …​ and result input" (observed)

An error was recorded and workflow returned input, but the mapping has no input result

Add <result name="input"> (or an inputResultName that exists)

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 method="input"; the default stack excludes input, back, cancel and browse

A validator does nothing and the file is ignored

The file name does not match the class (addBook-validation.xml), it is not in the same package directory, or it is under src/main/java and not copied to the class path

Name it ClassName-validation.xml and put it under src/main/resources in the package of the class

Every field is "valid" although the values are wrong

The setter has no @StrutsParameter (Struts 7), so the property keeps its default and the validator checks the default

Annotate the setter; remember depth for nested objects

HTTP 404 "Invalid validation type: isbm" (observed)

A type that is not registered; the name in validators.xml or in default.xml differs

Fix the type or register the class

required passes an empty text field (observed)

An empty request parameter sets the property to "", not null

Use requiredstring for text

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 struts.validators.skipValidatorsOnConversionError to true (7.3.0 and later)

A plain validator with short-circuit does not suppress field errors (observed)

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 fieldexpression or expression rule when the field was not sent (observed)

The expression calls a method on a property that is null; the failed evaluation counts as a failed rule

Start the expression with a test for null on the property

A ModelDriven form shows no error next to its field (observed)

The visitor on model stores the error as model.isbn, the field is named isbn

Set appendPrefix to false

@Validations on execute() also validates publish(); with validateAnnotatedMethodOnly the setters' annotations disappear (observed)

Annotations apply to every method unless the parameter restricts them to the invoked method

Use @SkipValidation for the methods that must not validate; use the parameter only when each method has its own annotation

creditcard accepts a wrong card number (observed)

It checks a pattern, not the check digit

Write a validator, or validate the Luhn digit in validate()

A date message shows 1/1/00 (observed)

${min} and ${max} of the date validator print dates in the locale’s short format

Write the bounds in the text

A validator message evaluates user input

${...} in a message, or a param of the message, names a request-controlled value

Reference only action properties that the application sets; never echo parameters into an OGNL expression

A regex pattern with < or & makes the XML invalid

XML special characters in a parameter

Wrap the pattern in <![CDATA[...]]>

HTTP 404 for an action named with a slash (observed)

struts.enable.SlashesInActionNames is on, but struts.mapper.alwaysSelectFullNamespace is true

Set struts.mapper.alwaysSelectFullNamespace to false, or avoid slashes in action names

The browser never rejects an invalid value although HTML5 constraints are on

The mapping is conservative: trim is true, the control is type="text", the validator is email or url

Set trim="false" where it is safe, give the input type="number", or write your own provider

What Changed Recently

Table 20. Changes to validation between the books' Struts 2.0 and Struts 7.4
Release Change

Struts 2.0.8

The validators.xml file no longer needs a copy of the default validators

Struts 2.1

The @Validation class marker is deprecated (documented as such; absent from the 7.4.0 jar): annotations on methods work without it

Struts 2.5

The Bean Validation plugin, as an alternative to the classic validators

Struts 7.0

The package com.opensymphony.xwork2 is renamed to org.apache.struts2: Validateable, ValidationAware, ValidatorSupport and the validators move to org.apache.struts2.validator; the DTD location uses https; @StrutsParameter is required on setters

Struts 7.3.0

struts.validators.skipValidatorsOnConversionError (default false)

Struts 7.4.0

HTML5 constraint validation (struts.ui.html5.constraints, struts.htmlConstraintProvider); the generated-JavaScript validation of the xhtml and css_xhtml themes is deprecated (WW-5694) and scheduled for removal in 8.0.0 (WW-5696)

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.

Table 21. Struts 2.0 and Struts 1 material against Struts 7.4
The book’s era Today (Struts 7.4)

Classes in com.opensymphony.xwork2.validator and com.opensymphony.xwork2.Validateable

org.apache.struts2.validator and org.apache.struts2.Validateable

The DOCTYPE xwork-validator-1.0.2.dtd at http://struts.apache.org (or the older OpenSymphony location)

xwork-validator-1.0.3.dtd at https://struts.apache.org/dtds/; the 7.4.0 jar also holds 1.0, 1.0.2

A validators.xml that repeats the default validators

Only custom validators; default.xml is in the jar

Setters populate the action without any annotation

@StrutsParameter is required; without it validation checks default values

@Validation on the class to enable annotations

Not needed; annotations on setters and methods work

Client-side validation with s:form validate="true" as the recommended way; Dojo AJAX validation

HTML5 constraints in the html5 theme, data-msg-* for messages; validate="true" JavaScript is deprecated; AJAX validation is a client-side script plus the jsonValidation interceptor

Struts 1 ValidatorForm, validator-rules.xml, validation.xml and html:javascript

Struts 2 files per action and per alias, validators registered by name, no form bean; see the comparison above

Parameters such as ${field} in messages as a convenience

Still available, but message and param expressions are OGNL evaluated against the value stack: keep user input out of them

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 in org.apache.struts2.validator.validators and org.apache.struts2.

  • The "Bundled Validators" list and the annotation index omit long and creditcard (with no page) and the annotations @CreditCardValidator, @LongRangeFieldValidator, @ShortRangeFieldValidator and @ConditionalVisitorFieldValidator.

  • The regex validator page lists the parameter as regexp; the class has setRegex and every example uses regex.

  • The Form Validation Using XML tutorial spells the parameter fieldname; the other pages spell it fieldName. Both work.

  • The Validation page says alwaysInvokeValidate is 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