There will be a more formal solution integrated into Jersey 2.x at a future date; but this solution will do at the moment if you want to play around with this.
So the first class we need to put in place is a model processor, very much and internal Jersey class, that allows us to amend the resource model with extra methods and resources. To each resource in the model we can add the
JsonSchemaHandler
which does the hard work of generating a new schema. Since this is a simple POC there is no caching going on here, please be aware of this if you are going to use this in production code.import com.google.common.collect.Lists; import example.Bean; import java.io.IOException; import java.io.StringWriter; import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import javax.inject.Inject; import javax.ws.rs.HttpMethod; import javax.ws.rs.WebApplicationException; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.core.Configuration; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.xml.bind.JAXBException; import javax.xml.bind.SchemaOutputResolver; import javax.xml.transform.Result; import javax.xml.transform.stream.StreamResult; import org.eclipse.persistence.jaxb.JAXBContext; import org.glassfish.jersey.process.Inflector; import org.glassfish.jersey.server.ExtendedUriInfo; import org.glassfish.jersey.server.model.ModelProcessor; import org.glassfish.jersey.server.model.ResourceMethod; import org.glassfish.jersey.server.model.ResourceModel; import org.glassfish.jersey.server.model.RuntimeResource; import org.glassfish.jersey.server.model.internal.ModelProcessorUtil; import org.glassfish.jersey.server.wadl.internal.WadlResource; public class JsonSchemaModelProcessor implements ModelProcessor { private static final MediaType JSON_SCHEMA_TYPE = MediaType.valueOf("application/schema+json"); private final List<ModelProcessorUtil.Method> methodList; public JsonSchemaModelProcessor() { methodList = Lists.newArrayList(); methodList.add(new ModelProcessorUtil.Method("$schema", HttpMethod.GET, MediaType.WILDCARD_TYPE, JSON_SCHEMA_TYPE, JsonSchemaHandler.class)); } @Override public ResourceModel processResourceModel(ResourceModel resourceModel, Configuration configuration) { return ModelProcessorUtil.enhanceResourceModel(resourceModel, true, methodList, true).build(); } @Override public ResourceModel processSubResource(ResourceModel resourceModel, Configuration configuration) { return ModelProcessorUtil.enhanceResourceModel(resourceModel, true, methodList, true).build(); } public static class JsonSchemaHandler implements Inflector<ContainerRequestContext, Response> { private final String lastModified = new SimpleDateFormat(WadlResource.HTTPDATEFORMAT).format(new Date()); @Inject private ExtendedUriInfo extendedUriInfo; @Override public Response apply(ContainerRequestContext containerRequestContext) { // Find the resource that we are decorating, then work out the // return type on the first GET List<RuntimeResource> ms = extendedUriInfo.getMatchedRuntimeResources(); List<ResourceMethod> rms = ms.get(1).getResourceMethods(); Class responseType = null; found: for (ResourceMethod rm : rms) { if ("GET".equals(rm.getHttpMethod())) { responseType = (Class) rm.getInvocable().getResponseType(); break found; } } if (responseType == null) { throw new WebApplicationException("Cannot resolve type for schema generation"); } // try { JAXBContext context = (JAXBContext) JAXBContext.newInstance(responseType); StringWriter sw = new StringWriter(); final StreamResult sr = new StreamResult(sw); context.generateJsonSchema(new SchemaOutputResolver() { @Override public Result createOutput(String namespaceUri, String suggestedFileName) throws IOException { return sr; } }, responseType); return Response.ok().type(JSON_SCHEMA_TYPE) .header("Last-modified", lastModified) .entity(sw.toString()).build(); } catch (JAXBException jaxb) { throw new WebApplicationException(jaxb); } } } }
Note the very simple heuristic in the
JsonSchemaHandler
code it assumes that for each resource there is a 1:1 mapping to a single JSON Schema element. This of course might not be true for your particular application.Now that we have the schema generated in a know location we need to tell the client about it, the first thing we will do is to make sure that there is a suitable link header when the user invokes OPTIONS on a particular resource:
import java.io.IOException; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.container.ContainerResponseContext; import javax.ws.rs.container.ContainerResponseFilter; import javax.ws.rs.core.Context; import javax.ws.rs.core.Link; import javax.ws.rs.core.UriInfo; public class JsonSchemaResponseFilter implements ContainerResponseFilter { @Context private UriInfo uriInfo; @Override public void filter(ContainerRequestContext containerRequestContext, ContainerResponseContext containerResponseContext) throws IOException { String method = containerRequestContext.getMethod(); if ("OPTIONS".equals(method)) { Link schemaUriLink = Link.fromUriBuilder(uriInfo.getRequestUriBuilder() .path("$schema")).rel("describedBy").build(); containerResponseContext.getHeaders().add("Link", schemaUriLink); } } }
Since this is JAX-RS 2.x we are working with we of course are going bundle all the bit together into a feature:
import javax.ws.rs.core.Feature; import javax.ws.rs.core.FeatureContext; public class JsonSchemaFeature implements Feature { @Override public boolean configure(FeatureContext featureContext) { if (!featureContext.getConfiguration().isRegistered(JsonSchemaModelProcessor.class)) { featureContext.register(JsonSchemaModelProcessor.class); featureContext.register(JsonSchemaResponseFilter.class); return true; } return false; } }
I am not going to show my entire set of POJO classes; but just quickly this is the Resource class with the @GET method required by the schema generation code:
import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.core.MediaType; @Path("/bean") public class BeanResource { @GET @Produces(MediaType.APPLICATION_JSON) public Bean getBean() { return new Bean(); } }
And finally here is what you see if you perform a GET on a resource:
GET .../resources/bean Content-Type: application/json { "message" : "hello", "other" : { "message" : "OtherBean" }, "strings" : [ "one", "two", "three", "four" ] }
And OPTIONS:
OPTIONS .../resources/bean Content-Type: text/plain Link: <http://.../resources/bean/$schema>; rel="describedBy" GET, OPTIONS, HEAD
And finally if you resolve the schema resource:
GET .../resources/bean/$schema Content-Type: application/schema+json { "$schema" : "http://json-schema.org/draft-04/schema#", "title" : "example.Bean", "type" : "object", "properties" : { "message" : { "type" : "string" }, "other" : { "$ref" : "#/definitions/OtherBean" }, "strings" : { "type" : "array", "items" : { "type" : "string" } } }, "additionalProperties" : false, "definitions" : { "OtherBean" : { "type" : "object", "properties" : { "message" : { "type" : "string" } }, "additionalProperties" : false } } }
There is a quite a bit of work to do here, in particular generating the hypermedia extensions based on the declarative linking annotations that I forward ported into Jersey 2.x a little while back. But it does point towards a solution and we get to exercise a variety of solutions to get something working now.
4 comments:
Would this be possible if using a Jackson ObjectMapper rather than the default MOXy?
I think there would be a mismatch unless the JSON was very simple.
Is this landed in Jersey, or evolved somewhere else? I tried to implement it but I'm getting a ClassCastException: com.sun.xml.bind.v2.runtime.JAXBContextImpl cannot be cast to org.eclipse.persistence.jaxb.JAXBContext.
I don't think it has been uptaken by Jersey, do you have the full stack trace?
Post a Comment