Another bit of interesting work I did, for another client, was to implement a CAPTCHA system. I chose the library
Kaptcha and built services and components around it.
If you follow the documentation for Katpcha, you'll see that you're supposed to configure it inside web.xml and add a servlet. That's not the Tapestry way, especially for something that will likely be split off into its own library at some point and there's no reason that all the necessary plumbing can't occur within the context of Tapestry's APIs.
The essence of a CAPTCHA is two fold: first, a secret string is generated on the server side. On the client-side, an image and a text field are displayed. The image is a distorted version of the secret text. The user must type the text ... humans being better able to pull meaning out of the distortion than any typical program.
Back on the server side, we compare what the user entered against the secret string.
I broke the implementation up into three pieces:
- A Tapestry service to handle generating the secret string and the image
- A Tapestry component to display the image
- A second component to handle the text field
In practice, all it takes to use this is the following:
<t:kaptchaimage t:id="kaptcha"/>
<br/>
<t:kaptchafield image="kaptcha"/>
The two components work together to select the secret word, display the image, and validate that the user has entered the expected value.
Let's look at how this all comes together.
KaptchaProducer Service
Kaptcha includes an interface, Producer, that has most of what I want:
package com.google.code.kaptcha;
import java.awt.image.BufferedImage;
/**
* Responsible for creating captcha image with a text drawn on it.
*/
public interface Producer
{
/**
* Create an image which will have written a distorted text.
*
* @param text
* the distorted characters
* @return image with the text
*/
BufferedImage createImage(String text);
/**
* @return the text to be drawn
*/
String createText();
}
I extended this to add methods for determining the width and height of the captcha image:
package com.myclient.services.kaptcha;
import com.google.code.kaptcha.Producer;
/**
* Extension of KatpchaProducer that exposes the images width and height (in
* pixels).
*
*/
public interface KaptchaProducer extends Producer {
int getWidth();
int getHeight();
}
My implementation is largely a wrapper around Kaptcha's default implementation:
package com.myclient.services.kaptcha;
import java.awt.image.BufferedImage;
import java.util.Map;
import java.util.Properties;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
public class KaptchaProducerImpl implements KaptchaProducer {
private final DefaultKaptcha producer;
private final int height;
private final int width;
public KaptchaProducerImpl(Map<String, String> configuration) {
producer = new DefaultKaptcha();
Config config = new Config(toProperties(configuration));
producer.setConfig(config);
height = config.getHeight();
width = config.getWidth();
}
public int getHeight() {
return height;
}
public int getWidth() {
return width;
}
public BufferedImage createImage(String text) {
return producer.createImage(text);
}
public String createText() {
return producer.createText();
}
private static Properties toProperties(Map<String, String> map) {
Properties result = new Properties();
for (String key : map.keySet()) {
result.put(key, map.get(key));
}
return result;
}
}
What's all the business with the Map<String, String> configuration
? That's a Tapestry IoC mapped configuration, that allows us to extend the configuration of the Kaptcha Producer ... say, to change the width or height or color scheme.
Note that this was my choice, to have a centralized text and image producer, so that all CAPTCHAs in the application would have a uniform look and feel. Another alterntiave would have been to have the KaptchaImage component (described shortly) have its own instance of DefaultKaptcha, with parameters to control its configuration.
KaptchaImage Component
So with this service in place, how do we generate the image? This is done in three steps:
- Selecting a secret word and storing it persistently in the session
- Rendering an <img> element, including a
src
attribute
- Providing an image byte stream when asked by the browser
package com.myclient.components;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;
import javax.imageio.ImageIO;
import org.apache.tapestry5.ComponentResources;
import org.apache.tapestry5.Link;
import org.apache.tapestry5.MarkupWriter;
import org.apache.tapestry5.annotations.Persist;
import org.apache.tapestry5.annotations.SupportsInformalParameters;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.services.Response;
import com.myclient.services.kaptcha.KaptchaProducer;
/**
* Part of a Captcha based authentication scheme; a KaptchaImage generates a new
* text image whenever it renders and can provide the previously
* rendred text subsequently (it is stored persistently in the session).
*
* The component renders an <img> tag, including width and height
* attributes. Other attributes come from informal parameters.
*/
@SupportsInformalParameters
public class KaptchaImage {
@Persist
private String captchaText;
@Inject
private KaptchaProducer producer;
@Inject
private ComponentResources resources;
@Inject
private Response response;
public String getCaptchaText() {
return captchaText;
}
void setupRender() {
captchaText = producer.createText();
}
boolean beginRender(MarkupWriter writer) {
Link link = resources.createEventLink("image");
writer.element("img",
"src", link.toAbsoluteURI(),
"width", producer.getWidth(),
"height", producer.getHeight());
resources.renderInformalParameters(writer);
writer.end();
return false;
}
void onImage() throws IOException {
BufferedImage image = producer.createImage(captchaText);
response.setDateHeader("Expires", 0);
response.setHeader("Cache-Control",
"no-store, no-cache, must-revalidate");
response.setHeader("Cache-Control", "post-check=0, pre-check=0");
response.setHeader("Pragma", "no-cache");
OutputStream stream = response.getOutputStream("image/jpeg");
ImageIO.write(image, "jpg", stream);
stream.flush();
stream.close();
}
}
This component (which has no template) has two render phase methods. In setupRender()
we choose the secret word; since the captchaText
field has the @Persist annotation, it's value will be stored in the session.
Inside beginRender()
is where we render the image. We also generate a callback link for an event named "image". The URL Tapestry generates will identify the page and component within the page, as well as this event name.
Notice how we use the getWidth()
and getHeight()
extensions on the service interface to set these attributes of the <img> tag.
Later, the browser will send a request for the event, and the onImage()
event handler method will be invoked. This is where we get the image bytestream from the service and pump it down to the client. As you can see, we set a bunch of header values to ensure that the browser won't cache the image.
KaptchaField Component
The last part of the overall puzzle is the text field. Again, there are two main responsibilities:
- Rendering out the text field (when rendering)
- Validating that the user entered the correct secret text (when the form is submitted)
package com.myclient.components;
import org.apache.tapestry5.BindingConstants;
import org.apache.tapestry5.ComponentResources;
import org.apache.tapestry5.FieldValidator;
import org.apache.tapestry5.MarkupWriter;
import org.apache.tapestry5.ValidationTracker;
import org.apache.tapestry5.annotations.BeginRender;
import org.apache.tapestry5.annotations.Environmental;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.SupportsInformalParameters;
import org.apache.tapestry5.corelib.base.AbstractField;
import org.apache.tapestry5.ioc.Messages;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.services.FieldValidatorSource;
import org.apache.tapestry5.services.Request;
/**
* Field paired with a {@link KaptchaImage} to ensure that the user has provided
* the correct value.
*
*/
@SupportsInformalParameters
public class KaptchaField extends AbstractField {
/**
* The image output for this field. The image will display a distorted text
* string. The user must decode the distorted text and enter the same value.
*/
@Parameter(required = true, defaultPrefix = BindingConstants.COMPONENT)
private KaptchaImage image;
@Inject
private Request request;
@Inject
private Messages messages;
@Inject
private ComponentResources resources;
@Environmental
private ValidationTracker validationTracker;
@Inject
private FieldValidatorSource fieldValidatorSource;
@Override
public boolean isRequired() {
return true;
}
@BeginRender
boolean renderTextField(MarkupWriter writer) {
writer.element("input",
"type", "password",
"id", getClientId(),
"name", getControlName(),
"value", "");
resources.renderInformalParameters(writer);
FieldValidator fieldValidator = fieldValidatorSource.createValidator(
this, "required", null);
fieldValidator.render(writer);
writer.end();
return false;
}
@Override
protected void processSubmission(String elementName) {
String userValue = request.getParameter(elementName);
if (image.getCaptchaText().equals(userValue))
return;
validationTracker.recordError(this, messages.get("incorrect-captcha"));
}
}
The renderTextField()
method is largely straight forward: by the time this is invoked, the unique clientId and controlName will already have been set for the field. The only trick here is to create some client-side validation to enforce that the field is required.
Later the form will be submitted by the user and the processSubmission()
method is invoked. It asks the KaptchaImage for the stored text and compares it to the user's input. If invalid, then an error message is recorded, associated with the field. The actual error text is stored in the component's message catalog.
Conclusion
Tapestry's approach is quite often about integration: integration of component code with other resources (such as templates or message catalogs), integration of components with other components, and integration of components with services. Here we get to see how a singleton service can be used by any number of components, how two components can be connected together, and how easy it is to provide logic both when rendering a page and on later related requests from the client.
Update
If you check the comments below, you'll see that Jon and I have been sparring good-naturedly about what constitutes "simple". Most of the code is related to integration: integrating the Kaptcha code into the Tapestry infrastructure; adding features such as just-in-time initialization to the DefaultProducer code, adding new features (access to the width and height of the image), allowing for configuration of the Kaptcha Producer in the "Tapestry way" (via contribution methods in module classes), and hooking into Tapestry's normal infrastructure for handling form submissions and reporting user input errors ... even client-side logic to enforce that the field is required.
All that integration is what allows the end-developer to get by with just the following in their
page template:
<t:kaptchaimage t:id="kaptcha"/>
<br/>
<t:kaptchafield image="kaptcha"/>
... and even that could be compressed down to a single convenience component wrapping the two underlying components:
<t:kaptcha/>
That is simplicity: no decisions to make, no URLs to map, no other files to edit, no additional code to write.
However, this still follows the Law of Immutable Complexity: making one part of a system simpler will make other parts more complex. In this situation, that extra complexity is the integration code (the two components and the service that Jon objects to). That's a trade-off I'm always willing to make: write some medium complex code once (and test it, once) and then be able to use it wherever I want.