Thursday, December 27, 2007

Ext Reader for Jersey Web Services

I just started exploring the impressive Ext JavaScript framework. It is very easy to work with and supports a number of options for getting data into your Web 2.0 applications. I also started experimenting with Jersey, the reference implementation for JSR 311 RESTful web services. After playing with both for a couple of days, I thought I would try using the two together, easier said than done.

Creating new a new data driven web service is easy when using the wizards that come with the Netbeans RESTful Web Services plugin. There is an excellent tutorial titled 'Getting Started with RESTful Web Services' on the Netbeans site to help you get started. The resources created by the wizards allow one to select the data format by setting the HTTP request header's 'Accept ' field to 'application/json' or 'application/xml' depending on the need. JAXB converter classes are also created by the wizards to simplify formatting the data as JSON or XML.

I assumed that using the two frameworks together would be trivial. Something like the following:
/* Set the request method, Jersey defaults to GET */
Ext.Ajax.method = 'GET';

/* Set the request header */
Ext.Ajax.defaultHeaders = {'Accept': 'application/json'};

/* Record */
record = Ext.data.Record.create([
    {name: 'id', mapping: 'id'},
    {name: 'uri', mapping: '@uri'}
]);

/* Reader */
reader = new Ext.data.JsonReader({
    totalProperty: "totalRows",
    root: "countries",
    id: "id"
}, record);

/* Store */
store = new Ext.data.Store({
 /* This points to a test Jersey (JSR 311) resource */
        proxy: new Ext.data.HttpProxy({url: 'http://localhost/app/resources/countries/'}),
        reader: reader,
        remoteSort: true
    }),
store.setDefaultSort('id', 'asc');
The above code will not work as Jersey returns Badgerfish notated JSON, a clean method for translating XML to JSON. I could have tried using the Jersey client stub scripts (which worked with simple examples) however, that would make using Jersey with Ext's data paging more complex as well as increasing the number of calls to produce a detailed resource list. There is an example project that comes with the Netbeans Jersey plugin illustrating how a client stub will make a single call per resource to build a detailed list of resources.

I needed a more efficient method of getting data from a Jersey web service using Ext. I examined the source for the Ext.data.JsonReader as well as the Jersey client stubs and came up with this (JSDoc comments omitted for clarity):
Ext.namespace('openEPRS', 'openEPRS.data');  

openEPRS.data.BadgerfishReader = function(meta, recordType){
    meta = meta || {};
    openEPRS.data.BadgerfishReader.superclass.constructor.call(this, meta, recordType || meta.fields);
};

Ext.extend(openEPRS.data.BadgerfishReader, Ext.data.DataReader, {
    read : function(response) {
        var json = response.responseText;
        var o = eval("("+json+")");
        if(!o) {
            throw {message: "BadgerfishReader.read: Json object not found"};
        }
        if(o.metaData) {
            delete this.ef;
            this.meta = o.metaData;
            this.recordType = Ext.data.Record.create(o.metaData.fields);
            this.onMetaChange(this.meta, this.recordType, o);
        }
        return this.readRecords(o);
    },

    onMetaChange : function(meta, recordType, o) {

    },

    simpleAccess: function(obj, subsc) {
        return obj[subsc];
    },

    getJsonAccessor: function() {
        var re = /[\[\.]/;
        return function(expr) {
            try {
                return(re.test(expr))
                ? new Function("obj", "return obj." + expr)
                : function(obj){
                    return obj[expr];
                };
            } catch(e) {}
            return Ext.emptyFn;
        };
    }(),

    readRecords : function(o) {
        this.jsonData = o;
        var s = this.meta, Record = this.recordType,
        f = Record.prototype.fields, fi = f.items, fl = f.length;

        if (!this.ef) {
            if(s.totalProperty) {
                this.getTotal = this.getJsonAccessor(s.totalProperty);
            }
            if(s.successProperty) {
                this.getSuccess = this.getJsonAccessor(s.successProperty);
            }
            this.getRoot = s.root ? this.getJsonAccessor(s.root) : function(p){return p;};
            if (s.id) {
                var g = this.getJsonAccessor(s.id);
                this.getId = function(rec) {
                    var r = g(rec);
                    return (r === undefined || r === "") ? null : r;
                };
            } else {
                this.getId = function(){return null;};
            }
            this.ef = [];
            for(var i = 0; i < fl; i++){
                f = fi[i];
                var map = (f.mapping !== undefined && f.mapping !== null) ? f.mapping : f.name;
                this.ef[i] = this.getJsonAccessor(map);
            }
        }
        var root = this.getRoot(o);
        var c = (root.length == undefined) ? 1 : root.length;
        var totalRecords = c;
        var success = true;
        if(s.totalProperty) {
            var v = parseInt(this.getTotal(o), 10);
            if(!isNaN(v)){
                totalRecords = v;
            }
        }
        if(s.successProperty) {
            var v = this.getSuccess(o);
            if(v === false || v === 'false') {
                success = false;
            }
        }
        var records = [];
        for(var i = 0; i < c; i++) {
            var n = (c == 1) ? root : root[i];
            var values = {};
            var id = this.findValue(null, this.getId(n));
            var v;
            for(var j = 0; j < fl; j++) {
                f = fi[j];
                if(f.name == '@uri' || f.mapping == '@uri' ) {
                    v = n['@uri'];
                } else {
                    v = this.ef[j](n);
                    v = this.findValue(f.name, v);
                }
                values[f.name] = f.convert((v !== undefined) ? v : f.defaultValue);
            }
            var record = new Record(values, id);
            record.json = n;
            records[i] = record;
        }
        return {
            success : success,
            records : records,
            totalRecords : totalRecords
        };
    },

    findValue : function(field, value) {
        if(value == undefined)
            return field;
        if(value['$'] == undefined) {
            var r = {};
            for(var i in value) {
                r[i] = value[i]['$'];
            }
            return r;
        } else {
            return value['$'];
        }
    }
});
The above solution can consume the following JSON data generated by a Jersey web service:
{"countries":{"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/",
   "countryRef":[
       {"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/1\/","id":{"$":"1"}},
       {"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/2\/","id":{"$":"2"}}
   ]
}}
To get a detailed list, with support for sorting and paging, I created a new converter class named CountriesListConverter:
package com.zunisoft.openeprs.converter;

import com.zunisoft.openeprs.db.Countries;
import java.net.URI;
import java.util.Collection;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlTransient;
import javax.xml.bind.annotation.XmlAttribute;
import java.util.ArrayList;

/**
 * Countries list converter class.
 *
 * @author krdavis
 */

@XmlRootElement(name = "countries")
public class CountriesListConverter {
    private Collection entities;
    private Collection references;
    private Long totalRows;
    private URI uri;

    /** Creates a new instance of CountriesListConverter */
    public CountriesListConverter() {
    }

    /**
     * Creates a new instance of CountriesListConverter.
     *
     * @param entities associated entities
     * @param totalRows total count of all associated entities
     * @param uri associated uri
     */
    public CountriesListConverter(Collection entities,
            Long totalRows, URI uri) {
        this.entities = entities;
        this.uri = uri;
        this.totalRows = totalRows;
    }

    /**
     * Returns a collection of CountryConverter.
     *
     * @return a collection of CountryConverter
     */
    @XmlElement(name = "country")
    public Collection getReferences() {
        references = new ArrayList();
        if (entities != null) {
            for (Countries entity : entities) {
                references.add(new CountryConverter(
                        entity, uri.resolve(entity.getId() + "/")));
            }
        }
        return references;
    }

    /**
     * Sets a collection of CountryConverter.
     *
     * @param references collection of CountryConverter to set
     */
    public void setReferences(Collection references) {
        this.references = references;
    }

    /**
     * Returns the total row count of all entities.
     *
     * @return total row count
     */
    @XmlAttribute(name = "totalRows")
    public Long getTotalRows() {
        return this.totalRows;
    }

    /**
     * Returns the URI associated with this converter.
     *
     * @return the uri
     */
    @XmlAttribute(name = "uri")
    public URI getResourceUri() {
        return uri;
    }

    /**
     * Returns a collection Countries entities.
     *
     * @return a collection of Countries entities
     */
    @XmlTransient
    public Collection getEntities() {
        entities = new ArrayList();
        if (references != null) {
            for (CountryConverter ref : references) {
                entities.add(ref.getEntity());
            }
        }
        return entities;
    }
}
Notes:
  1. Use of the original CountryConverter created by the wizard. This defines all fields, not just the URI and ID, making the creation of a detailed resource list more efficient.
  2. Addition of an attribute for the total resource row count for data paging in Ext.
I then created a new resource named CountriesListResource.
package com.zunisoft.openeprs.service;

import com.zunisoft.openeprs.converter.CountriesListConverter;
import com.zunisoft.openeprs.db.Countries;
import com.zunisoft.openeprs.db.util.SQLSanitizer;
import java.util.Collection;
import javax.ws.rs.UriTemplate;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.ProduceMime;
import javax.ws.rs.QueryParam;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.core.HttpContext;
import javax.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.List;
import javax.persistence.Query;
import javax.ws.rs.UriParam;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.UriBuilder;

/**
 * Countries list resource.
 *
 * @author krdavis
 */

@UriTemplate("/countriesList/")
public class CountriesListResource {
    @HttpContext
    private UriInfo context;

    /** Creates a new instance of CountriesListResource */
    public CountriesListResource() {
    }

    /**
     * Constructor used for instantiating an instance of dynamic resource.
     *
     * @param context HttpContext inherited from the parent resource
     */
    public CountriesListResource(UriInfo context) {
        this.context = context;
    }

    /**
     * Get method for retrieving a collection of Countries instance in XML format.
     *
     * @param start starting index of the collection, defaults to 0
     * @param max maximum number of entities to return, defaults to 20
     * @param sort collection sort field, defaults to 'name'
     * @param dir collection sort direction (ASC or DESC), defaults to 'ASC'
     * @return an instance of CountriesListConverter
     */
    @HttpMethod("GET")
    @ProduceMime({"application/xml", "application/json"})
    public CountriesListConverter get(
            @QueryParam("start")
            @DefaultValue("0")
            int start,
            @QueryParam("max")
            @DefaultValue("20")
            int max,
            @QueryParam("sort")
            @DefaultValue("name")
            String sort,
            @QueryParam("dir")
            @DefaultValue("ASC")
            String dir) {
        try {
            // Use this to get filters...
            MultivaluedMap map = context.getQueryParameters();

            URI newContext = UriBuilder.fromUri(context.getBase()).path(
                    "countries/").build();
            return new CountriesListConverter(
                    getEntities(start, max, sort, dir, null),
                    getEntityCount(),
                    newContext);
        } finally {
            PersistenceService.getInstance().close();
        }
    }

    /**
     * Returns the count of all entities associated with this resource.
     *
     * @return count of all entities
     */
    protected Long getEntityCount() {
        return (Long) PersistenceService.getInstance().createQuery(
                "SELECT COUNT(e.id) FROM Countries e").getSingleResult();
    }

    /**
     * Returns all the entities associated with this resource.
     *
     * @param start starting index of the collection
     * @param max maximum number of entities to return in the collection
     * @param sort sort field
     * @param dir sort direction
     * @param filter collection filter
     * @return a collection of Countries instances
     */
    @SuppressWarnings("unchecked")
    protected Collection getEntities(int start, int max,
            String sort, String dir, List filter) {
        String qyText = "SELECT e FROM Countries e ORDER BY";
        qyText = qyText.concat(" e." + SQLSanitizer.clean(sort));
        qyText = qyText.concat(" " + SQLSanitizer.clean(dir));

        Query q = PersistenceService.getInstance().createQuery(qyText);

        return q.setFirstResult(start).setMaxResults(max).getResultList();
    }
}
Notes:
  1. Extra parameters for sorting the results by a given field.
  2. Preservation of the original resource context making URIs point to the correct resource location.
  3. Addition of a function to get a count of all rows related to this resource for paging.
A call to the above service will produce the following JSON:
{"countries":{"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/", "@totalRows":"3",
 "country":[
  {"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/1\/","id":{"$":"1"},"isoCode2":{"$":"AF"},"isoCode3":{"$":"AFG"},"name":{"$":"Afghanistan"},"states":{"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/1\/states\/"},"userProfiles":{"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/1\/userProfiles\/"}},
  {"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/2\/","id":{"$":"2"},"isoCode2":{"$":"AL"},"isoCode3":{"$":"ALB"},"name":{"$":"Albania"},"states":{"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/2\/states\/"},"userProfiles":{"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/2\/userProfiles\/"}},
  {"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/3\/","id":{"$":"3"},"isoCode2":{"$":"DZ"},"isoCode3":{"$":"DZA"},"name":{"$":"Algeria"},"states":{"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/3\/states\/"},"userProfiles":{"@uri":"http:\/\/localhost:8080\/openeprs-svcs\/resources\/countries\/3\/userProfiles\/"}}
 ]
}}
Now you can do the following and it will work:
/* Record */
record = Ext.data.Record.create([
    {name: 'id', mapping: 'id'},
    {name: 'name', mapping: 'name'},
    {name: 'isoCode2', mapping: 'isoCode2'},
    {name: 'isoCode3', mapping: 'isoCode3'},
    {name: 'uri', mapping: '@uri'}
]);

/* Reader */
reader = new openEPRS.data.BadgerfishReader({
    totalProperty: "countries.@totalRows",
    root: "countries.country",
    id: "id"
}, record);

/* Store */
store = new Ext.data.Store({
        proxy: new Ext.data.HttpProxy({url: openEPRS.Config.getBaseSvcsUrl() + '/countriesList/'}),
        reader: reader,
        remoteSort: true
    }),
store.setDefaultSort('name', 'asc');
You may find this solution useful for your own starting point if you want to use Ext and Jersey together. Any comments or suggestions are welcome!