Reputation: 25421
I'll get to my real question/issue right away, is there any way to access annotations on the controller's handler method inside of a HttpMessageConverter? I'm pretty sure the answer is no (after walking through Spring's source code).
Is there any other way to use Jackson Mixins paired when using MappingJacksonHttpMessageConverter? I've already implemented my own HttpMessageConverter based on MappingJacksonHttpMessageConverter to "upgrade" it to use Jackson 2.0.
Controller.class
@Controller
public class Controller {
@JsonFilter({ @JsonMixin(target=MyTargetObject.class, mixin=MyTargetMixin.class) })
@RequestMapping(value="/my-rest/{id}/my-obj", method=RequestMethod.GET, produces="application/json")
public @ResponseBody List<MyTargetObject> getListOfFoo(@PathVariable("id") Integer id) {
return MyServiceImpl.getInstance().getBarObj(id).getFoos();
}
}
@JsonFilter
is a custom annotation I wish to pass to the mapper, which then can be fed directly to the ObjectMapper automatically.
MappingJacksonHttpMessageConverter.class
public class MappingJacksonHttpMessageConverter extends AbstractHttpMessageConverter<Object> {
...
@Override
protected void writeInternal(Object object, HttpOutputMessage outputMessage) {
//Obviously, no access to the HandlerMethod here.
}
...
}
I've searched far and wide for this answer. So far I've only seen people serialize their object to JSON inside of the Controller's handling method (violating the DRY principle repeatedly in every method). Or annotation their data objects directly (no decoupling or multiple configurations on how to expose your objects).
It may be that it can't be done in a HttpMessageConverter. Are there other options? Interceptors give access to the HandlerMethod but not to the returned object of the handler method.
Upvotes: 3
Views: 6914
Reputation: 91
I do not know if it is due to the version of Spring I am using (5) or whether I just did something wrong, but the answers here did not work for me. What I ended up doing was registering a RequestResponseBodyMethodProcessor with the desired ObjectMapper.
PushCard.getObjectMapperForDTO() is my method that returns an ObjectMapper that already has the right Mixins. You can obviously use your own method, which configures it however you want.
My configuration class looks like this.
@EnableWebMvc
@Configuration
public class SidekickApplicationConfiguration implements WebMvcConfigurer {
private static Logger logger = LoggerFactory.getLogger(SidekickApplicationConfiguration.class);
@Autowired
private RequestMappingHandlerAdapter requestHandler;
@Bean
RequestResponseBodyMethodProcessor registerReturnValueHandler() {
List<HttpMessageConverter<?>> messageConverters = new ArrayList<>();
messageConverters.add(new MappingJackson2HttpMessageConverter(PushCard.getObjectMapperForDTO()));
logger.info("Registering RequestResponseBodyMethodProcessor with DTO ObjectMapper...");
RequestResponseBodyMethodProcessor r = new RequestResponseBodyMethodProcessor(messageConverters);
requestHandler.setReturnValueHandlers(Arrays.asList(r));
return r;
}
}
Upvotes: 0
Reputation: 25421
This is NOT the ideal solution. See my second answer.
I solved this using a ModelAndViewResolver
. You are able to register these directly with the AnnotationMethodHandlerAdapter
with the perk of knowing that they'll always kick in first before the default handling occurs. Hence, Spring's documentation -
/**
* Set a custom ModelAndViewResolvers to use for special method return types.
* <p>Such a custom ModelAndViewResolver will kick in first, having a chance to resolve
* a return value before the standard ModelAndView handling kicks in.
*/
public void setCustomModelAndViewResolver(ModelAndViewResolver customModelAndViewResolver) {
this.customModelAndViewResolvers = new ModelAndViewResolver[] {customModelAndViewResolver};
}
Looking at the ModelAndViewResolver
interface, I knew that it contained all the arguments needed to extend some functionality into how the handler method worked.
public interface ModelAndViewResolver {
ModelAndView UNRESOLVED = new ModelAndView();
ModelAndView resolveModelAndView(Method handlerMethod,
Class handlerType,
Object returnValue,
ExtendedModelMap implicitModel,
NativeWebRequest webRequest);
}
Look at all those delicious arguments in resolveModelAndView
! I have access to virtually everything Spring knows about the request. Here's how I implemented the interface to act very similar to the MappingJacksonHttpMessageConverter
except in a uni-directional manner (outward):
public class JsonModelAndViewResolver implements ModelAndViewResolver {
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
public static final MediaType DEFAULT_MEDIA_TYPE = new MediaType("application", "json", DEFAULT_CHARSET);
private boolean prefixJson = false;
public void setPrefixJson(boolean prefixJson) {
this.prefixJson = prefixJson;
}
/**
* Converts Json.mixins() to a Map<Class, Class>
*
* @param jsonFilter Json annotation
* @return Map of Target -> Mixin classes
*/
protected Map<Class<?>, Class<?>> getMixins(Json jsonFilter) {
Map<Class<?>, Class<?>> mixins = new HashMap<Class<?>, Class<?>>();
if(jsonFilter != null) {
for(JsonMixin jsonMixin : jsonFilter.mixins()) {
mixins.put(jsonMixin.target(), jsonMixin.mixin());
}
}
return mixins;
}
@Override
public ModelAndView resolveModelAndView(Method handlerMethod, Class handlerType, Object returnValue, ExtendedModelMap implicitModel, NativeWebRequest webRequest) {
if(handlerMethod.getAnnotation(Json.class) != null) {
try {
HttpServletResponse httpResponse = webRequest.getNativeResponse(HttpServletResponse.class);
httpResponse.setContentType(DEFAULT_MEDIA_TYPE.toString());
OutputStream out = httpResponse.getOutputStream();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setMixInAnnotations(getMixins(handlerMethod.getAnnotation(Json.class)));
JsonGenerator jsonGenerator =
objectMapper.getJsonFactory().createJsonGenerator(out, JsonEncoding.UTF8);
if (this.prefixJson) {
jsonGenerator.writeRaw("{} && ");
}
objectMapper.writeValue(jsonGenerator, returnValue);
out.flush();
out.close();
return null;
} catch (JsonProcessingException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
return UNRESOLVED;
}
}
The only custom class used above is my annotation class @Json
which includes one parameter called mixins
. Here's how I implement this on the Controller side.
@Controller
public class Controller {
@Json({ @JsonMixin(target=MyTargetObject.class, mixin=MyTargetMixin.class) })
@RequestMapping(value="/my-rest/{id}/my-obj", method=RequestMethod.GET)
public @ResponseBody List<MyTargetObject> getListOfFoo(@PathVariable("id") Integer id) {
return MyServiceImpl.getInstance().getBarObj(id).getFoos();
}
}
That is some pretty awesome simplicity. The ModelAndViewResolver will automatically convert the return object to JSON and apply the annotated mix-ins as well.
The one "down side" (if you call it that) to this is having to revert back to the Spring 2.5 way of configuring this since the new 3.0 tag doesn't allow configuring the ModelAndViewResolver directly. Maybe they just overlooked this?
My Old Config (using Spring 3.1 style)
<mvc:annotation-driven />
My New Config (using Spring 2.5 style)
<bean class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter">
<property name="customModelAndViewResolvers">
<list>
<bean class="my.package.mvc.JsonModelAndViewResolver" />
</list>
</property>
</bean>
^^ 3.0+ doesn't have a way to wire-in the custom ModelAndViewResolver. Hence, the switch back to the old style.
Here's the custom annotations:
Json
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Json {
/**
* A list of Jackson Mixins.
* <p>
* {@link http://wiki.fasterxml.com/JacksonMixInAnnotations}
*/
JsonMixin[] mixins() default {};
}
JsonMixin
public @interface JsonMixin {
public Class<? extends Serializable> target();
public Class<?> mixin();
}
Upvotes: 3
Reputation: 25421
After posting the answer below, I changed how I did this. I used a HandlerMethodReturnValueHandle
r. I had to create a programmatic web config to override the order because custom return value handlers are triggered last. I needed them to be triggered before the defaults.
@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
...
}
Hopefully this will lead someone in a better direction than my answer below.
This allowed me to serialize any object directly into JSON. In the @RequestMapping has produces="application/json" then I'd always serialized the return value into JSON.
I did the same thing for parameter binding except I used a HandlerMethodArgumentResolver
. Just annotate your classes with an annotation of your choosing (I used JPA @Entity because I'd normally be serializing into models).
You now have seamless POJO to JSON de/serialization in your Spring controllers without any boilerplater code necessary.
Bonus: The argument resolver I have will check for @Id tags to of the parameter, if the JSON contains a key for an Id then the entity is retrieved and the JSON is applied TO the persisted object. Bam.
/**
* De-serializes JSON to a Java Object.
* <p>
* Also provides handling of simple data type validation. If a {@link JsonMappingException} is thrown then it
* is wrapped as a {@link ValidationException} and handled by the MVC/validation framework.
*
* @author John Strickler
* @since 2012-08-28
*/
public class EntityArgumentResolver implements HandlerMethodArgumentResolver {
@Autowired
private SessionFactory sessionFactory;
private final ObjectMapper objectMapper = new ObjectMapper();
private static final Logger log = Logger.getLogger(EntityArgumentResolver.class);
//whether to log the incoming JSON
private boolean doLog = false;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().getAnnotation(Entity.class) != null;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
String requestBody = IOUtils.toString(request.getReader());
Class<?> targetClass = parameter.getParameterType();
Object entity = this.parse(requestBody, targetClass);
Object entityId = getId(entity);
if(doLog) {
log.info(requestBody);
}
if(entityId != null) {
return copyObjectToPersistedEntity(entity, getKeyValueMap(requestBody), entityId);
} else {
return entity;
}
}
/**
* @param rawJson a json-encoded string
* @return a {@link Map} consisting of the key/value pairs of the JSON-encoded string
*/
@SuppressWarnings("unchecked")
private Map<String, Object> getKeyValueMap(String rawJson) throws JsonParseException, JsonMappingException, IOException {
return objectMapper.readValue(rawJson, HashMap.class);
}
/**
* Retrieve an existing entity and copy the new changes onto the entity.
*
* @param changes a recently deserialized entity object that contains the new changes
* @param rawJson the raw json string, used to determine which keys were passed to prevent
* copying unset/null values over to the persisted entity
* @return the persisted entity with the new changes copied onto it
* @throws NoSuchMethodException
* @throws SecurityException
* @throws InvocationTargetException
* @throws IllegalAccessException
* @throws IllegalArgumentException
*/
private Object copyObjectToPersistedEntity(Object changesObject, Map<String, Object> changesMap, Object id) throws SecurityException, NoSuchMethodException, IllegalArgumentException, IllegalAccessException, InvocationTargetException {
Session session = sessionFactory.openSession();
Object persistedObject =
session.get(changesObject.getClass(), (Serializable) id);
session.close();
if(persistedObject == null) {
throw new ValidationException(changesObject.getClass().getSimpleName() + " #" + id + " not found.");
}
Class<?> clazz = persistedObject.getClass();
for(Method getterMethod : ReflectionUtils.getAllDeclaredMethods(clazz)) {
Column column = getterMethod.getAnnotation(Column.class);
//Column annotation is required
if(column == null) {
continue;
}
//Is the field allowed to be updated?
if(!column.updatable()) {
continue;
}
//Was this change a part of JSON request body?
//(prevent fields false positive copies when certain fields weren't included in the JSON body)
if(!changesMap.containsKey(BeanUtils.toFieldName(getterMethod))) {
continue;
}
//Is the new field value different from the existing/persisted field value?
if(ObjectUtils.equals(getterMethod.invoke(persistedObject), getterMethod.invoke(changesObject))) {
continue;
}
//Copy the new field value to the persisted object
log.info("Update " + clazz.getSimpleName() + "(" + id + ") [" + column.name() + "]");
Object obj = getterMethod.invoke(changesObject);
Method setter = BeanUtils.toSetter(getterMethod);
setter.invoke(persistedObject, obj);
}
return persistedObject;
}
/**
* Check if the recently deserialized entity object was populated with its ID field
*
* @param entity the object
* @return an object value if the id exists, null if no id has been set
*/
private Object getId(Object entity) throws IllegalArgumentException, IllegalAccessException, InvocationTargetException {
for(Method method : ReflectionUtils.getAllDeclaredMethods(entity.getClass())) {
if(method.getAnnotation(Id.class) != null) {
method.setAccessible(true);
return method.invoke(entity);
}
}
return null;
}
private <T> T parse(String json, Class<T> clazz) throws JsonParseException, IOException {
try {
return objectMapper.readValue(json, clazz);
} catch(JsonMappingException e) {
throw new ValidationException(e);
}
}
public void setDoLog(boolean doLog) {
this.doLog = doLog;
}
}
Upvotes: 2