Thursday, March 14, 2013

Passing complex objects in URL parameters

Imagine you would like to pass primitive data types, complex Java objects like java.util.Data, java.lang.List, generic classes, arrays and everything what you want via URL parameters in order to preset default values on any web page after the page was loaded. Common task? Yeah, but available solutions are mostly restricted to encoding / decoding of java.lang.String. The approach I will show doesn't have any limits on the data types. Only one limit is the limit of the URL size. URLs longer than 2083 characters may not work properly in old IE versions. Modern Firefox, Opera, and Safari can handle at least 80000 characters in URL.

Passing any type of objects via URL parameters is possible if we serialize objects to JSON and deserialize them on the server-side. An encoded JSON string has a valid format and this is a way to go! Well, but there is a problem. A serialization / deserialization to / from the JSON format works fine if the object is a non-generic type. However, if the object is of a generic type, then the Generic type information gets lost because of Java Type Erasure. What to do in this case? The solution I want to demonstrate is not restricted to JSF, but as I'm developing Java / Web front-ends, I'm rotating in this circle... So, let's start. First, we need a proper converter to receive URL parameters in JSON format and converts them back to Java. PrimeFaces Extensions provides one - JsonConverter.java. How it works? The following example shows how the JsonConverter can be applied to f:viewParam to convert a list of Strings in the JSON format to a List in Java.
<f:metadata>
    <f:viewParam name="subscriptions" value="#{subscriptionController.subscriptions}">
        <pe:convertJson type="java.util.List<java.lang.String>" />
    </f:viewParam>
</f:metadata>

<h:selectManyCheckbox value="#{subscriptionController.subscriptions}">
    <f:selectItem id="item1" itemLabel="News" itemValue="1" />
    <f:selectItem id="item2" itemLabel="Sports" itemValue="2" />
    <f:selectItem id="item3" itemLabel="Music" itemValue="3" />
</h:selectManyCheckbox>
The JsonConverter has one optional attribute type. We don't need to provide a data type information for primitives such as boolean or int. But generally, the type information is a necessary attribute. It specifies a data type of the value object. Any primitive type, array, non generic or generic type is supported. The type consists of fully qualified class names (except primitives). Examples:
"long[]"
"java.lang.String"
"java.util.Date"
"java.util.Collection<java.lang.Integer>"
"java.util.Map<java.lang.String, com.prime.FooPair<java.lang.Integer, java.util.Date>>"
"com.prime.FooNonGenericClass"
"com.prime.FooGenericClass<java.lang.String, java.lang.Integer>"
"com.prime.FooGenericClass<int[], com.prime.FooGenericClass<com.prime.FooNonGenericClass, java.lang.Boolean>>"
The string in the type is parsed at runtime. The code for the JsonConverter is available here (for readers who are interested in details). The JsonConverter is based on three other classes: ParameterizedTypeImpl.java, GsonConverter.java and DateTypeAdapter.java. The last one is a special adapter for dates because java.util.Date should be converted to milliseconds as long and back to the java.util.Date. So far so good. But how to prepare the values as URL parameters on the Java side? I will show an utility class which can be used for that. Read comments please, they are self-explained.
import org.apache.log4j.Logger;
import org.primefaces.extensions.converter.JsonConverter;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import javax.faces.context.FacesContext;
import javax.servlet.http.HttpServletRequest;

/**
 * Builder for request parameters.
 */
public class RequestParameterBuilder {

    private Logger LOG = Logger.getLogger(RequestParameterBuilder.class);

    private StringBuilder buffer;
    private String originalUrl;
    private JsonConverter jsonConverter;
    private String encoding;
    private boolean added;

    /**
     * Creates a builder instance by the current request URL.
     */
    public RequestParameterBuilder() {
        this(((HttpServletRequest) FacesContext.getCurrentInstance().getExternalContext().getRequest()).getRequestURL()
            .toString());
    }

    /**
     * Creates a builder instance by the given URL.
     *
     * @param url URL
     */
    public RequestParameterBuilder(String url) {
        buffer = new StringBuilder(url);
        originalUrl = url;
        jsonConverter = new JsonConverter();

        encoding = FacesContext.getCurrentInstance().getExternalContext().getRequestCharacterEncoding();
        if (encoding == null) {
            encoding = "UTF-8";
        }
    }

    /**
     * Adds a request parameter to the URL without specifying a data type of the given parameter value.
     * Parameter's value is converted to JSON notation when adding. Furthermore, it will be encoded
     * according to the acquired encoding.
     *
     * @param name name of the request parameter
     * @param value value of the request parameter
     * @return RequestParameterBuilder updated this instance which can be reused
     */
    public RequestParameterBuilder paramJson(String name, Object value) throws UnsupportedEncodingException {
        return paramJson(name, value, null);
    }

    /**
     * Adds a request parameter to the URL with specifying a data type of the given parameter value. Data type is sometimes
     * required, especially for Java generic types, because type information is erased at runtime and the conversion to JSON
     * will not work properly. Parameter's value is converted to JSON notation when adding. Furthermore, it will be encoded
     * according to the acquired encoding.
     *
     * @param name name of the request parameter
     * @param value value of the request parameter
     * @param type data type of the value object. Any primitive type, array, non generic or generic type is supported.
     *             Data type is sometimes required to convert a value to a JSON representation. All data types should be
     *             fully qualified.
     * @return RequestParameterBuilder updated this instance which can be reused
     */
    public RequestParameterBuilder paramJson(String name, Object value, String type)
        throws UnsupportedEncodingException {
        jsonConverter.setType(type);

        String jsonValue;
        if (value == null) {
            jsonValue = "null";
        } else {
            jsonValue = jsonConverter.getAsString(null, null, value);
        }

        if (added || originalUrl.contains("?")) {
            buffer.append("&");
        } else {
            buffer.append("?");
        }

        buffer.append(name);
        buffer.append("=");
        buffer.append(URLEncoder.encode(jsonValue, encoding));

        // set a flag that at least one request parameter was added
        added = true;

        return this;
    }

    /**
     * Adds a request parameter to the URL. This is a convenient method for primitive, plain data types.
     * Parameter's value will not be converted to JSON notation when adding. It will be only encoded
     * according to the acquired encoding. Note: null values will not be added.
     *
     * @param name name of the request parameter
     * @param value value of the request parameter
     * @return RequestParameterBuilder updated this instance which can be reused
     */
    public RequestParameterBuilder param(String name, Object value) throws UnsupportedEncodingException {
        if (value == null) {
            return this;
        }

        if (added || originalUrl.contains("?")) {
            buffer.append("&");
        } else {
            buffer.append("?");
        }

        buffer.append(name);
        buffer.append("=");
        buffer.append(URLEncoder.encode(value.toString(), encoding));

        // set a flag that at least one request parameter was added
        added = true;

        return this;
    }

    /**
     * Builds the end result.
     *
     * @return String end result
     */
    public String build() {
        String url = buffer.toString();

        if (url.length() > 2083) {
            LOG.error("URL " + url + " is longer than 2083 chars (" + buffer.length() +
                "). It may not work properly in old IE versions.");
        }

        return url;
    }

    /**
     * Resets the internal state in order to be reused.
     *
     * @return RequestParameterBuilder reseted builder
     */
    public RequestParameterBuilder reset() {
        buffer = new StringBuilder(originalUrl);
        jsonConverter.setType(null);
        added = false;

        return this;
    }
}
A typically bean using the RequestParameterBuilder provides a parametrized URL by calling either paramJson(...) or param(...).
import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.faces.bean.ManagedBean;
import javax.faces.bean.SessionScoped;

/**
 * UrlParameterProvider bean.
 */
@ManagedBean
@SessionScoped
public class UrlParameterProvider implements Serializable {

    private String parametrizedUrl;

    @PostConstruct
    protected void initialize() {
        RequestParameterBuilder rpBuilder = new RequestParameterBuilder("/views/examples/params.jsf");
        try {
            List<String> subscriptions = new ArrayList<String>();
            tableBlockEntries.add("2");
            tableBlockEntries.add("3");

            // add the list to URL parameters with conversion to JSON
            rpBuilder.paramJson("subscriptions", subscriptions, "java.util.List<java.lang.String>");

            // add int values to URL parameters without conversion to JSON (just for example)
            rpBuilder.param("min", 20);
            rpBuilder.param("max", 80);   
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }

        parametrizedUrl = rpBuilder.build();
    }

    public String getParametrizedUrl() {
        return parametrizedUrl;
    }
}
Using in XHTML - example with h:outputLink
<h:outputLink value="#{urlParameterProvider.parametrizedUrl}">
    Parametrized URL
</h:outputLink>
Once the user clicks on the link and lands on the target page with the relative path /views/examples/params.jsf, he / she will see a pre-checked h:selectManyCheckbox.

The real world is more complicated. In the fact I've written a lot of custom converters having JsonConverter inside. So that instead of <pe:convertJson type="..." /> are custom converters attached. That subject is going beyond of this post.