diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d53d809a9..9fc4597de4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - First-party converters now support deferring serialization to happen when the request body is written (i.e., during HTTP execution) rather than when the HTTP request is created. In some cases this moves conversion from a calling thread to a background thread, such as in the case when using `Call.enqueue` directly. The following converters support this feature through a new `withStreaming()` factory method: + - Gson - Moshi - Wire @@ -16,7 +17,7 @@ **Fixed** - - Nothing yet! + - Primitive types used with `@Tag` now work by storing the value boxed with the boxed class as the key. ## [2.11.0] - 2024-03-28 diff --git a/build.gradle b/build.gradle index e3556abea1..9efec04fed 100644 --- a/build.gradle +++ b/build.gradle @@ -48,7 +48,7 @@ subprojects { tasks.withType(JavaCompile).configureEach { task -> task.options.errorprone { - excludedPaths = '.*/build/generated/source/proto/.*' + excludedPaths = '.*/build/generated/sources/proto/.*' check('MissingFail', CheckSeverity.ERROR) check('MissingOverride', CheckSeverity.ERROR) check('UnusedException', CheckSeverity.ERROR) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e0a736d8f8..ab79fb4168 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,21 +13,21 @@ # limitations under the License. [versions] -kotlin = "2.1.10" +kotlin = "2.1.20" okhttp = "3.14.9" protobuf = "3.25.6" robovm = "2.3.14" -kotlinx-serialization = "1.8.0" +kotlinx-serialization = "1.8.1" autoService = "1.1.1" incap = "1.0.0" jackson = "2.18.3" [libraries] -androidPlugin = { module = "com.android.tools.build:gradle", version = "8.9.0" } +androidPlugin = { module = "com.android.tools.build:gradle", version = "8.9.1" } robovmPlugin = { module = "com.mobidevelop.robovm:robovm-gradle-plugin", version.ref = "robovm" } dokkaPlugin = "org.jetbrains.dokka:dokka-gradle-plugin:2.0.0" gradleMavenPublishPlugin = "com.vanniktech:gradle-maven-publish-plugin:0.31.0" -spotlessPlugin = "com.diffplug.spotless:spotless-plugin-gradle:7.0.2" +spotlessPlugin = "com.diffplug.spotless:spotless-plugin-gradle:7.0.3" kotlin-stdLib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } @@ -40,7 +40,7 @@ errorproneJavac = { module = "com.google.errorprone:javac", version = "9+181-r41 animalSnifferPlugin = "ru.vyarus:gradle-animalsniffer-plugin:2.0.0" animalSnifferAnnotations = { module = "org.codehaus.mojo:animal-sniffer-annotations", version = "1.24" } -protobufPlugin = "com.google.protobuf:protobuf-gradle-plugin:0.9.4" +protobufPlugin = "com.google.protobuf:protobuf-gradle-plugin:0.9.5" protobuf = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" } protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } @@ -50,7 +50,7 @@ incap-processor = { module = "net.ltgt.gradle.incap:incap-processor", version.re autoService-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" } autoService-compiler = { module = "com.google.auto.service:auto-service", version.ref = "autoService" } -kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.10.1" } +kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.10.2" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-serialization-proto = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinx-serialization" } @@ -59,7 +59,7 @@ okhttp-loggingInterceptor = { module = "com.squareup.okhttp3:logging-interceptor okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } junit = { module = "junit:junit", version = "4.13.2" } truth = "com.google.truth:truth:1.4.4" -guava = { module = "com.google.guava:guava", version = "33.4.0-jre" } +guava = { module = "com.google.guava:guava", version = "33.4.7-jre" } android = { module = "com.google.android:android", version = "4.1.1.4" } findBugsAnnotations = { module = "com.google.code.findbugs:jsr305", version = "3.0.2" } androidxTestRunner = { module = "androidx.test:runner", version = "1.4.0" } @@ -80,7 +80,7 @@ simpleXml = { module = "org.simpleframework:simple-xml", version = "2.7.1" } wireRuntime = { module = "com.squareup.wire:wire-runtime", version = "2.2.0" } jsoup = { module = "org.jsoup:jsoup", version = "1.19.1" } robovm = { module = "com.mobidevelop.robovm:robovm-rt", version.ref = "robovm" } -googleJavaFormat = "com.google.googlejavaformat:google-java-format:1.25.2" +googleJavaFormat = "com.google.googlejavaformat:google-java-format:1.26.0" ktlint = "com.pinterest.ktlint:ktlint-cli:1.5.0" compileTesting = "com.google.testing.compile:compile-testing:0.21.0" testParameterInjector = "com.google.testparameterinjector:test-parameter-injector:1.18" diff --git a/retrofit-adapters/guava/src/main/java/retrofit2/adapter/guava/GuavaCallAdapterFactory.java b/retrofit-adapters/guava/src/main/java/retrofit2/adapter/guava/GuavaCallAdapterFactory.java index 871b4946cb..c73ddc5a06 100644 --- a/retrofit-adapters/guava/src/main/java/retrofit2/adapter/guava/GuavaCallAdapterFactory.java +++ b/retrofit-adapters/guava/src/main/java/retrofit2/adapter/guava/GuavaCallAdapterFactory.java @@ -164,7 +164,7 @@ private static final class CallCancelListenableFuture extends AbstractFuture< } @Override - public boolean set(@org.checkerframework.checker.nullness.qual.Nullable T value) { + public boolean set(T value) { return super.set(value); } diff --git a/retrofit-converters/gson/build.gradle b/retrofit-converters/gson/build.gradle index eed0d200c3..6a60acb2fa 100644 --- a/retrofit-converters/gson/build.gradle +++ b/retrofit-converters/gson/build.gradle @@ -9,6 +9,7 @@ dependencies { testImplementation libs.junit testImplementation libs.truth testImplementation libs.okhttp.mockwebserver + testImplementation libs.testParameterInjector } jar { diff --git a/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonConverterFactory.java b/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonConverterFactory.java index ad0b9e3dc3..63f619ed11 100644 --- a/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonConverterFactory.java +++ b/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonConverterFactory.java @@ -22,6 +22,7 @@ import java.lang.reflect.Type; import okhttp3.RequestBody; import okhttp3.ResponseBody; +import retrofit2.Call; import retrofit2.Converter; import retrofit2.Retrofit; @@ -49,13 +50,25 @@ public static GsonConverterFactory create() { @SuppressWarnings("ConstantConditions") // Guarding public API nullability. public static GsonConverterFactory create(Gson gson) { if (gson == null) throw new NullPointerException("gson == null"); - return new GsonConverterFactory(gson); + return new GsonConverterFactory(gson, false); } private final Gson gson; + private final boolean streaming; - private GsonConverterFactory(Gson gson) { + private GsonConverterFactory(Gson gson, boolean streaming) { this.gson = gson; + 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 GsonConverterFactory withStreaming() { + return new GsonConverterFactory(gson, true); } @Override @@ -72,6 +85,6 @@ public Converter requestBodyConverter( Annotation[] methodAnnotations, Retrofit retrofit) { TypeAdapter adapter = gson.getAdapter(TypeToken.get(type)); - return new GsonRequestBodyConverter<>(gson, adapter); + return new GsonRequestBodyConverter<>(gson, adapter, streaming); } } diff --git a/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonRequestBodyConverter.java b/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonRequestBodyConverter.java index 2261df7eca..8a0045ed96 100644 --- a/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonRequestBodyConverter.java +++ b/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonRequestBodyConverter.java @@ -26,26 +26,38 @@ import okhttp3.MediaType; import okhttp3.RequestBody; import okio.Buffer; +import okio.BufferedSink; import retrofit2.Converter; final class GsonRequestBodyConverter implements Converter { - private static final MediaType MEDIA_TYPE = MediaType.get("application/json; charset=UTF-8"); + static final MediaType MEDIA_TYPE = MediaType.get("application/json; charset=UTF-8"); private final Gson gson; private final TypeAdapter adapter; + private final boolean streaming; - GsonRequestBodyConverter(Gson gson, TypeAdapter adapter) { + GsonRequestBodyConverter(Gson gson, TypeAdapter adapter, boolean streaming) { this.gson = gson; this.adapter = adapter; + this.streaming = streaming; } @Override public RequestBody convert(T value) throws IOException { + if (streaming) { + return new GsonStreamingRequestBody<>(gson, adapter, value); + } + Buffer buffer = new Buffer(); - Writer writer = new OutputStreamWriter(buffer.outputStream(), UTF_8); + writeJson(buffer, gson, adapter, value); + return RequestBody.create(MEDIA_TYPE, buffer.readByteString()); + } + + static void writeJson(BufferedSink sink, Gson gson, TypeAdapter adapter, T value) + throws IOException { + Writer writer = new OutputStreamWriter(sink.outputStream(), UTF_8); JsonWriter jsonWriter = gson.newJsonWriter(writer); adapter.write(jsonWriter, value); jsonWriter.close(); - return RequestBody.create(MEDIA_TYPE, buffer.readByteString()); } } diff --git a/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonStreamingRequestBody.java b/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonStreamingRequestBody.java new file mode 100644 index 0000000000..590d6d144a --- /dev/null +++ b/retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonStreamingRequestBody.java @@ -0,0 +1,48 @@ +/* + * 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.gson; + +import static retrofit2.converter.gson.GsonRequestBodyConverter.MEDIA_TYPE; +import static retrofit2.converter.gson.GsonRequestBodyConverter.writeJson; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import java.io.IOException; +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okio.BufferedSink; + +final class GsonStreamingRequestBody extends RequestBody { + private final Gson gson; + private final TypeAdapter adapter; + private final T value; + + public GsonStreamingRequestBody(Gson gson, TypeAdapter adapter, T value) { + this.gson = gson; + this.adapter = adapter; + this.value = value; + } + + @Override + public MediaType contentType() { + return MEDIA_TYPE; + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + writeJson(sink, gson, adapter, value); + } +} diff --git a/retrofit-converters/gson/src/test/java/retrofit2/converter/gson/GsonConverterFactoryTest.java b/retrofit-converters/gson/src/test/java/retrofit2/converter/gson/GsonConverterFactoryTest.java index f09ba6d219..fc9d232ac8 100644 --- a/retrofit-converters/gson/src/test/java/retrofit2/converter/gson/GsonConverterFactoryTest.java +++ b/retrofit-converters/gson/src/test/java/retrofit2/converter/gson/GsonConverterFactoryTest.java @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import static org.junit.Assume.assumeTrue; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -25,20 +26,27 @@ import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import java.io.EOFException; 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.Before; 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.GET; import retrofit2.http.POST; +@RunWith(TestParameterInjector.class) public final class GsonConverterFactoryTest { interface AnInterface { String getName(); @@ -57,27 +65,27 @@ public String getName() { } } - static final class Value { - static final TypeAdapter BROKEN_ADAPTER = - new TypeAdapter() { + static final class ErroringValue { + static final TypeAdapter BROKEN_ADAPTER = + new TypeAdapter() { @Override - public void write(JsonWriter out, Value value) { - throw new AssertionError(); + public void write(JsonWriter out, ErroringValue value) throws IOException { + throw new EOFException("oops!"); } @Override @SuppressWarnings("CheckReturnValue") - public Value read(JsonReader reader) throws IOException { + public ErroringValue read(JsonReader reader) throws IOException { reader.beginObject(); reader.nextName(); String theName = reader.nextString(); - return new Value(theName); + return new ErroringValue(theName); } }; final String theName; - Value(String theName) { + ErroringValue(String theName) { this.theName = theName; } } @@ -116,25 +124,36 @@ interface Service { Call anInterface(@Body AnInterface impl); @GET("/") - Call value(); + Call readErroringValue(); + + @POST("/") + Call writeErroringValue(@Body ErroringValue value); } @Rule public final MockWebServer server = new MockWebServer(); - private Service service; + private final boolean streaming; + private final Service service; + + public GsonConverterFactoryTest(@TestParameter boolean streaming) { + this.streaming = streaming; - @Before - public void setUp() { Gson gson = new GsonBuilder() .registerTypeAdapter(AnInterface.class, new AnInterfaceAdapter()) - .registerTypeAdapter(Value.class, Value.BROKEN_ADAPTER) + .registerTypeAdapter(ErroringValue.class, ErroringValue.BROKEN_ADAPTER) .setLenient() .create(); + + GsonConverterFactory factory = GsonConverterFactory.create(gson); + if (streaming) { + factory = factory.withStreaming(); + } + Retrofit retrofit = - new Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(GsonConverterFactory.create(gson)) + new Retrofit.Builder() // + .baseUrl(server.url("/")) // + .addConverterFactory(factory) // .build(); service = retrofit.create(Service.class); } @@ -191,7 +210,7 @@ public void deserializeUsesConfiguration() throws IOException, InterruptedExcept public void requireFullResponseDocumentConsumption() throws Exception { server.enqueue(new MockResponse().setBody("{\"theName\":\"value\"}")); - Call call = service.value(); + Call call = service.readErroringValue(); try { call.execute(); fail(); @@ -199,4 +218,34 @@ public void requireFullResponseDocumentConsumption() throws Exception { assertThat(e).hasMessageThat().isEqualTo("JSON document was not fully consumed."); } } + + @Test + public void serializeIsStreamed() throws InterruptedException { + assumeTrue(streaming); + + Call call = service.writeErroringValue(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(EOFException.class); + assertThat(throwable).hasMessageThat().isEqualTo("oops!"); + } } diff --git a/retrofit-converters/guava/build.gradle b/retrofit-converters/guava/build.gradle index c51e978dd3..b5ea425d03 100644 --- a/retrofit-converters/guava/build.gradle +++ b/retrofit-converters/guava/build.gradle @@ -6,6 +6,7 @@ dependencies { api libs.guava compileOnly libs.findBugsAnnotations + testImplementation libs.findBugsAnnotations testImplementation libs.junit testImplementation libs.truth testImplementation libs.okhttp.mockwebserver diff --git a/retrofit/java-test/build.gradle b/retrofit/java-test/build.gradle index b75fa6f35a..4129d512d4 100644 --- a/retrofit/java-test/build.gradle +++ b/retrofit/java-test/build.gradle @@ -3,6 +3,7 @@ apply plugin: 'java-library' dependencies { testImplementation projects.retrofit testImplementation projects.retrofit.testHelpers + testImplementation libs.findBugsAnnotations testImplementation libs.junit testImplementation libs.truth testImplementation libs.guava @@ -14,7 +15,7 @@ tasks.withType(JavaCompile).configureEach { } // Create a test task for each supported JDK. -(8..21).each { majorVersion -> +(8..24).each { majorVersion -> def jdkTest = tasks.register("testJdk$majorVersion", Test) { javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(majorVersion) diff --git a/retrofit/java-test/src/test/java/retrofit2/RequestFactoryTest.java b/retrofit/java-test/src/test/java/retrofit2/RequestFactoryTest.java index a36d085f25..b4eae2ce2d 100644 --- a/retrofit/java-test/src/test/java/retrofit2/RequestFactoryTest.java +++ b/retrofit/java-test/src/test/java/retrofit2/RequestFactoryTest.java @@ -3330,6 +3330,19 @@ Call method(@Tag String tag) { assertThat(request.tag(String.class)).isEqualTo("tagValue"); } + @Test + public void tagPrimitive() { + class Example { + @GET("/") + Call method(@Tag long timestamp) { + return null; + } + } + + Request request = buildRequest(Example.class, 42L); + assertThat(request.tag(Long.class)).isEqualTo(42L); + } + @Test public void tagGeneric() { class Example { diff --git a/retrofit/src/main/java/retrofit2/RequestFactory.java b/retrofit/src/main/java/retrofit2/RequestFactory.java index 6a30a99190..1a554ab4d9 100644 --- a/retrofit/src/main/java/retrofit2/RequestFactory.java +++ b/retrofit/src/main/java/retrofit2/RequestFactory.java @@ -800,7 +800,7 @@ private ParameterHandler parseParameterAnnotation( } else if (annotation instanceof Tag) { validateResolvableType(p, type); - Class tagType = Utils.getRawType(type); + Class tagType = boxIfPrimitive(Utils.getRawType(type)); for (int i = p - 1; i >= 0; i--) { ParameterHandler otherHandler = parameterHandlers[i]; if (otherHandler instanceof ParameterHandler.Tag diff --git a/retrofit/src/main/java/retrofit2/http/Tag.java b/retrofit/src/main/java/retrofit2/http/Tag.java index 63cf3749c6..9d56d0d8ec 100644 --- a/retrofit/src/main/java/retrofit2/http/Tag.java +++ b/retrofit/src/main/java/retrofit2/http/Tag.java @@ -31,7 +31,9 @@ * * * Tag arguments may be {@code null} which will omit them from the request. Passing a parameterized - * type such as {@code List} will use the raw type (i.e., {@code List.class}) as the key. + * type will use the raw type as the key (e.g., {@code List} uses {@code List.class}). + * Primitive types will be boxed and stored using the boxed type + * (e.g., {@code long} uses {@code Long.class}). * Duplicate tag types are not allowed. */ @Documented