diff --git a/docs/http-adapter-implementation-guide.md b/docs/http-adapter-implementation-guide.md new file mode 100644 index 00000000..5efa7af9 --- /dev/null +++ b/docs/http-adapter-implementation-guide.md @@ -0,0 +1,302 @@ +# Implementing a Custom `HttpAdapter` + +The `HttpAdapter` interface lets you replace the default transport with any HTTP library or +middleware you prefer. Common reasons to do this: + +- Use a company-standard HTTP client (e.g. Apache HttpClient, `java.net.http.HttpClient`) +- Route requests through a proxy +- Add observability (logging, metrics, distributed tracing) +- Inject a fake transport in tests without hitting the network + +## Registering your implementation + +```java +ClientOptions options = new ClientOptions(); +options.setHttpAdapter(new MyHttpAdapter()); +Client client = new Client(apiKey, options); +``` + +When no adapter is set, the client uses `DefaultHttpAdapter`. + +--- + +## The `execute` contract + +```java +HttpResponse execute(String method, String url, Map headers, String body) + throws IOException; +``` + +### Method + +Always one of: `GET`, `POST`, `PUT`, `DELETE`, `HEAD`. No other values are sent by the client. + +### URL + +A fully-qualified absolute URL, e.g. `https://v3.recurly.com/accounts?limit=20`. Never `null`. +Query parameters are already encoded and appended by the client. + +### Headers + +All headers the client wants to send, including: + +| Header | Value | +|-----------------|----------------------------------------------------| +| `Authorization` | `Basic ` | +| `Accept` | Recurly API version string | +| `Content-Type` | `application/json` | +| `User-Agent` | `Recurly/; java ` | + +**Forward every entry without modification.** Do not add, remove, or override headers in the +adapter. The client owns header construction; the adapter owns transport. + +### Body + +- `POST` and `PUT` requests: a UTF-8-encoded JSON string. +- `GET`, `HEAD`, `DELETE` requests: `null`. + +When `body` is `null` and the HTTP method requires a body (e.g. `DELETE` with some servers), send +an empty body (`Content-Length: 0`). + +--- + +## Building the `HttpResponse` + +```java +return new HttpResponse(statusCode, responseHeaders, responseBodyBytes); +``` + +### Status code + +Return the exact HTTP status code from the server (e.g. `200`, `404`, `500`). The client uses it +to decide success vs. error and to select the right typed exception. Do not normalise or translate +status codes. + +### Response headers + +Pass a `Map` of all response headers. `HttpResponse` normalises keys to lower-case +internally, so you do not need to do it yourself — but passing lower-case keys is fine too. + +The client reads these specific headers: + +| Header | Purpose | +|-------------------------|---------------------------------------------------| +| `content-type` | Determines JSON vs. binary (PDF) deserialisation | +| `x-request-id` | Included in error messages | +| `recurly-deprecated` | Triggers a deprecation warning log | +| `recurly-sunset-date` | Included in the deprecation warning | +| `recurly-total-records` | Returned by `getRecordCount()` (HEAD requests) | + +Include all headers from the server response, not just these. Future library versions may read +additional headers without a breaking change. + +### Response body + +- Read the body **fully** before returning. Do not return a lazy or streaming reference — `HttpResponse` + takes a `byte[]` and the underlying stream will be closed once `execute` returns. +- Pass an empty `byte[]` (never `null`) when there is no body (e.g. `204 No Content`, `HEAD`). +- The client interprets the bytes as UTF-8 text for JSON and as raw bytes for binary types (PDF). + +--- + +## Error handling + +| Situation | What to do | +|-----------------------------------|----------------------------------------------------| +| Network failure (timeout, refused)| Throw `IOException`. The client wraps it in `NetworkException`. | +| TLS / certificate error | Throw `IOException`. | +| HTTP 4xx / 5xx response | Return the `HttpResponse` — do not throw. The client maps status codes to typed exceptions. | +| `InterruptedException` (JDK `HttpClient` only) | Restore the interrupt flag, then throw `IOException`. | + +Most HTTP libraries handle thread cancellation through their own timeout/cancellation mechanism and +only throw `IOException` from their synchronous execute call. You only need the pattern below if +your underlying client declares `throws InterruptedException` — notably +`java.net.http.HttpClient.send()`: + +```java +} catch (InterruptedException e) { + Thread.currentThread().interrupt(); // restore the flag + throw new IOException("Request interrupted", e); +} +``` + +--- + +## Thread safety + +A single `HttpAdapter` instance is shared across all requests made by one `Client` instance, and +`Client` is designed to be shared across threads. Your adapter must be safe for concurrent use. + +In practice this means: +- Hold your underlying HTTP client as a **field** (not a local variable per call). +- Ensure any client configuration (timeouts, proxy settings) is immutable after construction, or + protected by synchronisation. + +--- + +## Connection pooling + +Creating a new TCP connection for every request adds 50–200 ms of latency and wastes server +resources. Use an HTTP client that maintains a connection pool and hold it as a field: + +```java +public class MyHttpAdapter implements HttpAdapter { + // Shared, thread-safe; connection pool lives here. + private final java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient(); + + @Override + public HttpResponse execute(String method, String url, + Map headers, String body) throws IOException { + // ... + } +} +``` + +--- + +## Timeouts + +The Recurly client does not enforce timeouts. Set connect, read, and write timeouts inside your +adapter and adjust to your SLA requirements. + +--- + +## OkHttp example + +Add the OkHttp dependency to your project: + +```xml + + com.squareup.okhttp3 + okhttp + 4.12.0 + +``` + +```java +import com.recurly.v3.http.HttpAdapter; +import com.recurly.v3.http.HttpResponse; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +public class OkHttpAdapter implements HttpAdapter { + + private final OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build(); + + @Override + public HttpResponse execute(String method, String url, + Map headers, String body) throws IOException { + Request.Builder builder = new Request.Builder().url(url); + + for (Map.Entry header : headers.entrySet()) { + builder.header(header.getKey(), header.getValue()); + } + + RequestBody requestBody = body != null + ? RequestBody.create(body, MediaType.parse("application/json; charset=utf-8")) + : RequestBody.create(new byte[0]); + + switch (method) { + case "HEAD": builder.head(); break; + case "GET": builder.get(); break; + case "POST": builder.post(requestBody); break; + case "PUT": builder.put(requestBody); break; + case "DELETE": builder.delete(); break; + default: + throw new IllegalArgumentException(method + " is not a valid Recurly HTTP method"); + } + + try (Response response = client.newCall(builder.build()).execute()) { + int statusCode = response.code(); + + Map responseHeaders = new HashMap<>(); + Headers okHeaders = response.headers(); + for (int i = 0; i < okHeaders.size(); i++) { + responseHeaders.put(okHeaders.name(i).toLowerCase(), okHeaders.value(i)); + } + + ResponseBody responseBody = response.body(); + byte[] responseBodyBytes = responseBody != null ? responseBody.bytes() : new byte[0]; + + return new HttpResponse(statusCode, responseHeaders, responseBodyBytes); + } + } +} +``` + +--- + +## Verifying your implementation with `HttpAdapterContract` + +The library ships `HttpAdapterContract`, a portable JUnit 5 contract test that verifies any +`HttpAdapter` against the full behavioural requirements above. Extend it and implement +`createAdapter()`: + +```java +class MyAdapterTest extends HttpAdapterContract { + @Override + protected HttpAdapter createAdapter() { + return new MyHttpAdapter(); + } +} +``` + +**Java 11 required at test runtime.** `HttpAdapterContract` depends on WireMock 3, which requires +Java 11+. Tests are automatically skipped on Java 8 via `@DisabledOnJre(JRE.JAVA_8)`. Your +compiled adapter can still target Java 8 bytecode — only the test JVM must be Java 11+. + +Add these test-scope dependencies to your project: + +```xml + + org.wiremock + wiremock + 3.13.2 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.12.2 + test + +``` + +--- + +## Test doubles + +To test code that uses the Recurly client without network calls, implement `HttpAdapter` to return +canned `HttpResponse` objects: + +```java +public class FakeHttpAdapter implements HttpAdapter { + private final Queue responses = new ArrayDeque<>(); + + public void enqueue(HttpResponse r) { responses.add(r); } + + @Override + public HttpResponse execute(String method, String url, + Map headers, String body) { + HttpResponse r = responses.poll(); + if (r == null) throw new IllegalStateException("No response queued for " + method + " " + url); + return r; + } +} +``` + +See `DefaultHttpAdapter` for the `HttpURLConnection`-based reference implementation. diff --git a/pom.xml b/pom.xml index bd62f331..773ee24e 100644 --- a/pom.xml +++ b/pom.xml @@ -61,7 +61,7 @@ 1.8 UTF-8 UTF-8 - 4.12.0 + 3.13.2 3.5.5 0.8.13 @@ -152,6 +152,18 @@ + + org.apache.maven.plugins + maven-jar-plugin + + + test-jar + + test-jar + + + + org.apache.maven.plugins maven-source-plugin @@ -228,19 +240,9 @@ 2.13.1 - com.squareup.okhttp3 - okhttp - ${okhttp3.version} - - - com.squareup.okhttp3 - logging-interceptor - ${okhttp3.version} - - - com.squareup.okhttp3 - mockwebserver - ${okhttp3.version} + org.wiremock + wiremock + ${wiremock.version} test diff --git a/src/main/java/com/recurly/v3/BaseClient.java b/src/main/java/com/recurly/v3/BaseClient.java index deceebb1..4c6614cf 100644 --- a/src/main/java/com/recurly/v3/BaseClient.java +++ b/src/main/java/com/recurly/v3/BaseClient.java @@ -2,54 +2,54 @@ import com.google.gson.annotations.SerializedName; import com.recurly.v3.exception.ExceptionFactory; -import com.recurly.v3.http.HeaderInterceptor; -import com.recurly.v3.ClientOptions; +import com.recurly.v3.http.DefaultHttpAdapter; +import com.recurly.v3.http.HttpAdapter; +import com.recurly.v3.http.HttpResponse; import java.io.IOException; +import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.lang.reflect.Field; import java.lang.reflect.Type; -import java.math.BigDecimal; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; import java.util.HashMap; -import java.util.Map; +import java.util.HashSet; import java.util.List; -import java.util.Arrays; +import java.util.Map; +import java.util.Properties; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; - -import okhttp3.*; -import okhttp3.Request.Builder; -import okhttp3.logging.HttpLoggingInterceptor; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; public abstract class BaseClient { private static final List BINARY_TYPES = Arrays.asList("application/pdf"); + private static final Set VALID_METHODS = + new HashSet<>(Arrays.asList("GET", "POST", "PUT", "DELETE", "HEAD")); + private static final String USER_AGENT = buildUserAgent(); private static final JsonSerializer jsonSerializer = new JsonSerializer(); private static final FileSerializer fileSerializer = new FileSerializer(); - private final String apiKey; - private final OkHttpClient client; + + private final String authToken; + private final HttpAdapter httpAdapter; private String apiUrl; protected BaseClient(final String apiKey) { - this(apiKey, newHttpClient(validateApiKey(apiKey)), new ClientOptions()); + this(apiKey, new ClientOptions()); } protected BaseClient(final String apiKey, final ClientOptions clientOptions) { - this(apiKey, newHttpClient(validateApiKey(apiKey)), clientOptions); - } - - protected BaseClient(final String apiKey, final OkHttpClient client) { - this(apiKey, client, new ClientOptions()); - } - - protected BaseClient(final String apiKey, final OkHttpClient client, final ClientOptions clientOptions) { - this.apiKey = validateApiKey(apiKey); - this.client = client; + this.authToken = buildAuthToken(validateApiKey(apiKey)); this.apiUrl = clientOptions.getBaseUrl(); + this.httpAdapter = + clientOptions.getHttpAdapter() != null + ? clientOptions.getHttpAdapter() + : new DefaultHttpAdapter(); } private static String validateApiKey(final String apiKey) { @@ -59,21 +59,71 @@ private static String validateApiKey(final String apiKey) { return apiKey; } - private static OkHttpClient newHttpClient(final String apiKey) { - final OkHttpClient.Builder httpClientBuilder = new OkHttpClient.Builder(); - - final String authToken = Credentials.basic(apiKey, ""); - final HeaderInterceptor headerInterceptor = - new HeaderInterceptor(authToken, Client.API_VERSION); - httpClientBuilder.addInterceptor(headerInterceptor); - - if (envEnabled("RECURLY_INSECURE") && envEnabled("RECURLY_DEBUG")) { - final HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); - logging.setLevel(HttpLoggingInterceptor.Level.BASIC); - httpClientBuilder.addInterceptor(logging); + private static String buildAuthToken(final String apiKey) { + return "Basic " + + Base64.getEncoder() + .encodeToString((apiKey + ":").getBytes(StandardCharsets.ISO_8859_1)); + } + + private Map buildHeaders() { + final Map headers = new HashMap<>(); + headers.put("Authorization", authToken); + headers.put("Accept", "application/vnd.recurly." + Client.API_VERSION); + headers.put("Accept-Encoding", "gzip"); + headers.put("Content-Type", "application/json"); + headers.put("User-Agent", USER_AGENT); + return headers; + } + + private String buildUrl(final String path, final HashMap queryParams) { + if (queryParams == null || queryParams.isEmpty()) { + return this.apiUrl + path; + } + + final StringBuilder sb = new StringBuilder(this.apiUrl).append(path); + boolean first = true; + + for (final Map.Entry param : queryParams.entrySet()) { + final Object value = param.getValue(); + if (value == null) continue; + + final String stringValue; + if (value instanceof String) { + stringValue = value.toString(); + } else if (value instanceof ZonedDateTime) { + stringValue = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format((ZonedDateTime) value); + } else if (value instanceof Integer) { + stringValue = Integer.toString((Integer) value); + } else if (value instanceof Float) { + stringValue = Float.toString((Float) value); + } else if (value instanceof Double) { + stringValue = Double.toString((Double) value); + } else if (value instanceof Long) { + stringValue = Long.toString((Long) value); + } else if (value instanceof Enum) { + stringValue = getSerializedEnumName((Enum) value); + } else { + stringValue = value.toString(); + } + + if (stringValue == null) continue; + + try { + sb.append(first ? "?" : "&") + .append(param.getKey()) + .append("=") + .append(URLEncoder.encode(stringValue, StandardCharsets.UTF_8.toString())); + first = false; + } catch (UnsupportedEncodingException ex) { + throw new RecurlyException(ex.getCause()); + } } - return httpClientBuilder.build(); + return sb.toString(); + } + + private static boolean isSuccessful(final int statusCode) { + return statusCode >= 200 && statusCode < 300; } protected static boolean envEnabled(final String envVar) { @@ -81,26 +131,25 @@ protected static boolean envEnabled(final String envVar) { } protected void makeRequest(final String method, final String url) { - final okhttp3.Request request = buildRequest(method, url, null, null); + validateMethod(method); + final String fullUrl = buildUrl(url, null); + final Map headers = buildHeaders(); - try (final Response response = client.newCall(request).execute()) { - if (!response.isSuccessful()) { - String responseString = response.body().string(); - if (envEnabled("RECURLY_INSECURE") && envEnabled("RECURLY_DEBUG")) { - System.out.println(responseString); - } - throw jsonSerializer.deserializeError(responseString); - } - - final Headers responseHeaders = response.headers(); - - if (envEnabled("RECURLY_INSECURE") && envEnabled("RECURLY_DEBUG")) { - for (int i = 0; i < responseHeaders.size(); i++) { - System.out.println(responseHeaders.name(i) + ": " + responseHeaders.value(i)); + try { + final HttpResponse response = httpAdapter.execute(method, fullUrl, headers, null); + + if (!isSuccessful(response.getStatusCode())) { + final String contentType = + response.getHeaders().getOrDefault("content-type", "application/json"); + if (contentType.contains("application/json")) { + throw jsonSerializer.deserializeError( + new String(response.getBody(), StandardCharsets.UTF_8)); + } else { + throw ExceptionFactory.getExceptionClass(response); } } - this.warnIfDeprecated(responseHeaders); + warnIfDeprecated(response.getHeaders()); } catch (IOException e) { throw new NetworkException(e); @@ -130,28 +179,34 @@ protected T makeRequest( final Request body, final HashMap queryParams, final Type resourceClass) { - final okhttp3.Request request = buildRequest(method, url, body, queryParams); + validateMethod(method); + final String fullUrl = buildUrl(url, queryParams); + final String bodyString = body != null ? jsonSerializer.serialize(body) : null; + final Map headers = buildHeaders(); - try (final Response response = client.newCall(request).execute()) { + try { + final HttpResponse response = httpAdapter.execute(method, fullUrl, headers, bodyString); - final Headers responseHeaders = response.headers(); - final ResponseBody responseBody = response.body(); - MediaType contentType = responseBody.contentType(); + final int statusCode = response.getStatusCode(); + final String contentType = + response.getHeaders().getOrDefault("content-type", "application/json"); - if (!response.isSuccessful()) { - if (contentType.type().equals("application") && contentType.subtype().equals("json")) { - throw jsonSerializer.deserializeError(responseBody.string()); + if (!isSuccessful(statusCode)) { + if (contentType.contains("application/json")) { + throw jsonSerializer.deserializeError( + new String(response.getBody(), StandardCharsets.UTF_8)); } else { throw ExceptionFactory.getExceptionClass(response); } } - this.warnIfDeprecated(responseHeaders); + warnIfDeprecated(response.getHeaders()); - if (BINARY_TYPES.contains(contentType.type() + "/" + contentType.subtype())) { - return fileSerializer.deserialize(responseBody.bytes(), resourceClass); + if (BINARY_TYPES.stream().anyMatch(contentType::startsWith)) { + return fileSerializer.deserialize(response.getBody(), resourceClass); } else { - return jsonSerializer.deserialize(responseBody.string(), resourceClass); + return jsonSerializer.deserialize( + new String(response.getBody(), StandardCharsets.UTF_8), resourceClass); } } catch (IOException e) { @@ -160,111 +215,51 @@ protected T makeRequest( } public int getRecordCount(final String url, final HashMap queryParams) { - final okhttp3.Request request = buildRequest("HEAD", url, null, queryParams); - - try (final Response response = client.newCall(request).execute()) { + final String fullUrl = buildUrl(url, queryParams); + final Map headers = buildHeaders(); - final Headers responseHeaders = response.headers(); - final ResponseBody responseBody = response.body(); + try { + final HttpResponse response = httpAdapter.execute("HEAD", fullUrl, headers, null); - if (!response.isSuccessful()) { - throw jsonSerializer.deserializeError(responseBody.string()); + if (!isSuccessful(response.getStatusCode())) { + throw ExceptionFactory.getExceptionClass(response); } - this.warnIfDeprecated(responseHeaders); + warnIfDeprecated(response.getHeaders()); - String count = responseHeaders.get("Recurly-Total-Records"); - return Integer.parseInt(count); + final String recordCount = response.getHeaders().get("recurly-total-records"); + try { + return Integer.parseInt(recordCount); + } catch (NumberFormatException e) { + throw new RecurlyException("Invalid recurly-total-records header value: " + recordCount); + } } catch (IOException e) { throw new NetworkException(e); } } - private String getSerializedEnumName(Enum e) { + private static void validateMethod(final String method) { + if (!VALID_METHODS.contains(method)) { + throw new IllegalArgumentException(method + " is not a valid Recurly HTTP method"); + } + } + + private String getSerializedEnumName(final Enum e) { try { - Field f = e.getClass().getField(e.name()); - SerializedName a = f.getAnnotation(SerializedName.class); + final Field f = e.getClass().getField(e.name()); + final SerializedName a = f.getAnnotation(SerializedName.class); return a == null ? null : a.value(); } catch (NoSuchFieldException ignored) { return null; } } - private okhttp3.Request buildRequest( - final String method, - final String url, - final Request body, - final HashMap queryParams) { - final HttpUrl.Builder httpBuilder = HttpUrl.parse(this.apiUrl + url).newBuilder(); - - final RequestBody requestBody = - RequestBody.create( - jsonSerializer.serialize(body), MediaType.parse("application/json; charset=utf-8")); - - if (queryParams != null) { - for (Map.Entry param : queryParams.entrySet()) { - final Object value = param.getValue(); - final String stringValue; - - if (value == null) { - continue; - } else if (value instanceof String) { - stringValue = value.toString(); - } else if (value instanceof ZonedDateTime) { - stringValue = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format((ZonedDateTime) value); - } else if (value instanceof Integer) { - stringValue = Integer.toString((Integer) value); - } else if (value instanceof Float) { - stringValue = Float.toString((Float) value); - } else if (value instanceof Double) { - stringValue = Double.toString((Double) value); - } else if (value instanceof Long) { - stringValue = Long.toString((Long) value); - } else if (value instanceof Enum) { - stringValue = getSerializedEnumName((Enum)value); - } else { - stringValue = value.toString(); - } - - httpBuilder.addQueryParameter(param.getKey(), stringValue); - } - } - - final HttpUrl requestUrl = httpBuilder.build(); - - if (envEnabled("RECURLY_INSECURE") && envEnabled("RECURLY_DEBUG")) { - System.out.println("Performing " + method + " request to " + requestUrl); - } - - final Builder requestBuilder = new okhttp3.Request.Builder().url(requestUrl); - - switch (method) { - case "HEAD": - return requestBuilder.head().build(); - - case "GET": - return requestBuilder.build(); - - case "POST": - return requestBuilder.post(requestBody).build(); - - case "PUT": - return requestBuilder.put(requestBody).build(); - - case "DELETE": - return requestBuilder.delete().build(); - - default: - String message = method + " is not a valid Recurly HTTP method"; - throw new IllegalArgumentException(message); - } - } - private void validatePathParameters(final HashMap urlParams) { - Map invalidParams = urlParams.entrySet().stream() - .filter(p -> p.getValue() == null || p.getValue().trim().isEmpty()) - .collect(Collectors.toMap(e->e.getKey(),e->e.getValue())); + Map invalidParams = + urlParams.entrySet().stream() + .filter(p -> p.getValue() == null || p.getValue().trim().isEmpty()) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); if (!invalidParams.isEmpty()) { String invalidKeys = String.join(",", invalidParams.keySet()); throw new RecurlyException(invalidKeys + " cannot be an empty value"); @@ -283,10 +278,11 @@ protected String interpolatePath(String path, final HashMap urlP while (m.find()) { final String key = m.group(1).replace("{", "").replace("}", ""); try { - final String value = URLEncoder.encode(urlParams.get(key), StandardCharsets.UTF_8.toString()); + final String value = + URLEncoder.encode(urlParams.get(key), StandardCharsets.UTF_8.toString()); path = path.replace(m.group(1), value); } catch (UnsupportedEncodingException ex) { - throw new RecurlyException(ex.getCause()); + throw new RecurlyException(ex.getCause()); } } @@ -309,19 +305,37 @@ public String getApiUrl() { return this.apiUrl; } - private void warnIfDeprecated(Headers responseHeaders) { - String deprecated = responseHeaders.get("Recurly-Deprecated"); + private void warnIfDeprecated(final Map headers) { + final String deprecated = headers.get("recurly-deprecated"); - if (deprecated != null && deprecated.toUpperCase() == "TRUE") { - String sunset = responseHeaders.get("Recurly-Sunset-Date"); - - String warning = + if (deprecated != null && "TRUE".equalsIgnoreCase(deprecated)) { + final String sunset = headers.get("recurly-sunset-date"); + System.out.println( "[recurly-client-java] WARNING: Your current API version \"" + Client.API_VERSION + "\" is deprecated and will be sunset on " - + sunset; + + sunset); + } + } - System.out.println(warning); + private static String buildUserAgent() { + final String defaultVersion = "3.?.?"; + final String defaultJvmInfo = "?"; + final Properties properties = new Properties(); + + try (final InputStream inputStream = + BaseClient.class.getResourceAsStream("/version.properties")) { + if (inputStream != null) { + properties.load(inputStream); + final String version = properties.getProperty("version", defaultVersion); + final String jvmInfo = System.getProperty("java.version", defaultJvmInfo); + return String.format("Recurly/%s; java %s", version, jvmInfo); + } + } catch (Exception e) { + System.out.println("[Recurly][WARNING] " + e.toString()); } + + System.out.println("[Recurly][WARNING] Could not set user agent header."); + return String.format("Recurly/%s; java %s", defaultVersion, defaultJvmInfo); } } diff --git a/src/main/java/com/recurly/v3/Client.java b/src/main/java/com/recurly/v3/Client.java index 79c79e3a..84a65b28 100644 --- a/src/main/java/com/recurly/v3/Client.java +++ b/src/main/java/com/recurly/v3/Client.java @@ -10,7 +10,6 @@ import com.recurly.v3.requests.*; import com.recurly.v3.resources.*; import com.recurly.v3.queryparams.*; -import okhttp3.OkHttpClient; import java.time.ZonedDateTime; import java.lang.reflect.Type; diff --git a/src/main/java/com/recurly/v3/ClientOptions.java b/src/main/java/com/recurly/v3/ClientOptions.java index eacf7f7f..0b739a94 100644 --- a/src/main/java/com/recurly/v3/ClientOptions.java +++ b/src/main/java/com/recurly/v3/ClientOptions.java @@ -1,4 +1,6 @@ package com.recurly.v3; + +import com.recurly.v3.http.HttpAdapter; import java.util.HashMap; public class ClientOptions { @@ -9,18 +11,20 @@ public enum Regions { }; private static final HashMap regionsMap = new HashMap<>(); + static { - regionsMap.put(Regions.US, "https://v3.recurly.com"); - regionsMap.put(Regions.EU, "https://v3.eu.recurly.com"); + regionsMap.put(Regions.US, "https://v3.recurly.com"); + regionsMap.put(Regions.EU, "https://v3.eu.recurly.com"); } private Regions region; + private HttpAdapter httpAdapter; public ClientOptions() { this.region = Regions.US; } - public void setRegion(Regions r) { + public void setRegion(final Regions r) { this.region = r; } @@ -28,4 +32,12 @@ public void setRegion(Regions r) { public String getBaseUrl() { return regionsMap.get(this.region); } + + public void setHttpAdapter(final HttpAdapter adapter) { + this.httpAdapter = adapter; + } + + public HttpAdapter getHttpAdapter() { + return httpAdapter; + } } \ No newline at end of file diff --git a/src/main/java/com/recurly/v3/exception/ExceptionFactory.java b/src/main/java/com/recurly/v3/exception/ExceptionFactory.java index 64e8d519..bf622f66 100644 --- a/src/main/java/com/recurly/v3/exception/ExceptionFactory.java +++ b/src/main/java/com/recurly/v3/exception/ExceptionFactory.java @@ -7,8 +7,8 @@ import com.recurly.v3.ApiException; import com.recurly.v3.RecurlyException; +import com.recurly.v3.http.HttpResponse; import com.recurly.v3.resources.ErrorMayHaveTransaction; -import okhttp3.Response; public class ExceptionFactory { @@ -79,9 +79,9 @@ public static T getExceptionClass(ApiException apiE } @SuppressWarnings("unchecked") - public static T getExceptionClass(Response response) { - String requestId = response.header("X-Request-Id", "none"); - int code = response.code(); + public static T getExceptionClass(HttpResponse response) { + String requestId = response.getHeaders().getOrDefault("x-request-id", "none"); + int code = response.getStatusCode(); String message = "Unexpected " + code + " Error. Recurly Request Id: " + requestId; switch (code) { case 500: diff --git a/src/main/java/com/recurly/v3/http/DefaultHttpAdapter.java b/src/main/java/com/recurly/v3/http/DefaultHttpAdapter.java new file mode 100644 index 00000000..a797c7bc --- /dev/null +++ b/src/main/java/com/recurly/v3/http/DefaultHttpAdapter.java @@ -0,0 +1,109 @@ +package com.recurly.v3.http; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +public class DefaultHttpAdapter implements HttpAdapter { + private static final int DEFAULT_TIMEOUT_MS = 60_000; + + private final int timeoutMs; + + public DefaultHttpAdapter() { + this(DEFAULT_TIMEOUT_MS); + } + + public DefaultHttpAdapter(final int timeoutMs) { + this.timeoutMs = timeoutMs; + } + + @Override + public HttpResponse execute( + final String method, + final String url, + final Map headers, + final String body) + throws IOException { + final boolean debug = envEnabled("RECURLY_INSECURE") && envEnabled("RECURLY_DEBUG"); + if (debug) { + System.out.println("--> " + method + " " + url); + } + final long startMs = System.currentTimeMillis(); + + final HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(timeoutMs); + connection.setReadTimeout(timeoutMs); + + for (final Map.Entry header : headers.entrySet()) { + connection.setRequestProperty(header.getKey(), header.getValue()); + } + + if (body != null) { + connection.setDoOutput(true); + try (final OutputStream out = connection.getOutputStream()) { + out.write(body.getBytes(StandardCharsets.UTF_8)); + } + } else if ("POST".equals(method) || "PUT".equals(method)) { + connection.setDoOutput(true); + connection.setRequestProperty("Content-Length", "0"); + connection.getOutputStream().close(); + } + + final int statusCode = connection.getResponseCode(); + + final Map responseHeaders = new HashMap<>(); + for (final Map.Entry> entry : connection.getHeaderFields().entrySet()) { + final String key = entry.getKey(); + if (key != null && !entry.getValue().isEmpty()) { + responseHeaders.put(key.toLowerCase(), entry.getValue().get(0)); + } + } + + final InputStream inputStream; + if ("HEAD".equals(method)) { + inputStream = null; + } else { + inputStream = statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream(); + } + + final byte[] responseBodyBytes; + if (inputStream == null) { + responseBodyBytes = new byte[0]; + } else { + final boolean gzip = "gzip".equalsIgnoreCase(responseHeaders.get("content-encoding")); + try (final InputStream is = gzip ? new GZIPInputStream(inputStream) : inputStream) { + responseBodyBytes = readAllBytes(is); + } + } + + if (debug) { + System.out.println( + "<-- " + statusCode + " " + url + " (" + (System.currentTimeMillis() - startMs) + "ms)"); + } + + return new HttpResponse(statusCode, responseHeaders, responseBodyBytes); + } + + private static byte[] readAllBytes(final InputStream inputStream) throws IOException { + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + final byte[] chunk = new byte[8192]; + int n; + while ((n = inputStream.read(chunk)) != -1) { + buffer.write(chunk, 0, n); + } + return buffer.toByteArray(); + } + + private static boolean envEnabled(final String envVar) { + return "true".equals(System.getenv(envVar)); + } +} diff --git a/src/main/java/com/recurly/v3/http/HeaderInterceptor.java b/src/main/java/com/recurly/v3/http/HeaderInterceptor.java deleted file mode 100644 index 79f542db..00000000 --- a/src/main/java/com/recurly/v3/http/HeaderInterceptor.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.recurly.v3.http; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Properties; -import okhttp3.Interceptor; -import okhttp3.Request; -import okhttp3.Response; - -public class HeaderInterceptor implements Interceptor { - - private final String apiVersion; - private final String authToken; - private static String USER_AGENT = buildUserAgent(); - - public HeaderInterceptor(final String authToken, final String apiVersion) { - this.apiVersion = apiVersion; - this.authToken = authToken; - } - - public Response intercept(final Chain chain) throws IOException { - Request original = chain.request(); - - Request.Builder builder = - original - .newBuilder() - .header("Authorization", authToken) - .header("Accept", "application/vnd.recurly." + apiVersion) - .header("Content-Type", "application/json") - .header("User-Agent", USER_AGENT); - - Request request = builder.build(); - return chain.proceed(request); - } - - private static String buildUserAgent() { - final String defaultVersion = "3.?.?"; - final String defaultJvmInfo = "?"; - final Properties properties = new Properties(); - - try { - final InputStream inputStream = - HeaderInterceptor.class.getResourceAsStream("/version.properties"); - if (inputStream != null) { - properties.load(inputStream); - final String version = properties.getProperty("version", defaultVersion); - final String jvmInfo = System.getProperty("java.version", defaultJvmInfo); - return String.format("Recurly/%s; java %s", version, jvmInfo); - } - } catch (Exception e) { - // TODO rethrow exception in strict-mode - System.out.println("[Recurly][WARNING] " + e.getStackTrace().toString()); - } - - System.out.println("[Recurly][WARNING] Could not set user agent header."); - return String.format("Recurly/%s; java %s", defaultVersion, defaultJvmInfo); - } -} diff --git a/src/main/java/com/recurly/v3/http/HttpAdapter.java b/src/main/java/com/recurly/v3/http/HttpAdapter.java new file mode 100644 index 00000000..16703f26 --- /dev/null +++ b/src/main/java/com/recurly/v3/http/HttpAdapter.java @@ -0,0 +1,85 @@ +package com.recurly.v3.http; + +import java.io.IOException; +import java.util.Map; + +/** + * Pluggable HTTP transport layer for the Recurly Java client. + * + *

The client ships with {@link DefaultHttpAdapter}. Implement this interface when you need to + * supply a different HTTP library, add middleware (logging, metrics, proxy routing), or inject a + * fake transport in tests. + * + *

Registration + * + *

{@code
+ * ClientOptions options = new ClientOptions();
+ * options.setHttpAdapter(new MyHttpAdapter());
+ * Client client = new Client(apiKey, options);
+ * }
+ * + *

Thread safety
+ * A single {@code HttpAdapter} instance is shared across all requests made by a {@code Client}. + * Implementations must be safe for concurrent use from multiple threads. + * + *

Connection pooling
+ * Implementations should reuse connections across calls (e.g. via an {@code HttpClient} instance + * held as a field) to avoid the overhead of establishing a new TCP connection on every request. + * + *

Timeouts
+ * The client imposes no timeout of its own. Set connect, read, and write timeouts inside the + * implementation. + * + *

See {@code docs/http-adapter-implementation-guide.md} for a full contract reference and + * worked examples. + */ +public interface HttpAdapter { + + /** + * Executes a single HTTP request and returns the complete response. + * + *

Parameters + * + *

    + *
  • {@code method} — always one of {@code GET}, {@code POST}, {@code PUT}, {@code DELETE}, + * {@code HEAD}. + *
  • {@code url} — fully-qualified URL including scheme, host, path, and any query string. + * Never {@code null}. + *
  • {@code headers} — all request headers the client wants sent (Authorization, + * Accept, Content-Type, User-Agent, etc.). Forward every entry without modification; + * do not add, remove, or override headers in the adapter. + *
  • {@code body} — UTF-8 JSON string for {@code POST} and {@code PUT} requests; {@code null} + * for {@code GET}, {@code HEAD}, and {@code DELETE}. + *
+ * + *

Return value
+ * Return an {@link HttpResponse} containing: + * + *

    + *
  • The HTTP status code exactly as received. + *
  • All response headers. {@link HttpResponse} normalises header names to lower-case + * internally, so case in the map passed to the constructor does not matter. The client + * reads {@code content-type}, {@code x-request-id}, {@code recurly-deprecated}, + * {@code recurly-sunset-date}, and {@code recurly-total-records}. + *
  • The complete response body as a byte array. Read the body fully before returning; do not + * return a lazy or streaming reference. Pass an empty {@code byte[]} (never {@code null}) + * when there is no body. + *
+ * + *

Error handling
+ * Throw {@link IOException} for any network-level failure (connection refused, timeout, TLS + * error, etc.). The client wraps it in a {@code NetworkException}. Do not throw for + * HTTP-level errors (4xx/5xx) — return the response and let the client map those to typed + * exceptions. + * + * @param method HTTP method ({@code GET}, {@code POST}, {@code PUT}, {@code DELETE}, + * {@code HEAD}) + * @param url absolute URL to request + * @param headers request headers to send; must be forwarded unmodified + * @param body request body as a JSON string, or {@code null} if there is no body + * @return the complete HTTP response + * @throws IOException on network or I/O failure + */ + HttpResponse execute(String method, String url, Map headers, String body) + throws IOException; +} diff --git a/src/main/java/com/recurly/v3/http/HttpResponse.java b/src/main/java/com/recurly/v3/http/HttpResponse.java new file mode 100644 index 00000000..8ba8f2e4 --- /dev/null +++ b/src/main/java/com/recurly/v3/http/HttpResponse.java @@ -0,0 +1,39 @@ +package com.recurly.v3.http; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +public class HttpResponse { + private final int statusCode; + private final Map headers; + private final byte[] body; + + /** + * Constructs an immutable HTTP response snapshot. The {@code body} array is defensively copied; + * callers may not observe mutations to the original array through this object. + */ + public HttpResponse(final int statusCode, final Map headers, final byte[] body) { + Objects.requireNonNull(headers, "headers must not be null"); + Objects.requireNonNull(body, "body must not be null"); + this.statusCode = statusCode; + final Map normalized = new HashMap<>(); + headers.forEach((k, v) -> normalized.put(k.toLowerCase(), v)); + this.headers = Collections.unmodifiableMap(normalized); + this.body = body.clone(); + } + + public int getStatusCode() { + return statusCode; + } + + public Map getHeaders() { + return headers; + } + + /** Returns a copy of the response body. Mutations to the returned array do not affect this object. */ + public byte[] getBody() { + return body.clone(); + } +} diff --git a/src/test/java/com/recurly/v3/BaseClientTest.java b/src/test/java/com/recurly/v3/BaseClientTest.java index d2b8427c..721dfa35 100644 --- a/src/test/java/com/recurly/v3/BaseClientTest.java +++ b/src/test/java/com/recurly/v3/BaseClientTest.java @@ -7,154 +7,108 @@ import com.recurly.v3.exception.TransactionException; import com.recurly.v3.exception.ValidationException; import com.recurly.v3.fixtures.FixtureConstants; -import com.recurly.v3.ApiException; import com.recurly.v3.fixtures.MockClient; import com.recurly.v3.fixtures.MockQueryParams; import com.recurly.v3.fixtures.MyRequest; import com.recurly.v3.fixtures.MyResource; -import okhttp3.Call; -import okhttp3.Headers; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; - +import com.recurly.v3.http.HttpAdapter; +import com.recurly.v3.http.HttpResponse; import org.apache.commons.io.IOUtils; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; -import org.junit.Assert; import org.junit.jupiter.api.Test; -import org.mockito.stubbing.Answer; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; import java.io.IOException; import java.io.InputStream; -import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; -import org.mockito.MockedStatic; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.mockito.Mockito.eq; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; @SuppressWarnings("unchecked") public class BaseClientTest { - @Test - public void testMakeRequestWithResource() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { - Request request = i.getArgument(0); - HttpUrl url = request.url(); - assertEquals("GET", request.method()); - assertEquals("/resources/code-aaron", url.url().getPath()); - return mCall; - }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(200, "OK", getResponseJson())); + private static HttpResponse jsonResponse(final int statusCode, final String body) { + final Map headers = new HashMap<>(); + headers.put("content-type", "application/json; charset=utf-8"); + return new HttpResponse(statusCode, headers, body.getBytes(StandardCharsets.UTF_8)); + } + + private static HttpResponse htmlResponse(final int statusCode, final String body) { + final Map headers = new HashMap<>(); + headers.put("content-type", "text/html; charset=UTF-8"); + return new HttpResponse(statusCode, headers, body.getBytes(StandardCharsets.UTF_8)); + } + + private static HttpResponse headResponse(final int statusCode, final String recordCount) { + final Map headers = new HashMap<>(); + headers.put("recurly-total-records", recordCount); + return new HttpResponse(statusCode, headers, new byte[0]); + } - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + private static MockClient mockClientWith(final HttpAdapter adapter) { + final ClientOptions options = new ClientOptions(); + options.setHttpAdapter(adapter); + return new MockClient("apiKey", options); + } - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - final MyResource resource = client.getResource("code-aaron"); + @Test + public void testMakeRequestWithResource() throws IOException { + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(eq("GET"), contains("/resources/code-aaron"), any(), isNull())) + .thenReturn(jsonResponse(200, getResponseJson())); + final MyResource resource = mockClientWith(mockAdapter).getResource("code-aaron"); assertEquals(MyResource.class, resource.getClass()); } @Test public void testMakeRequestWithBody() throws IOException { - final Call mCall = mock(Call.class); - AtomicBoolean postCalled = new AtomicBoolean(false); - AtomicBoolean putCalled = new AtomicBoolean(false); - Answer answer = (i) -> { - Request request = i.getArgument(0); - HttpUrl url = request.url(); - switch (request.method()) { - case "POST": - assertEquals("/resources", url.url().getPath()); - postCalled.set(true); - break; - case "PUT": - assertEquals("/resources/someId", url.url().getPath()); - putCalled.set(true); - break; - default: - // Any other request method is a failure - Assert.fail(); - } - return mCall; - }; - when(mCall.execute()) - .thenReturn(MockClient.buildResponse(200, "OK", getResponseJson())) - .thenReturn(MockClient.buildResponse(200, "OK", getResponseJson())); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(200, getResponseJson())); - final MockClient client = new MockClient("apiKey", mockOkHttpClient); + final MockClient client = mockClientWith(mockAdapter); final MyRequest newResource = new MyRequest(); newResource.setMyString("aaron"); final MyResource resource = client.createResource(newResource); + verify(mockAdapter).execute(eq("POST"), contains("/resources"), any(), notNull()); assertEquals(MyResource.class, resource.getClass()); assertEquals("aaron", resource.getMyString()); - assertTrue(postCalled.get()); final MyResource anotherResource = client.updateResource("someId", newResource); + verify(mockAdapter).execute(eq("PUT"), contains("/resources/someId"), any(), notNull()); assertEquals(MyResource.class, anotherResource.getClass()); assertEquals("aaron", anotherResource.getMyString()); - assertTrue(putCalled.get()); } @Test public void testMakeRequestWithoutResource() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { - Request request = i.getArgument(0); - HttpUrl url = request.url(); - assertEquals("DELETE", request.method()); - assertEquals("/resources/resource-id", url.url().getPath()); - return mCall; - }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(200, "OK", "")); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(eq("DELETE"), contains("/resources/resource-id"), any(), isNull())) + .thenReturn(jsonResponse(200, "")); - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - client.removeResource("resource-id"); + mockClientWith(mockAdapter).removeResource("resource-id"); + verify(mockAdapter).execute(eq("DELETE"), contains("/resources/resource-id"), any(), isNull()); } @Test public void testMakeRequestWithQueryParams() throws IOException { - ZonedDateTime dateTime = ZonedDateTime.now(); - - final Call mCall = mock(Call.class); - Answer answer = (i) -> { - Request request = i.getArgument(0); - HttpUrl url = request.url(); - assertEquals("Aaron", url.queryParameter("my_string")); - assertEquals(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(dateTime), url.queryParameter("my_date_time")); - assertEquals("1", url.queryParameter("my_integer")); - assertEquals("2.3", url.queryParameter("my_float")); - assertEquals("4.5", url.queryParameter("my_double")); - assertEquals("6", url.queryParameter("my_long")); - assertEquals("twenty-three", url.queryParameter("my_enum")); - assertEquals(null, url.queryParameter("my_random")); - assertEquals("[]", url.queryParameter("unsupported")); - return mCall; - }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(200, "OK", getResponseListJson())); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); + final ZonedDateTime dateTime = ZonedDateTime.now(); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(200, getResponseListJson())); + final MockQueryParams qp = new MockQueryParams(); qp.setMyString("Aaron"); qp.setMyDateTime(dateTime); @@ -165,186 +119,128 @@ public void testMakeRequestWithQueryParams() throws IOException { qp.setMyEnum(FixtureConstants.ConstantType.TWENTY_THREE); qp.setMyRandom(null); qp.setUnsupported(new ArrayList<>()); - final Pager pager = client.listResources(qp); - pager.getNextPage(); + + final ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + mockClientWith(mockAdapter).listResources(qp).getNextPage(); + + verify(mockAdapter).execute(eq("GET"), urlCaptor.capture(), any(), isNull()); + final String url = urlCaptor.getValue(); + + assertTrue(url.contains("my_string=Aaron")); + assertTrue(url.contains("my_date_time=" + DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(dateTime).replace(":", "%3A").replace("+", "%2B"))); + assertTrue(url.contains("my_integer=1")); + assertTrue(url.contains("my_float=2.3")); + assertTrue(url.contains("my_double=4.5")); + assertTrue(url.contains("my_long=6")); + assertTrue(url.contains("my_enum=twenty-three")); + assertFalse(url.contains("my_random")); + assertTrue(url.contains("unsupported=%5B%5D")); } @Test public void testNonJsonError0() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - Headers headers = new Headers.Builder().build(); - MediaType contentType = MediaType.get("text/html; charset=UTF-8"); - when(mCall.execute()).thenReturn(MockClient.buildResponse(0, "Not A Real Status", "badness", headers, contentType)); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(htmlResponse(0, "badness")); - assertThrows( - ApiException.class, - () -> { - client.getResource("code-aaron"); - }); + assertThrows(ApiException.class, () -> mockClientWith(mockAdapter).getResource("code-aaron")); } @Test public void testNonJsonError500() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - Headers headers = new Headers.Builder().build(); - MediaType contentType = MediaType.get("text/html; charset=UTF-8"); - when(mCall.execute()).thenReturn(MockClient.buildResponse(500, "Internal Server Error", "badness", headers, contentType)); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(htmlResponse(500, "badness")); assertThrows( InternalServerException.class, - () -> { - client.getResource("code-aaron"); - }); + () -> mockClientWith(mockAdapter).getResource("code-aaron")); } @Test public void testInvalidApiKey() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(404, "Not Found", getErrorJson("invalid_api_key"))); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(404, getErrorJson("invalid_api_key"))); - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - - // This test is important because it ensures that application/json response errors are based on the json - // body's error type and not the status code based error assertThrows( InvalidApiKeyException.class, - () -> { - client.getResource("code-aaron"); - }); + () -> mockClientWith(mockAdapter).getResource("code-aaron")); } @Test public void testNotFoundError() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(404, "Not Found", getErrorJson("not_found"))); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(404, getErrorJson("not_found"))); assertThrows( NotFoundException.class, - () -> { - client.getResource("code-aaron"); - }); + () -> mockClientWith(mockAdapter).getResource("code-aaron")); } @Test public void testUnknownError() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - final Response response = MockClient.buildResponse(999, "Unknown", getErrorJson("unknown")); - when(mCall.execute()).thenReturn(response); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(999, getErrorJson("unknown"))); - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + assertThrows(ApiException.class, () -> mockClientWith(mockAdapter).getResource("code-aaron")); - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - // asserts that generic api exception is thrown for unknown error - assertThrows( - ApiException.class, - () -> { - client.getResource("code-aaron"); - }); - final RecurlyException exception = ExceptionFactory.getExceptionClass(response); + final Map headers = new HashMap<>(); + headers.put("content-type", "application/json"); + final HttpResponse httpResponse = + new HttpResponse(999, headers, getErrorJson("unknown").getBytes(StandardCharsets.UTF_8)); + final RecurlyException exception = ExceptionFactory.getExceptionClass(httpResponse); assertTrue(exception.toString().contains("ApiException")); } @Test public void testValidationError() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(422, "Unprocessable Entity", getErrorResponse("validation"))); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(422, getErrorResponse("validation"))); assertThrows( ValidationException.class, - () -> { - client.removeResource("code-aaron"); - }); + () -> mockClientWith(mockAdapter).removeResource("code-aaron")); } @Test public void testTransactionError() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(422, "Unprocessable Entity", getErrorResponse("transaction"))); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(422, getErrorResponse("transaction"))); - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - - TransactionException t = assertThrows( + final TransactionException t = + assertThrows( TransactionException.class, - () -> { - client.removeResource("code-aaron"); - }); + () -> mockClientWith(mockAdapter).removeResource("code-aaron")); assertEquals("mbca9aaao6xr", t.getError().getTransactionError().getTransactionId()); } @Test public void testNetworkError() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - when(mCall.execute()).thenThrow(new IOException()); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())).thenThrow(new IOException()); - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - assertThrows( - NetworkException.class, - () -> { - client.getResource("code-aaron"); - }); + assertThrows(NetworkException.class, () -> mockClientWith(mockAdapter).getResource("code-aaron")); } @Test public void testNetworkErrorWithoutResource() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - when(mCall.execute()).thenThrow(new IOException()); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())).thenThrow(new IOException()); - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); assertThrows( NetworkException.class, - () -> { - client.removeResource("code-aaron"); - }); + () -> mockClientWith(mockAdapter).removeResource("code-aaron")); } @Test - public void testBadMethodError() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - when(mCall.execute()).thenThrow(new IOException()); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - - assertThrows( - IllegalArgumentException.class, - () -> { - client.badRequestMethod(); - }); + public void testBadMethodError() { + final MockClient client = new MockClient("apiKey"); + assertThrows(IllegalArgumentException.class, () -> client.badRequestMethod()); } @Test @@ -361,14 +257,13 @@ public void testSetApiUrl() { } @Test - public void testCantSetApiUrlWithoutRecurlyInsecure() throws Exception { + public void testCantSetApiUrlWithoutRecurlyInsecure() { try (MockedStatic theMock = mockStatic(BaseClient.class)) { theMock.when(() -> BaseClient.envEnabled(eq("RECURLY_INSECURE"))).thenReturn(false); final MockClient client = new MockClient("apiKey"); final String originalUrl = client.getApiUrl(); - final String newApiUrl = "https://my.base.url/"; - client._setApiUrl(newApiUrl); + client._setApiUrl("https://my.base.url/"); assertEquals(originalUrl, client.getApiUrl()); } @@ -376,72 +271,103 @@ public void testCantSetApiUrlWithoutRecurlyInsecure() throws Exception { @Test public void testWithoutClientOptions() { - // The default region should be ClientOptions.Regions.US - final MockClient client = new MockClient("apiKey"); - assertEquals("https://v3.recurly.com", client.getApiUrl()); + assertEquals("https://v3.recurly.com", new MockClient("apiKey").getApiUrl()); } @Test public void testUsingRegionUSClientOptions() { final ClientOptions options = new ClientOptions(); options.setRegion(ClientOptions.Regions.US); - final MockClient client = new MockClient("apiKey", options); - assertEquals("https://v3.recurly.com", client.getApiUrl()); + assertEquals("https://v3.recurly.com", new MockClient("apiKey", options).getApiUrl()); } @Test public void testUsingRegionEUClientOptions() { final ClientOptions options = new ClientOptions(); options.setRegion(ClientOptions.Regions.EU); - final MockClient client = new MockClient("apiKey", options); - assertEquals("https://v3.eu.recurly.com", client.getApiUrl()); + assertEquals("https://v3.eu.recurly.com", new MockClient("apiKey", options).getApiUrl()); } @Test public void testInterpolatePathWithoutParams() { - final MockClient client = new MockClient("apiKey"); - final String path = "/accounts"; - final String interpolatedPath = client.interpolatePath(path); - - assertEquals("/accounts", interpolatedPath); + assertEquals("/accounts", new MockClient("apiKey").interpolatePath("/accounts")); } @Test public void testInterpolatePathWithParams() { - final MockClient client = new MockClient("apiKey"); - final String path = "/accounts/{account_id}/notes/{account_note_id}"; - final HashMap urlParams = new HashMap(); + final HashMap urlParams = new HashMap<>(); urlParams.put("account_id", "accountId/"); urlParams.put("account_note_id", "noteId,"); - final String interpolatedPath = client.interpolatePath(path, urlParams); + assertEquals( + "/accounts/accountId%2F/notes/noteId%2C", + new MockClient("apiKey") + .interpolatePath("/accounts/{account_id}/notes/{account_note_id}", urlParams)); + } + + @Test + public void testGetRecordCountNonSuccessThrowsRecurlyException() throws IOException { + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(eq("HEAD"), any(), any(), isNull())) + .thenReturn(new HttpResponse(404, new HashMap<>(), new byte[0])); + + assertThrows( + RecurlyException.class, + () -> mockClientWith(mockAdapter).getRecordCount("/resources", null)); + } + + @Test + public void testGetRecordCountMissingHeader() throws IOException { + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + final Map headers = new HashMap<>(); + when(mockAdapter.execute(eq("HEAD"), any(), any(), isNull())) + .thenReturn(new HttpResponse(200, headers, new byte[0])); + + assertThrows( + RecurlyException.class, + () -> mockClientWith(mockAdapter).getRecordCount("/resources", null)); + } + + @Test + public void testHttpResponseNullHeadersThrows() { + assertThrows(NullPointerException.class, () -> new HttpResponse(200, null, new byte[0])); + } + + @Test + public void testMixedCaseHeadersAreNormalized() throws IOException { + // Without normalization BaseClient looks up "content-type" but finds nothing (key is + // "Content-Type"), falls back to "application/json", and tries to JSON-parse an HTML body. + // With normalization it correctly routes to ExceptionFactory → InternalServerException. + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + final Map headers = new HashMap<>(); + headers.put("Content-Type", "text/html; charset=UTF-8"); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(new HttpResponse(500, headers, "error".getBytes(StandardCharsets.UTF_8))); - assertEquals("/accounts/accountId%2F/notes/noteId%2C", interpolatedPath); + assertThrows( + InternalServerException.class, + () -> mockClientWith(mockAdapter).getResource("code-aaron")); } @Test public void testInterpolatePathValidations() { - final MockClient client = new MockClient("apiKey"); - final String path = "/accounts/{account_id}/notes/{account_note_id}"; - final HashMap urlParams = new HashMap(); + final HashMap urlParams = new HashMap<>(); urlParams.put("account_id", ""); urlParams.put("account_note_id", ""); - assertThrows( RecurlyException.class, - () -> { - client.interpolatePath(path, urlParams); - }); + () -> + new MockClient("apiKey") + .interpolatePath( + "/accounts/{account_id}/notes/{account_note_id}", urlParams)); } @Test public void testInterpolatePathMatching() { - final MockClient client = new MockClient("apiKey"); - final String path = "/url_path/{url_path}"; - final HashMap urlParams = new HashMap(); + final HashMap urlParams = new HashMap<>(); urlParams.put("url_path", "replacement"); - - final String interpolatedPath = client.interpolatePath(path, urlParams); - assertEquals("/url_path/replacement", interpolatedPath); + assertEquals( + "/url_path/replacement", + new MockClient("apiKey").interpolatePath("/url_path/{url_path}", urlParams)); } private static String getResponseJson() { @@ -462,7 +388,7 @@ private static String getResponseListJson() { + "}"; } - private static String getErrorJson(String exception) { + private static String getErrorJson(final String exception) { return "" + "{\n" + " \"error\": {\n" @@ -477,7 +403,7 @@ private static String getErrorJson(String exception) { + "}"; } - private static String getErrorResponse(String exception) { + private static String getErrorResponse(final String exception) { InputStream resource = null; if ("validation".equals(exception)) { diff --git a/src/test/java/com/recurly/v3/HeaderInterceptorTest.java b/src/test/java/com/recurly/v3/HeaderInterceptorTest.java deleted file mode 100644 index e4ff015e..00000000 --- a/src/test/java/com/recurly/v3/HeaderInterceptorTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.recurly.v3; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.recurly.v3.http.HeaderInterceptor; -import java.io.IOException; -import okhttp3.*; -import okhttp3.Request; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.Test; - -public class HeaderInterceptorTest { - @Test - public void testHttpHeaders() throws IOException, InterruptedException { - MockWebServer mockWebServer = new MockWebServer(); - mockWebServer.start(); - mockWebServer.enqueue(new MockResponse()); - - OkHttpClient okHttpClient = - new OkHttpClient() - .newBuilder() - .addInterceptor(new HeaderInterceptor("apikey", "version")) - .build(); - - okHttpClient.newCall(new Request.Builder().url(mockWebServer.url("/")).build()).execute(); - - RecordedRequest request = mockWebServer.takeRequest(); - assertEquals("apikey", request.getHeader("Authorization")); - assertEquals("application/vnd.recurly.version", request.getHeader("Accept")); - assertEquals("application/json", request.getHeader("Content-Type")); - - // TODO this regex will change on GA - // BETA semver sequence is forced until then - final String agentFormat = - "Recurly/\\d+\\.\\d+\\.\\d+(-SNAPSHOT)?;\\s+java\\s+\\d+.*"; - assertEquals(request.getHeader("User-Agent").matches(agentFormat), true); - - mockWebServer.shutdown(); - } -} diff --git a/src/test/java/com/recurly/v3/PagerTest.java b/src/test/java/com/recurly/v3/PagerTest.java index 8b3a68e6..5485e2f9 100644 --- a/src/test/java/com/recurly/v3/PagerTest.java +++ b/src/test/java/com/recurly/v3/PagerTest.java @@ -5,38 +5,41 @@ import com.recurly.v3.fixtures.MockClient; import com.recurly.v3.fixtures.MyResource; +import com.recurly.v3.http.HttpAdapter; +import com.recurly.v3.http.HttpResponse; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; import java.util.NoSuchElementException; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import okhttp3.*; -import okhttp3.Request; import org.junit.jupiter.api.Test; -import org.mockito.stubbing.Answer; public class PagerTest { + + private static HttpResponse jsonResponse(final int statusCode, final String body) { + final Map headers = new HashMap<>(); + headers.put("content-type", "application/json; charset=utf-8"); + return new HttpResponse(statusCode, headers, body.getBytes(StandardCharsets.UTF_8)); + } + + private static MockClient mockClientWith(final HttpAdapter adapter) { + final ClientOptions options = new ClientOptions(); + options.setHttpAdapter(adapter); + return new MockClient("apiKey", options); + } + @Test public void testForEach() throws IOException { - final Call mCall = mock(Call.class); - AtomicBoolean firstCalled = new AtomicBoolean(false); - Answer answer = (i) -> { - Request request = i.getArgument(0); - HttpUrl url = request.url(); - if (firstCalled.get()) { - assertEquals("/next", url.url().getPath()); - } - firstCalled.set(true); - return mCall; - }; - when(mCall.execute()) - .thenReturn(MockClient.buildResponse(200, "OK", getResourceFirstPageJson("/next"))) - .thenReturn(MockClient.buildResponse(200, "OK", getResourceSecondPageJson())); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - Pager pager = client.listResources(null); - AtomicInteger count = new AtomicInteger(0); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(200, getResourceFirstPageJson("/next"))) + .thenReturn(jsonResponse(200, getResourceSecondPageJson())); + + final MockClient client = mockClientWith(mockAdapter); + final Pager pager = client.listResources(null); + final AtomicInteger count = new AtomicInteger(0); + pager.forEach( resource -> { if (count.get() < 3) { @@ -50,58 +53,40 @@ public void testForEach() throws IOException { @Test public void testEachItem() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(200, "OK", getResourceSecondPageJson())); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(200, getResourceSecondPageJson())); - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - Pager pager = client.listResources(null); + final MockClient client = mockClientWith(mockAdapter); + final Pager pager = client.listResources(null); pager.eachItem(resource -> assertNotNull(resource.getMyString())); } @Test public void testEmptyList() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { return mCall; }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(200, "OK", getEmptyListJson())); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(200, getEmptyListJson())); - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - Pager pager = client.listResources(null); + final MockClient client = mockClientWith(mockAdapter); + final Pager pager = client.listResources(null); assertEquals(0, pager.getData().size()); for (MyResource myResource : pager) { - myResource.getMyString(); // This should not throw NullPointerException + myResource.getMyString(); } - pager.forEach( - myResource -> - myResource.getMyString()); // This should not throw NullPointerException either + pager.forEach(myResource -> myResource.getMyString()); } @Test public void testForLoop() throws IOException { - final Call mCall = mock(Call.class); - AtomicBoolean firstCalled = new AtomicBoolean(false); - Answer answer = (i) -> { - Request request = i.getArgument(0); - HttpUrl url = request.url(); - if (firstCalled.get()) { - assertEquals("/next", url.url().getPath()); - } - firstCalled.set(true); - return mCall; - }; - when(mCall.execute()) - .thenReturn(MockClient.buildResponse(200, "OK", getResourceFirstPageJson("/next"))) - .thenReturn(MockClient.buildResponse(200, "OK", getResourceSecondPageJson())); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(200, getResourceFirstPageJson("/next"))) + .thenReturn(jsonResponse(200, getResourceSecondPageJson())); - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - Pager pager = client.listResources(null); + final MockClient client = mockClientWith(mockAdapter); + final Pager pager = client.listResources(null); int count = 0; for (MyResource res : pager) { if (count < 3) { @@ -116,67 +101,50 @@ public void testForLoop() throws IOException { @Test public void testNullNextPage() { - Pager pager = new Pager<>(null, null, null, null); - + final Pager pager = new Pager<>(null, null, null, null); assertThrows(NoSuchElementException.class, () -> pager.getNextPage()); } @Test public void testCount() throws IOException { - final Call mCall = mock(Call.class); - Headers headers = new Headers.Builder().set("Recurly-Total-Records", "1337").build(); - Answer answer = (i) -> { - Request request = i.getArgument(0); - assertEquals("HEAD", request.method()); - return mCall; - }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(200, "OK", getResourceFirstItemJson(), headers)); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - Pager pager = client.listResources(null); - int count = pager.getCount(); - assertEquals(1337, count); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + final Map headers = new HashMap<>(); + headers.put("recurly-total-records", "1337"); + when(mockAdapter.execute(eq("HEAD"), any(), any(), any())) + .thenReturn(new HttpResponse(200, headers, new byte[0])); + + final MockClient client = mockClientWith(mockAdapter); + final Pager pager = client.listResources(null); + assertEquals(1337, pager.getCount()); } @Test public void testFirst() throws IOException { - final Call mCall = mock(Call.class); - Answer answer = (i) -> { - Request request = i.getArgument(0); - HttpUrl url = request.url(); - assertEquals("1", url.queryParameter("limit")); - return mCall; - }; - when(mCall.execute()).thenReturn(MockClient.buildResponse(200, "OK", getResourceFirstItemJson())); - - OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); - - final MockClient client = new MockClient("apiKey", mockOkHttpClient); - Pager pager = client.listResources(null); - MyResource resource = pager.getFirst(); + final HttpAdapter mockAdapter = mock(HttpAdapter.class); + when(mockAdapter.execute(any(), any(), any(), any())) + .thenReturn(jsonResponse(200, getResourceFirstItemJson())); + + final MockClient client = mockClientWith(mockAdapter); + final Pager pager = client.listResources(null); + final MyResource resource = pager.getFirst(); + + verify(mockAdapter).execute(eq("GET"), contains("limit=1"), any(), any()); assertEquals("Resource First Item", resource.getMyString()); } - private String getResourceFirstPageJson(String next) { + private String getResourceFirstPageJson(final String next) { return "" + "{" + "\"object\":\"list\"," + "\"has_more\":true," - + "\"next\":\"" + next + "\"," + + "\"next\":\"" + + next + + "\"," + "\"data\": [" - + "{" - + "\"my_string\":\"Resource Page 1\"" - + "}," - + "{" - + "\"my_string\":\"Resource Page 1\"" - + "}," - + "{" - + "\"my_string\":\"Resource Page 1\"" - + "}" - + "]" - + "}"; + + "{\"my_string\":\"Resource Page 1\"}," + + "{\"my_string\":\"Resource Page 1\"}," + + "{\"my_string\":\"Resource Page 1\"}" + + "]}"; } private String getResourceSecondPageJson() { @@ -186,14 +154,9 @@ private String getResourceSecondPageJson() { + "\"has_more\":false," + "\"next\":null," + "\"data\": [" - + "{" - + "\"my_string\":\"Resource Page 2\"" - + "}," - + "{" - + "\"my_string\":\"Resource Page 2\"" - + "}" - + "]" - + "}"; + + "{\"my_string\":\"Resource Page 2\"}," + + "{\"my_string\":\"Resource Page 2\"}" + + "]}"; } private String getEmptyListJson() { @@ -201,14 +164,10 @@ private String getEmptyListJson() { } private String getResourceFirstItemJson() { - return "{" + - "\"object\": \"list\"," + - "\"has_more\": false," + - "\"next\": null," + - "\"data\": [" + - " {" + - " \"my_string\":\"Resource First Item\"" + - " }" + - "]}"; + return "{" + + "\"object\": \"list\"," + + "\"has_more\": false," + + "\"next\": null," + + "\"data\": [{\"my_string\":\"Resource First Item\"}]}"; } } diff --git a/src/test/java/com/recurly/v3/fixtures/MockClient.java b/src/test/java/com/recurly/v3/fixtures/MockClient.java index 7b2be9b8..c12052ff 100644 --- a/src/test/java/com/recurly/v3/fixtures/MockClient.java +++ b/src/test/java/com/recurly/v3/fixtures/MockClient.java @@ -2,50 +2,26 @@ import com.google.gson.reflect.TypeToken; import com.recurly.v3.BaseClient; -import com.recurly.v3.Pager; import com.recurly.v3.ClientOptions; +import com.recurly.v3.Pager; import com.recurly.v3.fixtures.MockQueryParams; -import org.mockito.stubbing.Answer; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; - import java.lang.reflect.Type; import java.util.HashMap; -import okhttp3.Headers; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - public class MockClient extends BaseClient { public MockClient(final String apiKey) { super(apiKey); } - public MockClient(final String apiKey, final OkHttpClient client) { - super(apiKey, client, new ClientOptions()); - } - public MockClient(final String apiKey, final ClientOptions clientOptions) { super(apiKey, clientOptions); } - public MockClient(final String apiKey, final OkHttpClient client, final ClientOptions clientOptions) { - super(apiKey, client, clientOptions); - } - - public String apiUrl; - public MyResource getResource(String resourceId) { final String url = "/resources/{resource_id}"; final HashMap urlParams = new HashMap(); urlParams.put("resource_id", resourceId); - final HashMap queryParams = new HashMap(); final String path = this.interpolatePath(url, urlParams); Type returnType = MyResource.class; return this.makeRequest("GET", path, returnType); @@ -64,7 +40,6 @@ public Pager listResources(MockQueryParams queryParams) { public MyResource createResource(MyRequest body) { final String url = "/resources"; final HashMap urlParams = new HashMap(); - final HashMap queryParams = new HashMap(); final String path = this.interpolatePath(url, urlParams); Type returnType = MyResource.class; return this.makeRequest("POST", path, body, returnType); @@ -83,7 +58,6 @@ public void removeResource(String resourceId) { final String url = "/resources/{resource_id}"; final HashMap urlParams = new HashMap(); urlParams.put("resource_id", resourceId); - final HashMap queryParams = new HashMap(); final String path = this.interpolatePath(url, urlParams); this.makeRequest("DELETE", path); } @@ -91,35 +65,4 @@ public void removeResource(String resourceId) { public void badRequestMethod() { this.makeRequest("BOGUS", "/accounts"); } - - public static final Response buildResponse(Integer code, String message, String response) { - Headers headers = new Headers.Builder().build(); - return buildResponse(code, message, response, headers); - } - - public static final Response buildResponse(Integer code, String message, String response, Headers headers) { - MediaType contentType = MediaType.get("application/json; charset=utf-8"); - return buildResponse(code, message, response, headers, contentType); - } - - public static final Response buildResponse(Integer code, String message, String response, Headers headers, MediaType contentType) { - final Request mRequest = new Request.Builder().url("https://v3.recurly.com").build(); - - final Response mResponse = - new Response.Builder() - .request(mRequest) - .protocol(okhttp3.Protocol.HTTP_1_1) - .code(code) // status code - .message(message) - .body(ResponseBody.create(contentType, response)) - .headers(headers) - .build(); - return mResponse; - } - - public static OkHttpClient getMockOkHttpClient(Answer answer) { - final OkHttpClient mockOkHttpClient = mock(OkHttpClient.class); - doAnswer(answer).when(mockOkHttpClient).newCall(any()); - return mockOkHttpClient; - } } diff --git a/src/test/java/com/recurly/v3/http/DefaultHttpAdapterContractTest.java b/src/test/java/com/recurly/v3/http/DefaultHttpAdapterContractTest.java new file mode 100644 index 00000000..2c601267 --- /dev/null +++ b/src/test/java/com/recurly/v3/http/DefaultHttpAdapterContractTest.java @@ -0,0 +1,13 @@ +package com.recurly.v3.http; + +/** + * Verifies that {@link DefaultHttpAdapter} satisfies the {@link HttpAdapterContract}. + * Serves as a live example of how to wire up the contract test for a custom implementation. + */ +class DefaultHttpAdapterContractTest extends HttpAdapterContract { + + @Override + protected HttpAdapter createAdapter() { + return new DefaultHttpAdapter(); + } +} diff --git a/src/test/java/com/recurly/v3/http/HttpAdapterContract.java b/src/test/java/com/recurly/v3/http/HttpAdapterContract.java new file mode 100644 index 00000000..5c07f954 --- /dev/null +++ b/src/test/java/com/recurly/v3/http/HttpAdapterContract.java @@ -0,0 +1,418 @@ +package com.recurly.v3.http; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.stubbing.ServeEvent; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnJre; +import org.junit.jupiter.api.condition.JRE; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPOutputStream; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Portable contract test for {@link HttpAdapter} implementations. + * + *

Extend this class and implement {@link #createAdapter()} to verify that your adapter + * satisfies the full behavioral contract required by the Recurly Java client. + * + *

{@code
+ * class MyAdapterTest extends HttpAdapterContract {
+ *     @Override
+ *     protected HttpAdapter createAdapter() {
+ *         return new MyHttpAdapter();
+ *     }
+ * }
+ * }
+ * + *

Each test starts a local {@link WireMockServer} on a random port, so no real network + * access is needed. Requires Java 11 or later at test runtime (WireMock 3 constraint); + * tests are automatically skipped on Java 8. + * + *

You will need the following test-scope dependencies in your project: + * + *

{@code
+ * 
+ *     org.wiremock
+ *     wiremock
+ *     3.13.2
+ *     test
+ * 
+ * 
+ *     org.junit.jupiter
+ *     junit-jupiter-engine
+ *     5.12.2
+ *     test
+ * 
+ * }
+ */ +@DisabledOnJre(JRE.JAVA_8) +public abstract class HttpAdapterContract { + + private WireMockServer server; + protected HttpAdapter adapter; + + /** + * Return the {@link HttpAdapter} implementation under test. Called once per test method; the + * returned instance is assigned to {@link #adapter} before the test runs. + */ + protected abstract HttpAdapter createAdapter(); + + @BeforeEach + void setUp() { + server = new WireMockServer(wireMockConfig().dynamicPort()); + server.start(); + adapter = createAdapter(); + } + + @AfterEach + void tearDown() { + server.stop(); + } + + // --------------------------------------------------------------------------- + // HTTP methods + // --------------------------------------------------------------------------- + + @Test + void getRequest_sendsCorrectMethodAndNoBody() throws Exception { + server.stubFor(any(urlPathEqualTo("/accounts")).willReturn(ok().withBody("{}"))); + + adapter.execute("GET", url("/accounts"), noHeaders(), null); + + LoggedRequest req = singleRequest(); + assertEquals("GET", req.getMethod().getName()); + assertEquals(0, req.getBody().length, "GET must not send a body"); + } + + @Test + void postRequest_sendsBodyAndCorrectMethod() throws Exception { + String body = "{\"code\":\"silver\"}"; + server.stubFor(any(urlPathEqualTo("/subscriptions")) + .willReturn(aResponse().withStatus(201).withBody("{}"))); + + adapter.execute("POST", url("/subscriptions"), jsonHeaders(), body); + + LoggedRequest req = singleRequest(); + assertEquals("POST", req.getMethod().getName()); + assertEquals(body, req.getBodyAsString(), "POST body must be forwarded verbatim"); + } + + @Test + void putRequest_sendsBodyAndCorrectMethod() throws Exception { + String body = "{\"first_name\":\"Jane\"}"; + server.stubFor(any(urlPathEqualTo("/accounts/abc123")).willReturn(ok().withBody("{}"))); + + adapter.execute("PUT", url("/accounts/abc123"), jsonHeaders(), body); + + LoggedRequest req = singleRequest(); + assertEquals("PUT", req.getMethod().getName()); + assertEquals(body, req.getBodyAsString(), "PUT body must be forwarded verbatim"); + } + + @Test + void postRequest_nullBody_sendsContentLengthZero() throws Exception { + server.stubFor(any(urlPathEqualTo("/subscriptions")).willReturn(aResponse().withStatus(201).withBody("{}"))); + + adapter.execute("POST", url("/subscriptions"), noHeaders(), null); + + LoggedRequest req = singleRequest(); + assertEquals("POST", req.getMethod().getName()); + assertEquals(0, req.getBody().length, "POST with null body must not send body bytes"); + assertEquals("0", req.getHeader("Content-Length"), + "POST with null body must send Content-Length: 0"); + } + + @Test + void putRequest_nullBody_sendsContentLengthZero() throws Exception { + server.stubFor(any(urlPathEqualTo("/accounts/abc123")).willReturn(ok().withBody("{}"))); + + adapter.execute("PUT", url("/accounts/abc123"), noHeaders(), null); + + LoggedRequest req = singleRequest(); + assertEquals("PUT", req.getMethod().getName()); + assertEquals(0, req.getBody().length, "PUT with null body must not send body bytes"); + assertEquals("0", req.getHeader("Content-Length"), + "PUT with null body must send Content-Length: 0"); + } + @Test + void deleteRequest_sendsCorrectMethodAndNoBody() throws Exception { + server.stubFor(any(urlPathEqualTo("/accounts/abc123")) + .willReturn(aResponse().withStatus(204))); + + adapter.execute("DELETE", url("/accounts/abc123"), noHeaders(), null); + + LoggedRequest req = singleRequest(); + assertEquals("DELETE", req.getMethod().getName()); + assertEquals(0, req.getBody().length, "DELETE must not send a body"); + } + + @Test + void headRequest_sendsCorrectMethodAndNoBody() throws Exception { + server.stubFor(any(urlPathEqualTo("/accounts")) + .willReturn(ok().withHeader("recurly-total-records", "42"))); + + HttpResponse response = adapter.execute("HEAD", url("/accounts"), noHeaders(), null); + + LoggedRequest req = singleRequest(); + assertEquals("HEAD", req.getMethod().getName()); + assertEquals(0, req.getBody().length, "HEAD must not send a body"); + assertNotNull(response.getBody(), "HEAD response body must be a non-null byte array"); + } + + // --------------------------------------------------------------------------- + // Request headers + // --------------------------------------------------------------------------- + + @Test + void requestHeaders_forwardedUnmodified() throws Exception { + server.stubFor(any(urlPathEqualTo("/accounts")).willReturn(ok().withBody("{}"))); + + Map headers = new HashMap<>(); + headers.put("Authorization", "Basic dXNlcjpwYXNz"); + headers.put("Accept", "application/json"); + headers.put("X-Api-Version", "2021-02-25"); + adapter.execute("GET", url("/accounts"), headers, null); + + LoggedRequest req = singleRequest(); + assertEquals("Basic dXNlcjpwYXNz", req.getHeader("Authorization"), + "Authorization header must be forwarded unmodified"); + assertEquals("application/json", req.getHeader("Accept"), + "Accept header must be forwarded unmodified"); + assertEquals("2021-02-25", req.getHeader("X-Api-Version"), + "Custom headers must be forwarded unmodified"); + } + + // --------------------------------------------------------------------------- + // Response: status code, headers, body + // --------------------------------------------------------------------------- + + @Test + void responseStatusCode_matchesServerResponse() throws Exception { + server.stubFor(any(urlPathEqualTo("/missing")) + .willReturn(aResponse().withStatus(404).withBody("{}"))); + + HttpResponse response = adapter.execute("GET", url("/missing"), noHeaders(), null); + + assertEquals(404, response.getStatusCode(), + "Status code must match what the server returned"); + } + + @Test + void responseHeaders_returnedInResponse() throws Exception { + server.stubFor(any(urlPathEqualTo("/accounts")) + .willReturn(ok().withBody("{}") + .withHeader("X-Request-Id", "req-xyz") + .withHeader("Recurly-Total-Records", "99"))); + + HttpResponse response = adapter.execute("GET", url("/accounts"), noHeaders(), null); + + // HttpResponse normalises header names to lower-case + Map h = response.getHeaders(); + assertNotNull(h); + assertTrue(h.containsKey("x-request-id"), + "x-request-id response header must be present (case-insensitive lookup)"); + assertEquals("req-xyz", h.get("x-request-id")); + } + + @Test + void responseBody_returnedAsBytes() throws Exception { + String json = "{\"object\":\"account\",\"code\":\"abc\"}"; + server.stubFor(any(urlPathEqualTo("/accounts/abc")).willReturn(ok().withBody(json))); + + HttpResponse response = adapter.execute("GET", url("/accounts/abc"), noHeaders(), null); + + assertNotNull(response.getBody(), "body must never be null"); + assertEquals(json, new String(response.getBody(), StandardCharsets.UTF_8)); + } + + @Test + void emptyResponseBody_returnsEmptyByteArray_notNull() throws Exception { + server.stubFor(any(urlPathEqualTo("/accounts/abc")) + .willReturn(aResponse().withStatus(204))); + + HttpResponse response = adapter.execute("DELETE", url("/accounts/abc"), noHeaders(), null); + + assertNotNull(response.getBody(), + "body must be a non-null byte[] even when the response has no body"); + } + + @Test + void largeResponseBody_readFully() throws Exception { + // 256 KB — guards against implementations that return a lazy or truncated stream + StringBuilder sb = new StringBuilder(256 * 1024); + for (int i = 0; i < 256 * 1024; i++) { + sb.append('x'); + } + String large = sb.toString(); + server.stubFor(any(urlPathEqualTo("/data")).willReturn(ok().withBody(large))); + + HttpResponse response = adapter.execute("GET", url("/data"), noHeaders(), null); + + assertEquals(large.length(), response.getBody().length, + "Adapter must read the response body fully before returning"); + } + + // --------------------------------------------------------------------------- + // URL forwarding + // --------------------------------------------------------------------------- + + @Test + void urlWithQueryString_forwardedUnmodified() throws Exception { + server.stubFor(any(urlPathEqualTo("/accounts")).willReturn(ok().withBody("{}"))); + + adapter.execute("GET", url("/accounts?limit=20&sort=created_at"), noHeaders(), null); + + LoggedRequest req = singleRequest(); + String fullUrl = req.getUrl(); + assertNotNull(fullUrl); + assertTrue(fullUrl.contains("limit=20"), "Query parameter 'limit' must be forwarded"); + assertTrue(fullUrl.contains("sort=created_at"), "Query parameter 'sort' must be forwarded"); + } + + // --------------------------------------------------------------------------- + // Error handling + // --------------------------------------------------------------------------- + + @Test + void httpErrors_returnedAsResponses_notThrown() throws Exception { + // 4xx and 5xx must NOT cause an exception — return the response and let the client handle it + server.stubFor(any(urlPathEqualTo("/subscriptions")) + .willReturn(aResponse().withStatus(422).withBody("{\"error\":{}}"))); + + HttpResponse response = adapter.execute("POST", url("/subscriptions"), jsonHeaders(), "{}"); + + assertEquals(422, response.getStatusCode(), + "HTTP error status codes must be returned as HttpResponse, not thrown as exceptions"); + } + + @Test + void networkFailure_throwsIOException() { + WireMockServer dead = new WireMockServer(wireMockConfig().dynamicPort()); + dead.start(); + int port = dead.port(); + dead.stop(); + + assertThrows(IOException.class, + () -> adapter.execute("GET", "http://localhost:" + port + "/test", noHeaders(), null), + "Network-level failures must propagate as IOException"); + } + + // --------------------------------------------------------------------------- + // Gzip decompression + // --------------------------------------------------------------------------- + + @Test + void gzipEncodedSuccessResponse_isDecompressed() throws Exception { + String json = "{\"object\":\"account\",\"code\":\"abc\"}"; + server.stubFor(any(urlPathEqualTo("/accounts/abc")) + .willReturn(ok() + .withHeader("Content-Encoding", "gzip") + .withBody(gzip(json)))); + + HttpResponse response = adapter.execute("GET", url("/accounts/abc"), noHeaders(), null); + + assertNotNull(response.getBody()); + assertEquals(json, new String(response.getBody(), StandardCharsets.UTF_8), + "Gzip-encoded success body must be transparently decompressed"); + } + + @Test + void gzipEncodedErrorResponse_isDecompressed() throws Exception { + String json = "{\"error\":{\"type\":\"not_found\"}}"; + server.stubFor(any(urlPathEqualTo("/accounts/missing")) + .willReturn(aResponse().withStatus(404) + .withHeader("Content-Encoding", "gzip") + .withBody(gzip(json)))); + + HttpResponse response = adapter.execute("GET", url("/accounts/missing"), noHeaders(), null); + + assertEquals(404, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals(json, new String(response.getBody(), StandardCharsets.UTF_8), + "Gzip-encoded error body must be transparently decompressed"); + } + + // --------------------------------------------------------------------------- + // Thread safety + // --------------------------------------------------------------------------- + + @Test + void concurrentRequests_completeSafely() throws Exception { + int threadCount = 20; + server.stubFor(any(anyUrl()).willReturn(ok().withBody("{}"))); + + ExecutorService pool = Executors.newFixedThreadPool(threadCount); + CountDownLatch allReady = new CountDownLatch(threadCount); + CountDownLatch startGun = new CountDownLatch(1); + AtomicInteger errors = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + pool.submit(() -> { + allReady.countDown(); + try { + startGun.await(); + adapter.execute("GET", url("/accounts"), noHeaders(), null); + } catch (Exception e) { + errors.incrementAndGet(); + } + }); + } + + allReady.await(5, TimeUnit.SECONDS); + startGun.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(15, TimeUnit.SECONDS), + "All concurrent requests must complete within 15 seconds"); + assertEquals(0, errors.get(), + "No exceptions should occur during concurrent use of the adapter"); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private String url(final String path) { + return "http://localhost:" + server.port() + path; + } + + private static Map noHeaders() { + return new HashMap<>(); + } + + private static Map jsonHeaders() { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json; charset=utf-8"); + return headers; + } + + private LoggedRequest singleRequest() { + List events = server.getAllServeEvents(); + assertFalse(events.isEmpty(), "No request was received by the mock server"); + return events.get(0).getRequest(); + } + + private static byte[] gzip(final String content) throws IOException { + final ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (final GZIPOutputStream gz = new GZIPOutputStream(bos)) { + gz.write(content.getBytes(StandardCharsets.UTF_8)); + } + return bos.toByteArray(); + } +} diff --git a/src/test/java/com/recurly/v3/http/HttpResponseTest.java b/src/test/java/com/recurly/v3/http/HttpResponseTest.java new file mode 100644 index 00000000..418424c6 --- /dev/null +++ b/src/test/java/com/recurly/v3/http/HttpResponseTest.java @@ -0,0 +1,59 @@ +package com.recurly.v3.http; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class HttpResponseTest { + + private static final Map EMPTY_HEADERS = Collections.emptyMap(); + private static final byte[] EMPTY_BODY = new byte[0]; + + @Test + public void nullHeadersThrows() { + assertThrows(NullPointerException.class, () -> + new HttpResponse(200, null, EMPTY_BODY)); + } + + @Test + public void nullBodyThrows() { + assertThrows(NullPointerException.class, () -> + new HttpResponse(200, EMPTY_HEADERS, null)); + } + + @Test + public void validConstructionSucceeds() { + final HttpResponse response = new HttpResponse(200, EMPTY_HEADERS, EMPTY_BODY); + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getHeaders()); + assertNotNull(response.getBody()); + } + + @Test + public void headersAreNormalizedToLowercase() { + final Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("X-Custom-Header", "value"); + + final HttpResponse response = new HttpResponse(200, headers, EMPTY_BODY); + + assertEquals("application/json", response.getHeaders().get("content-type")); + assertEquals("value", response.getHeaders().get("x-custom-header")); + assertNull(response.getHeaders().get("Content-Type")); + } + + @Test + public void headersAreImmutable() { + final Map headers = new HashMap<>(); + headers.put("content-type", "application/json"); + + final HttpResponse response = new HttpResponse(200, headers, EMPTY_BODY); + + assertThrows(UnsupportedOperationException.class, () -> + response.getHeaders().put("x-injected", "value")); + } +}