Saturday, April 16, 2016

JSON filters for Spring MVC

Spring MVC easily allows us to convert the responce body to JSON. It's great but unfortunately we usually need different JSON for different methods even for the same class. For instance, the class we have to serialize to JSON may have a lot of properties. We usually need just a small part of them when we list instances of the class in a grid. On the other hand, most of them are actually needed when we want to display the instance itself. If a class depends on other classes the problem becomes even complicated. Jackson JSON supports filters and views as a solution. Moreover, Spring 4.2 added support for Jackson's @JsonView annotation declared on a controller method. But I don't think Jackson's views are good for that. First of all you have to annotate methods of the class itself with @JsonView to filter its properties out. Then you have to define additional classes for the views. I think it'd be much easier just to list all the required properties for a controller method itself. So I added custom annotations for Spring MVC controllers:
/**
 * Annotation applied to a Spring MVC {@code @RequestMapping} or {@code @ExceptionHandler} method to filter out
 * its response body during JSON serialization.
 * <p/>
 * Example:
 * <pre>
 * public class PropSet extends PredefinedPropSet {
 *     public PropSet() {
 *         super("propA");
 *     }
 * }
 *
 * @JsonFilter(target = Bean.class, include = {"propB"}, propSets = PropSet.class)
 * public @RequestBody getBean() {
 *  ...
 * }
 * </pre>
 *
 * @author Oleg Galkin
 * @see com.fasterxml.jackson.databind.ObjectMapper#setFilterProvider(com.fasterxml.jackson.databind.ser.FilterProvider)
 * @see com.fasterxml.jackson.databind.ObjectMapper#setAnnotationIntrospector(com.fasterxml.jackson.databind.AnnotationIntrospector)
 */
@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface JsonFilter {
    /**
     * Class the filter is used for.
     */
    Class<?> target();

    /**
     * Explicitly defined properties that should be kept when serializing the specified class to JSON.
     */
    String[] include() default {};

    /**
     * {@link PredefinedPropSet Property sets} that define the properties included in JSON.
     * Each property set class must have a default constructor.
     */
    Class<? extends PredefinedPropSet>[] propSets() default {};
}

/**
 * Declares several {@link JsonFilter} annotations for different classes on the same method.
 *
 * @author Oleg Galkin
 */
@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface JsonFilters {
    /**
     * Defined filters.
     */
    JsonFilter[] value() default {};
}
The annotations can be used as follows:
@JsonFilters({
        @JsonFilter(target = User.class, propSets = {IdPropSet.class, UserPropSet.class}),
        @JsonFilter(target = Role.class, include = {"id", "name"})
})
@RequestMapping(value = "/user/{id}", method = RequestMethod.GET)
@ResponseBody
public User getUser(long id) {
    ...
}
They allow listing properties included in JSON explicitly or using special property sets inherited from PredefinedPropSet.
To remove unnecessary properties we need Jackson's filter that only serializes the allowed object properties
public class ExceptPropertyFilter implements PropertyFilter {
    private final Map<Class<?>, Set<String>> filters = Maps.newHashMap();

    ...

    @Override
    public void serializeAsField(Object pojo, JsonGenerator jgen, SerializerProvider prov, PropertyWriter writer)
            throws Exception {
        if (include(pojo, writer)) {
            writer.serializeAsField(pojo, jgen, prov);
        } else if (!jgen.canOmitFields()) {
            writer.serializeAsOmittedField(pojo, jgen, prov);
        }
    }

   ...

    protected boolean include(Object object, PropertyWriter writer) {
        Validate.notNull(object);
        Set<String> properties = getProperties(object);
        return properties == null || properties.contains(writer.getName());
    }

    private Set<String> getProperties(Object object) {
        for (Class<?> cls = object.getClass(); cls != Object.class; cls = cls.getSuperclass()) {
            Set<String> fields = filters.get(cls);
            if (fields != null) {
                return fields;
            }
        }
        return null;
    }
}
The filter must be defined for all classes. Because Jackson uses string filter identifiers to find a specific filter, we have to define one and get it known to Jackson. To do it we can override the annotation introspector Jackson has:
public class JsonFilterAnnotationIntrospector extends JacksonAnnotationIntrospector {
    private final String filterId;

    ,,,

    @Override
    public Object findFilterId(Annotated ann) {
        Object id = super.findFilterId(ann);
        if (id == null) {
            JavaType javaType = TypeFactory.defaultInstance().constructType(ann.getRawType());
            if (!javaType.isContainerType()) {
                id = filterId;
            }
        }
        return id;
    }
}
The annotation introspector must be set to the object mapper Spring MVC MappingJackson2HttpMessageConverter will use. It can be done by overriding a WebMvcConfigurerAdapter method:
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
    ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
            .annotationIntrospector(new JsonFilterAnnotationIntrospector())
            .filters(new SimpleFilterProvider().setFailOnUnknownId(false))
            .build();
    converters.add(new MappingJackson2HttpMessageConverter(objectMapper));
}
And finally we need to activate the filter when a method annotated with @JsonFilter is invoked by implementing Spring MVC ResponseBodyAdvice interface:
/**
 * {@link ResponseBodyAdvice} implementation that supports the {@link JsonFilter} annotation declared on a Spring MVC
 * {@code @RequestMapping} or {@code @ExceptionHandler} method.
 * <p/>
 * The created {@link ExceptPropertyFilter} is used within {@link MappingJackson2HttpMessageConverter} to serialize
 * the response body to JSON.
 *
 * @author Oleg Galkin
 * @see JsonFilter
 * @see ExceptPropertyFilter
 * @see MappingJackson2HttpMessageConverter
 */
@ControllerAdvice
public class JsonFilterResponseBodyAdvice extends AbstractMappingJacksonResponseBodyAdvice {
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return super.supports(returnType, converterType) &&
                (returnType.getMethodAnnotation(JsonFilter.class) != null ||
                        returnType.getMethodAnnotation(JsonFilters.class) != null);
    }

    @Override
    protected void beforeBodyWriteInternal(MappingJacksonValue bodyContainer, MediaType contentType,
                                           MethodParameter returnType, ServerHttpRequest request,
                                           ServerHttpResponse response) {
        ExceptPropertyFilter filter;
        JsonFilter annotation = returnType.getMethodAnnotation(JsonFilter.class);
        if (annotation != null) {
            filter = new ExceptPropertyFilter(annotation);
        } else {
            filter = new ExceptPropertyFilter(returnType.getMethodAnnotation(JsonFilters.class).value());
        }

        SimpleFilterProvider filters = new SimpleFilterProvider();
        filters.addFilter(JsonFilterAnnotationIntrospector.DEFAULT_FILTER_ID, filter);
        bodyContainer.setFilters(filters);
    }
}
The code is available here.

No comments:

Post a Comment