diff --git a/spring-web/spring-web.gradle b/spring-web/spring-web.gradle index 30d45a1ed5f..adcebcbff79 100644 --- a/spring-web/spring-web.gradle +++ b/spring-web/spring-web.gradle @@ -57,6 +57,12 @@ dependencies { optional("org.jetbrains.kotlinx:kotlinx-serialization-cbor") optional("org.jetbrains.kotlinx:kotlinx-serialization-json") optional("org.jetbrains.kotlinx:kotlinx-serialization-protobuf") + 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") optional("com.fasterxml:aalto-xml") // out of order to avoid XML parser override testFixturesApi("jakarta.servlet:jakarta.servlet-api") testFixturesApi("org.junit.jupiter:junit-jupiter-api") @@ -89,6 +95,7 @@ dependencies { testImplementation("org.skyscreamer:jsonassert") testImplementation("org.xmlunit:xmlunit-assertj") testImplementation("org.xmlunit:xmlunit-matchers") + testImplementation("tools.jackson.module:jackson-module-kotlin") testRuntimeOnly("com.sun.xml.bind:jaxb-core") testRuntimeOnly("com.sun.xml.bind:jaxb-impl") testRuntimeOnly("jakarta.json:jakarta.json-api") diff --git a/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonDecoder.java new file mode 100644 index 00000000000..4a742348cd5 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonDecoder.java @@ -0,0 +1,298 @@ +/* + * 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.codec; + +import java.lang.annotation.Annotation; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.ContextView; +import tools.jackson.core.JacksonException; +import tools.jackson.core.exc.JacksonIOException; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectReader; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.exc.InvalidDefinitionException; +import tools.jackson.databind.util.TokenBuffer; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.CodecException; +import org.springframework.core.codec.DecodingException; +import org.springframework.core.codec.Hints; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.PooledDataBuffer; +import org.springframework.core.log.LogFormatUtils; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; + +/** + * Abstract base class for Jackson 3.x decoding, leveraging non-blocking parsing. + * + * @author Sebastien Deleuze + * @since 7.0 + */ +public abstract class AbstractJacksonDecoder extends JacksonCodecSupport implements HttpMessageDecoder { + + private int maxInMemorySize = 256 * 1024; + + + /** + * 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 MimeType}s. + */ + protected AbstractJacksonDecoder(MapperBuilder builder, MimeType... mimeTypes) { + super(builder, mimeTypes); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper} and {@link MimeType}s. + */ + protected AbstractJacksonDecoder(ObjectMapper mapper, MimeType... mimeTypes) { + super(mapper, mimeTypes); + } + + /** + * Set the max number of bytes that can be buffered by this decoder. This + * is either the size of the entire input when decoding as a whole, or the + * size of one top-level JSON object within a JSON stream. When the limit + * is exceeded, {@link DataBufferLimitException} is raised. + *

By default this is set to 256K. + * @param byteCount the max number of bytes to buffer, or -1 for unlimited + */ + public void setMaxInMemorySize(int byteCount) { + this.maxInMemorySize = byteCount; + } + + /** + * Return the {@link #setMaxInMemorySize configured} byte count limit. + */ + public int getMaxInMemorySize() { + return this.maxInMemorySize; + } + + + @Override + public boolean canDecode(ResolvableType elementType, @Nullable MimeType mimeType) { + if (!supportsMimeType(mimeType)) { + return false; + } + ObjectMapper mapper = selectObjectMapper(elementType, mimeType); + if (mapper == null) { + return false; + } + return !CharSequence.class.isAssignableFrom(elementType.toClass()); + } + + @Override + public Flux decode(Publisher input, ResolvableType elementType, + @Nullable MimeType mimeType, @Nullable Map hints) { + + ObjectMapper mapper = selectObjectMapper(elementType, mimeType); + if (mapper == null) { + return Flux.error(new IllegalStateException("No ObjectMapper for " + elementType)); + } + + boolean forceUseOfBigDecimal = mapper.isEnabled(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + if (BigDecimal.class.equals(elementType.getType())) { + forceUseOfBigDecimal = true; + } + + boolean tokenizeArrays = (!elementType.isArray() && + !Collection.class.isAssignableFrom(elementType.resolve(Object.class))); + + Flux processed = processInput(input, elementType, mimeType, hints); + Flux tokens = JacksonTokenizer.tokenize(processed, mapper, + tokenizeArrays, forceUseOfBigDecimal, getMaxInMemorySize()); + + return Flux.deferContextual(contextView -> { + + Map hintsToUse = contextView.isEmpty() ? hints : + Hints.merge(hints, ContextView.class.getName(), contextView); + + ObjectReader reader = createObjectReader(mapper, elementType, hintsToUse); + + return tokens.handle((tokenBuffer, sink) -> { + try { + Object value = reader.readValue(tokenBuffer.asParser(getObjectMapper()._deserializationContext())); + logValue(value, hints); + if (value != null) { + sink.next(value); + } + } + catch (JacksonException ex) { + sink.error(processException(ex)); + } + }) + .doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release); + }); + } + + /** + * Process the input publisher into a flux. Default implementation returns + * {@link Flux#from(Publisher)}, but subclasses can choose to customize + * this behavior. + * @param input the {@code DataBuffer} input stream to process + * @param elementType the expected type of elements in the output stream + * @param mimeType the MIME type associated with the input stream (optional) + * @param hints additional information about how to do encode + * @return the processed flux + */ + protected Flux processInput(Publisher input, ResolvableType elementType, + @Nullable MimeType mimeType, @Nullable Map hints) { + + return Flux.from(input); + } + + @Override + public Mono decodeToMono(Publisher input, ResolvableType elementType, + @Nullable MimeType mimeType, @Nullable Map hints) { + + return Mono.deferContextual(contextView -> { + + Map hintsToUse = contextView.isEmpty() ? hints : + Hints.merge(hints, ContextView.class.getName(), contextView); + + return DataBufferUtils.join(input, this.maxInMemorySize).flatMap(dataBuffer -> + Mono.justOrEmpty(decode(dataBuffer, elementType, mimeType, hintsToUse))); + }); + } + + @Override + public Object decode(DataBuffer dataBuffer, ResolvableType targetType, + @Nullable MimeType mimeType, @Nullable Map hints) throws DecodingException { + + ObjectMapper mapper = selectObjectMapper(targetType, mimeType); + if (mapper == null) { + throw new IllegalStateException("No ObjectMapper for " + targetType); + } + + try { + ObjectReader objectReader = createObjectReader(mapper, targetType, hints); + Object value = objectReader.readValue(dataBuffer.asInputStream()); + logValue(value, hints); + return value; + } + catch (JacksonException ex) { + throw processException(ex); + } + finally { + DataBufferUtils.release(dataBuffer); + } + } + + private ObjectReader createObjectReader( + ObjectMapper mapper, ResolvableType elementType, @Nullable Map hints) { + + Assert.notNull(elementType, "'elementType' must not be null"); + Class contextClass = getContextClass(elementType); + if (contextClass == null && hints != null) { + contextClass = getContextClass((ResolvableType) hints.get(ACTUAL_TYPE_HINT)); + } + JavaType javaType = getJavaType(elementType.getType(), contextClass); + Class jsonView = (hints != null ? (Class) hints.get(JacksonCodecSupport.JSON_VIEW_HINT) : null); + + ObjectReader objectReader = (jsonView != null ? + mapper.readerWithView(jsonView).forType(javaType) : + mapper.readerFor(javaType)); + + return customizeReader(objectReader, elementType, hints); + } + + /** + * Subclasses can use this method to customize {@link ObjectReader} used + * for reading values. + * @param reader the reader instance to customize + * @param elementType the target type of element values to read to + * @param hints a map with serialization hints; + * the Reactor Context, when available, may be accessed under the key + * {@code ContextView.class.getName()} + * @return the customized {@code ObjectReader} to use + */ + protected ObjectReader customizeReader( + ObjectReader reader, ResolvableType elementType, @Nullable Map hints) { + + return reader; + } + + private @Nullable Class getContextClass(@Nullable ResolvableType elementType) { + MethodParameter param = (elementType != null ? getParameter(elementType) : null); + return (param != null ? param.getContainingClass() : null); + } + + private void logValue(@Nullable Object value, @Nullable Map hints) { + if (!Hints.isLoggingSuppressed(hints)) { + LogFormatUtils.traceDebug(logger, traceOn -> { + String formatted = LogFormatUtils.formatValue(value, !traceOn); + return Hints.getLogPrefix(hints) + "Decoded [" + formatted + "]"; + }); + } + } + + private CodecException processException(JacksonException ex) { + if (ex instanceof InvalidDefinitionException ide) { + JavaType type = ide.getType(); + return new CodecException("Type definition error: " + type, ex); + } + if (ex instanceof JacksonIOException) { + return new DecodingException("I/O error while parsing input stream", ex); + } + String originalMessage = ex.getOriginalMessage(); + return new DecodingException("JSON decoding error: " + originalMessage, ex); + } + + + // HttpMessageDecoder + + @Override + public Map getDecodeHints(ResolvableType actualType, ResolvableType elementType, + ServerHttpRequest request, ServerHttpResponse response) { + + return getHints(actualType); + } + + @Override + public List getDecodableMimeTypes() { + return getMimeTypes(); + } + + @Override + public List getDecodableMimeTypes(ResolvableType targetType) { + return getMimeTypes(targetType); + } + + // JacksonCodecSupport + + @Override + protected @Nullable A getAnnotation(MethodParameter parameter, Class annotType) { + return parameter.getParameterAnnotation(annotType); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonEncoder.java new file mode 100644 index 00000000000..01efc689d39 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonEncoder.java @@ -0,0 +1,444 @@ +/* + * 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.codec; + +import java.lang.annotation.Annotation; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.ContextView; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonEncoding; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.exc.JacksonIOException; +import tools.jackson.core.util.ByteArrayBuilder; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.SequenceWriter; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.exc.InvalidDefinitionException; +import tools.jackson.databind.ser.FilterProvider; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.CodecException; +import org.springframework.core.codec.EncodingException; +import org.springframework.core.codec.Hints; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.log.LogFormatUtils; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJacksonValue; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; + +/** + * Base class providing support methods for Jackson 3.x encoding. For non-streaming use + * cases, {@link Flux} elements are collected into a {@link List} before serialization for + * performance reasons. + * + * @author Sebastien Deleuze + * @since 7.0 + */ +public abstract class AbstractJacksonEncoder extends JacksonCodecSupport implements HttpMessageEncoder { + + private static final byte[] NEWLINE_SEPARATOR = {'\n'}; + + private static final byte[] EMPTY_BYTES = new byte[0]; + + private static final Map ENCODINGS; + + static { + ENCODINGS = CollectionUtils.newHashMap(JsonEncoding.values().length); + for (JsonEncoding encoding : JsonEncoding.values()) { + ENCODINGS.put(encoding.getJavaName(), encoding); + } + ENCODINGS.put("US-ASCII", JsonEncoding.UTF8); + } + + + private final List streamingMediaTypes = new ArrayList<>(1); + + + /** + * 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 MimeType}s. + */ + protected AbstractJacksonEncoder(MapperBuilder builder, MimeType... mimeTypes) { + super(builder, mimeTypes); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper} and {@link MimeType}s. + */ + protected AbstractJacksonEncoder(ObjectMapper mapper, MimeType... mimeTypes) { + super(mapper, mimeTypes); + } + + /** + * Configure "streaming" media types for which flushing should be performed + * automatically vs at the end of the stream. + */ + public void setStreamingMediaTypes(List mediaTypes) { + this.streamingMediaTypes.clear(); + this.streamingMediaTypes.addAll(mediaTypes); + } + + @Override + public boolean canEncode(ResolvableType elementType, @Nullable MimeType mimeType) { + if (!supportsMimeType(mimeType)) { + return false; + } + if (mimeType != null && mimeType.getCharset() != null) { + Charset charset = mimeType.getCharset(); + if (!ENCODINGS.containsKey(charset.name())) { + return false; + } + } + if (this.objectMapperRegistrations != null && selectObjectMapper(elementType, mimeType) == null) { + return false; + } + Class clazz = elementType.resolve(); + if (clazz == null) { + return true; + } + if (MappingJacksonValue.class.isAssignableFrom(elementType.resolve(clazz))) { + throw new UnsupportedOperationException("MappingJacksonValue is not supported, use hints instead"); + } + return !String.class.isAssignableFrom(elementType.resolve(clazz)); + } + + @Override + public Flux encode(Publisher inputStream, DataBufferFactory bufferFactory, + ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map hints) { + + Assert.notNull(inputStream, "'inputStream' must not be null"); + Assert.notNull(bufferFactory, "'bufferFactory' must not be null"); + Assert.notNull(elementType, "'elementType' must not be null"); + + return Flux.deferContextual(contextView -> { + + Map hintsToUse = contextView.isEmpty() ? hints : + Hints.merge(hints, ContextView.class.getName(), contextView); + + if (inputStream instanceof Mono) { + return Mono.from(inputStream) + .map(value -> encodeValue(value, bufferFactory, elementType, mimeType, hintsToUse)) + .flux(); + } + + try { + ObjectMapper mapper = selectObjectMapper(elementType, mimeType); + if (mapper == null) { + throw new IllegalStateException("No ObjectMapper for " + elementType); + } + + ObjectWriter writer = createObjectWriter(mapper, elementType, mimeType, null, hintsToUse); + ByteArrayBuilder byteBuilder = new ByteArrayBuilder(writer.generatorFactory()._getBufferRecycler()); + JsonEncoding encoding = getJsonEncoding(mimeType); + JsonGenerator generator = mapper.createGenerator(byteBuilder, encoding); + SequenceWriter sequenceWriter = writer.writeValues(generator); + + byte[] separator = getStreamingMediaTypeSeparator(mimeType); + Flux dataBufferFlux; + + if (separator != null) { + dataBufferFlux = Flux.from(inputStream).map(value -> encodeStreamingValue( + value, bufferFactory, hintsToUse, sequenceWriter, byteBuilder, EMPTY_BYTES, separator)); + } + else { + JsonArrayJoinHelper helper = new JsonArrayJoinHelper(); + + // Do not prepend JSON array prefix until first signal is known, onNext vs onError + // Keeps response not committed for error handling + + dataBufferFlux = Flux.from(inputStream) + .map(value -> { + byte[] prefix = helper.getPrefix(); + byte[] delimiter = helper.getDelimiter(); + + DataBuffer dataBuffer = encodeStreamingValue( + value, bufferFactory, hintsToUse, sequenceWriter, byteBuilder, + delimiter, EMPTY_BYTES); + + return (prefix.length > 0 ? + bufferFactory.join(List.of(bufferFactory.wrap(prefix), dataBuffer)) : + dataBuffer); + }) + .switchIfEmpty(Mono.fromCallable(() -> bufferFactory.wrap(helper.getPrefix()))) + .concatWith(Mono.fromCallable(() -> bufferFactory.wrap(helper.getSuffix()))); + } + + return dataBufferFlux + .doOnNext(dataBuffer -> Hints.touchDataBuffer(dataBuffer, hintsToUse, logger)) + .doAfterTerminate(() -> { + try { + generator.close(); + byteBuilder.release(); + } + catch (JacksonIOException ex) { + logger.error("Could not close Encoder resources", ex); + } + }); + } + catch (JacksonIOException ex) { + return Flux.error(ex); + } + }); + } + + @Override + public DataBuffer encodeValue(Object value, DataBufferFactory bufferFactory, + ResolvableType valueType, @Nullable MimeType mimeType, @Nullable Map hints) { + + Class jsonView = null; + FilterProvider filters = null; + if (hints != null) { + jsonView = (Class) hints.get(JSON_VIEW_HINT); + filters = (FilterProvider) hints.get(FILTER_PROVIDER_HINT); + } + + ObjectMapper mapper = selectObjectMapper(valueType, mimeType); + if (mapper == null) { + throw new IllegalStateException("No ObjectMapper for " + valueType); + } + + ObjectWriter writer = createObjectWriter(mapper, valueType, mimeType, jsonView, hints); + if (filters != null) { + writer = writer.with(filters); + } + + ByteArrayBuilder byteBuilder = new ByteArrayBuilder(writer.generatorFactory()._getBufferRecycler()); + try { + JsonEncoding encoding = getJsonEncoding(mimeType); + + logValue(hints, value); + + try (JsonGenerator generator = writer.createGenerator(byteBuilder, encoding)) { + writer.writeValue(generator, value); + generator.flush(); + } + catch (InvalidDefinitionException ex) { + throw new CodecException("Type definition error: " + ex.getType(), ex); + } + catch (JacksonException ex) { + throw new EncodingException("JSON encoding error: " + ex.getOriginalMessage(), ex); + } + + byte[] bytes = byteBuilder.toByteArray(); + DataBuffer buffer = bufferFactory.allocateBuffer(bytes.length); + buffer.write(bytes); + Hints.touchDataBuffer(buffer, hints, logger); + + return buffer; + } + finally { + byteBuilder.release(); + } + } + + private DataBuffer encodeStreamingValue( + Object value, DataBufferFactory bufferFactory, @Nullable Map hints, + SequenceWriter sequenceWriter, ByteArrayBuilder byteArrayBuilder, + byte[] prefix, byte[] suffix) { + + logValue(hints, value); + + try { + sequenceWriter.write(value); + sequenceWriter.flush(); + } + catch (InvalidDefinitionException ex) { + throw new CodecException("Type definition error: " + ex.getType(), ex); + } + catch (JacksonException ex) { + throw new EncodingException("JSON encoding error: " + ex.getOriginalMessage(), ex); + } + + byte[] bytes = byteArrayBuilder.toByteArray(); + byteArrayBuilder.reset(); + + int offset; + int length; + if (bytes.length > 0 && bytes[0] == ' ') { + // SequenceWriter writes an unnecessary space in between values + offset = 1; + length = bytes.length - 1; + } + else { + offset = 0; + length = bytes.length; + } + DataBuffer buffer = bufferFactory.allocateBuffer(length + prefix.length + suffix.length); + if (prefix.length != 0) { + buffer.write(prefix); + } + buffer.write(bytes, offset, length); + if (suffix.length != 0) { + buffer.write(suffix); + } + Hints.touchDataBuffer(buffer, hints, logger); + + return buffer; + } + + private void logValue(@Nullable Map hints, Object value) { + if (!Hints.isLoggingSuppressed(hints)) { + LogFormatUtils.traceDebug(logger, traceOn -> { + String formatted = LogFormatUtils.formatValue(value, !traceOn); + return Hints.getLogPrefix(hints) + "Encoding [" + formatted + "]"; + }); + } + } + + private ObjectWriter createObjectWriter( + ObjectMapper mapper, ResolvableType valueType, @Nullable MimeType mimeType, + @Nullable Class jsonView, @Nullable Map hints) { + + JavaType javaType = getJavaType(valueType.getType(), null); + if (jsonView == null && hints != null) { + jsonView = (Class) hints.get(JacksonCodecSupport.JSON_VIEW_HINT); + } + ObjectWriter writer = (jsonView != null ? mapper.writerWithView(jsonView) : mapper.writer()); + if (javaType.isContainerType()) { + writer = writer.forType(javaType); + } + return customizeWriter(writer, mimeType, valueType, hints); + } + + /** + * Subclasses can use this method to customize the {@link ObjectWriter} used + * for writing values. + * @param writer the writer instance to customize + * @param mimeType the selected MIME type + * @param elementType the type of element values to write + * @param hints a map with serialization hints; the Reactor Context, when + * available, may be accessed under the key + * {@code ContextView.class.getName()} + * @return the customized {@code ObjectWriter} to use + */ + protected ObjectWriter customizeWriter(ObjectWriter writer, @Nullable MimeType mimeType, + ResolvableType elementType, @Nullable Map hints) { + + return writer; + } + + /** + * Return the separator to use for the given mime type. + *

By default, this method returns new line {@code "\n"} if the given + * mime type is one of the configured {@link #setStreamingMediaTypes(List) + * streaming} mime types. + */ + protected byte @Nullable [] getStreamingMediaTypeSeparator(@Nullable MimeType mimeType) { + for (MediaType streamingMediaType : this.streamingMediaTypes) { + if (streamingMediaType.isCompatibleWith(mimeType)) { + return NEWLINE_SEPARATOR; + } + } + return null; + } + + /** + * Determine the JSON encoding to use for the given mime type. + * @param mimeType the mime type as requested by the caller + * @return the JSON encoding to use (never {@code null}) + */ + protected JsonEncoding getJsonEncoding(@Nullable MimeType mimeType) { + if (mimeType != null && mimeType.getCharset() != null) { + Charset charset = mimeType.getCharset(); + JsonEncoding result = ENCODINGS.get(charset.name()); + if (result != null) { + return result; + } + } + return JsonEncoding.UTF8; + } + + + // HttpMessageEncoder + + @Override + public List getEncodableMimeTypes() { + return getMimeTypes(); + } + + @Override + public List getEncodableMimeTypes(ResolvableType elementType) { + return getMimeTypes(elementType); + } + + @Override + public List getStreamingMediaTypes() { + return Collections.unmodifiableList(this.streamingMediaTypes); + } + + @Override + public Map getEncodeHints(@Nullable ResolvableType actualType, ResolvableType elementType, + @Nullable MediaType mediaType, ServerHttpRequest request, ServerHttpResponse response) { + + return (actualType != null ? getHints(actualType) : Hints.none()); + } + + + // JacksonCodecSupport + + @Override + protected @Nullable A getAnnotation(MethodParameter parameter, Class annotType) { + return parameter.getMethodAnnotation(annotType); + } + + + private static class JsonArrayJoinHelper { + + private static final byte[] COMMA_SEPARATOR = {','}; + + private static final byte[] OPEN_BRACKET = {'['}; + + private static final byte[] CLOSE_BRACKET = {']'}; + + private boolean firstItemEmitted; + + public byte[] getDelimiter() { + if (this.firstItemEmitted) { + return COMMA_SEPARATOR; + } + this.firstItemEmitted = true; + return EMPTY_BYTES; + } + + public byte[] getPrefix() { + return (this.firstItemEmitted ? EMPTY_BYTES : OPEN_BRACKET); + } + + public byte[] getSuffix() { + return CLOSE_BRACKET; + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java b/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java index 95add785f17..1d2ead56ce4 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java +++ b/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java @@ -23,6 +23,8 @@ import org.jspecify.annotations.Nullable; import org.springframework.core.codec.Decoder; import org.springframework.core.codec.Encoder; +import org.springframework.http.codec.smile.JacksonSmileDecoder; +import org.springframework.http.codec.smile.JacksonSmileEncoder; /** * Defines a common interface for configuring either client or server HTTP @@ -109,7 +111,16 @@ public interface CodecConfigurer { interface DefaultCodecs { /** - * Override the default Jackson JSON {@code Decoder}. + * Override the default Jackson 3.x JSON {@code Decoder}. + *

Note that {@link #maxInMemorySize(int)}, if configured, will be + * applied to the given decoder. + * @param decoder the decoder instance to use + * @see org.springframework.http.codec.json.JacksonJsonDecoder + */ + void jacksonJsonDecoder(Decoder decoder); + + /** + * Override the default Jackson 2.x JSON {@code Decoder}. *

Note that {@link #maxInMemorySize(int)}, if configured, will be * applied to the given decoder. * @param decoder the decoder instance to use @@ -118,14 +129,30 @@ public interface CodecConfigurer { void jackson2JsonDecoder(Decoder decoder); /** - * Override the default Jackson JSON {@code Encoder}. + * Override the default Jackson 3.x JSON {@code Encoder}. + * @param encoder the encoder instance to use + * @see org.springframework.http.codec.json.JacksonJsonEncoder + */ + void jacksonJsonEncoder(Encoder encoder); + + /** + * Override the default Jackson 2.x JSON {@code Encoder}. * @param encoder the encoder instance to use * @see org.springframework.http.codec.json.Jackson2JsonEncoder */ void jackson2JsonEncoder(Encoder encoder); /** - * Override the default Jackson Smile {@code Decoder}. + * Override the default Jackson 3.x Smile {@code Decoder}. + *

Note that {@link #maxInMemorySize(int)}, if configured, will be + * applied to the given decoder. + * @param decoder the decoder instance to use + * @see JacksonSmileDecoder + */ + void jacksonSmileDecoder(Decoder decoder); + + /** + * Override the default Jackson 2.x Smile {@code Decoder}. *

Note that {@link #maxInMemorySize(int)}, if configured, will be * applied to the given decoder. * @param decoder the decoder instance to use @@ -134,7 +161,14 @@ public interface CodecConfigurer { void jackson2SmileDecoder(Decoder decoder); /** - * Override the default Jackson Smile {@code Encoder}. + * Override the default Jackson 3.x Smile {@code Encoder}. + * @param encoder the encoder instance to use + * @see JacksonSmileEncoder + */ + void jacksonSmileEncoder(Encoder encoder); + + /** + * Override the default Jackson 2.x Smile {@code Encoder}. * @param encoder the encoder instance to use * @see org.springframework.http.codec.json.Jackson2SmileEncoder */ diff --git a/spring-web/src/main/java/org/springframework/http/codec/JacksonCodecSupport.java b/spring-web/src/main/java/org/springframework/http/codec/JacksonCodecSupport.java new file mode 100644 index 00000000000..9d307da7b52 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/JacksonCodecSupport.java @@ -0,0 +1,275 @@ +/* + * 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.codec; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +import com.fasterxml.jackson.annotation.JsonView; +import org.apache.commons.logging.Log; +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.ser.FilterProvider; + +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Hints; +import org.springframework.http.HttpLogging; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; + +/** + * Base class providing support methods for Jackson 2.x encoding and decoding. + * + * @author Sebastien Deleuze + * @since 7.0 + */ +public abstract class JacksonCodecSupport { + + /** + * The key for the hint to specify a "JSON View" for encoding or decoding + * with the value expected to be a {@link Class}. + */ + public static final String JSON_VIEW_HINT = JsonView.class.getName(); + + /** + * The key for the hint to specify a {@link FilterProvider}. + */ + public static final String FILTER_PROVIDER_HINT = FilterProvider.class.getName(); + + /** + * The key for the hint to access the actual ResolvableType passed into + * {@link org.springframework.http.codec.HttpMessageReader#read(ResolvableType, ResolvableType, ServerHttpRequest, ServerHttpResponse, Map)} + * (server-side only). Currently set when the method argument has generics because + * in case of reactive types, use of {@code ResolvableType.getGeneric()} means no + * MethodParameter source and no knowledge of the containing class. + */ + static final String ACTUAL_TYPE_HINT = JacksonCodecSupport.class.getName() + ".actualType"; + + private static final String JSON_VIEW_HINT_ERROR = + "@JsonView only supported for write hints with exactly 1 class argument: "; + + + protected final Log logger = HttpLogging.forLogName(getClass()); + + private final ObjectMapper defaultObjectMapper; + + protected @Nullable Map, Map> objectMapperRegistrations; + + private final List mimeTypes; + + private static volatile @Nullable List modules = null; + + /** + * 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 MimeType}s. + */ + protected JacksonCodecSupport(MapperBuilder builder, MimeType... mimeTypes) { + Assert.notNull(builder, "MapperBuilder must not be null"); + Assert.notEmpty(mimeTypes, "MimeTypes must not be empty"); + this.defaultObjectMapper = builder.addModules(initModules()).build(); + this.mimeTypes = List.of(mimeTypes); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper} + * customized with the {@link tools.jackson.databind.JacksonModule}s found + * by {@link MapperBuilder#findModules(ClassLoader)} and {@link MimeType}s. + */ + protected JacksonCodecSupport(ObjectMapper objectMapper, MimeType... mimeTypes) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + Assert.notEmpty(mimeTypes, "MimeTypes must not be empty"); + this.defaultObjectMapper = objectMapper; + this.mimeTypes = List.of(mimeTypes); + } + + private List initModules() { + if (modules == null) { + modules = MapperBuilder.findModules(JacksonCodecSupport.class.getClassLoader()); + + } + return Objects.requireNonNull(modules); + } + + /** + * Return the {@link ObjectMapper configured} default ObjectMapper. + */ + 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 supported + * {@link #getMimeTypes() MimeTypes} 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 @Nullable Map getObjectMappersForType(Class clazz) { + for (Map.Entry, Map> entry : getObjectMapperRegistrations().entrySet()) { + if (entry.getKey().isAssignableFrom(clazz)) { + return entry.getValue(); + } + } + return Collections.emptyMap(); + } + + protected Map, Map> getObjectMapperRegistrations() { + return (this.objectMapperRegistrations != null ? this.objectMapperRegistrations : Collections.emptyMap()); + } + + /** + * Subclasses should expose this as "decodable" or "encodable" mime types. + */ + protected List getMimeTypes() { + return this.mimeTypes; + } + + protected List getMimeTypes(ResolvableType elementType) { + Class elementClass = elementType.toClass(); + List result = null; + for (Map.Entry, Map> entry : getObjectMapperRegistrations().entrySet()) { + if (entry.getKey().isAssignableFrom(elementClass)) { + result = (result != null ? result : new ArrayList<>(entry.getValue().size())); + result.addAll(entry.getValue().keySet()); + } + } + if (!CollectionUtils.isEmpty(result)) { + return result; + } + return (ProblemDetail.class.isAssignableFrom(elementClass) ? getMediaTypesForProblemDetail() : getMimeTypes()); + } + + /** + * Return the supported media type(s) for {@link ProblemDetail}. + * By default, an empty list, unless overridden in subclasses. + */ + protected List getMediaTypesForProblemDetail() { + return Collections.emptyList(); + } + + protected boolean supportsMimeType(@Nullable MimeType mimeType) { + if (mimeType == null) { + return true; + } + for (MimeType supportedMimeType : this.mimeTypes) { + if (supportedMimeType.isCompatibleWith(mimeType)) { + return true; + } + } + return false; + } + + protected JavaType getJavaType(Type type, @Nullable Class contextClass) { + return this.defaultObjectMapper.constructType(GenericTypeResolver.resolveType(type, contextClass)); + } + + protected Map getHints(ResolvableType resolvableType) { + MethodParameter param = getParameter(resolvableType); + if (param != null) { + Map hints = null; + if (resolvableType.hasGenerics()) { + hints = new HashMap<>(2); + hints.put(ACTUAL_TYPE_HINT, resolvableType); + } + JsonView annotation = getAnnotation(param, JsonView.class); + if (annotation != null) { + Class[] classes = annotation.value(); + Assert.isTrue(classes.length == 1, () -> JSON_VIEW_HINT_ERROR + param); + hints = (hints != null ? hints : new HashMap<>(1)); + hints.put(JSON_VIEW_HINT, classes[0]); + } + if (hints != null) { + return hints; + } + } + return Hints.none(); + } + + protected @Nullable MethodParameter getParameter(ResolvableType type) { + return (type.getSource() instanceof MethodParameter methodParameter ? methodParameter : null); + } + + protected abstract @Nullable A getAnnotation(MethodParameter parameter, Class annotType); + + /** + * 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)}. + */ + protected @Nullable ObjectMapper selectObjectMapper(ResolvableType targetType, @Nullable MimeType targetMimeType) { + if (targetMimeType == null || CollectionUtils.isEmpty(this.objectMapperRegistrations)) { + return this.defaultObjectMapper; + } + Class targetClass = targetType.toClass(); + for (Map.Entry, Map> typeEntry : getObjectMapperRegistrations().entrySet()) { + if (typeEntry.getKey().isAssignableFrom(targetClass)) { + for (Map.Entry objectMapperEntry : typeEntry.getValue().entrySet()) { + if (objectMapperEntry.getKey().includes(targetMimeType)) { + return objectMapperEntry.getValue(); + } + } + // No matching registrations + return null; + } + } + // No registrations + return this.defaultObjectMapper; + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/JacksonTokenizer.java b/spring-web/src/main/java/org/springframework/http/codec/JacksonTokenizer.java new file mode 100644 index 00000000000..692f4151214 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/JacksonTokenizer.java @@ -0,0 +1,241 @@ +/* + * 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.codec; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import reactor.core.publisher.Flux; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.async.ByteArrayFeeder; +import tools.jackson.core.async.ByteBufferFeeder; +import tools.jackson.core.async.NonBlockingInputFeeder; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.util.TokenBuffer; + +import org.springframework.core.codec.DecodingException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.core.io.buffer.DataBufferUtils; + +/** + * {@link Function} to transform a JSON stream of arbitrary size, byte array + * chunks into a {@code Flux} where each token buffer is a + * well-formed JSON object with Jackson 3.x. + * + * @author Sebastien Deleuze + * @since 7.0 + */ +final class JacksonTokenizer { + + private final JsonParser parser; + + private final NonBlockingInputFeeder inputFeeder; + + private final boolean tokenizeArrayElements; + + private final boolean forceUseOfBigDecimal; + + private final int maxInMemorySize; + + private int objectDepth; + + private int arrayDepth; + + private int byteCount; + + private TokenBuffer tokenBuffer; + + + private JacksonTokenizer(JsonParser parser, boolean tokenizeArrayElements, boolean forceUseOfBigDecimal, int maxInMemorySize) { + this.parser = parser; + this.inputFeeder = this.parser.nonBlockingInputFeeder(); + this.tokenizeArrayElements = tokenizeArrayElements; + this.forceUseOfBigDecimal = forceUseOfBigDecimal; + this.maxInMemorySize = maxInMemorySize; + this.tokenBuffer = createToken(); + } + + + private List tokenize(DataBuffer dataBuffer) { + try { + int bufferSize = dataBuffer.readableByteCount(); + List tokens = new ArrayList<>(); + if (this.inputFeeder instanceof ByteBufferFeeder byteBufferFeeder) { + try (DataBuffer.ByteBufferIterator iterator = dataBuffer.readableByteBuffers()) { + while (iterator.hasNext()) { + byteBufferFeeder.feedInput(iterator.next()); + parseTokens(tokens); + } + } + } + else if (this.inputFeeder instanceof ByteArrayFeeder byteArrayFeeder) { + byte[] bytes = new byte[bufferSize]; + dataBuffer.read(bytes); + byteArrayFeeder.feedInput(bytes, 0, bufferSize); + parseTokens(tokens); + } + assertInMemorySize(bufferSize, tokens); + return tokens; + } + catch (JacksonException ex) { + throw new DecodingException("JSON decoding error: " + ex.getOriginalMessage(), ex); + } + finally { + DataBufferUtils.release(dataBuffer); + } + } + + private Flux endOfInput() { + return Flux.defer(() -> { + this.inputFeeder.endOfInput(); + try { + List tokens = new ArrayList<>(); + parseTokens(tokens); + return Flux.fromIterable(tokens); + } + catch (JacksonException ex) { + throw new DecodingException("JSON decoding error: " + ex.getOriginalMessage(), ex); + } + }); + } + + private void parseTokens(List tokens) { + // SPR-16151: Smile data format uses null to separate documents + boolean previousNull = false; + while (!this.parser.isClosed()) { + JsonToken token = this.parser.nextToken(); + if (token == JsonToken.NOT_AVAILABLE || + token == null && previousNull) { + break; + } + else if (token == null ) { // !previousNull + previousNull = true; + continue; + } + else { + previousNull = false; + } + updateDepth(token); + if (!this.tokenizeArrayElements) { + processTokenNormal(token, tokens); + } + else { + processTokenArray(token, tokens); + } + } + } + + private void updateDepth(JsonToken token) { + switch (token) { + case START_OBJECT -> this.objectDepth++; + case END_OBJECT -> this.objectDepth--; + case START_ARRAY -> this.arrayDepth++; + case END_ARRAY -> this.arrayDepth--; + } + } + + private void processTokenNormal(JsonToken token, List result) { + this.tokenBuffer.copyCurrentEvent(this.parser); + + if ((token.isStructEnd() || token.isScalarValue()) && this.objectDepth == 0 && this.arrayDepth == 0) { + result.add(this.tokenBuffer); + this.tokenBuffer = createToken(); + } + } + + private void processTokenArray(JsonToken token, List result) { + if (!isTopLevelArrayToken(token)) { + this.tokenBuffer.copyCurrentEvent(this.parser); + } + + if (this.objectDepth == 0 && (this.arrayDepth == 0 || this.arrayDepth == 1) && + (token == JsonToken.END_OBJECT || token.isScalarValue())) { + result.add(this.tokenBuffer); + this.tokenBuffer = createToken(); + } + } + + private TokenBuffer createToken() { + TokenBuffer tokenBuffer = TokenBuffer.forBuffering(this.parser, this.parser.objectReadContext()); + tokenBuffer.forceUseOfBigDecimal(this.forceUseOfBigDecimal); + return tokenBuffer; + } + + private boolean isTopLevelArrayToken(JsonToken token) { + return this.objectDepth == 0 && ((token == JsonToken.START_ARRAY && this.arrayDepth == 1) || + (token == JsonToken.END_ARRAY && this.arrayDepth == 0)); + } + + private void assertInMemorySize(int currentBufferSize, List result) { + if (this.maxInMemorySize >= 0) { + if (!result.isEmpty()) { + this.byteCount = 0; + } + else if (currentBufferSize > Integer.MAX_VALUE - this.byteCount) { + raiseLimitException(); + } + else { + this.byteCount += currentBufferSize; + if (this.byteCount > this.maxInMemorySize) { + raiseLimitException(); + } + } + } + } + + private void raiseLimitException() { + throw new DataBufferLimitException( + "Exceeded limit on max bytes per JSON object: " + this.maxInMemorySize); + } + + + /** + * Tokenize the given {@code Flux} into {@code Flux}. + * @param dataBuffers the source data buffers + * @param objectMapper the current mapper instance + * @param tokenizeArrays if {@code true} and the "top level" JSON object is + * an array, each element is returned individually immediately after it is received + * @param forceUseOfBigDecimal if {@code true}, any floating point values encountered + * in source will use {@link java.math.BigDecimal} + * @param maxInMemorySize maximum memory size + * @return the resulting token buffers + */ + public static Flux tokenize(Flux dataBuffers, + ObjectMapper objectMapper, boolean tokenizeArrays, boolean forceUseOfBigDecimal, int maxInMemorySize) { + + try { + JsonParser parser; + try { + parser = objectMapper.createNonBlockingByteBufferParser(); + } + catch (UnsupportedOperationException ex) { + parser = objectMapper.createNonBlockingByteArrayParser(); + } + JacksonTokenizer tokenizer = + new JacksonTokenizer(parser, tokenizeArrays, forceUseOfBigDecimal, maxInMemorySize); + return dataBuffers.concatMapIterable(tokenizer::tokenize).concatWith(tokenizer.endOfInput()); + } + catch (JacksonException ex) { + return Flux.error(ex); + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/cbor/Jackson2CborDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/cbor/Jackson2CborDecoder.java index 9daf3b302db..babbf48a699 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/cbor/Jackson2CborDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/cbor/Jackson2CborDecoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 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. @@ -33,7 +33,7 @@ import org.springframework.util.Assert; import org.springframework.util.MimeType; /** - * Decode bytes into CBOR and convert to Object's with Jackson. + * Decode bytes into CBOR and convert to Object's with Jackson 2.x. * Stream decoding is not supported yet. * * @author Sebastien Deleuze diff --git a/spring-web/src/main/java/org/springframework/http/codec/cbor/Jackson2CborEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/cbor/Jackson2CborEncoder.java index 92219536296..2da7e3a7c20 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/cbor/Jackson2CborEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/cbor/Jackson2CborEncoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 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. @@ -34,7 +34,7 @@ import org.springframework.util.Assert; import org.springframework.util.MimeType; /** - * Encode from an {@code Object} to bytes of CBOR objects using Jackson. + * Encode from an {@code Object} to bytes of CBOR objects using Jackson 2.x. * Stream encoding is not supported yet. * * @author Sebastien Deleuze diff --git a/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborDecoder.java new file mode 100644 index 00000000000..657e484869f --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborDecoder.java @@ -0,0 +1,76 @@ +/* + * 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.codec.cbor; + +import java.util.Map; + +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.cbor.CBORMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.http.codec.AbstractJacksonDecoder; +import org.springframework.util.MimeType; + +/** + * Decode bytes into CBOR and convert to Object's with Jackson 3.x. + * Stream decoding is not supported yet. + * + * @author Sebastien Deleuze + * @since 7.0 + * @see JacksonCborEncoder + * @see Add CBOR support to WebFlux + */ +public class JacksonCborDecoder extends AbstractJacksonDecoder { + + /** + * Construct a new instance with a {@link CBORMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)}. + */ + public JacksonCborDecoder() { + super(CBORMapper.builder(), MediaType.APPLICATION_CBOR); + } + + /** + * Construct a new instance with the provided {@link CBORMapper}. + */ + public JacksonCborDecoder(CBORMapper mapper) { + super(mapper, MediaType.APPLICATION_CBOR); + } + + /** + * Construct a new instance with the provided {@link CBORMapper} and {@link MimeType}s. + * @see CBORMapper#builder() + * @see MapperBuilder#findAndAddModules(ClassLoader) + */ + public JacksonCborDecoder(CBORMapper mapper, MimeType... mimeTypes) { + super(mapper, mimeTypes); + } + + + @Override + public Flux decode(Publisher input, ResolvableType elementType, @Nullable MimeType mimeType, + @Nullable Map hints) { + throw new UnsupportedOperationException("Does not support stream decoding yet"); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborEncoder.java new file mode 100644 index 00000000000..6c2eeafccc5 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborEncoder.java @@ -0,0 +1,79 @@ +/* + * 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.codec.cbor; + +import java.util.Map; + +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.cbor.CBORMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.MediaType; +import org.springframework.http.codec.AbstractJacksonEncoder; +import org.springframework.util.MimeType; + +/** + * Encode from an {@code Object} to bytes of CBOR objects using Jackson 3.x. + * Stream encoding is not supported yet. + * + * @author Sebastien Deleuze + * @since 7.0 + * @see JacksonCborDecoder + * @see Add CBOR support to WebFlux + */ +public class JacksonCborEncoder extends AbstractJacksonEncoder { + + /** + * Construct a new instance with a {@link CBORMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)}. + */ + public JacksonCborEncoder() { + super(CBORMapper.builder(), MediaType.APPLICATION_CBOR); + } + + /** + * Construct a new instance with the provided {@link CBORMapper}. + * @see CBORMapper#builder() + * @see MapperBuilder#findAndAddModules(ClassLoader) + */ + public JacksonCborEncoder(CBORMapper mapper) { + super(mapper, MediaType.APPLICATION_CBOR); + } + + /** + * Construct a new instance with the provided {@link CBORMapper} and {@link MimeType}s. + * @see CBORMapper#builder() + * @see MapperBuilder#findAndAddModules(ClassLoader) + */ + public JacksonCborEncoder(CBORMapper mapper, MimeType... mimeTypes) { + super(mapper, mimeTypes); + } + + + @Override + public Flux encode(Publisher inputStream, DataBufferFactory bufferFactory, ResolvableType elementType, + @Nullable MimeType mimeType, @Nullable Map hints) { + throw new UnsupportedOperationException("Does not support stream encoding yet"); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2Tokenizer.java b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2Tokenizer.java index 427d8025a7a..dc16bcd77aa 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2Tokenizer.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2Tokenizer.java @@ -44,7 +44,7 @@ import org.springframework.core.io.buffer.DataBufferUtils; /** * {@link Function} to transform a JSON stream of arbitrary size, byte array * chunks into a {@code Flux} where each token buffer is a - * well-formed JSON object. + * well-formed JSON object with Jackson 2.x. * * @author Arjen Poutsma * @author Rossen Stoyanchev diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java new file mode 100644 index 00000000000..82083d9d294 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java @@ -0,0 +1,114 @@ +/* + * 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.codec.json; + +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; + +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.CharBufferDecoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.MediaType; +import org.springframework.http.codec.AbstractJacksonDecoder; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * Decode a byte stream into JSON and convert to Object's with + * Jackson 3.x + * leveraging non-blocking parsing. + * + *

The default constructor loads {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * + * @author Sebastien Deleuze + * @since 7.0 + * @see JacksonJsonEncoder + */ +public class JacksonJsonDecoder extends AbstractJacksonDecoder { + + private static final CharBufferDecoder CHAR_BUFFER_DECODER = CharBufferDecoder.textPlainOnly(Arrays.asList(",", "\n"), false); + + private static final ResolvableType CHAR_BUFFER_TYPE = ResolvableType.forClass(CharBuffer.class); + + private static final MimeType[] DEFAULT_JSON_MIME_TYPES = new MimeType[] { + MediaType.APPLICATION_JSON, + new MediaType("application", "*+json"), + MediaType.APPLICATION_NDJSON + }; + + + /** + * Construct a new instance with a {@link JsonMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)}. + */ + public JacksonJsonDecoder() { + super(JsonMapper.builder(), DEFAULT_JSON_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper}. + * @see JsonMapper#builder() + * @see MapperBuilder#findModules(ClassLoader) + */ + public JacksonJsonDecoder(ObjectMapper mapper) { + this(mapper, DEFAULT_JSON_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper} and {@link MimeType}s. + * @see JsonMapper#builder() + * @see MapperBuilder#findModules(ClassLoader) + */ + public JacksonJsonDecoder(ObjectMapper mapper, MimeType... mimeTypes) { + super(mapper, mimeTypes); + } + + @Override + protected Flux processInput(Publisher input, ResolvableType elementType, + @Nullable MimeType mimeType, @Nullable Map hints) { + + Flux flux = Flux.from(input); + if (mimeType == null) { + return flux; + } + + // Jackson asynchronous parser only supports UTF-8 + Charset charset = mimeType.getCharset(); + if (charset == null || StandardCharsets.UTF_8.equals(charset) || StandardCharsets.US_ASCII.equals(charset)) { + return flux; + } + + // Re-encode as UTF-8. + MimeType textMimeType = new MimeType(MimeTypeUtils.TEXT_PLAIN, charset); + Flux decoded = CHAR_BUFFER_DECODER.decode(input, CHAR_BUFFER_TYPE, textMimeType, null); + return decoded.map(charBuffer -> DefaultDataBufferFactory.sharedInstance.wrap(StandardCharsets.UTF_8.encode(charBuffer))); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java new file mode 100644 index 00000000000..67f62a57958 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java @@ -0,0 +1,125 @@ +/* + * 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.codec.json; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import tools.jackson.core.PrettyPrinter; +import tools.jackson.core.util.DefaultIndenter; +import tools.jackson.core.util.DefaultPrettyPrinter; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.codec.AbstractJacksonEncoder; +import org.springframework.http.converter.json.ProblemDetailJacksonMixin; +import org.springframework.util.MimeType; + +/** + * Encode from an {@code Object} stream to a byte stream of JSON objects using + * Jackson 3.x. For non-streaming + * use cases, {@link Flux} elements are collected into a {@link List} before + * serialization for performance reason. + * + *

The default constructor loads {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * + * @author Sebastien Deleuze + * @since 7.0 + * @see JacksonJsonDecoder + */ +public class JacksonJsonEncoder extends AbstractJacksonEncoder { + + private static final List problemDetailMimeTypes = + Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON); + + private static final MimeType[] DEFAULT_JSON_MIME_TYPES = new MimeType[] { + MediaType.APPLICATION_JSON, + new MediaType("application", "*+json"), + MediaType.APPLICATION_NDJSON + }; + + + private final @Nullable PrettyPrinter ssePrettyPrinter; + + + /** + * 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 JacksonJsonEncoder() { + super(JsonMapper.builder().addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class), + DEFAULT_JSON_MIME_TYPES); + setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); + this.ssePrettyPrinter = initSsePrettyPrinter(); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper}. + * @see JsonMapper#builder() + * @see MapperBuilder#findModules(ClassLoader) + */ + public JacksonJsonEncoder(ObjectMapper mapper) { + this(mapper, DEFAULT_JSON_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper} and + * {@link MimeType}s. + * @see JsonMapper#builder() + * @see MapperBuilder#findModules(ClassLoader) + */ + public JacksonJsonEncoder(ObjectMapper mapper, MimeType... mimeTypes) { + super(mapper, mimeTypes); + setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); + this.ssePrettyPrinter = initSsePrettyPrinter(); + } + + private static PrettyPrinter initSsePrettyPrinter() { + DefaultPrettyPrinter printer = new DefaultPrettyPrinter(); + printer.indentObjectsWith(new DefaultIndenter(" ", "\ndata:")); + return printer; + } + + + @Override + protected List getMediaTypesForProblemDetail() { + return problemDetailMimeTypes; + } + + @Override + protected ObjectWriter customizeWriter(ObjectWriter writer, @Nullable MimeType mimeType, + ResolvableType elementType, @Nullable Map hints) { + + return (this.ssePrettyPrinter != null && + MediaType.TEXT_EVENT_STREAM.isCompatibleWith(mimeType) && + writer.getConfig().isEnabled(SerializationFeature.INDENT_OUTPUT) ? + writer.with(this.ssePrettyPrinter) : writer); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileDecoder.java new file mode 100644 index 00000000000..bca5718cfd6 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileDecoder.java @@ -0,0 +1,69 @@ +/* + * 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.codec.smile; + +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.smile.SmileMapper; + +import org.springframework.http.codec.AbstractJacksonDecoder; +import org.springframework.util.MimeType; + +/** + * Decode a byte stream into Smile and convert to Object's with Jackson 3.x, + * leveraging non-blocking parsing. + * + *

The default constructor loads {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * + * @author Sebastien Deleuze + * @since 7.0 + * @see JacksonSmileEncoder + */ +public class JacksonSmileDecoder extends AbstractJacksonDecoder { + + private static final MimeType[] DEFAULT_SMILE_MIME_TYPES = new MimeType[] { + new MimeType("application", "x-jackson-smile"), + new MimeType("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 JacksonSmileDecoder() { + super(SmileMapper.builder(), DEFAULT_SMILE_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link SmileMapper}. + * @see SmileMapper#builder() + * @see MapperBuilder#findAndAddModules(ClassLoader) + */ + public JacksonSmileDecoder(SmileMapper mapper) { + this(mapper, DEFAULT_SMILE_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link SmileMapper} and {@link MimeType}s. + * @see SmileMapper#builder() + * @see MapperBuilder#findAndAddModules(ClassLoader) + */ + public JacksonSmileDecoder(SmileMapper mapper, MimeType... mimeTypes) { + super(mapper, mimeTypes); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileEncoder.java new file mode 100644 index 00000000000..f3c8ad9a566 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileEncoder.java @@ -0,0 +1,101 @@ +/* + * 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.codec.smile; + +import java.util.Collections; +import java.util.List; + +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.smile.SmileMapper; + +import org.springframework.http.MediaType; +import org.springframework.http.codec.AbstractJacksonEncoder; +import org.springframework.util.MimeType; + +/** + * Encode from an {@code Object} stream to a byte stream of Smile objects using Jackson 3.x. + * For non-streaming use cases, {@link Flux} elements are collected into a {@link List} + * before serialization for performance reason. + * + *

The default constructor loads {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * + * @author Sebastien Deleuze + * @since 7.0 + * @see JacksonSmileDecoder + */ +public class JacksonSmileEncoder extends AbstractJacksonEncoder { + + private static final MimeType[] DEFAULT_SMILE_MIME_TYPES = new MimeType[] { + new MimeType("application", "x-jackson-smile"), + new MimeType("application", "*+x-jackson-smile")}; + + private static final MediaType DEFAULT_SMILE_STREAMING_MEDIA_TYPE = + new MediaType("application", "stream+x-jackson-smile"); + + private static final byte[] STREAM_SEPARATOR = new byte[0]; + + + /** + * Construct a new instance with a {@link SmileMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)}. + */ + public JacksonSmileEncoder() { + super(SmileMapper.builder(), DEFAULT_SMILE_MIME_TYPES); + setStreamingMediaTypes(Collections.singletonList(DEFAULT_SMILE_STREAMING_MEDIA_TYPE)); + } + + /** + * Construct a new instance with the provided {@link SmileMapper}. + * @see SmileMapper#builder() + * @see MapperBuilder#findAndAddModules(ClassLoader) + */ + public JacksonSmileEncoder(SmileMapper mapper) { + super(mapper, DEFAULT_SMILE_MIME_TYPES); + setStreamingMediaTypes(Collections.singletonList(DEFAULT_SMILE_STREAMING_MEDIA_TYPE)); + } + + /** + * Construct a new instance with the provided {@link SmileMapper} and {@link MimeType}s. + * @see SmileMapper#builder() + * @see MapperBuilder#findAndAddModules(ClassLoader) + */ + public JacksonSmileEncoder(SmileMapper mapper, MimeType... mimeTypes) { + super(mapper, mimeTypes); + setStreamingMediaTypes(Collections.singletonList(DEFAULT_SMILE_STREAMING_MEDIA_TYPE)); + } + + + /** + * Return the separator to use for the given mime type. + *

By default, this method returns a single byte 0 if the given + * mime type is one of the configured {@link #setStreamingMediaTypes(List) + * streaming} mime types. + */ + @Override + protected byte @Nullable [] getStreamingMediaTypeSeparator(@Nullable MimeType mimeType) { + for (MediaType streamingMediaType : getStreamingMediaTypes()) { + if (streamingMediaType.isCompatibleWith(mimeType)) { + return STREAM_SEPARATOR; + } + } + return null; + } +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/smile/package-info.java b/spring-web/src/main/java/org/springframework/http/codec/smile/package-info.java new file mode 100644 index 00000000000..a5dfff44a85 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/smile/package-info.java @@ -0,0 +1,7 @@ +/** + * Provides an encoder and a decoder for the Smile data format ("binary JSON"). + */ +@NullMarked +package org.springframework.http.codec.smile; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-web/src/main/java/org/springframework/http/codec/support/BaseDefaultCodecs.java b/spring-web/src/main/java/org/springframework/http/codec/support/BaseDefaultCodecs.java index a604f3729f4..7243bafe576 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/support/BaseDefaultCodecs.java +++ b/spring-web/src/main/java/org/springframework/http/codec/support/BaseDefaultCodecs.java @@ -39,6 +39,7 @@ import org.springframework.core.codec.NettyByteBufDecoder; import org.springframework.core.codec.NettyByteBufEncoder; import org.springframework.core.codec.ResourceDecoder; import org.springframework.core.codec.StringDecoder; +import org.springframework.http.codec.AbstractJacksonDecoder; import org.springframework.http.codec.CodecConfigurer; import org.springframework.http.codec.DecoderHttpMessageReader; import org.springframework.http.codec.EncoderHttpMessageWriter; @@ -57,6 +58,8 @@ import org.springframework.http.codec.json.Jackson2JsonDecoder; import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.http.codec.json.Jackson2SmileDecoder; import org.springframework.http.codec.json.Jackson2SmileEncoder; +import org.springframework.http.codec.json.JacksonJsonDecoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; import org.springframework.http.codec.json.KotlinSerializationJsonDecoder; import org.springframework.http.codec.json.KotlinSerializationJsonEncoder; import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader; @@ -70,6 +73,8 @@ import org.springframework.http.codec.protobuf.KotlinSerializationProtobufEncode import org.springframework.http.codec.protobuf.ProtobufDecoder; import org.springframework.http.codec.protobuf.ProtobufEncoder; import org.springframework.http.codec.protobuf.ProtobufHttpMessageWriter; +import org.springframework.http.codec.smile.JacksonSmileDecoder; +import org.springframework.http.codec.smile.JacksonSmileEncoder; import org.springframework.http.codec.xml.Jaxb2XmlDecoder; import org.springframework.http.codec.xml.Jaxb2XmlEncoder; import org.springframework.util.ClassUtils; @@ -84,8 +89,12 @@ import org.springframework.util.ObjectUtils; */ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigurer.DefaultCodecConfig { + static final boolean jacksonPresent; + static final boolean jackson2Present; + private static final boolean jacksonSmilePresent; + private static final boolean jackson2SmilePresent; private static final boolean jaxb2Present; @@ -102,9 +111,11 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure static { ClassLoader classLoader = BaseCodecConfigurer.class.getClassLoader(); + 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); - jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); + jacksonSmilePresent = jacksonPresent && ClassUtils.isPresent("tools.jackson.dataformat.smile.SmileMapper", classLoader); + jackson2SmilePresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); jaxb2Present = ClassUtils.isPresent("jakarta.xml.bind.Binder", classLoader); protobufPresent = ClassUtils.isPresent("com.google.protobuf.Message", classLoader); nettyByteBufPresent = ClassUtils.isPresent("io.netty.buffer.ByteBuf", classLoader); @@ -114,12 +125,20 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure } + private @Nullable Decoder jacksonJsonDecoder; + private @Nullable Decoder jackson2JsonDecoder; + private @Nullable Encoder jacksonJsonEncoder; + private @Nullable Encoder jackson2JsonEncoder; + private @Nullable Encoder jacksonSmileEncoder; + private @Nullable Encoder jackson2SmileEncoder; + private @Nullable Decoder jacksonSmileDecoder; + private @Nullable Decoder jackson2SmileDecoder; private @Nullable Decoder protobufDecoder; @@ -195,9 +214,13 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure * Create a deep copy of the given {@link BaseDefaultCodecs}. */ protected BaseDefaultCodecs(BaseDefaultCodecs other) { + this.jacksonJsonDecoder = other.jacksonJsonDecoder; this.jackson2JsonDecoder = other.jackson2JsonDecoder; + this.jacksonJsonEncoder = other.jacksonJsonEncoder; this.jackson2JsonEncoder = other.jackson2JsonEncoder; + this.jacksonSmileDecoder = other.jacksonSmileDecoder; this.jackson2SmileDecoder = other.jackson2SmileDecoder; + this.jacksonSmileEncoder = other.jacksonSmileEncoder; this.jackson2SmileEncoder = other.jackson2SmileEncoder; this.protobufDecoder = other.protobufDecoder; this.protobufEncoder = other.protobufEncoder; @@ -222,12 +245,25 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure this.objectWriters.addAll(other.objectWriters); } + @Override + public void jacksonJsonDecoder(Decoder decoder) { + this.jacksonJsonDecoder = decoder; + initObjectReaders(); + } + @Override public void jackson2JsonDecoder(Decoder decoder) { this.jackson2JsonDecoder = decoder; initObjectReaders(); } + @Override + public void jacksonJsonEncoder(Encoder encoder) { + this.jacksonJsonEncoder = encoder; + initObjectWriters(); + initTypedWriters(); + } + @Override public void jackson2JsonEncoder(Encoder encoder) { this.jackson2JsonEncoder = encoder; @@ -235,12 +271,25 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure initTypedWriters(); } + @Override + public void jacksonSmileDecoder(Decoder decoder) { + this.jacksonSmileDecoder = decoder; + initObjectReaders(); + } + @Override public void jackson2SmileDecoder(Decoder decoder) { this.jackson2SmileDecoder = decoder; initObjectReaders(); } + @Override + public void jacksonSmileEncoder(Encoder encoder) { + this.jacksonSmileEncoder = encoder; + initObjectWriters(); + initTypedWriters(); + } + @Override public void jackson2SmileEncoder(Encoder encoder) { this.jackson2SmileEncoder = encoder; @@ -476,6 +525,11 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure kotlinSerializationProtobufDec.setMaxInMemorySize(size); } } + if (jacksonPresent) { + if (codec instanceof AbstractJacksonDecoder abstractJacksonDecoder) { + abstractJacksonDecoder.setMaxInMemorySize(size); + } + } if (jackson2Present) { if (codec instanceof AbstractJackson2Decoder abstractJackson2Decoder) { abstractJackson2Decoder.setMaxInMemorySize(size); @@ -574,13 +628,20 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure (KotlinSerializationProtobufDecoder) this.kotlinSerializationProtobufDecoder : new KotlinSerializationProtobufDecoder())); } - if (jackson2Present) { + if (jacksonPresent) { + addCodec(this.objectReaders, new DecoderHttpMessageReader<>(getJacksonJsonDecoder())); + } + else if (jackson2Present) { addCodec(this.objectReaders, new DecoderHttpMessageReader<>(getJackson2JsonDecoder())); } else if (kotlinSerializationJsonPresent) { addCodec(this.objectReaders, new DecoderHttpMessageReader<>(getKotlinSerializationJsonDecoder())); } - if (jackson2SmilePresent) { + if (jacksonSmilePresent) { + addCodec(this.objectReaders, new DecoderHttpMessageReader<>(this.jacksonSmileDecoder != null ? + (JacksonSmileDecoder) this.jacksonSmileDecoder : new JacksonSmileDecoder())); + } + else if (jackson2SmilePresent) { addCodec(this.objectReaders, new DecoderHttpMessageReader<>(this.jackson2SmileDecoder != null ? (Jackson2SmileDecoder) this.jackson2SmileDecoder : new Jackson2SmileDecoder())); } @@ -711,13 +772,20 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure (KotlinSerializationProtobufEncoder) this.kotlinSerializationProtobufEncoder : new KotlinSerializationProtobufEncoder())); } - if (jackson2Present) { + if (jacksonPresent) { + addCodec(writers, new EncoderHttpMessageWriter<>(getJacksonJsonEncoder())); + } + else if (jackson2Present) { addCodec(writers, new EncoderHttpMessageWriter<>(getJackson2JsonEncoder())); } else if (kotlinSerializationJsonPresent) { addCodec(writers, new EncoderHttpMessageWriter<>(getKotlinSerializationJsonEncoder())); } - if (jackson2SmilePresent) { + if (jacksonSmilePresent) { + addCodec(writers, new EncoderHttpMessageWriter<>(this.jacksonSmileEncoder != null ? + (JacksonSmileEncoder) this.jacksonSmileEncoder : new JacksonSmileEncoder())); + } + else if (jackson2SmilePresent) { addCodec(writers, new EncoderHttpMessageWriter<>(this.jackson2SmileEncoder != null ? (Jackson2SmileEncoder) this.jackson2SmileEncoder : new Jackson2SmileEncoder())); } @@ -764,6 +832,13 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure // Accessors for use in subclasses... + protected Decoder getJacksonJsonDecoder() { + if (this.jacksonJsonDecoder == null) { + this.jacksonJsonDecoder = new JacksonJsonDecoder(); + } + return this.jacksonJsonDecoder; + } + protected Decoder getJackson2JsonDecoder() { if (this.jackson2JsonDecoder == null) { this.jackson2JsonDecoder = new Jackson2JsonDecoder(); @@ -771,6 +846,13 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure return this.jackson2JsonDecoder; } + protected Encoder getJacksonJsonEncoder() { + if (this.jacksonJsonEncoder == null) { + this.jacksonJsonEncoder = new JacksonJsonEncoder(); + } + return this.jacksonJsonEncoder; + } + protected Encoder getJackson2JsonEncoder() { if (this.jackson2JsonEncoder == null) { this.jackson2JsonEncoder = new Jackson2JsonEncoder(); diff --git a/spring-web/src/main/java/org/springframework/http/codec/support/ClientDefaultCodecsImpl.java b/spring-web/src/main/java/org/springframework/http/codec/support/ClientDefaultCodecsImpl.java index 87dd4ddef14..4de79d900a7 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/support/ClientDefaultCodecsImpl.java +++ b/spring-web/src/main/java/org/springframework/http/codec/support/ClientDefaultCodecsImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 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. @@ -53,6 +53,7 @@ class ClientDefaultCodecsImpl extends BaseDefaultCodecs implements ClientCodecCo protected void extendObjectReaders(List> objectReaders) { Decoder decoder = (this.sseDecoder != null ? this.sseDecoder : + jacksonPresent ? getJacksonJsonDecoder() : jackson2Present ? getJackson2JsonDecoder() : kotlinSerializationJsonPresent ? getKotlinSerializationJsonDecoder() : null); diff --git a/spring-web/src/main/java/org/springframework/http/codec/support/ServerDefaultCodecsImpl.java b/spring-web/src/main/java/org/springframework/http/codec/support/ServerDefaultCodecsImpl.java index 27ebcc6e68e..6bd0bb9c2a4 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/support/ServerDefaultCodecsImpl.java +++ b/spring-web/src/main/java/org/springframework/http/codec/support/ServerDefaultCodecsImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 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. @@ -57,6 +57,7 @@ class ServerDefaultCodecsImpl extends BaseDefaultCodecs implements ServerCodecCo private @Nullable Encoder getSseEncoder() { return this.sseEncoder != null ? this.sseEncoder : + jacksonPresent ? getJacksonJsonEncoder() : jackson2Present ? getJackson2JsonEncoder() : kotlinSerializationJsonPresent ? getKotlinSerializationJsonEncoder() : null; diff --git a/spring-web/src/test/java/org/springframework/http/codec/JacksonTokenizerTests.java b/spring-web/src/test/java/org/springframework/http/codec/JacksonTokenizerTests.java new file mode 100644 index 00000000000..6d62a9bc6ce --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/JacksonTokenizerTests.java @@ -0,0 +1,376 @@ +/* + * 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.codec; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.CompositeByteBuf; +import io.netty.buffer.UnpooledByteBufAllocator; +import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.TreeNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.util.TokenBuffer; + +import org.springframework.core.codec.DecodingException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.core.testfixture.io.buffer.AbstractLeakCheckingTests; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JacksonTokenizer}. + * + * @author Sebastien Deleuze + */ +class JacksonTokenizerTests extends AbstractLeakCheckingTests { + + private ObjectMapper objectMapper; + + + @BeforeEach + void createParser() { + this.objectMapper = JsonMapper.builder().build(); + } + + + @Test + void doNotTokenizeArrayElements() { + testTokenize( + singletonList("{\"foo\": \"foofoo\", \"bar\": \"barbar\"}"), + singletonList("{\"foo\": \"foofoo\", \"bar\": \"barbar\"}"), false); + + testTokenize( + asList( + "{\"foo\": \"foofoo\"", + ", \"bar\": \"barbar\"}" + ), + singletonList("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}"), false); + + testTokenize( + singletonList("[{\"foo\": \"foofoo\", \"bar\": \"barbar\"},{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}]"), + singletonList("[{\"foo\": \"foofoo\", \"bar\": \"barbar\"},{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}]"), + false); + + testTokenize( + singletonList("[{\"foo\": \"bar\"},{\"foo\": \"baz\"}]"), + singletonList("[{\"foo\": \"bar\"},{\"foo\": \"baz\"}]"), false); + + testTokenize( + asList( + "[{\"foo\": \"foofoo\", \"bar\"", + ": \"barbar\"},{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}]" + ), + singletonList("[{\"foo\": \"foofoo\", \"bar\": \"barbar\"},{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}]"), + false); + + testTokenize( + asList( + "[", + "{\"id\":1,\"name\":\"Robert\"}", ",", + "{\"id\":2,\"name\":\"Raide\"}", ",", + "{\"id\":3,\"name\":\"Ford\"}", "]" + ), + singletonList("[{\"id\":1,\"name\":\"Robert\"},{\"id\":2,\"name\":\"Raide\"},{\"id\":3,\"name\":\"Ford\"}]"), + false); + + // SPR-16166: top-level JSON values + testTokenize(asList("\"foo", "bar\""), singletonList("\"foobar\""), false); + testTokenize(asList("12", "34"), singletonList("1234"), false); + testTokenize(asList("12.", "34"), singletonList("12.34"), false); + + // note that we do not test for null, true, or false, which are also valid top-level values, + // but are unsupported by JSONassert + } + + @Test + void tokenizeArrayElements() { + testTokenize( + singletonList("{\"foo\": \"foofoo\", \"bar\": \"barbar\"}"), + singletonList("{\"foo\": \"foofoo\", \"bar\": \"barbar\"}"), true); + + testTokenize( + asList( + "{\"foo\": \"foofoo\"", + ", \"bar\": \"barbar\"}" + ), + singletonList("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}"), true); + + testTokenize( + singletonList("[{\"foo\": \"foofoo\", \"bar\": \"barbar\"},{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}]"), + asList( + "{\"foo\": \"foofoo\", \"bar\": \"barbar\"}", + "{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}" + ), + true); + + testTokenize( + singletonList("[{\"foo\": \"bar\"},{\"foo\": \"baz\"}]"), + asList( + "{\"foo\": \"bar\"}", + "{\"foo\": \"baz\"}" + ), + true); + + // SPR-15803: nested array + testTokenize( + singletonList("[" + + "{\"id\":\"0\",\"start\":[-999999999,1,1],\"end\":[999999999,12,31]}," + + "{\"id\":\"1\",\"start\":[-999999999,1,1],\"end\":[999999999,12,31]}," + + "{\"id\":\"2\",\"start\":[-999999999,1,1],\"end\":[999999999,12,31]}" + + "]"), + asList( + "{\"id\":\"0\",\"start\":[-999999999,1,1],\"end\":[999999999,12,31]}", + "{\"id\":\"1\",\"start\":[-999999999,1,1],\"end\":[999999999,12,31]}", + "{\"id\":\"2\",\"start\":[-999999999,1,1],\"end\":[999999999,12,31]}" + ), + true); + + // SPR-15803: nested array, no top-level array + testTokenize( + singletonList("{\"speakerIds\":[\"tastapod\"],\"language\":\"ENGLISH\"}"), + singletonList("{\"speakerIds\":[\"tastapod\"],\"language\":\"ENGLISH\"}"), true); + + testTokenize( + asList( + "[{\"foo\": \"foofoo\", \"bar\"", + ": \"barbar\"},{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}]" + ), + asList( + "{\"foo\": \"foofoo\", \"bar\": \"barbar\"}", + "{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}"), true); + + testTokenize( + asList( + "[", + "{\"id\":1,\"name\":\"Robert\"}", + ",", + "{\"id\":2,\"name\":\"Raide\"}", + ",", + "{\"id\":3,\"name\":\"Ford\"}", + "]" + ), + asList( + "{\"id\":1,\"name\":\"Robert\"}", + "{\"id\":2,\"name\":\"Raide\"}", + "{\"id\":3,\"name\":\"Ford\"}" + ), + true); + + // SPR-16166: top-level JSON values + testTokenize(asList("\"foo", "bar\""), singletonList("\"foobar\""), true); + testTokenize(asList("12", "34"), singletonList("1234"), true); + testTokenize(asList("12.", "34"), singletonList("12.34"), true); + + // SPR-16407 + testTokenize(asList("[1", ",2,", "3]"), asList("1", "2", "3"), true); + } + + @Test + void tokenizeStream() { + + // NDJSON (Newline Delimited JSON), JSON Lines + testTokenize( + asList( + "{\"id\":1,\"name\":\"Robert\"}", + "\n", + "{\"id\":2,\"name\":\"Raide\"}", + "\n", + "{\"id\":3,\"name\":\"Ford\"}" + ), + asList( + "{\"id\":1,\"name\":\"Robert\"}", + "{\"id\":2,\"name\":\"Raide\"}", + "{\"id\":3,\"name\":\"Ford\"}" + ), + true); + + // JSON Sequence with newline separator + testTokenize( + asList( + "\n", + "{\"id\":1,\"name\":\"Robert\"}", + "\n", + "{\"id\":2,\"name\":\"Raide\"}", + "\n", + "{\"id\":3,\"name\":\"Ford\"}" + ), + asList( + "{\"id\":1,\"name\":\"Robert\"}", + "{\"id\":2,\"name\":\"Raide\"}", + "{\"id\":3,\"name\":\"Ford\"}" + ), + true); + } + + private void testTokenize(List input, List output, boolean tokenize) { + StepVerifier.FirstStep builder = StepVerifier.create(decode(input, tokenize, -1)); + output.forEach(expected -> builder.assertNext(actual -> { + try { + JSONAssert.assertEquals(expected, actual, true); + } + catch (JSONException ex) { + throw new RuntimeException(ex); + } + })); + builder.verifyComplete(); + } + + @Test + void testLimit() { + List source = asList( + "[", + "{", "\"id\":1,\"name\":\"Dan\"", "},", + "{", "\"id\":2,\"name\":\"Ron\"", "},", + "{", "\"id\":3,\"name\":\"Bartholomew\"", "}", + "]" + ); + + String expected = String.join("", source); + int maxInMemorySize = expected.length(); + + StepVerifier.create(decode(source, false, maxInMemorySize)) + .expectNext(expected) + .verifyComplete(); + + StepVerifier.create(decode(source, false, maxInMemorySize - 2)) + .verifyError(DataBufferLimitException.class); + } + + @Test + void testLimitTokenized() { + + List source = asList( + "[", + "{", "\"id\":1, \"name\":\"Dan\"", "},", + "{", "\"id\":2, \"name\":\"Ron\"", "},", + "{", "\"id\":3, \"name\":\"Bartholomew\"", "}", + "]" + ); + + String expected = "{\"id\":3,\"name\":\"Bartholomew\"}"; + int maxInMemorySize = expected.length(); + + StepVerifier.create(decode(source, true, maxInMemorySize)) + .expectNext("{\"id\":1,\"name\":\"Dan\"}") + .expectNext("{\"id\":2,\"name\":\"Ron\"}") + .expectNext(expected) + .verifyComplete(); + + StepVerifier.create(decode(source, true, maxInMemorySize - 1)) + .expectNext("{\"id\":1,\"name\":\"Dan\"}") + .expectNext("{\"id\":2,\"name\":\"Ron\"}") + .verifyError(DataBufferLimitException.class); + } + + @Test + void errorInStream() { + DataBuffer buffer = stringBuffer("{\"id\":1,\"name\":"); + Flux source = Flux.just(buffer).concatWith(Flux.error(new RuntimeException())); + Flux result = JacksonTokenizer.tokenize(source, this.objectMapper, true, false, -1); + + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test // SPR-16521 + public void jsonEOFExceptionIsWrappedAsDecodingError() { + Flux source = Flux.just(stringBuffer("{\"status\": \"noClosingQuote}")); + Flux tokens = JacksonTokenizer.tokenize(source, this.objectMapper, false, false, -1); + + StepVerifier.create(tokens) + .expectError(DecodingException.class) + .verify(); + } + + @Test + void useBigDecimalForFloats() { + Flux source = Flux.just(stringBuffer("1E+2")); + Flux tokens = JacksonTokenizer.tokenize( + source, this.objectMapper, false, true, -1); + + StepVerifier.create(tokens) + .assertNext(tokenBuffer -> { + JsonParser parser = tokenBuffer.asParser(); + JsonToken token = parser.nextToken(); + assertThat(token).isEqualTo(JsonToken.VALUE_NUMBER_FLOAT); + JsonParser.NumberType numberType = parser.getNumberType(); + assertThat(numberType).isEqualTo(JsonParser.NumberType.BIG_DECIMAL); + }) + .verifyComplete(); + } + + // gh-31747 + @Test + void compositeNettyBuffer() { + ByteBufAllocator allocator = UnpooledByteBufAllocator.DEFAULT; + ByteBuf firstByteBuf = allocator.buffer(); + firstByteBuf.writeBytes("{\"foo\": \"foofoo\"".getBytes(StandardCharsets.UTF_8)); + ByteBuf secondBuf = allocator.buffer(); + secondBuf.writeBytes(", \"bar\": \"barbar\"}".getBytes(StandardCharsets.UTF_8)); + CompositeByteBuf composite = allocator.compositeBuffer(); + composite.addComponent(true, firstByteBuf); + composite.addComponent(true, secondBuf); + + NettyDataBufferFactory bufferFactory = new NettyDataBufferFactory(allocator); + Flux source = Flux.just(bufferFactory.wrap(composite)); + Flux tokens = JacksonTokenizer.tokenize(source, this.objectMapper, false, false, -1); + + Flux strings = tokens.map(this::tokenToString); + + StepVerifier.create(strings) + .assertNext(s -> assertThat(s).isEqualTo("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}")) + .verifyComplete(); + } + + + private Flux decode(List source, boolean tokenize, int maxInMemorySize) { + + Flux tokens = JacksonTokenizer.tokenize( + Flux.fromIterable(source).map(this::stringBuffer), this.objectMapper, tokenize, false, maxInMemorySize); + + return tokens.map(this::tokenToString); + } + + private String tokenToString(TokenBuffer tokenBuffer) { + TreeNode root = this.objectMapper.readTree(tokenBuffer.asParser()); + return this.objectMapper.writeValueAsString(root); + } + + private DataBuffer stringBuffer(String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + DataBuffer buffer = this.bufferFactory.allocateBuffer(bytes.length); + buffer.write(bytes); + return buffer; + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java index a312cd3dbcb..234dea82f2e 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java @@ -31,6 +31,7 @@ import org.springframework.core.io.buffer.DataBufferLimitException; import org.springframework.core.testfixture.io.buffer.AbstractLeakCheckingTests; import org.springframework.http.MediaType; import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.JacksonJsonDecoder; import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; import org.springframework.web.testfixture.xml.Pojo; @@ -44,7 +45,7 @@ import static org.assertj.core.api.Assertions.assertThat; */ class ServerSentEventHttpMessageReaderTests extends AbstractLeakCheckingTests { - private final Jackson2JsonDecoder jsonDecoder = new Jackson2JsonDecoder(); + private final JacksonJsonDecoder jsonDecoder = new JacksonJsonDecoder(); private ServerSentEventHttpMessageReader reader = new ServerSentEventHttpMessageReader(this.jsonDecoder); diff --git a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java index 43d6f01ef63..51fb1ee904b 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java @@ -22,19 +22,19 @@ import java.time.Duration; import java.util.Collections; import java.util.Map; -import com.fasterxml.jackson.databind.ObjectMapper; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; import org.springframework.core.ResolvableType; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.testfixture.io.buffer.AbstractDataBufferAllocatingTests; import org.springframework.http.MediaType; -import org.springframework.http.codec.json.Jackson2JsonEncoder; -import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.codec.json.JacksonJsonEncoder; import org.springframework.web.testfixture.http.server.reactive.MockServerHttpResponse; import org.springframework.web.testfixture.xml.Pojo; @@ -54,7 +54,7 @@ class ServerSentEventHttpMessageWriterTests extends AbstractDataBufferAllocating private static final Map HINTS = Collections.emptyMap(); private ServerSentEventHttpMessageWriter messageWriter = - new ServerSentEventHttpMessageWriter(new Jackson2JsonEncoder()); + new ServerSentEventHttpMessageWriter(new JacksonJsonEncoder()); @ParameterizedDataBufferAllocatingTest @@ -151,10 +151,10 @@ class ServerSentEventHttpMessageWriterTests extends AbstractDataBufferAllocating StepVerifier.create(outputMessage.getBody()) .consumeNextWith(stringConsumer("data:")) - .consumeNextWith(stringConsumer("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}")) + .consumeNextWith(stringConsumer("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")) .consumeNextWith(stringConsumer("\n\n")) .consumeNextWith(stringConsumer("data:")) - .consumeNextWith(stringConsumer("{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}")) + .consumeNextWith(stringConsumer("{\"bar\":\"barbarbar\",\"foo\":\"foofoofoo\"}")) .consumeNextWith(stringConsumer("\n\n")) .expectComplete() .verify(); @@ -164,8 +164,8 @@ class ServerSentEventHttpMessageWriterTests extends AbstractDataBufferAllocating void writePojoWithPrettyPrint(DataBufferFactory bufferFactory) { super.bufferFactory = bufferFactory; - ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().indentOutput(true).build(); - this.messageWriter = new ServerSentEventHttpMessageWriter(new Jackson2JsonEncoder(mapper)); + JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + this.messageWriter = new ServerSentEventHttpMessageWriter(new JacksonJsonEncoder(mapper)); MockServerHttpResponse outputMessage = new MockServerHttpResponse(super.bufferFactory); Flux source = Flux.just(new Pojo("foofoo", "barbar"), new Pojo("foofoofoo", "barbarbar")); @@ -175,15 +175,15 @@ class ServerSentEventHttpMessageWriterTests extends AbstractDataBufferAllocating .consumeNextWith(stringConsumer("data:")) .consumeNextWith(stringConsumer(""" { - data: "foo" : "foofoo", - data: "bar" : "barbar" + data: "bar" : "barbar", + data: "foo" : "foofoo" data:}""")) .consumeNextWith(stringConsumer("\n\n")) .consumeNextWith(stringConsumer("data:")) .consumeNextWith(stringConsumer(""" { - data: "foo" : "foofoofoo", - data: "bar" : "barbarbar" + data: "bar" : "barbarbar", + data: "foo" : "foofoofoo" data:}""")) .consumeNextWith(stringConsumer("\n\n")) .expectComplete() @@ -203,7 +203,7 @@ class ServerSentEventHttpMessageWriterTests extends AbstractDataBufferAllocating assertThat(outputMessage.getHeaders().getContentType()).isEqualTo(mediaType); StepVerifier.create(outputMessage.getBody()) .consumeNextWith(stringConsumer("data:", charset)) - .consumeNextWith(stringConsumer("{\"foo\":\"foo\uD834\uDD1E\",\"bar\":\"bar\uD834\uDD1E\"}", charset)) + .consumeNextWith(stringConsumer("{\"bar\":\"bar\uD834\uDD1E\",\"foo\":\"foo\uD834\uDD1E\"}", charset)) .consumeNextWith(stringConsumer("\n\n", charset)) .expectComplete() .verify(); diff --git a/spring-web/src/test/java/org/springframework/http/codec/cbor/JacksonCborDecoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/cbor/JacksonCborDecoderTests.java new file mode 100644 index 00000000000..13f753037a8 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/cbor/JacksonCborDecoderTests.java @@ -0,0 +1,103 @@ +/* + * 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.codec.cbor; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import tools.jackson.core.JacksonException; +import tools.jackson.dataformat.cbor.CBORMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.testfixture.codec.AbstractDecoderTests; +import org.springframework.http.MediaType; +import org.springframework.web.testfixture.xml.Pojo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.http.MediaType.APPLICATION_JSON; + +/** + * Tests for {@link JacksonCborDecoder}. + * + * @author Sebastien Deleuze + */ +class JacksonCborDecoderTests extends AbstractDecoderTests { + + private Pojo pojo1 = new Pojo("f1", "b1"); + + private Pojo pojo2 = new Pojo("f2", "b2"); + + private CBORMapper mapper = CBORMapper.builder().build(); + + public JacksonCborDecoderTests() { + super(new JacksonCborDecoder()); + } + + @Override + @Test + protected void canDecode() { + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), MediaType.APPLICATION_CBOR)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), null)).isTrue(); + + assertThat(decoder.canDecode(ResolvableType.forClass(String.class), null)).isFalse(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), APPLICATION_JSON)).isFalse(); + } + + @Override + @Test + protected void decode() { + Flux input = Flux.just(this.pojo1, this.pojo2) + .map(this::writeObject) + .flatMap(this::dataBuffer); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> + testDecodeAll(input, Pojo.class, step -> step + .expectNext(pojo1) + .expectNext(pojo2) + .verifyComplete())); + + } + + private byte[] writeObject(Object o) { + try { + return this.mapper.writer().writeValueAsBytes(o); + } + catch (JacksonException e) { + throw new AssertionError(e); + } + + } + + @Override + @Test + protected void decodeToMono() { + List expected = Arrays.asList(pojo1, pojo2); + + Flux input = Flux.just(expected) + .map(this::writeObject) + .flatMap(this::dataBuffer); + + ResolvableType elementType = ResolvableType.forClassWithGenerics(List.class, Pojo.class); + testDecodeToMono(input, elementType, step -> step + .expectNext(expected) + .expectComplete() + .verify(), null, null); + } +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/cbor/JacksonCborEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/cbor/JacksonCborEncoderTests.java new file mode 100644 index 00000000000..aa9bf45ef66 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/cbor/JacksonCborEncoderTests.java @@ -0,0 +1,91 @@ +/* + * 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.codec.cbor; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import tools.jackson.dataformat.cbor.CBORMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.testfixture.io.buffer.AbstractLeakCheckingTests; +import org.springframework.core.testfixture.io.buffer.DataBufferTestUtils; +import org.springframework.http.MediaType; +import org.springframework.web.testfixture.xml.Pojo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.core.io.buffer.DataBufferUtils.release; +import static org.springframework.http.MediaType.APPLICATION_XML; + +/** + * Tests for {@link JacksonCborEncoder}. + * + * @author Sebastien Deleuze + */ +class JacksonCborEncoderTests extends AbstractLeakCheckingTests { + + private final CBORMapper mapper = CBORMapper.builder().build(); + + private final JacksonCborEncoder encoder = new JacksonCborEncoder(); + + private Consumer pojoConsumer(Pojo expected) { + return dataBuffer -> { + Pojo actual = this.mapper.reader().forType(Pojo.class) + .readValue(DataBufferTestUtils.dumpBytes(dataBuffer)); + assertThat(actual).isEqualTo(expected); + release(dataBuffer); + }; + } + + @Test + void canEncode() { + ResolvableType pojoType = ResolvableType.forClass(Pojo.class); + assertThat(this.encoder.canEncode(pojoType, MediaType.APPLICATION_CBOR)).isTrue(); + assertThat(this.encoder.canEncode(pojoType, null)).isTrue(); + + // SPR-15464 + assertThat(this.encoder.canEncode(ResolvableType.NONE, null)).isTrue(); + } + + @Test + void canNotEncode() { + assertThat(this.encoder.canEncode(ResolvableType.forClass(String.class), null)).isFalse(); + assertThat(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), APPLICATION_XML)).isFalse(); + } + + @Test + void encode() { + Pojo value = new Pojo("foo", "bar"); + DataBuffer result = encoder.encodeValue(value, this.bufferFactory, ResolvableType.forClass(Pojo.class), + MediaType.APPLICATION_CBOR, null); + pojoConsumer(value).accept(result); + } + + @Test + void encodeStream() { + Pojo pojo1 = new Pojo("foo", "bar"); + Pojo pojo2 = new Pojo("foofoo", "barbar"); + Pojo pojo3 = new Pojo("foofoofoo", "barbarbar"); + Flux input = Flux.just(pojo1, pojo2, pojo3); + ResolvableType type = ResolvableType.forClass(Pojo.class); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> + encoder.encode(input, this.bufferFactory, type, MediaType.APPLICATION_CBOR, null)); + } +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/CustomizedJacksonJsonDecoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/CustomizedJacksonJsonDecoderTests.java new file mode 100644 index 00000000000..2a2c09bae65 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/json/CustomizedJacksonJsonDecoderTests.java @@ -0,0 +1,123 @@ +/* + * 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.codec.json; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import tools.jackson.databind.ObjectReader; +import tools.jackson.databind.cfg.EnumFeature; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.testfixture.codec.AbstractDecoderTests; + +/** + * Tests for a customized {@link JacksonJsonDecoder}. + * + * @author Sebastien Deleuze + */ +class CustomizedJacksonJsonDecoderTests extends AbstractDecoderTests { + + CustomizedJacksonJsonDecoderTests() { + super(new JacksonJsonDecoderWithCustomization()); + } + + + @Override + public void canDecode() throws Exception { + // Not Testing, covered under JacksonJsonDecoderTests + } + + @Test + @Override + public void decode() throws Exception { + Flux input = Flux.concat(stringBuffer("{\"property\":\"Value1\"}")); + + testDecodeAll(input, MyCustomizedDecoderBean.class, step -> step + .expectNextMatches(obj -> obj.getProperty() == MyCustomDecoderEnum.VAL1) + .verifyComplete()); + } + + @Test + @Override + public void decodeToMono() throws Exception { + Mono input = stringBuffer("{\"property\":\"Value2\"}"); + + ResolvableType elementType = ResolvableType.forClass(MyCustomizedDecoderBean.class); + + testDecodeToMono(input, elementType, step -> step + .expectNextMatches(obj -> ((MyCustomizedDecoderBean)obj).getProperty() == MyCustomDecoderEnum.VAL2) + .expectComplete() + .verify(), null, null); + } + + private Mono stringBuffer(String value) { + return stringBuffer(value, StandardCharsets.UTF_8); + } + + private Mono stringBuffer(String value, Charset charset) { + return Mono.defer(() -> { + byte[] bytes = value.getBytes(charset); + DataBuffer buffer = this.bufferFactory.allocateBuffer(bytes.length); + buffer.write(bytes); + return Mono.just(buffer); + }); + } + + + private static class MyCustomizedDecoderBean { + + private MyCustomDecoderEnum property; + + public MyCustomDecoderEnum getProperty() { + return property; + } + + @SuppressWarnings("unused") + public void setProperty(MyCustomDecoderEnum property) { + this.property = property; + } + } + + + private enum MyCustomDecoderEnum { + VAL1, + VAL2; + + @Override + public String toString() { + return this == VAL1 ? "Value1" : "Value2"; + } + } + + + private static class JacksonJsonDecoderWithCustomization extends JacksonJsonDecoder { + + @Override + protected ObjectReader customizeReader( + ObjectReader reader, ResolvableType elementType, Map hints) { + + return reader.with(EnumFeature.READ_ENUMS_USING_TO_STRING); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/CustomizedJacksonJsonEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/CustomizedJacksonJsonEncoderTests.java new file mode 100644 index 00000000000..9bf5887b6e6 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/json/CustomizedJacksonJsonEncoderTests.java @@ -0,0 +1,121 @@ +/* + * 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.codec.json; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.cfg.EnumFeature; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.testfixture.codec.AbstractEncoderTests; +import org.springframework.util.MimeType; + +import static org.springframework.http.MediaType.APPLICATION_NDJSON; + +/** + * Tests for a customized {@link JacksonJsonEncoder}. + * + * @author Sebastien Deleuze + */ +class CustomizedJacksonJsonEncoderTests extends AbstractEncoderTests { + + CustomizedJacksonJsonEncoderTests() { + super(new JacksonJsonEncoderWithCustomization()); + } + + + @Override + public void canEncode() throws Exception { + // Not Testing, covered under JacksonJsonEncoderTests + } + + @Test + @Override + public void encode() throws Exception { + Flux input = Flux.just( + new MyCustomizedEncoderBean(MyCustomEncoderEnum.VAL1), + new MyCustomizedEncoderBean(MyCustomEncoderEnum.VAL2) + ); + + testEncodeAll(input, ResolvableType.forClass(MyCustomizedEncoderBean.class), APPLICATION_NDJSON, null, step -> step + .consumeNextWith(expectString("{\"property\":\"Value1\"}\n")) + .consumeNextWith(expectString("{\"property\":\"Value2\"}\n")) + .verifyComplete() + ); + } + + @Test + void encodeNonStream() { + Flux input = Flux.just( + new MyCustomizedEncoderBean(MyCustomEncoderEnum.VAL1), + new MyCustomizedEncoderBean(MyCustomEncoderEnum.VAL2) + ); + + testEncode(input, MyCustomizedEncoderBean.class, step -> step + .consumeNextWith(expectString("[{\"property\":\"Value1\"}").andThen(DataBufferUtils::release)) + .consumeNextWith(expectString(",{\"property\":\"Value2\"}").andThen(DataBufferUtils::release)) + .consumeNextWith(expectString("]").andThen(DataBufferUtils::release)) + .verifyComplete()); + } + + + private static class MyCustomizedEncoderBean { + + private MyCustomEncoderEnum property; + + public MyCustomizedEncoderBean(MyCustomEncoderEnum property) { + this.property = property; + } + + @SuppressWarnings("unused") + public MyCustomEncoderEnum getProperty() { + return property; + } + + @SuppressWarnings("unused") + public void setProperty(MyCustomEncoderEnum property) { + this.property = property; + } + } + + + private enum MyCustomEncoderEnum { + VAL1, + VAL2; + + @Override + public String toString() { + return this == VAL1 ? "Value1" : "Value2"; + } + } + + + private static class JacksonJsonEncoderWithCustomization extends JacksonJsonEncoder { + + @Override + protected ObjectWriter customizeWriter( + ObjectWriter writer, MimeType mimeType, ResolvableType elementType, Map hints) { + + return writer.with(EnumFeature.WRITE_ENUMS_USING_TO_STRING); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java new file mode 100644 index 00000000000..6e65929bb9d --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java @@ -0,0 +1,414 @@ +/* + * 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.codec.json; + +import java.math.BigDecimal; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.CodecException; +import org.springframework.core.codec.DecodingException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.testfixture.codec.AbstractDecoderTests; +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.JacksonViewBean.MyJacksonView1; +import org.springframework.http.codec.json.JacksonViewBean.MyJacksonView3; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.web.testfixture.xml.Pojo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.APPLICATION_NDJSON; +import static org.springframework.http.MediaType.APPLICATION_XML; +import static org.springframework.http.codec.JacksonCodecSupport.JSON_VIEW_HINT; + +/** + * Tests for {@link JacksonJsonDecoder}. + * + * @author Sebastien Deleuze + */ +class JacksonJsonDecoderTests extends AbstractDecoderTests { + + private final Pojo pojo1 = new Pojo("f1", "b1"); + + private final Pojo pojo2 = new Pojo("f2", "b2"); + + + public JacksonJsonDecoderTests() { + super(new JacksonJsonDecoder(JsonMapper.builder().build())); + } + + + @Override + @Test + public void canDecode() { + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), APPLICATION_JSON)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), APPLICATION_NDJSON)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), null)).isTrue(); + + assertThat(decoder.canDecode(ResolvableType.forClass(String.class), null)).isFalse(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), APPLICATION_XML)).isFalse(); + assertThat(this.decoder.canDecode(ResolvableType.forClass(Pojo.class), + new MediaType("application", "json", StandardCharsets.UTF_8))).isTrue(); + assertThat(this.decoder.canDecode(ResolvableType.forClass(Pojo.class), + new MediaType("application", "json", StandardCharsets.US_ASCII))).isTrue(); + assertThat(this.decoder.canDecode(ResolvableType.forClass(Pojo.class), + new MediaType("application", "json", StandardCharsets.ISO_8859_1))).isTrue(); + } + + @Test + void canDecodeWithObjectMapperRegistrationForType() { + MediaType halJsonMediaType = MediaType.parseMediaType("application/hal+json"); + MediaType halFormsJsonMediaType = MediaType.parseMediaType("application/prs.hal-forms+json"); + + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), halJsonMediaType)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), MediaType.APPLICATION_JSON)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), halFormsJsonMediaType)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Map.class), MediaType.APPLICATION_JSON)).isTrue(); + + decoder.registerObjectMappersForType(Pojo.class, map -> { + map.put(halJsonMediaType, new ObjectMapper()); + map.put(MediaType.APPLICATION_JSON, new ObjectMapper()); + }); + + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), halJsonMediaType)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), MediaType.APPLICATION_JSON)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), halFormsJsonMediaType)).isFalse(); + assertThat(decoder.canDecode(ResolvableType.forClass(Map.class), MediaType.APPLICATION_JSON)).isTrue(); + + } + + @Test // SPR-15866 + void canDecodeWithProvidedMimeType() { + MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), textJavascript); + + assertThat(decoder.getDecodableMimeTypes()).isEqualTo(Collections.singletonList(textJavascript)); + } + + @Test + @SuppressWarnings("unchecked") + void decodableMimeTypesIsImmutable() { + MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), textJavascript); + + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> + decoder.getDecodableMimeTypes().add(new MimeType("text", "ecmascript"))); + } + + @Test + void decodableMimeTypesWithObjectMapperRegistration() { + MimeType mimeType1 = MediaType.parseMediaType("application/hal+json"); + MimeType mimeType2 = new MimeType("text", "javascript", StandardCharsets.UTF_8); + + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), mimeType2); + decoder.registerObjectMappersForType(Pojo.class, map -> map.put(mimeType1, new ObjectMapper())); + + assertThat(decoder.getDecodableMimeTypes(ResolvableType.forClass(Pojo.class))) + .containsExactly(mimeType1); + } + + @Override + @Test + protected void decode() { + Flux input = Flux.concat( + stringBuffer("[{\"bar\":\"b1\",\"foo\":\"f1\"},"), + stringBuffer("{\"bar\":\"b2\",\"foo\":\"f2\"}]")); + + testDecodeAll(input, Pojo.class, step -> step + .expectNext(pojo1) + .expectNext(pojo2) + .verifyComplete()); + } + + @Override + @Test + protected void decodeToMono() { + Flux input = Flux.concat( + stringBuffer("[{\"bar\":\"b1\",\"foo\":\"f1\"},"), + stringBuffer("{\"bar\":\"b2\",\"foo\":\"f2\"}]")); + + ResolvableType elementType = ResolvableType.forClassWithGenerics(List.class, Pojo.class); + + testDecodeToMonoAll(input, elementType, step -> step + .expectNext(Arrays.asList(new Pojo("f1", "b1"), new Pojo("f2", "b2"))) + .expectComplete() + .verify(), null, null); + } + + @Test + void decodeToFluxWithListElements() { + Flux input = Flux.concat( + stringBuffer("[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"), + stringBuffer("[{\"bar\":\"b3\",\"foo\":\"f3\"},{\"bar\":\"b4\",\"foo\":\"f4\"}]")); + + ResolvableType elementType = ResolvableType.forClassWithGenerics(List.class, Pojo.class); + + testDecodeAll(input, elementType, + step -> step + .expectNext(List.of(pojo1, pojo2)) + .expectNext(List.of(new Pojo("f3", "b3"), new Pojo("f4", "b4"))) + .verifyComplete(), + MimeTypeUtils.APPLICATION_JSON, + Collections.emptyMap()); + } + + @Test + void decodeEmptyArrayToFlux() { + Flux input = Flux.from(stringBuffer("[]")); + + testDecode(input, Pojo.class, StepVerifier.LastStep::verifyComplete); + } + + @Test + void fieldLevelJsonView() { + Flux input = Flux.from(stringBuffer( + "{\"withView1\" : \"with\", \"withView2\" : \"with\", \"withoutView\" : \"without\"}")); + + ResolvableType elementType = ResolvableType.forClass(JacksonViewBean.class); + Map hints = Collections.singletonMap(JSON_VIEW_HINT, MyJacksonView1.class); + + testDecode(input, elementType, step -> step + .consumeNextWith(value -> { + JacksonViewBean bean = (JacksonViewBean) value; + assertThat(bean.getWithView1()).isEqualTo("with"); + assertThat(bean.getWithView2()).isNull(); + assertThat(bean.getWithoutView()).isNull(); + }), null, hints); + } + + @Test + void classLevelJsonView() { + Flux input = Flux.from(stringBuffer( + "{\"withoutView\" : \"without\"}")); + + ResolvableType elementType = ResolvableType.forClass(JacksonViewBean.class); + Map hints = Collections.singletonMap(JSON_VIEW_HINT, MyJacksonView3.class); + + testDecode(input, elementType, step -> step + .consumeNextWith(value -> { + JacksonViewBean bean = (JacksonViewBean) value; + assertThat(bean.getWithoutView()).isEqualTo("without"); + assertThat(bean.getWithView1()).isNull(); + assertThat(bean.getWithView2()).isNull(); + }) + .verifyComplete(), null, hints); + } + + @Test + void invalidData() { + Flux input = Flux.from(stringBuffer("{\"foofoo\": \"foofoo\", \"barbar\": \"barbar\"")); + testDecode(input, Pojo.class, step -> step.verifyError(DecodingException.class)); + } + + @Test // gh-22042 + void decodeWithNullLiteral() { + Flux result = this.decoder.decode(Flux.concat(stringBuffer("null")), + ResolvableType.forType(Pojo.class), MediaType.APPLICATION_JSON, Collections.emptyMap()); + + StepVerifier.create(result).expectComplete().verify(); + } + + @Test // gh-27511 + void noDefaultConstructor() { + Flux input = Flux.from(stringBuffer("{\"property1\":\"foo\",\"property2\":\"bar\"}")); + + testDecode(input, BeanWithNoDefaultConstructor.class, step -> step + .consumeNextWith(o -> { + assertThat(o.getProperty1()).isEqualTo("foo"); + assertThat(o.getProperty2()).isEqualTo("bar"); + }) + .verifyComplete() + ); + } + + @Test + void codecException() { + Flux input = Flux.from(stringBuffer("[")); + ResolvableType elementType = ResolvableType.forClass(BeanWithNoDefaultConstructor.class); + Flux flux = new Jackson2JsonDecoder().decode(input, elementType, null, Collections.emptyMap()); + StepVerifier.create(flux).verifyError(CodecException.class); + } + + @Test // SPR-15975 + void customDeserializer() { + Mono input = stringBuffer("{\"test\": 1}"); + + testDecode(input, TestObject.class, step -> step + .consumeNextWith(o -> assertThat(o.getTest()).isEqualTo(1)) + .verifyComplete() + ); + } + + @Test + void bigDecimalFlux() { + Flux input = stringBuffer("[ 1E+2 ]").flux(); + + testDecode(input, BigDecimal.class, step -> step + .expectNext(new BigDecimal("1E+2")) + .verifyComplete() + ); + } + + @Test + @SuppressWarnings("unchecked") + void decodeNonUtf8Encoding() { + Mono input = stringBuffer("{\"foo\":\"bar\"}", StandardCharsets.UTF_16); + ResolvableType type = ResolvableType.forType(new ParameterizedTypeReference>() {}); + + testDecode(input, type, step -> step + .assertNext(value -> assertThat((Map) value).containsEntry("foo", "bar")) + .verifyComplete(), + MediaType.parseMediaType("application/json; charset=utf-16"), + null); + } + + @Test + @SuppressWarnings("unchecked") + void decodeNonUnicode() { + Flux input = Flux.concat(stringBuffer("{\"føø\":\"bår\"}", StandardCharsets.ISO_8859_1)); + ResolvableType type = ResolvableType.forType(new ParameterizedTypeReference>() {}); + + testDecode(input, type, step -> step + .assertNext(o -> assertThat((Map) o).containsEntry("føø", "bår")) + .verifyComplete(), + MediaType.parseMediaType("application/json; charset=iso-8859-1"), + null); + } + + @Test + @SuppressWarnings("unchecked") + void decodeMonoNonUtf8Encoding() { + Mono input = stringBuffer("{\"foo\":\"bar\"}", StandardCharsets.UTF_16); + ResolvableType type = ResolvableType.forType(new ParameterizedTypeReference>() {}); + + testDecodeToMono(input, type, step -> step + .assertNext(value -> assertThat((Map) value).containsEntry("foo", "bar")) + .verifyComplete(), + MediaType.parseMediaType("application/json; charset=utf-16"), + null); + } + + @Test + @SuppressWarnings("unchecked") + void decodeAscii() { + Flux input = Flux.concat(stringBuffer("{\"foo\":\"bar\"}", StandardCharsets.US_ASCII)); + ResolvableType type = ResolvableType.forType(new ParameterizedTypeReference>() {}); + + testDecode(input, type, step -> step + .assertNext(value -> assertThat((Map) value).containsEntry("foo", "bar")) + .verifyComplete(), + MediaType.parseMediaType("application/json; charset=us-ascii"), + null); + } + + @Test + void cancelWhileDecoding() { + Flux input = Flux.just( + stringBuffer("[{\"bar\":\"b1\",\"foo\":\"f1\"},").block(), + stringBuffer("{\"bar\":\"b2\",\"foo\":\"f2\"}]").block()); + + testDecodeCancel(input, ResolvableType.forClass(Pojo.class), null, null); + } + + + private Mono stringBuffer(String value) { + return stringBuffer(value, StandardCharsets.UTF_8); + } + + private Mono stringBuffer(String value, Charset charset) { + return Mono.defer(() -> { + byte[] bytes = value.getBytes(charset); + DataBuffer buffer = this.bufferFactory.allocateBuffer(bytes.length); + buffer.write(bytes); + return Mono.just(buffer); + }); + } + + + @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 this.property1; + } + + public String getProperty2() { + return this.property2; + } + } + + + @JsonDeserialize(using = Deserializer.class) + private static class TestObject { + + private int test; + + public int getTest() { + return this.test; + } + public void setTest(int test) { + this.test = test; + } + } + + + private static class Deserializer extends StdDeserializer { + + Deserializer() { + super(TestObject.class); + } + + @Override + public TestObject deserialize(JsonParser p, DeserializationContext ctxt) { + JsonNode node = p.readValueAsTree(); + TestObject result = new TestObject(); + result.setTest(node.get("test").asInt()); + return result; + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java new file mode 100644 index 00000000000..35f521619e5 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java @@ -0,0 +1,268 @@ +/* + * 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.codec.json; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.testfixture.codec.AbstractEncoderTests; +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.JacksonViewBean.MyJacksonView1; +import org.springframework.http.codec.json.JacksonViewBean.MyJacksonView3; +import org.springframework.http.converter.json.MappingJacksonValue; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.web.testfixture.xml.Pojo; + +import static java.util.Collections.singletonMap; +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.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.APPLICATION_NDJSON; +import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM; +import static org.springframework.http.MediaType.APPLICATION_XML; +import static org.springframework.http.codec.JacksonCodecSupport.JSON_VIEW_HINT; + +/** + * Tests for {@link JacksonJsonEncoder}. + * @author Sebastien Deleuze + */ +class JacksonJsonEncoderTests extends AbstractEncoderTests { + + public JacksonJsonEncoderTests() { + super(new JacksonJsonEncoder()); + } + + @Override + @Test + public void canEncode() { + ResolvableType pojoType = ResolvableType.forClass(Pojo.class); + assertThat(this.encoder.canEncode(pojoType, APPLICATION_JSON)).isTrue(); + assertThat(this.encoder.canEncode(pojoType, APPLICATION_NDJSON)).isTrue(); + assertThat(this.encoder.canEncode(pojoType, null)).isTrue(); + + assertThat(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), + new MediaType("application", "json", StandardCharsets.UTF_8))).isTrue(); + assertThat(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), + new MediaType("application", "json", StandardCharsets.US_ASCII))).isTrue(); + assertThat(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), + new MediaType("application", "json", StandardCharsets.ISO_8859_1))).isFalse(); + + // SPR-15464 + assertThat(this.encoder.canEncode(ResolvableType.NONE, null)).isTrue(); + + // SPR-15910 + assertThat(this.encoder.canEncode(ResolvableType.forClass(Object.class), APPLICATION_OCTET_STREAM)).isFalse(); + + assertThatThrownBy(() -> this.encoder.canEncode(ResolvableType.forClass(MappingJacksonValue.class), APPLICATION_JSON)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Override + @Test + public void encode() throws Exception { + Flux input = Flux.just(new Pojo("foo", "bar"), + new Pojo("foofoo", "barbar"), + new Pojo("foofoofoo", "barbarbar")); + + testEncodeAll(input, ResolvableType.forClass(Pojo.class), APPLICATION_NDJSON, null, step -> step + .consumeNextWith(expectString("{\"bar\":\"bar\",\"foo\":\"foo\"}\n")) + .consumeNextWith(expectString("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}\n")) + .consumeNextWith(expectString("{\"bar\":\"barbarbar\",\"foo\":\"foofoofoo\"}\n")) + .verifyComplete() + ); + } + + @Test // SPR-15866 + public void canEncodeWithCustomMimeType() { + MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(new ObjectMapper(), textJavascript); + + assertThat(encoder.getEncodableMimeTypes()).isEqualTo(Collections.singletonList(textJavascript)); + } + + @Test + void encodableMimeTypesIsImmutable() { + MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(new ObjectMapper(), textJavascript); + + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> + encoder.getEncodableMimeTypes().add(new MimeType("text", "ecmascript"))); + } + + @Test + void canNotEncode() { + assertThat(this.encoder.canEncode(ResolvableType.forClass(String.class), null)).isFalse(); + assertThat(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), APPLICATION_XML)).isFalse(); + } + + @Test + void encodeNonStream() { + Flux input = Flux.just( + new Pojo("foo", "bar"), + new Pojo("foofoo", "barbar"), + new Pojo("foofoofoo", "barbarbar") + ); + + testEncode(input, Pojo.class, step -> step + .consumeNextWith(expectString("[{\"bar\":\"bar\",\"foo\":\"foo\"}")) + .consumeNextWith(expectString(",{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")) + .consumeNextWith(expectString(",{\"bar\":\"barbarbar\",\"foo\":\"foofoofoo\"}")) + .consumeNextWith(expectString("]")) + .verifyComplete()); + } + + @Test + void encodeNonStreamEmpty() { + testEncode(Flux.empty(), Pojo.class, step -> step + .consumeNextWith(expectString("[")) + .consumeNextWith(expectString("]")) + .verifyComplete()); + } + + @Test // gh-29038 + void encodeNonStreamWithErrorAsFirstSignal() { + String message = "I'm a teapot"; + Flux input = Flux.error(new IllegalStateException(message)); + + Flux output = this.encoder.encode( + input, this.bufferFactory, ResolvableType.forClass(Pojo.class), null, null); + + StepVerifier.create(output).expectErrorMessage(message).verify(); + } + + @Test + void encodeWithType() { + Flux input = Flux.just(new Foo(), new Bar()); + + testEncode(input, ParentClass.class, step -> step + .consumeNextWith(expectString("[{\"type\":\"foo\"}")) + .consumeNextWith(expectString(",{\"type\":\"bar\"}")) + .consumeNextWith(expectString("]")) + .verifyComplete()); + } + + + @Test // SPR-15727 + public void encodeStreamWithCustomStreamingType() { + MediaType fooMediaType = new MediaType("application", "foo"); + MediaType barMediaType = new MediaType("application", "bar"); + this.encoder.setStreamingMediaTypes(Arrays.asList(fooMediaType, barMediaType)); + Flux input = Flux.just( + new Pojo("foo", "bar"), + new Pojo("foofoo", "barbar"), + new Pojo("foofoofoo", "barbarbar") + ); + + testEncode(input, ResolvableType.forClass(Pojo.class), barMediaType, null, step -> step + .consumeNextWith(expectString("{\"bar\":\"bar\",\"foo\":\"foo\"}\n")) + .consumeNextWith(expectString("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}\n")) + .consumeNextWith(expectString("{\"bar\":\"barbarbar\",\"foo\":\"foofoofoo\"}\n")) + .verifyComplete() + ); + } + + @Test + void fieldLevelJsonView() { + JacksonViewBean bean = new JacksonViewBean(); + bean.setWithView1("with"); + bean.setWithView2("with"); + bean.setWithoutView("without"); + Mono input = Mono.just(bean); + + ResolvableType type = ResolvableType.forClass(JacksonViewBean.class); + Map hints = singletonMap(JSON_VIEW_HINT, MyJacksonView1.class); + + testEncode(input, type, null, hints, step -> step + .consumeNextWith(expectString("{\"withView1\":\"with\"}")) + .verifyComplete() + ); + } + + @Test + void classLevelJsonView() { + JacksonViewBean bean = new JacksonViewBean(); + bean.setWithView1("with"); + bean.setWithView2("with"); + bean.setWithoutView("without"); + Mono input = Mono.just(bean); + + ResolvableType type = ResolvableType.forClass(JacksonViewBean.class); + Map hints = singletonMap(JSON_VIEW_HINT, MyJacksonView3.class); + + testEncode(input, type, null, hints, step -> step + .consumeNextWith(expectString("{\"withoutView\":\"without\"}")) + .verifyComplete() + ); + } + + @Test // gh-22771 + public void encodeWithFlushAfterWriteOff() { + ObjectMapper mapper = JsonMapper.builder().configure(SerializationFeature.FLUSH_AFTER_WRITE_VALUE, false).build(); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(mapper); + + Flux result = encoder.encode(Flux.just(new Pojo("foo", "bar")), this.bufferFactory, + ResolvableType.forClass(Pojo.class), MimeTypeUtils.APPLICATION_JSON, Collections.emptyMap()); + + StepVerifier.create(result) + .consumeNextWith(expectString("[{\"bar\":\"bar\",\"foo\":\"foo\"}")) + .consumeNextWith(expectString("]")) + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + + @Test + void encodeAscii() { + Mono input = Mono.just(new Pojo("foo", "bar")); + MimeType mimeType = new MimeType("application", "json", StandardCharsets.US_ASCII); + + testEncode(input, ResolvableType.forClass(Pojo.class), mimeType, null, step -> step + .consumeNextWith(expectString("{\"bar\":\"bar\",\"foo\":\"foo\"}")) + .verifyComplete() + ); + } + + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") + private static class ParentClass { + } + + @JsonTypeName("foo") + private static class Foo extends ParentClass { + } + + @JsonTypeName("bar") + private static class Bar extends ParentClass { + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/smile/JacksonSmileDecoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/smile/JacksonSmileDecoderTests.java new file mode 100644 index 00000000000..ae1bcf72f4a --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/smile/JacksonSmileDecoderTests.java @@ -0,0 +1,100 @@ +/* + * 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.codec.smile; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import tools.jackson.dataformat.smile.SmileMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.testfixture.codec.AbstractDecoderTests; +import org.springframework.util.MimeType; +import org.springframework.web.testfixture.xml.Pojo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_JSON; + +/** + * Tests for {@link JacksonSmileDecoder}. + * + * @author Sebastien Deleuze + */ +class JacksonSmileDecoderTests extends AbstractDecoderTests { + + private static final MimeType SMILE_MIME_TYPE = new MimeType("application", "x-jackson-smile"); + private static final MimeType STREAM_SMILE_MIME_TYPE = new MimeType("application", "stream+x-jackson-smile"); + + private Pojo pojo1 = new Pojo("f1", "b1"); + + private Pojo pojo2 = new Pojo("f2", "b2"); + + private SmileMapper mapper = SmileMapper.builder().build(); + + public JacksonSmileDecoderTests() { + super(new JacksonSmileDecoder()); + } + + @Override + @Test + protected void canDecode() { + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), SMILE_MIME_TYPE)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), STREAM_SMILE_MIME_TYPE)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), null)).isTrue(); + + assertThat(decoder.canDecode(ResolvableType.forClass(String.class), null)).isFalse(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), APPLICATION_JSON)).isFalse(); + } + + @Override + @Test + protected void decode() { + Flux input = Flux.just(this.pojo1, this.pojo2) + .map(this::writeObject) + .flatMap(this::dataBuffer); + + testDecodeAll(input, Pojo.class, step -> step + .expectNext(pojo1) + .expectNext(pojo2) + .verifyComplete()); + + } + + private byte[] writeObject(Object o) { + return this.mapper.writer().writeValueAsBytes(o); + } + + @Override + @Test + protected void decodeToMono() { + List expected = Arrays.asList(pojo1, pojo2); + + Flux input = Flux.just(expected) + .map(this::writeObject) + .flatMap(this::dataBuffer); + + ResolvableType elementType = ResolvableType.forClassWithGenerics(List.class, Pojo.class); + testDecodeToMono(input, elementType, step -> step + .expectNext(expected) + .expectComplete() + .verify(), null, null); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/smile/JacksonSmileEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/smile/JacksonSmileEncoderTests.java new file mode 100644 index 00000000000..3761a4868bc --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/smile/JacksonSmileEncoderTests.java @@ -0,0 +1,130 @@ +/* + * 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.codec.smile; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import tools.jackson.databind.MappingIterator; +import tools.jackson.dataformat.smile.SmileMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.testfixture.codec.AbstractEncoderTests; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.http.codec.json.Jackson2SmileEncoder; +import org.springframework.util.MimeType; +import org.springframework.web.testfixture.xml.Pojo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.core.io.buffer.DataBufferUtils.release; +import static org.springframework.http.MediaType.APPLICATION_XML; + +/** + * Tests for {@link JacksonSmileEncoder}. + * + * @author Sebastien Deleuze + */ +class JacksonSmileEncoderTests extends AbstractEncoderTests { + + private static final MimeType SMILE_MIME_TYPE = new MimeType("application", "x-jackson-smile"); + private static final MimeType STREAM_SMILE_MIME_TYPE = new MimeType("application", "stream+x-jackson-smile"); + + private final Jackson2SmileEncoder encoder = new Jackson2SmileEncoder(); + + private final SmileMapper mapper = SmileMapper.builder().build(); + + public JacksonSmileEncoderTests() { + super(new JacksonSmileEncoder()); + + } + + @Override + @Test + protected void canEncode() { + ResolvableType pojoType = ResolvableType.forClass(Pojo.class); + assertThat(this.encoder.canEncode(pojoType, SMILE_MIME_TYPE)).isTrue(); + assertThat(this.encoder.canEncode(pojoType, STREAM_SMILE_MIME_TYPE)).isTrue(); + assertThat(this.encoder.canEncode(pojoType, null)).isTrue(); + + // SPR-15464 + assertThat(this.encoder.canEncode(ResolvableType.NONE, null)).isTrue(); + } + + @Test + void canNotEncode() { + assertThat(this.encoder.canEncode(ResolvableType.forClass(String.class), null)).isFalse(); + assertThat(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), APPLICATION_XML)).isFalse(); + + ResolvableType sseType = ResolvableType.forClass(ServerSentEvent.class); + assertThat(this.encoder.canEncode(sseType, SMILE_MIME_TYPE)).isFalse(); + } + + @Override + @Test + protected void encode() { + List list = Arrays.asList( + new Pojo("foo", "bar"), + new Pojo("foofoo", "barbar"), + new Pojo("foofoofoo", "barbarbar")); + + Flux input = Flux.fromIterable(list); + + testEncode(input, Pojo.class, step -> step + .consumeNextWith(dataBuffer -> { + try { + Object actual = this.mapper.reader().forType(List.class) + .readValue(dataBuffer.asInputStream()); + assertThat(actual).isEqualTo(list); + } + finally { + release(dataBuffer); + } + })); + } + + @Test + void encodeError() { + Mono input = Mono.error(new InputException()); + testEncode(input, Pojo.class, step -> step.expectError(InputException.class).verify()); + } + + @Test + void encodeAsStream() { + Pojo pojo1 = new Pojo("foo", "bar"); + Pojo pojo2 = new Pojo("foofoo", "barbar"); + Pojo pojo3 = new Pojo("foofoofoo", "barbarbar"); + Flux input = Flux.just(pojo1, pojo2, pojo3); + ResolvableType type = ResolvableType.forClass(Pojo.class); + + Flux result = this.encoder + .encode(input, bufferFactory, type, STREAM_SMILE_MIME_TYPE, null); + + Mono> joined = DataBufferUtils.join(result) + .map(buffer -> this.mapper.reader().forType(Pojo.class).readValues(buffer.asInputStream(true))); + + StepVerifier.create(joined) + .assertNext(iter -> assertThat(iter).toIterable().contains(pojo1, pojo2, pojo3)) + .verifyComplete(); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/support/ClientCodecConfigurerTests.java b/spring-web/src/test/java/org/springframework/http/codec/support/ClientCodecConfigurerTests.java index 049d46d4020..317eeef2f07 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/support/ClientCodecConfigurerTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/support/ClientCodecConfigurerTests.java @@ -23,7 +23,6 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -55,11 +54,8 @@ import org.springframework.http.codec.ResourceHttpMessageWriter; import org.springframework.http.codec.ServerSentEventHttpMessageReader; import org.springframework.http.codec.cbor.KotlinSerializationCborDecoder; import org.springframework.http.codec.cbor.KotlinSerializationCborEncoder; -import org.springframework.http.codec.json.Jackson2CodecSupport; -import org.springframework.http.codec.json.Jackson2JsonDecoder; -import org.springframework.http.codec.json.Jackson2JsonEncoder; -import org.springframework.http.codec.json.Jackson2SmileDecoder; -import org.springframework.http.codec.json.Jackson2SmileEncoder; +import org.springframework.http.codec.json.JacksonJsonDecoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader; import org.springframework.http.codec.multipart.MultipartHttpMessageReader; import org.springframework.http.codec.multipart.MultipartHttpMessageWriter; @@ -70,6 +66,8 @@ import org.springframework.http.codec.protobuf.KotlinSerializationProtobufDecode import org.springframework.http.codec.protobuf.KotlinSerializationProtobufEncoder; import org.springframework.http.codec.protobuf.ProtobufDecoder; import org.springframework.http.codec.protobuf.ProtobufHttpMessageWriter; +import org.springframework.http.codec.smile.JacksonSmileDecoder; +import org.springframework.http.codec.smile.JacksonSmileEncoder; import org.springframework.http.codec.xml.Jaxb2XmlDecoder; import org.springframework.http.codec.xml.Jaxb2XmlEncoder; import org.springframework.util.MimeTypeUtils; @@ -81,6 +79,7 @@ import static org.springframework.core.ResolvableType.forClass; * Tests for {@link ClientCodecConfigurer}. * * @author Rossen Stoyanchev + * @author Sebastien Deleuze */ class ClientCodecConfigurerTests { @@ -107,8 +106,8 @@ class ClientCodecConfigurerTests { assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(PartEventHttpMessageReader.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(KotlinSerializationCborDecoder.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(KotlinSerializationProtobufDecoder.class); - assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2JsonDecoder.class); - assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2SmileDecoder.class); + assertThat(getNextDecoder(readers).getClass()).isEqualTo(JacksonJsonDecoder.class); + assertThat(getNextDecoder(readers).getClass()).isEqualTo(JacksonSmileDecoder.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jaxb2XmlDecoder.class); assertSseReader(readers); assertStringDecoder(getNextDecoder(readers), false); @@ -130,55 +129,33 @@ class ClientCodecConfigurerTests { assertThat(writers.get(this.index.getAndIncrement()).getClass()).isEqualTo(PartHttpMessageWriter.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(KotlinSerializationCborEncoder.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(KotlinSerializationProtobufEncoder.class); - assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jackson2JsonEncoder.class); - assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jackson2SmileEncoder.class); + assertThat(getNextEncoder(writers).getClass()).isEqualTo(JacksonJsonEncoder.class); + assertThat(getNextEncoder(writers).getClass()).isEqualTo(JacksonSmileEncoder.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jaxb2XmlEncoder.class); assertStringEncoder(getNextEncoder(writers), false); } @Test - void jackson2CodecCustomization() { - Jackson2JsonDecoder decoder = new Jackson2JsonDecoder(); - Jackson2JsonEncoder encoder = new Jackson2JsonEncoder(); - this.configurer.defaultCodecs().jackson2JsonDecoder(decoder); - this.configurer.defaultCodecs().jackson2JsonEncoder(encoder); + void jacksonCodecCustomization() { + JacksonJsonDecoder decoder = new JacksonJsonDecoder(); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(); + this.configurer.defaultCodecs().jacksonJsonDecoder(decoder); + this.configurer.defaultCodecs().jacksonJsonEncoder(encoder); List> readers = this.configurer.getReaders(); - Jackson2JsonDecoder actualDecoder = findCodec(readers, Jackson2JsonDecoder.class); + JacksonJsonDecoder actualDecoder = findCodec(readers, JacksonJsonDecoder.class); assertThat(actualDecoder).isSameAs(decoder); assertThat(findCodec(readers, ServerSentEventHttpMessageReader.class).getDecoder()).isSameAs(decoder); List> writers = this.configurer.getWriters(); - Jackson2JsonEncoder actualEncoder = findCodec(writers, Jackson2JsonEncoder.class); + JacksonJsonEncoder actualEncoder = findCodec(writers, JacksonJsonEncoder.class); assertThat(actualEncoder).isSameAs(encoder); MultipartHttpMessageWriter multipartWriter = findCodec(writers, MultipartHttpMessageWriter.class); - actualEncoder = findCodec(multipartWriter.getPartWriters(), Jackson2JsonEncoder.class); + actualEncoder = findCodec(multipartWriter.getPartWriters(), JacksonJsonEncoder.class); assertThat(actualEncoder).isSameAs(encoder); } - @Test - void objectMapperCustomization() { - ObjectMapper objectMapper = new ObjectMapper(); - this.configurer.defaultCodecs().configureDefaultCodec(codec -> { - if (codec instanceof Jackson2CodecSupport) { - ((Jackson2CodecSupport) codec).setObjectMapper(objectMapper); - } - }); - - List> readers = this.configurer.getReaders(); - Jackson2JsonDecoder actualDecoder = findCodec(readers, Jackson2JsonDecoder.class); - assertThat(actualDecoder.getObjectMapper()).isSameAs(objectMapper); - - List> writers = this.configurer.getWriters(); - Jackson2JsonEncoder actualEncoder = findCodec(writers, Jackson2JsonEncoder.class); - assertThat(actualEncoder.getObjectMapper()).isSameAs(objectMapper); - - MultipartHttpMessageWriter multipartWriter = findCodec(writers, MultipartHttpMessageWriter.class); - actualEncoder = findCodec(multipartWriter.getPartWriters(), Jackson2JsonEncoder.class); - assertThat(actualEncoder.getObjectMapper()).isSameAs(objectMapper); - } - @Test void maxInMemorySize() { int size = 99; @@ -198,13 +175,13 @@ class ClientCodecConfigurerTests { assertThat(((PartEventHttpMessageReader) nextReader(readers)).getMaxInMemorySize()).isEqualTo(size); assertThat(((KotlinSerializationCborDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); assertThat(((KotlinSerializationProtobufDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); - assertThat(((Jackson2JsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); - assertThat(((Jackson2SmileDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); + assertThat(((JacksonJsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); + assertThat(((JacksonSmileDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); assertThat(((Jaxb2XmlDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); ServerSentEventHttpMessageReader reader = (ServerSentEventHttpMessageReader) nextReader(readers); assertThat(reader.getMaxInMemorySize()).isEqualTo(size); - assertThat(((Jackson2JsonDecoder) reader.getDecoder()).getMaxInMemorySize()).isEqualTo(size); + assertThat(((JacksonJsonDecoder) reader.getDecoder()).getMaxInMemorySize()).isEqualTo(size); assertThat(((StringDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); } @@ -226,9 +203,9 @@ class ClientCodecConfigurerTests { void clonedConfigurer() { ClientCodecConfigurer clone = this.configurer.clone(); - Jackson2JsonDecoder jackson2Decoder = new Jackson2JsonDecoder(); - clone.defaultCodecs().serverSentEventDecoder(jackson2Decoder); - clone.defaultCodecs().multipartCodecs().encoder(new Jackson2SmileEncoder()); + JacksonJsonDecoder jacksonDecoder = new JacksonJsonDecoder(); + clone.defaultCodecs().serverSentEventDecoder(jacksonDecoder); + clone.defaultCodecs().multipartCodecs().encoder(new JacksonSmileEncoder()); clone.defaultCodecs().multipartCodecs().writer(new ResourceHttpMessageWriter()); // Clone has the customizations @@ -236,7 +213,7 @@ class ClientCodecConfigurerTests { Decoder sseDecoder = findCodec(clone.getReaders(), ServerSentEventHttpMessageReader.class).getDecoder(); List> writers = findCodec(clone.getWriters(), MultipartHttpMessageWriter.class).getPartWriters(); - assertThat(sseDecoder).isSameAs(jackson2Decoder); + assertThat(sseDecoder).isSameAs(jacksonDecoder); assertThat(writers).hasSize(2); // Original does not have the customizations @@ -244,7 +221,7 @@ class ClientCodecConfigurerTests { sseDecoder = findCodec(this.configurer.getReaders(), ServerSentEventHttpMessageReader.class).getDecoder(); writers = findCodec(this.configurer.getWriters(), MultipartHttpMessageWriter.class).getPartWriters(); - assertThat(sseDecoder).isNotSameAs(jackson2Decoder); + assertThat(sseDecoder).isNotSameAs(jacksonDecoder); assertThat(writers).hasSize(16); } @@ -264,7 +241,7 @@ class ClientCodecConfigurerTests { ClientCodecConfigurer clone = this.configurer.clone(); this.configurer.registerDefaults(false); - this.configurer.customCodecs().register(new Jackson2JsonEncoder()); + this.configurer.customCodecs().register(new JacksonJsonEncoder()); List> writers = findCodec(clone.getWriters(), MultipartHttpMessageWriter.class).getPartWriters(); @@ -332,7 +309,7 @@ class ClientCodecConfigurerTests { assertThat(reader.getClass()).isEqualTo(ServerSentEventHttpMessageReader.class); Decoder decoder = ((ServerSentEventHttpMessageReader) reader).getDecoder(); assertThat(decoder).isNotNull(); - assertThat(decoder.getClass()).isEqualTo(Jackson2JsonDecoder.class); + assertThat(decoder.getClass()).isEqualTo(JacksonJsonDecoder.class); } } diff --git a/spring-web/src/test/java/org/springframework/http/codec/support/CodecConfigurerTests.java b/spring-web/src/test/java/org/springframework/http/codec/support/CodecConfigurerTests.java index 45c8436659b..085f5187cf0 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/support/CodecConfigurerTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/support/CodecConfigurerTests.java @@ -49,10 +49,8 @@ import org.springframework.http.codec.ServerSentEventHttpMessageReader; import org.springframework.http.codec.ServerSentEventHttpMessageWriter; import org.springframework.http.codec.cbor.KotlinSerializationCborDecoder; import org.springframework.http.codec.cbor.KotlinSerializationCborEncoder; -import org.springframework.http.codec.json.Jackson2JsonDecoder; -import org.springframework.http.codec.json.Jackson2JsonEncoder; -import org.springframework.http.codec.json.Jackson2SmileDecoder; -import org.springframework.http.codec.json.Jackson2SmileEncoder; +import org.springframework.http.codec.json.JacksonJsonDecoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader; import org.springframework.http.codec.multipart.MultipartHttpMessageReader; import org.springframework.http.codec.multipart.MultipartHttpMessageWriter; @@ -64,6 +62,8 @@ import org.springframework.http.codec.protobuf.KotlinSerializationProtobufEncode import org.springframework.http.codec.protobuf.ProtobufDecoder; import org.springframework.http.codec.protobuf.ProtobufEncoder; import org.springframework.http.codec.protobuf.ProtobufHttpMessageWriter; +import org.springframework.http.codec.smile.JacksonSmileDecoder; +import org.springframework.http.codec.smile.JacksonSmileEncoder; import org.springframework.http.codec.xml.Jaxb2XmlDecoder; import org.springframework.http.codec.xml.Jaxb2XmlEncoder; import org.springframework.util.MimeTypeUtils; @@ -102,8 +102,8 @@ class CodecConfigurerTests { assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(PartEventHttpMessageReader.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(KotlinSerializationCborDecoder.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(KotlinSerializationProtobufDecoder.class); - assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2JsonDecoder.class); - assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2SmileDecoder.class); + assertThat(getNextDecoder(readers).getClass()).isEqualTo(JacksonJsonDecoder.class); + assertThat(getNextDecoder(readers).getClass()).isEqualTo(JacksonSmileDecoder.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jaxb2XmlDecoder.class); assertStringDecoder(getNextDecoder(readers), false); } @@ -124,8 +124,8 @@ class CodecConfigurerTests { assertThat(writers.get(index.getAndIncrement()).getClass()).isEqualTo(PartHttpMessageWriter.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(KotlinSerializationCborEncoder.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(KotlinSerializationProtobufEncoder.class); - assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jackson2JsonEncoder.class); - assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jackson2SmileEncoder.class); + assertThat(getNextEncoder(writers).getClass()).isEqualTo(JacksonJsonEncoder.class); + assertThat(getNextEncoder(writers).getClass()).isEqualTo(JacksonSmileEncoder.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jaxb2XmlEncoder.class); assertStringEncoder(getNextEncoder(writers), false); } @@ -170,8 +170,8 @@ class CodecConfigurerTests { assertThat(readers.get(this.index.getAndIncrement())).isSameAs(customReader2); assertThat(getNextDecoder(readers).getClass()).isEqualTo(KotlinSerializationCborDecoder.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(KotlinSerializationProtobufDecoder.class); - assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2JsonDecoder.class); - assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2SmileDecoder.class); + assertThat(getNextDecoder(readers).getClass()).isEqualTo(JacksonJsonDecoder.class); + assertThat(getNextDecoder(readers).getClass()).isEqualTo(JacksonSmileDecoder.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jaxb2XmlDecoder.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(StringDecoder.class); } @@ -215,8 +215,8 @@ class CodecConfigurerTests { assertThat(writers.get(this.index.getAndIncrement())).isSameAs(customWriter2); assertThat(getNextEncoder(writers).getClass()).isEqualTo(KotlinSerializationCborEncoder.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(KotlinSerializationProtobufEncoder.class); - assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jackson2JsonEncoder.class); - assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jackson2SmileEncoder.class); + assertThat(getNextEncoder(writers).getClass()).isEqualTo(JacksonJsonEncoder.class); + assertThat(getNextEncoder(writers).getClass()).isEqualTo(JacksonSmileEncoder.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jaxb2XmlEncoder.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(CharSequenceEncoder.class); } @@ -285,19 +285,19 @@ class CodecConfigurerTests { @Test void encoderDecoderOverrides() { - Jackson2JsonDecoder jacksonDecoder = new Jackson2JsonDecoder(); - Jackson2JsonEncoder jacksonEncoder = new Jackson2JsonEncoder(); - Jackson2SmileDecoder smileDecoder = new Jackson2SmileDecoder(); - Jackson2SmileEncoder smileEncoder = new Jackson2SmileEncoder(); + JacksonJsonDecoder jacksonDecoder = new JacksonJsonDecoder(); + JacksonJsonEncoder jacksonEncoder = new JacksonJsonEncoder(); + JacksonSmileDecoder smileDecoder = new JacksonSmileDecoder(); + JacksonSmileEncoder smileEncoder = new JacksonSmileEncoder(); ProtobufDecoder protobufDecoder = new ProtobufDecoder(ExtensionRegistry.newInstance()); ProtobufEncoder protobufEncoder = new ProtobufEncoder(); Jaxb2XmlEncoder jaxb2Encoder = new Jaxb2XmlEncoder(); Jaxb2XmlDecoder jaxb2Decoder = new Jaxb2XmlDecoder(); - this.configurer.defaultCodecs().jackson2JsonDecoder(jacksonDecoder); - this.configurer.defaultCodecs().jackson2JsonEncoder(jacksonEncoder); - this.configurer.defaultCodecs().jackson2SmileDecoder(smileDecoder); - this.configurer.defaultCodecs().jackson2SmileEncoder(smileEncoder); + this.configurer.defaultCodecs().jacksonJsonDecoder(jacksonDecoder); + this.configurer.defaultCodecs().jacksonJsonEncoder(jacksonEncoder); + this.configurer.defaultCodecs().jacksonSmileDecoder(smileDecoder); + this.configurer.defaultCodecs().jacksonSmileEncoder(smileEncoder); this.configurer.defaultCodecs().protobufDecoder(protobufDecoder); this.configurer.defaultCodecs().protobufEncoder(protobufEncoder); this.configurer.defaultCodecs().jaxb2Decoder(jaxb2Decoder); @@ -320,8 +320,8 @@ class CodecConfigurerTests { assertThat(this.configurer.getWriters()).isEmpty(); CodecConfigurer clone = this.configurer.clone(); - clone.customCodecs().register(new Jackson2JsonEncoder()); - clone.customCodecs().register(new Jackson2JsonDecoder()); + clone.customCodecs().register(new JacksonJsonEncoder()); + clone.customCodecs().register(new JacksonJsonDecoder()); clone.customCodecs().register(new ServerSentEventHttpMessageReader()); clone.customCodecs().register(new ServerSentEventHttpMessageWriter()); @@ -337,8 +337,8 @@ class CodecConfigurerTests { assertThat(this.configurer.getReaders()).isEmpty(); assertThat(this.configurer.getWriters()).isEmpty(); - this.configurer.customCodecs().register(new Jackson2JsonEncoder()); - this.configurer.customCodecs().register(new Jackson2JsonDecoder()); + this.configurer.customCodecs().register(new JacksonJsonEncoder()); + this.configurer.customCodecs().register(new JacksonJsonDecoder()); this.configurer.customCodecs().register(new ServerSentEventHttpMessageReader()); this.configurer.customCodecs().register(new ServerSentEventHttpMessageWriter()); assertThat(this.configurer.getReaders()).hasSize(2); @@ -355,15 +355,15 @@ class CodecConfigurerTests { void cloneDefaultCodecs() { CodecConfigurer clone = this.configurer.clone(); - Jackson2JsonDecoder jacksonDecoder = new Jackson2JsonDecoder(); - Jackson2JsonEncoder jacksonEncoder = new Jackson2JsonEncoder(); + JacksonJsonDecoder jacksonDecoder = new JacksonJsonDecoder(); + JacksonJsonEncoder jacksonEncoder = new JacksonJsonEncoder(); Jaxb2XmlDecoder jaxb2Decoder = new Jaxb2XmlDecoder(); Jaxb2XmlEncoder jaxb2Encoder = new Jaxb2XmlEncoder(); ProtobufDecoder protoDecoder = new ProtobufDecoder(); ProtobufEncoder protoEncoder = new ProtobufEncoder(); - clone.defaultCodecs().jackson2JsonDecoder(jacksonDecoder); - clone.defaultCodecs().jackson2JsonEncoder(jacksonEncoder); + clone.defaultCodecs().jacksonJsonDecoder(jacksonDecoder); + clone.defaultCodecs().jacksonJsonEncoder(jacksonEncoder); clone.defaultCodecs().jaxb2Decoder(jaxb2Decoder); clone.defaultCodecs().jaxb2Encoder(jaxb2Encoder); clone.defaultCodecs().protobufDecoder(protoDecoder); diff --git a/spring-web/src/test/java/org/springframework/http/codec/support/ServerCodecConfigurerTests.java b/spring-web/src/test/java/org/springframework/http/codec/support/ServerCodecConfigurerTests.java index 7dd1508da5e..c8fafad54a0 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/support/ServerCodecConfigurerTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/support/ServerCodecConfigurerTests.java @@ -54,10 +54,8 @@ import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.http.codec.ServerSentEventHttpMessageWriter; import org.springframework.http.codec.cbor.KotlinSerializationCborDecoder; import org.springframework.http.codec.cbor.KotlinSerializationCborEncoder; -import org.springframework.http.codec.json.Jackson2JsonDecoder; -import org.springframework.http.codec.json.Jackson2JsonEncoder; -import org.springframework.http.codec.json.Jackson2SmileDecoder; -import org.springframework.http.codec.json.Jackson2SmileEncoder; +import org.springframework.http.codec.json.JacksonJsonDecoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader; import org.springframework.http.codec.multipart.MultipartHttpMessageReader; import org.springframework.http.codec.multipart.MultipartHttpMessageWriter; @@ -68,6 +66,8 @@ import org.springframework.http.codec.protobuf.KotlinSerializationProtobufDecode import org.springframework.http.codec.protobuf.KotlinSerializationProtobufEncoder; import org.springframework.http.codec.protobuf.ProtobufDecoder; import org.springframework.http.codec.protobuf.ProtobufHttpMessageWriter; +import org.springframework.http.codec.smile.JacksonSmileDecoder; +import org.springframework.http.codec.smile.JacksonSmileEncoder; import org.springframework.http.codec.xml.Jaxb2XmlDecoder; import org.springframework.http.codec.xml.Jaxb2XmlEncoder; import org.springframework.util.MimeTypeUtils; @@ -79,6 +79,7 @@ import static org.springframework.core.ResolvableType.forClass; * Tests for {@link ServerCodecConfigurer}. * * @author Rossen Stoyanchev + * @author Sebastien Deleuze */ class ServerCodecConfigurerTests { @@ -104,8 +105,8 @@ class ServerCodecConfigurerTests { assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(PartEventHttpMessageReader.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(KotlinSerializationCborDecoder.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(KotlinSerializationProtobufDecoder.class); - assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2JsonDecoder.class); - assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2SmileDecoder.class); + assertThat(getNextDecoder(readers).getClass()).isEqualTo(JacksonJsonDecoder.class); + assertThat(getNextDecoder(readers).getClass()).isEqualTo(JacksonSmileDecoder.class); assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jaxb2XmlDecoder.class); assertStringDecoder(getNextDecoder(readers), false); } @@ -126,26 +127,26 @@ class ServerCodecConfigurerTests { assertThat(writers.get(this.index.getAndIncrement()).getClass()).isEqualTo(PartHttpMessageWriter.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(KotlinSerializationCborEncoder.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(KotlinSerializationProtobufEncoder.class); - assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jackson2JsonEncoder.class); - assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jackson2SmileEncoder.class); + assertThat(getNextEncoder(writers).getClass()).isEqualTo(JacksonJsonEncoder.class); + assertThat(getNextEncoder(writers).getClass()).isEqualTo(JacksonSmileEncoder.class); assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jaxb2XmlEncoder.class); assertSseWriter(writers); assertStringEncoder(getNextEncoder(writers), false); } @Test - void jackson2EncoderOverride() { - Jackson2JsonDecoder decoder = new Jackson2JsonDecoder(); - Jackson2JsonEncoder encoder = new Jackson2JsonEncoder(); - this.configurer.defaultCodecs().jackson2JsonDecoder(decoder); - this.configurer.defaultCodecs().jackson2JsonEncoder(encoder); + void jacksonEncoderOverride() { + JacksonJsonDecoder decoder = new JacksonJsonDecoder(); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(); + this.configurer.defaultCodecs().jacksonJsonDecoder(decoder); + this.configurer.defaultCodecs().jacksonJsonEncoder(encoder); List> readers = this.configurer.getReaders(); - Jackson2JsonDecoder actualDecoder = findCodec(readers, Jackson2JsonDecoder.class); + JacksonJsonDecoder actualDecoder = findCodec(readers, JacksonJsonDecoder.class); assertThat(actualDecoder).isSameAs(decoder); List> writers = this.configurer.getWriters(); - Jackson2JsonEncoder actualEncoder = findCodec(writers, Jackson2JsonEncoder.class); + JacksonJsonEncoder actualEncoder = findCodec(writers, JacksonJsonEncoder.class); assertThat(actualEncoder).isSameAs(encoder); assertThat(findCodec(writers, ServerSentEventHttpMessageWriter.class).getEncoder()).isSameAs(encoder); } @@ -173,8 +174,8 @@ class ServerCodecConfigurerTests { assertThat(((KotlinSerializationCborDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); assertThat(((KotlinSerializationProtobufDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); - assertThat(((Jackson2JsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); - assertThat(((Jackson2SmileDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); + assertThat(((JacksonJsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); + assertThat(((JacksonSmileDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); assertThat(((Jaxb2XmlDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); assertThat(((StringDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); } @@ -189,16 +190,16 @@ class ServerCodecConfigurerTests { CodecConfigurer.CustomCodecs customCodecs = this.configurer.customCodecs(); customCodecs.register(new ByteArrayDecoder()); customCodecs.registerWithDefaultConfig(new ByteArrayDecoder()); - customCodecs.register(new Jackson2JsonDecoder()); - customCodecs.registerWithDefaultConfig(new Jackson2JsonDecoder()); + customCodecs.register(new JacksonJsonDecoder()); + customCodecs.registerWithDefaultConfig(new JacksonJsonDecoder()); this.configurer.defaultCodecs().enableLoggingRequestDetails(true); List> readers = this.configurer.getReaders(); assertThat(((ByteArrayDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(256 * 1024); assertThat(((ByteArrayDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); - assertThat(((Jackson2JsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(256 * 1024); - assertThat(((Jackson2JsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); + assertThat(((JacksonJsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(256 * 1024); + assertThat(((JacksonJsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size); } @Test @@ -235,7 +236,7 @@ class ServerCodecConfigurerTests { ServerCodecConfigurer clone = this.configurer.clone(); MultipartHttpMessageReader reader = new MultipartHttpMessageReader(new DefaultPartHttpMessageReader()); - Jackson2JsonEncoder encoder = new Jackson2JsonEncoder(); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(); clone.defaultCodecs().multipartReader(reader); clone.defaultCodecs().serverSentEventEncoder(encoder); @@ -319,7 +320,7 @@ class ServerCodecConfigurerTests { assertThat(writer.getClass()).isEqualTo(ServerSentEventHttpMessageWriter.class); Encoder encoder = ((ServerSentEventHttpMessageWriter) writer).getEncoder(); assertThat(encoder).isNotNull(); - assertThat(encoder.getClass()).isEqualTo(Jackson2JsonEncoder.class); + assertThat(encoder.getClass()).isEqualTo(JacksonJsonEncoder.class); } } diff --git a/spring-webflux/spring-webflux.gradle b/spring-webflux/spring-webflux.gradle index 6cd50c1905b..7445a756f5d 100644 --- a/spring-webflux/spring-webflux.gradle +++ b/spring-webflux/spring-webflux.gradle @@ -34,6 +34,8 @@ dependencies { optional("org.jetbrains.kotlin:kotlin-stdlib") optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") optional("org.webjars:webjars-locator-lite") + optional("tools.jackson.core:jackson-databind") + optional("tools.jackson.dataformat:jackson-dataformat-smile") testImplementation(testFixtures(project(":spring-beans"))) testImplementation(testFixtures(project(":spring-core"))) testImplementation(testFixtures(project(":spring-web"))) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java index d89d8d1b32f..585836ca336 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 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. @@ -33,7 +33,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; -import org.springframework.http.codec.json.Jackson2CodecSupport; +import org.springframework.http.codec.JacksonCodecSupport; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyInserter; @@ -292,7 +292,7 @@ public interface EntityResponse extends ServerResponse { Builder contentType(MediaType contentType); /** - * Add a serialization hint like {@link Jackson2CodecSupport#JSON_VIEW_HINT} to + * Add a serialization hint like {@link JacksonCodecSupport#JSON_VIEW_HINT} to * customize how the body will be serialized. * @param key the hint key * @param value the hint value diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java index 4f3263c4c37..5fbdfa853dc 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java @@ -40,7 +40,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpRange; import org.springframework.http.MediaType; import org.springframework.http.codec.HttpMessageReader; -import org.springframework.http.codec.json.Jackson2CodecSupport; +import org.springframework.http.codec.JacksonCodecSupport; import org.springframework.http.codec.multipart.Part; import org.springframework.http.server.RequestPath; import org.springframework.http.server.reactive.ServerHttpRequest; @@ -142,7 +142,7 @@ public interface ServerRequest { /** * Extract the body with the given {@code BodyExtractor} and hints. * @param extractor the {@code BodyExtractor} that reads from the request - * @param hints the map of hints like {@link Jackson2CodecSupport#JSON_VIEW_HINT} + * @param hints the map of hints like {@link JacksonCodecSupport#JSON_VIEW_HINT} * to use to customize body extraction * @param the type of the body returned * @return the extracted body diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java index 1e52b9a3f49..d584749c007 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java @@ -40,7 +40,7 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.http.codec.HttpMessageWriter; -import org.springframework.http.codec.json.Jackson2CodecSupport; +import org.springframework.http.codec.JacksonCodecSupport; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.MultiValueMap; import org.springframework.web.ErrorResponse; @@ -385,7 +385,7 @@ public interface ServerResponse { BodyBuilder contentType(MediaType contentType); /** - * Add a serialization hint like {@link Jackson2CodecSupport#JSON_VIEW_HINT} + * Add a serialization hint like {@link JacksonCodecSupport#JSON_VIEW_HINT} * to customize how the body will be serialized. * @param key the hint key * @param value the hint value diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java index b6884d47b9f..d60a826acf8 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.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. @@ -121,11 +121,12 @@ public class DispatcherHandlerErrorTests { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PROBLEM_JSON); assertThat(response.getBodyAsString().block()).isEqualTo(""" - {"type":"about:blank",\ - "title":"Not Found",\ - "status":404,\ + {\ "detail":"No static resource non-existing.",\ - "instance":"/resources/non-existing"}\ + "instance":"\\/resources\\/non-existing",\ + "status":404,\ + "title":"Not Found",\ + "type":"about:blank"}\ """); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyExtractorsTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyExtractorsTests.java index b8c0fb4ff18..5b36922ae45 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyExtractorsTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyExtractorsTests.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. @@ -50,7 +50,7 @@ import org.springframework.http.ReactiveHttpInputMessage; import org.springframework.http.codec.DecoderHttpMessageReader; import org.springframework.http.codec.FormHttpMessageReader; import org.springframework.http.codec.HttpMessageReader; -import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.JacksonJsonDecoder; import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader; import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FormFieldPart; @@ -66,7 +66,7 @@ import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRe import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.InstanceOfAssertFactories.type; -import static org.springframework.http.codec.json.Jackson2CodecSupport.JSON_VIEW_HINT; +import static org.springframework.http.codec.JacksonCodecSupport.JSON_VIEW_HINT; /** * @author Arjen Poutsma @@ -89,7 +89,7 @@ class BodyExtractorsTests { messageReaders.add(new DecoderHttpMessageReader<>(new ByteBufferDecoder())); messageReaders.add(new DecoderHttpMessageReader<>(StringDecoder.allMimeTypes())); messageReaders.add(new DecoderHttpMessageReader<>(new Jaxb2XmlDecoder())); - messageReaders.add(new DecoderHttpMessageReader<>(new Jackson2JsonDecoder())); + messageReaders.add(new DecoderHttpMessageReader<>(new JacksonJsonDecoder())); messageReaders.add(new FormHttpMessageReader()); DefaultPartHttpMessageReader partReader = new DefaultPartHttpMessageReader(); messageReaders.add(partReader); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java index 7bafc13681a..c2115a16b06 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.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. @@ -56,7 +56,7 @@ import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.codec.ResourceHttpMessageWriter; import org.springframework.http.codec.ServerSentEvent; import org.springframework.http.codec.ServerSentEventHttpMessageWriter; -import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; import org.springframework.http.codec.multipart.MultipartHttpMessageWriter; import org.springframework.http.codec.xml.Jaxb2XmlEncoder; import org.springframework.http.server.reactive.ServerHttpRequest; @@ -69,7 +69,7 @@ import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRe import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.http.codec.json.Jackson2CodecSupport.JSON_VIEW_HINT; +import static org.springframework.http.codec.JacksonCodecSupport.JSON_VIEW_HINT; /** * @author Arjen Poutsma @@ -89,7 +89,7 @@ class BodyInsertersTests { messageWriters.add(new EncoderHttpMessageWriter<>(CharSequenceEncoder.textPlainOnly())); messageWriters.add(new ResourceHttpMessageWriter()); messageWriters.add(new EncoderHttpMessageWriter<>(new Jaxb2XmlEncoder())); - Jackson2JsonEncoder jsonEncoder = new Jackson2JsonEncoder(); + JacksonJsonEncoder jsonEncoder = new JacksonJsonEncoder(); messageWriters.add(new EncoderHttpMessageWriter<>(jsonEncoder)); messageWriters.add(new ServerSentEventHttpMessageWriter(jsonEncoder)); messageWriters.add(new FormHttpMessageWriter()); @@ -140,7 +140,7 @@ class BodyInsertersTests { StepVerifier.create(result).expectComplete().verify(); StepVerifier.create(response.getBodyAsString()) - .expectNext("{\"username\":\"foo\",\"password\":\"bar\"}") + .expectNext("{\"password\":\"bar\",\"username\":\"foo\"}") .expectComplete() .verify(); } @@ -169,7 +169,7 @@ class BodyInsertersTests { Mono result = inserter.insert(response, this.context); StepVerifier.create(result).expectComplete().verify(); StepVerifier.create(response.getBodyAsString()) - .expectNext("{\"username\":\"foo\",\"password\":\"bar\"}") + .expectNext("{\"password\":\"bar\",\"username\":\"foo\"}") .expectComplete() .verify(); } @@ -200,7 +200,7 @@ class BodyInsertersTests { Mono result = inserter.insert(response, this.context); StepVerifier.create(result).expectComplete().verify(); StepVerifier.create(response.getBodyAsString()) - .expectNext("{\"username\":\"foo\",\"password\":\"bar\"}") + .expectNext("{\"password\":\"bar\",\"username\":\"foo\"}") .expectComplete() .verify(); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java index b86f350c2a3..f9e35ba0ee7 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java @@ -767,7 +767,7 @@ class WebClientIntegrationTests { 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.CONTENT_LENGTH)).isEqualTo("31"); assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); assertThat(request.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java index cc146be163b..8c91b49bcd3 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.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. @@ -122,11 +122,11 @@ class RequestMappingExceptionHandlingIntegrationTests extends AbstractRequestMap .isThrownBy(() -> performGet("/no-such-handler", new HttpHeaders(), String.class)) .satisfies(ex -> { assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - assertThat(ex.getResponseBodyAsString()).isEqualTo( - "{\"type\":\"about:blank\"," + - "\"title\":\"Not Found\"," + + assertThat(ex.getResponseBodyAsString()).isEqualTo("{" + + "\"instance\":\"\\/no-such-handler\"," + "\"status\":404," + - "\"instance\":\"/no-such-handler\"}"); + "\"title\":\"Not Found\"," + + "\"type\":\"about:blank\"}"); }); } @@ -139,11 +139,11 @@ class RequestMappingExceptionHandlingIntegrationTests extends AbstractRequestMap .satisfies(ex -> { assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(ex.getResponseBodyAsString()).isEqualTo("{" + - "\"type\":\"about:blank\"," + - "\"title\":\"Bad Request\"," + - "\"status\":400," + "\"detail\":\"Required query parameter 'q' is not present.\"," + - "\"instance\":\"/missing-request-parameter\"}"); + "\"instance\":\"\\/missing-request-parameter\"," + + "\"status\":400," + + "\"title\":\"Bad Request\"," + + "\"type\":\"about:blank\"}"); }); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/HttpMessageWriterViewTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/HttpMessageWriterViewTests.java index 18222a1aa81..a2bbe041499 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/HttpMessageWriterViewTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/HttpMessageWriterViewTests.java @@ -27,7 +27,7 @@ import reactor.test.StepVerifier; import org.springframework.core.codec.CharSequenceEncoder; import org.springframework.http.MediaType; -import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; import org.springframework.http.codec.xml.Jaxb2XmlEncoder; import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; import org.springframework.web.testfixture.server.MockServerWebExchange; @@ -43,7 +43,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException; */ class HttpMessageWriterViewTests { - private HttpMessageWriterView view = new HttpMessageWriterView(new Jackson2JsonEncoder()); + private HttpMessageWriterView view = new HttpMessageWriterView(new JacksonJsonEncoder()); private final Map model = new HashMap<>(); diff --git a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/MessageWriterResultHandlerKotlinTests.kt b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/MessageWriterResultHandlerKotlinTests.kt index 324451beb18..ce9b35ff6c2 100644 --- a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/MessageWriterResultHandlerKotlinTests.kt +++ b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/MessageWriterResultHandlerKotlinTests.kt @@ -25,7 +25,7 @@ import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.http.codec.EncoderHttpMessageWriter import org.springframework.http.codec.HttpMessageWriter -import org.springframework.http.codec.json.Jackson2JsonEncoder +import org.springframework.http.codec.json.JacksonJsonEncoder import org.springframework.http.codec.json.KotlinSerializationJsonEncoder import org.springframework.util.ObjectUtils import org.springframework.web.bind.annotation.GetMapping @@ -54,7 +54,7 @@ class MessageWriterResultHandlerKotlinTests { val writerList = if (ObjectUtils.isEmpty(writers)) { listOf( EncoderHttpMessageWriter(KotlinSerializationJsonEncoder()), - EncoderHttpMessageWriter(Jackson2JsonEncoder()) + EncoderHttpMessageWriter(JacksonJsonEncoder()) ) } else { listOf(*writers)