diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c92e832f4..bd8cf38053 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" +jackson3 = "3.1.0" [libraries] androidPlugin = "com.android.tools.build:gradle:9.1.0" @@ -64,7 +65,9 @@ reactiveStreams = { module = "org.reactivestreams:reactive-streams", version = " scalaLibrary = { module = "org.scala-lang:scala-library", version = "2.13.18" } gson = { module = "com.google.code.gson:gson", version = "2.13.2" } jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } +jackson3Databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson3" } jacksonDataformatCbor = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor", version.ref = "jackson" } +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.6" } jaxb3Api = { module = "jakarta.xml.bind:jakarta.xml.bind-api", version = "3.0.1" } diff --git a/retrofit-converters/jackson3/build.gradle b/retrofit-converters/jackson3/build.gradle new file mode 100644 index 0000000000..c158a4140f --- /dev/null +++ b/retrofit-converters/jackson3/build.gradle @@ -0,0 +1,27 @@ +apply plugin: 'java-library' +apply plugin: 'com.vanniktech.maven.publish' + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + + +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.jackson3DataformatCbor +} + +jar { + manifest { + attributes 'Automatic-Module-Name': 'retrofit2.converter.jackson3' + } +} diff --git a/retrofit-converters/jackson3/gradle.properties b/retrofit-converters/jackson3/gradle.properties new file mode 100644 index 0000000000..8cd2c983a2 --- /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 v3 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..268f700792 --- /dev/null +++ b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonRequestBodyConverter.java @@ -0,0 +1,43 @@ +/* + * 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.databind.ObjectWriter; + +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) { + if (streaming) { + return new JacksonStreamingRequestBody(adapter, value, mediaType); + } + + byte[] bytes = adapter.writeValueAsBytes(value); + return RequestBody.create(mediaType, bytes); + } +} 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..75233aee16 --- /dev/null +++ b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonResponseBodyConverter.java @@ -0,0 +1,37 @@ +/* + * 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.databind.ObjectReader; + +final class JacksonResponseBodyConverter implements Converter { + private final ObjectReader adapter; + + JacksonResponseBodyConverter(ObjectReader adapter) { + this.adapter = adapter; + } + + @Override + public T convert(ResponseBody value) { + try { + return adapter.readValue(value.byteStream()); + } finally { + value.close(); + } + } +} 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..61c33491a9 --- /dev/null +++ b/retrofit-converters/jackson3/src/main/java/retrofit2/converter/jackson3/JacksonStreamingRequestBody.java @@ -0,0 +1,43 @@ +/* + * 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.databind.ObjectWriter; + +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) { + adapter.writeValue(sink.outputStream(), value); + } +} 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..3382b74b51 --- /dev/null +++ b/retrofit-converters/jackson3/src/test/java/retrofit2/converter/jackson3/JacksonCborConverterFactoryTest.java @@ -0,0 +1,84 @@ +/* + * 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 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; + +import java.io.IOException; + +import static com.google.common.truth.Truth.assertThat; + +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..dbb876deff --- /dev/null +++ b/retrofit-converters/jackson3/src/test/java/retrofit2/converter/jackson3/JacksonConverterFactoryTest.java @@ -0,0 +1,243 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +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.JacksonIOException; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ObjectMapper; +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; + +import java.io.EOFException; +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assume.assumeTrue; + +@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 + ) throws JacksonException { + + 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 JacksonIOException.construct(new EOFException("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()); + ObjectMapper mapper = JsonMapper.builder() + .addModule(module) + .changeDefaultVisibility(visibilityChecker -> + visibilityChecker.withGetterVisibility(JsonAutoDetect.Visibility.NONE) + .withSetterVisibility(JsonAutoDetect.Visibility.NONE) + .withIsGetterVisibility(JsonAutoDetect.Visibility.NONE) + .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); + latch.countDown(); + } + }); + latch.await(); + + Throwable throwable = throwableRef.get(); + assertThat(throwable).isInstanceOf(IOException.class); + assertThat(throwable).hasMessageThat().contains("oops!"); + } +} diff --git a/settings.gradle b/settings.gradle index e94afa5343..d9eac51353 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,7 +14,7 @@ rootProject.name = 'retrofit-root' include ':retrofit' include ':retrofit-bom' -include ':retrofit:android-test' +//include ':retrofit:android-test' include ':retrofit:java-test' include ':retrofit:kotlin-test' include ':retrofit:robovm-test' @@ -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'