Skip to content

Commit 919ee8c

Browse files
authored
Add streaming support for Gson request bodies (#4335)
1 parent 59e9a79 commit 919ee8c

File tree

6 files changed

+149
-25
lines changed

6 files changed

+149
-25
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- 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.
88

99
The following converters support this feature through a new `withStreaming()` factory method:
10+
- Gson
1011
- Moshi
1112
- Wire
1213

retrofit-converters/gson/build.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies {
99
testImplementation libs.junit
1010
testImplementation libs.truth
1111
testImplementation libs.okhttp.mockwebserver
12+
testImplementation libs.testParameterInjector
1213
}
1314

1415
jar {

retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonConverterFactory.java

+16-3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.lang.reflect.Type;
2323
import okhttp3.RequestBody;
2424
import okhttp3.ResponseBody;
25+
import retrofit2.Call;
2526
import retrofit2.Converter;
2627
import retrofit2.Retrofit;
2728

@@ -49,13 +50,25 @@ public static GsonConverterFactory create() {
4950
@SuppressWarnings("ConstantConditions") // Guarding public API nullability.
5051
public static GsonConverterFactory create(Gson gson) {
5152
if (gson == null) throw new NullPointerException("gson == null");
52-
return new GsonConverterFactory(gson);
53+
return new GsonConverterFactory(gson, false);
5354
}
5455

5556
private final Gson gson;
57+
private final boolean streaming;
5658

57-
private GsonConverterFactory(Gson gson) {
59+
private GsonConverterFactory(Gson gson, boolean streaming) {
5860
this.gson = gson;
61+
this.streaming = streaming;
62+
}
63+
64+
/**
65+
* Return a new factory which streams serialization of request messages to bytes on the HTTP thread
66+
* This is either the calling thread for {@link Call#execute()}, or one of OkHttp's background
67+
* threads for {@link Call#enqueue}. Response bytes are always converted to message instances on
68+
* one of OkHttp's background threads.
69+
*/
70+
public GsonConverterFactory withStreaming() {
71+
return new GsonConverterFactory(gson, true);
5972
}
6073

6174
@Override
@@ -72,6 +85,6 @@ public Converter<?, RequestBody> requestBodyConverter(
7285
Annotation[] methodAnnotations,
7386
Retrofit retrofit) {
7487
TypeAdapter<?> adapter = gson.getAdapter(TypeToken.get(type));
75-
return new GsonRequestBodyConverter<>(gson, adapter);
88+
return new GsonRequestBodyConverter<>(gson, adapter, streaming);
7689
}
7790
}

retrofit-converters/gson/src/main/java/retrofit2/converter/gson/GsonRequestBodyConverter.java

+16-4
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,38 @@
2626
import okhttp3.MediaType;
2727
import okhttp3.RequestBody;
2828
import okio.Buffer;
29+
import okio.BufferedSink;
2930
import retrofit2.Converter;
3031

3132
final class GsonRequestBodyConverter<T> implements Converter<T, RequestBody> {
32-
private static final MediaType MEDIA_TYPE = MediaType.get("application/json; charset=UTF-8");
33+
static final MediaType MEDIA_TYPE = MediaType.get("application/json; charset=UTF-8");
3334

3435
private final Gson gson;
3536
private final TypeAdapter<T> adapter;
37+
private final boolean streaming;
3638

37-
GsonRequestBodyConverter(Gson gson, TypeAdapter<T> adapter) {
39+
GsonRequestBodyConverter(Gson gson, TypeAdapter<T> adapter, boolean streaming) {
3840
this.gson = gson;
3941
this.adapter = adapter;
42+
this.streaming = streaming;
4043
}
4144

4245
@Override
4346
public RequestBody convert(T value) throws IOException {
47+
if (streaming) {
48+
return new GsonStreamingRequestBody<>(gson, adapter, value);
49+
}
50+
4451
Buffer buffer = new Buffer();
45-
Writer writer = new OutputStreamWriter(buffer.outputStream(), UTF_8);
52+
writeJson(buffer, gson, adapter, value);
53+
return RequestBody.create(MEDIA_TYPE, buffer.readByteString());
54+
}
55+
56+
static <T> void writeJson(BufferedSink sink, Gson gson, TypeAdapter<T> adapter, T value)
57+
throws IOException {
58+
Writer writer = new OutputStreamWriter(sink.outputStream(), UTF_8);
4659
JsonWriter jsonWriter = gson.newJsonWriter(writer);
4760
adapter.write(jsonWriter, value);
4861
jsonWriter.close();
49-
return RequestBody.create(MEDIA_TYPE, buffer.readByteString());
5062
}
5163
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (C) 2025 Square, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package retrofit2.converter.gson;
17+
18+
import static retrofit2.converter.gson.GsonRequestBodyConverter.MEDIA_TYPE;
19+
import static retrofit2.converter.gson.GsonRequestBodyConverter.writeJson;
20+
21+
import com.google.gson.Gson;
22+
import com.google.gson.TypeAdapter;
23+
import java.io.IOException;
24+
import okhttp3.MediaType;
25+
import okhttp3.RequestBody;
26+
import okio.BufferedSink;
27+
28+
final class GsonStreamingRequestBody<T> extends RequestBody {
29+
private final Gson gson;
30+
private final TypeAdapter<T> adapter;
31+
private final T value;
32+
33+
public GsonStreamingRequestBody(Gson gson, TypeAdapter<T> adapter, T value) {
34+
this.gson = gson;
35+
this.adapter = adapter;
36+
this.value = value;
37+
}
38+
39+
@Override
40+
public MediaType contentType() {
41+
return MEDIA_TYPE;
42+
}
43+
44+
@Override
45+
public void writeTo(BufferedSink sink) throws IOException {
46+
writeJson(sink, gson, adapter, value);
47+
}
48+
}

retrofit-converters/gson/src/test/java/retrofit2/converter/gson/GsonConverterFactoryTest.java

+67-18
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import static com.google.common.truth.Truth.assertThat;
1919
import static org.junit.Assert.fail;
20+
import static org.junit.Assume.assumeTrue;
2021

2122
import com.google.gson.Gson;
2223
import com.google.gson.GsonBuilder;
@@ -25,20 +26,27 @@
2526
import com.google.gson.stream.JsonReader;
2627
import com.google.gson.stream.JsonToken;
2728
import com.google.gson.stream.JsonWriter;
29+
import com.google.testing.junit.testparameterinjector.TestParameter;
30+
import com.google.testing.junit.testparameterinjector.TestParameterInjector;
31+
import java.io.EOFException;
2832
import java.io.IOException;
33+
import java.util.concurrent.CountDownLatch;
34+
import java.util.concurrent.atomic.AtomicReference;
2935
import okhttp3.mockwebserver.MockResponse;
3036
import okhttp3.mockwebserver.MockWebServer;
3137
import okhttp3.mockwebserver.RecordedRequest;
32-
import org.junit.Before;
3338
import org.junit.Rule;
3439
import org.junit.Test;
40+
import org.junit.runner.RunWith;
3541
import retrofit2.Call;
42+
import retrofit2.Callback;
3643
import retrofit2.Response;
3744
import retrofit2.Retrofit;
3845
import retrofit2.http.Body;
3946
import retrofit2.http.GET;
4047
import retrofit2.http.POST;
4148

49+
@RunWith(TestParameterInjector.class)
4250
public final class GsonConverterFactoryTest {
4351
interface AnInterface {
4452
String getName();
@@ -57,27 +65,27 @@ public String getName() {
5765
}
5866
}
5967

60-
static final class Value {
61-
static final TypeAdapter<Value> BROKEN_ADAPTER =
62-
new TypeAdapter<Value>() {
68+
static final class ErroringValue {
69+
static final TypeAdapter<ErroringValue> BROKEN_ADAPTER =
70+
new TypeAdapter<ErroringValue>() {
6371
@Override
64-
public void write(JsonWriter out, Value value) {
65-
throw new AssertionError();
72+
public void write(JsonWriter out, ErroringValue value) throws IOException {
73+
throw new EOFException("oops!");
6674
}
6775

6876
@Override
6977
@SuppressWarnings("CheckReturnValue")
70-
public Value read(JsonReader reader) throws IOException {
78+
public ErroringValue read(JsonReader reader) throws IOException {
7179
reader.beginObject();
7280
reader.nextName();
7381
String theName = reader.nextString();
74-
return new Value(theName);
82+
return new ErroringValue(theName);
7583
}
7684
};
7785

7886
final String theName;
7987

80-
Value(String theName) {
88+
ErroringValue(String theName) {
8189
this.theName = theName;
8290
}
8391
}
@@ -116,25 +124,36 @@ interface Service {
116124
Call<AnInterface> anInterface(@Body AnInterface impl);
117125

118126
@GET("/")
119-
Call<Value> value();
127+
Call<ErroringValue> readErroringValue();
128+
129+
@POST("/")
130+
Call<Void> writeErroringValue(@Body ErroringValue value);
120131
}
121132

122133
@Rule public final MockWebServer server = new MockWebServer();
123134

124-
private Service service;
135+
private final boolean streaming;
136+
private final Service service;
137+
138+
public GsonConverterFactoryTest(@TestParameter boolean streaming) {
139+
this.streaming = streaming;
125140

126-
@Before
127-
public void setUp() {
128141
Gson gson =
129142
new GsonBuilder()
130143
.registerTypeAdapter(AnInterface.class, new AnInterfaceAdapter())
131-
.registerTypeAdapter(Value.class, Value.BROKEN_ADAPTER)
144+
.registerTypeAdapter(ErroringValue.class, ErroringValue.BROKEN_ADAPTER)
132145
.setLenient()
133146
.create();
147+
148+
GsonConverterFactory factory = GsonConverterFactory.create(gson);
149+
if (streaming) {
150+
factory = factory.withStreaming();
151+
}
152+
134153
Retrofit retrofit =
135-
new Retrofit.Builder()
136-
.baseUrl(server.url("/"))
137-
.addConverterFactory(GsonConverterFactory.create(gson))
154+
new Retrofit.Builder() //
155+
.baseUrl(server.url("/")) //
156+
.addConverterFactory(factory) //
138157
.build();
139158
service = retrofit.create(Service.class);
140159
}
@@ -191,12 +210,42 @@ public void deserializeUsesConfiguration() throws IOException, InterruptedExcept
191210
public void requireFullResponseDocumentConsumption() throws Exception {
192211
server.enqueue(new MockResponse().setBody("{\"theName\":\"value\"}"));
193212

194-
Call<Value> call = service.value();
213+
Call<ErroringValue> call = service.readErroringValue();
195214
try {
196215
call.execute();
197216
fail();
198217
} catch (JsonIOException e) {
199218
assertThat(e).hasMessageThat().isEqualTo("JSON document was not fully consumed.");
200219
}
201220
}
221+
222+
@Test
223+
public void serializeIsStreamed() throws InterruptedException {
224+
assumeTrue(streaming);
225+
226+
Call<Void> call = service.writeErroringValue(new ErroringValue("hi"));
227+
228+
final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
229+
final CountDownLatch latch = new CountDownLatch(1);
230+
231+
// If streaming were broken, the call to enqueue would throw the exception synchronously.
232+
call.enqueue(
233+
new Callback<Void>() {
234+
@Override
235+
public void onResponse(Call<Void> call, Response<Void> response) {
236+
latch.countDown();
237+
}
238+
239+
@Override
240+
public void onFailure(Call<Void> call, Throwable t) {
241+
throwableRef.set(t);
242+
latch.countDown();
243+
}
244+
});
245+
latch.await();
246+
247+
Throwable throwable = throwableRef.get();
248+
assertThat(throwable).isInstanceOf(EOFException.class);
249+
assertThat(throwable).hasMessageThat().isEqualTo("oops!");
250+
}
202251
}

0 commit comments

Comments
 (0)