From d13c77c7d8c2dfb33485c486f77d2ed17193c9e4 Mon Sep 17 00:00:00 2001 From: Chris Barton Date: Tue, 21 Jul 2026 09:34:33 -0700 Subject: [PATCH 1/2] feat: add support for customer headers / idemptotency key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every operation on the client has an overload that accepts a `RequestOptions` object. Use it to set per-request options such as an idempotency key. Idempotency Keys ```java import com.recurly.v3.RequestOptions; import com.recurly.v3.requests.AccountCreate; import com.recurly.v3.resources.Account; final AccountCreate accountReq = new AccountCreate(); accountReq.setCode("myaccountcode"); final RequestOptions options = RequestOptions.builder() .idempotencyKey("unique-key-for-this-operation"); final Account account = client.createAccount(accountReq, options); ``` Custom Headers ```java final RequestOptions options = RequestOptions.builder() .header("X-Custom-Header", "value") .header("X-Another-Header", "other-value"); final Account account = client.createAccount(accountReq, options); ``` Options can be combined — `idempotencyKey` and `header` calls chain together: ```java final RequestOptions options = RequestOptions.builder() .idempotencyKey("unique-key") .header("X-Custom-Header", "value"); ``` --- README.md | 48 ++++++++++++++- src/main/java/com/recurly/v3/BaseClient.java | 56 +++++++++++++++--- .../java/com/recurly/v3/RequestOptions.java | 48 +++++++++++++++ .../java/com/recurly/v3/BaseClientTest.java | 59 +++++++++++++++++++ .../com/recurly/v3/fixtures/MockClient.java | 10 +++- 5 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/recurly/v3/RequestOptions.java diff --git a/README.md b/README.md index 267d46b..08197b1 100644 --- a/README.md +++ b/README.md @@ -232,8 +232,54 @@ try { } catch (NetworkException e) { // You may want to find out the root cause System.out.println(e.getCause().getCause()); -} +} ``` +### Request Options + +Every operation on the client has an overload that accepts a `RequestOptions` object. Use it to set +per-request options such as an idempotency key. + +#### Idempotency Keys + +[Idempotency keys](https://developers.recurly.com/api/latest/#section/Getting-Started/Idempotent-Requests) +allow you to safely retry mutating requests (POST, PUT, DELETE) without the risk of performing the same +operation twice. Pass a unique value per logical operation — Recurly will deduplicate requests that +share a key. + +```java +import com.recurly.v3.RequestOptions; +import com.recurly.v3.requests.AccountCreate; +import com.recurly.v3.resources.Account; + +final AccountCreate accountReq = new AccountCreate(); +accountReq.setCode("myaccountcode"); + +final RequestOptions options = RequestOptions.builder() + .idempotencyKey("unique-key-for-this-operation"); + +final Account account = client.createAccount(accountReq, options); +``` + +#### Custom Headers + +You can also set arbitrary request headers via `RequestOptions`: + +```java +final RequestOptions options = RequestOptions.builder() + .header("X-Custom-Header", "value") + .header("X-Another-Header", "other-value"); + +final Account account = client.createAccount(accountReq, options); +``` + +Options can be combined — `idempotencyKey` and `header` calls chain together: + +```java +final RequestOptions options = RequestOptions.builder() + .idempotencyKey("unique-key") + .header("X-Custom-Header", "value"); +``` + ## Support Looking for help? Please contact [support@recurly.com](mailto:support@recurly.com) or visit diff --git a/src/main/java/com/recurly/v3/BaseClient.java b/src/main/java/com/recurly/v3/BaseClient.java index deceebb..17b0a8d 100644 --- a/src/main/java/com/recurly/v3/BaseClient.java +++ b/src/main/java/com/recurly/v3/BaseClient.java @@ -81,7 +81,11 @@ 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); + makeRequest(method, url, (RequestOptions) null); + } + + protected void makeRequest(final String method, final String url, final RequestOptions options) { + final okhttp3.Request request = buildRequest(method, url, null, null, options); try (final Response response = client.newCall(request).execute()) { if (!response.isSuccessful()) { @@ -108,20 +112,38 @@ protected void makeRequest(final String method, final String url) { } protected T makeRequest(final String method, final String url, final Type resourceClass) { - return makeRequest(method, url, null, null, resourceClass); + return makeRequest(method, url, null, null, null, resourceClass); + } + + protected T makeRequest(final String method, final String url, final RequestOptions options, final Type resourceClass) { + return makeRequest(method, url, null, null, options, resourceClass); } protected T makeRequest( final String method, final String url, final Request body, final Type resourceClass) { - return makeRequest(method, url, body, null, resourceClass); + return makeRequest(method, url, body, null, null, resourceClass); + } + + protected T makeRequest( + final String method, final String url, final Request body, final RequestOptions options, final Type resourceClass) { + return makeRequest(method, url, body, null, options, resourceClass); + } + + protected T makeRequest( + final String method, + final String url, + final HashMap queryParams, + final Type resourceClass) { + return makeRequest(method, url, null, queryParams, null, resourceClass); } protected T makeRequest( final String method, final String url, final HashMap queryParams, + final RequestOptions options, final Type resourceClass) { - return makeRequest(method, url, null, queryParams, resourceClass); + return makeRequest(method, url, null, queryParams, options, resourceClass); } protected T makeRequest( @@ -130,7 +152,17 @@ protected T makeRequest( final Request body, final HashMap queryParams, final Type resourceClass) { - final okhttp3.Request request = buildRequest(method, url, body, queryParams); + return makeRequest(method, url, body, queryParams, null, resourceClass); + } + + protected T makeRequest( + final String method, + final String url, + final Request body, + final HashMap queryParams, + final RequestOptions options, + final Type resourceClass) { + final okhttp3.Request request = buildRequest(method, url, body, queryParams, options); try (final Response response = client.newCall(request).execute()) { @@ -160,7 +192,7 @@ protected T makeRequest( } public int getRecordCount(final String url, final HashMap queryParams) { - final okhttp3.Request request = buildRequest("HEAD", url, null, queryParams); + final okhttp3.Request request = buildRequest("HEAD", url, null, queryParams, null); try (final Response response = client.newCall(request).execute()) { @@ -195,7 +227,8 @@ private okhttp3.Request buildRequest( final String method, final String url, final Request body, - final HashMap queryParams) { + final HashMap queryParams, + final RequestOptions options) { final HttpUrl.Builder httpBuilder = HttpUrl.parse(this.apiUrl + url).newBuilder(); final RequestBody requestBody = @@ -239,6 +272,15 @@ private okhttp3.Request buildRequest( final Builder requestBuilder = new okhttp3.Request.Builder().url(requestUrl); + if (options != null) { + for (Map.Entry entry : options.getHeaders().entrySet()) { + requestBuilder.header(entry.getKey(), entry.getValue()); + } + if (options.getIdempotencyKey() != null) { + requestBuilder.header("Idempotency-Key", options.getIdempotencyKey()); + } + } + switch (method) { case "HEAD": return requestBuilder.head().build(); diff --git a/src/main/java/com/recurly/v3/RequestOptions.java b/src/main/java/com/recurly/v3/RequestOptions.java new file mode 100644 index 0000000..ca8b7fb --- /dev/null +++ b/src/main/java/com/recurly/v3/RequestOptions.java @@ -0,0 +1,48 @@ +package com.recurly.v3; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class RequestOptions { + private final String idempotencyKey; + private final Map headers; + + private RequestOptions(final Builder builder) { + this.idempotencyKey = builder.idempotencyKey; + this.headers = Collections.unmodifiableMap(new HashMap<>(builder.headers)); + } + + public static Builder builder() { + return new Builder(); + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public Map getHeaders() { + return headers; + } + + public static class Builder { + private String idempotencyKey; + private Map headers = new HashMap<>(); + + private Builder() {} + + public Builder idempotencyKey(final String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + return this; + } + + public Builder header(final String name, final String value) { + this.headers.put(name, value); + return this; + } + + public RequestOptions build() { + return new RequestOptions(this); + } + } +} diff --git a/src/test/java/com/recurly/v3/BaseClientTest.java b/src/test/java/com/recurly/v3/BaseClientTest.java index d2b8427..bd15991 100644 --- a/src/test/java/com/recurly/v3/BaseClientTest.java +++ b/src/test/java/com/recurly/v3/BaseClientTest.java @@ -12,6 +12,7 @@ import com.recurly.v3.fixtures.MockQueryParams; import com.recurly.v3.fixtures.MyRequest; import com.recurly.v3.fixtures.MyResource; +import com.recurly.v3.RequestOptions; import okhttp3.Call; import okhttp3.Headers; import okhttp3.HttpUrl; @@ -397,6 +398,64 @@ public void testUsingRegionEUClientOptions() { assertEquals("https://v3.eu.recurly.com", client.getApiUrl()); } + @Test + public void testIdempotencyKeyHeader() throws IOException { + final Call mCall = mock(Call.class); + final String idempotencyKey = "test-idempotency-key-123"; + Answer answer = (i) -> { + Request request = i.getArgument(0); + assertEquals(idempotencyKey, request.header("Idempotency-Key")); + return mCall; + }; + when(mCall.execute()).thenReturn(MockClient.buildResponse(200, "OK", getResponseJson())); + + OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + + final MockClient client = new MockClient("apiKey", mockOkHttpClient); + final MyRequest body = new MyRequest(); + final RequestOptions options = RequestOptions.builder().idempotencyKey(idempotencyKey).build(); + client.createResource(body, options); + } + + @Test + public void testRawHeaders() throws IOException { + final Call mCall = mock(Call.class); + Answer answer = (i) -> { + Request request = i.getArgument(0); + assertEquals("bar", request.header("X-Custom-Foo")); + assertEquals("baz", request.header("X-Custom-Qux")); + return mCall; + }; + when(mCall.execute()).thenReturn(MockClient.buildResponse(200, "OK", getResponseJson())); + + OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + + final MockClient client = new MockClient("apiKey", mockOkHttpClient); + final MyRequest body = new MyRequest(); + final RequestOptions options = RequestOptions.builder() + .header("X-Custom-Foo", "bar") + .header("X-Custom-Qux", "baz") + .build(); + client.createResource(body, options); + } + + @Test + public void testNoIdempotencyKeyHeader() throws IOException { + final Call mCall = mock(Call.class); + Answer answer = (i) -> { + Request request = i.getArgument(0); + assertEquals(null, request.header("Idempotency-Key")); + return mCall; + }; + when(mCall.execute()).thenReturn(MockClient.buildResponse(200, "OK", getResponseJson())); + + OkHttpClient mockOkHttpClient = MockClient.getMockOkHttpClient(answer); + + final MockClient client = new MockClient("apiKey", mockOkHttpClient); + final MyRequest body = new MyRequest(); + client.createResource(body); + } + @Test public void testInterpolatePathWithoutParams() { final MockClient client = new MockClient("apiKey"); diff --git a/src/test/java/com/recurly/v3/fixtures/MockClient.java b/src/test/java/com/recurly/v3/fixtures/MockClient.java index 7b2be9b..179297f 100644 --- a/src/test/java/com/recurly/v3/fixtures/MockClient.java +++ b/src/test/java/com/recurly/v3/fixtures/MockClient.java @@ -4,6 +4,7 @@ import com.recurly.v3.BaseClient; import com.recurly.v3.Pager; import com.recurly.v3.ClientOptions; +import com.recurly.v3.RequestOptions; import com.recurly.v3.fixtures.MockQueryParams; import org.mockito.stubbing.Answer; @@ -64,12 +65,19 @@ 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); } + public MyResource createResource(MyRequest body, RequestOptions options) { + final String url = "/resources"; + final HashMap urlParams = new HashMap(); + final String path = this.interpolatePath(url, urlParams); + Type returnType = MyResource.class; + return this.makeRequest("POST", path, body, options, returnType); + } + public MyResource updateResource(String resourceId, MyRequest body) { final String url = "/resources/{resource_id}"; final HashMap urlParams = new HashMap(); From 44b6552ad60039ca213464f59408bd04de745457 Mon Sep 17 00:00:00 2001 From: Chris Barton Date: Tue, 21 Jul 2026 10:01:28 -0700 Subject: [PATCH 2/2] chore: update google-format to 1.35 --- scripts/build | 4 ++-- scripts/format | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/build b/scripts/build index b04c3e7..0af1f67 100755 --- a/scripts/build +++ b/scripts/build @@ -7,9 +7,9 @@ if [ ! -d ./bin ]; then mkdir -p ./bin; fi -FORMAT=./bin/google-java-format-1.7-all-deps.jar +FORMAT=./bin/google-java-format-1.35.0-all-deps.jar if test -f $FORMAT; then echo "Formatter file exists" else - curl -L https://github.com/google/google-java-format/releases/download/google-java-format-1.7/google-java-format-1.7-all-deps.jar > ./bin/google-java-format-1.7-all-deps.jar + curl -L https://github.com/google/google-java-format/releases/download/v1.35.0/google-java-format-1.35.0-all-deps.jar > $FORMAT fi diff --git a/scripts/format b/scripts/format index c9dc8ef..8f75deb 100755 --- a/scripts/format +++ b/scripts/format @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -e -java -jar ./bin/google-java-format-1.7-all-deps.jar -i $(ls src/main/java/com/recurly/v3/**/*.java) +java -jar ./bin/google-java-format-1.35.0-all-deps.jar -i $(ls src/main/java/com/recurly/v3/**/*.java)