From 92b2c452811feec49ecd7ad411b090b863b20ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 6 Oct 2020 23:02:14 +0200 Subject: [PATCH] Add kotlinx.serialization JSON support to Spring WebFlux Flow decoding is not supported yet since it depends on kotlin/kotlinx.serialization#1073, but it will be enabled when this issue will be fixed. Closes gh-25771 --- .../http/codec/CodecConfigurer.java | 16 +++ .../json/KotlinSerializationJsonDecoder.java | 103 ++++++++++++++++ .../json/KotlinSerializationJsonEncoder.java | 114 ++++++++++++++++++ .../http/codec/support/BaseDefaultCodecs.java | 43 +++++++ .../support/ClientDefaultCodecsImpl.java | 7 +- .../support/ServerDefaultCodecsImpl.java | 5 +- .../KotlinSerializationJsonDecoderTests.kt | 98 +++++++++++++++ .../KotlinSerializationJsonEncoderTests.kt | 95 +++++++++++++++ src/docs/asciidoc/languages/kotlin.adoc | 4 +- 9 files changed, 479 insertions(+), 6 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java create mode 100644 spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java create mode 100644 spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt create mode 100644 spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonEncoderTests.kt 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 ec850a697c..bc55b86c43 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 @@ -176,6 +176,22 @@ public interface CodecConfigurer { */ void jaxb2Encoder(Encoder encoder); + /** + * Override the default Kotlin Serialization JSON {@code Decoder}. + * @param decoder the decoder instance to use + * @since 5.3 + * @see org.springframework.http.codec.json.KotlinSerializationJsonDecoder + */ + void kotlinSerializationJsonDecoder(Decoder decoder); + + /** + * Override the default Kotlin Serialization JSON {@code Encoder}. + * @param encoder the encoder instance to use + * @since 5.3 + * @see org.springframework.http.codec.json.KotlinSerializationJsonEncoder + */ + void kotlinSerializationJsonEncoder(Encoder encoder); + /** * Configure a limit on the number of bytes that can be buffered whenever * the input stream needs to be aggregated. This can be a result of diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java new file mode 100644 index 0000000000..98da5971f6 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-2020 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.lang.reflect.Type; +import java.util.Map; + +import kotlinx.serialization.KSerializer; +import kotlinx.serialization.SerializersKt; +import kotlinx.serialization.json.Json; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.AbstractDecoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.MimeType; + +/** + * Decode a byte stream into JSON and convert to Object's with + * kotlinx.serialization. + * + *

This decoder can be used to bind {@code @Serializable} Kotlin classes. + * It supports {@code application/json} and {@code application/*+json} with + * various character sets, {@code UTF-8} being the default. + * + *

Decoding streams is not supported yet, see + * kotlinx.serialization/issues/1073 + * related issue. + * + * @author Sebastien Deleuze + * @since 5.3 + */ +public class KotlinSerializationJsonDecoder extends AbstractDecoder { + + private static final Map> serializerCache = new ConcurrentReferenceHashMap<>(); + + private final Json json; + + // String decoding needed for now, see https://github.com/Kotlin/kotlinx.serialization/issues/204 for more details + private final StringDecoder stringDecoder = StringDecoder.allMimeTypes(StringDecoder.DEFAULT_DELIMITERS, false); + + public KotlinSerializationJsonDecoder() { + this(Json.Default); + } + + public KotlinSerializationJsonDecoder(Json json) { + super(MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + this.json = json; + } + + @Override + public boolean canDecode(ResolvableType elementType, MimeType mimeType) { + return super.canDecode(elementType, mimeType) && (!CharSequence.class.isAssignableFrom(elementType.toClass())); + } + + @Override + public Flux decode(Publisher inputStream, ResolvableType elementType, MimeType mimeType, Map hints) { + return Flux.error(new UnsupportedOperationException()); + } + + @Override + public Mono decodeToMono(Publisher inputStream, ResolvableType elementType, MimeType mimeType, Map hints) { + return stringDecoder + .decodeToMono(inputStream, elementType, mimeType, hints) + .map(jsonText -> this.json.decodeFromString(serializer(elementType.getType()), jsonText)); + } + + /** + * Tries to find a serializer that can marshall or unmarshall instances of the given type + * using kotlinx.serialization. If no serializer can be found, an exception is thrown. + *

Resolved serializers are cached and cached results are returned on successive calls. + * @param type the type to find a serializer for + * @return a resolved serializer for the given type + * @throws RuntimeException if no serializer supporting the given type can be found + */ + private KSerializer serializer(Type type) { + KSerializer serializer = serializerCache.get(type); + if (serializer == null) { + serializer = SerializersKt.serializer(type); + serializerCache.put(type, serializer); + } + return serializer; + } +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java new file mode 100644 index 0000000000..f40e684fcc --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java @@ -0,0 +1,114 @@ +/* + * Copyright 2002-2020 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.lang.reflect.Type; +import java.util.List; +import java.util.Map; + +import kotlinx.serialization.KSerializer; +import kotlinx.serialization.SerializersKt; +import kotlinx.serialization.json.Json; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.AbstractEncoder; +import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.MimeType; + +/** + * Encode from an {@code Object} stream to a byte stream of JSON objects using + * kotlinx.serialization. + * + *

This encoder can be used to bind {@code @Serializable} Kotlin classes. + * It supports {@code application/json} and {@code application/*+json} with + * various character sets, {@code UTF-8} being the default. + * + * @author Sebastien Deleuze + * @since 5.3 + */ +public class KotlinSerializationJsonEncoder extends AbstractEncoder { + + private static final Map> serializerCache = new ConcurrentReferenceHashMap<>(); + + private final Json json; + + // CharSequence encoding needed for now, see https://github.com/Kotlin/kotlinx.serialization/issues/204 for more details + private final CharSequenceEncoder charSequenceEncoder = CharSequenceEncoder.allMimeTypes(); + + public KotlinSerializationJsonEncoder() { + this(Json.Default); + } + + public KotlinSerializationJsonEncoder(Json json) { + super(MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + this.json = json; + } + + @Override + public boolean canEncode(ResolvableType elementType, MimeType mimeType) { + return super.canEncode(elementType, mimeType) + && (!String.class.isAssignableFrom(elementType.toClass())) + && (!ServerSentEvent.class.isAssignableFrom(elementType.toClass())); + } + + @Override + public Flux encode(Publisher inputStream, DataBufferFactory bufferFactory, ResolvableType elementType, MimeType mimeType, Map hints) { + if (inputStream instanceof Mono) { + return Mono.from(inputStream) + .map(value -> encodeValue(value, bufferFactory, elementType, mimeType, hints)) + .flux(); + } + else { + ResolvableType listType = ResolvableType.forClassWithGenerics(List.class, elementType); + return Flux.from(inputStream) + .collectList() + .map(list -> encodeValue(list, bufferFactory, listType, mimeType, hints)) + .flux(); + } + } + + @Override + public DataBuffer encodeValue(Object value, DataBufferFactory bufferFactory, ResolvableType valueType, MimeType mimeType, Map hints) { + String json = this.json.encodeToString(serializer(valueType.getType()), value); + return charSequenceEncoder.encodeValue(json, bufferFactory, valueType, mimeType, null); + } + + /** + * Tries to find a serializer that can marshall or unmarshall instances of the given type + * using kotlinx.serialization. If no serializer can be found, an exception is thrown. + *

Resolved serializers are cached and cached results are returned on successive calls. + * @param type the type to find a serializer for + * @return a resolved serializer for the given type + * @throws RuntimeException if no serializer supporting the given type can be found + */ + private KSerializer serializer(Type type) { + KSerializer serializer = serializerCache.get(type); + if (serializer == null) { + serializer = SerializersKt.serializer(type); + serializerCache.put(type, serializer); + } + return serializer; + } +} 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 b3cb66400e..0f40420298 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 @@ -51,6 +51,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.KotlinSerializationJsonDecoder; +import org.springframework.http.codec.json.KotlinSerializationJsonEncoder; import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader; import org.springframework.http.codec.multipart.MultipartHttpMessageReader; import org.springframework.http.codec.multipart.MultipartHttpMessageWriter; @@ -91,6 +93,8 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure static final boolean nettyByteBufPresent; + static final boolean kotlinSerializationJsonPresent; + static { ClassLoader classLoader = BaseCodecConfigurer.class.getClassLoader(); jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) && @@ -100,6 +104,7 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure protobufPresent = ClassUtils.isPresent("com.google.protobuf.Message", classLoader); synchronossMultipartPresent = ClassUtils.isPresent("org.synchronoss.cloud.nio.multipart.NioMultipartParser", classLoader); nettyByteBufPresent = ClassUtils.isPresent("io.netty.buffer.ByteBuf", classLoader); + kotlinSerializationJsonPresent = ClassUtils.isPresent("kotlinx.serialization.json.Json", classLoader); } @@ -127,6 +132,12 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure @Nullable private Encoder jaxb2Encoder; + @Nullable + private Decoder kotlinSerializationJsonDecoder; + + @Nullable + private Encoder kotlinSerializationJsonEncoder; + @Nullable private Integer maxInMemorySize; @@ -151,6 +162,8 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure this.protobufEncoder = other.protobufEncoder; this.jaxb2Decoder = other.jaxb2Decoder; this.jaxb2Encoder = other.jaxb2Encoder; + this.kotlinSerializationJsonDecoder = other.kotlinSerializationJsonDecoder; + this.kotlinSerializationJsonEncoder = other.kotlinSerializationJsonEncoder; this.maxInMemorySize = other.maxInMemorySize; this.enableLoggingRequestDetails = other.enableLoggingRequestDetails; this.registerDefaults = other.registerDefaults; @@ -196,6 +209,16 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure this.jaxb2Encoder = encoder; } + @Override + public void kotlinSerializationJsonDecoder(Decoder decoder) { + this.kotlinSerializationJsonDecoder = decoder; + } + + @Override + public void kotlinSerializationJsonEncoder(Encoder encoder) { + this.kotlinSerializationJsonEncoder = encoder; + } + @Override public void maxInMemorySize(int byteCount) { this.maxInMemorySize = byteCount; @@ -365,6 +388,9 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure if (jackson2Present) { addCodec(readers, new DecoderHttpMessageReader<>(getJackson2JsonDecoder())); } + else if (kotlinSerializationJsonPresent) { + addCodec(readers, new DecoderHttpMessageReader<>(getKotlinSerializationJsonDecoder())); + } if (jackson2SmilePresent) { addCodec(readers, new DecoderHttpMessageReader<>(this.jackson2SmileDecoder != null ? (Jackson2SmileDecoder) this.jackson2SmileDecoder : new Jackson2SmileDecoder())); @@ -461,6 +487,9 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure if (jackson2Present) { writers.add(new EncoderHttpMessageWriter<>(getJackson2JsonEncoder())); } + else if (kotlinSerializationJsonPresent) { + writers.add(new EncoderHttpMessageWriter<>(getKotlinSerializationJsonEncoder())); + } if (jackson2SmilePresent) { writers.add(new EncoderHttpMessageWriter<>(this.jackson2SmileEncoder != null ? (Jackson2SmileEncoder) this.jackson2SmileEncoder : new Jackson2SmileEncoder())); @@ -522,4 +551,18 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure return this.jackson2JsonEncoder; } + protected Decoder getKotlinSerializationJsonDecoder() { + if (this.kotlinSerializationJsonDecoder == null) { + this.kotlinSerializationJsonDecoder = new KotlinSerializationJsonDecoder(); + } + return this.kotlinSerializationJsonDecoder; + } + + protected Encoder getKotlinSerializationJsonEncoder() { + if (this.kotlinSerializationJsonEncoder == null) { + this.kotlinSerializationJsonEncoder = new KotlinSerializationJsonEncoder(); + } + return this.kotlinSerializationJsonEncoder; + } + } 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 cc1c7f1a43..1179e27418 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 @@ -96,9 +96,10 @@ class ClientDefaultCodecsImpl extends BaseDefaultCodecs implements ClientCodecCo @Override protected void extendObjectReaders(List> objectReaders) { - Decoder decoder = (this.sseDecoder != null ? - this.sseDecoder : - jackson2Present ? getJackson2JsonDecoder() : null); + Decoder decoder = (this.sseDecoder != null ? this.sseDecoder : + jackson2Present ? getJackson2JsonDecoder() : + kotlinSerializationJsonPresent ? getKotlinSerializationJsonDecoder() : + null); addCodec(objectReaders, new ServerSentEventHttpMessageReader(decoder)); } 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 9b8de3f068..2a0de1a76e 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 @@ -86,7 +86,10 @@ class ServerDefaultCodecsImpl extends BaseDefaultCodecs implements ServerCodecCo @Nullable private Encoder getSseEncoder() { - return this.sseEncoder != null ? this.sseEncoder : jackson2Present ? getJackson2JsonEncoder() : null; + return this.sseEncoder != null ? this.sseEncoder : + jackson2Present ? getJackson2JsonEncoder() : + kotlinSerializationJsonPresent ? getKotlinSerializationJsonEncoder() : + null; } } diff --git a/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt new file mode 100644 index 0000000000..6be5d1981c --- /dev/null +++ b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2020 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 kotlinx.serialization.Serializable +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +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 reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import reactor.test.StepVerifier +import reactor.test.StepVerifier.FirstStep +import java.lang.UnsupportedOperationException +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** + * Tests for the JSON decoding using kotlinx.serialization. + * + * @author Sebastien Deleuze + */ +class KotlinSerializationJsonDecoderTests : AbstractDecoderTests(KotlinSerializationJsonDecoder()) { + + @Suppress("UsePropertyAccessSyntax", "DEPRECATION") + @Test + override fun canDecode() { + val jsonSubtype = MediaType("application", "vnd.test-micro-type+json") + Assertions.assertThat(decoder.canDecode(ResolvableType.forClass(Pojo::class.java), MediaType.APPLICATION_JSON)).isTrue() + Assertions.assertThat(decoder.canDecode(ResolvableType.forClass(Pojo::class.java), jsonSubtype)).isTrue() + Assertions.assertThat(decoder.canDecode(ResolvableType.forClass(Pojo::class.java), null)).isTrue() + Assertions.assertThat(decoder.canDecode(ResolvableType.forClass(String::class.java), null)).isFalse() + Assertions.assertThat(decoder.canDecode(ResolvableType.forClass(Pojo::class.java), MediaType.APPLICATION_XML)).isFalse() + Assertions.assertThat(decoder.canDecode(ResolvableType.forClass(Pojo::class.java), + MediaType("application", "json", StandardCharsets.UTF_8))).isTrue() + Assertions.assertThat(decoder.canDecode(ResolvableType.forClass(Pojo::class.java), + MediaType("application", "json", StandardCharsets.US_ASCII))).isTrue() + Assertions.assertThat(decoder.canDecode(ResolvableType.forClass(Pojo::class.java), + MediaType("application", "json", StandardCharsets.ISO_8859_1))).isTrue() + } + + @Test + override fun decode() { + val output = decoder.decode(Mono.empty(), ResolvableType.forClass(Pojo::class.java), null, emptyMap()) + StepVerifier + .create(output) + .expectError(UnsupportedOperationException::class.java) + } + + @Test + override fun decodeToMono() { + val input = Flux.concat( + stringBuffer("[{\"bar\":\"b1\",\"foo\":\"f1\"},"), + stringBuffer("{\"bar\":\"b2\",\"foo\":\"f2\"}]")) + + val elementType = ResolvableType.forClassWithGenerics(List::class.java, Pojo::class.java) + + testDecodeToMonoAll(input, elementType, { step: FirstStep -> + step + .expectNext(listOf(Pojo("f1", "b1"), Pojo("f2", "b2"))) + .expectComplete() + .verify() + }, null, null) + } + + private fun stringBuffer(value: String): Mono { + return stringBuffer(value, StandardCharsets.UTF_8) + } + + private fun stringBuffer(value: String, charset: Charset): Mono { + return Mono.defer { + val bytes = value.toByteArray(charset) + val buffer = bufferFactory.allocateBuffer(bytes.size) + buffer.write(bytes) + Mono.just(buffer) + } + } + + @Serializable + data class Pojo(val foo: String, val bar: String) + +} \ No newline at end of file diff --git a/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonEncoderTests.kt b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonEncoderTests.kt new file mode 100644 index 0000000000..d1a6a987d8 --- /dev/null +++ b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonEncoderTests.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2002-2020 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 kotlinx.serialization.Serializable +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +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.MediaType +import org.springframework.http.codec.ServerSentEvent +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import reactor.test.StepVerifier.FirstStep +import java.nio.charset.StandardCharsets + +/** + * Tests for the JSON encoding using kotlinx.serialization. + * + * @author Sebastien Deleuze + */ +@Suppress("UsePropertyAccessSyntax") +class KotlinSerializationJsonEncoderTests : AbstractEncoderTests(KotlinSerializationJsonEncoder()) { + + @Test + override fun canEncode() { + val pojoType = ResolvableType.forClass(Pojo::class.java) + val jsonSubtype = MediaType("application", "vnd.test-micro-type+json") + Assertions.assertThat(encoder.canEncode(pojoType, MediaType.APPLICATION_JSON)).isTrue() + Assertions.assertThat(encoder.canEncode(pojoType, jsonSubtype)).isTrue() + Assertions.assertThat(encoder.canEncode(pojoType, null)).isTrue() + Assertions.assertThat(encoder.canEncode(ResolvableType.forClass(Pojo::class.java), + MediaType("application", "json", StandardCharsets.UTF_8))).isTrue() + Assertions.assertThat(encoder.canEncode(ResolvableType.forClass(Pojo::class.java), + MediaType("application", "json", StandardCharsets.US_ASCII))).isTrue() + Assertions.assertThat(encoder.canEncode(ResolvableType.NONE, null)).isTrue() + } + + @Test + override fun encode() { + val input = Flux.just( + Pojo("foo", "bar"), + Pojo("foofoo", "barbar"), + Pojo("foofoofoo", "barbarbar") + ) + testEncode(input, Pojo::class.java, { step: FirstStep -> + step + .consumeNextWith(expectString("[" + + "{\"foo\":\"foo\",\"bar\":\"bar\"}," + + "{\"foo\":\"foofoo\",\"bar\":\"barbar\"}," + + "{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}]") + .andThen { dataBuffer: DataBuffer? -> DataBufferUtils.release(dataBuffer) }) + .verifyComplete() + }) + } + + @Test + fun encodeMono() { + val input = Mono.just(Pojo("foo", "bar")) + testEncode(input, Pojo::class.java, { step: FirstStep -> + step + .consumeNextWith(expectString("{\"foo\":\"foo\",\"bar\":\"bar\"}") + .andThen { dataBuffer: DataBuffer? -> DataBufferUtils.release(dataBuffer) }) + .verifyComplete() + }) + } + + @Test + fun canNotEncode() { + Assertions.assertThat(encoder.canEncode(ResolvableType.forClass(String::class.java), null)).isFalse() + Assertions.assertThat(encoder.canEncode(ResolvableType.forClass(Pojo::class.java), MediaType.APPLICATION_XML)).isFalse() + val sseType = ResolvableType.forClass(ServerSentEvent::class.java) + Assertions.assertThat(encoder.canEncode(sseType, MediaType.APPLICATION_JSON)).isFalse() + } + + @Serializable + data class Pojo(val foo: String, val bar: String) + +} \ No newline at end of file diff --git a/src/docs/asciidoc/languages/kotlin.adoc b/src/docs/asciidoc/languages/kotlin.adoc index 0285a99204..1acf4bb8cf 100644 --- a/src/docs/asciidoc/languages/kotlin.adoc +++ b/src/docs/asciidoc/languages/kotlin.adoc @@ -389,14 +389,14 @@ project for more details. === Kotlin multiplatform serialization As of Spring Framework 5.3, https://github.com/Kotlin/kotlinx.serialization[Kotlin multiplatform serialization] is -supported in Spring MVC. The builtin support currently only targets JSON format. +supported in Spring MVC and Spring WebFlux. The builtin support currently only targets JSON format. To enable it, follow https://github.com/Kotlin/kotlinx.serialization#setup[those instructions] and make sure neither Jackson, GSON or JSONB are in the classpath. NOTE: For a typical Spring Boot web application, that can be achieved by excluding `spring-boot-starter-json` dependency. -If you need Jackson, GSON or JSONB for other purposes, you can keep them on the classpath and +In Spring MVC, if you need Jackson, GSON or JSONB for other purposes, you can keep them on the classpath and <> to remove `MappingJackson2HttpMessageConverter` and add `KotlinSerializationJsonHttpMessageConverter`.