Selecting level of detail returned by varying the content type, part II
In my previous entry, we looked at using the feature of MOXy to control the level of data output for a particular entity. This post looks at an abstraction provided by Jersey 2.x that allows you to define a custom set of annotations to have the same effect.
As before we have an almost trivial resource that returns an object that Jersey will covert to JSON for us, note that for the moment there is nothing in this code to do the filtering – I am not going to pass in annotations to the
Response
object as in the Jersey examples:
import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; @Path("hello") public class SelectableHello { @GET @Produces({ "application/json; level=detailed", "application/json; level=summary", "application/json; level=normal" }) public Message hello() { return new Message(); } }
In my design I am going to define four annotations: NoView
, SummaryView
, NormalView
and DetailedView
. All root objects have to implement the NoView annotation to prevent un-annotated fields from being exposed – you might not feel this is necessary in your design. All of these classes look the same so I am going to only show one. Note that the factory method creating a AnnotationLiteral
has to be used in preference to a factory that would create a dynamic proxy to have the same effect. There is code in 2.5 that will ignore any annotation implemented by a java.lang.reflect.Proxy
object, this includes any annotations you may have retrieved from a class. I am working on submitting a fix for this.
import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import javax.enterprise.util.AnnotationLiteral; import org.glassfish.jersey.message.filtering.EntityFiltering; @Target({ ElementType.TYPE, ElementType.METHOD, ElementType.FIELD }) @Retention(RetentionPolicy.RUNTIME) @Documented @EntityFiltering public @interface NoView { /** * Factory class for creating instances of the annotation. */ public static class Factory extends AnnotationLiteral<NoView> implements NoView { private Factory() { } public static NoView get() { return new Factory(); } } }
Now we can take a quick look at our Message bean, this is slightly more complicated than my previous example to showing filtering of subgraphs in a very simple form. As I said before the class is annotated with a NoView annotation at the root – this should mean that the privateData
is never returned to the client as it is not specifically annotated.
import javax.xml.bind.annotation.XmlRootElement; @XmlRootElement @NoView public class Message { private String privateData; @SummaryView private String summary; @NormalView private String message; @DetailedView private String subtext; @DetailedView private SubMessage submessage; public Message() { summary = "Some simple summary"; message = "This is indeed the message"; subtext = "This is the deep and meaningful subtext"; submessage = new SubMessage(); privateData = "The fox is flying tonight"; } // Getters and setters not shown } public class SubMessage { private String message; public SubMessage() { message = "Some sub messages"; } // Getters and setters not shown }
As noted before there is no code in the resource class to deal with filtering – I considered this to be a cross cutting concern so I have abstracted this into a WriterInterceptor. Note the exception thrown if a entity is used that doesn’t have the NoView annotation on it.
import java.io.IOException; import java.lang.annotation.Annotation; import java.util.Arrays; import java.util.LinkedHashSet; import java.util.Set; import javax.ws.rs.ServerErrorException; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Context; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.ext.Provider; import javax.ws.rs.ext.WriterInterceptor; import javax.ws.rs.ext.WriterInterceptorContext; @Provider public class ViewWriteInterceptor implements WriterInterceptor { private HttpHeaders httpHeaders; public ViewWriteInterceptor(@Context HttpHeaders httpHeaders) { this.httpHeaders = httpHeaders; } @Override public void aroundWriteTo(WriterInterceptorContext writerInterceptorContext) throws IOException, WebApplicationException { // I assume this case will never happen, just to be sure if (writerInterceptorContext.getEntity() == null) { writerInterceptorContext.proceed(); return; } else { Class<?> entityType = writerInterceptorContext.getEntity() .getClass(); String entityTypeString = entityType.getName(); // Ignore any Jersey system classes, for example wadl // if (entityType == String.class || entityType.isArray() || entityTypeString.startsWith("com.sun") || entityTypeString.startsWith("org.glassfish")) { writerInterceptorContext.proceed(); return; } // Fail if the class doesn't have the default NoView annotation // this prevents any unannotated fields from showing up // else if (!entityType.isAnnotationPresent(NoView.class)) { throw new ServerErrorException("Entity type should be tagged with @NoView annotation " + entityType, Response.Status.INTERNAL_SERVER_ERROR); } } // Get hold of the return media type: // MediaType mt = writerInterceptorContext.getMediaType(); String level = mt.getParameters().get("level"); // Get the annotations and modify as required // Set<Annotation> current = new LinkedHashSet<>(); current.addAll(Arrays.asList( writerInterceptorContext.getAnnotations())); switch (level != null ? level : "") { default: case "detailed": current.add(com.example.annotation.DetailedView.Factory.get()); case "normal": current.add(com.example.annotation.NormalView.Factory.get()); case "summary": current.add(com.example.annotation.SummaryView.Factory.get()); } writerInterceptorContext.setAnnotations( current.toArray(new Annotation[current.size()])); // writerInterceptorContext.proceed(); } }
Finally you have to enable the EntityFilterFeature manually, to do this you can simple register it in your Application class
import java.lang.annotation.Annotation; import javax.ws.rs.ApplicationPath; import org.glassfish.jersey.message.filtering.EntityFilteringFeature; import org.glassfish.jersey.server.ResourceConfig; @ApplicationPath("/resources/") public class SelectableApplication extends ResourceConfig { public SelectableApplication() { packages("..."); // Set entity-filtering scope via configuration. property(EntityFilteringFeature.ENTITY_FILTERING_SCOPE, new Annotation[] { NormalView.Factory.get(), DetailedView.Factory.get(), NoView.Factory.get(), SummaryView.Factory.get() }); register(EntityFilteringFeature.class); } }
Once you have this all up and running the application will respond as before:
GET .../hello Accept application/json; level=detailed or application/json { "message" : "This is indeed the message", "submessage" : { "message" : "Some sub messages" }, "subtext" : "This is the deep and meaningful subtext", "summary" : "Some simple summary" } GET .../hello Accept application/json; level=normal { "message" : "This is indeed the message", "summary" : "Some simple summary" } GET .../hello Accept application/json; level=summary { "summary" : "Some simple summary" }
This is feel is a better alternative to using the MOXy annotations directly – using custom annotations should have to much easier to port your application to over implementation even if you have to provide you own filter. Finally it is worth also exploring the Jersey extension to this that allows Role based filtering which I can see as being useful in a security aspect.