Programming Thoughts
Struts 2 - Annotation-based Validation
Workflow Redesign

Improving a fix to a Struts 2 feature

Struts 2 is a popular MVC framework for Java-based web applications but its annotation-based validation doesn't properly work. So, an alternative was created but it has design limitations. This article considers a redesign to overcome them.

Alternate design workflow

Before manual validation can be considered, the stages of the alternate design must first be described.

  1. Dispatcher - creates Interceptor stack, creates Value Stack and places Action on it.
  2. ModelDriven interceptor - standard interceptor, if Action implements ModelDriven, places model on Value Stack
  3. Parameters interceptor - standard interceptor reads request parameters and sets properties on Value Stack, affecting Action or model as appropriate. Would create conversion errors but alternate design avoids them by using string fields.
  4. Conversion Error interceptor - standard interceptor that writes conversion errors as Value Stack expression overrides and writes error message to Action for each, but no conversion errors are created.
  5. Annotation Validation interceptor - custom interceptor that finds form field annotations on model or Action, reads string values, adjusts, converts and validates, sets converted values, and writes any error messages.
  6. Validation interceptor - standard interceptor calls Action's validate method, which can write error messages. Confusingly, the default reference name is validation but the class name is AnnotationValidationInterceptor
  7. Workflow interceptor - standard interceptor checks for error messages, setting input result and skipping Action if any.
  8. Form driven Action - processes form and decides result.
  9. Message and form store interceptors - custom interceptor that stores messages and form in session.
  10. Result - writes response, which is usually a redirect.
  11. Dispatcher
  12. Message and form retrieve interceptors - custom interceptor that retrieves messages and form in session and injects into Action.
  13. Viewer Action - retrieves other data for display, including other forms that weren't injected.
  14. [Form formatter interceptor] - if custom interceptor was used, it would execute here. Formats new forms.
  15. Result - writes response, which is usually a JSP page.

Parameters interceptor

Removing formatted form fields means the Parameter interceptor attempts to set form fields and can generate conversion errors, even if they have alternate annotations. It's possible extend it to ignore particular request parameters but requiring no annotation to be mandatory means all form fields are subject to the alternate validator. Thus, the alternate validator must assume the role of this interceptor. This also removes the purpose of the Conversion Error interceptor.

To assume the Parameters interceptor's role, its other behaviour must be described.

  • Request parameter names are OGNL expressions - form fields are set using ValueStack.setParameter(String expr, Object value), where expr can contain object path names.
  • Ignores OGNL expressions that evaluate an embedded OGNL expression - this is required to stop injection attacks.
  • Default accepted and excluded parameter name - this is required to stop injection attacks.
  • Action can filter parameter names - Actions can implement ParameterNameAware to filter parameter names for security reasons.
  • Action can filter parameter values - Since version 6.1.0, Actions can implement ParameterValueAware to filter parameter values for security reasons.
  • Ordered parameters - parameter names, which are OGNL expressions, with have the fewest object 'hops' are set first. This allows setting a property early in order to dynamically create objects to be set later in order.
  • Maximum parameter name length - default 100.
  • Included/Excluded methods - achieved by extending MethodFilterInterceptor.

By directly setting the form, not via the Value Stack, parameter names containing OGNL expressions cannot be evaluated, making the first six items impossible. Items two to five are to stop OGNL injection attacks anyway, so their absence isn't missed. Item one, setting parameters with object path names, is necessary for the expected use of Struts, where the Action that displays a page also processes every form on that page. If a page contains multiple forms, it's often easiest to separate each form into their own object and parameter path names distinguish between them. Item six allows a needed form to be created before its properties are set. The absence of OGNL path names is not a problem as Post/Redirect/Post leads to form processing Actions, which process only one form, and forms can be flat, not in an object graph.

That is, the Annotation Validation interceptor will only enforce maximum parameter length and extend MethodFilterInterceptor, which it already does.

Form Store and Retrieve interceptors

Removing formatted form fields also means user's rejected data entry, the conversion errors, must be preserved in session. Fortunately, the custom interceptors described in Post/Redirect/Get, More Form Injection do the same for forms and can be co-opted. The preserved data becomes.

public static class StoredForm { private Object form; // The form being preserved private Map<String, ConversionData> conversionErrors; // Form fields that failed conversion or validation private boolean invalid; // Whether form was rejected private String owningURL; // URL of viewer Action set by form retrieval interceptor private Class<?> processor; // Class of form processing Action ...

For Form Store.

@Override public String intercept(ActionInvocation invocation) throws Exception { FormDriven<?> formDriven; StoredForm storedForm; String result; result = invocation.invoke(); if (invocation.getAction() instanceof FormDriven) { formDriven = (FormDriven<?>)invocation.getAction(); if (formDriven.getModel() != null && formDriven.getModel().getClass() != NullForm.class) { storedForm = new StoredForm(); storedForm.setForm(formDriven.getModel()); storedForm.setConversionErrors(invocation.getInvocationContext().getConversionErrors()); storedForm.setInvalid(formDriven.formValidationFailed()); storedForm.setProcessor(invocation.getAction().getClass()); ActionContext.getContext().getSession().put(SESSION_STORED_FORM, storedForm); } else { ActionContext.getContext().getSession().put(SESSION_STORED_FORM, null); } } return result; }

For Form Retrieval, the names of each conversion error must be changed. In standard Struts 2 design, where the same Action processes and displays a form, the OGNL property name that sets a form field is the same to read it. With separate form processing and view Actions, forms do not know the field name used by the view Action to display it. The Form Retrieval interceptor does as it's injecting into the field and the OGNL property name is "<inject field name>.<conversion error name>".

protected void restoreForm(ActionInvocation invocation, StoredForm storedForm) { Collection<Field> fields; Set<String> fieldNames; Class<?> actionClass; boolean receive; actionClass = invocation.getAction().getClass(); fields = getProperties(actionClass); fieldNames = new HashSet<>(); for (Field field: fields) { receive = fieldReceives(actionClass, field, storedForm); if (receive) { try { injectForm(invocation, storedForm, field); addConversionErrors(invocation, storedForm); fieldNames.add(field.getName()); } catch (Exception e) { LOG.error("Set stored form of Struts action member variable failed" + " Struts action=" + invocation.getAction().getClass() + " field=" + field.getName(), e); } } } storedForm.setOwningURL(getFullURL()); storedForm.setOwningActionClass(actionClass); storedForm.setOwningActionFieldNames(fieldNames); } protected void addConversionErrors(ActionInvocation invocation, StoredForm storedForm, Field field) throws Exception { String fieldName, propertyName; fieldName = field.getName(); for (Entry<String, ConversionData> entry: storedForm.getConversionErrors().entrySet()) { propertyName = fieldName + "." + entry.getKey(); invocation.getInvocationContext().getConversionErrors().put(propertyName, entry.getValue()); } }

Rejected Form Values interceptor

The Conversion Errors interceptor places parameter values that couldn't convert onto the Value Stack and writes error messages for each but there's no way to enable the desired, first function and not the other, so the interceptor must be replaced.

Alternate redesign workflow

The redesigned workflow brecomes.

  1. Dispatcher - creates Interceptor stack, creates Value Stack and places Action on it.
  2. ModelDriven interceptor - standard interceptor, if Action implements ModelDriven, places model on Value Stack
  3. Parameters interceptor - standard interceptor reads request parameters and sets properties on Value Stack, affecting Action or model as appropriate. Would create conversion errors but alternate design avoids them by using string fields.
  4. Conversion Error interceptor - standard interceptor that writes conversion errors as Value Stack expression overrides and writes error message to Action for each, but no conversion errors are created.
  5. Annotation Validation interceptor - custom interceptor that finds form field annotations on model or Action, reads string values and/or requests parameters, , adjusts, converts and validates, set converted values, writes conversion errors as Value Stack expression overrides, and writes any error messages.
  6. Validation interceptor - standard interceptor calls Action's validate method, which can write error messages. Confusingly, the default reference name is validation but the class name is AnnotationValidationInterceptor
  7. Workflow interceptor - standard interceptor checks for error messages, setting input result and skipping Action if any.
  8. Form driven Action - processes form and decides result.
  9. Message and form store interceptors - custom interceptor that stores messages and form in session.
  10. Result - writes response, which is usually a redirect.
  11. Dispatcher
  12. Message and form retrieve interceptors - custom interceptor that retrieves messages, conversion errors and form in session and injects into Action.
  13. Rejected form values interceptor - custom interceptor that writes conversion errors as Value Stack expression overrides.
  14. Viewer Action - retrieves other data for display, including other forms that weren't injected.
  15. [Form Formatter interceptor] - if custom interceptor was used, it would execute here. Formats new forms. For all forms that are FormattableForm, write a fake, formatted form to the Value Stack.
  16. Result - writes response, which is usually a JSP page, .

This reduces three interceptors into one, reducing flexibility, but this is unavoidable due to annotations integrating policy with messages. For ease of coding, converters and validators can define their own messages or message keys. Also, conversion and validation steps are too interwoven, with non-conversion validation executing before conversion, post-conversion afterwards and rejection at one step stopping further ones. Instead, flexibility is achieved with numerous, protected functions providing extension and override points.

Next part

Continued in Interceptor Redesign.