Skip to content

Commit 9339862

Browse files
douglasmillerclaude
andcommitted
fix: Switch DefaultHttpAdapter from OkHttp to HttpURLConnection
Replace OkHttp transport with java.net.HttpURLConnection; remove okhttp and logging-interceptor compile dependencies. Preserve debug logging behind RECURLY_INSECURE + RECURLY_DEBUG using System.out.println. Fix 411 errors on POST/PUT with null body by sending Content-Length: 0, matching OkHttp prior behavior. Add contract tests for this case. Update the implementation guide to use OkHttp as the example adapter instead of java.net.http.HttpClient. Co-Authored-By: Claude <noreply@anthropic.com> feat: Add gzip response decompression to DefaultHttpAdapter DefaultHttpAdapter now sets Accept-Encoding: gzip on outgoing requests (unless the caller already set one) and transparently decompresses gzip-encoded response and error bodies via GZIPInputStream, matching the behavior OkHttp provided automatically before the HttpURLConnection migration. content-encoding and content-length are stripped from the returned headers once decompressed since they no longer describe the decompressed body. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 564ea92 commit 9339862

6 files changed

Lines changed: 346 additions & 98 deletions

File tree

docs/http-adapter-implementation-guide.md

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -161,53 +161,78 @@ adapter and adjust to your SLA requirements.
161161

162162
---
163163

164-
## Minimal example
164+
## OkHttp example
165+
166+
Add the OkHttp dependency to your project:
167+
168+
```xml
169+
<dependency>
170+
<groupId>com.squareup.okhttp3</groupId>
171+
<artifactId>okhttp</artifactId>
172+
<version>4.12.0</version>
173+
</dependency>
174+
```
165175

166176
```java
167177
import com.recurly.v3.http.HttpAdapter;
168178
import com.recurly.v3.http.HttpResponse;
169179

170180
import java.io.IOException;
171-
import java.net.URI;
172-
import java.net.http.HttpClient;
173-
import java.net.http.HttpRequest;
174-
import java.net.http.HttpRequest.BodyPublishers;
175-
import java.time.Duration;
176181
import java.util.HashMap;
177182
import java.util.Map;
178-
179-
public class JavaNetHttpAdapter implements HttpAdapter {
180-
181-
private final HttpClient client = HttpClient.newBuilder()
182-
.connectTimeout(Duration.ofSeconds(30))
183+
import java.util.concurrent.TimeUnit;
184+
import okhttp3.Headers;
185+
import okhttp3.MediaType;
186+
import okhttp3.OkHttpClient;
187+
import okhttp3.Request;
188+
import okhttp3.RequestBody;
189+
import okhttp3.Response;
190+
import okhttp3.ResponseBody;
191+
192+
public class OkHttpAdapter implements HttpAdapter {
193+
194+
private final OkHttpClient client = new OkHttpClient.Builder()
195+
.connectTimeout(60, TimeUnit.SECONDS)
196+
.readTimeout(60, TimeUnit.SECONDS)
197+
.writeTimeout(60, TimeUnit.SECONDS)
183198
.build();
184199

185200
@Override
186201
public HttpResponse execute(String method, String url,
187202
Map<String, String> headers, String body) throws IOException {
188-
HttpRequest.Builder builder = HttpRequest.newBuilder()
189-
.uri(URI.create(url))
190-
.method(method, body != null
191-
? BodyPublishers.ofString(body)
192-
: BodyPublishers.noBody());
203+
Request.Builder builder = new Request.Builder().url(url);
193204

194-
headers.forEach(builder::header);
205+
for (Map.Entry<String, String> header : headers.entrySet()) {
206+
builder.header(header.getKey(), header.getValue());
207+
}
208+
209+
RequestBody requestBody = body != null
210+
? RequestBody.create(body, MediaType.parse("application/json; charset=utf-8"))
211+
: RequestBody.create(new byte[0]);
212+
213+
switch (method) {
214+
case "HEAD": builder.head(); break;
215+
case "GET": builder.get(); break;
216+
case "POST": builder.post(requestBody); break;
217+
case "PUT": builder.put(requestBody); break;
218+
case "DELETE": builder.delete(); break;
219+
default:
220+
throw new IllegalArgumentException(method + " is not a valid Recurly HTTP method");
221+
}
195222

196-
try {
197-
java.net.http.HttpResponse<byte[]> resp =
198-
client.send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofByteArray());
223+
try (Response response = client.newCall(builder.build()).execute()) {
224+
int statusCode = response.code();
199225

200226
Map<String, String> responseHeaders = new HashMap<>();
201-
resp.headers().map().forEach((k, vs) -> {
202-
if (k != null && !vs.isEmpty()) responseHeaders.put(k, vs.get(0));
203-
});
227+
Headers okHeaders = response.headers();
228+
for (int i = 0; i < okHeaders.size(); i++) {
229+
responseHeaders.put(okHeaders.name(i).toLowerCase(), okHeaders.value(i));
230+
}
204231

205-
return new HttpResponse(resp.statusCode(), responseHeaders,
206-
resp.body() != null ? resp.body() : new byte[0]);
232+
ResponseBody responseBody = response.body();
233+
byte[] responseBodyBytes = responseBody != null ? responseBody.bytes() : new byte[0];
207234

208-
} catch (InterruptedException e) {
209-
Thread.currentThread().interrupt();
210-
throw new IOException("HTTP request interrupted", e);
235+
return new HttpResponse(statusCode, responseHeaders, responseBodyBytes);
211236
}
212237
}
213238
}
@@ -274,4 +299,4 @@ public class FakeHttpAdapter implements HttpAdapter {
274299
}
275300
```
276301

277-
See `DefaultHttpAdapter` for the complete production reference implementation.
302+
See `DefaultHttpAdapter` for the `HttpURLConnection`-based reference implementation.

pom.xml

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@
6161
<java.version>1.8</java.version>
6262
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
6363
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
64-
<okhttp3.version>4.12.0</okhttp3.version>
6564
<wiremock.version>3.13.2</wiremock.version>
6665
<surefire.version>3.5.5</surefire.version>
6766
<jacoco.version>0.8.13</jacoco.version>
@@ -240,16 +239,6 @@
240239
<artifactId>gson</artifactId>
241240
<version>2.13.1</version>
242241
</dependency>
243-
<dependency>
244-
<groupId>com.squareup.okhttp3</groupId>
245-
<artifactId>okhttp</artifactId>
246-
<version>${okhttp3.version}</version>
247-
</dependency>
248-
<dependency>
249-
<groupId>com.squareup.okhttp3</groupId>
250-
<artifactId>logging-interceptor</artifactId>
251-
<version>${okhttp3.version}</version>
252-
</dependency>
253242
<dependency>
254243
<groupId>org.wiremock</groupId>
255244
<artifactId>wiremock</artifactId>

src/main/java/com/recurly/v3/Client.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import com.recurly.v3.requests.*;
1111
import com.recurly.v3.resources.*;
1212
import com.recurly.v3.queryparams.*;
13-
import okhttp3.OkHttpClient;
1413
import java.time.ZonedDateTime;
1514

1615
import java.lang.reflect.Type;

src/main/java/com/recurly/v3/http/DefaultHttpAdapter.java

Lines changed: 80 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,28 @@
11
package com.recurly.v3.http;
22

3+
import java.io.ByteArrayOutputStream;
34
import java.io.IOException;
5+
import java.io.InputStream;
6+
import java.io.OutputStream;
7+
import java.net.HttpURLConnection;
8+
import java.net.URL;
9+
import java.nio.charset.StandardCharsets;
410
import java.util.HashMap;
11+
import java.util.List;
512
import java.util.Map;
6-
import java.util.concurrent.TimeUnit;
7-
import okhttp3.Headers;
8-
import okhttp3.MediaType;
9-
import okhttp3.OkHttpClient;
10-
import okhttp3.Request;
11-
import okhttp3.RequestBody;
12-
import okhttp3.Response;
13-
import okhttp3.ResponseBody;
14-
import okhttp3.logging.HttpLoggingInterceptor;
13+
import java.util.zip.GZIPInputStream;
1514

1615
public class DefaultHttpAdapter implements HttpAdapter {
1716
private static final int DEFAULT_TIMEOUT_MS = 60_000;
1817

19-
private final OkHttpClient httpClient;
18+
private final int timeoutMs;
2019

2120
public DefaultHttpAdapter() {
2221
this(DEFAULT_TIMEOUT_MS);
2322
}
2423

2524
public DefaultHttpAdapter(final int timeoutMs) {
26-
final OkHttpClient.Builder builder =
27-
new OkHttpClient.Builder()
28-
.connectTimeout(timeoutMs, TimeUnit.MILLISECONDS)
29-
.readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
30-
.writeTimeout(timeoutMs, TimeUnit.MILLISECONDS);
31-
32-
if (envEnabled("RECURLY_INSECURE") && envEnabled("RECURLY_DEBUG")) {
33-
final HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
34-
logging.setLevel(HttpLoggingInterceptor.Level.BASIC);
35-
builder.addInterceptor(logging);
36-
}
37-
38-
this.httpClient = builder.build();
25+
this.timeoutMs = timeoutMs;
3926
}
4027

4128
@Override
@@ -45,51 +32,87 @@ public HttpResponse execute(
4532
final Map<String, String> headers,
4633
final String body)
4734
throws IOException {
48-
final Request.Builder requestBuilder = new Request.Builder().url(url);
35+
final boolean debug = envEnabled("RECURLY_INSECURE") && envEnabled("RECURLY_DEBUG");
36+
if (debug) {
37+
System.out.println("--> " + method + " " + url);
38+
}
39+
final long startMs = System.currentTimeMillis();
4940

41+
final HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
42+
connection.setRequestMethod(method);
43+
connection.setConnectTimeout(timeoutMs);
44+
connection.setReadTimeout(timeoutMs);
45+
46+
boolean callerSetAcceptEncoding = false;
5047
for (final Map.Entry<String, String> header : headers.entrySet()) {
51-
requestBuilder.header(header.getKey(), header.getValue());
48+
connection.setRequestProperty(header.getKey(), header.getValue());
49+
if ("Accept-Encoding".equalsIgnoreCase(header.getKey())) {
50+
callerSetAcceptEncoding = true;
51+
}
52+
}
53+
if (!callerSetAcceptEncoding) {
54+
connection.setRequestProperty("Accept-Encoding", "gzip");
5255
}
5356

54-
final RequestBody requestBody =
55-
body != null
56-
? RequestBody.create(body, MediaType.parse("application/json; charset=utf-8"))
57-
: RequestBody.create(new byte[0]);
58-
59-
switch (method) {
60-
case "HEAD":
61-
requestBuilder.head();
62-
break;
63-
case "GET":
64-
requestBuilder.get();
65-
break;
66-
case "POST":
67-
requestBuilder.post(requestBody);
68-
break;
69-
case "PUT":
70-
requestBuilder.put(requestBody);
71-
break;
72-
case "DELETE":
73-
requestBuilder.delete();
74-
break;
75-
default:
76-
throw new IllegalArgumentException(method + " is not a valid Recurly HTTP method");
57+
if (body != null || "POST".equals(method) || "PUT".equals(method)) {
58+
connection.setDoOutput(true);
59+
final byte[] bodyBytes = body != null ? body.getBytes(StandardCharsets.UTF_8) : new byte[0];
60+
try (final OutputStream out = connection.getOutputStream()) {
61+
out.write(bodyBytes);
62+
}
7763
}
7864

79-
try (final Response response = httpClient.newCall(requestBuilder.build()).execute()) {
80-
final int statusCode = response.code();
65+
final int statusCode = connection.getResponseCode();
8166

82-
final Map<String, String> responseHeaders = new HashMap<>();
83-
final Headers okHeaders = response.headers();
84-
for (int i = 0; i < okHeaders.size(); i++) {
85-
responseHeaders.put(okHeaders.name(i).toLowerCase(), okHeaders.value(i));
67+
final Map<String, String> responseHeaders = new HashMap<>();
68+
for (final Map.Entry<String, List<String>> entry : connection.getHeaderFields().entrySet()) {
69+
final String key = entry.getKey();
70+
if (key != null && !entry.getValue().isEmpty()) {
71+
responseHeaders.put(key.toLowerCase(), entry.getValue().get(0));
8672
}
73+
}
74+
75+
final InputStream inputStream;
76+
if ("HEAD".equals(method)) {
77+
inputStream = null;
78+
} else {
79+
inputStream = statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream();
80+
}
8781

88-
final ResponseBody responseBody = response.body();
89-
final byte[] responseBodyBytes = responseBody != null ? responseBody.bytes() : new byte[0];
82+
final String contentEncoding = responseHeaders.get("content-encoding");
83+
final boolean gzipEncoded = contentEncoding != null && "gzip".equalsIgnoreCase(contentEncoding.trim());
84+
85+
final byte[] responseBodyBytes;
86+
if (inputStream == null) {
87+
responseBodyBytes = new byte[0];
88+
} else {
89+
try (final InputStream rawStream = inputStream;
90+
final InputStream is = gzipEncoded ? new GZIPInputStream(rawStream) : rawStream) {
91+
responseBodyBytes = readAllBytes(is);
92+
}
93+
}
94+
95+
if (gzipEncoded) {
96+
responseHeaders.remove("content-encoding");
97+
responseHeaders.remove("content-length");
98+
}
99+
100+
if (debug) {
101+
System.out.println(
102+
"<-- " + statusCode + " " + url + " (" + (System.currentTimeMillis() - startMs) + "ms)");
103+
}
104+
105+
return new HttpResponse(statusCode, responseHeaders, responseBodyBytes);
106+
}
90107

91-
return new HttpResponse(statusCode, responseHeaders, responseBodyBytes);
108+
private static byte[] readAllBytes(final InputStream inputStream) throws IOException {
109+
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
110+
final byte[] chunk = new byte[8192];
111+
int n;
112+
while ((n = inputStream.read(chunk)) != -1) {
113+
buffer.write(chunk, 0, n);
92114
}
115+
return buffer.toByteArray();
93116
}
94117

95118
private static boolean envEnabled(final String envVar) {

src/test/java/com/recurly/v3/BaseClientTest.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,50 @@ public void testMakeRequestWithoutResource() throws IOException {
102102
verify(mockAdapter).execute(eq("DELETE"), contains("/resources/resource-id"), any(), isNull());
103103
}
104104

105+
@Test
106+
public void testBuildHeadersSendsCorrectAuthAcceptAndUserAgent() throws IOException {
107+
final HttpAdapter mockAdapter = mock(HttpAdapter.class);
108+
when(mockAdapter.execute(any(), any(), any(), any())).thenReturn(jsonResponse(200, "{}"));
109+
final ArgumentCaptor<Map<String, String>> headersCaptor = ArgumentCaptor.forClass(Map.class);
110+
111+
mockClientWith(mockAdapter).getResource("resource-id");
112+
113+
verify(mockAdapter).execute(any(), any(), headersCaptor.capture(), any());
114+
final Map<String, String> headers = headersCaptor.getValue();
115+
116+
assertEquals(
117+
"Basic " + java.util.Base64.getEncoder().encodeToString("apiKey:".getBytes(StandardCharsets.ISO_8859_1)),
118+
headers.get("Authorization"));
119+
assertEquals("application/vnd.recurly." + Client.API_VERSION, headers.get("Accept"));
120+
assertTrue(
121+
headers.get("User-Agent").matches("Recurly/\\d+\\.\\d+\\.\\d+(-SNAPSHOT)?;\\s+java\\s+\\d+.*"),
122+
"User-Agent header should match the expected format, was: " + headers.get("User-Agent"));
123+
}
124+
125+
@Test
126+
public void testWarnIfDeprecatedPrintsWarningWhenHeaderPresent() throws IOException {
127+
final HttpAdapter mockAdapter = mock(HttpAdapter.class);
128+
final Map<String, String> responseHeaders = new HashMap<>();
129+
responseHeaders.put("content-type", "application/json; charset=utf-8");
130+
responseHeaders.put("recurly-deprecated", "true");
131+
responseHeaders.put("recurly-sunset-date", "2026-01-01");
132+
when(mockAdapter.execute(any(), any(), any(), any()))
133+
.thenReturn(new HttpResponse(200, responseHeaders, "{}".getBytes(StandardCharsets.UTF_8)));
134+
135+
final java.io.PrintStream originalOut = System.out;
136+
final java.io.ByteArrayOutputStream captured = new java.io.ByteArrayOutputStream();
137+
System.setOut(new java.io.PrintStream(captured));
138+
try {
139+
mockClientWith(mockAdapter).getResource("resource-id");
140+
} finally {
141+
System.setOut(originalOut);
142+
}
143+
144+
final String output = captured.toString(StandardCharsets.UTF_8.name());
145+
assertTrue(output.contains("WARNING"), "Expected a deprecation warning, got: " + output);
146+
assertTrue(output.contains("2026-01-01"), "Expected the sunset date in the warning, got: " + output);
147+
}
148+
105149
@Test
106150
public void testMakeRequestWithQueryParams() throws IOException {
107151
final ZonedDateTime dateTime = ZonedDateTime.now();
@@ -157,6 +201,17 @@ public void testNonJsonError500() throws IOException {
157201
() -> mockClientWith(mockAdapter).getResource("code-aaron"));
158202
}
159203

204+
@Test
205+
public void testNonJsonErrorViaRemoveResource() throws IOException {
206+
final HttpAdapter mockAdapter = mock(HttpAdapter.class);
207+
when(mockAdapter.execute(any(), any(), any(), any()))
208+
.thenReturn(htmlResponse(500, "<html>badness</html>"));
209+
210+
assertThrows(
211+
InternalServerException.class,
212+
() -> mockClientWith(mockAdapter).removeResource("code-aaron"));
213+
}
214+
160215
@Test
161216
public void testInvalidApiKey() throws IOException {
162217
final HttpAdapter mockAdapter = mock(HttpAdapter.class);

0 commit comments

Comments
 (0)