diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000000..acfd79d1a68 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,49 @@ +# stripe-java + +## Testing + +- Run all tests: `just test` +- Run a single test class: `just test-one com.stripe.net.HttpClientTest` +- Pass extra Gradle args: `just test --tests com.stripe.SomeTest` + +## Formatting + +- Format: `just format` (uses Spotless via Gradle) +- Format check: `just format-check` + +## Key Locations + +- HTTP client abstract base (retry logic): `src/main/java/com/stripe/net/HttpClient.java` +- HTTP implementation: `src/main/java/com/stripe/net/HttpURLConnectionClient.java` +- Header management: `src/main/java/com/stripe/net/HttpHeaders.java` +- Request building: `src/main/java/com/stripe/net/StripeRequest.java` +- Authentication: `src/main/java/com/stripe/net/Authenticator.java`, `BearerTokenAuthenticator.java` +- Response getter (request dispatch): `src/main/java/com/stripe/net/LiveStripeResponseGetter.java` +- Main config/version: `src/main/java/com/stripe/Stripe.java` +- Client class: `src/main/java/com/stripe/StripeClient.java` + +## Generated Code + +- Files containing `File generated from our OpenAPI spec` at the top are generated; do not edit. Similarly, any code block starting with `The beginning of the section generated from our OpenAPI spec` is generated and should not be edited directly. + - If something in a generated file/range needs to be updated, add a summary of the change to your report but don't attempt to edit it directly. +- Resource model classes under `src/main/java/com/stripe/model/` are largely generated. +- The `net/` package (HTTP client, headers, request/response) is NOT generated. + +## Conventions + +- Uses Java's built-in `HttpURLConnection` for HTTP +- Requires JDK 17 to build +- Gradle build system +- Work is not complete until `just test` passes + +### Comments + +- Comments MUST only be used to: + 1. Document a function + 2. Explain the WHY of a piece of code + 3. Explain a particularly complicated piece of code +- Comments NEVER should be used to: + 1. Say what used to be there. That's no longer relevant! + 2. Explain the WHAT of a piece of code (unless it's very non-obvious) + +It's ok not to put comments on/in a function if their addition wouldn't meaningfully clarify anything. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86c0dbccc2e..38c2cf60ec2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,7 @@ jobs: ((github.event_name == 'workflow_dispatch') || (github.event_name == 'push')) && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, 'beta') && + !contains(github.ref, 'alpha') && endsWith(github.actor, '-stripe') needs: [build, test] runs-on: "ubuntu-24.04" diff --git a/CODEGEN_VERSION b/CODEGEN_VERSION index c99d4272fbb..0f9f7f07fc5 100644 --- a/CODEGEN_VERSION +++ b/CODEGEN_VERSION @@ -1 +1 @@ -e10daa4ed23a4fe87d6ea60836226446e042fdd3 \ No newline at end of file +46812bb1234aef25ae1070b402c602491ee66f8d \ No newline at end of file diff --git a/OPENAPI_VERSION b/OPENAPI_VERSION index 58dae793582..4c2310759e9 100644 --- a/OPENAPI_VERSION +++ b/OPENAPI_VERSION @@ -1 +1 @@ -v2186 \ No newline at end of file +v2189 \ No newline at end of file diff --git a/src/main/java/com/stripe/model/BalanceTransactionSourceTypeAdapterFactory.java b/src/main/java/com/stripe/model/BalanceTransactionSourceTypeAdapterFactory.java index 29e3c7c60be..fc84822c515 100644 --- a/src/main/java/com/stripe/model/BalanceTransactionSourceTypeAdapterFactory.java +++ b/src/main/java/com/stripe/model/BalanceTransactionSourceTypeAdapterFactory.java @@ -25,9 +25,6 @@ public TypeAdapter create(Gson gson, TypeToken type) { } final String discriminator = "object"; final TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); - final TypeAdapter balanceTransactionSourceAdapter = - gson.getDelegateAdapter( - this, TypeToken.get(com.stripe.model.BalanceTransactionSource.class)); final TypeAdapter applicationFeeAdapter = gson.getDelegateAdapter(this, TypeToken.get(com.stripe.model.ApplicationFee.class)); final TypeAdapter chargeAdapter = @@ -68,7 +65,13 @@ public TypeAdapter create(Gson gson, TypeToken type) { new TypeAdapter() { @Override public void write(JsonWriter out, BalanceTransactionSource value) throws IOException { - balanceTransactionSourceAdapter.write(out, value); + @SuppressWarnings("unchecked") + TypeAdapter adapter = + (TypeAdapter) + gson.getDelegateAdapter( + BalanceTransactionSourceTypeAdapterFactory.this, + TypeToken.get(value.getClass())); + adapter.write(out, value); } @Override diff --git a/src/main/java/com/stripe/model/ExternalAccountTypeAdapterFactory.java b/src/main/java/com/stripe/model/ExternalAccountTypeAdapterFactory.java index 1d24a1d21a3..a4c99067782 100644 --- a/src/main/java/com/stripe/model/ExternalAccountTypeAdapterFactory.java +++ b/src/main/java/com/stripe/model/ExternalAccountTypeAdapterFactory.java @@ -28,8 +28,6 @@ public TypeAdapter create(Gson gson, TypeToken type) { } final String discriminator = "object"; final TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); - final TypeAdapter externalAccountAdapter = - gson.getDelegateAdapter(this, TypeToken.get(com.stripe.model.ExternalAccount.class)); final TypeAdapter bankAccountAdapter = gson.getDelegateAdapter(this, TypeToken.get(com.stripe.model.BankAccount.class)); final TypeAdapter cardAdapter = @@ -39,7 +37,12 @@ public TypeAdapter create(Gson gson, TypeToken type) { new TypeAdapter() { @Override public void write(JsonWriter out, ExternalAccount value) throws IOException { - externalAccountAdapter.write(out, value); + @SuppressWarnings("unchecked") + TypeAdapter adapter = + (TypeAdapter) + gson.getDelegateAdapter( + ExternalAccountTypeAdapterFactory.this, TypeToken.get(value.getClass())); + adapter.write(out, value); } @Override diff --git a/src/main/java/com/stripe/model/PaymentSourceTypeAdapterFactory.java b/src/main/java/com/stripe/model/PaymentSourceTypeAdapterFactory.java index 309770b166e..59a32b6884f 100644 --- a/src/main/java/com/stripe/model/PaymentSourceTypeAdapterFactory.java +++ b/src/main/java/com/stripe/model/PaymentSourceTypeAdapterFactory.java @@ -25,8 +25,6 @@ public TypeAdapter create(Gson gson, TypeToken type) { } final String discriminator = "object"; final TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); - final TypeAdapter paymentSourceAdapter = - gson.getDelegateAdapter(this, TypeToken.get(com.stripe.model.PaymentSource.class)); final TypeAdapter accountAdapter = gson.getDelegateAdapter(this, TypeToken.get(com.stripe.model.Account.class)); final TypeAdapter bankAccountAdapter = @@ -40,7 +38,12 @@ public TypeAdapter create(Gson gson, TypeToken type) { new TypeAdapter() { @Override public void write(JsonWriter out, PaymentSource value) throws IOException { - paymentSourceAdapter.write(out, value); + @SuppressWarnings("unchecked") + TypeAdapter adapter = + (TypeAdapter) + gson.getDelegateAdapter( + PaymentSourceTypeAdapterFactory.this, TypeToken.get(value.getClass())); + adapter.write(out, value); } @Override diff --git a/src/main/java/com/stripe/net/HttpClient.java b/src/main/java/com/stripe/net/HttpClient.java index 2d4942d9964..ebbae9e9842 100644 --- a/src/main/java/com/stripe/net/HttpClient.java +++ b/src/main/java/com/stripe/net/HttpClient.java @@ -13,6 +13,7 @@ import java.util.Optional; import java.util.Properties; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Function; /** Base abstract class for HTTP clients used to send requests to Stripe's API. */ public abstract class HttpClient { @@ -137,12 +138,39 @@ public StripeResponseStream requestStreamWithRetries(StripeRequest request) return sendWithRetries(request, (r) -> this.requestStream(r)); } + static String detectAIAgent() { + return detectAIAgent(System::getenv); + } + + static String detectAIAgent(Function getEnv) { + String[][] agents = { + {"ANTIGRAVITY_CLI_ALIAS", "antigravity"}, + {"CLAUDECODE", "claude_code"}, + {"CLINE_ACTIVE", "cline"}, + {"CODEX_SANDBOX", "codex_cli"}, + {"CURSOR_AGENT", "cursor"}, + {"GEMINI_CLI", "gemini_cli"}, + {"OPENCODE", "open_code"}, + }; + for (String[] agent : agents) { + String val = getEnv.apply(agent[0]); + if (val != null && !val.isEmpty()) { + return agent[1]; + } + } + return ""; + } + /** * Builds the value of the {@code User-Agent} header. * * @return a string containing the value of the {@code User-Agent} header */ protected static String buildUserAgentString(StripeRequest request) { + return buildUserAgentString(request, detectAIAgent()); + } + + static String buildUserAgentString(StripeRequest request, String aiAgent) { String apiMode = request.apiMode() == ApiMode.V2 ? "v2" : "v1"; String userAgent = String.format("Stripe/%s JavaBindings/%s", apiMode, Stripe.VERSION); @@ -151,6 +179,10 @@ protected static String buildUserAgentString(StripeRequest request) { userAgent += " " + formatAppInfo(Stripe.getAppInfo()); } + if (!aiAgent.isEmpty()) { + userAgent += " AIAgent/" + aiAgent; + } + return userAgent; } @@ -160,6 +192,10 @@ protected static String buildUserAgentString(StripeRequest request) { * @return a string containing the value of the {@code X-Stripe-Client-User-Agent} header */ protected static String buildXStripeClientUserAgentString() { + return buildXStripeClientUserAgentString(detectAIAgent()); + } + + static String buildXStripeClientUserAgentString(String aiAgent) { String[] propertyNames = { "os.name", "os.version", @@ -182,6 +218,10 @@ protected static String buildXStripeClientUserAgentString() { } getGsonVersion().ifPresent(ver -> propertyMap.put("gson.version", ver)); + if (!aiAgent.isEmpty()) { + propertyMap.put("ai_agent", aiAgent); + } + return ApiResource.INTERNAL_GSON.toJson(propertyMap); } diff --git a/src/test/java/com/stripe/net/HttpClientTest.java b/src/test/java/com/stripe/net/HttpClientTest.java index a7e679b17eb..9edcb6a0717 100644 --- a/src/test/java/com/stripe/net/HttpClientTest.java +++ b/src/test/java/com/stripe/net/HttpClientTest.java @@ -192,9 +192,8 @@ public void testV1RequestSetsCorrectUserAgent() throws StripeException { RequestOptions.builder().setApiKey("sk_test_123").setMaxNetworkRetries(2).build(), ApiMode.V1); - assertEquals( - HttpClient.buildUserAgentString(request), - String.format("Stripe/v1 JavaBindings/%s", Stripe.VERSION)); + String userAgent = HttpClient.buildUserAgentString(request); + assertTrue(userAgent.startsWith(String.format("Stripe/v1 JavaBindings/%s", Stripe.VERSION))); } @Test @@ -207,8 +206,52 @@ public void testV2RequestSetsCorrectUserAgent() throws StripeException { RequestOptions.builder().setApiKey("sk_test_123").setMaxNetworkRetries(2).build(), ApiMode.V2); - assertEquals( - HttpClient.buildUserAgentString(request), - String.format("Stripe/v2 JavaBindings/%s", Stripe.VERSION)); + String userAgent = HttpClient.buildUserAgentString(request); + assertTrue(userAgent.startsWith(String.format("Stripe/v2 JavaBindings/%s", Stripe.VERSION))); + } + + @Test + public void testDetectAIAgent() { + String agent = HttpClient.detectAIAgent(key -> key.equals("CLAUDECODE") ? "1" : null); + assertEquals("claude_code", agent); + } + + @Test + public void testDetectAIAgentNoEnv() { + String agent = HttpClient.detectAIAgent(key -> null); + assertEquals("", agent); + } + + @Test + public void testDetectAIAgentFirstMatchWins() { + String agent = + HttpClient.detectAIAgent( + key -> { + if (key.equals("CURSOR_AGENT") || key.equals("OPENCODE")) return "1"; + return null; + }); + assertEquals("cursor", agent); + } + + @Test + public void testBuildUserAgentStringWithAIAgent() throws StripeException { + StripeRequest request = + StripeRequest.create( + ApiResource.RequestMethod.GET, + "http://example.com/get", + null, + RequestOptions.builder().setApiKey("sk_test_123").build(), + ApiMode.V1); + + String userAgent = HttpClient.buildUserAgentString(request, "cursor"); + assertTrue(userAgent.contains("AIAgent/cursor")); + } + + @Test + public void testBuildXStripeClientUserAgentStringWithAIAgent() { + String json = HttpClient.buildXStripeClientUserAgentString("cursor"); + com.google.gson.JsonObject parsed = + com.google.gson.JsonParser.parseString(json).getAsJsonObject(); + assertEquals("cursor", parsed.get("ai_agent").getAsString()); } }