Posting complex forms with RESTEasy - Part 2

Maarten Winkels

As promised in a previous blog, I'll devote this blog to how to extend the RESTEasy framework with support for mapping form fields on object-graphs with complex associations, like lists and maps.

These extensions have been reported to RESTEasy as two issues with patches. If you like these features, please vote for these issues.

Extending the existing framework (with the extensions from the previous blog) is not so hard and might be a lot easier then you think. The crux is finding the right mapping from names of the form fields to the complex structures in the Java objects.

A form with these fields and values:

firstName=Maarten
lastName=Winkels
emailAddresses[0].emailAddress=me@xebia.com
emailAddresses[1].emailAddress=me@gmail.com
phoneNumbers[home].number=0306324178
phoneNumbers[work].number=+31 35 2356785

Could be mapped to the Java class below easily.

public class Person {

	@FormParam("firstName")
	private String firstName;

	@FormParam("lastName")
	private String lastName;

	@NestedFormParams("emailAddresses")
	private List<EmailAddress> emailAddresses;

	@NestedFormParams("phoneNumbers")
	private Map<String, PhoneNumber> phoneNumbers;
}

public class EmailAddress {

	@FormParam("emailAddress")
	private String emailAddress;
}

public class PhoneNumber {

	@FormParam("number")
	private String number;
}

The mapping is quite simple. A list is mapped with its prefix appended with an index number between square brackets. A maps prefix is appended with the key between square brackets. As soon as a prefix is found, the rest of the "deserialization" is the same as for the address in the previous blog: Properties can be appended to the prefix with a dot as separator.

Now when the RESTEasy framework encounters a @NestedFormParam, it can look at the type of the property to find out how to interpret the form fields. It also needs to figure out the types of the elements in the collection. This is done in the InjectorFactory or its extenion.

public class ExtendedInjectorFactory extends InjectorFactoryImpl {

	private final ResteasyProviderFactory providerFactory;

	public ExtendedInjectorFactory(ResteasyProviderFactory factory) {
		super(factory);
		this.providerFactory = factory;
	}

	public ValueInjector createParameterExtractor(Class injectTargetClass,
			AccessibleObject injectTarget, Class type, Type genericType,
			Annotation[] annotations, boolean useDefault) {
		NestedFormParams param = FindAnnotation.findAnnotation(annotations,
				NestedFormParams.class);
		if (param != null) {
			String prefix = param.value();
			if (genericType instanceof ParameterizedType) {
				ParameterizedType pType = (ParameterizedType) genericType;
				if (isA(List.class, pType)) {
					return new ListFormInjector(type,
							getTypeArgument(pType, 0), prefix, providerFactory);
				}
				if (isA(Map.class, pType)) {
					return new MapFormInjector(type, getArgumentType(pType, 0),
							getTypeArgument(pType, 1), prefix, providerFactory);
				}
			}
			return new NestedFormInjector(type, prefix, providerFactory);
		}
		return super.createParameterExtractor(injectTargetClass, injectTarget,
				type, genericType, annotations, useDefault);

	}

	private boolean isA(Class clazz, ParameterizedType pType) {
		return clazz.isAssignableFrom((Class) pType.getRawType());
	}

	private Class getTypeArgument(ParameterizedType pType, int index) {
		return (Class) pType.getActualTypeArguments()[index];
	}
}

By inspecting the genericType parameter, the InjectorFactory finds out which Injector to use to instantiate and populate the correct class. The isA method is a utility method to find out if a collection type has been encountered. To find out the type of the elements in the collection, the getTypeArgument method is used. For a map type property the key type is also needed. Note that this implementation requires the collection to be parameterized with the concrete type of its elements!

The implementations of ListFormInjector and MapFormInjector are very similar and have a common superclass.

public abstract class AbstractCollectionFormInjector<T> extends NestedFormInjector {

	private final Class collectionType;

	private final Pattern pattern;

	protected AbstractCollectionFormInjector(Class collectionType, Class genericType,
				String prefix, Pattern pattern, ResteasyProviderFactory factory) {
		super(genericType, prefix, factory);
		this.collectionType = collectionType;
		this.pattern = pattern;
	}

	public Object inject(HttpRequest req, HttpResponse res) {
		T result = createInstance(collectionType);
		for (String prefix : findPrefixes(req.getDecodedFormParameters())) {
			Matcher matcher = pattern.matcher(prefix);
			matcher.matches();
			String key = matcher.group(1);
			addTo(result, key, super.doInject(prefix, req, res));
		}
		if (empty(result)) {
			return null;
		}
		return result;
	}

	private Set<String> findPrefixes(MultivaluedMap<String, String> parameters) {
		final HashSet<String> result = new HashSet<String>();
		for (String parameterName : parameters.keySet()) {
			final Matcher matcher = pattern.matcher(parameterName);
			if (matcher.lookingAt() && hasValue(parameters.get(parameterName))) {
				result.add(matcher.group(0));
			}
		}
		return result;
	}

	protected abstract T createInstance(Class collectionType);

	protected abstract void addTo(T collection, String key, Object value);

	protected abstract boolean empty(T result);
}

The idea of this class is quite simple:

  1. First all matches in the form field names for the matcher are gathered. If the form contains fields like 'phoneNumbers[home].number', 'phoneNumbers[home].extension' and 'phoneNumbers[work].number' and the pattern is 'phoneNumbers[(.*)]', then the prefixes 'phoneNumbers[home]' and 'phoneNumbers[work]' are found. This is done in the findPrefixes method.
  2. Now for each prefix the key is extracted. This is the part of the prefix that matched the first group. In the above example the first key would be home and the second would be work.
  3. For each prefix an instance of the required element type is constructed by using the functionality in the NestedFormInjector.
  4. All instances are collected in a collection, which is the end result of this injector.

There are three parts of this procedure that need to be adapted for the specific collection type. Let's look at the ListFormInjector.

public class ListFormInjector extends AbstractCollectionFormInjector<List> {

	public ListFormInjector(Class collectionType, Class genericType,
				String prefix, ResteasyProviderFactory factory) {
		super(collectionType, genericType, prefix,
				Pattern.compile("^" + prefix + "\

(\\d+)\

"), factory); } protected List createInstance(Class collectionType) { return new ArrayList(); } protected void addTo(List collection, String key, Object value) { collection.add(Integer.parseInt(key), value); } protected boolean empty(List result) { return result.isEmpty(); } }

This is a very simple implementation. The most important part is probably the construction of the pattern at line 6. The pattern consists of the prefix and a number between square brackets. This number is later used as index into the list at line 14, where a new element is added to the list.

The implementation for maps is a little more complicated, since the key value has to be converted to an object as well. For this we use the StringParameterInjector of RESTEasy. An instance of this class can be used to convert a String to an instance of the required type.

public class MapFormInjector extends AbstractCollectionFormInjector<Map> {

	private final StringParameterInjector keyInjector;

	public MapFormInjector(Class collectionType, Class keyType,
				Class valueType, String prefix, ResteasyProviderFactory factory) {
		super(collectionType, valueType, prefix,
				Pattern.compile("^" + prefix + "\

([a-zA-Z_]+)\

"), factory); keyInjector = new StringParameterInjector(keyType, keyType, null, Form.class, null, null, new Annotation[0], factory); } protected Map createInstance(Class collectionType) { if (collectionType.isAssignableFrom(LinkedHashMap.class)) { return new LinkedHashMap(); } if (collectionType.isAssignableFrom(TreeMap.class)) { return new TreeMap(); } throw new RuntimeException("Unsupported collectionType: " + collectionType); } protected void addTo(Map collection, String key, Object value) { collection.put(keyInjector.extractValue(key), value); } protected boolean empty(Map result) { return result.isEmpty(); } }

The pattern used here is quite strict: Only letters and underscores are allowed. This restricts the possibilities for the key values in the map. In some situations this might be to restrictive.

Conclusion

With a few classes we have extended the RESTEasy framework to be able to support mapping form fields to collections. Th required classes are attached to this blog in a zip file as a Maven project. Feel free to use it in your own projects.
I think this would be a good extension of RESTEasy, but so far the patches have not been accepted by the developers. If you like this extension, please vote for the issues mentioned in the beginning of this blog.

Comments (2)

  1. Lukasz Kaleta - Reply

    February 1, 2015 at 5:22 pm

    Hi, I was searching for such a solution. But seems that RestEasy has a support for it:

    public class Bean {
    @Form(prefix = "nested") private NestedBean;
    //getters-setters
    }

    public class NestedBean {
    @FormParma("xxx") private String xxx;
    //getters-setters
    }

  2. Mihai Grebenisan - Reply

    March 31, 2015 at 12:48 pm

    This is a very nice solution. I tested it and it works just fine.

    Unfortunately if you want to expose your REST service class also as a CDI bean in order to be able to use @Inject or @Ejb, this will not work (at least for me it did not).

    A fix for that would be for the ExtendedInjectorFactory to extend the CdiInjectorFactory instead of InjectorFactoryImpl:

    public class ExtendedInjectorFactory extends CdiInjectorFactory {
    ...
    }

    The CdiInjectorFactory comes from the following dependency :

    org.jboss.resteasy
    resteasy-cdi

    You can read more about RESTeasy and CDI in the official doc: http://docs.jboss.org/resteasy/docs/2.0.0.GA/userguide/html/CDI.html#d0e2923

Add a Comment