title | image |
---|---|
Validation without code. |
../images/validation.jpg |
Tapestry has some built in validators and a module to support JSR303 validators. JSR303 is more portable and flexible but requires a bit of configuration. First, add the module to the pom.xml file, then the configuration to the AppModule.
<dependency>
<groupId>org.apache.tapestry</groupId>
<artifactId>tapestry-beanvalidator</artifactId>
<version>${tapestry-release-version}</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>4.3.2.Final</version>
</dependency>
@Contribute(BeanValidatorSource.class)
public static void provideBeanValidatorConfigurer(OrderedConfiguration<BeanValidatorConfigurer> configuration)
{
configuration.add("BeanValidatorConfigurer", new BeanValidatorConfigurer()
{
public void configure(javax.validation.Configuration<?> configuration)
{
configuration.ignoreXmlConfiguration();
}
});
}
The Booking object contains a start/end dates. Both should be required and that can be accomplished by adding a @NotNull annotation. The start date should also be in the future and @Future handles that but has no included client side validation. Adding one requires a bit of configuration and some Javascript. First the configuration
@Contribute(ClientConstraintDescriptorSource.class)
public static void provideClientConstraintDescriptors(
final JavaScriptSupport javaScriptSupport,
Configuration<ClientConstraintDescriptor> config) {
config.add(new BaseCCD(Future.class) {
@Override
public void applyClientValidation(MarkupWriter writer, String message, Map<String, Object> attributes) {
javaScriptSupport.require("jq/validator/future");
writer.attributes("data-validate-future",message);
for ( Entry<String, Object> e : attributes.entrySet() ) {
writer.attributes("data-future-" + e.getKey(),e.getValue());
}
}
});
}
BaseCCD is an internal class and in theory should not be used by applications but it just simplifies creating a ClientConstraintDescriptor and the real work is done by applyClientValidation which imports the right Javascript and writes the data attributes onto the input element. Next the Javascript. All that's needed is to register a field validate event then check if the date provided in the memo object is less than today if so set memo.error.
(function() {
define(["t5/core/dom", "t5/core/events"], function(dom, events) {
dom.onDocument(events.field.validate, "[data-validate-future]", function(event, memo) {
var today = new Date();
if ( new Date(memo.translated) < today) {
memo.error = this.attr("data-validate-future");
return false;
}
});
});
}).call(this);
Finally the endDate should be after the startDate which requires checking two fields in the bean. No @DateRange is provided but it's possible to create one.
First thing is to create a DateRange annotation with start and end dates. The implementation class will take the configured methods from the annotation and compare them. It's certainly possible to do that with reflection but Tapestry has the PropertyAccess service that makes this easy but unfortunately there is no built in way to access it from the validator. One way to solve this is a AppGlobals class with static properties populated when the application starts up. Sometimes it's important to cleanup resources when the application is stopped so the Global class is runnable and added to the shutdownHub. In order to find services the locator is added the AppGlobals.
@Startup
public static void initMyApplication(RegistryShutdownHub hub, ObjectLocator locator) {
hub.addRegistryShutdownListener(new AppGlobals(locator));
}
and the validator
package com.trsvax.jacquard.jsr303;
@Target( { ElementType.TYPE, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {DateRangeValidator.class} )
@Documented
public @interface DateRange {
String message() default "{end} should be later than {start}";
String start();
String end();
@SuppressWarnings("rawtypes")
Class[] groups() default {};
@SuppressWarnings("rawtypes")
Class[] payload() default {};
}
public class DateRangeValidator implements ConstraintValidator<DateRange, Object>{
private String start;
private String end;
@Override
public void initialize(DateRange dateRange) {
this.start = dateRange.start();
this.end = dateRange.end();
}
@Override
public boolean isValid(Object instance, ConstraintValidatorContext context) {
PropertyAccess propertyAccess = AppGlobals.locator.getService(PropertyAccess.class);
Date startDate = (Date) propertyAccess.get(instance, start);
Date endDate = (Date) propertyAccess.get(instance, end);
if ( startDate == null || endDate == null ) {
return true;
}
return startDate.before(endDate);
}
}
The PropertyAccess service makes the DateRange validator simple. As per JSR303 recommendations null values are ignored as they should be handled by @NotNull. Adding the DateRange annotation to the booking entity now throws a Hibernate error when an invalid entity is saved to the database. While this is helpful for keeping bad data from the database it would be better if this error was presented in a more friendly way. The provided Tapestry module only validates fields but this validator works on the whole class. What's needed on an onValidate event handler to run the validator and add any errors to the form. This could be done directly on the page but a mixin could do this for any BeanEditForm.
package com.trsvax.jacquard.mixins;
public class FormValidate {
@BindParameter
Object object;
@BindParameter
BeanModel<?> model;
@InjectContainer
BeanEditForm beanEditForm;
@Inject
ClientConstraintDescriptorSource clientConstraintDescriptorSource;
@Inject
BeanValidatorSource beanValidatorSource;
@AfterRender
void afterRender(MarkupWriter writer) {
Class<?> beanType = model.getBeanType();
if ( beanType == null ) {
return;
}
BeanDescriptor beanDescriptor = beanValidatorSource.getValidatorFactory().getValidator().getConstraintsForClass(beanType);
Set<ConstraintDescriptor<?>> constraintDescriptors = beanDescriptor.getConstraintDescriptors();
if ( constraintDescriptors == null || constraintDescriptors.size() == 0 ) {
return;
}
Map<ClientConstraintDescriptor,List<ConstraintDescriptor<?>>> contraints = new HashMap<>();
for ( ConstraintDescriptor<?> constraintDescriptor: constraintDescriptors ) {
ClientConstraintDescriptor clientConstraintDescriptor = clientConstraintDescriptorSource.getConstraintDescriptor(constraintDescriptor.getAnnotation().annotationType());
if ( clientConstraintDescriptor != null ) {
if ( ! contraints.containsKey(clientConstraintDescriptor)) {
contraints.put(clientConstraintDescriptor, new ArrayList<>());
}
contraints.get(clientConstraintDescriptor).add(constraintDescriptor);
}
}
if ( ! contraints.isEmpty() ) {
for ( Entry<ClientConstraintDescriptor,List<ConstraintDescriptor<?>>> e : contraints.entrySet() ) {
e.getKey().applyClientValidation(writer, null, attributes(e.getKey(),e.getValue()));
}
}
}
@OnEvent(EventConstants.VALIDATE)
boolean onValidate() {
Set<ConstraintViolation<Object>> constraintViolations = beanValidatorSource.getValidatorFactory().getValidator().validate(object);
for ( ConstraintViolation<Object> violation : constraintViolations ) {
beanEditForm.recordError(violation.getMessage());
}
return false;
}
Map<String, Object> attributes(ClientConstraintDescriptor clientConstraintDescriptor, List<ConstraintDescriptor<?>> list) {
Map<String, Object> attributes = new HashMap<>();
JSONObject json = new JSONObject();
JSONArray constraints = new JSONArray();
json.accumulate("constraints", constraints);
attributes.put("json", json);
for ( ConstraintDescriptor<?> constraintDescriptor : list ) {
JSONObject constraint = new JSONObject();
constraints.put(constraint);
for ( String key : clientConstraintDescriptor.getAttributes()) {
if ( key.equals("message")) {
constraint.accumulate(key, interpolateMessage(constraintDescriptor));
} else {
constraint.accumulate(key, constraintDescriptor.getAttributes().get(key));
}
}
}
return attributes;
}
private String interpolateMessage(final ConstraintDescriptor<?> descriptor)
{
String messageTemplate = (String) descriptor.getAttributes().get("message");
MessageInterpolator messageInterpolator = beanValidatorSource.getValidatorFactory().getMessageInterpolator();
return messageInterpolator.interpolate(messageTemplate, new Context()
{
@Override
public ConstraintDescriptor<?> getConstraintDescriptor()
{
return descriptor;
}
@Override
public Object getValidatedValue()
{
return object;
}
});
}
}
Form validate does two things it handles server side validation in the onValidate method and it sets up the client side validation.
First step is to create a method to handle the validate server side event. Next add a bind parameter to allow access the object that needs validating, run the validator and record the errors. Finally attach the mixin to the BeanEditForm and any errors will be detected on the server side and displayed to the user. Validating on the client side requires a bit more work. The first thing is the add the validator to the validatorSource configuration. Next find all the validation annotations match up the attributes and call the applyClientValidation method. That takes care of the server side now to make the client work there will need to be some Javascript to register on the t5:form:validate and check the fields.
(function() {
define(["t5/core/dom", "t5/core/events"], function(dom, events) {
addErrors = function (form,error) {
$(form).before(
'<div class="alert alert-danger alert-dismissable">'+
'<button type="button" class="close" ' +
'data-dismiss="alert" aria-hidden="true">' +
'×' +
'</button>' +
error +
'</div>');
}
dom.onDocument(events.form.validate, function(event, memo) {
var constraints = JSON.parse(event.nativeEvent.target.dataset.daterangeJson).constraints;
for ( var i = 0; i < constraints.length; i++ ) {
var constraint = constraints[i];
var startDate = new Date(document.getElementById(constraint.start).value);
var endDate = new Date(document.getElementById(constraint.end).value);
if ( startDate == null || endDate == null ) {
return;
}
if ( ! startDate < endDate ) {
memo.error = true;
addErrors(event.nativeEvent.target, constraint.message);
}
}
});
});
}).call(this);
This time the validation is done in the form.validate event. The other difference is the configuration is passed in JSON to allow multiple @DateRange validators on a bean. The errors are added at the top of the form with Bootstrap alerts. That's quite a bit of code but the next validator only requires the Javascript part.
That takes care of validating form input but what about page/component input. What about validation on other events. The BookingNew page should always have a Hotel as input so step one is to add @NotNull to the hotel field. What's needed here is to register for PageActivation events and validate the page when they occur. There are quite a few possible events on a page so the group parameter in the annotation can be used to mark which validators go with which events. The mate to the activation event is passivate.
package com.trsvax.jacquard.mixins;
public class RenderValidator {
final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
@Inject
ComponentResources componentResources;
@SetupRender
void setupRender() {
factory.getValidator().validate(componentResources.getContainer(),SetupRender.class);
}
@BeginRender
void beginRender() {
factory.getValidator().validate(componentResources.getContainer(),BeginRender.class);
}
@BeforeRenderTemplate
void beforeRenderTemplate() {
factory.getValidator().validate(componentResources.getContainer(),BeforeRenderTemplate.class);
}
@AfterRenderBody
void afterRenderBody() {
factory.getValidator().validate(componentResources.getContainer(),AfterRenderBody.class);
}
@AfterRenderTemplate
void afterRenderTemplate() {
factory.getValidator().validate(componentResources.getContainer(),AfterRenderTemplate.class);
}
@AfterRender
void afterRender() {
factory.getValidator().validate(componentResources.getContainer(),AfterRender.class);
}
@CleanupRender
void cleanupRender() {
factory.getValidator().validate(componentResources.getContainer(),CleanupRender.class);
}
}
Adding the mixins automatically is the last issue to resolve. Instead of adding the mixin in every tml file it would be nice to automatically add it to every BeanEditForm/Component/Page and this can be accomplished via a worker. Tapestry uses on the fly code generation create runtime classes for pages and components. Workers allow access to that process and adding mixins is pretty simple. Since both the validator mixin and the template mixin do not have required parameters it's easy enough to add both and remove the t:mixins attribute from booking.tml. The ComponentClassTransformWorker2 configuration is ordered and the workers need to be added at the correct time. The Activation worker needs to be last so all date is setup before the validation runs. When the passivate event fires all the data is already set so this worker needs to run before other workers.
package com.trsvax.jacquard.services.worker;
public class JSR303Worker implements ComponentClassTransformWorker2 {
private final Logger logger;
public JSR303Worker(Logger logger) {
this.logger = logger;
}
@Override
public void transform(PlasticClass plasticClass, TransformationSupport support, MutableComponentModel model) {
final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
final List<String> properties = new ArrayList<String>();
for ( PlasticField field : plasticClass.getFieldsWithAnnotation(ValidateBeforeSuccessEvent.class)) {
properties.add(field.getName());
}
if ( model.isPage() ) {
logger.info("name {}",model.getComponentClassName());
//plasticClass.introduceInterface(IsPage.class);
model.addMixinClassName(RenderValidator.class.getName(), "after:\*");
support.addEventHandler(EventConstants.ACTIVATE, 0, "Validate Activation Event", new ComponentEventHandler() {
@Override
public void handleEvent(Component instance, ComponentEvent event) {
Set constraintViolations = factory.getValidator().validate(instance,
ActivationRequestParameter.class, PageActivationContext.class);
//logger.info("activate {} {}",instance,constraintViolations);
if ( constraintViolations.size() > 0 ) {
throw new ConstraintViolationException("Validate Activation Event Failed",constraintViolations);
}
}
});
support.addEventHandler(EventConstants.SUCCESS, 0, "Form Validate", new ComponentEventHandler() {
@Override
public void handleEvent(Component instance, ComponentEvent event) {
Set constraintViolations = factory.getValidator().validate(instance,ValidateBeforeSuccessEvent.class);
//logger.info("validate {} {}",instance,constraintViolations);
for ( String property : properties ) {
//logger.info("validate property {} {}",instance,property);
constraintViolations.addAll(factory.getValidator().validateProperty(instance,property,ValidateBeforeSuccessEvent.class));
}
if ( constraintViolations.size() > 0 ) {
throw new ConstraintViolationException("Form Validate",constraintViolations);
}
}
});
}
}
}
What about validating service methods? JSR349 adds additional validation methods. Upgrading the hibernate validator to 5.x adds the ability to validate method calls and Tapestry provides a way to add intercepters to services methods. The intercepter has access to the input parameters as well as the return value so it's possible to validate both, but for now this will have to do.
JSR303 Validation provides an automatic, no code, consistent way to validate Java objects. Tapestry provides the ability to plug validators into all levels of the application. This makes it simple to add all the validation that should be done but never is.