Many people prefer the kind of dynamic fluent API that Jersey provides for calling RESTful services. This doesn't suit every situation though and in some cases it would be nice to have a statically typed interface. RESTEasy provides something along these lines; but I wanted something that worked with Jersey and to take it a bit further to support basic HATEOAS.
So consider the following two interfaces and one bean class that make up the service we are trying to call. Note that there isn't a one to one mapping with the server classes as unlike with SOAP/WSDL you can be a bit flexible. It wouldn't make sense to generate a static client that supports both XML and JSON content types for example where-as the server would support both.
package bucketservice; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Response; @Path("buckets") public interface Buckets { @POST public Bucket createNewBucketBound(String input); @GET @Produces("application/buckets+xml") public BucketList getBuckets(); @Path("/{id}") public Bucket getBucket(@PathParam("id") String id); }
Note there is a method for getting a bucket from an "id" property; but more likely you are going to want to create a resource directly from the URI. This you can do easily as we will see later.
The bucket itself is really simple as it just exposes the get and delete services.
package bucketservice; import javax.ws.rs.DELETE; import javax.ws.rs.GET; public interface Bucket { @GET public String getBucket(); @DELETE public Response.Status delete(); }
The bean class is straight forward enough but there is an extra annotation @MappedResource which is used to produce an extra method that is in terms of the resource rather than URIs. Currently this work is done with a Mk1 Human Generator but it wouldn't take too long to implement with with the APT.
package bucketservice; import javax.xml.bind.annotation.XmlRootElement; import proxy.MappedResource; @XmlRootElement public class BucketList { URI list[]; public void setList(URI[] list) { this.list = list; } @MappedResource(Bucket.class) public URI[] getList() { return list; } }
So the key part of the client example below is a static "of" method on ClientProxy that takes as it's input a web resource and the interface you want to use to talk to this resource and return a dynamic proxy based on the interface. This resource location shouldn't include any sub path information included on the interface.
The call to createNewBucketBound(...) results in a HTTP response of "201 Created" with the location of the resource as a URI. The proxy code knows the return type so will return a new proxy for the Bucket interface as determined by the return type of the method. You can then go off and happily invoke methods on this such as get or delete as if it was the real interface.
The rest of the method gets hold of a list of buckets, again these are dynamic proxies based on the interface given; deletes the resource, then show the list again to be sure.
package bucketclient; import bucketservice.Bucket; import bucketservice.BucketList; import bucketservice.Buckets; import com.sun.jersey.api.client.Client; import com.sun.jersey.api.client.WebResource; import static java.lang.System.out; import java.net.URI; import static proxy.ClientProxy.of; import proxy.hateoas.HATEOASClientConfig; public class BucketClient { public static void main(String[] args) { Client client = Client.create(new HATEOASClientConfig()); WebResource rootResource = client.resource("http://localhost:7101/RSProxyClient-BucketService-context-root/jersey/"); // Proxy Buckets buckets = of(rootResource, Buckets.class); out.println("Using proxy " + buckets); // Create me two buckets, note nothing is returned in the body of // the response message, just a location in the header. // Bucket firstBucketRSP = buckets.createNewBucketBound("First Bucket"); // POST -> 201 Bucket secondBucketRSP = buckets.createNewBucketBound("Second Bucket"); // POST -> 201 // Get the contents of each // out.println("<Contents of buckets>"); out.println(firstBucketRSP + "First # " + firstBucketRSP.getBucket()); // GET .../id -> 200 text/plain out.println(secondBucketRSP + "# " + secondBucketRSP.getBucket()); // GET .../id -> 200 text/plain out.println("</Contents of buckets>"); // Get the list of buckets, use the injected getListAsResource method // to get bound interfaces // BucketList bucketList = buckets.getBuckets(); // GET .../ -> 200 application/buckets+xml out.println("<Bucket List>"); for (Bucket next : bucketList.getListAsResources()) { out.println(" " + next); } out.println("</Bucket List>"); // Remove our buckets using the interface we had before // firstBucketRSP.delete(); // DELETE .../id -> 200 secondBucketRSP.delete(); // DELETE ../id -> 200 // Trace out bucket list again bucketList = buckets.getBuckets(); // GET .../ -> 200 application/buckets+xml out.println("<Bucket List After Delete>"); if (bucketList.getListAsResources()!=null) { for (Bucket next : bucketList.getListAsResources()) { out.println(" " + next); } } out.println("</Bucket List After Delete>"); } }
The getListAsResource(..) method as you might have noticed is not part of the BucketList interface and would be generated based on the @MappedResource annotation. How this this would happen I am not entirely sure; but you can start to see the start of a HATEOAS enabled client. Basically you can access the next resource along without any further work. You can imagine a "Transfer" bean that exposed the resources for the "Bank" at each end of the transfer. The client can deal with wiring this all up for you.
So the output of the run looks like this, note that all the Is-A object are dynamic proxies of the resource.
Using proxy Is-A:bucketservice.Buckets@[uri=http://localhost:7101/RSProxyClient-BucketService-context-root/jersey/buckets] <Contents of buckets> Is-A:bucketservice.Bucket@[uri=http://localhost:7101/RSProxyClient-BucketService-context-root/jersey/buckets/2]First # First Bucket Is-A:bucketservice.Bucket@[uri=http://localhost:7101/RSProxyClient-BucketService-context-root/jersey/buckets/3]# Second Bucket </Contents of buckets> <Bucket List> Is-A:bucketservice.Bucket@[uri=http://localhost:7101/RSProxyClient-BucketService-context-root/jersey/buckets/2] Is-A:bucketservice.Bucket@[uri=http://localhost:7101/RSProxyClient-BucketService-context-root/jersey/buckets/3] </Bucket List> <Bucket List After Delete> </Bucket List After Delete>
I hope you can see by example how easy it is to convert a URI into a strongly typed interface.
URI bucket = .... Bucket bucketIF = of(client.resource(bucket), Bucket.class);
Ideally the interfaces would be generated from a sub set of a WADL, you would want to be able to filter by content-type and resource path. Again this kind of focused generation would have been much harder with JAX-WS / WSDL.
The code for this is still on my laptop, except for the code generation, and unfortunately it is not something I can distribute at the moment. This is something I am going to look into if people find this approach interesting.
2 comments:
I'm definitely interseted in seeing where this goes. I tried to put something similar into RESTEasy. Take a look towards the end of http://www.jroller.com/Solomon/entry/declarative_hyperlinking_in_resteasy
I didn't use URI at all. I used an annotation on my domain/dto object that describes how to convert the object to and from a URL (@URITemplate), and added a @XmlJavaTypeAdapter on a class
Yes, interesting. I do remember reading this when you first posted.
I do wonder though whether we could be both wrong in our original plans as we should be focusing on id/idrefs pairs.
Fundamentally HATEOAS, and REST to a lesser extent, models the entire system as one large interconnected information model which breaks between documents at the convenience of the developers.
Take for example trying to map Emp/Dept to a simple web service.
In this case use Id/IdRef to determine the intersection between different documents or parts there of. If the adapter on Id already has a suitable adapter to generate a self URI then IdRef should get that value automatically.
Not sure this is entirely fully thought out; but it feels like the right direction.
Any thoughts?
Gerard
Post a Comment