Programming Thoughts
Struts 2 - Annotation-based Validation
Fails Out-of-the-Box

A Struts 2 feature conflicts with others

Struts 2 is a popular MVC framework for Java-based web applications. As Java evolved and supported new language features over the years, Struts evolved to utilise them. In particular, Struts used the annotations feature, in-code configuration that eliminates separate, arcane configuration files. Alas, annotation-based form validation doesn't work out-of-the-box nor, particularly model driven, which is required for Post/Redirect/Get.

The ancient method

If a user submits an invalid form, appropriate error messages must be displayed with the rejected form, which differ between applications. Older versions of Struts 2 required separate configuration files for each Struts Action. See Struts documentation here and here.

I never bothered with that and used the Action's validate method to convert and validate forms, re-using code written for Struts 1 projects. This kept the validation rules and error messages in the same place as the form itself, making it easier to maintain. Even then, it involved a fair amount of boiler plate code, such as below.

@Override public void doValidate(ValidationAware validationAware, TextProvider textProvider) { parsedId = ValidatorFormUtil2.checkRequiredInt(id, validationAware, "The id must be a number", "The id must be a number"); ValidatorFormUtil2.checkRequiredString(name, validationAware, "A name is required"); parsedWatermark = ValidatorFormUtil2.checkBoolean(watermark); }

The new annotations

Struts 2 can replace these using annotations, such as below.

@RequiredFieldValidator(message = "Operand 1 is required") @ConversionErrorFieldValidator(message = "Operand 1 must be a number", repopulateField = true) public int getOperand1() { return operand1; }

Example

A simple example using annotation-based validation is running here and the pertinent annotations are shown below.

@ConversionErrorFieldValidator(message = "Operand 1 must be a number", repopulateField = true, shortCircuit = true) @RequiredFieldValidator(message = "Operand 1 is required") public int getOperand1() { return operand1; } public void setOperand1(int operand1) { this.operand1 = operand1; } @RequiredStringValidator(message = "Operator is required") public String getOperator() { return operator; } public void setOperator(String operator) { this.operator = operator; } @ConversionErrorFieldValidator(message = "Operand 2 must be a number", repopulateField = true, shortCircuit = true) @RequiredFieldValidator(message = "Operand 2 is required") public Integer getOperand2() { return operand2; } public void setOperand2(Integer operand2) { this.operand2 = operand2; }

If the form is submitted with empty form fields, it's rejected with the following error messages.

Figure 1: Error messages of rejected, empty form (embedded form)

It seems the required message for operand1 is ignored because it's a primitive int.

If the form is submitted with junk, it's rejected with the following error messages.

Figure 2: Error messages of rejected, junk form (embedded form)

The first and third error messages are the defaults for the ancient method.

What's going wrong

For figure 1, the number type converter tries and fails to parse empty string for the primitive int field, generating a conversion error, but doesn't bother for Integer fields, setting to null. Annoyingly inconsistent.

For figure 2, the params interceptor tries and fails to convert junk values, generating conversion errors, and these are picked up by the conversionError interceptor to write the appropriate error message. That's the non-annotation validation method. However, validation interceptor detects the annotations and tries to convert the values itself, writing its own error messages. The out-of-the-box, default interceptor stack runs both old school and new school validation.

Example using separate form

The same example but using the form as a separate class is running here. This requires use of the VisitorFieldValidator annotation, such as below.

@VisitorFieldValidator(appendPrefix = false) public ArithmeticForm geForm() { if (form == null) { form = new ArithmeticForm(); } return form; }

Rejection of empty fields and junk results in similar error messages to figures 1 and 2. The cause is the same.

Example using multiple, separate forms

The same example but using two forms is running here.

If the form is submitted with valid values, it's rejected due to missing values of the other form!

Figure 3: Incorrect rejection of valid form (two forms)

What's going wrong

The validation interceptor is finding both VisitorFieldValidator annotations and is, thus, validating both forms. There is no way to select which form to validate depending on function called, even using ConditionalVisitorFieldValidator.

Sadly, this is a degradation compared to non-annotation validation as that will invoke the validation function named after the processing function being called, allowing different validation functions to have different forms.

Example using ModelDriven

The same example but using ModelDriven is running here. This requires use of the VisitorFieldValidator annotation, such as below.

@Override @VisitorFieldValidator(appendPrefix = false) public ArithmeticForm getModel() { if (form == null) { form = new ArithmeticForm(); } return form; }

If the form is submitted with empty form fields, it's rejected with the following error messages.

Figure 4: Error messages of rejected, missing values (ModelDriven)

If the form is submitted with junk values, it's rejected with the following error messages.

Figure 5: Error messages of rejected, junk values (ModelDriven)

What's going wrong

The model driven interceptor pushes the form on the Value Stack, on top of the Action, so the parameters interceptor sets the fields of that instead of the Action. However, the validation interceptor trawls the Value Stack for the annotations on the objects it's going to annotate and finds the annotations twice.

Fixing out-of-the-box

The table below summaries what goes wrong with out-of-the-box configuration and how to fix.

Failure Reason Fix
Both non-annotation and annotation error messages Conversion error and validation interceptor are both in the default stack Remove conversion error interceptor (or stick to string only fields)
Every form is validated, not just the desired one Annotation interceptor can't distinguish the current form None
Duplicate error messages when using ModelDriven feature Validation interceptor doesn't realise the form appears twice in the Value Stack None

The second problem is rendered moot by restructuring Actions for Post/Redirect/Get, where form processing Actions only deal with one form. Alas, the interceptors written to support this use ModelDriven, as explained in Struts 2 - Post/Redirect/Get, Use ModelDriven. Thus, the bug above renders standard, annotation-based validation unusable.

Next part

Continued in Fails Out-of-the-Box Example.