From d4e4a9ae06451d7575766f8817c7f9266be8faa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 13 May 2025 12:38:18 +0200 Subject: [PATCH] Introduce Jackson 3 support for converters This commit introduces Jackson 3 SmartHttpMessageConverter based variants of the following Jackson 2 classes (and related dependent classes). org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter -> org.springframework.http.converter.AbstractJacksonHttpMessageConverter MappingJackson2HttpMessageConverter -> JacksonJsonHttpMessageConverter MappingJackson2SmileHttpMessageConverter -> JacksonSmileHttpMessageConverter MappingJackson2CborHttpMessageConverter -> JacksonCborHttpMessageConverter MappingJackson2XmlHttpMessageConverter -> JacksonXmlHttpMessageConverter MappingJackson2YamlHttpMessageConverter -> JacksonYamlHttpMessageConverter They use hints instead of MappingJacksonValue and MappingJacksonInputMessage to support `@JsonView` and FilterProvider. Jackson 3 support is configured if found in the classpath otherwise fallback to Jackson 2. JacksonHandlerInstantiator needs to be enabled explicitly if needed. See gh-33798 --- .../AbstractJacksonHttpMessageConverter.java | 509 +++++++++++ .../cbor/JacksonCborHttpMessageConverter.java | 61 ++ .../json/JacksonJsonHttpMessageConverter.java | 124 +++ .../JacksonSmileHttpMessageConverter.java | 63 ++ ...lEncompassingFormHttpMessageConverter.java | 55 +- .../xml/JacksonXmlHttpMessageConverter.java | 111 +++ .../yaml/JacksonYamlHttpMessageConverter.java | 61 ++ .../web/client/DefaultRestClientBuilder.java | 51 +- .../web/client/RestTemplate.java | 53 +- .../JacksonCborHttpMessageConverterTests.java | 157 ++++ .../JacksonJsonHttpMessageConverterTests.java | 843 ++++++++++++++++++ ...JacksonSmileHttpMessageConverterTests.java | 157 ++++ .../JacksonXmlHttpMessageConverterTests.java | 319 +++++++ .../JacksonYamlHttpMessageConverterTests.java | 157 ++++ .../client/RestClientIntegrationTests.java | 2 +- .../client/RestTemplateIntegrationTests.java | 6 +- .../web/client/RestTemplateTests.java | 4 +- spring-webmvc/spring-webmvc.gradle | 6 + .../WebMvcConfigurationSupport.java | 69 +- .../annotation/MethodValidationTests.java | 4 +- ...questResponseBodyMethodProcessorTests.java | 292 +++++- ...nseBodyEmitterReturnValueHandlerTests.java | 4 +- .../ServletInvocableHandlerMethodTests.java | 6 +- 23 files changed, 3028 insertions(+), 86 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/http/converter/AbstractJacksonHttpMessageConverter.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverter.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverter.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverter.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverter.java create mode 100644 spring-web/src/test/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverterTests.java create mode 100644 spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java create mode 100644 spring-web/src/test/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverterTests.java create mode 100644 spring-web/src/test/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverterTests.java create mode 100644 spring-web/src/test/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverterTests.java diff --git a/spring-web/src/main/java/org/springframework/http/converter/AbstractJacksonHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/AbstractJacksonHttpMessageConverter.java new file mode 100644 index 00000000000..fca8911bad9 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/AbstractJacksonHttpMessageConverter.java @@ -0,0 +1,509 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +import com.fasterxml.jackson.annotation.JsonView; +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonEncoding; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.PrettyPrinter; +import tools.jackson.core.util.DefaultIndenter; +import tools.jackson.core.util.DefaultPrettyPrinter; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectReader; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.SerializationConfig; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.exc.InvalidDefinitionException; +import tools.jackson.databind.ser.FilterProvider; + +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.http.converter.json.MappingJacksonInputMessage; +import org.springframework.http.converter.json.MappingJacksonValue; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StreamUtils; +import org.springframework.util.TypeUtils; + +/** + * Abstract base class for Jackson based and content type independent + * {@link HttpMessageConverter} implementations. + * + *

The following hint entries are supported: + *

+ * + * @author Sebastien Deleuze + * @since 7.0 + * @see JacksonJsonHttpMessageConverter + */ +public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartHttpMessageConverter { + + private static final String JSON_VIEW_HINT = JsonView.class.getName(); + + private static final String FILTER_PROVIDER_HINT = FilterProvider.class.getName(); + + private static final Map ENCODINGS; + + private static volatile @Nullable List modules = null; + + static { + ENCODINGS = CollectionUtils.newHashMap(JsonEncoding.values().length); + for (JsonEncoding encoding : JsonEncoding.values()) { + ENCODINGS.put(encoding.getJavaName(), encoding); + } + ENCODINGS.put("US-ASCII", JsonEncoding.UTF8); + } + + + protected final ObjectMapper defaultObjectMapper; + + private @Nullable Map, Map> objectMapperRegistrations; + + private final @Nullable PrettyPrinter ssePrettyPrinter; + + + /** + * Construct a new instance with a provided {@link MapperBuilder builder} + * customized with the {@link tools.jackson.databind.JacksonModule}s found + * by {@link MapperBuilder#findModules(ClassLoader)}. + */ + private AbstractJacksonHttpMessageConverter(MapperBuilder builder) { + this.defaultObjectMapper = builder.addModules(initModules()).build(); + this.ssePrettyPrinter = initSsePrettyPrinter(); + } + + /** + * Construct a new instance with the provided {@link MapperBuilder builder} + * customized with the {@link tools.jackson.databind.JacksonModule}s found + * by {@link MapperBuilder#findModules(ClassLoader)} and {@link MediaType}. + */ + protected AbstractJacksonHttpMessageConverter(MapperBuilder builder, MediaType supportedMediaType) { + this(builder); + setSupportedMediaTypes(Collections.singletonList(supportedMediaType)); + } + + /** + * Construct a new instance with the provided {@link MapperBuilder builder} + * customized with the {@link tools.jackson.databind.JacksonModule}s found + * by {@link MapperBuilder#findModules(ClassLoader)} and {@link MediaType}s. + */ + protected AbstractJacksonHttpMessageConverter(MapperBuilder builder, MediaType... supportedMediaTypes) { + this(builder); + setSupportedMediaTypes(Arrays.asList(supportedMediaTypes)); + } + + /** + * Construct a new instance with a provided {@link ObjectMapper}. + */ + protected AbstractJacksonHttpMessageConverter(ObjectMapper objectMapper) { + this.defaultObjectMapper = objectMapper; + this.ssePrettyPrinter = initSsePrettyPrinter(); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper} and {@link MediaType}. + */ + protected AbstractJacksonHttpMessageConverter(ObjectMapper objectMapper, MediaType supportedMediaType) { + this(objectMapper); + setSupportedMediaTypes(Collections.singletonList(supportedMediaType)); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper} and {@link MediaType}s. + */ + protected AbstractJacksonHttpMessageConverter(ObjectMapper objectMapper, MediaType... supportedMediaTypes) { + this(objectMapper); + setSupportedMediaTypes(Arrays.asList(supportedMediaTypes)); + } + + private List initModules() { + if (modules == null) { + modules = MapperBuilder.findModules(AbstractJacksonHttpMessageConverter.class.getClassLoader()); + + } + return Objects.requireNonNull(modules); + } + + private PrettyPrinter initSsePrettyPrinter() { + DefaultPrettyPrinter prettyPrinter = new DefaultPrettyPrinter(); + prettyPrinter.indentObjectsWith(new DefaultIndenter(" ", "\ndata:")); + return prettyPrinter; + } + + @Override + public void setSupportedMediaTypes(List supportedMediaTypes) { + super.setSupportedMediaTypes(supportedMediaTypes); + } + + /** + * Return the main {@code ObjectMapper} in use. + */ + public ObjectMapper getObjectMapper() { + 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 + */ + 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. + */ + public Map getObjectMappersForType(Class clazz) { + for (Map.Entry, Map> entry : getObjectMapperRegistrations().entrySet()) { + if (entry.getKey().isAssignableFrom(clazz)) { + return entry.getValue(); + } + } + return Collections.emptyMap(); + } + + @Override + public List getSupportedMediaTypes(Class clazz) { + List result = null; + for (Map.Entry, Map> entry : getObjectMapperRegistrations().entrySet()) { + if (entry.getKey().isAssignableFrom(clazz)) { + result = (result != null ? result : new ArrayList<>(entry.getValue().size())); + result.addAll(entry.getValue().keySet()); + } + } + if (!CollectionUtils.isEmpty(result)) { + return result; + } + return (ProblemDetail.class.isAssignableFrom(clazz) ? + getMediaTypesForProblemDetail() : getSupportedMediaTypes()); + } + + private Map, Map> getObjectMapperRegistrations() { + return (this.objectMapperRegistrations != null ? this.objectMapperRegistrations : Collections.emptyMap()); + } + + /** + * Return the supported media type(s) for {@link ProblemDetail}. + * By default, an empty list, unless overridden in subclasses. + */ + protected List getMediaTypesForProblemDetail() { + return Collections.emptyList(); + } + + + @Override + public boolean canRead(ResolvableType type, @Nullable MediaType mediaType) { + if (!canRead(mediaType)) { + return false; + } + Class clazz = type.resolve(); + if (clazz == null) { + return false; + } + return this.objectMapperRegistrations == null || selectObjectMapper(clazz, mediaType) != null; + } + + @Override + public boolean canWrite(Class clazz, @Nullable MediaType mediaType) { + if (!canWrite(mediaType)) { + return false; + } + if (mediaType != null && mediaType.getCharset() != null) { + Charset charset = mediaType.getCharset(); + if (!ENCODINGS.containsKey(charset.name())) { + return false; + } + } + if (MappingJacksonValue.class.isAssignableFrom(clazz)) { + throw new UnsupportedOperationException("MappingJacksonValue is not supported, use hints instead"); + } + return this.objectMapperRegistrations == null || selectObjectMapper(clazz, mediaType) != null; + } + + /** + * 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)}. + */ + private @Nullable 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; + } + + @Override + public Object read(ResolvableType type, HttpInputMessage inputMessage, @Nullable Map hints) + throws IOException, HttpMessageNotReadableException { + + Class contextClass = (type.getSource() instanceof MethodParameter parameter ? parameter.getContainingClass() : null); + JavaType javaType = getJavaType(type.getType(), contextClass); + return readJavaType(javaType, inputMessage, hints); + } + + @Override + protected Object readInternal(Class clazz, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + + JavaType javaType = getJavaType(clazz, null); + return readJavaType(javaType, inputMessage, null); + } + + private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage, @Nullable Map hints) throws IOException { + 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()) || + "UTF-16".equals(charset.name()) || + "UTF-32".equals(charset.name()); + try { + InputStream inputStream = StreamUtils.nonClosing(inputMessage.getBody()); + if (inputMessage instanceof MappingJacksonInputMessage) { + throw new UnsupportedOperationException("MappingJacksonInputMessage is not supported, use hints instead"); + } + ObjectReader objectReader = objectMapper.readerFor(javaType); + if (hints != null && hints.containsKey(JSON_VIEW_HINT)) { + objectReader = objectReader.withView((Class) hints.get(JSON_VIEW_HINT)); + } + objectReader = customizeReader(objectReader, javaType); + if (isUnicode) { + return objectReader.readValue(inputStream); + } + else { + Reader reader = new InputStreamReader(inputStream, charset); + return objectReader.readValue(reader); + } + } + catch (InvalidDefinitionException ex) { + throw new HttpMessageConversionException("Type definition error: " + ex.getType(), ex); + } + catch (JacksonException ex) { + throw new HttpMessageNotReadableException("JSON parse error: " + ex.getOriginalMessage(), ex, inputMessage); + } + } + + /** + * Subclasses can use this method to customize {@link ObjectReader} used + * for reading values. + * @param reader the reader instance to customize + * @param javaType the target type of element values to read to + * @return the customized {@link ObjectReader} + */ + protected ObjectReader customizeReader(ObjectReader reader, JavaType javaType) { + return reader; + } + + /** + * Determine the charset to use for JSON input. + *

By default this is either the charset from the input {@code MediaType} + * or otherwise falling back on {@code UTF-8}. Can be overridden in subclasses. + * @param contentType the content type of the HTTP input message + * @return the charset to use + */ + protected Charset getCharset(@Nullable MediaType contentType) { + if (contentType != null && contentType.getCharset() != null) { + return contentType.getCharset(); + } + else { + return StandardCharsets.UTF_8; + } + } + + @Override + protected void writeInternal(Object object, ResolvableType resolvableType, HttpOutputMessage outputMessage, @Nullable Map hints) + throws IOException, HttpMessageNotWritableException { + + MediaType contentType = outputMessage.getHeaders().getContentType(); + JsonEncoding encoding = getJsonEncoding(contentType); + + Class clazz = object.getClass(); + ObjectMapper objectMapper = selectObjectMapper(clazz, contentType); + Assert.state(objectMapper != null, () -> "No ObjectMapper for " + clazz.getName()); + + OutputStream outputStream = StreamUtils.nonClosing(outputMessage.getBody()); + Class jsonView = null; + FilterProvider filters = null; + JavaType javaType = null; + + Type type = resolvableType.getType(); + if (TypeUtils.isAssignable(type, object.getClass())) { + javaType = getJavaType(type, null); + } + if (hints != null) { + jsonView = (Class) hints.get(JSON_VIEW_HINT); + filters = (FilterProvider) hints.get(FILTER_PROVIDER_HINT); + } + + ObjectWriter objectWriter = (jsonView != null ? + objectMapper.writerWithView(jsonView) : objectMapper.writer()); + if (filters != null) { + objectWriter = objectWriter.with(filters); + } + if (javaType != null && (javaType.isContainerType() || javaType.isTypeOrSubTypeOf(Optional.class))) { + objectWriter = objectWriter.forType(javaType); + } + SerializationConfig config = objectWriter.getConfig(); + if (contentType != null && contentType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM) && + config.isEnabled(SerializationFeature.INDENT_OUTPUT)) { + objectWriter = objectWriter.with(this.ssePrettyPrinter); + } + objectWriter = customizeWriter(objectWriter, javaType, contentType); + + try (JsonGenerator generator = objectWriter.createGenerator(outputStream, encoding)) { + writePrefix(generator, object); + objectWriter.writeValue(generator, object); + writeSuffix(generator, object); + generator.flush(); + } + catch (InvalidDefinitionException ex) { + throw new HttpMessageConversionException("Type definition error: " + ex.getType(), ex); + } + catch (JacksonException ex) { + throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getOriginalMessage(), ex); + } + } + + /** + * Subclasses can use this method to customize {@link ObjectWriter} used + * for writing values. + * @param writer the writer instance to customize + * @param javaType the type of element values to write + * @param contentType the selected media type + * @return the customized {@link ObjectWriter} + */ + protected ObjectWriter customizeWriter( + ObjectWriter writer, @Nullable JavaType javaType, @Nullable MediaType contentType) { + + return writer; + } + + /** + * Write a prefix before the main content. + * @param generator the generator to use for writing content. + * @param object the object to write to the output message. + */ + protected void writePrefix(JsonGenerator generator, Object object) { + } + + /** + * Write a suffix after the main content. + * @param generator the generator to use for writing content. + * @param object the object to write to the output message. + */ + protected void writeSuffix(JsonGenerator generator, Object object) { + } + + /** + * Return the Jackson {@link JavaType} for the specified type and context class. + * @param type the generic type to return the Jackson JavaType for + * @param contextClass a context class for the target type, for example a class + * in which the target type appears in a method signature (can be {@code null}) + * @return the Jackson JavaType + */ + protected JavaType getJavaType(Type type, @Nullable Class contextClass) { + return this.defaultObjectMapper.constructType(GenericTypeResolver.resolveType(type, contextClass)); + } + + /** + * Determine the JSON encoding to use for the given content type. + * @param contentType the media type as requested by the caller + * @return the JSON encoding to use (never {@code null}) + */ + protected JsonEncoding getJsonEncoding(@Nullable MediaType contentType) { + if (contentType != null && contentType.getCharset() != null) { + Charset charset = contentType.getCharset(); + JsonEncoding encoding = ENCODINGS.get(charset.name()); + if (encoding != null) { + return encoding; + } + } + return JsonEncoding.UTF8; + } + + @Override + protected boolean supportsRepeatableWrites(Object o) { + return true; + } +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverter.java new file mode 100644 index 00000000000..2c76703f667 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverter.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.cbor; + +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.cbor.CBORMapper; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; + +/** + * Implementation of {@link org.springframework.http.converter.HttpMessageConverter + * HttpMessageConverter} that can read and write the CBOR + * data format using + * the dedicated Jackson 3.x extension. + * + *

By default, this converter supports the {@link MediaType#APPLICATION_CBOR_VALUE} + * media type. This can be overridden by setting the {@link #setSupportedMediaTypes + * supportedMediaTypes} property. + * + *

The default constructor loads {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * + * @author Sebastien Deleuze + * @since 7.0 + */ +public class JacksonCborHttpMessageConverter extends AbstractJacksonHttpMessageConverter { + + /** + * Construct a new instance with a {@link CBORMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)}. + */ + public JacksonCborHttpMessageConverter() { + super(CBORMapper.builder(), MediaType.APPLICATION_CBOR); + } + + /** + * Construct a new instance with the provided {@link CBORMapper}. + * @see CBORMapper#builder() + * @see MapperBuilder#findAndAddModules(ClassLoader) + */ + public JacksonCborHttpMessageConverter(CBORMapper mapper) { + super(mapper, MediaType.APPLICATION_CBOR); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java new file mode 100644 index 00000000000..787bf1689b1 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java @@ -0,0 +1,124 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.json; + +import java.util.Collections; +import java.util.List; + +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; + +/** + * Implementation of {@link org.springframework.http.converter.HttpMessageConverter} + * that can read and write JSON using Jackson 3.x's + * {@link ObjectMapper}. + * + *

This converter can be used to bind to typed beans, or untyped + * {@code HashMap} instances. + * + *

By default, this converter supports {@code application/json} and + * {@code application/*+json} with {@code UTF-8} character set. This + * can be overridden by setting the {@link #setSupportedMediaTypes supportedMediaTypes} + * property. + * + *

The default constructor loads {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * + *

The following hints entries are supported: + *

    + *
  • A JSON view with a com.fasterxml.jackson.annotation.JsonView + * key and the class name of the JSON view as value.
  • + *
  • A filter provider with a tools.jackson.databind.ser.FilterProvider + * key and the filter provider class name as value.
  • + *
+ * + * @author Sebastien Deleuze + * @since 7.0 + */ +public class JacksonJsonHttpMessageConverter extends AbstractJacksonHttpMessageConverter { + + private static final List problemDetailMediaTypes = + Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON); + + private static final MediaType[] DEFAULT_JSON_MIME_TYPES = new MediaType[] { + MediaType.APPLICATION_JSON, new MediaType("application", "*+json") }; + + + private @Nullable String jsonPrefix; + + + /** + * Construct a new instance with a {@link JsonMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)} and + * {@link ProblemDetailJacksonMixin}. + */ + public JacksonJsonHttpMessageConverter() { + super(JsonMapper.builder().addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class), DEFAULT_JSON_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper}. + * @see JsonMapper#builder() + * @see MapperBuilder#findModules(ClassLoader) + */ + public JacksonJsonHttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, DEFAULT_JSON_MIME_TYPES); + } + + + /** + * Specify a custom prefix to use for this view's JSON output. + * Default is none. + * @see #setPrefixJson + */ + public void setJsonPrefix(String jsonPrefix) { + this.jsonPrefix = jsonPrefix; + } + + /** + * Indicate whether the JSON output by this view should be prefixed with ")]}', ". Default is {@code false}. + *

Prefixing the JSON string in this manner is used to help prevent JSON Hijacking. + * The prefix renders the string syntactically invalid as a script so that it cannot be hijacked. + * This prefix should be stripped before parsing the string as JSON. + * @see #setJsonPrefix + */ + public void setPrefixJson(boolean prefixJson) { + this.jsonPrefix = (prefixJson ? ")]}', " : null); + } + + + @Override + protected List getMediaTypesForProblemDetail() { + return problemDetailMediaTypes; + } + + @Override + protected void writePrefix(JsonGenerator generator, Object object) { + if (this.jsonPrefix != null) { + generator.writeRaw(this.jsonPrefix); + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverter.java new file mode 100644 index 00000000000..7a8ee20cb27 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverter.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.smile; + +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.smile.SmileMapper; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; + +/** + * Implementation of {@link org.springframework.http.converter.HttpMessageConverter HttpMessageConverter} + * that can read and write Smile data format ("binary JSON") using + * + * the dedicated Jackson 3.x extension. + * + *

By default, this converter supports {@code "application/x-jackson-smile"} + * media type. This can be overridden by setting the + * {@link #setSupportedMediaTypes supportedMediaTypes} property. + * + *

The default constructor loads {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * + * @author Sebastien Deleuze + * @since 7.0 + */ +public class JacksonSmileHttpMessageConverter extends AbstractJacksonHttpMessageConverter { + + private static final MediaType DEFAULT_SMILE_MIME_TYPES = new MediaType("application", "x-jackson-smile"); + + /** + * Construct a new instance with a {@link SmileMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)}. + */ + public JacksonSmileHttpMessageConverter() { + super(SmileMapper.builder(), DEFAULT_SMILE_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link SmileMapper}. + * @see SmileMapper#builder() + * @see MapperBuilder#findAndAddModules(ClassLoader) + */ + public JacksonSmileHttpMessageConverter(SmileMapper mapper) { + super(mapper, DEFAULT_SMILE_MIME_TYPES); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java index 31053bfe174..9c29df735cb 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java @@ -17,16 +17,21 @@ package org.springframework.http.converter.support; import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.cbor.JacksonCborHttpMessageConverter; import org.springframework.http.converter.cbor.KotlinSerializationCborHttpMessageConverter; import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.http.converter.json.JsonbHttpMessageConverter; import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.protobuf.KotlinSerializationProtobufHttpMessageConverter; +import org.springframework.http.converter.smile.JacksonSmileHttpMessageConverter; import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter; +import org.springframework.http.converter.xml.JacksonXmlHttpMessageConverter; import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; +import org.springframework.http.converter.yaml.JacksonYamlHttpMessageConverter; import org.springframework.http.converter.yaml.MappingJackson2YamlHttpMessageConverter; import org.springframework.util.ClassUtils; @@ -44,14 +49,24 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv private static final boolean jaxb2Present; + private static final boolean jacksonPresent; + private static final boolean jackson2Present; + private static final boolean jacksonXmlPresent; + private static final boolean jackson2XmlPresent; + private static final boolean jacksonSmilePresent; + private static final boolean jackson2SmilePresent; + private static final boolean jacksonCborPresent; + private static final boolean jackson2CborPresent; + private static final boolean jacksonYamlPresent; + private static final boolean jackson2YamlPresent; private static final boolean gsonPresent; @@ -67,12 +82,17 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv static { ClassLoader classLoader = AllEncompassingFormHttpMessageConverter.class.getClassLoader(); jaxb2Present = ClassUtils.isPresent("jakarta.xml.bind.Binder", classLoader); + jacksonPresent = ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", classLoader); jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); - jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader); - jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); - jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader); - jackson2YamlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", classLoader); + jacksonXmlPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.xml.XmlMapper", classLoader); + jackson2XmlPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader); + jacksonSmilePresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.smile.SmileMapper", classLoader); + jackson2SmilePresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); + jacksonCborPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.cbor.CBORMapper", classLoader); + jackson2CborPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader); + jacksonYamlPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.yaml.YAMLMapper", classLoader); + jackson2YamlPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader); kotlinSerializationCborPresent = ClassUtils.isPresent("kotlinx.serialization.cbor.Cbor", classLoader); @@ -83,11 +103,14 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv public AllEncompassingFormHttpMessageConverter() { - if (jaxb2Present && !jackson2XmlPresent) { + if (jaxb2Present && !jacksonXmlPresent && !jackson2XmlPresent) { addPartConverter(new Jaxb2RootElementHttpMessageConverter()); } - if (jackson2Present) { + if (jacksonPresent) { + addPartConverter(new JacksonJsonHttpMessageConverter()); + } + else if (jackson2Present) { addPartConverter(new MappingJackson2HttpMessageConverter()); } else if (gsonPresent) { @@ -100,22 +123,34 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv addPartConverter(new KotlinSerializationJsonHttpMessageConverter()); } - if (jackson2XmlPresent) { + if (jacksonXmlPresent) { + addPartConverter(new JacksonXmlHttpMessageConverter()); + } + else if (jackson2XmlPresent) { addPartConverter(new MappingJackson2XmlHttpMessageConverter()); } - if (jackson2SmilePresent) { + if (jacksonSmilePresent) { + addPartConverter(new JacksonSmileHttpMessageConverter()); + } + else if (jackson2SmilePresent) { addPartConverter(new MappingJackson2SmileHttpMessageConverter()); } - if (jackson2CborPresent) { + if (jacksonCborPresent) { + addPartConverter(new JacksonCborHttpMessageConverter()); + } + else if (jackson2CborPresent) { addPartConverter(new MappingJackson2CborHttpMessageConverter()); } else if (kotlinSerializationCborPresent) { addPartConverter(new KotlinSerializationCborHttpMessageConverter()); } - if (jackson2YamlPresent) { + if (jacksonYamlPresent) { + addPartConverter(new JacksonYamlHttpMessageConverter()); + } + else if (jackson2YamlPresent) { addPartConverter(new MappingJackson2YamlHttpMessageConverter()); } diff --git a/spring-web/src/main/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverter.java new file mode 100644 index 00000000000..840e154ef12 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverter.java @@ -0,0 +1,111 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.xml; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.xml.XmlFactory; +import tools.jackson.dataformat.xml.XmlMapper; + +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; +import org.springframework.http.converter.json.ProblemDetailJacksonXmlMixin; +import org.springframework.util.xml.StaxUtils; + +/** + * Implementation of {@link org.springframework.http.converter.HttpMessageConverter HttpMessageConverter} + * that can read and write XML using + * Jackson 3.x extension component for reading and writing XML encoded data. + * + *

By default, this converter supports {@code application/xml}, {@code text/xml}, and + * {@code application/*+xml} with {@code UTF-8} character set. This can be overridden by + * setting the {@link #setSupportedMediaTypes supportedMediaTypes} property. + * + *

The default constructor loads {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * + *

The following hint entries are supported: + *

    + *
  • A JSON view with a com.fasterxml.jackson.annotation.JsonView + * key and the class name of the JSON view as value.
  • + *
  • A filter provider with a tools.jackson.databind.ser.FilterProvider + * key and the filter provider class name as value.
  • + *
+ * + * @author Sebastien Deleuze + * @since 7.0 + */ +public class JacksonXmlHttpMessageConverter extends AbstractJacksonHttpMessageConverter { + + private static final List problemDetailMediaTypes = + Collections.singletonList(MediaType.APPLICATION_PROBLEM_XML); + + private static final MediaType[] DEFAULT_XML_MIME_TYPES = new MediaType[] { + new MediaType("application", "xml", StandardCharsets.UTF_8), + new MediaType("text", "xml", StandardCharsets.UTF_8), + new MediaType("application", "*+xml", StandardCharsets.UTF_8) + }; + + /** + * Construct a new instance with a {@link XmlMapper} created from + * {@link #defensiveXmlFactory} and customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)} and + * {@link ProblemDetailJacksonXmlMixin}. + */ + public JacksonXmlHttpMessageConverter() { + this(XmlMapper.builder(defensiveXmlFactory())); + } + + /** + * Construct a new instance with the provided {@link XmlMapper.Builder builder} + * customized with the {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)} and + * {@link ProblemDetailJacksonXmlMixin}. + */ + public JacksonXmlHttpMessageConverter(XmlMapper.Builder builder) { + super(builder.addMixIn(ProblemDetail.class, ProblemDetailJacksonXmlMixin.class), DEFAULT_XML_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link XmlMapper}. + * @see XmlMapper#builder() + * @see MapperBuilder#findModules(ClassLoader) + */ + public JacksonXmlHttpMessageConverter(XmlMapper xmlMapper) { + super(xmlMapper, DEFAULT_XML_MIME_TYPES); + } + + /** + * Return an {@link XmlFactory} created from {@link StaxUtils#createDefensiveInputFactory} + * with Spring's defensive setup, i.e. no support for the resolution of DTDs and external + * entities. + */ + public static XmlFactory defensiveXmlFactory() { + return new XmlFactory(StaxUtils.createDefensiveInputFactory()); + } + + @Override + protected List getMediaTypesForProblemDetail() { + return problemDetailMediaTypes; + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverter.java new file mode 100644 index 00000000000..f5b6aa7e26d --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverter.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.yaml; + +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.yaml.YAMLMapper; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; + +/** + * Implementation of {@link org.springframework.http.converter.HttpMessageConverter + * HttpMessageConverter} that can read and write the YAML + * data format using + * the dedicated Jackson 3.x extension. + * + *

By default, this converter supports the {@link MediaType#APPLICATION_YAML_VALUE} + * media type. This can be overridden by setting the {@link #setSupportedMediaTypes + * supportedMediaTypes} property. + * + *

The default constructor loads {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * + * @author Sebastien Deleuze + * @since 7.0 + */ +public class JacksonYamlHttpMessageConverter extends AbstractJacksonHttpMessageConverter { + + /** + * Construct a new instance with a {@link YAMLMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)}. + */ + public JacksonYamlHttpMessageConverter() { + super(YAMLMapper.builder(), MediaType.APPLICATION_YAML); + } + + /** + * Construct a new instance with the provided {@link YAMLMapper}. + * @see YAMLMapper#builder() + * @see MapperBuilder#findAndAddModules(ClassLoader) + */ + public JacksonYamlHttpMessageConverter(YAMLMapper mapper) { + super(mapper, MediaType.APPLICATION_YAML); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java index f59a4aa1537..9e83bb5e78f 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java @@ -47,19 +47,24 @@ import org.springframework.http.converter.ByteArrayHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.ResourceHttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.cbor.JacksonCborHttpMessageConverter; import org.springframework.http.converter.cbor.KotlinSerializationCborHttpMessageConverter; import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter; import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter; import org.springframework.http.converter.feed.RssChannelHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.http.converter.json.JsonbHttpMessageConverter; import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.protobuf.KotlinSerializationProtobufHttpMessageConverter; +import org.springframework.http.converter.smile.JacksonSmileHttpMessageConverter; import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; +import org.springframework.http.converter.xml.JacksonXmlHttpMessageConverter; import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; +import org.springframework.http.converter.yaml.JacksonYamlHttpMessageConverter; import org.springframework.http.converter.yaml.MappingJackson2YamlHttpMessageConverter; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -96,14 +101,24 @@ final class DefaultRestClientBuilder implements RestClient.Builder { private static final boolean jaxb2Present; + private static final boolean jacksonPresent; + private static final boolean jackson2Present; + private static final boolean jacksonXmlPresent; + private static final boolean jackson2XmlPresent; + private static final boolean jacksonSmilePresent; + private static final boolean jackson2SmilePresent; + private static final boolean jacksonCborPresent; + private static final boolean jackson2CborPresent; + private static final boolean jacksonYamlPresent; + private static final boolean jackson2YamlPresent; private static final boolean gsonPresent; @@ -126,12 +141,17 @@ final class DefaultRestClientBuilder implements RestClient.Builder { romePresent = ClassUtils.isPresent("com.rometools.rome.feed.WireFeed", loader); jaxb2Present = ClassUtils.isPresent("jakarta.xml.bind.Binder", loader); + jacksonPresent = ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", loader); jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", loader) && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", loader); - jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", loader); - jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", loader); - jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", loader); - jackson2YamlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", loader); + jacksonXmlPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.xml.XmlMapper", loader); + jackson2XmlPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", loader); + jacksonSmilePresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.smile.SmileMapper", loader); + jackson2SmilePresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", loader); + jacksonCborPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.cbor.CBORMapper", loader); + jackson2CborPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", loader); + jacksonYamlPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.yaml.YAMLMapper", loader); + jackson2YamlPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", loader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", loader); jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", loader); kotlinSerializationCborPresent = ClassUtils.isPresent("kotlinx.serialization.cbor.Cbor", loader); @@ -463,7 +483,10 @@ final class DefaultRestClientBuilder implements RestClient.Builder { this.messageConverters.add(new RssChannelHttpMessageConverter()); } - if (jackson2XmlPresent) { + if (jacksonXmlPresent) { + this.messageConverters.add(new JacksonXmlHttpMessageConverter()); + } + else if (jackson2XmlPresent) { this.messageConverters.add(new MappingJackson2XmlHttpMessageConverter()); } else if (jaxb2Present) { @@ -474,7 +497,10 @@ final class DefaultRestClientBuilder implements RestClient.Builder { this.messageConverters.add(new KotlinSerializationProtobufHttpMessageConverter()); } - if (jackson2Present) { + if (jacksonPresent) { + this.messageConverters.add(new JacksonJsonHttpMessageConverter()); + } + else if (jackson2Present) { this.messageConverters.add(new MappingJackson2HttpMessageConverter()); } else if (gsonPresent) { @@ -487,18 +513,27 @@ final class DefaultRestClientBuilder implements RestClient.Builder { this.messageConverters.add(new KotlinSerializationJsonHttpMessageConverter()); } + if (jacksonSmilePresent) { + this.messageConverters.add(new JacksonSmileHttpMessageConverter()); + } if (jackson2SmilePresent) { this.messageConverters.add(new MappingJackson2SmileHttpMessageConverter()); } - if (jackson2CborPresent) { + if (jacksonCborPresent) { + this.messageConverters.add(new JacksonCborHttpMessageConverter()); + } + else if (jackson2CborPresent) { this.messageConverters.add(new MappingJackson2CborHttpMessageConverter()); } else if (kotlinSerializationCborPresent) { this.messageConverters.add(new KotlinSerializationCborHttpMessageConverter()); } - if (jackson2YamlPresent) { + if (jacksonYamlPresent) { + this.messageConverters.add(new JacksonYamlHttpMessageConverter()); + } + else if (jackson2YamlPresent) { this.messageConverters.add(new MappingJackson2YamlHttpMessageConverter()); } } diff --git a/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java b/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java index 674c3c80622..a5f7a692a32 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java @@ -56,19 +56,24 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.ResourceHttpMessageConverter; import org.springframework.http.converter.SmartHttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.cbor.JacksonCborHttpMessageConverter; import org.springframework.http.converter.cbor.KotlinSerializationCborHttpMessageConverter; import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter; import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter; import org.springframework.http.converter.feed.RssChannelHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.http.converter.json.JsonbHttpMessageConverter; import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.protobuf.KotlinSerializationProtobufHttpMessageConverter; +import org.springframework.http.converter.smile.JacksonSmileHttpMessageConverter; import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; +import org.springframework.http.converter.xml.JacksonXmlHttpMessageConverter; import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; +import org.springframework.http.converter.yaml.JacksonYamlHttpMessageConverter; import org.springframework.http.converter.yaml.MappingJackson2YamlHttpMessageConverter; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -124,14 +129,24 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat private static final boolean jaxb2Present; + private static final boolean jacksonPresent; + private static final boolean jackson2Present; + private static final boolean jacksonXmlPresent; + private static final boolean jackson2XmlPresent; + private static final boolean jacksonSmilePresent; + private static final boolean jackson2SmilePresent; + private static final boolean jacksonCborPresent; + private static final boolean jackson2CborPresent; + private static final boolean jacksonYamlPresent; + private static final boolean jackson2YamlPresent; private static final boolean gsonPresent; @@ -151,12 +166,17 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat romePresent = ClassUtils.isPresent("com.rometools.rome.feed.WireFeed", classLoader); jaxb2Present = ClassUtils.isPresent("jakarta.xml.bind.Binder", classLoader); + jacksonPresent = ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", classLoader); jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); - jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader); - jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); - jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader); - jackson2YamlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", classLoader); + jacksonXmlPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.xml.XmlMapper", classLoader); + jackson2XmlPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader); + jacksonSmilePresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.smile.SmileMapper", classLoader); + jackson2SmilePresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); + jacksonCborPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.cbor.CBORMapper", classLoader); + jackson2CborPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader); + jacksonYamlPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.yaml.YAMLMapper", classLoader); + jackson2YamlPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader); kotlinSerializationCborPresent = ClassUtils.isPresent("kotlinx.serialization.cbor.Cbor", classLoader); @@ -193,7 +213,10 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat this.messageConverters.add(new RssChannelHttpMessageConverter()); } - if (jackson2XmlPresent) { + if (jacksonXmlPresent) { + this.messageConverters.add(new JacksonXmlHttpMessageConverter()); + } + else if (jackson2XmlPresent) { this.messageConverters.add(new MappingJackson2XmlHttpMessageConverter()); } else if (jaxb2Present) { @@ -204,7 +227,10 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat this.messageConverters.add(new KotlinSerializationProtobufHttpMessageConverter()); } - if (jackson2Present) { + if (jacksonPresent) { + this.messageConverters.add(new JacksonJsonHttpMessageConverter()); + } + else if (jackson2Present) { this.messageConverters.add(new MappingJackson2HttpMessageConverter()); } else if (gsonPresent) { @@ -217,18 +243,27 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat this.messageConverters.add(new KotlinSerializationJsonHttpMessageConverter()); } - if (jackson2SmilePresent) { + if (jacksonSmilePresent) { + this.messageConverters.add(new JacksonSmileHttpMessageConverter()); + } + else if (jackson2SmilePresent) { this.messageConverters.add(new MappingJackson2SmileHttpMessageConverter()); } - if (jackson2CborPresent) { + if (jacksonCborPresent) { + this.messageConverters.add(new JacksonCborHttpMessageConverter()); + } + else if (jackson2CborPresent) { this.messageConverters.add(new MappingJackson2CborHttpMessageConverter()); } else if (kotlinSerializationCborPresent) { this.messageConverters.add(new KotlinSerializationCborHttpMessageConverter()); } - if (jackson2YamlPresent) { + if (jacksonYamlPresent) { + this.messageConverters.add(new JacksonYamlHttpMessageConverter()); + } + else if (jackson2YamlPresent) { this.messageConverters.add(new MappingJackson2YamlHttpMessageConverter()); } diff --git a/spring-web/src/test/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverterTests.java new file mode 100644 index 00000000000..288718750ac --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverterTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.cbor; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import tools.jackson.dataformat.cbor.CBORMapper; + +import org.springframework.http.MediaType; +import org.springframework.web.testfixture.http.MockHttpInputMessage; +import org.springframework.web.testfixture.http.MockHttpOutputMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Jackson 3.x CBOR converter tests. + * + * @author Sebastien Deleuze + */ +class JacksonCborHttpMessageConverterTests { + + private final JacksonCborHttpMessageConverter converter = new JacksonCborHttpMessageConverter(); + private final CBORMapper mapper = CBORMapper.builder().build(); + + + @Test + void canRead() { + assertThat(converter.canRead(MyBean.class, MediaType.APPLICATION_CBOR)).isTrue(); + assertThat(converter.canRead(MyBean.class, new MediaType("application", "json"))).isFalse(); + assertThat(converter.canRead(MyBean.class, new MediaType("application", "xml"))).isFalse(); + } + + @Test + void canWrite() { + assertThat(converter.canWrite(MyBean.class, MediaType.APPLICATION_CBOR)).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "json"))).isFalse(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "xml"))).isFalse(); + } + + @Test + void read() throws IOException { + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[]{"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[]{0x1, 0x2}); + MockHttpInputMessage inputMessage = new MockHttpInputMessage(mapper.writeValueAsBytes(body)); + inputMessage.getHeaders().setContentType(MediaType.APPLICATION_CBOR); + MyBean result = (MyBean) converter.read(MyBean.class, inputMessage); + assertThat(result.getString()).isEqualTo("Foo"); + assertThat(result.getNumber()).isEqualTo(42); + assertThat(result.getFraction()).isCloseTo(42F, within(0F)); + + assertThat(result.getArray()).isEqualTo(new String[]{"Foo", "Bar"}); + assertThat(result.isBool()).isTrue(); + assertThat(result.getBytes()).isEqualTo(new byte[]{0x1, 0x2}); + } + + @Test + void write() throws IOException { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[]{"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[]{0x1, 0x2}); + converter.write(body, null, outputMessage); + assertThat(outputMessage.getBodyAsBytes()).isEqualTo(mapper.writeValueAsBytes(body)); + assertThat(outputMessage.getHeaders().getContentType()) + .as("Invalid content-type").isEqualTo(MediaType.APPLICATION_CBOR); + } + + + public static class MyBean { + + private String string; + + private int number; + + private float fraction; + + private String[] array; + + private boolean bool; + + private byte[] bytes; + + public byte[] getBytes() { + return bytes; + } + + public void setBytes(byte[] bytes) { + this.bytes = bytes; + } + + public boolean isBool() { + return bool; + } + + public void setBool(boolean bool) { + this.bool = bool; + } + + public String getString() { + return string; + } + + public void setString(String string) { + this.string = string; + } + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public float getFraction() { + return fraction; + } + + public void setFraction(float fraction) { + this.fraction = fraction; + } + + public String[] getArray() { + return array; + } + + public void setArray(String[] array) { + this.array = array; + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java new file mode 100644 index 00000000000..86fe09d6960 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java @@ -0,0 +1,843 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.json; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonView; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectReader; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.cfg.EnumFeature; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.ser.FilterProvider; +import tools.jackson.databind.ser.std.SimpleBeanPropertyFilter; +import tools.jackson.databind.ser.std.SimpleFilterProvider; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.testfixture.http.MockHttpInputMessage; +import org.springframework.web.testfixture.http.MockHttpOutputMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; +import static org.assertj.core.api.Assertions.within; + +/** + * Jackson 3.x converter tests. + * + * @author Sebastien Deleuze + */ +class JacksonJsonHttpMessageConverterTests { + + private JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter(); + + + @Test + void canRead() { + assertThat(converter.canRead(MyBean.class, new MediaType("application", "json"))).isTrue(); + assertThat(converter.canRead(Map.class, new MediaType("application", "json"))).isTrue(); + assertThat(converter.canRead(MyBean.class, new MediaType("application", "json", StandardCharsets.UTF_8))).isTrue(); + assertThat(converter.canRead(MyBean.class, new MediaType("application", "json", StandardCharsets.US_ASCII))).isTrue(); + assertThat(converter.canRead(MyBean.class, new MediaType("application", "json", StandardCharsets.ISO_8859_1))).isTrue(); + } + + @Test + 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(Map.class, MediaType.APPLICATION_JSON)).isTrue(); + } + + @Test + void canWrite() { + assertThat(converter.canWrite(MyBean.class, MediaType.APPLICATION_JSON)).isTrue(); + assertThat(converter.canWrite(Map.class, MediaType.APPLICATION_JSON)).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "json", StandardCharsets.UTF_8))).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "json", StandardCharsets.US_ASCII))).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "json", StandardCharsets.ISO_8859_1))).isFalse(); + assertThatThrownBy(() -> converter.canWrite(MappingJacksonValue.class, MediaType.APPLICATION_JSON)).isInstanceOf(UnsupportedOperationException.class); + } + + @Test // SPR-7905 + void canReadAndWriteMicroformats() { + assertThat(converter.canRead(MyBean.class, new MediaType("application", "vnd.test-micro-type+json"))).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "vnd.test-micro-type+json"))).isTrue(); + } + + @Test + void getSupportedMediaTypes() { + MediaType[] defaultMediaTypes = {MediaType.APPLICATION_JSON, MediaType.parseMediaType("application/*+json")}; + assertThat(converter.getSupportedMediaTypes()).containsExactly(defaultMediaTypes); + assertThat(converter.getSupportedMediaTypes(MyBean.class)).containsExactly(defaultMediaTypes); + + MediaType halJson = MediaType.parseMediaType("application/hal+json"); + converter.registerObjectMappersForType(MyBean.class, map -> { + map.put(halJson, new ObjectMapper()); + map.put(MediaType.APPLICATION_JSON, new ObjectMapper()); + }); + + assertThat(converter.getSupportedMediaTypes(MyBean.class)).containsExactly(halJson, MediaType.APPLICATION_JSON); + assertThat(converter.getSupportedMediaTypes(Map.class)).containsExactly(defaultMediaTypes); + } + + @Test + void readTyped() throws IOException { + String body = "{" + + "\"bytes\":\"AQI=\"," + + "\"array\":[\"Foo\",\"Bar\"]," + + "\"number\":42," + + "\"string\":\"Foo\"," + + "\"bool\":true," + + "\"fraction\":42.0}"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(new MediaType("application", "json")); + MyBean result = (MyBean) converter.read(MyBean.class, inputMessage); + assertThat(result.getString()).isEqualTo("Foo"); + assertThat(result.getNumber()).isEqualTo(42); + assertThat(result.getFraction()).isCloseTo(42F, within(0F)); + assertThat(result.getArray()).isEqualTo(new String[] {"Foo", "Bar"}); + assertThat(result.isBool()).isTrue(); + assertThat(result.getBytes()).isEqualTo(new byte[] {0x1, 0x2}); + } + + @Test + @SuppressWarnings("unchecked") + void readUntyped() throws IOException { + String body = "{" + + "\"bytes\":\"AQI=\"," + + "\"array\":[\"Foo\",\"Bar\"]," + + "\"number\":42," + + "\"string\":\"Foo\"," + + "\"bool\":true," + + "\"fraction\":42.0}"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(new MediaType("application", "json")); + HashMap result = (HashMap) converter.read(HashMap.class, inputMessage); + assertThat(result).containsEntry("string", "Foo"); + assertThat(result).containsEntry("number", 42); + assertThat((Double) result.get("fraction")).isCloseTo(42D, within(0D)); + List array = new ArrayList<>(); + array.add("Foo"); + array.add("Bar"); + assertThat(result).containsEntry("array", array); + assertThat(result).containsEntry("bool", Boolean.TRUE); + assertThat(result).containsEntry("bytes", "AQI="); + } + + @Test + void write() throws IOException { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[] {"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[] {0x1, 0x2}); + converter.write(body, null, outputMessage); + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).contains("\"string\":\"Foo\""); + assertThat(result).contains("\"number\":42"); + assertThat(result).contains("fraction\":42.0"); + assertThat(result).contains("\"array\":[\"Foo\",\"Bar\"]"); + assertThat(result).contains("\"bool\":true"); + assertThat(result).contains("\"bytes\":\"AQI=\""); + assertThat(outputMessage.getHeaders().getContentType()) + .as("Invalid content-type").isEqualTo(MediaType.APPLICATION_JSON); + } + + @Test + void writeWithBaseType() throws IOException { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[] {"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[] {0x1, 0x2}); + converter.write(body, ResolvableType.forClass(MyBase.class), null, outputMessage, null); + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).contains("\"string\":\"Foo\""); + assertThat(result).contains("\"number\":42"); + assertThat(result).contains("fraction\":42.0"); + assertThat(result).contains("\"array\":[\"Foo\",\"Bar\"]"); + assertThat(result).contains("\"bool\":true"); + assertThat(result).contains("\"bytes\":\"AQI=\""); + assertThat(outputMessage.getHeaders().getContentType()) + .as("Invalid content-type").isEqualTo(MediaType.APPLICATION_JSON); + } + + @Test + void writeUTF16() throws IOException { + MediaType contentType = new MediaType("application", "json", StandardCharsets.UTF_16BE); + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + String body = "H\u00e9llo W\u00f6rld"; + converter.write(body, contentType, outputMessage); + assertThat(outputMessage.getBodyAsString(StandardCharsets.UTF_16BE)).as("Invalid result").isEqualTo(("\"" + body + "\"")); + assertThat(outputMessage.getHeaders().getContentType()).as("Invalid content-type").isEqualTo(contentType); + } + + @Test + void readInvalidJson() { + String body = "FooBar"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(new MediaType("application", "json")); + assertThatExceptionOfType(HttpMessageNotReadableException.class) + .isThrownBy(() -> converter.read(MyBean.class, inputMessage)); + } + + @Test + void readValidJsonWithUnknownProperty() throws IOException { + String body = "{\"string\":\"string\",\"unknownProperty\":\"value\"}"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(new MediaType("application", "json")); + converter.read(MyBean.class, inputMessage); + // Assert no HttpMessageNotReadableException is thrown + } + + @Test + @SuppressWarnings("unchecked") + void readAndWriteGenerics() throws Exception { + JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter() { + @Override + protected JavaType getJavaType(Type type, @Nullable Class contextClass) { + if (type instanceof Class && List.class.isAssignableFrom((Class)type)) { + return new ObjectMapper().getTypeFactory().constructCollectionType(ArrayList.class, MyBean.class); + } + else { + return super.getJavaType(type, contextClass); + } + } + }; + String body = "[{" + + "\"bytes\":\"AQI=\"," + + "\"array\":[\"Foo\",\"Bar\"]," + + "\"number\":42," + + "\"string\":\"Foo\"," + + "\"bool\":true," + + "\"fraction\":42.0}]"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(new MediaType("application", "json")); + + List results = (List) converter.read(List.class, inputMessage); + assertThat(results).hasSize(1); + MyBean result = results.get(0); + assertThat(result.getString()).isEqualTo("Foo"); + assertThat(result.getNumber()).isEqualTo(42); + assertThat(result.getFraction()).isCloseTo(42F, within(0F)); + assertThat(result.getArray()).isEqualTo(new String[] {"Foo", "Bar"}); + assertThat(result.isBool()).isTrue(); + assertThat(result.getBytes()).isEqualTo(new byte[] {0x1, 0x2}); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + converter.write(results, MediaType.APPLICATION_JSON, outputMessage); + JSONAssert.assertEquals(body, outputMessage.getBodyAsString(StandardCharsets.UTF_8), true); + } + + @Test + @SuppressWarnings("unchecked") + void readAndWriteParameterizedType() throws Exception { + ParameterizedTypeReference> beansList = new ParameterizedTypeReference<>() {}; + + String body = "[{" + + "\"bytes\":\"AQI=\"," + + "\"array\":[\"Foo\",\"Bar\"]," + + "\"number\":42," + + "\"string\":\"Foo\"," + + "\"bool\":true," + + "\"fraction\":42.0}]"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter(); + List results = (List) converter.read(ResolvableType.forType(beansList), inputMessage, null); + assertThat(results).hasSize(1); + MyBean result = results.get(0); + assertThat(result.getString()).isEqualTo("Foo"); + assertThat(result.getNumber()).isEqualTo(42); + assertThat(result.getFraction()).isCloseTo(42F, within(0F)); + assertThat(result.getArray()).isEqualTo(new String[] {"Foo", "Bar"}); + assertThat(result.isBool()).isTrue(); + assertThat(result.getBytes()).isEqualTo(new byte[] {0x1, 0x2}); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + converter.write(results, ResolvableType.forType(beansList), MediaType.APPLICATION_JSON, outputMessage, null); + JSONAssert.assertEquals(body, outputMessage.getBodyAsString(StandardCharsets.UTF_8), true); + } + + @Test + @SuppressWarnings("unchecked") + void writeParameterizedBaseType() throws Exception { + ParameterizedTypeReference> beansList = new ParameterizedTypeReference<>() {}; + ParameterizedTypeReference> baseList = new ParameterizedTypeReference<>() {}; + + String body = "[{" + + "\"bytes\":\"AQI=\"," + + "\"array\":[\"Foo\",\"Bar\"]," + + "\"number\":42," + + "\"string\":\"Foo\"," + + "\"bool\":true," + + "\"fraction\":42.0}]"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + List results = (List) converter.read(beansList.getType(), null, inputMessage); + assertThat(results).hasSize(1); + MyBean result = results.get(0); + assertThat(result.getString()).isEqualTo("Foo"); + assertThat(result.getNumber()).isEqualTo(42); + assertThat(result.getFraction()).isCloseTo(42F, within(0F)); + assertThat(result.getArray()).isEqualTo(new String[] {"Foo", "Bar"}); + assertThat(result.isBool()).isTrue(); + assertThat(result.getBytes()).isEqualTo(new byte[] {0x1, 0x2}); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + converter.write(results, baseList.getType(), MediaType.APPLICATION_JSON, outputMessage); + JSONAssert.assertEquals(body, outputMessage.getBodyAsString(StandardCharsets.UTF_8), true); + } + + // gh-24498 + @Test + void writeOptional() throws IOException { + ParameterizedTypeReference> optionalParent = new ParameterizedTypeReference<>() {}; + Optional result = Optional.of(new Impl1()); + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + converter.write(result, ResolvableType.forType(optionalParent.getType()), + MediaType.APPLICATION_JSON, outputMessage, null); + + assertThat(outputMessage.getBodyAsString(StandardCharsets.UTF_8)) + .contains("@type"); + } + + @Test + void prettyPrint() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + PrettyPrintBean bean = new PrettyPrintBean(); + bean.setName("Jason"); + + ObjectMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + this.converter = new JacksonJsonHttpMessageConverter(mapper); + this.converter.write(bean, ResolvableType.forType(PrettyPrintBean.class), + MediaType.APPLICATION_JSON, outputMessage, null); + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + + assertThat(result).isEqualToNormalizingNewlines(""" + { + \s "name" : "Jason" + }"""); + } + + @Test + void prettyPrintWithSse() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + outputMessage.getHeaders().setContentType(MediaType.TEXT_EVENT_STREAM); + PrettyPrintBean bean = new PrettyPrintBean(); + bean.setName("Jason"); + + ObjectMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + this.converter = new JacksonJsonHttpMessageConverter(mapper); + this.converter.write(bean, ResolvableType.forType(PrettyPrintBean.class), + MediaType.APPLICATION_JSON, outputMessage, null); + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + + assertThat(result).isEqualTo("{\ndata: \"name\" : \"Jason\"\ndata:}"); + } + + @Test + void prefixJson() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + this.converter.setPrefixJson(true); + this.converter.write("foo", ResolvableType.forType(String.class), MediaType.APPLICATION_JSON, + outputMessage, null); + + assertThat(outputMessage.getBodyAsString(StandardCharsets.UTF_8)).isEqualTo(")]}', \"foo\""); + } + + @Test + void prefixJsonCustom() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + this.converter.setJsonPrefix(")))"); + this.converter.write("foo", ResolvableType.forType(String.class), MediaType.APPLICATION_JSON, + outputMessage, null); + + assertThat(outputMessage.getBodyAsString(StandardCharsets.UTF_8)).isEqualTo(")))\"foo\""); + } + + @Test + void fieldLevelJsonView() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + JacksonViewBean bean = new JacksonViewBean(); + bean.setWithView1("with"); + bean.setWithView2("with"); + bean.setWithoutView("without"); + + Map hints = Collections.singletonMap(JsonView.class.getName(), MyJacksonView1.class); + this.converter.write(bean, ResolvableType.forType(JacksonViewBean.class), MediaType.APPLICATION_JSON, + outputMessage, hints); + + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).contains("\"withView1\":\"with\""); + assertThat(result).doesNotContain("\"withView2\":\"with\""); + assertThat(result).doesNotContain("\"withoutView\":\"without\""); + } + + @Test + void classLevelJsonView() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + JacksonViewBean bean = new JacksonViewBean(); + bean.setWithView1("with"); + bean.setWithView2("with"); + bean.setWithoutView("without"); + + Map hints = Collections.singletonMap(JsonView.class.getName(), MyJacksonView3.class); + this.converter.write(bean, ResolvableType.forType(JacksonViewBean.class), MediaType.APPLICATION_JSON, + outputMessage, hints); + + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).doesNotContain("\"withView1\":\"with\""); + assertThat(result).doesNotContain("\"withView2\":\"with\""); + assertThat(result).contains("\"withoutView\":\"without\""); + } + + @Test + void filters() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + JacksonFilteredBean bean = new JacksonFilteredBean(); + bean.setProperty1("value"); + bean.setProperty2("value"); + + + FilterProvider filters = new SimpleFilterProvider().addFilter("myJacksonFilter", + SimpleBeanPropertyFilter.serializeAllExcept("property2")); + Map hints = Collections.singletonMap(FilterProvider.class.getName(), filters); + this.converter.write(bean, ResolvableType.forType(JacksonFilteredBean.class), MediaType.APPLICATION_JSON, + outputMessage, hints); + + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).contains("\"property1\":\"value\""); + assertThat(result).doesNotContain("\"property2\":\"value\""); + } + + @Test // SPR-13318 + void writeSubType() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + MyBean bean = new MyBean(); + bean.setString("Foo"); + bean.setNumber(42); + + this.converter.write(bean, ResolvableType.forType(MyInterface.class), + MediaType.APPLICATION_JSON, outputMessage, null); + + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).contains("\"string\":\"Foo\""); + assertThat(result).contains("\"number\":42"); + } + + @Test // SPR-13318 + void writeSubTypeList() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + List beans = new ArrayList<>(); + MyBean foo = new MyBean(); + foo.setString("Foo"); + foo.setNumber(42); + beans.add(foo); + MyBean bar = new MyBean(); + bar.setString("Bar"); + bar.setNumber(123); + beans.add(bar); + ParameterizedTypeReference> typeReference = + new ParameterizedTypeReference<>() {}; + + this.converter.write(beans, ResolvableType.forType(typeReference), MediaType.APPLICATION_JSON, + outputMessage, null); + + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).contains("\"string\":\"Foo\""); + assertThat(result).contains("\"number\":42"); + assertThat(result).contains("\"string\":\"Bar\""); + assertThat(result).contains("\"number\":123"); + } + + @Test // gh-27511 + void readWithNoDefaultConstructor() throws Exception { + String body = "{\"property1\":\"foo\",\"property2\":\"bar\"}"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(MediaType.APPLICATION_JSON); + BeanWithNoDefaultConstructor bean = + (BeanWithNoDefaultConstructor)converter.read(BeanWithNoDefaultConstructor.class, inputMessage); + assertThat(bean.property1).isEqualTo("foo"); + assertThat(bean.property2).isEqualTo("bar"); + } + + @Test + @SuppressWarnings("unchecked") + void readNonUnicode() throws Exception { + String body = "{\"føø\":\"bår\"}"; + Charset charset = StandardCharsets.ISO_8859_1; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(charset)); + inputMessage.getHeaders().setContentType(new MediaType("application", "json", charset)); + HashMap result = (HashMap) this.converter.read(HashMap.class, inputMessage); + + assertThat(result).containsExactly(entry("føø", "bår")); + } + + @Test + @SuppressWarnings("unchecked") + void readAscii() throws Exception { + String body = "{\"foo\":\"bar\"}"; + Charset charset = StandardCharsets.US_ASCII; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(charset)); + inputMessage.getHeaders().setContentType(new MediaType("application", "json", charset)); + HashMap result = (HashMap) this.converter.read(HashMap.class, inputMessage); + + assertThat(result).containsExactly(entry("foo", "bar")); + } + + @Test + void writeAscii() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + Map body = new HashMap<>(); + body.put("foo", "bar"); + Charset charset = StandardCharsets.US_ASCII; + MediaType contentType = new MediaType("application", "json", charset); + converter.write(body, contentType, outputMessage); + + String result = outputMessage.getBodyAsString(charset); + assertThat(result).isEqualTo("{\"foo\":\"bar\"}"); + assertThat(outputMessage.getHeaders().getContentType()).as("Invalid content-type").isEqualTo(contentType); + } + + @Test + void readWithCustomized() throws IOException { + JacksonJsonHttpMessageConverterWithCustomization customizedConverter = + new JacksonJsonHttpMessageConverterWithCustomization(); + String body = "{\"property\":\"Value1\"}"; + MockHttpInputMessage inputMessage1 = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + MockHttpInputMessage inputMessage2 = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage1.getHeaders().setContentType(new MediaType("application", "json")); + inputMessage2.getHeaders().setContentType(new MediaType("application", "json")); + + assertThatExceptionOfType(HttpMessageNotReadableException.class) + .isThrownBy(() -> customizedConverter.read(MyCustomizedBean.class, inputMessage1)); + + MyCustomizedBean customizedResult = (MyCustomizedBean) converter.read(MyCustomizedBean.class, inputMessage2); + assertThat(customizedResult.getProperty()).isEqualTo(MyCustomEnum.VAL1); + } + + @Test + void writeWithCustomized() throws IOException { + JacksonJsonHttpMessageConverterWithCustomization customizedConverter = + new JacksonJsonHttpMessageConverterWithCustomization(); + MockHttpOutputMessage outputMessage1 = new MockHttpOutputMessage(); + MockHttpOutputMessage outputMessage2 = new MockHttpOutputMessage(); + MyCustomizedBean body = new MyCustomizedBean(); + body.setProperty(MyCustomEnum.VAL2); + converter.write(body, null, outputMessage1); + customizedConverter.write(body, null, outputMessage2); + String result1 = outputMessage1.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result1).contains("\"property\":\"Value2\""); + String result2 = outputMessage2.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result2).contains("\"property\":\"VAL2\""); + } + + @Test + void repeatableWrites() throws IOException { + MockHttpOutputMessage outputMessage1 = new MockHttpOutputMessage(); + MyBean body = new MyBean(); + body.setString("Foo"); + converter.write(body, null, outputMessage1); + String result = outputMessage1.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).contains("\"string\":\"Foo\""); + + MockHttpOutputMessage outputMessage2 = new MockHttpOutputMessage(); + converter.write(body, null, outputMessage2); + result = outputMessage2.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).contains("\"string\":\"Foo\""); + } + + + + interface MyInterface { + + String getString(); + + void setString(String string); + } + + + public static class MyBase implements MyInterface { + + private String string; + + @Override + public String getString() { + return string; + } + + @Override + public void setString(String string) { + this.string = string; + } + } + + + public static class MyBean extends MyBase { + + private int number; + + private float fraction; + + private String[] array; + + private boolean bool; + + private byte[] bytes; + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public float getFraction() { + return fraction; + } + + public void setFraction(float fraction) { + this.fraction = fraction; + } + + public String[] getArray() { + return array; + } + + public void setArray(String[] array) { + this.array = array; + } + + public boolean isBool() { + return bool; + } + + public void setBool(boolean bool) { + this.bool = bool; + } + + public byte[] getBytes() { + return bytes; + } + + public void setBytes(byte[] bytes) { + this.bytes = bytes; + } + } + + + public static class PrettyPrintBean { + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + + private interface MyJacksonView1 {} + + private interface MyJacksonView2 {} + + private interface MyJacksonView3 {} + + + @SuppressWarnings("unused") + @JsonView(MyJacksonView3.class) + private static class JacksonViewBean { + + @JsonView(MyJacksonView1.class) + private String withView1; + + @JsonView(MyJacksonView2.class) + private String withView2; + + private String withoutView; + + public String getWithView1() { + return withView1; + } + + public void setWithView1(String withView1) { + this.withView1 = withView1; + } + + public String getWithView2() { + return withView2; + } + + public void setWithView2(String withView2) { + this.withView2 = withView2; + } + + public String getWithoutView() { + return withoutView; + } + + public void setWithoutView(String withoutView) { + this.withoutView = withoutView; + } + } + + + @JsonFilter("myJacksonFilter") + @SuppressWarnings("unused") + private static class JacksonFilteredBean { + + private String property1; + + private String property2; + + public String getProperty1() { + return property1; + } + + public void setProperty1(String property1) { + this.property1 = property1; + } + + public String getProperty2() { + return property2; + } + + public void setProperty2(String property2) { + this.property2 = property2; + } + } + + + @SuppressWarnings("unused") + private static class BeanWithNoDefaultConstructor { + + private final String property1; + + private final String property2; + + public BeanWithNoDefaultConstructor(String property1, String property2) { + this.property1 = property1; + this.property2 = property2; + } + + public String getProperty1() { + return property1; + } + + public String getProperty2() { + return property2; + } + } + + public static class MyCustomizedBean { + + private MyCustomEnum property; + + public MyCustomEnum getProperty() { + return property; + } + + public void setProperty(MyCustomEnum property) { + this.property = property; + } + } + + public enum MyCustomEnum { + VAL1, + VAL2; + + @Override + public String toString() { + return this == VAL1 ? "Value1" : "Value2"; + } + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME) + @JsonSubTypes(value = {@JsonSubTypes.Type(value = Impl1.class), + @JsonSubTypes.Type(value = Impl2.class)}) + public interface MyParent { + } + + public static class Impl1 implements MyParent { + } + + public static class Impl2 implements MyParent { + } + + private static class JacksonJsonHttpMessageConverterWithCustomization extends JacksonJsonHttpMessageConverter { + + @Override + protected ObjectReader customizeReader(ObjectReader reader, JavaType javaType) { + return reader.without(EnumFeature.READ_ENUMS_USING_TO_STRING); + } + + @Override + protected ObjectWriter customizeWriter(ObjectWriter writer, @Nullable JavaType javaType, @Nullable MediaType contentType) { + return writer.without(EnumFeature.WRITE_ENUMS_USING_TO_STRING); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverterTests.java new file mode 100644 index 00000000000..09fa326ea00 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverterTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.smile; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import tools.jackson.dataformat.smile.SmileMapper; + +import org.springframework.http.MediaType; +import org.springframework.web.testfixture.http.MockHttpInputMessage; +import org.springframework.web.testfixture.http.MockHttpOutputMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Jackson 3.x Smile converter tests. + * + * @author Sebastien Deleuze + */ +class JacksonSmileHttpMessageConverterTests { + + private final JacksonSmileHttpMessageConverter converter = new JacksonSmileHttpMessageConverter(); + private final SmileMapper mapper = SmileMapper.builder().build(); + + + @Test + void canRead() { + assertThat(converter.canRead(MyBean.class, new MediaType("application", "x-jackson-smile"))).isTrue(); + assertThat(converter.canRead(MyBean.class, new MediaType("application", "json"))).isFalse(); + assertThat(converter.canRead(MyBean.class, new MediaType("application", "xml"))).isFalse(); + } + + @Test + void canWrite() { + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "x-jackson-smile"))).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "json"))).isFalse(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "xml"))).isFalse(); + } + + @Test + void read() throws IOException { + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[]{"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[]{0x1, 0x2}); + MockHttpInputMessage inputMessage = new MockHttpInputMessage(mapper.writeValueAsBytes(body)); + inputMessage.getHeaders().setContentType(new MediaType("application", "x-jackson-smile")); + MyBean result = (MyBean) converter.read(MyBean.class, inputMessage); + assertThat(result.getString()).isEqualTo("Foo"); + assertThat(result.getNumber()).isEqualTo(42); + assertThat(result.getFraction()).isCloseTo(42F, within(0F)); + + assertThat(result.getArray()).isEqualTo(new String[]{"Foo", "Bar"}); + assertThat(result.isBool()).isTrue(); + assertThat(result.getBytes()).isEqualTo(new byte[]{0x1, 0x2}); + } + + @Test + void write() throws IOException { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[]{"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[]{0x1, 0x2}); + converter.write(body, null, outputMessage); + assertThat(outputMessage.getBodyAsBytes()).isEqualTo(mapper.writeValueAsBytes(body)); + assertThat(outputMessage.getHeaders().getContentType()) + .as("Invalid content-type").isEqualTo(new MediaType("application", "x-jackson-smile")); + } + + + public static class MyBean { + + private String string; + + private int number; + + private float fraction; + + private String[] array; + + private boolean bool; + + private byte[] bytes; + + public byte[] getBytes() { + return bytes; + } + + public void setBytes(byte[] bytes) { + this.bytes = bytes; + } + + public boolean isBool() { + return bool; + } + + public void setBool(boolean bool) { + this.bool = bool; + } + + public String getString() { + return string; + } + + public void setString(String string) { + this.string = string; + } + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public float getFraction() { + return fraction; + } + + public void setFraction(float fraction) { + this.fraction = fraction; + } + + public String[] getArray() { + return array; + } + + public void setArray(String[] array) { + this.array = array; + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverterTests.java new file mode 100644 index 00000000000..580fa7fc9e9 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverterTests.java @@ -0,0 +1,319 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.xml; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonView; +import org.junit.jupiter.api.Test; +import tools.jackson.dataformat.xml.XmlMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.testfixture.http.MockHttpInputMessage; +import org.springframework.web.testfixture.http.MockHttpOutputMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.within; + +/** + * Jackson 3.x XML converter tests. + * + * @author Sebastien Deleuze + */ +class JacksonXmlHttpMessageConverterTests { + + private final JacksonXmlHttpMessageConverter converter = new JacksonXmlHttpMessageConverter(); + + + @Test + void canRead() { + assertThat(converter.canRead(MyBean.class, new MediaType("application", "xml"))).isTrue(); + assertThat(converter.canRead(MyBean.class, new MediaType("text", "xml"))).isTrue(); + assertThat(converter.canRead(MyBean.class, new MediaType("application", "soap+xml"))).isTrue(); + assertThat(converter.canRead(MyBean.class, new MediaType("text", "xml", StandardCharsets.UTF_8))).isTrue(); + assertThat(converter.canRead(MyBean.class, new MediaType("text", "xml", StandardCharsets.ISO_8859_1))).isTrue(); + } + + @Test + void canWrite() { + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "xml"))).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("text", "xml"))).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "soap+xml"))).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("text", "xml", StandardCharsets.UTF_8))).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("text", "xml", StandardCharsets.ISO_8859_1))).isFalse(); + } + + @Test + void read() throws IOException { + String body = "" + + "Foo" + + "42" + + "42.0" + + "Foo" + + "Bar" + + "true" + + "AQI="; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(new MediaType("application", "xml")); + MyBean result = (MyBean) converter.read(MyBean.class, inputMessage); + assertThat(result.getString()).isEqualTo("Foo"); + assertThat(result.getNumber()).isEqualTo(42); + assertThat(result.getFraction()).isCloseTo(42F, within(0F)); + assertThat(result.getArray()).isEqualTo(new String[]{"Foo", "Bar"}); + assertThat(result.isBool()).isTrue(); + assertThat(result.getBytes()).isEqualTo(new byte[]{0x1, 0x2}); + } + + @Test + void write() throws IOException { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[]{"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[]{0x1, 0x2}); + converter.write(body, null, outputMessage); + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).contains("Foo"); + assertThat(result).contains("42"); + assertThat(result).contains("42.0"); + assertThat(result).contains("FooBar"); + assertThat(result).contains("true"); + assertThat(result).contains("AQI="); + assertThat(outputMessage.getHeaders().getContentType()) + .as("Invalid content-type").isEqualTo(new MediaType("application", "xml", StandardCharsets.UTF_8)); + } + + @Test + void readInvalidXml() { + String body = "FooBar"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(MediaType.APPLICATION_XML); + assertThatExceptionOfType(HttpMessageNotReadableException.class).isThrownBy(() -> + converter.read(MyBean.class, inputMessage)); + } + + @Test + void readValidXmlWithUnknownProperty() throws IOException { + String body = "stringvalue"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(MediaType.APPLICATION_XML); + converter.read(MyBean.class, inputMessage); + // Assert no HttpMessageNotReadableException is thrown + } + + @Test + void jsonView() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + JacksonViewBean bean = new JacksonViewBean(); + bean.setWithView1("with"); + bean.setWithView2("with"); + bean.setWithoutView("without"); + + Map hints = Collections.singletonMap(JsonView.class.getName(), MyJacksonView1.class); + this.converter.write(bean, ResolvableType.forClass(JacksonViewBean.class), null, outputMessage, hints); + + String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); + assertThat(result).contains("with"); + assertThat(result).doesNotContain("with"); + assertThat(result).doesNotContain("without"); + } + + @Test + void customXmlMapper() { + new JacksonXmlHttpMessageConverter(new MyXmlMapper()); + // Assert no exception is thrown + } + + @Test + void readWithExternalReference() throws IOException { + String body = "\n" + + " ]>&ext;"; + + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(MediaType.APPLICATION_XML); + + assertThatExceptionOfType(HttpMessageNotReadableException.class).isThrownBy(() -> + this.converter.read(MyBean.class, inputMessage)); + } + + @Test + void readWithXmlBomb() { + // https://en.wikipedia.org/wiki/Billion_laughs + // https://msdn.microsoft.com/en-us/magazine/ee335713.aspx + String body = """ + + + + + + + + + + + + + ]> + &lol9;"""; + + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(MediaType.APPLICATION_XML); + + assertThatExceptionOfType(HttpMessageNotReadableException.class).isThrownBy(() -> + this.converter.read(MyBean.class, inputMessage)); + } + + @Test + void readNonUnicode() throws Exception { + String body = "" + + "føø bår" + + ""; + + Charset charset = StandardCharsets.ISO_8859_1; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(charset)); + inputMessage.getHeaders().setContentType(new MediaType("application", "xml", charset)); + MyBean result = (MyBean) converter.read(MyBean.class, inputMessage); + assertThat(result.getString()).isEqualTo("føø bår"); + } + + + + public static class MyBean { + + private String string; + + private int number; + + private float fraction; + + private String[] array; + + private boolean bool; + + private byte[] bytes; + + public byte[] getBytes() { + return bytes; + } + + public void setBytes(byte[] bytes) { + this.bytes = bytes; + } + + public boolean isBool() { + return bool; + } + + public void setBool(boolean bool) { + this.bool = bool; + } + + public String getString() { + return string; + } + + public void setString(String string) { + this.string = string; + } + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public float getFraction() { + return fraction; + } + + public void setFraction(float fraction) { + this.fraction = fraction; + } + + public String[] getArray() { + return array; + } + + public void setArray(String[] array) { + this.array = array; + } + } + + + private interface MyJacksonView1 {} + + private interface MyJacksonView2 {} + + + @SuppressWarnings("unused") + private static class JacksonViewBean { + + @JsonView(MyJacksonView1.class) + private String withView1; + + @JsonView(MyJacksonView2.class) + private String withView2; + + private String withoutView; + + public String getWithView1() { + return withView1; + } + + public void setWithView1(String withView1) { + this.withView1 = withView1; + } + + public String getWithView2() { + return withView2; + } + + public void setWithView2(String withView2) { + this.withView2 = withView2; + } + + public String getWithoutView() { + return withoutView; + } + + public void setWithoutView(String withoutView) { + this.withoutView = withoutView; + } + } + + + private static class MyXmlMapper extends XmlMapper { + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverterTests.java new file mode 100644 index 00000000000..7f0fbd95450 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverterTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2002-2025 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.yaml; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import tools.jackson.dataformat.yaml.YAMLMapper; + +import org.springframework.http.MediaType; +import org.springframework.web.testfixture.http.MockHttpInputMessage; +import org.springframework.web.testfixture.http.MockHttpOutputMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Jackson 3.x YAML converter tests. + * + * @author Sebastien Deleuze + */ +class JacksonYamlHttpMessageConverterTests { + + private final JacksonYamlHttpMessageConverter converter = new JacksonYamlHttpMessageConverter(); + private final YAMLMapper mapper = YAMLMapper.builder().build(); + + + @Test + void canRead() { + assertThat(converter.canRead(MyBean.class, MediaType.APPLICATION_YAML)).isTrue(); + assertThat(converter.canRead(MyBean.class, new MediaType("application", "json"))).isFalse(); + assertThat(converter.canRead(MyBean.class, new MediaType("application", "xml"))).isFalse(); + } + + @Test + void canWrite() { + assertThat(converter.canWrite(MyBean.class, MediaType.APPLICATION_YAML)).isTrue(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "json"))).isFalse(); + assertThat(converter.canWrite(MyBean.class, new MediaType("application", "xml"))).isFalse(); + } + + @Test + void read() throws IOException { + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[]{"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[]{0x1, 0x2}); + MockHttpInputMessage inputMessage = new MockHttpInputMessage(mapper.writeValueAsBytes(body)); + inputMessage.getHeaders().setContentType(MediaType.APPLICATION_YAML); + MyBean result = (MyBean) converter.read(MyBean.class, inputMessage); + assertThat(result.getString()).isEqualTo("Foo"); + assertThat(result.getNumber()).isEqualTo(42); + assertThat(result.getFraction()).isCloseTo(42F, within(0F)); + + assertThat(result.getArray()).isEqualTo(new String[]{"Foo", "Bar"}); + assertThat(result.isBool()).isTrue(); + assertThat(result.getBytes()).isEqualTo(new byte[]{0x1, 0x2}); + } + + @Test + void write() throws IOException { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[]{"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[]{0x1, 0x2}); + converter.write(body, null, outputMessage); + assertThat(outputMessage.getBodyAsBytes()).isEqualTo(mapper.writeValueAsBytes(body)); + assertThat(outputMessage.getHeaders().getContentType()) + .as("Invalid content-type").isEqualTo(MediaType.APPLICATION_YAML); + } + + + public static class MyBean { + + private String string; + + private int number; + + private float fraction; + + private String[] array; + + private boolean bool; + + private byte[] bytes; + + public byte[] getBytes() { + return bytes; + } + + public void setBytes(byte[] bytes) { + this.bytes = bytes; + } + + public boolean isBool() { + return bool; + } + + public void setBool(boolean bool) { + this.bool = bool; + } + + public String getString() { + return string; + } + + public void setString(String string) { + this.string = string; + } + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public float getFraction() { + return fraction; + } + + public void setFraction(float fraction) { + this.fraction = fraction; + } + + public String[] getArray() { + return array; + } + + public void setArray(String[] array) { + this.array = array; + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java index c675334f92a..dced302e5b0 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java @@ -522,7 +522,7 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { assertThat(request.getPath()).isEqualTo("/pojo/capitalize"); - assertThat(request.getBody().readUtf8()).isEqualTo("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}"); + assertThat(request.getBody().readUtf8()).isEqualTo("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"); assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); assertThat(request.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); }); diff --git a/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java index 4a595825229..a0b9e63505a 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -30,6 +30,7 @@ import java.util.stream.Stream; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeName; import com.fasterxml.jackson.annotation.JsonView; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.TestExecutionExceptionHandler; import org.junit.jupiter.params.ParameterizedTest; @@ -407,7 +408,8 @@ class RestTemplateIntegrationTests extends AbstractMockWebServerTests { } @ParameterizedRestTemplateTest - void jsonPostForObjectWithJacksonView(ClientHttpRequestFactory clientHttpRequestFactory) { + @Disabled("Use RestClient + upcoming hint management instead") + void jsonPostForObjectWithJacksonJsonView(ClientHttpRequestFactory clientHttpRequestFactory) { setUpClient(clientHttpRequestFactory); HttpHeaders entityHeaders = new HttpHeaders(); diff --git a/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java b/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java index fcf750abd26..4c421a56bde 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java @@ -53,8 +53,8 @@ import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.SmartHttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.util.FileCopyUtils; import org.springframework.web.util.DefaultUriBuilderFactory; @@ -113,7 +113,7 @@ class RestTemplateTests { RestTemplate restTemplate = new RestTemplate(); List> httpMessageConverters = restTemplate.getMessageConverters(); assertThat(httpMessageConverters).extracting("class").containsOnlyOnce( - MappingJackson2HttpMessageConverter.class + JacksonJsonHttpMessageConverter.class ); assertThat(httpMessageConverters).extracting("class").doesNotContain( KotlinSerializationJsonHttpMessageConverter.class diff --git a/spring-webmvc/spring-webmvc.gradle b/spring-webmvc/spring-webmvc.gradle index 7f4aabc0d1d..e26677cc6ff 100644 --- a/spring-webmvc/spring-webmvc.gradle +++ b/spring-webmvc/spring-webmvc.gradle @@ -39,6 +39,12 @@ dependencies { optional("org.jetbrains.kotlinx:kotlinx-serialization-protobuf") optional("org.reactivestreams:reactive-streams") optional("org.webjars:webjars-locator-lite") + optional("tools.jackson.core:jackson-databind") + optional("tools.jackson.dataformat:jackson-dataformat-smile") + optional("tools.jackson.dataformat:jackson-dataformat-cbor") + optional("tools.jackson.dataformat:jackson-dataformat-smile") + optional("tools.jackson.dataformat:jackson-dataformat-xml") + optional("tools.jackson.dataformat:jackson-dataformat-yaml") testCompileOnly("com.google.code.findbugs:findbugs") { // for groovy-templates exclude group: "dom4j", module: "dom4j" } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java index 8d031b7990a..72bc6fd107d 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java @@ -45,20 +45,25 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.ResourceHttpMessageConverter; import org.springframework.http.converter.ResourceRegionHttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.cbor.JacksonCborHttpMessageConverter; import org.springframework.http.converter.cbor.KotlinSerializationCborHttpMessageConverter; import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter; import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter; import org.springframework.http.converter.feed.RssChannelHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.http.converter.json.JsonbHttpMessageConverter; import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.protobuf.KotlinSerializationProtobufHttpMessageConverter; +import org.springframework.http.converter.smile.JacksonSmileHttpMessageConverter; import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; +import org.springframework.http.converter.xml.JacksonXmlHttpMessageConverter; import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; +import org.springframework.http.converter.yaml.JacksonYamlHttpMessageConverter; import org.springframework.http.converter.yaml.MappingJackson2YamlHttpMessageConverter; import org.springframework.util.AntPathMatcher; import org.springframework.util.Assert; @@ -196,14 +201,24 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv private static final boolean jaxb2Present; + private static final boolean jacksonPresent; + private static final boolean jackson2Present; + private static final boolean jacksonXmlPresent; + private static final boolean jackson2XmlPresent; + private static final boolean jacksonSmilePresent; + private static final boolean jackson2SmilePresent; + private static final boolean jacksonCborPresent; + private static final boolean jackson2CborPresent; + private static final boolean jacksonYamlPresent; + private static final boolean jackson2YamlPresent; private static final boolean gsonPresent; @@ -220,12 +235,17 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv ClassLoader classLoader = WebMvcConfigurationSupport.class.getClassLoader(); romePresent = ClassUtils.isPresent("com.rometools.rome.feed.WireFeed", classLoader); jaxb2Present = ClassUtils.isPresent("jakarta.xml.bind.Binder", classLoader); + jacksonPresent = ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", classLoader); jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); - jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader); - jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); - jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader); - jackson2YamlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", classLoader); + jacksonXmlPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.xml.XmlMapper", classLoader); + jackson2XmlPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader); + jacksonSmilePresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.smile.SmileMapper", classLoader); + jackson2SmilePresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); + jacksonCborPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.cbor.CBORMapper", classLoader); + jackson2CborPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader); + jacksonYamlPresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.yaml.YAMLMapper", classLoader); + jackson2YamlPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader); kotlinSerializationCborPresent = ClassUtils.isPresent("kotlinx.serialization.cbor.Cbor", classLoader); @@ -445,19 +465,19 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv map.put("atom", MediaType.APPLICATION_ATOM_XML); map.put("rss", MediaType.APPLICATION_RSS_XML); } - if (jaxb2Present || jackson2XmlPresent) { + if (jaxb2Present || jacksonXmlPresent || jackson2XmlPresent) { map.put("xml", MediaType.APPLICATION_XML); } - if (jackson2Present || gsonPresent || jsonbPresent || kotlinSerializationJsonPresent) { + if (jacksonPresent || jackson2Present || gsonPresent || jsonbPresent || kotlinSerializationJsonPresent) { map.put("json", MediaType.APPLICATION_JSON); } - if (jackson2SmilePresent) { + if (jacksonSmilePresent || jackson2SmilePresent) { map.put("smile", MediaType.valueOf("application/x-jackson-smile")); } - if (jackson2CborPresent || kotlinSerializationCborPresent) { + if (jacksonCborPresent || jackson2CborPresent || kotlinSerializationCborPresent) { map.put("cbor", MediaType.APPLICATION_CBOR); } - if (jackson2YamlPresent) { + if (jacksonYamlPresent || jackson2YamlPresent) { map.put("yaml", MediaType.APPLICATION_YAML); } return map; @@ -679,7 +699,7 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv adapter.setCustomReturnValueHandlers(getReturnValueHandlers()); adapter.setErrorResponseInterceptors(getErrorResponseInterceptors()); - if (jackson2Present) { + if (jacksonPresent || jackson2Present) { adapter.setRequestBodyAdvice(Collections.singletonList(new JsonViewRequestBodyAdvice())); adapter.setResponseBodyAdvice(Collections.singletonList(new JsonViewResponseBodyAdvice())); } @@ -909,7 +929,10 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv messageConverters.add(new RssChannelHttpMessageConverter()); } - if (jackson2XmlPresent) { + if (jacksonXmlPresent) { + messageConverters.add(new JacksonXmlHttpMessageConverter()); + } + else if (jackson2XmlPresent) { Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.xml(); if (this.applicationContext != null) { builder.applicationContext(this.applicationContext); @@ -924,7 +947,10 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv messageConverters.add(new KotlinSerializationProtobufHttpMessageConverter()); } - if (jackson2Present) { + if (jacksonPresent) { + messageConverters.add(new JacksonJsonHttpMessageConverter()); + } + else if (jackson2Present) { Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.json(); if (this.applicationContext != null) { builder.applicationContext(this.applicationContext); @@ -941,14 +967,21 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv messageConverters.add(new KotlinSerializationJsonHttpMessageConverter()); } - if (jackson2SmilePresent) { + if (jacksonSmilePresent) { + messageConverters.add(new JacksonSmileHttpMessageConverter()); + } + else if (jackson2SmilePresent) { Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.smile(); if (this.applicationContext != null) { builder.applicationContext(this.applicationContext); } messageConverters.add(new MappingJackson2SmileHttpMessageConverter(builder.build())); } - if (jackson2CborPresent) { + + if (jacksonCborPresent) { + messageConverters.add(new JacksonCborHttpMessageConverter()); + } + else if (jackson2CborPresent) { Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.cbor(); if (this.applicationContext != null) { builder.applicationContext(this.applicationContext); @@ -958,7 +991,11 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv else if (kotlinSerializationCborPresent) { messageConverters.add(new KotlinSerializationCborHttpMessageConverter()); } - if (jackson2YamlPresent) { + + if (jacksonYamlPresent) { + messageConverters.add(new JacksonYamlHttpMessageConverter()); + } + else if (jackson2YamlPresent) { Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.yaml(); if (this.applicationContext != null) { builder.applicationContext(this.applicationContext); @@ -1084,7 +1121,7 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv exceptionHandlerResolver.setCustomArgumentResolvers(getArgumentResolvers()); exceptionHandlerResolver.setCustomReturnValueHandlers(getReturnValueHandlers()); exceptionHandlerResolver.setErrorResponseInterceptors(getErrorResponseInterceptors()); - if (jackson2Present) { + if (jacksonPresent || jackson2Present) { exceptionHandlerResolver.setResponseBodyAdvice( Collections.singletonList(new JsonViewResponseBodyAdvice())); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java index 544e37e31e1..8850d49a09e 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java @@ -50,7 +50,7 @@ import org.springframework.context.MessageSourceResolvable; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.MediaType; import org.springframework.http.converter.StringHttpMessageConverter; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.validation.Errors; import org.springframework.validation.ObjectError; import org.springframework.validation.Validator; @@ -138,7 +138,7 @@ class MethodValidationTests { handlerAdapter.setApplicationContext(context); handlerAdapter.setBeanFactory(context.getBeanFactory()); handlerAdapter.setMessageConverters( - List.of(new StringHttpMessageConverter(), new MappingJackson2HttpMessageConverter())); + List.of(new StringHttpMessageConverter(), new JacksonJsonHttpMessageConverter())); handlerAdapter.afterPropertiesSet(); return handlerAdapter; } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java index 1acdb9db88a..74bf7f23a22 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java @@ -28,12 +28,13 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeName; import com.fasterxml.jackson.annotation.JsonView; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import org.xmlunit.assertj.XmlAssert; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; import org.springframework.aop.framework.ProxyFactory; import org.springframework.aop.target.SingletonTargetSource; @@ -51,8 +52,10 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.ResourceHttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; +import org.springframework.http.converter.xml.JacksonXmlHttpMessageConverter; import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; import org.springframework.util.MultiValueMap; import org.springframework.util.ReflectionUtils; @@ -69,8 +72,6 @@ import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.method.HandlerMethod; import org.springframework.web.method.support.ModelAndViewContainer; -import org.springframework.web.servlet.ModelAndView; -import org.springframework.web.servlet.view.json.MappingJackson2JsonView; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import org.springframework.web.testfixture.servlet.MockHttpServletResponse; import org.springframework.web.util.WebUtils; @@ -116,7 +117,7 @@ class RequestResponseBodyMethodProcessorTests { this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); this.servletRequest.setContentType(MediaType.APPLICATION_JSON_VALUE); - List> converters = List.of(new MappingJackson2HttpMessageConverter()); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); @SuppressWarnings("unchecked") @@ -151,7 +152,7 @@ class RequestResponseBodyMethodProcessorTests { this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); this.servletRequest.setContentType("application/json"); - List> converters = List.of(new MappingJackson2HttpMessageConverter()); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); SimpleBean result = (SimpleBean) processor.resolveArgument( @@ -207,7 +208,7 @@ class RequestResponseBodyMethodProcessorTests { this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); this.servletRequest.setContentType(MediaType.APPLICATION_JSON_VALUE); - List> converters = List.of(new MappingJackson2HttpMessageConverter()); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); SimpleBean result = (SimpleBean) processor.resolveArgument(methodParam, container, request, factory); @@ -226,7 +227,7 @@ class RequestResponseBodyMethodProcessorTests { this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); this.servletRequest.setContentType(MediaType.APPLICATION_JSON_VALUE); - List> converters = List.of(new MappingJackson2HttpMessageConverter()); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); @SuppressWarnings("unchecked") @@ -246,7 +247,7 @@ class RequestResponseBodyMethodProcessorTests { this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); this.servletRequest.setContentType(MediaType.APPLICATION_JSON_VALUE); - HttpMessageConverter target = new MappingJackson2HttpMessageConverter(); + HttpMessageConverter target = new JacksonJsonHttpMessageConverter(); HttpMessageConverter proxy = ProxyFactory.getProxy(HttpMessageConverter.class, new SingletonTargetSource(target)); List> converters = List.of(proxy); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); @@ -262,7 +263,7 @@ class RequestResponseBodyMethodProcessorTests { this.servletRequest.addHeader("Accept", "text/plain; q=0.5, application/json"); List> converters = - List.of(new MappingJackson2HttpMessageConverter(), new StringHttpMessageConverter()); + List.of(new JacksonJsonHttpMessageConverter(), new StringHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); processor.writeWithMessageConverters("Foo", returnTypeString, request); @@ -331,15 +332,14 @@ class RequestResponseBodyMethodProcessorTests { this.servletRequest.addHeader("Accept", halFormsMediaType + "," + halMediaType); - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.configure(SerializationFeature.INDENT_OUTPUT, true); + JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); SimpleBean simpleBean = new SimpleBean(); simpleBean.setId(12L); simpleBean.setName("Jason"); - MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); - converter.registerObjectMappersForType(SimpleBean.class, map -> map.put(halMediaType, objectMapper)); + JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter(); + converter.registerObjectMappersForType(SimpleBean.class, map -> map.put(halMediaType, mapper)); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(List.of(converter)); MethodParameter returnType = new MethodParameter(getClass().getDeclaredMethod("getSimpleBean"), -1); @@ -380,7 +380,7 @@ class RequestResponseBodyMethodProcessorTests { RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(List.of( - new MappingJackson2HttpMessageConverter(), new MappingJackson2XmlHttpMessageConverter())); + new JacksonJsonHttpMessageConverter(), new JacksonXmlHttpMessageConverter())); MethodParameter returnType = new MethodParameter(getClass().getDeclaredMethod("handleAndReturnProblemDetail"), -1); @@ -393,10 +393,10 @@ class RequestResponseBodyMethodProcessorTests { if (expectedContentType.equals(MediaType.APPLICATION_PROBLEM_XML_VALUE)) { XmlAssert.assertThat(this.servletResponse.getContentAsString()).and(""" - about:blank - Bad Request 400 /path + Bad Request + about:blank """) .ignoreWhitespace() .areIdentical(); @@ -413,6 +413,7 @@ class RequestResponseBodyMethodProcessorTests { } @Test + @Disabled("https://github.com/FasterXML/jackson-dataformat-xml/issues/757") void problemDetailWhenProblemXmlRequested() throws Exception { this.servletRequest.addHeader("Accept", MediaType.APPLICATION_PROBLEM_XML_VALUE); testProblemDetailMediaType(MediaType.APPLICATION_PROBLEM_XML_VALUE); @@ -504,6 +505,25 @@ class RequestResponseBodyMethodProcessorTests { HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodReturnType = handlerMethod.getReturnType(); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); + RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor( + converters, null, List.of(new JsonViewResponseBodyAdvice())); + + Object returnValue = new JacksonController().handleResponseBody(); + processor.handleReturnValue(returnValue, methodReturnType, this.container, this.request); + + assertThat(this.servletResponse.getContentAsString()) + .doesNotContain("\"withView1\":\"with\"") + .contains("\"withView2\":\"with\"") + .doesNotContain("\"withoutView\":\"without\""); + } + + @Test + void jackson2JsonViewWithResponseBodyAndJsonMessageConverter() throws Exception { + Method method = JacksonController.class.getMethod("handleResponseBody"); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodReturnType = handlerMethod.getReturnType(); + List> converters = List.of(new MappingJackson2HttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor( converters, null, List.of(new JsonViewResponseBodyAdvice())); @@ -523,6 +543,25 @@ class RequestResponseBodyMethodProcessorTests { HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodReturnType = handlerMethod.getReturnType(); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); + HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor( + converters, null, List.of(new JsonViewResponseBodyAdvice())); + + Object returnValue = new JacksonController().handleResponseEntity(); + processor.handleReturnValue(returnValue, methodReturnType, this.container, this.request); + + assertThat(this.servletResponse.getContentAsString()) + .doesNotContain("\"withView1\":\"with\"") + .contains("\"withView2\":\"with\"") + .doesNotContain("\"withoutView\":\"without\""); + } + + @Test + void jackson2JsonViewWithResponseEntityAndJsonMessageConverter() throws Exception { + Method method = JacksonController.class.getMethod("handleResponseEntity"); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodReturnType = handlerMethod.getReturnType(); + List> converters = List.of(new MappingJackson2HttpMessageConverter()); HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor( converters, null, List.of(new JsonViewResponseBodyAdvice())); @@ -536,13 +575,13 @@ class RequestResponseBodyMethodProcessorTests { .doesNotContain("\"withoutView\":\"without\""); } - @Test // SPR-12149 + @Test void jacksonJsonViewWithResponseBodyAndXmlMessageConverter() throws Exception { Method method = JacksonController.class.getMethod("handleResponseBody"); HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodReturnType = handlerMethod.getReturnType(); - List> converters = List.of(new MappingJackson2XmlHttpMessageConverter()); + List> converters = List.of(new JacksonXmlHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor( converters, null, List.of(new JsonViewResponseBodyAdvice())); @@ -556,11 +595,49 @@ class RequestResponseBodyMethodProcessorTests { } @Test // SPR-12149 + void jackson2JsonViewWithResponseBodyAndXmlMessageConverter() throws Exception { + Method method = JacksonController.class.getMethod("handleResponseBody"); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodReturnType = handlerMethod.getReturnType(); + + List> converters = List.of(new MappingJackson2XmlHttpMessageConverter()); + RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor( + converters, null, List.of(new JsonViewResponseBodyAdvice())); + + Object returnValue = new JacksonController().handleResponseBody(); + processor.handleReturnValue(returnValue, methodReturnType, this.container, this.request); + + assertThat(this.servletResponse.getContentAsString()) + .doesNotContain("with") + .contains("with") + .doesNotContain("without"); + } + + @Test void jacksonJsonViewWithResponseEntityAndXmlMessageConverter() throws Exception { Method method = JacksonController.class.getMethod("handleResponseEntity"); HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodReturnType = handlerMethod.getReturnType(); + List> converters = List.of(new JacksonXmlHttpMessageConverter()); + HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor( + converters, null, List.of(new JsonViewResponseBodyAdvice())); + + Object returnValue = new JacksonController().handleResponseEntity(); + processor.handleReturnValue(returnValue, methodReturnType, this.container, this.request); + + assertThat(this.servletResponse.getContentAsString()) + .doesNotContain("with") + .contains("with") + .doesNotContain("without"); + } + + @Test // SPR-12149 + void jackson2JsonViewWithResponseEntityAndXmlMessageConverter() throws Exception { + Method method = JacksonController.class.getMethod("handleResponseEntity"); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodReturnType = handlerMethod.getReturnType(); + List> converters = List.of(new MappingJackson2XmlHttpMessageConverter()); HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor( converters, null, List.of(new JsonViewResponseBodyAdvice())); @@ -574,7 +651,7 @@ class RequestResponseBodyMethodProcessorTests { .doesNotContain("without"); } - @Test // SPR-12501 + @Test void resolveArgumentWithJacksonJsonView() throws Exception { String content = "{\"withView1\" : \"with\", \"withView2\" : \"with\", \"withoutView\" : \"without\"}"; this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); @@ -584,7 +661,7 @@ class RequestResponseBodyMethodProcessorTests { HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; - List> converters = List.of(new MappingJackson2HttpMessageConverter()); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor( converters, null, List.of(new JsonViewRequestBodyAdvice())); @@ -598,6 +675,29 @@ class RequestResponseBodyMethodProcessorTests { } @Test // SPR-12501 + void resolveArgumentWithJackson2JsonView() throws Exception { + String content = "{\"withView1\" : \"with\", \"withView2\" : \"with\", \"withoutView\" : \"without\"}"; + this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + this.servletRequest.setContentType(MediaType.APPLICATION_JSON_VALUE); + + Method method = JacksonController.class.getMethod("handleRequestBody", JacksonViewBean.class); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; + + List> converters = List.of(new MappingJackson2HttpMessageConverter()); + RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor( + converters, null, List.of(new JsonViewRequestBodyAdvice())); + + JacksonViewBean result = (JacksonViewBean) + processor.resolveArgument(methodParameter, this.container, this.request, this.factory); + + assertThat(result).isNotNull(); + assertThat(result.getWithView1()).isEqualTo("with"); + assertThat(result.getWithView2()).isNull(); + assertThat(result.getWithoutView()).isNull(); + } + + @Test void resolveHttpEntityArgumentWithJacksonJsonView() throws Exception { String content = "{\"withView1\" : \"with\", \"withView2\" : \"with\", \"withoutView\" : \"without\"}"; this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); @@ -607,7 +707,7 @@ class RequestResponseBodyMethodProcessorTests { HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; - List> converters = List.of(new MappingJackson2HttpMessageConverter()); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor( converters, null, List.of(new JsonViewRequestBodyAdvice())); @@ -623,6 +723,31 @@ class RequestResponseBodyMethodProcessorTests { } @Test // SPR-12501 + void resolveHttpEntityArgumentWithJackson2JsonView() throws Exception { + String content = "{\"withView1\" : \"with\", \"withView2\" : \"with\", \"withoutView\" : \"without\"}"; + this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + this.servletRequest.setContentType(MediaType.APPLICATION_JSON_VALUE); + + Method method = JacksonController.class.getMethod("handleHttpEntity", HttpEntity.class); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; + + List> converters = List.of(new MappingJackson2HttpMessageConverter()); + HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor( + converters, null, List.of(new JsonViewRequestBodyAdvice())); + + @SuppressWarnings("unchecked") + HttpEntity result = (HttpEntity) + processor.resolveArgument( methodParameter, this.container, this.request, this.factory); + + assertThat(result).isNotNull(); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getWithView1()).isEqualTo("with"); + assertThat(result.getBody().getWithView2()).isNull(); + assertThat(result.getBody().getWithoutView()).isNull(); + } + + @Test void resolveArgumentWithJacksonJsonViewAndXmlMessageConverter() throws Exception { String content = "" + "with" + @@ -635,7 +760,7 @@ class RequestResponseBodyMethodProcessorTests { HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; - List> converters = List.of(new MappingJackson2XmlHttpMessageConverter()); + List> converters = List.of(new JacksonXmlHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor( converters, null, List.of(new JsonViewRequestBodyAdvice())); @@ -649,6 +774,32 @@ class RequestResponseBodyMethodProcessorTests { } @Test // SPR-12501 + void resolveArgumentWithJackson2JsonViewAndXmlMessageConverter() throws Exception { + String content = "" + + "with" + + "with" + + "without"; + this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + this.servletRequest.setContentType(MediaType.APPLICATION_XML_VALUE); + + Method method = JacksonController.class.getMethod("handleRequestBody", JacksonViewBean.class); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; + + List> converters = List.of(new MappingJackson2XmlHttpMessageConverter()); + RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor( + converters, null, List.of(new JsonViewRequestBodyAdvice())); + + JacksonViewBean result = (JacksonViewBean) + processor.resolveArgument(methodParameter, this.container, this.request, this.factory); + + assertThat(result).isNotNull(); + assertThat(result.getWithView1()).isEqualTo("with"); + assertThat(result.getWithView2()).isNull(); + assertThat(result.getWithoutView()).isNull(); + } + + @Test void resolveHttpEntityArgumentWithJacksonJsonViewAndXmlMessageConverter() throws Exception { String content = "" + "with" + @@ -661,6 +812,34 @@ class RequestResponseBodyMethodProcessorTests { HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; + List> converters = List.of(new JacksonXmlHttpMessageConverter()); + HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor( + converters, null, List.of(new JsonViewRequestBodyAdvice())); + + @SuppressWarnings("unchecked") + HttpEntity result = (HttpEntity) + processor.resolveArgument(methodParameter, this.container, this.request, this.factory); + + assertThat(result).isNotNull(); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getWithView1()).isEqualTo("with"); + assertThat(result.getBody().getWithView2()).isNull(); + assertThat(result.getBody().getWithoutView()).isNull(); + } + + @Test // SPR-12501 + void resolveHttpEntityArgumentWithJackson2JsonViewAndXmlMessageConverter() throws Exception { + String content = "" + + "with" + + "with" + + "without"; + this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8)); + this.servletRequest.setContentType(MediaType.APPLICATION_XML_VALUE); + + Method method = JacksonController.class.getMethod("handleHttpEntity", HttpEntity.class); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; + List> converters = List.of(new MappingJackson2XmlHttpMessageConverter()); HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor( converters, null, List.of(new JsonViewRequestBodyAdvice())); @@ -676,12 +855,29 @@ class RequestResponseBodyMethodProcessorTests { assertThat(result.getBody().getWithoutView()).isNull(); } - @Test // SPR-12811 + @Test void jacksonTypeInfoList() throws Exception { Method method = JacksonController.class.getMethod("handleTypeInfoList"); HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodReturnType = handlerMethod.getReturnType(); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); + RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); + + Object returnValue = new JacksonController().handleTypeInfoList(); + processor.handleReturnValue(returnValue, methodReturnType, this.container, this.request); + + assertThat(this.servletResponse.getContentAsString()) + .contains("\"type\":\"foo\"") + .contains("\"type\":\"bar\""); + } + + @Test // SPR-12811 + void jackson2TypeInfoList() throws Exception { + Method method = JacksonController.class.getMethod("handleTypeInfoList"); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodReturnType = handlerMethod.getReturnType(); + List> converters = List.of(new MappingJackson2HttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); @@ -693,13 +889,13 @@ class RequestResponseBodyMethodProcessorTests { .contains("\"type\":\"bar\""); } - @Test // SPR-13318 + @Test void jacksonSubType() throws Exception { Method method = JacksonController.class.getMethod("handleSubType"); HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodReturnType = handlerMethod.getReturnType(); - List> converters = List.of(new MappingJackson2HttpMessageConverter()); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); Object returnValue = new JacksonController().handleSubType(); @@ -711,11 +907,47 @@ class RequestResponseBodyMethodProcessorTests { } @Test // SPR-13318 + void jackson2SubType() throws Exception { + Method method = JacksonController.class.getMethod("handleSubType"); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodReturnType = handlerMethod.getReturnType(); + + List> converters = List.of(new MappingJackson2HttpMessageConverter()); + RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); + + Object returnValue = new JacksonController().handleSubType(); + processor.handleReturnValue(returnValue, methodReturnType, this.container, this.request); + + assertThat(this.servletResponse.getContentAsString()) + .contains("\"id\":123") + .contains("\"name\":\"foo\""); + } + + @Test void jacksonSubTypeList() throws Exception { Method method = JacksonController.class.getMethod("handleSubTypeList"); HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); MethodParameter methodReturnType = handlerMethod.getReturnType(); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); + RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); + + Object returnValue = new JacksonController().handleSubTypeList(); + processor.handleReturnValue(returnValue, methodReturnType, this.container, this.request); + + assertThat(this.servletResponse.getContentAsString()) + .contains("\"id\":123") + .contains("\"name\":\"foo\"") + .contains("\"id\":456") + .contains("\"name\":\"bar\""); + } + + @Test // SPR-13318 + void jackson2SubTypeList() throws Exception { + Method method = JacksonController.class.getMethod("handleSubTypeList"); + HandlerMethod handlerMethod = new HandlerMethod(new JacksonController(), method); + MethodParameter methodReturnType = handlerMethod.getReturnType(); + List> converters = List.of(new MappingJackson2HttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); @@ -738,7 +970,7 @@ class RequestResponseBodyMethodProcessorTests { HandlerMethod handlerMethod = new HandlerMethod(new MyControllerImplementingInterface(), method); MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; - List> converters = List.of(new MappingJackson2HttpMessageConverter()); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); assertThat(processor.supportsParameter(methodParameter)).isTrue(); @@ -756,7 +988,7 @@ class RequestResponseBodyMethodProcessorTests { HandlerMethod handlerMethod = new HandlerMethod(new SubControllerImplementingInterface(), method); MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; - List> converters = List.of(new MappingJackson2HttpMessageConverter()); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); assertThat(processor.supportsParameter(methodParameter)).isTrue(); @@ -774,7 +1006,7 @@ class RequestResponseBodyMethodProcessorTests { HandlerMethod handlerMethod = new HandlerMethod(new SubControllerImplementingAbstractMethod(), method); MethodParameter methodParameter = handlerMethod.getMethodParameters()[0]; - List> converters = List.of(new MappingJackson2HttpMessageConverter()); + List> converters = List.of(new JacksonJsonHttpMessageConverter()); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters); assertThat(processor.supportsParameter(methodParameter)).isTrue(); @@ -1068,8 +1300,6 @@ class RequestResponseBodyMethodProcessorTests { bean.setWithView1("with"); bean.setWithView2("with"); bean.setWithoutView("without"); - ModelAndView mav = new ModelAndView(new MappingJackson2JsonView()); - mav.addObject("bean", bean); return new ResponseEntity<>(bean, HttpStatus.OK); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java index 1d7e1565e7c..158719595b7 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java @@ -35,7 +35,7 @@ import reactor.core.scheduler.Schedulers; import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.async.AsyncWebRequest; @@ -63,7 +63,7 @@ import static org.springframework.web.testfixture.method.ResolvableMethod.on; class ResponseBodyEmitterReturnValueHandlerTests { private final ResponseBodyEmitterReturnValueHandler handler = - new ResponseBodyEmitterReturnValueHandler(List.of(new MappingJackson2HttpMessageConverter())); + new ResponseBodyEmitterReturnValueHandler(List.of(new JacksonJsonHttpMessageConverter())); private final MockHttpServletRequest request = new MockHttpServletRequest(); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletInvocableHandlerMethodTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletInvocableHandlerMethodTests.java index 03ac4e60fff..8620e1aff30 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletInvocableHandlerMethodTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletInvocableHandlerMethodTests.java @@ -41,7 +41,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.http.converter.StringHttpMessageConverter; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.web.accept.ContentNegotiationManager; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @@ -373,7 +373,7 @@ class ServletInvocableHandlerMethodTests { @Test void wrapConcurrentResult_CollectedValuesList() throws Exception { - List> converters = Collections.singletonList(new MappingJackson2HttpMessageConverter()); + List> converters = Collections.singletonList(new JacksonJsonHttpMessageConverter()); ResolvableType elementType = ResolvableType.forClass(List.class); ReactiveTypeHandler.CollectedValuesList result = new ReactiveTypeHandler.CollectedValuesList(elementType); result.add(Arrays.asList("foo1", "bar1")); @@ -391,7 +391,7 @@ class ServletInvocableHandlerMethodTests { @Test // SPR-15478 public void wrapConcurrentResult_CollectedValuesListWithResponseEntity() throws Exception { - List> converters = Collections.singletonList(new MappingJackson2HttpMessageConverter()); + List> converters = Collections.singletonList(new JacksonJsonHttpMessageConverter()); ResolvableType elementType = ResolvableType.forClass(Bar.class); ReactiveTypeHandler.CollectedValuesList result = new ReactiveTypeHandler.CollectedValuesList(elementType); result.add(new Bar("foo"));