Programming Thoughts
Struts 2 - Annotation-based Validation
Form Formatting
Actually reducing Struts 2's arcane configuration
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. As converter annotations define how a form field is parsed, automated formatting of forms, such as initialised from database records, should also reduce boilerplate code.
Interceptor
Obviously, automation is done with interceptors and only applies to viewer interceptor stacks. See Different Interceptor Stacks if you don't recognise the term. The correct time to format forms is after the viewer Action has created them but before result processing, which means adding a pre-result listener, as shown below. This means order within an interceptor stack doesn't matter. There's also a flag in case individual Actions need to disable automated formatting for some reason.
public class FormFormatterInterceptor extends AbstractInterceptor { public static class FormFormatterPreResultListener implements PreResultListener { @Override public void beforeResult(ActionInvocation invocation, String resultCode) { ValidatorLibrary.formatForms(); } } private boolean disabled; public boolean getDisabled() { return disabled; } public void setDisabled(boolean disabled) { this.disabled = disabled; } @Override public String intercept(ActionInvocation invocation) throws Exception { if (!disabled) { invocation.addPreResultListener(new FormFormatterPreResultListener()); } return invocation.invoke(); } }
Outer Loop
The core algorithm is to iterate over every field of the viewer Action that's a form and each of their fields
that has a converter annotation, to apply the converter's format function. The exceptions are, firstly,
injected forms from form processing Actions as such forms are supposed to be presented to the user as is.
Secondly, the Form
annotation, described in
More Form Injection can disable automated formatting
for a form, if this clashes with some other code. The new version of it shown below.
@Documented @Inherited @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface Form { public enum Reception {NEVER, ERROR, SUCCESS, ALWAYS} public boolean disableFormatting() default false; public Class<?>[] processors() default {}; public Reception reception() default Reception.ERROR; }
Function for the outer loop is:-
public static void formatForms() { Form formAnnotation; ActionContext actionContext; Collection<Field> actionFields; Object action; boolean doForm; actionContext = ActionContext.getContext(); action = actionContext.getActionInvocation().getAction(); actionFields = getProperties(action.getClass()); for (Field actionField: actionFields) { doForm = !fieldReceivedStoredForm(actionField) && !fieldIsNotForm(actionField); if (doForm) { actionField.setAccessible(true); formAnnotation = actionField.getAnnotation(Form.class); if (formAnnotation == null || !formAnnotation.disableFormatting()) { formatForm(actionField.get(action)); } } } }
The fieldReceivedStoredForm
function, shown below, checks a stored form exists and owned by the
Action's field in question. See More Form Injection
for how stored forms work.
public static boolean fieldReceivedStoredForm(Field actionField) { StoredForm storedForm; if (actionField == null) { return false; } storedForm = getStoredForm(); if (storedForm == null) { return false; } if (storedForm.getOwningURL() == null) { return false; } if (actionField.getDeclaringClass() != storedForm.getOwningActionClass()) { return false; } return storedForm.getOwningActionFieldNames().contains(actionField.getName()); }
The fieldIsNotForm
function below is lazily written. Its purpose is to identify the Action's
fields that definitely can't contain forms and can be skipped. As forms are class that contain multiple
member fields, fields that are primitive types or strings certainly don't qualify. Checking for the
Form
annotation or derived form AbstractForm
isn't sufficient as neither are
mandatory for a form. Other types could be checked but this, ultimately, doesn't matter as classes with no
member fields annotated with converters are scanned but trigger no action.
public static boolean fieldIsNotForm(Field actionField) { Class<?> fieldClass; fieldClass = actionField.getType(); if (fieldClass.isPrimitive()) { return true; } if (fieldClass.isAssignableFrom(String.class)) { return true; } if (Modifier.isStatic(actionField.getModifiers())) { return true; } return false; }
Form Formatting
Once a potential form is identified, the algorithm iterates over every member field annotated with a converter. Non-string fields are skipped as they're the target for converted values, not form fields.
public static void formatForm(Object form) { Annotation[] annotations; AnnotationUsageResult<?> annotationUsageResult; Collection<Field> formFields; if (form != null) { formFields = getProperties(form.getClass()); for (Field formField: formFields) { if (formField.getType().isAssignableFrom(String.class)) { annotations = formField.getAnnotations(); for (Annotation annotation: annotations) { try { annotationUsageResult = getAnnotationUsage(annotation); } catch (Exception e) { annotationUsageResult = new AnnotationUsageResult(); LOG.error("Client supplied validator for form field failed " + " Struts action=" + ActionContext.getContext().getName() + " field=" + formField.getName() + " annotation=" + annotation.annotationType(), e); } if (annotationUsageResult.getAnnotationUsage() == AnnotationUsage.CONVERT) { formatFormField(form, formFields, formField, annotationUsageResult); break; } else if (annotationUsageResult.getAnnotationUsage() == AnnotationUsage.COLLECTION_CONVERT) { formatCollectionFormField(form, formFields, formField, annotationUsageResult); break; } } } } } }
Most of the formatting functions below is defensive code against converter annotations referring to converted value fields that are missing or the wrong data type.
private static <T> void formatFormField(Object form, Collection<Field> allFormFields, Field formField, AnnotationUsageResult<T> annotationUsageResult) { Field unformattedField; String fieldName, unformattedFieldName, formattedText; T unformattedValue; boolean compatible; fieldName = formField.getName(); unformattedFieldName = annotationUsageResult.getConverter().getRecipientFieldName(); if (unformattedFieldName == null || unformattedFieldName.length() == 0) { unformattedFieldName = getDefaultRecipientName(fieldName); } unformattedField = getUnformattedField(allFormFields, unformattedFieldName); if (unformattedField == null) { LOG.error("Form field annotation refers to non-existent unformatted field, explicitly or by default" + " Struts action=" + ActionContext.getContext().getName() + " form=" + form.getClass() + " field=" + formField.getName() + " unformattedFieldName=" + unformattedFieldName); return; } compatible = checkRecipientDataType(unformattedField, annotationUsageResult.getConverter().getRecipientClass()); if (!compatible) { LOG.error("Unformatted field is not compatible with validator" + " Struts action=" + ActionContext.getContext().getName() + " form=" + form.getClass() + " field=" + formField.getName() + " unformattedFieldName=" + unformattedFieldName + " allowed class=" + annotationUsageResult.getConverter().getRecipientClass()); return; } unformattedValue = null; try { unformattedField.setAccessible(true); unformattedValue = (T)unformattedField.get(form); formattedText = formatValue(annotationUsageResult.getConverter(), unformattedValue); formField.setAccessible(true); formField.set(form, formattedText); } catch (Exception e) { LOG.error("Setting formatted form field failed" + " Struts action=" + ActionContext.getContext().getName() + " form=" + form.getClass() + " field=" + formField.getName() + " unformattedFieldName=" + unformattedFieldName + " value to format=" + unformattedValue, e); } } private static <T> String formatValue(Converter<Annotation,T> converter, T unformattedValue) { if (unformattedValue != null) { return converter.format(unformattedValue); } else { return ""; } } private static <T> void formatCollectionFormField(Object form, Collection<Field> allFormFields, Field formField, AnnotationUsageResult<T> annotationUsageResult) { Field unformattedField; String fieldName, unformattedFieldName, formattedText; Collection<T> unformattedValue; boolean compatible; fieldName = formField.getName(); unformattedFieldName = annotationUsageResult.getCollectionConverter().getRecipientFieldName(); if (unformattedFieldName == null || unformattedFieldName.length() == 0) { unformattedFieldName = getDefaultRecipientName(fieldName); } unformattedField = getUnformattedField(allFormFields, unformattedFieldName); if (unformattedField == null) { LOG.error("Form field annotation refers to non-existent unformatted field, explicitly or by default" + " Struts action=" + ActionContext.getContext().getName() + " form=" + form.getClass() + " field=" + formField.getName() + " unformattedFieldName=" + unformattedFieldName); return; } compatible = checkCollectionRecipientDataType(unformattedField, annotationUsageResult.getCollectionConverter().getRecipientClass()); if (!compatible) { LOG.error("Unformatted field is not compatible with validator" + " Struts action=" + ActionContext.getContext().getName() + " form=" + form.getClass() + " field=" + formField.getName() + " unformattedFieldName=" + unformattedFieldName + " allowed class=" + annotationUsageResult.getCollectionConverter().getRecipientClass()); return; } unformattedValue = null; try { unformattedField.setAccessible(true); unformattedValue = (Collection<T>)unformattedField.get(form); formattedText = formatCollectionValue(annotationUsageResult.getCollectionConverter(), unformattedValue); formField.setAccessible(true); formField.set(form, formattedText); } catch (Exception e) { LOG.error("Setting formatted form field failed" + " Struts action=" + ActionContext.getContext().getName() + " form=" + form.getClass() + " field=" + formField.getName() + " unformattedFieldName=" + unformattedFieldName + " value to format=" + unformattedValue, e); } } private static <T> String formatCollectionValue(CollectionConverter<Annotation,T> collectionConverter, Collection<T> unformattedValue) { if (unformattedValue != null) { return collectionConverter.format(unformattedValue); } else { return ""; } }
The Flaw
This interceptor design works fine except if the Action uses helpers that read form values. The extract below is from working code.
if (form == null) { form = new UpdatePopupStaffCountryForm(selectedItem); } globalCountryDisplay = new GlobalCountryWithBlankSelectBoxDisplay(); globalCountryDisplay.setModel(globalCountries); globalCountryDisplay.setSelectedValue(form.getGlobalCountryId()); return "success";
globalCountryDisplay
is a helper for displaying SELECT tags and reads the selected value from
the formatted form value, not the unformatted value. As it runs in the Action's execute
method,
it's done before the form formatting run in the pre-result stage, so it reads no formatted value.
This could be solved by moving such helpers to the pre-result stage and, as the listener is set after the form formatter interceptor sets its listener, will run after form formatting. However, this imposes a requirement on Actions that can be easily forgotten. The view helpers could be rewritten to select from unformatted form values but this requires a lot of writing of existing code.
The Other Flaw
The interceptor works fine for new forms but not existing ones. The following code, also shown above, checks if a form was injected for display and creates a new one from a database record if not.
if (form == null) { form = new UpdatePopupStaffCountryForm(selectedItem); }
The constructor is shown below, which sets unformatted fields, or fields that aren't converted.
public UpdatePopupStaffCountryForm(CountryVO selectedItem) { shortcode = selectedItem.getShortCode(); parsedGlobalCountryId = selectedItem.getGlobalCountryId(); }
This differs from the previous version, which sets formatted fields, or fields that aren't converted. Here, unformatted fields aren't set because they're not displayed.
public UpdatePopupStaffCountryForm(CountryVO selectedItem) { shortcode = selectedItem.getShortCode(); globalCountryId = Integer.toString(selectedItem.getGlobalCountryId()); }
The former constructor shows a desired reduction in boilerplate code but lots of code is still written like the latter. An automated formatter encountering a form created using the latter would overwrite the formatted values from missing unformatted values, breaking existing code.
It's possible to check for any non-blank, formatted value but experience shows this is asking for unrelated changes creating bugs. Converted Actions could use interceptor stack parameters to switch on the form formatter but this is another configuration and doesn't fix the previous flaw.
The Kludge
To avoid automated breaking of code, it's necessary for the Action to declare if and when to use form formatting, preferably with as little and simplest code as possible. Thus, Actions can call the form formatting code directly, or indirectly via method of a custom base class. The extract below shows the change.
if (form == null) { form = new UpdatePopupStaffCountryForm(selectedItem); } formatForms(); globalCountryDisplay = new GlobalCountryWithBlankSelectBoxDisplay(); globalCountryDisplay.setModel(globalCountries); globalCountryDisplay.setSelectedValue(form.getGlobalCountryId()); return "success";
Next part
Continued in Evaluation.