diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java index 2abdd4a6c78..ce3b5edb500 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,8 +25,11 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import com.fasterxml.jackson.core.JsonEncoding; import com.fasterxml.jackson.core.JsonGenerator; @@ -94,7 +97,10 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener public static final Charset DEFAULT_CHARSET = null; - protected ObjectMapper objectMapper; + protected ObjectMapper defaultObjectMapper; + + @Nullable + private Map, Map> objectMapperRegistrations; @Nullable private Boolean prettyPrint; @@ -104,7 +110,7 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; + this.defaultObjectMapper = objectMapper; DefaultPrettyPrinter prettyPrinter = new DefaultPrettyPrinter(); prettyPrinter.indentObjectsWith(new DefaultIndenter(" ", "\ndata:")); this.ssePrettyPrinter = prettyPrinter; @@ -122,27 +128,74 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener /** - * Set the {@code ObjectMapper} for this view. - * If not set, a default {@link ObjectMapper#ObjectMapper() ObjectMapper} is used. - *

Setting a custom-configured {@code ObjectMapper} is one way to take further - * control of the JSON serialization process. For example, an extended + * Configure the main {@code ObjectMapper} to use for Object conversion. + * If not set, a default {@link ObjectMapper} instance is created. + *

Setting a custom-configured {@code ObjectMapper} is one way to take + * further control of the JSON serialization process. For example, an extended * {@link com.fasterxml.jackson.databind.ser.SerializerFactory} * can be configured that provides custom serializers for specific types. - * The other option for refining the serialization process is to use Jackson's + * Another option for refining the serialization process is to use Jackson's * provided annotations on the types to be serialized, in which case a * custom-configured ObjectMapper is unnecessary. + * @see #registerObjectMappersForType(Class, Consumer) */ public void setObjectMapper(ObjectMapper objectMapper) { Assert.notNull(objectMapper, "ObjectMapper must not be null"); - this.objectMapper = objectMapper; + this.defaultObjectMapper = objectMapper; configurePrettyPrint(); } /** - * Return the underlying {@code ObjectMapper} for this view. + * Return the main {@code ObjectMapper} in use. */ public ObjectMapper getObjectMapper() { - return this.objectMapper; + return this.defaultObjectMapper; + } + + /** + * Configure the {@link ObjectMapper} instances to use for the given + * {@link Class}. This is useful when you want to deviate from the + * {@link #getObjectMapper() default} ObjectMapper or have the + * {@code ObjectMapper} vary by {@code MediaType}. + *

Note: Use of this method effectively turns off use of + * the default {@link #getObjectMapper() ObjectMapper} and + * {@link #setSupportedMediaTypes(List) supportedMediaTypes} for the given + * class. Therefore it is important for the mappings configured here to + * {@link MediaType#includes(MediaType) include} every MediaType that must + * be supported for the given class. + * @param clazz the type of Object to register ObjectMapper instances for + * @param registrar a consumer to populate or otherwise update the + * MediaType-to-ObjectMapper associations for the given Class + * @since 5.3.4 + */ + public void registerObjectMappersForType(Class clazz, Consumer> registrar) { + if (this.objectMapperRegistrations == null) { + this.objectMapperRegistrations = new LinkedHashMap<>(); + } + Map registrations = + this.objectMapperRegistrations.computeIfAbsent(clazz, c -> new LinkedHashMap<>()); + registrar.accept(registrations); + } + + /** + * Return ObjectMapper registrations for the given class, if any. + * @param clazz the class to look up for registrations for + * @return a map with registered MediaType-to-ObjectMapper registrations, + * or empty if in case of no registrations for the given class. + * @since 5.3.4 + */ + @Nullable + public Map getObjectMappersForType(Class clazz) { + for (Map.Entry, Map> entry : getObjectMapperRegistrations().entrySet()) { + if (entry.getKey().isAssignableFrom(clazz)) { + return entry.getValue(); + } + } + return Collections.emptyMap(); + } + + private Map, Map> getObjectMapperRegistrations() { + return (this.objectMapperRegistrations != null ? this.objectMapperRegistrations : Collections.emptyMap()); } /** @@ -161,7 +214,7 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener private void configurePrettyPrint() { if (this.prettyPrint != null) { - this.objectMapper.configure(SerializationFeature.INDENT_OUTPUT, this.prettyPrint); + this.defaultObjectMapper.configure(SerializationFeature.INDENT_OUTPUT, this.prettyPrint); } } @@ -177,8 +230,12 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener return false; } JavaType javaType = getJavaType(type, contextClass); + ObjectMapper objectMapper = selectObjectMapper(javaType.getRawClass(), mediaType); + if (objectMapper == null) { + return false; + } AtomicReference causeRef = new AtomicReference<>(); - if (this.objectMapper.canDeserialize(javaType, causeRef)) { + if (objectMapper.canDeserialize(javaType, causeRef)) { return true; } logWarningIfNecessary(javaType, causeRef.get()); @@ -196,14 +253,43 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener return false; } } + ObjectMapper objectMapper = selectObjectMapper(clazz, mediaType); + if (objectMapper == null) { + return false; + } AtomicReference causeRef = new AtomicReference<>(); - if (this.objectMapper.canSerialize(clazz, causeRef)) { + if (objectMapper.canSerialize(clazz, causeRef)) { return true; } logWarningIfNecessary(clazz, causeRef.get()); return false; } + /** + * Select an ObjectMapper to use, either the main ObjectMapper or another + * if the handling for the given Class has been customized through + * {@link #registerObjectMappersForType(Class, Consumer)}. + */ + @Nullable + private ObjectMapper selectObjectMapper(Class targetType, @Nullable MediaType targetMediaType) { + if (targetMediaType == null || CollectionUtils.isEmpty(this.objectMapperRegistrations)) { + return this.defaultObjectMapper; + } + for (Map.Entry, Map> typeEntry : getObjectMapperRegistrations().entrySet()) { + if (typeEntry.getKey().isAssignableFrom(targetType)) { + for (Map.Entry objectMapperEntry : typeEntry.getValue().entrySet()) { + if (objectMapperEntry.getKey().includes(targetMediaType)) { + return objectMapperEntry.getValue(); + } + } + // No matching registrations + return null; + } + } + // No registrations + return this.defaultObjectMapper; + } + /** * Determine whether to log the given exception coming from a * {@link ObjectMapper#canDeserialize} / {@link ObjectMapper#canSerialize} check. @@ -255,12 +341,15 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener MediaType contentType = inputMessage.getHeaders().getContentType(); Charset charset = getCharset(contentType); + ObjectMapper objectMapper = selectObjectMapper(javaType.getRawClass(), contentType); + Assert.state(objectMapper != null, "No ObjectMapper for " + javaType); + boolean isUnicode = ENCODINGS.containsKey(charset.name()); try { if (inputMessage instanceof MappingJacksonInputMessage) { Class deserializationView = ((MappingJacksonInputMessage) inputMessage).getDeserializationView(); if (deserializationView != null) { - ObjectReader objectReader = this.objectMapper.readerWithView(deserializationView).forType(javaType); + ObjectReader objectReader = objectMapper.readerWithView(deserializationView).forType(javaType); if (isUnicode) { return objectReader.readValue(inputMessage.getBody()); } @@ -271,11 +360,11 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener } } if (isUnicode) { - return this.objectMapper.readValue(inputMessage.getBody(), javaType); + return objectMapper.readValue(inputMessage.getBody(), javaType); } else { Reader reader = new InputStreamReader(inputMessage.getBody(), charset); - return this.objectMapper.readValue(reader, javaType); + return objectMapper.readValue(reader, javaType); } } catch (InvalidDefinitionException ex) { @@ -310,8 +399,13 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener MediaType contentType = outputMessage.getHeaders().getContentType(); JsonEncoding encoding = getJsonEncoding(contentType); + Class clazz = (object instanceof MappingJacksonValue ? + ((MappingJacksonValue) object).getValue().getClass() : object.getClass()); + ObjectMapper objectMapper = selectObjectMapper(clazz, contentType); + Assert.state(objectMapper != null, "No ObjectMapper for " + clazz.getName()); + OutputStream outputStream = StreamUtils.nonClosing(outputMessage.getBody()); - try (JsonGenerator generator = this.objectMapper.getFactory().createGenerator(outputStream, encoding)) { + try (JsonGenerator generator = objectMapper.getFactory().createGenerator(outputStream, encoding)) { writePrefix(generator, object); Object value = object; @@ -330,7 +424,7 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener } ObjectWriter objectWriter = (serializationView != null ? - this.objectMapper.writerWithView(serializationView) : this.objectMapper.writer()); + objectMapper.writerWithView(serializationView) : objectMapper.writer()); if (filters != null) { objectWriter = objectWriter.with(filters); } @@ -379,7 +473,7 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener * @return the Jackson JavaType */ protected JavaType getJavaType(Type type, @Nullable Class contextClass) { - return this.objectMapper.constructType(GenericTypeResolver.resolveType(type, contextClass)); + return this.defaultObjectMapper.constructType(GenericTypeResolver.resolveType(type, contextClass)); } /** diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverterTests.java index 79b1b8ba36d..76e9bfceebe 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverterTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverterTests.java @@ -73,6 +73,27 @@ public class MappingJackson2HttpMessageConverterTests { assertThat(converter.canRead(MyBean.class, new MediaType("application", "json", StandardCharsets.ISO_8859_1))).isTrue(); } + @Test + public void canReadWithObjectMapperRegistrationForType() { + MediaType halJsonMediaType = MediaType.parseMediaType("application/hal+json"); + MediaType halFormsJsonMediaType = MediaType.parseMediaType("application/prs.hal-forms+json"); + + assertThat(converter.canRead(MyBean.class, halJsonMediaType)).isTrue(); + assertThat(converter.canRead(MyBean.class, MediaType.APPLICATION_JSON)).isTrue(); + assertThat(converter.canRead(MyBean.class, halFormsJsonMediaType)).isTrue(); + assertThat(converter.canRead(Map.class, MediaType.APPLICATION_JSON)).isTrue(); + + converter.registerObjectMappersForType(MyBean.class, map -> { + map.put(halJsonMediaType, new ObjectMapper()); + map.put(MediaType.APPLICATION_JSON, new ObjectMapper()); + }); + + assertThat(converter.canRead(MyBean.class, halJsonMediaType)).isTrue(); + assertThat(converter.canRead(MyBean.class, MediaType.APPLICATION_JSON)).isTrue(); + assertThat(converter.canRead(MyBean.class, halFormsJsonMediaType)).isFalse(); + assertThat(converter.canRead(Map.class, MediaType.APPLICATION_JSON)).isTrue(); + } + @Test public void canWrite() { assertThat(converter.canWrite(MyBean.class, new MediaType("application", "json"))).isTrue();