From 1ba6b7dab0279d60157958f9aab0f97c1bfd7a42 Mon Sep 17 00:00:00 2001 From: Sebastian Schuberth Date: Wed, 5 Nov 2025 16:44:37 +0100 Subject: [PATCH 1/2] Add `jackson-annotations` as an explicit test dependency This was implicitly available as a transitive dependency, but that should not be relied on. It is required for `JsonAutoDetect.Visibility.ANY`. Note that `jackson-annotations` is versioned independently of the rest of Jackson and does not use a path-level version suffix. --- gradle/libs.versions.toml | 2 ++ retrofit-converters/jackson/build.gradle | 1 + 2 files changed, 3 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7fd8a71eca..de0949dbb5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ kotlinx-serialization = "1.10.0" autoService = "1.1.1" incap = "1.0.0" jackson = "2.21.1" +jacksonAnnotations = "2.21" [libraries] androidPlugin = "com.android.tools.build:gradle:9.1.0" @@ -63,6 +64,7 @@ rxjava3 = { module = "io.reactivex.rxjava3:rxjava", version = "3.1.12" } reactiveStreams = { module = "org.reactivestreams:reactive-streams", version = "1.0.4" } scalaLibrary = { module = "org.scala-lang:scala-library", version = "2.13.18" } gson = { module = "com.google.code.gson:gson", version = "2.13.2" } +jacksonAnnotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jacksonAnnotations" } jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jacksonDataformatCbor = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor", version.ref = "jackson" } jaxbApi = { module = "javax.xml.bind:jaxb-api", version = "2.3.1" } diff --git a/retrofit-converters/jackson/build.gradle b/retrofit-converters/jackson/build.gradle index 7900d02c48..3024360eb2 100644 --- a/retrofit-converters/jackson/build.gradle +++ b/retrofit-converters/jackson/build.gradle @@ -10,6 +10,7 @@ dependencies { testImplementation libs.truth testImplementation libs.okhttp.mockwebserver testImplementation libs.testParameterInjector + testImplementation libs.jacksonAnnotations testImplementation libs.jacksonDataformatCbor } From d0459e64a56daa989606fac83dab26808cdac102 Mon Sep 17 00:00:00 2001 From: Sebastian Schuberth Date: Wed, 5 Nov 2025 16:36:28 +0100 Subject: [PATCH 2/2] Add a converter for Jackson 3 This mostly copies the existing Jackson (2) converter and does the necessary migration [1]. Note that Jackson 3 requires Java 17. [1]: https://github.com/FasterXML/jackson/blob/main/jackson3/MIGRATING_TO_JACKSON_3.md --- CHANGELOG.md | 1 + gradle/libs.versions.toml | 3 + retrofit-converters/jackson3/README.md | 34 +++ retrofit-converters/jackson3/build.gradle | 27 ++ .../jackson3/gradle.properties | 3 + .../jackson3/JacksonConverterFactory.java | 99 ++++++++ .../jackson3/JacksonRequestBodyConverter.java | 50 ++++ .../JacksonResponseBodyConverter.java | 40 +++ .../jackson3/JacksonStreamingRequestBody.java | 50 ++++ .../converter/jackson3/package-info.java | 2 + .../JacksonCborConverterFactoryTest.java | 83 +++++++ .../jackson3/JacksonConverterFactoryTest.java | 235 ++++++++++++++++++ settings.gradle | 1 + 13 files changed, 628 insertions(+) create mode 100644 retrofit-converters/jackson3/README.md create mode 100644 retrofit-converters/jackson3/build.gradle create mode 100644 retrofit-converters/jackson3/gradle.properties create mode 100644 retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonConverterFactory.java create mode 100644 retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonRequestBodyConverter.java create mode 100644 retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonResponseBodyConverter.java create mode 100644 retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonStreamingRequestBody.java create mode 100644 retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/package-info.java create mode 100644 retrofit-converters/jackson3/src/test/java/retrofit2/converter/jackson3/JacksonCborConverterFactoryTest.java create mode 100644 retrofit-converters/jackson3/src/test/java/retrofit2/converter/jackson3/JacksonConverterFactoryTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 582b98174c..20b05cbf60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Add explicit keep rules for RxJava `Result` types to prevent their generic information from being removed. - Add `allowoptimization` flags for most kept types. - Add `Invocation.annotationUrl` which returns the original URL from the method annotation. + - Add a converter for Jackson 3. **Changed** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de0949dbb5..e384e7e750 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ autoService = "1.1.1" incap = "1.0.0" jackson = "2.21.1" jacksonAnnotations = "2.21" +jackson3 = "3.1.3" [libraries] androidPlugin = "com.android.tools.build:gradle:9.1.0" @@ -67,6 +68,8 @@ gson = { module = "com.google.code.gson:gson", version = "2.13.2" } jacksonAnnotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jacksonAnnotations" } jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jacksonDataformatCbor = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor", version.ref = "jackson" } +jackson3Databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson3" } +jackson3DataformatCbor = { module = "tools.jackson.dataformat:jackson-dataformat-cbor", version.ref = "jackson3" } jaxbApi = { module = "javax.xml.bind:jaxb-api", version = "2.3.1" } jaxbImpl = { module = "org.glassfish.jaxb:jaxb-runtime", version = "4.0.7" } jaxb3Api = { module = "jakarta.xml.bind:jakarta.xml.bind-api", version = "3.0.1" } diff --git a/retrofit-converters/jackson3/README.md b/retrofit-converters/jackson3/README.md new file mode 100644 index 0000000000..46710fbcbb --- /dev/null +++ b/retrofit-converters/jackson3/README.md @@ -0,0 +1,34 @@ +Jackson Converter +================= + +A `Converter` which uses [Jackson][1] 3 for serialization to and from JSON. + +A default `ObjectMapper` instance will be created or one can be configured and passed to the +`JacksonConverterFactory` construction to further control the serialization. + + +Download +-------- + +Download [the latest JAR][2] or grab via [Maven][3]: +```xml + + com.squareup.retrofit2 + converter-jackson3 + latest.version + +``` +or [Gradle][3]: +```groovy +implementation 'com.squareup.retrofit2:converter-jackson3:latest.version' +``` + +Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. + + + + [1]: https://github.com/FasterXML/jackson + [2]: https://search.maven.org/remote_content?g=com.squareup.retrofit2&a=converter-jackson3&v=LATEST + [3]: http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.squareup.retrofit2%22%20a%3A%22converter-jackson3%22 + [snap]: https://s01.oss.sonatype.org/content/repositories/snapshots/ + diff --git a/retrofit-converters/jackson3/build.gradle b/retrofit-converters/jackson3/build.gradle new file mode 100644 index 0000000000..3e90ae70cd --- /dev/null +++ b/retrofit-converters/jackson3/build.gradle @@ -0,0 +1,27 @@ +apply plugin: 'java-library' +apply plugin: 'com.vanniktech.maven.publish' + +dependencies { + api projects.retrofit + api libs.jackson3Databind + compileOnly libs.findBugsAnnotations + + testImplementation libs.junit + testImplementation libs.truth + testImplementation libs.okhttp.mockwebserver + testImplementation libs.testParameterInjector + testImplementation libs.jacksonAnnotations + testImplementation libs.jackson3DataformatCbor +} + +jar { + manifest { + attributes 'Automatic-Module-Name': 'retrofit2.converter.jackson3' + } +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} diff --git a/retrofit-converters/jackson3/gradle.properties b/retrofit-converters/jackson3/gradle.properties new file mode 100644 index 0000000000..32b13fca14 --- /dev/null +++ b/retrofit-converters/jackson3/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=converter-jackson3 +POM_NAME=Converter: Jackson3 +POM_DESCRIPTION=A Retrofit Converter which uses Jackson 3 for serialization. diff --git a/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonConverterFactory.java b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonConverterFactory.java new file mode 100644 index 0000000000..4c05113bf8 --- /dev/null +++ b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonConverterFactory.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * 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 + * + * http://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 retrofit2.converter.jackson3; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.Converter; +import retrofit2.Retrofit; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectReader; +import tools.jackson.databind.ObjectWriter; + +/** + * A {@linkplain Converter.Factory converter} which uses Jackson. + * + *

Because Jackson is so flexible in the types it supports, this converter assumes that it can + * handle all types. If you are mixing JSON serialization with something else (such as protocol + * buffers), you must {@linkplain Retrofit.Builder#addConverterFactory(Converter.Factory) add this + * instance} last to allow the other converters a chance to see their types. + */ +public final class JacksonConverterFactory extends Converter.Factory { + private static final MediaType DEFAULT_MEDIA_TYPE = + MediaType.get("application/json; charset=UTF-8"); + + /** Create an instance using a default {@link ObjectMapper} instance for conversion. */ + public static JacksonConverterFactory create() { + return new JacksonConverterFactory(new ObjectMapper(), DEFAULT_MEDIA_TYPE, false); + } + + /** Create an instance using {@code mapper} for conversion. */ + public static JacksonConverterFactory create(ObjectMapper mapper) { + return create(mapper, DEFAULT_MEDIA_TYPE); + } + + /** Create an instance using {@code mapper} and {@code mediaType} for conversion. */ + @SuppressWarnings("ConstantConditions") // Guarding public API nullability. + public static JacksonConverterFactory create(ObjectMapper mapper, MediaType mediaType) { + if (mapper == null) throw new NullPointerException("mapper == null"); + if (mediaType == null) throw new NullPointerException("mediaType == null"); + return new JacksonConverterFactory(mapper, mediaType, false); + } + + private final ObjectMapper mapper; + private final MediaType mediaType; + private final boolean streaming; + + private JacksonConverterFactory(ObjectMapper mapper, MediaType mediaType, boolean streaming) { + this.mapper = mapper; + this.mediaType = mediaType; + this.streaming = streaming; + } + + /** + * Return a new factory which streams serialization of request messages to bytes on the HTTP thread + * This is either the calling thread for {@link Call#execute()}, or one of OkHttp's background + * threads for {@link Call#enqueue}. Response bytes are always converted to message instances on + * one of OkHttp's background threads. + */ + public JacksonConverterFactory withStreaming() { + return new JacksonConverterFactory(mapper, mediaType, true); + } + + @Override + public Converter responseBodyConverter( + Type type, Annotation[] annotations, Retrofit retrofit) { + JavaType javaType = mapper.getTypeFactory().constructType(type); + ObjectReader reader = mapper.readerFor(javaType); + return new JacksonResponseBodyConverter<>(reader); + } + + @Override + public Converter requestBodyConverter( + Type type, + Annotation[] parameterAnnotations, + Annotation[] methodAnnotations, + Retrofit retrofit) { + JavaType javaType = mapper.getTypeFactory().constructType(type); + ObjectWriter writer = mapper.writerFor(javaType); + return new JacksonRequestBodyConverter<>(writer, mediaType, streaming); + } +} diff --git a/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonRequestBodyConverter.java b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonRequestBodyConverter.java new file mode 100644 index 0000000000..251b987087 --- /dev/null +++ b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonRequestBodyConverter.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * 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 + * + * http://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 retrofit2.converter.jackson3; + +import okhttp3.MediaType; +import okhttp3.RequestBody; +import retrofit2.Converter; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectWriter; + +import java.io.IOException; + +final class JacksonRequestBodyConverter implements Converter { + private final ObjectWriter adapter; + private final MediaType mediaType; + private final boolean streaming; + + JacksonRequestBodyConverter(ObjectWriter adapter, MediaType mediaType, boolean streaming) { + this.adapter = adapter; + this.mediaType = mediaType; + this.streaming = streaming; + } + + @Override + public RequestBody convert(T value) throws IOException { + if (streaming) { + return new JacksonStreamingRequestBody(adapter, value, mediaType); + } + + try { + byte[] bytes = adapter.writeValueAsBytes(value); + return RequestBody.create(bytes, mediaType); + } catch (JacksonException e) { + throw new IOException(e); + } + } +} diff --git a/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonResponseBodyConverter.java b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonResponseBodyConverter.java new file mode 100644 index 0000000000..f13a888210 --- /dev/null +++ b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonResponseBodyConverter.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * 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 + * + * http://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 retrofit2.converter.jackson3; + +import okhttp3.ResponseBody; +import retrofit2.Converter; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectReader; + +import java.io.IOException; + +final class JacksonResponseBodyConverter implements Converter { + private final ObjectReader adapter; + + JacksonResponseBodyConverter(ObjectReader adapter) { + this.adapter = adapter; + } + + @Override + public T convert(ResponseBody value) throws IOException { + try (value) { + return adapter.readValue(value.byteStream()); + } catch (JacksonException e) { + throw new IOException(e); + } + } +} diff --git a/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonStreamingRequestBody.java b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonStreamingRequestBody.java new file mode 100644 index 0000000000..62255ba3ac --- /dev/null +++ b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonStreamingRequestBody.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2025 Square, Inc. + * + * 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 + * + * http://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 retrofit2.converter.jackson3; + +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okio.BufferedSink; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectWriter; + +import java.io.IOException; + +final class JacksonStreamingRequestBody extends RequestBody { + private final ObjectWriter adapter; + private final Object value; + private final MediaType mediaType; + + public JacksonStreamingRequestBody(ObjectWriter adapter, Object value, MediaType mediaType) { + this.adapter = adapter; + this.value = value; + this.mediaType = mediaType; + } + + @Override + public MediaType contentType() { + return mediaType; + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + try { + adapter.writeValue(sink.outputStream(), value); + } catch (JacksonException e) { + throw new IOException(e); + } + } +} diff --git a/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/package-info.java b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/package-info.java new file mode 100644 index 0000000000..d7f9874e32 --- /dev/null +++ b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/package-info.java @@ -0,0 +1,2 @@ +@retrofit2.internal.EverythingIsNonNull +package retrofit2.converter.jackson3; diff --git a/retrofit-converters/jackson3/src/test/java/retrofit2/converter/jackson3/JacksonCborConverterFactoryTest.java b/retrofit-converters/jackson3/src/test/java/retrofit2/converter/jackson3/JacksonCborConverterFactoryTest.java new file mode 100644 index 0000000000..092583281e --- /dev/null +++ b/retrofit-converters/jackson3/src/test/java/retrofit2/converter/jackson3/JacksonCborConverterFactoryTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * 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 + * + * http://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 retrofit2.converter.jackson3; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.IOException; +import okhttp3.MediaType; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okio.Buffer; +import okio.ByteString; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import retrofit2.Call; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.http.Body; +import retrofit2.http.POST; +import tools.jackson.dataformat.cbor.CBORMapper; + +public class JacksonCborConverterFactoryTest { + static class IntWrapper { + public int value; + + public IntWrapper(int v) { + value = v; + } + + protected IntWrapper() {} + } + + interface Service { + @POST("/") + Call post(@Body IntWrapper person); + } + + @Rule public final MockWebServer server = new MockWebServer(); + + private Service service; + + @Before + public void setUp() { + Retrofit retrofit = + new Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory( + JacksonConverterFactory.create(new CBORMapper(), MediaType.get("application/cbor"))) + .build(); + service = retrofit.create(Service.class); + } + + @Test + public void post() throws IOException, InterruptedException { + server.enqueue( + new MockResponse() + .setBody(new Buffer().write(ByteString.decodeHex("bf6576616c7565182aff")))); + + Call call = service.post(new IntWrapper(12)); + Response response = call.execute(); + assertThat(response.body().value).isEqualTo(42); + + RecordedRequest request = server.takeRequest(); + assertThat(request.getBody().readByteString()) + .isEqualTo(ByteString.decodeHex("bf6576616c75650cff")); + assertThat(request.getHeader("Content-Type")).isEqualTo("application/cbor"); + } +} diff --git a/retrofit-converters/jackson3/src/test/java/retrofit2/converter/jackson3/JacksonConverterFactoryTest.java b/retrofit-converters/jackson3/src/test/java/retrofit2/converter/jackson3/JacksonConverterFactoryTest.java new file mode 100644 index 0000000000..325b754ed1 --- /dev/null +++ b/retrofit-converters/jackson3/src/test/java/retrofit2/converter/jackson3/JacksonConverterFactoryTest.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * 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 + * + * http://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 retrofit2.converter.jackson3; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assume.assumeTrue; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.http.Body; +import retrofit2.http.POST; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.exc.StreamConstraintsException; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.std.StdSerializer; + +@RunWith(TestParameterInjector.class) +public final class JacksonConverterFactoryTest { + interface AnInterface { + String getName(); + } + + static class AnImplementation implements AnInterface { + private String theName; + + AnImplementation() {} + + AnImplementation(String name) { + theName = name; + } + + @Override + public String getName() { + return theName; + } + } + + static class AnInterfaceSerializer extends StdSerializer { + AnInterfaceSerializer() { + super(AnInterface.class); + } + + @Override + public void serialize( + AnInterface anInterface, JsonGenerator jsonGenerator, SerializationContext ctxt) { + jsonGenerator.writeStartObject(); + jsonGenerator.writeName("name"); + jsonGenerator.writeString(anInterface.getName()); + jsonGenerator.writeEndObject(); + } + } + + static class AnInterfaceDeserializer extends StdDeserializer { + AnInterfaceDeserializer() { + super(AnInterface.class); + } + + @Override + public AnInterface deserialize(JsonParser jp, DeserializationContext ctxt) { + if (jp.currentToken() != JsonToken.START_OBJECT) { + throw new AssertionError("Expected start object."); + } + + String name = null; + + while (jp.nextToken() != JsonToken.END_OBJECT) { + switch (jp.currentName()) { + case "name": + name = jp.getValueAsString(); + break; + } + } + + return new AnImplementation(name); + } + } + + static final class ErroringValue { + final String theName; + + ErroringValue(String theName) { + this.theName = theName; + } + } + + static final class ErroringValueSerializer extends StdSerializer { + ErroringValueSerializer() { + super(ErroringValue.class); + } + + @Override + public void serialize( + ErroringValue erroringValue, JsonGenerator jsonGenerator, SerializationContext ctxt) + throws JacksonException { + throw new StreamConstraintsException("oops!"); + } + } + + interface Service { + @POST("/") + Call anImplementation(@Body AnImplementation impl); + + @POST("/") + Call anInterface(@Body AnInterface impl); + + @POST("/") + Call erroringValue(@Body ErroringValue value); + } + + @Rule public final MockWebServer server = new MockWebServer(); + + private final Service service; + private final boolean streaming; + + public JacksonConverterFactoryTest(@TestParameter boolean streaming) { + this.streaming = streaming; + + SimpleModule module = new SimpleModule(); + module.addSerializer(AnInterface.class, new AnInterfaceSerializer()); + module.addSerializer(ErroringValue.class, new ErroringValueSerializer()); + module.addDeserializer(AnInterface.class, new AnInterfaceDeserializer()); + + JsonMapper mapper = + JsonMapper.builder() + .addModule(module) + .changeDefaultVisibility(vc -> vc.withGetterVisibility(JsonAutoDetect.Visibility.NONE)) + .changeDefaultVisibility(vc -> vc.withSetterVisibility(JsonAutoDetect.Visibility.NONE)) + .changeDefaultVisibility( + vc -> vc.withIsGetterVisibility(JsonAutoDetect.Visibility.NONE)) + .changeDefaultVisibility(vc -> vc.withFieldVisibility(JsonAutoDetect.Visibility.ANY)) + .build(); + + JacksonConverterFactory factory = JacksonConverterFactory.create(mapper); + if (streaming) { + factory = factory.withStreaming(); + } + + Retrofit retrofit = + new Retrofit.Builder().baseUrl(server.url("/")).addConverterFactory(factory).build(); + service = retrofit.create(Service.class); + } + + @Test + public void anInterface() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("{\"name\":\"value\"}")); + + Call call = service.anInterface(new AnImplementation("value")); + Response response = call.execute(); + AnInterface body = response.body(); + assertThat(body.getName()).isEqualTo("value"); + + RecordedRequest request = server.takeRequest(); + assertThat(request.getBody().readUtf8()).isEqualTo("{\"name\":\"value\"}"); + assertThat(request.getHeader("Content-Type")).isEqualTo("application/json; charset=UTF-8"); + } + + @Test + public void anImplementation() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("{\"theName\":\"value\"}")); + + Call call = service.anImplementation(new AnImplementation("value")); + Response response = call.execute(); + AnImplementation body = response.body(); + assertThat(body.theName).isEqualTo("value"); + + RecordedRequest request = server.takeRequest(); + // TODO figure out how to get Jackson to stop using AnInterface's serializer here. + assertThat(request.getBody().readUtf8()).isEqualTo("{\"name\":\"value\"}"); + assertThat(request.getHeader("Content-Type")).isEqualTo("application/json; charset=UTF-8"); + } + + @Test + public void serializeIsStreamed() throws InterruptedException { + assumeTrue(streaming); + + Call call = service.erroringValue(new ErroringValue("hi")); + + final AtomicReference throwableRef = new AtomicReference<>(); + final CountDownLatch latch = new CountDownLatch(1); + + // If streaming were broken, the call to enqueue would throw the exception synchronously. + call.enqueue( + new Callback() { + @Override + public void onResponse(Call call, Response response) { + latch.countDown(); + } + + @Override + public void onFailure(Call call, Throwable t) { + throwableRef.set(t.getCause()); + latch.countDown(); + } + }); + latch.await(); + + Throwable throwable = throwableRef.get(); + assertThat(throwable).isInstanceOf(JacksonException.class); + assertThat(throwable).hasMessageThat().isEqualTo("oops!"); + } +} diff --git a/settings.gradle b/settings.gradle index e94afa5343..7c0ce27863 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,6 +34,7 @@ include ':retrofit-adapters:scala' include ':retrofit-converters:gson' include ':retrofit-converters:guava' include ':retrofit-converters:jackson' +include ':retrofit-converters:jackson3' include ':retrofit-converters:java8' include ':retrofit-converters:jaxb' include ':retrofit-converters:jaxb3'