diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index bda42a02..463be0c5 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -29,3 +29,34 @@ jobs:
- name: Javadoc
run: mvn -B javadoc:javadoc
+
+ - name: Generator tests
+ run: mvn -B -f tools/model-generator/pom.xml test
+
+ generated-sdk:
+ # Regenerates the SDK from the committed spec inside the disposable CI checkout and proves the
+ # result compiles and passes the SDK tests. Generated output is not committed in this phase;
+ # once a baseline is committed, `make check-generated` replaces this job as the gate.
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Harden the runner (Audit all outbound calls)
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
+ with:
+ egress-policy: audit
+
+ - name: Checkout
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - name: Setup Java
+ uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
+ with:
+ distribution: temurin
+ java-version: "11"
+ cache: maven
+
+ - name: Generate SDK from committed spec
+ run: make generate
+
+ - name: Test generated SDK
+ run: mvn -B test
diff --git a/.gitignore b/.gitignore
index 83fee5bc..9bc479c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,7 @@ dependency-reduced-pom.xml
tmp/
ai-docs/
+docs/
# SDK generator download + OpenAPI CLI output
generated/
diff --git a/Makefile b/Makefile
index c58bc974..197e958b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,20 @@
-.PHONY: fetch-spec compile format
+.PHONY: fetch-spec compile format generate check-generated generate-live-diff
+
fetch-spec:
- @mkdir -p apiSpec
- @curl -fsSL -o apiSpec/prime-public-spec.yaml https://api.prime.coinbase.com/v1/openapi.yaml
+ mvn -B -f tools/model-generator/pom.xml compile exec:java@generate-models -Dexec.args="--fetch-spec"
compile:
mvn -B compile
format:
mvn -B spotless:apply
+
+generate:
+ mvn -B -f tools/model-generator/pom.xml compile exec:java@generate-models
+ mvn -B spotless:apply
+
+check-generated:
+ mvn -B -f tools/model-generator/pom.xml compile exec:java@generate-models -Dexec.args="--check"
+
+generate-live-diff:
+ mvn -B -f tools/model-generator/pom.xml compile exec:java@generate-models -Dexec.args="--live-diff"
diff --git a/src/main/java/com/coinbase/prime/client/CoinbasePrimeClient.java b/src/main/java/com/coinbase/prime/client/CoinbasePrimeClient.java
index 0de766cb..7850ce09 100644
--- a/src/main/java/com/coinbase/prime/client/CoinbasePrimeClient.java
+++ b/src/main/java/com/coinbase/prime/client/CoinbasePrimeClient.java
@@ -29,34 +29,52 @@
import java.util.List;
public class CoinbasePrimeClient extends CoinbaseNetHttpClient {
+ private final String baseUrl;
+ private final HttpClient httpClient;
+
public CoinbasePrimeClient(CoinbasePrimeCredentials credentials, String baseUrl) {
super(credentials, baseUrl);
+ this.baseUrl = baseUrl;
+ this.httpClient = null;
}
public CoinbasePrimeClient(
CoinbasePrimeCredentials credentials, String baseUrl, HttpClient client) {
super(credentials, baseUrl, client);
+ this.baseUrl = baseUrl;
+ this.httpClient = client;
}
public CoinbasePrimeClient(CoinbasePrimeCredentials credentials) {
super(credentials, Constants.CB_PRIME_BASE_URL);
+ this.baseUrl = Constants.CB_PRIME_BASE_URL;
+ this.httpClient = null;
}
public CoinbasePrimeClient(CoinbasePrimeCredentials credentials, HttpClient client) {
super(credentials, Constants.CB_PRIME_BASE_URL, client);
+ this.baseUrl = Constants.CB_PRIME_BASE_URL;
+ this.httpClient = client;
+ }
+
+ public String getBaseUrl() {
+ return baseUrl;
}
/**
- * Returns a new client with the given base URL; credentials and behavior are otherwise unchanged.
- * Prefer this for single-call version overrides (e.g. {@code v2}) without mutating the shared
- * client.
+ * Returns a new client with the given base URL; credentials and the injected HttpClient are
+ * reused. Prefer this for single-call version overrides (e.g. {@code v2}) without mutating the
+ * shared client.
*/
public CoinbasePrimeClient withBaseUrl(String baseUrl) {
CoinbaseCredentials credentials = getCredentials();
if (!(credentials instanceof CoinbasePrimeCredentials)) {
throw new IllegalStateException("Credentials must be CoinbasePrimeCredentials");
}
- return new CoinbasePrimeClient((CoinbasePrimeCredentials) credentials, baseUrl);
+ CoinbasePrimeCredentials primeCredentials = (CoinbasePrimeCredentials) credentials;
+ return httpClient == null
+ ? new CoinbasePrimeClient(primeCredentials, baseUrl)
+ : new CoinbasePrimeClient(primeCredentials, baseUrl, httpClient);
}
@Override
diff --git a/src/test/java/com/coinbase/prime/client/CoinbasePrimeClientTest.java b/src/test/java/com/coinbase/prime/client/CoinbasePrimeClientTest.java
new file mode 100644
index 00000000..f5edc1bc
--- /dev/null
+++ b/src/test/java/com/coinbase/prime/client/CoinbasePrimeClientTest.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.coinbase.prime.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import com.coinbase.core.client.CoinbaseNetHttpClient;
+import com.coinbase.prime.credentials.CoinbasePrimeCredentials;
+import java.lang.reflect.Field;
+import java.net.http.HttpClient;
+import org.junit.jupiter.api.Test;
+
+class CoinbasePrimeClientTest {
+ private static final CoinbasePrimeCredentials CREDENTIALS =
+ new CoinbasePrimeCredentials("test-access-key", "test-passphrase", "test-signing-key");
+
+ @Test
+ void withBaseUrlReusesInjectedHttpClient() throws Exception {
+ HttpClient injected = HttpClient.newHttpClient();
+ CoinbasePrimeClient client =
+ new CoinbasePrimeClient(CREDENTIALS, "https://example.test/v1", injected);
+
+ CoinbasePrimeClient v2 = client.withBaseUrl("https://example.test/v2");
+
+ assertEquals("https://example.test/v2", v2.getBaseUrl());
+ assertSame(injected, transport(v2));
+ }
+
+ @Test
+ void withBaseUrlKeepsDefaultTransportWhenNoneInjected() {
+ CoinbasePrimeClient v2 =
+ new CoinbasePrimeClient(CREDENTIALS).withBaseUrl("https://example.test/v2");
+
+ assertEquals("https://example.test/v2", v2.getBaseUrl());
+ }
+
+ private static HttpClient transport(CoinbasePrimeClient client) throws Exception {
+ Field field = CoinbaseNetHttpClient.class.getDeclaredField("client");
+ field.setAccessible(true);
+ return (HttpClient) field.get(client);
+ }
+}
diff --git a/tools/model-generator/.openapi-generator-ignore b/tools/model-generator/.openapi-generator-ignore
index bdfbf8b2..24843965 100644
--- a/tools/model-generator/.openapi-generator-ignore
+++ b/tools/model-generator/.openapi-generator-ignore
@@ -6,12 +6,9 @@
src/main/java/com/coinbase/prime/model/*Request.java
src/main/java/com/coinbase/prime/model/*Response.java
-# Per-operation typed error payloads and codes (not consumed by the SDK yet)
+# Per-operation typed error payloads are not consumed by the SDK yet. ErrorCode and
+# Subcode enums are intentionally generated and routed to model/errors by PostProcessor.
src/main/java/com/coinbase/prime/model/*ErrorResponse.java
-src/main/java/com/coinbase/prime/model/*ErrorCode.java
-src/main/java/com/coinbase/prime/model/*Subcode.java
-src/main/java/com/coinbase/prime/model/enums/*ErrorCode.java
-src/main/java/com/coinbase/prime/model/enums/*Subcode.java
# Google infrastructure types (google.type.Date is generated as GoogleTypeDate and renamed to DateOfBirth)
src/main/java/com/coinbase/prime/model/GoogleProtobufAny.java
diff --git a/tools/model-generator/config/generator-config.json b/tools/model-generator/config/generator-config.json
new file mode 100644
index 00000000..5ec14f99
--- /dev/null
+++ b/tools/model-generator/config/generator-config.json
@@ -0,0 +1,26 @@
+{
+ "specUrl": "https://api.prime.coinbase.com/v1/openapi.yaml",
+ "committedSpecPath": "apiSpec/prime-public-spec.yaml",
+ "tagToFolderOverrides": {
+ "Travel Rule": "transactions"
+ },
+ "modelTypeMappings": {
+ "CreateOnchainTransactionRequestEVMParams": "EvmParams",
+ "CreateOnchainTransactionRequestEvmParams": "EvmParams",
+ "FcmFuturesSweepRequestAmount": "SweepAmount",
+ "FcmFuturesSweep": "FuturesSweep",
+ "EvmParam": "EvmParams",
+ "RPC": "RpcConfig",
+ "XMControlStatus": "PrimeXMControlStatus",
+ "XMMarginLevel": "PrimeXMMarginLevel"
+ },
+ "sharedModelMappings": {
+ "PaginatedResponse": "com.coinbase.prime.common.Pagination"
+ },
+ "protectedCompatibilityFiles": [
+ "com/coinbase/prime/portfolios/GetPortfolioCounterpartyIdRequest.java",
+ "com/coinbase/prime/portfolios/GetPortfolioCounterpartyIdResponse.java",
+ "com/coinbase/prime/portfolios/PortfoliosService.java",
+ "com/coinbase/prime/portfolios/PortfoliosServiceImpl.java"
+ ]
+}
diff --git a/tools/model-generator/config/naming-parity.json b/tools/model-generator/config/naming-parity.json
new file mode 100644
index 00000000..c635a65e
--- /dev/null
+++ b/tools/model-generator/config/naming-parity.json
@@ -0,0 +1,100 @@
+[
+ {
+ "operationId": "PrimeRESTAPI_CreateWalletTransfer",
+ "canonicalJavaMethod": "createTransfer",
+ "legacyJavaAliases": ["createWalletTransfer"],
+ "serviceFolder": "transactions",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_CreateWalletWithdrawal",
+ "canonicalJavaMethod": "createWithdrawal",
+ "legacyJavaAliases": ["createWalletWithdrawal"],
+ "serviceFolder": "transactions",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetOrder",
+ "canonicalJavaMethod": "getOrder",
+ "legacyJavaAliases": ["getOrderByOrderId"],
+ "serviceFolder": "orders",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_StakingClaimRewards",
+ "canonicalJavaMethod": "claimStakingRewards",
+ "legacyJavaAliases": ["claimRewards"],
+ "serviceFolder": "staking",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_PortfolioStakingInitiate",
+ "canonicalJavaMethod": "createPortfolioStake",
+ "legacyJavaAliases": ["portfolioStakingInitiate"],
+ "serviceFolder": "staking",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_PortfolioStakingUnstake",
+ "canonicalJavaMethod": "createPortfolioUnstake",
+ "legacyJavaAliases": ["portfolioStakingUnstake"],
+ "serviceFolder": "staking",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetEntityPaymentMethods",
+ "canonicalJavaMethod": "listEntityPaymentMethods",
+ "legacyJavaAliases": ["listPaymentMethods"],
+ "serviceFolder": "paymentmethods",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetEntityPaymentMethodDetails",
+ "canonicalJavaMethod": "getEntityPaymentMethod",
+ "legacyJavaAliases": ["getPaymentMethodDetails"],
+ "serviceFolder": "paymentmethods",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetPortfolioActivities",
+ "canonicalJavaMethod": "listActivities",
+ "legacyJavaAliases": ["listPortfolioActivities"],
+ "serviceFolder": "activities",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetPortfolioAddressBook",
+ "canonicalJavaMethod": "getAddressBook",
+ "legacyJavaAliases": ["listAddressBook"],
+ "serviceFolder": "addressbook",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetAllocationsByClientNettingId",
+ "canonicalJavaMethod": "getAllocationsByClientNettingId",
+ "legacyJavaAliases": ["listAllocationsByNettingId"],
+ "serviceFolder": "allocations",
+ "aliasBehavior": "requestResponseSubclass"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetEntityUsers",
+ "canonicalJavaMethod": "listEntityUsers",
+ "legacyJavaAliases": [],
+ "serviceFolder": "users",
+ "aliasBehavior": "none"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetFcmBalance",
+ "canonicalJavaMethod": "getEntityFcmBalance",
+ "legacyJavaAliases": [],
+ "serviceFolder": "futures",
+ "aliasBehavior": "none"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetPortfolioCounterpartyID",
+ "canonicalJavaMethod": "getPortfolioCounterpartyId",
+ "legacyJavaAliases": [],
+ "serviceFolder": "advancedtransfer",
+ "aliasBehavior": "none"
+ }
+]
diff --git a/tools/model-generator/config/operations-overrides.json b/tools/model-generator/config/operations-overrides.json
new file mode 100644
index 00000000..ffd38120
--- /dev/null
+++ b/tools/model-generator/config/operations-overrides.json
@@ -0,0 +1,283 @@
+[
+ {
+ "operationId": "PrimeRESTAPI_GetActivity",
+ "convenienceConstructorParameters": ["activity_id"]
+ },
+ {
+ "operationId": "PrimeRESTAPI_CreateOrder",
+ "parameterTypeOverrides": {
+ "is_raise_exact": "boolean",
+ "is_buy_exact": "boolean"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetEntityActivities",
+ "parameterTypeOverrides": {
+ "activity_level": "com.coinbase.prime.model.enums.ActivityLevel",
+ "symbols": "String[]",
+ "categories": "com.coinbase.prime.model.enums.ActivityCategory[]",
+ "statuses": "com.coinbase.prime.model.enums.ActivityStatus[]"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetPortfolioActivities",
+ "sdkMethod": "ListActivities",
+ "parameterTypeOverrides": {
+ "symbols": "String[]",
+ "categories": "com.coinbase.prime.model.enums.ActivityCategory[]",
+ "statuses": "com.coinbase.prime.model.enums.ActivityStatus[]"
+ },
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "ListPortfolioActivities",
+ "message": "Use listActivities instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_ListEntityBalances",
+ "parameterTypeOverrides": {
+ "symbols": "String[]",
+ "aggregation_type": "com.coinbase.prime.model.enums.PortfolioBalanceType"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetPortfolioBalances",
+ "paginated": true,
+ "parameterTypeOverrides": {
+ "symbols": "String[]",
+ "balance_type": "com.coinbase.prime.model.enums.PortfolioBalanceType"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetCandles",
+ "parameterTypeOverrides": {
+ "granularity": "com.coinbase.prime.model.enums.CandlesGranularity"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetOpenOrders",
+ "parameterTypeOverrides": {
+ "product_ids": "String[]",
+ "order_type": "com.coinbase.prime.model.enums.OrderType",
+ "order_side": "com.coinbase.prime.model.enums.OrderSide"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetOrders",
+ "sdkMethod": "ListPortfolioOrders",
+ "responseTypeOverrides": {
+ "orders": "com.coinbase.prime.model.Order[]"
+ },
+ "parameterTypeOverrides": {
+ "order_type": "com.coinbase.prime.model.enums.OrderType",
+ "order_side": "com.coinbase.prime.model.enums.OrderSide",
+ "order_statuses": "com.coinbase.prime.model.enums.OrderStatus[]",
+ "product_ids": "String[]"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetOrder",
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "GetOrderByOrderId",
+ "message": "Use getOrder instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetEntityPaymentMethods",
+ "sdkMethod": "ListEntityPaymentMethods",
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "ListPaymentMethods",
+ "message": "Use listEntityPaymentMethods instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetEntityPaymentMethodDetails",
+ "sdkMethod": "GetEntityPaymentMethod",
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "GetPaymentMethodDetails",
+ "message": "Use getEntityPaymentMethod instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetPortfolios",
+ "omitRequest": false
+ },
+ {
+ "operationId": "PrimeRESTAPI_RotateAPIKey",
+ "sdkMethod": "RotateApiKey",
+ "serviceFolder": "apikey",
+ "serviceName": "ApiKeyService",
+ "parameterTypeOverrides": {
+ "duration_seconds": "Long"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_ListAdvancedTransferTransactions",
+ "serviceFolder": "advancedtransfer",
+ "serviceName": "AdvancedTransferService"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetPortfolioCounterpartyID",
+ "sdkMethod": "GetPortfolioCounterpartyId"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetBuyingPower",
+ "sdkMethod": "GetPortfolioBuyingPower"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetWithdrawalPower",
+ "sdkMethod": "GetPortfolioWithdrawalPower"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetXMLiquidation",
+ "sdkMethod": "GetCrossMarginLiquidation"
+ },
+ {
+ "operationId": "PrimeRESTAPI_ListXMLiquidations",
+ "sdkMethod": "ListCrossMarginLiquidations",
+ "parameterTypeOverrides": {
+ "status": "com.coinbase.prime.model.enums.XMLiquidationStatus"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetDerivativePositions",
+ "sdkMethod": "GetDerivativePositions"
+ },
+ {
+ "operationId": "PrimeRESTAPI_StakingClaimRewards",
+ "sdkMethod": "ClaimStakingRewards",
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "ClaimRewards",
+ "message": "Use claimStakingRewards instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_StakingInitiate",
+ "sdkMethod": "CreateStake"
+ },
+ {
+ "operationId": "PrimeRESTAPI_StakingUnstake",
+ "sdkMethod": "CreateUnstake"
+ },
+ {
+ "operationId": "PrimeRESTAPI_PortfolioStakingInitiate",
+ "sdkMethod": "CreatePortfolioStake",
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "PortfolioStakingInitiate",
+ "message": "Use createPortfolioStake instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_PortfolioStakingUnstake",
+ "sdkMethod": "CreatePortfolioUnstake",
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "PortfolioStakingUnstake",
+ "message": "Use createPortfolioUnstake instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_ListTransactionValidators",
+ "parameterTypeOverrides": {
+ "transaction_ids": "String[]"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetInvoices",
+ "parameterTypeOverrides": {
+ "states": "com.coinbase.prime.model.enums.InvoiceState[]"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetPortfolioTransactions",
+ "parameterTypeOverrides": {
+ "symbols": "String[]",
+ "types": "com.coinbase.prime.model.enums.TransactionType[]"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetWalletTransactions",
+ "parameterTypeOverrides": {
+ "types": "com.coinbase.prime.model.enums.TransactionType[]"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_CreateWalletTransfer",
+ "sdkMethod": "CreateTransfer",
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "CreateWalletTransfer",
+ "message": "Use createTransfer instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_CreateWalletWithdrawal",
+ "sdkMethod": "CreateWithdrawal",
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "CreateWalletWithdrawal",
+ "message": "Use createWithdrawal instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_ListWeb3WalletBalances",
+ "sdkMethod": "ListOnchainWalletBalances"
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetWallets",
+ "parameterTypeOverrides": {
+ "type": "com.coinbase.prime.model.enums.WalletType",
+ "symbols": "String[]"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_CreateWallet",
+ "propertyNameOverrides": {
+ "wallet_type": "type"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetWalletDepositInstructions",
+ "parameterTypeOverrides": {
+ "deposit_type": "com.coinbase.prime.model.enums.WalletDepositInstructionType"
+ }
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetPortfolioAddressBook",
+ "sdkMethod": "GetAddressBook",
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "ListAddressBook",
+ "message": "Use getAddressBook instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetAllocationsByClientNettingId",
+ "sdkMethod": "GetAllocationsByClientNettingId",
+ "deprecatedAliases": [
+ {
+ "sdkMethod": "ListAllocationsByNettingId",
+ "message": "Use getAllocationsByClientNettingId instead."
+ }
+ ]
+ },
+ {
+ "operationId": "PrimeRESTAPI_GetFcmBalance",
+ "sdkMethod": "GetEntityFcmBalance"
+ }
+]
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/DeprecatedAlias.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/DeprecatedAlias.java
new file mode 100644
index 00000000..e118313c
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/DeprecatedAlias.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.coinbase.tools.modelgenerator;
+
+/**
+ * A declarative Java SDK compatibility alias for a canonical operation binding.
+ *
+ *
Aliases preserve a previously public Java name (request type, response type, and service
+ * method) as a deprecated forwarding surface once an operation's canonical cross-SDK name
+ * diverges from the existing Java name.
+ */
+public final class DeprecatedAlias {
+ private final String sdkMethod;
+ private final String message;
+
+ public DeprecatedAlias(String sdkMethod, String message) {
+ this.sdkMethod = sdkMethod;
+ this.message = message;
+ }
+
+ /** The deprecated PascalCase Java SDK method/type basename, for example {@code CreateWalletTransfer}. */
+ public String sdkMethod() {
+ return sdkMethod;
+ }
+
+ /** The {@code @deprecated} Javadoc message pointing consumers to the canonical replacement. */
+ public String message() {
+ return message;
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/FactoryPhase.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/FactoryPhase.java
new file mode 100644
index 00000000..36ccfe7c
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/FactoryPhase.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Emits the static factory surface for all generated Prime services. */
+public final class FactoryPhase {
+ private FactoryPhase() {}
+ public static Map render(List bindings) {
+ Map services = new LinkedHashMap<>();
+ for (OperationBinding binding : bindings) services.putIfAbsent(binding.serviceName(), binding);
+ List ordered = new java.util.ArrayList<>(services.values()); ordered.sort(Comparator.comparing(OperationBinding::serviceName));
+ Set imports = new LinkedHashSet<>(); imports.add("com.coinbase.prime.client.CoinbasePrimeClient");
+ for (OperationBinding binding : ordered) { String prefix="com.coinbase.prime." + binding.serviceFolder() + "."; imports.add(prefix + binding.serviceName()); imports.add(prefix + binding.serviceName() + "Impl"); }
+ StringBuilder source=new StringBuilder(SourceTemplates.header()).append("package com.coinbase.prime.factory;\n\n"); SourceTemplates.imports(source, imports); source.append("public class PrimeServiceFactory {\n");
+ for (OperationBinding binding : ordered) source.append(" public static ").append(binding.serviceName()).append(" create").append(binding.serviceName()).append("(CoinbasePrimeClient client) {\n return new ").append(binding.serviceName()).append("Impl(client);\n }\n\n");
+ source.append("}\n"); Map result=new LinkedHashMap<>(); result.put(Path.of("com/coinbase/prime/factory/PrimeServiceFactory.java"),source.toString()); return result;
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedEnumKind.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedEnumKind.java
new file mode 100644
index 00000000..b76e4e33
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedEnumKind.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+/** Distinguishes domain enums from OpenAPI error Subcode and ErrorCode enums. */
+public final class GeneratedEnumKind {
+ public static final String ENUMS_PACKAGE = "com.coinbase.prime.model.enums";
+ public static final String ERRORS_PACKAGE = "com.coinbase.prime.model.errors";
+
+ private GeneratedEnumKind() {}
+
+ public static boolean isSubcode(String typeName) {
+ return typeName.endsWith("Subcode");
+ }
+
+ public static boolean isErrorCode(String typeName) {
+ return typeName.endsWith("ErrorCode");
+ }
+
+ public static boolean isErrorEnum(String typeName) {
+ return isSubcode(typeName) || isErrorCode(typeName);
+ }
+
+ public static String packageFor(String typeName) {
+ return isErrorEnum(typeName) ? ERRORS_PACKAGE : ENUMS_PACKAGE;
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedSourceReconciler.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedSourceReconciler.java
new file mode 100644
index 00000000..38e09e52
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedSourceReconciler.java
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.Locale;
+import java.util.TreeMap;
+
+/** Reconciles only manifest-owned generated files; hand-authored SDK files are never glob-deleted. */
+public final class GeneratedSourceReconciler {
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ private GeneratedSourceReconciler() {}
+
+ public static List diff(Path outputRoot, Map generated) throws IOException {
+ return diff(outputRoot, generated, Collections.emptySet(), null);
+ }
+
+ public static List diff(
+ Path outputRoot,
+ Map generated,
+ Set protectedFiles,
+ Path manifest)
+ throws IOException {
+ validateCaseInsensitivePaths(outputRoot, generated.keySet());
+ List changes = new ArrayList<>();
+ for (Map.Entry entry : new TreeMap<>(generated).entrySet()) {
+ String relative = entry.getKey().toString().replace('\\', '/');
+ Path target = outputRoot.resolve(entry.getKey());
+ String existing = Files.exists(target) ? Files.readString(target) : null;
+ if (protectedFiles.contains(relative)) {
+ if (!entry.getValue().equals(existing)) {
+ changes.add("SKIP " + entry.getKey());
+ }
+ continue;
+ }
+ if (!entry.getValue().equals(existing)) {
+ changes.add((existing == null ? "ADD " : "CHANGE ") + entry.getKey());
+ }
+ }
+ if (manifest != null) {
+ for (String stale : manifestEntries(manifest)) {
+ if (!generated.containsKey(Path.of(stale))
+ && !protectedFiles.contains(stale)
+ && Files.exists(outputRoot.resolve(stale))) {
+ changes.add("DELETE " + stale);
+ }
+ }
+ }
+ Collections.sort(changes);
+ return Collections.unmodifiableList(changes);
+ }
+
+ public static void write(Path outputRoot, Map generated) throws IOException {
+ write(outputRoot, generated, Collections.emptySet(), null);
+ }
+
+ public static void write(
+ Path outputRoot, Map generated, Set protectedFiles, Path manifest)
+ throws IOException {
+ validateCaseInsensitivePaths(outputRoot, generated.keySet());
+ for (Map.Entry entry : new TreeMap<>(generated).entrySet()) {
+ String relative = entry.getKey().toString().replace('\\', '/');
+ if (protectedFiles.contains(relative)) {
+ continue;
+ }
+ Path target = outputRoot.resolve(entry.getKey());
+ if (Files.exists(target) && entry.getValue().equals(Files.readString(target))) {
+ continue;
+ }
+ Files.createDirectories(target.getParent());
+ Files.writeString(target, entry.getValue(), StandardCharsets.UTF_8);
+ }
+ if (manifest != null) {
+ for (String stale : manifestEntries(manifest)) {
+ if (!generated.containsKey(Path.of(stale)) && !protectedFiles.contains(stale)) {
+ Files.deleteIfExists(outputRoot.resolve(stale));
+ }
+ }
+ writeManifest(manifest, generated.keySet());
+ }
+ }
+
+ private static void validateCaseInsensitivePaths(Path outputRoot, Set generated)
+ throws IOException {
+ Map generatedByFoldedPath = new LinkedHashMap<>();
+ for (Path relative : generated) {
+ String normalized = relative.toString().replace('\\', '/');
+ Path previous = generatedByFoldedPath.putIfAbsent(normalized.toLowerCase(Locale.ROOT), relative);
+ if (previous != null && !previous.equals(relative)) {
+ throw new IOException(
+ "Generated source paths differ only by case: " + previous + " and " + relative);
+ }
+ Path target = outputRoot.resolve(relative);
+ Path parent = target.getParent();
+ if (parent == null || !Files.isDirectory(parent)) {
+ continue;
+ }
+ try (java.util.stream.Stream children = Files.list(parent)) {
+ Path collision =
+ children
+ .filter(child -> !child.getFileName().equals(target.getFileName()))
+ .filter(
+ child ->
+ child.getFileName()
+ .toString()
+ .equalsIgnoreCase(target.getFileName().toString()))
+ .findFirst()
+ .orElse(null);
+ if (collision != null) {
+ throw new IOException(
+ "Generated source path collides case-insensitively with existing file: "
+ + relative
+ + " and "
+ + outputRoot.relativize(collision));
+ }
+ }
+ }
+ }
+
+ /** Loads manifest-owned source content for isolated generation/check comparisons. */
+ static Map readOwnedSources(Path outputRoot, Path manifest) throws IOException {
+ Map sources = new LinkedHashMap<>();
+ for (String entry : manifestEntries(manifest)) {
+ Path relative = Path.of(entry);
+ Path source = outputRoot.resolve(relative);
+ if (!Files.isRegularFile(source)) {
+ throw new IOException("Generated manifest references missing source: " + entry);
+ }
+ sources.put(relative, Files.readString(source));
+ }
+ return sources;
+ }
+
+ static Set manifestEntries(Path manifest) throws IOException {
+ if (!Files.exists(manifest)) {
+ return Collections.emptySet();
+ }
+ Set entries = new LinkedHashSet<>();
+ for (String entry : JSON.readValue(Files.readString(manifest), new TypeReference>() {})) {
+ Path relative = Path.of(entry).normalize();
+ if (relative.isAbsolute() || relative.startsWith("..")) {
+ throw new IOException("Generated manifest contains unsafe path: " + entry);
+ }
+ entries.add(relative.toString().replace('\\', '/'));
+ }
+ return entries;
+ }
+
+ static void writeManifest(Path manifest, Set paths) throws IOException {
+ List entries = new ArrayList<>();
+ for (Path path : paths) {
+ entries.add(path.toString().replace('\\', '/'));
+ }
+ Collections.sort(entries);
+ Files.createDirectories(manifest.getParent());
+ Files.writeString(
+ manifest,
+ JSON.writerWithDefaultPrettyPrinter().writeValueAsString(entries) + "\n",
+ StandardCharsets.UTF_8);
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorConfiguration.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorConfiguration.java
new file mode 100644
index 00000000..3c1a6a62
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorConfiguration.java
@@ -0,0 +1,370 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+/** Loads deterministic generation configuration and validates sparse operation overrides. */
+public final class GeneratorConfiguration {
+ private static final ObjectMapper JSON = new ObjectMapper();
+ private final String specUrl;
+ private final String committedSpecPath;
+ private final Map tagFolders;
+ private final Map modelTypeMappings;
+ private final Map sharedModelMappings;
+ private final Set protectedFiles;
+ private final Map overrides;
+
+ private GeneratorConfiguration(
+ String specUrl,
+ String committedSpecPath,
+ Map tagFolders,
+ Map modelTypeMappings,
+ Map sharedModelMappings,
+ Set protectedFiles,
+ Map overrides) {
+ this.specUrl = specUrl;
+ this.committedSpecPath = committedSpecPath;
+ this.tagFolders = Collections.unmodifiableMap(tagFolders);
+ this.modelTypeMappings = Collections.unmodifiableMap(modelTypeMappings);
+ this.sharedModelMappings = Collections.unmodifiableMap(sharedModelMappings);
+ this.protectedFiles = Collections.unmodifiableSet(protectedFiles);
+ this.overrides = Collections.unmodifiableMap(overrides);
+ }
+
+ public static GeneratorConfiguration load(GeneratorPaths paths) throws IOException {
+ return load(paths.configuration(), paths.operationOverrides());
+ }
+
+ static GeneratorConfiguration loadForTests(Path configurationPath, Path overridesPath)
+ throws IOException {
+ return load(configurationPath, overridesPath);
+ }
+
+ private static GeneratorConfiguration load(Path configurationPath, Path overridesPath)
+ throws IOException {
+ JsonNode config = JSON.readTree(configurationPath.toFile());
+ String specUrl = required(config, "specUrl");
+ String committedSpecPath = required(config, "committedSpecPath");
+ validateRelativePath(committedSpecPath, "committedSpecPath");
+ Map tagFolders = strings(config.path("tagToFolderOverrides"));
+ for (Map.Entry entry : tagFolders.entrySet()) {
+ validatePackageFolder(entry.getValue(), "tagToFolderOverrides." + entry.getKey());
+ }
+ Map modelTypeMappings = strings(config.path("modelTypeMappings"));
+ for (Map.Entry entry : modelTypeMappings.entrySet()) {
+ validateJavaIdentifier(entry.getKey(), "modelTypeMappings key");
+ validateJavaIdentifier(entry.getValue(), "modelTypeMappings." + entry.getKey());
+ }
+ Map sharedMappings = strings(config.path("sharedModelMappings"));
+ for (Map.Entry entry : sharedMappings.entrySet()) {
+ validateJavaIdentifier(entry.getKey(), "sharedModelMappings key");
+ validateQualifiedType(entry.getValue(), "sharedModelMappings." + entry.getKey());
+ }
+ Set protectedFiles = new LinkedHashSet<>();
+ for (JsonNode node : config.path("protectedCompatibilityFiles")) {
+ String protectedFile = node.asText();
+ validateRelativePath(protectedFile, "protectedCompatibilityFiles");
+ protectedFiles.add(protectedFile);
+ }
+
+ Map overrides = new LinkedHashMap<>();
+ JsonNode overrideNodes = JSON.readTree(overridesPath.toFile());
+ if (!overrideNodes.isArray()) {
+ throw new IllegalArgumentException("operations-overrides.json must be an array");
+ }
+ for (JsonNode node : overrideNodes) {
+ String operationId = required(node, "operationId");
+ if (overrides.put(operationId, Override.from(node)) != null) {
+ throw new IllegalArgumentException("Duplicate operation override: " + operationId);
+ }
+ }
+ return new GeneratorConfiguration(
+ specUrl,
+ committedSpecPath,
+ tagFolders,
+ modelTypeMappings,
+ sharedMappings,
+ protectedFiles,
+ overrides);
+ }
+
+ private static String required(JsonNode node, String field) {
+ String value = node.path(field).asText();
+ if (value.isEmpty()) {
+ throw new IllegalArgumentException("Configuration is missing " + field);
+ }
+ return value;
+ }
+
+ private static Map strings(JsonNode node) {
+ Map values = new LinkedHashMap<>();
+ node.fields().forEachRemaining(entry -> values.put(entry.getKey(), entry.getValue().asText()));
+ return values;
+ }
+
+ static void validateJavaIdentifier(String value, String field) {
+ if (value == null
+ || value.isEmpty()
+ || !Character.isJavaIdentifierStart(value.charAt(0))) {
+ throw new IllegalArgumentException(field + " must be a Java identifier: " + value);
+ }
+ for (int index = 1; index < value.length(); index++) {
+ if (!Character.isJavaIdentifierPart(value.charAt(index))) {
+ throw new IllegalArgumentException(field + " must be a Java identifier: " + value);
+ }
+ }
+ }
+
+ static void validatePascalCaseIdentifier(String value, String field) {
+ validateJavaIdentifier(value, field);
+ if (!Character.isUpperCase(value.charAt(0))) {
+ throw new IllegalArgumentException(field + " must be PascalCase: " + value);
+ }
+ }
+
+ static void validatePackageFolder(String value, String field) {
+ if (value == null || value.isEmpty()) {
+ throw new IllegalArgumentException(field + " must be a Java package folder");
+ }
+ for (String segment : value.split("\\.")) {
+ validateJavaIdentifier(segment, field);
+ }
+ }
+
+ private static void validateQualifiedType(String value, String field) {
+ if (value == null || value.isEmpty()) {
+ throw new IllegalArgumentException(field + " must be a qualified Java type");
+ }
+ for (String segment : value.split("\\.")) {
+ validateJavaIdentifier(segment, field);
+ }
+ }
+
+ private static void validateJavaType(String value, String field) {
+ if (value == null || !value.matches("[A-Za-z_$][A-Za-z0-9_$.]*(?:\\[\\])?")) {
+ throw new IllegalArgumentException(field + " must be a Java type: " + value);
+ }
+ }
+
+ private static void validateRelativePath(String value, String field) {
+ Path path = Path.of(value);
+ if (path.isAbsolute() || path.normalize().startsWith("..")) {
+ throw new IllegalArgumentException(field + " must remain within the repository: " + value);
+ }
+ }
+
+ public String specUrl() {
+ return specUrl;
+ }
+
+ public String committedSpecPath() {
+ return committedSpecPath;
+ }
+
+ public Map tagFolders() {
+ return tagFolders;
+ }
+
+ public Map modelTypeMappings() {
+ return modelTypeMappings;
+ }
+
+ public Map sharedModelMappings() {
+ return sharedModelMappings;
+ }
+
+ public Set protectedFiles() {
+ return protectedFiles;
+ }
+
+ public Map overrides() {
+ return overrides;
+ }
+
+ public static final class Override {
+ private final String sdkMethod;
+ private final String serviceFolder;
+ private final String serviceName;
+ private final Boolean omitRequest;
+ private final Boolean paginated;
+ private final Map parameterTypes;
+ private final Map propertyNames;
+ private final Map responseTypes;
+ private final List convenienceConstructorParameters;
+ private final List statuses;
+ private final List deprecatedAliases;
+
+ private Override(
+ String sdkMethod,
+ String serviceFolder,
+ String serviceName,
+ Boolean omitRequest,
+ Boolean paginated,
+ Map parameterTypes,
+ Map propertyNames,
+ Map responseTypes,
+ List convenienceConstructorParameters,
+ List statuses,
+ List deprecatedAliases) {
+ this.sdkMethod = sdkMethod;
+ this.serviceFolder = serviceFolder;
+ this.serviceName = serviceName;
+ this.omitRequest = omitRequest;
+ this.paginated = paginated;
+ this.parameterTypes = parameterTypes;
+ this.propertyNames = propertyNames;
+ this.responseTypes = responseTypes;
+ this.convenienceConstructorParameters = convenienceConstructorParameters;
+ this.statuses = statuses;
+ this.deprecatedAliases = deprecatedAliases;
+ }
+
+ static Override from(JsonNode node) {
+ String sdkMethod = node.path("sdkMethod").asText(null);
+ if (sdkMethod != null) {
+ validatePascalCaseIdentifier(sdkMethod, "sdkMethod");
+ }
+ String serviceFolder = node.path("serviceFolder").asText(null);
+ if (serviceFolder != null) {
+ validatePackageFolder(serviceFolder, "serviceFolder");
+ }
+ String serviceName = node.path("serviceName").asText(null);
+ if (serviceName != null) {
+ validatePascalCaseIdentifier(serviceName, "serviceName");
+ }
+ Map parameterTypes = strings(node.path("parameterTypeOverrides"));
+ for (Map.Entry entry : parameterTypes.entrySet()) {
+ validateJavaIdentifier(entry.getKey(), "parameterTypeOverrides key");
+ validateJavaType(entry.getValue(), "parameterTypeOverrides." + entry.getKey());
+ }
+ Map propertyNames = strings(node.path("propertyNameOverrides"));
+ for (Map.Entry entry : propertyNames.entrySet()) {
+ validateJavaIdentifier(entry.getKey(), "propertyNameOverrides key");
+ validateJavaIdentifier(entry.getValue(), "propertyNameOverrides." + entry.getKey());
+ }
+ Map responseTypes = strings(node.path("responseTypeOverrides"));
+ for (Map.Entry entry : responseTypes.entrySet()) {
+ validateJavaIdentifier(entry.getKey(), "responseTypeOverrides key");
+ validateJavaType(entry.getValue(), "responseTypeOverrides." + entry.getKey());
+ }
+ List convenienceConstructorParameters = new ArrayList<>();
+ for (JsonNode parameter : node.path("convenienceConstructorParameters")) {
+ String parameterName = parameter.asText();
+ validateJavaIdentifier(parameterName, "convenienceConstructorParameters");
+ if (convenienceConstructorParameters.contains(parameterName)) {
+ throw new IllegalArgumentException(
+ "Duplicate convenience constructor parameter: " + parameterName);
+ }
+ convenienceConstructorParameters.add(parameterName);
+ }
+ TreeSet normalizedStatuses = new TreeSet<>();
+ for (JsonNode status : node.path("statusCodes")) {
+ int code = status.asInt();
+ if (code < 200 || code >= 300) {
+ throw new IllegalArgumentException("Override status must be a 2xx code");
+ }
+ normalizedStatuses.add(code);
+ }
+ List deprecatedAliases = new ArrayList<>();
+ Set aliasNames = new HashSet<>();
+ for (JsonNode aliasNode : node.path("deprecatedAliases")) {
+ String aliasSdkMethod = required(aliasNode, "sdkMethod");
+ validatePascalCaseIdentifier(aliasSdkMethod, "deprecatedAliases.sdkMethod");
+ String message = required(aliasNode, "message");
+ if (sdkMethod != null && sdkMethod.equals(aliasSdkMethod)) {
+ throw new IllegalArgumentException(
+ "deprecatedAliases.sdkMethod must differ from sdkMethod: " + aliasSdkMethod);
+ }
+ if (!aliasNames.add(aliasSdkMethod)) {
+ throw new IllegalArgumentException(
+ "Duplicate deprecatedAliases.sdkMethod: " + aliasSdkMethod);
+ }
+ deprecatedAliases.add(new DeprecatedAlias(aliasSdkMethod, message));
+ }
+
+ return new Override(
+ sdkMethod,
+ serviceFolder,
+ serviceName,
+ node.has("omitRequest") ? node.get("omitRequest").asBoolean() : null,
+ node.has("paginated") ? node.get("paginated").asBoolean() : null,
+ Collections.unmodifiableMap(new LinkedHashMap<>(parameterTypes)),
+ Collections.unmodifiableMap(new LinkedHashMap<>(propertyNames)),
+ Collections.unmodifiableMap(new LinkedHashMap<>(responseTypes)),
+ Collections.unmodifiableList(convenienceConstructorParameters),
+ Collections.unmodifiableList(new ArrayList<>(normalizedStatuses)),
+ Collections.unmodifiableList(deprecatedAliases));
+ }
+
+ public String sdkMethod() {
+ return sdkMethod;
+ }
+
+ public String serviceFolder() {
+ return serviceFolder;
+ }
+
+ public String serviceName() {
+ return serviceName;
+ }
+
+ public Boolean omitRequest() {
+ return omitRequest;
+ }
+
+ public Boolean paginated() {
+ return paginated;
+ }
+
+ public Map parameterTypes() {
+ return parameterTypes;
+ }
+
+ public Map propertyNames() {
+ return propertyNames;
+ }
+
+ public Map responseTypes() {
+ return responseTypes;
+ }
+
+ public List convenienceConstructorParameters() {
+ return convenienceConstructorParameters;
+ }
+
+ public List statuses() {
+ return statuses;
+ }
+
+ public List deprecatedAliases() {
+ return deprecatedAliases;
+ }
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorPaths.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorPaths.java
new file mode 100644
index 00000000..179eddf2
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorPaths.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/** Canonical generator input and output locations relative to the SDK repository. */
+public final class GeneratorPaths {
+ private final Path root;
+
+ private GeneratorPaths(Path root) { this.root = root.toAbsolutePath().normalize(); }
+
+ static GeneratorPaths forRoot(Path root) { return new GeneratorPaths(root); }
+
+ public static GeneratorPaths fromWorkingDirectory() {
+ Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
+ while (current != null) {
+ if (current.resolve("pom.xml").toFile().exists()
+ && current.resolve("src/main/java/com/coinbase/prime").toFile().exists()) {
+ return new GeneratorPaths(current);
+ }
+ current = current.getParent();
+ }
+ throw new IllegalStateException("Could not locate the Coinbase Prime SDK repository root");
+ }
+
+ public Path root() { return root; }
+ public Path configuration() { return root.resolve("tools/model-generator/config/generator-config.json"); }
+ public Path operationOverrides() { return root.resolve("tools/model-generator/config/operations-overrides.json"); }
+ public Path sourceRoot() { return root.resolve("src/main/java"); }
+ public Path modelRoot() { return sourceRoot().resolve("com/coinbase/prime/model"); }
+ public Path enumRoot() { return modelRoot().resolve("enums"); }
+ public Path errorRoot() { return modelRoot().resolve("errors"); }
+ public Path rawRoot() { return root.resolve("generated"); }
+ public Path manifest() { return root.resolve("tools/model-generator/generated-files.json"); }
+ public Path modelManifest() { return root.resolve("tools/model-generator/generated-model-files.json"); }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/JavaTypeResolver.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/JavaTypeResolver.java
new file mode 100644
index 00000000..0cc152ab
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/JavaTypeResolver.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Resolves OpenAPI schemas to the post-processed Java model and enum type names. */
+public final class JavaTypeResolver {
+ private final Map schemas;
+ private final NamingResolver names;
+ private final Map sharedModelMappings;
+
+ public JavaTypeResolver(SpecModels.Document document, NamingResolver names) {
+ this(document, names, Collections.emptyMap());
+ }
+
+ @SuppressWarnings("unchecked")
+ public JavaTypeResolver(
+ SpecModels.Document document, NamingResolver names, Map sharedModelMappings) {
+ this.schemas =
+ SpecParser.map(
+ SpecParser.map(SpecParser.map(document.root().get("components")).get("schemas")));
+ this.names = names;
+ this.sharedModelMappings = sharedModelMappings;
+ }
+
+ public Type resolve(Map schema) {
+ if (schema == null || schema.isEmpty()) return new Type("Object", Collections.emptySet());
+ String ref = string(schema.get("$ref"));
+ if (!ref.isEmpty()) {
+ String raw = ref.substring(ref.lastIndexOf('/') + 1);
+ Map target = schemas.get(raw) instanceof Map ? SpecParser.map(schemas.get(raw)) : Collections.emptyMap();
+ String typeName = names.typeName(schemaTypeName(raw));
+ String sharedType = sharedModelMappings.get(raw);
+ if (sharedType == null) {
+ sharedType = sharedModelMappings.get(typeName);
+ }
+ if (sharedType != null) {
+ return external(sharedType);
+ }
+ String packageName =
+ target.containsKey("enum")
+ ? GeneratedEnumKind.packageFor(typeName) + "."
+ : "com.coinbase.prime.model.";
+ return external(packageName + typeName);
+ }
+ String type = string(schema.get("type"));
+ if ("array".equals(type)) return array(resolve(SpecParser.map(schema.get("items"))));
+ if ("object".equals(type) && schema.containsKey("additionalProperties")) {
+ return map(resolve(SpecParser.map(schema.get("additionalProperties"))));
+ }
+ if (schema.containsKey("enum")) return resolveInlineEnum(schema);
+ switch (type) {
+ case "integer": return new Type("Integer", Collections.emptySet());
+ case "number": return new Type("Double", Collections.emptySet());
+ // Boxed to match the vended DTOs: unset optional flags are omitted under NON_NULL instead of
+ // being sent as false. Primitive compatibility fields use parameterTypeOverrides.
+ case "boolean": return new Type("Boolean", Collections.emptySet());
+ case "string": return new Type("String", Collections.emptySet());
+ default: return new Type("Object", Collections.emptySet());
+ }
+ }
+
+ /** Resolves an explicit compatibility type while retaining imports for qualified model types. */
+ public Type configured(String javaType) {
+ Set imports = new LinkedHashSet<>();
+ Matcher matcher = Pattern.compile("(?:[A-Za-z_$][A-Za-z0-9_$]*\\.)+([A-Za-z_$][A-Za-z0-9_$]*)").matcher(javaType);
+ StringBuffer source = new StringBuffer();
+ while (matcher.find()) {
+ String qualifiedName = matcher.group();
+ imports.add(qualifiedName);
+ matcher.appendReplacement(source, matcher.group(1));
+ }
+ matcher.appendTail(source);
+ return new Type(source.toString(), imports);
+ }
+
+ public Map dereference(Map schema) {
+ String ref = schema == null ? "" : string(schema.get("$ref"));
+ return ref.isEmpty() ? (schema == null ? Collections.emptyMap() : schema)
+ : SpecParser.map(schemas.get(ref.substring(ref.lastIndexOf('/') + 1)));
+ }
+ private Type external(String qualifiedName) {
+ Set imports = new LinkedHashSet<>(); imports.add(qualifiedName);
+ return new Type(qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1), imports);
+ }
+ private Type array(Type item) {
+ return new Type(item.name() + "[]", new LinkedHashSet<>(item.imports()));
+ }
+
+ /** Resolves an inline enum only when it exactly matches one named schema enum. */
+ private Type resolveInlineEnum(Map schema) {
+ Object values = schema.get("enum");
+ Set matches = new LinkedHashSet<>();
+ for (Map.Entry entry : schemas.entrySet()) {
+ if (values.equals(SpecParser.map(entry.getValue()).get("enum"))) {
+ matches.add(names.typeName(schemaTypeName(entry.getKey())));
+ }
+ }
+ // Zero or several candidates would silently pick an arbitrary type; require an override.
+ if (matches.size() != 1) {
+ throw new IllegalArgumentException(
+ "Inline enum cannot be safely resolved to one named SDK enum " + matches + ": " + values);
+ }
+ String typeName = matches.iterator().next();
+ return external(GeneratedEnumKind.packageFor(typeName) + "." + typeName);
+ }
+
+ private Type map(Type value) {
+ Set imports = new LinkedHashSet<>(value.imports());
+ imports.add("java.util.Map");
+ return new Type("Map", imports);
+ }
+ private static String schemaTypeName(String schemaName) {
+ StringBuilder result = new StringBuilder();
+ for (String part : schemaName.split("[._]")) {
+ if (!part.isEmpty()) {
+ result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1));
+ }
+ }
+ return result.toString();
+ }
+
+ private static String string(Object value) { return value == null ? "" : String.valueOf(value); }
+
+ public static final class Type {
+ private final String name; private final Set imports;
+ Type(String name, Set imports) { this.name = name; this.imports = Collections.unmodifiableSet(imports); }
+ public String name() { return name; } public Set imports() { return imports; }
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/Main.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/Main.java
index e95d6080..568b8886 100644
--- a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/Main.java
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/Main.java
@@ -1,92 +1,240 @@
/*
- * Copyright 2025-present Coinbase Global, Inc.
+ * Copyright 2026-present Coinbase Global, Inc.
*
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
*
- * http://www.apache.org/licenses/LICENSE-2.0
+ * http://www.apache.org/licenses/LICENSE-2.0
*
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
*/
-
package com.coinbase.tools.modelgenerator;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.io.FileUtils;
+
+/** CLI entry point for committed-spec generation, isolated checks, and live client-surface diffs. */
+public final class Main {
+ private Main() {}
+
+ public static void main(String[] args) {
+ try {
+ run(args, GeneratorPaths.fromWorkingDirectory());
+ } catch (Exception exception) {
+ exception.printStackTrace(System.err);
+ System.exit(1);
+ }
+ }
+
+ static void run(String[] args, GeneratorPaths paths) throws Exception {
+ GeneratorConfiguration configuration = GeneratorConfiguration.load(paths);
+ if (has(args, "--fetch-spec")) {
+ SpecFetcher.fetch(paths.root(), configuration.specUrl(), configuration.committedSpecPath());
+ return;
+ }
-public class Main {
- private static final Logger logger = LoggerFactory.getLogger(Main.class);
+ boolean check = has(args, "--check");
+ boolean liveDiff = has(args, "--live-diff");
+ boolean skipModels = has(args, "--skip-models");
+ Path spec = paths.root().resolve(configuration.committedSpecPath());
+ Path temporaryLiveSpec = null;
+ try {
+ if (liveDiff) {
+ temporaryLiveSpec = SpecFetcher.fetchToTemporary(paths.root(), configuration.specUrl());
+ spec = temporaryLiveSpec;
+ check = true;
+ }
- public static void main(String[] args) {
+ if (check) {
+ // Live diffs use the identical isolated rendering and Spotless normalization as --check.
+ List changes = checkGeneratedInIsolation(paths, spec, configuration);
+ reportChanges(changes);
+ return;
+ }
+
+ // Render and validate the whole client surface before writing anything, so a binding error
+ // cannot leave regenerated models next to stale services and DTOs.
+ Map sources = renderClientSources(spec, configuration, paths.sourceRoot());
+ if (!skipModels) {
+ new OpenApiGenerator(spec.toString(), paths.rawRoot()).generateModels();
+ new PostProcessor(
+ paths.rawRoot(),
+ paths.sourceRoot(),
+ paths.modelRoot(),
+ paths.enumRoot(),
+ paths.errorRoot(),
+ spec,
+ paths.modelManifest())
+ .processModels();
+ }
+ GeneratedSourceReconciler.write(
+ paths.sourceRoot(), sources, configuration.protectedFiles(), paths.manifest());
+ System.out.println("Generated " + sources.size() + " client-surface files");
+ } finally {
+ if (temporaryLiveSpec != null) {
+ Files.deleteIfExists(temporaryLiveSpec);
try {
- logger.info("Coinbase Prime Java SDK Model Generator");
- logger.info("========================================");
- logger.info("Mode: FULL GENERATION (regenerates all models from OpenAPI spec)");
- logger.info("");
-
- // Find project root
- Path projectRoot = findProjectRoot();
- Path outputDir = projectRoot.resolve("src/main/java/com/coinbase/prime/model");
- Path enumsDir = outputDir.resolve("enums");
- Path tempDir = projectRoot.resolve("generated");
-
- logger.info("Project Root: {}", projectRoot);
- logger.info("Output Directory: {}", outputDir);
- logger.info("Enums Directory: {}", enumsDir);
- logger.info("Temp Directory: {}", tempDir);
-
- // Phase 0: Always fetch the latest spec into apiSpec/ (tracked in Git)
- logger.info("\nPhase 0: Fetching latest OpenAPI spec...");
- Path specPath = SpecFetcher.fetch(projectRoot);
-
- // Phase 1: Generate raw models using OpenAPI Generator
- logger.info("\nPhase 1: Generating raw models with OpenAPI Generator...");
- OpenApiGenerator generator = new OpenApiGenerator(specPath.toString(), tempDir);
- generator.generateModels();
-
- // Phase 2: Post-process models to match existing patterns
- logger.info("\nPhase 2: Post-processing models...");
- PostProcessor postProcessor = new PostProcessor(tempDir, outputDir, enumsDir, specPath);
- postProcessor.processModels();
-
- logger.info("\nModel generation completed successfully!");
- logger.info("\nGenerated models are in: {}", outputDir);
- logger.info("Generated enums are in: {}", enumsDir);
- logger.info("\nNext steps:");
- logger.info("1. Review apiSpec/prime-public-spec.yaml and generated models");
- logger.info("2. Run: mvn spotless:apply && mvn test");
- logger.info("3. Commit spec + model changes together");
-
- } catch (Exception e) {
- logger.error("Error during model generation", e);
- System.exit(1);
+ Files.deleteIfExists(temporaryLiveSpec.getParent());
+ } catch (java.nio.file.DirectoryNotEmptyException ignored) {
+ // The generator output directory pre-existed or has unrelated content.
}
+ }
}
+ }
- private static Path findProjectRoot() {
- Path current = Paths.get(System.getProperty("user.dir"));
- while (current != null) {
- if (current.resolve("pom.xml").toFile().exists() &&
- current.resolve("src/main/java/com/coinbase/prime").toFile().exists()) {
- return current;
- }
- current = current.getParent();
- }
-
- // If we can't find it, check if we're in the tools directory
- Path toolsPath = Paths.get(System.getProperty("user.dir"));
- if (toolsPath.toString().contains("tools/model-generator")) {
- return toolsPath.getParent().getParent();
- }
-
- throw new RuntimeException("Could not find project root (looking for pom.xml and src/main/java/com/coinbase/prime)");
+ /**
+ * Runs the complete write-mode pipeline against a disposable copy of the source tree.
+ *
+ * The staged project receives the same Spotless normalization as {@code make generate}; only its
+ * rendered, manifest-owned files are compared with the repository. The repository itself is never
+ * used as an output directory in check mode.
+ */
+ static List checkGeneratedInIsolation(
+ GeneratorPaths paths, Path spec, GeneratorConfiguration configuration) throws Exception {
+ Path stagingRoot = Files.createTempDirectory("prime-sdk-java-generator-check-");
+ try {
+ Path stagedSourceRoot = stagingRoot.resolve("src/main/java");
+ Path stagedModelRoot = stagedSourceRoot.resolve("com/coinbase/prime/model");
+ Path stagedModelManifest = stagingRoot.resolve("tools/model-generator/generated-model-files.json");
+ Path stagedClientManifest = stagingRoot.resolve("tools/model-generator/generated-files.json");
+
+ copyProjectInputs(paths, stagingRoot, stagedSourceRoot, stagedModelManifest, stagedClientManifest);
+ Path stagedRawRoot = stagingRoot.resolve("generated");
+ new OpenApiGenerator(spec.toString(), stagedRawRoot, paths.root()).generateModels();
+ new PostProcessor(
+ stagedRawRoot,
+ stagedSourceRoot,
+ stagedModelRoot,
+ stagedModelRoot.resolve("enums"),
+ stagedModelRoot.resolve("errors"),
+ spec,
+ stagedModelManifest)
+ .processModels();
+ // Stage protected proposals too so Spotless can normalize and compare them without touching the
+ // repository's compatibility files.
+ GeneratedSourceReconciler.write(
+ stagedSourceRoot,
+ renderClientSources(spec, configuration, stagedSourceRoot),
+ Collections.emptySet(),
+ stagedClientManifest);
+ formatStagedSources(stagingRoot);
+
+ List changes = new ArrayList<>();
+ changes.addAll(
+ GeneratedSourceReconciler.diff(
+ paths.sourceRoot(),
+ GeneratedSourceReconciler.readOwnedSources(stagedSourceRoot, stagedModelManifest),
+ Collections.emptySet(),
+ paths.modelManifest()));
+ changes.addAll(
+ GeneratedSourceReconciler.diff(
+ paths.sourceRoot(),
+ GeneratedSourceReconciler.readOwnedSources(stagedSourceRoot, stagedClientManifest),
+ configuration.protectedFiles(),
+ paths.manifest()));
+ addManifestChangeIfPresent(changes, paths.modelManifest(), stagedModelManifest);
+ addManifestChangeIfPresent(changes, paths.manifest(), stagedClientManifest);
+ Collections.sort(changes);
+ return Collections.unmodifiableList(changes);
+ } finally {
+ FileUtils.deleteDirectory(stagingRoot.toFile());
+ }
+ }
+
+ private static void copyProjectInputs(
+ GeneratorPaths paths,
+ Path stagingRoot,
+ Path stagedSourceRoot,
+ Path stagedModelManifest,
+ Path stagedClientManifest)
+ throws IOException {
+ Files.copy(paths.root().resolve("pom.xml"), stagingRoot.resolve("pom.xml"));
+ FileUtils.copyDirectory(paths.sourceRoot().toFile(), stagedSourceRoot.toFile());
+ copyIfPresent(paths.modelManifest(), stagedModelManifest);
+ copyIfPresent(paths.manifest(), stagedClientManifest);
+ }
+
+ private static void copyIfPresent(Path source, Path target) throws IOException {
+ if (Files.exists(source)) {
+ Files.createDirectories(target.getParent());
+ Files.copy(source, target);
+ }
+ }
+
+ static void formatStagedSources(Path stagingRoot) throws IOException, InterruptedException {
+ Process process =
+ new ProcessBuilder(
+ "mvn", "-B", "-f", stagingRoot.resolve("pom.xml").toString(), "spotless:apply")
+ .redirectErrorStream(true)
+ .start();
+ String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ if (process.waitFor() != 0) {
+ throw new IOException("Could not format staged generated sources:\n" + output);
+ }
+ }
+
+ private static void addManifestChangeIfPresent(
+ List changes, Path committedManifest, Path stagedManifest) throws IOException {
+ if (Files.exists(committedManifest)
+ && !Files.readString(committedManifest).equals(Files.readString(stagedManifest))) {
+ changes.add("CHANGE " + committedManifest.getFileName());
+ }
+ }
+
+ private static Map renderClientSources(
+ Path spec, GeneratorConfiguration configuration, Path sourceRoot) throws IOException {
+ SpecModels.Document document = SpecParser.load(spec);
+ NamingResolver names = new NamingResolver(configuration.modelTypeMappings());
+ List bindings = OperationBindingGenerator.deriveAll(document, configuration);
+ JavaTypeResolver types = new JavaTypeResolver(document, names, configuration.sharedModelMappings());
+ Map sources = new LinkedHashMap<>();
+ sources.putAll(RequestPhase.render(document, bindings, types, names));
+ sources.putAll(ResponsePhase.render(document, bindings, types, names));
+ sources.putAll(ServicePhase.render(document, bindings, configuration, names));
+ sources.putAll(FactoryPhase.render(bindings));
+ return preserveClientSourceHeaders(sources, sourceRoot);
+ }
+
+ static Map preserveClientSourceHeaders(Map sources, Path sourceRoot)
+ throws IOException {
+ Map headedSources = new LinkedHashMap<>();
+ for (Map.Entry source : sources.entrySet()) {
+ headedSources.put(
+ source.getKey(),
+ GeneratedFileHeader.applyStartYear(
+ source.getValue(), GeneratedFileHeader.resolveStartYear(sourceRoot.resolve(source.getKey()))));
+ }
+ return headedSources;
+ }
+
+ private static void reportChanges(List changes) {
+ for (String change : changes) {
+ System.out.println(change);
+ }
+ if (!changes.isEmpty()) {
+ throw new IllegalStateException("Generated source is out of date (" + changes.size() + " changes)");
+ }
+ }
+
+ private static boolean has(String[] args, String value) {
+ for (String arg : args) {
+ if (value.equals(arg)) {
+ return true;
+ }
}
-}
\ No newline at end of file
+ return false;
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/NamingResolver.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/NamingResolver.java
new file mode 100644
index 00000000..d7f28fe2
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/NamingResolver.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** Shared schema-to-Java naming policy used by models and client-surface emitters. */
+public final class NamingResolver {
+ private final Map modelTypeMappings;
+
+ public NamingResolver() {
+ this(Collections.emptyMap());
+ }
+
+ public NamingResolver(Map modelTypeMappings) {
+ this.modelTypeMappings = new LinkedHashMap<>(modelTypeMappings);
+ }
+
+ public String typeName(String schemaName) {
+ // PostProcessor owns acronym and prefix normalization, so type resolution starts from the raw
+ // schema name and only then applies explicit model type mappings.
+ String result = SharedTransforms.modelType(schemaName == null ? "Object" : schemaName);
+ return modelTypeMappings.getOrDefault(result, result);
+ }
+
+ public String propertyName(String wireName) {
+ StringBuilder result = new StringBuilder();
+ boolean upper = false;
+ for (char character : wireName.toCharArray()) {
+ if (!Character.isLetterOrDigit(character)) {
+ upper = true;
+ continue;
+ }
+ if (result.length() == 0) result.append(Character.toLowerCase(character));
+ else result.append(upper ? Character.toUpperCase(character) : character);
+ upper = false;
+ }
+ return result.length() == 0 ? "value" : result.toString();
+ }
+
+ public String methodName(String sdkMethod) {
+ return Character.toLowerCase(sdkMethod.charAt(0)) + sdkMethod.substring(1);
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OpenApiGenerator.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OpenApiGenerator.java
index cce77e26..790039ff 100644
--- a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OpenApiGenerator.java
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OpenApiGenerator.java
@@ -16,7 +16,6 @@
package com.coinbase.tools.modelgenerator;
-import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.io.FileUtils;
import org.openapitools.codegen.*;
import org.openapitools.codegen.config.CodegenConfigurator;
@@ -34,11 +33,17 @@ public class OpenApiGenerator {
private final String specLocation;
private final Path outputDir;
- private final ObjectMapper objectMapper = new ObjectMapper();
+ private final Path projectRoot;
public OpenApiGenerator(String specLocation, Path outputDir) {
+ this(specLocation, outputDir, null);
+ }
+
+ /** Allows isolated checks to write outside the repository while still using repository templates. */
+ public OpenApiGenerator(String specLocation, Path outputDir, Path projectRoot) {
this.specLocation = specLocation;
this.outputDir = outputDir;
+ this.projectRoot = projectRoot;
}
public void generateModels() throws IOException {
@@ -141,6 +146,9 @@ public void generateModels() throws IOException {
}
private Path findProjectRoot() {
+ if (projectRoot != null) {
+ return projectRoot;
+ }
Path current = outputDir.getParent();
while (current != null) {
if (current.resolve("pom.xml").toFile().exists() &&
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBinding.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBinding.java
new file mode 100644
index 00000000..7dcfb800
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBinding.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Resolved Java SDK ownership and naming for one OpenAPI operation. */
+public final class OperationBinding {
+ private final String operationId;
+ private final String serviceFolder;
+ private final String serviceName;
+ private final String sdkMethod;
+ private final boolean omitRequest;
+ private final boolean paginated;
+ private final Map parameterTypeOverrides;
+ private final Map propertyNameOverrides;
+ private final Map responseTypeOverrides;
+ private final List convenienceConstructorParameters;
+ private final List deprecatedAliases;
+
+ OperationBinding(
+ String operationId,
+ String serviceFolder,
+ String serviceName,
+ String sdkMethod,
+ boolean omitRequest,
+ boolean paginated,
+ Map parameterTypeOverrides) {
+ this(
+ operationId,
+ serviceFolder,
+ serviceName,
+ sdkMethod,
+ omitRequest,
+ paginated,
+ parameterTypeOverrides,
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyList(),
+ Collections.emptyList());
+ }
+
+ OperationBinding(
+ String operationId,
+ String serviceFolder,
+ String serviceName,
+ String sdkMethod,
+ boolean omitRequest,
+ boolean paginated,
+ Map parameterTypeOverrides,
+ Map propertyNameOverrides,
+ Map responseTypeOverrides,
+ List convenienceConstructorParameters,
+ List deprecatedAliases) {
+ this.operationId = operationId;
+ this.serviceFolder = serviceFolder;
+ this.serviceName = serviceName;
+ this.sdkMethod = sdkMethod;
+ this.omitRequest = omitRequest;
+ this.paginated = paginated;
+ this.parameterTypeOverrides =
+ Collections.unmodifiableMap(new LinkedHashMap<>(parameterTypeOverrides));
+ this.propertyNameOverrides =
+ Collections.unmodifiableMap(new LinkedHashMap<>(propertyNameOverrides));
+ this.responseTypeOverrides =
+ Collections.unmodifiableMap(new LinkedHashMap<>(responseTypeOverrides));
+ this.convenienceConstructorParameters =
+ Collections.unmodifiableList(convenienceConstructorParameters);
+ this.deprecatedAliases = Collections.unmodifiableList(new ArrayList<>(deprecatedAliases));
+ }
+
+ public String operationId() {
+ return operationId;
+ }
+
+ public String serviceFolder() {
+ return serviceFolder;
+ }
+
+ public String serviceName() {
+ return serviceName;
+ }
+
+ public String sdkMethod() {
+ return sdkMethod;
+ }
+
+ public boolean omitRequest() {
+ return omitRequest;
+ }
+
+ public boolean paginated() {
+ return paginated;
+ }
+
+ public Map parameterTypeOverrides() {
+ return parameterTypeOverrides;
+ }
+
+ public Map propertyNameOverrides() {
+ return propertyNameOverrides;
+ }
+
+ public Map responseTypeOverrides() {
+ return responseTypeOverrides;
+ }
+
+ public List convenienceConstructorParameters() {
+ return convenienceConstructorParameters;
+ }
+
+ public List deprecatedAliases() {
+ return deprecatedAliases;
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingGenerator.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingGenerator.java
new file mode 100644
index 00000000..473810e6
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingGenerator.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+
+/** Derives deterministic Java SDK names from parsed OpenAPI operations. */
+public final class OperationBindingGenerator {
+ private static final Map METHOD_RENAMES = new HashMap<>();
+
+ static {
+ METHOD_RENAMES.put("CancelFuturesSweep", "CancelEntityFuturesSweep");
+ METHOD_RENAMES.put("CreateOnchainAddressGroup", "CreateOnchainAddressBookEntry");
+ METHOD_RENAMES.put("CreatePortfolioAddressBookEntry", "CreateAddressBookEntry");
+ METHOD_RENAMES.put("CreateQuoteRequest", "CreateQuote");
+ METHOD_RENAMES.put("GetEntityAssets", "ListAssets");
+ METHOD_RENAMES.put("GetEntityUsers", "ListEntityUsers");
+ METHOD_RENAMES.put("GetFuturesSweeps", "ListEntityFuturesSweeps");
+ METHOD_RENAMES.put("GetLocateAvailabilities", "GetEntityLocateAvailabilities");
+ METHOD_RENAMES.put("GetMarginSummaries", "ListMarginCallSummaries");
+ METHOD_RENAMES.put("GetPortfolioInterestAccruals", "ListInterestAccrualsForPortfolio");
+ METHOD_RENAMES.put("GetPostTradeCredit", "GetPortfolioCreditInformation");
+ METHOD_RENAMES.put("GetTFTieredPricingFees", "GetTradeFinanceTieredPricingFees");
+ METHOD_RENAMES.put("ListTFObligations", "ListTradeFinanceObligations");
+ METHOD_RENAMES.put("OrderPreview", "GetOrderPreview");
+ METHOD_RENAMES.put("ScheduleFuturesSweep", "ScheduleEntityFuturesSweep");
+ METHOD_RENAMES.put("UpdateOnchainAddressGroup", "UpdateOnchainAddressBookEntry");
+ }
+
+ private OperationBindingGenerator() {}
+
+ public static List deriveAll(SpecModels.Document document) {
+ return deriveAll(document, null);
+ }
+
+ public static List deriveAll(
+ SpecModels.Document document, GeneratorConfiguration configuration) {
+ List bindings = new ArrayList<>();
+ Set knownOperations = new HashSet<>();
+ for (SpecModels.Operation operation : document.operations()) {
+ knownOperations.add(operation.operationId());
+ OperationBinding derived = derive(operation);
+ if (configuration != null && !operation.tags().isEmpty()) {
+ String configuredFolder = configuration.tagFolders().get(operation.tags().get(0));
+ if (configuredFolder != null && !configuredFolder.equals(derived.serviceFolder())) {
+ derived = new OperationBinding(derived.operationId(), configuredFolder,
+ canonicalServiceForFolder(document, configuredFolder), derived.sdkMethod(), derived.omitRequest(),
+ derived.paginated(), derived.parameterTypeOverrides());
+ }
+ }
+ GeneratorConfiguration.Override override = configuration == null ? null
+ : configuration.overrides().get(operation.operationId());
+ if (override != null) {
+ Map body =
+ SpecParser.map(dereference(document, operation.requestBodySchema()).get("properties"));
+ validateCompatibilityFields(operation, body, override.parameterTypes().keySet(), "parameter override");
+ validateCompatibilityFields(operation, body, override.propertyNames().keySet(), "property name override");
+ validateCompatibilityFields(
+ operation,
+ body,
+ new HashSet<>(override.convenienceConstructorParameters()),
+ "convenience constructor parameter");
+ derived = applyOverride(derived, override);
+ }
+ bindings.add(derived);
+ }
+ if (configuration != null) {
+ for (String operationId : configuration.overrides().keySet()) {
+ if (!knownOperations.contains(operationId)) {
+ throw new IllegalArgumentException("Unknown operation override: " + operationId);
+ }
+ }
+ }
+ bindings.sort(Comparator.comparing(OperationBinding::operationId));
+ OperationBindingValidator.validate(document, bindings);
+ return Collections.unmodifiableList(bindings);
+ }
+
+ private static OperationBinding applyOverride(
+ OperationBinding binding, GeneratorConfiguration.Override override) {
+ String folder = override.serviceFolder() == null ? binding.serviceFolder() : override.serviceFolder();
+ String service =
+ override.serviceName() == null
+ ? (folder.equals(binding.serviceFolder()) ? binding.serviceName() : folderToService(folder))
+ : override.serviceName();
+ String method = override.sdkMethod() == null ? binding.sdkMethod() : override.sdkMethod();
+ boolean omit = override.omitRequest() == null ? binding.omitRequest() : override.omitRequest();
+ boolean paginated = override.paginated() == null ? binding.paginated() : override.paginated();
+ if (folder.equals(binding.serviceFolder())
+ && service.equals(binding.serviceName())
+ && method.equals(binding.sdkMethod())
+ && omit == binding.omitRequest()
+ && paginated == binding.paginated()
+ && override.parameterTypes().isEmpty()
+ && override.propertyNames().isEmpty()
+ && override.responseTypes().isEmpty()
+ && override.convenienceConstructorParameters().isEmpty()
+ && override.statuses().isEmpty()
+ && override.deprecatedAliases().isEmpty()) {
+ System.err.println("WARN redundant operation override: " + binding.operationId());
+ }
+ return new OperationBinding(
+ binding.operationId(),
+ folder,
+ service,
+ method,
+ omit,
+ paginated,
+ override.parameterTypes().isEmpty()
+ ? binding.parameterTypeOverrides()
+ : override.parameterTypes(),
+ override.propertyNames().isEmpty() ? binding.propertyNameOverrides() : override.propertyNames(),
+ override.responseTypes().isEmpty() ? binding.responseTypeOverrides() : override.responseTypes(),
+ override.convenienceConstructorParameters().isEmpty()
+ ? binding.convenienceConstructorParameters()
+ : override.convenienceConstructorParameters(),
+ override.deprecatedAliases().isEmpty()
+ ? binding.deprecatedAliases()
+ : override.deprecatedAliases());
+ }
+
+ private static Map dereference(
+ SpecModels.Document document, Map schema) {
+ String ref = String.valueOf(schema.getOrDefault("$ref", ""));
+ if (ref.isEmpty()) {
+ return schema;
+ }
+ Map schemas =
+ SpecParser.map(SpecParser.map(document.root().get("components")).get("schemas"));
+ return SpecParser.map(schemas.get(ref.substring(ref.lastIndexOf('/') + 1)));
+ }
+
+ private static void validateCompatibilityFields(
+ SpecModels.Operation operation,
+ Map body,
+ Set fields,
+ String label) {
+ for (String field : fields) {
+ boolean declared = operation.parameters().stream().anyMatch(value -> value.name().equals(field));
+ if (!declared && !body.containsKey(field)) {
+ throw new IllegalArgumentException(
+ "Unknown " + label + " " + field + " for " + operation.operationId());
+ }
+ }
+ }
+
+ private static String folderToService(String folder) {
+ StringBuilder name = new StringBuilder();
+ for (String part : folder.split("[^A-Za-z0-9]+")) {
+ if (!part.isEmpty()) name.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1));
+ }
+ return name.append("Service").toString();
+ }
+
+ static OperationBinding derive(SpecModels.Operation operation) {
+ String tag = operation.tags().isEmpty() ? "Misc" : operation.tags().get(0);
+ String folder = tag.replaceAll("[^A-Za-z0-9]", "").replace(" ", "").toLowerCase(Locale.ROOT);
+ String serviceName = pascal(tag) + "Service";
+ String raw =
+ operation.sdkMethodName().isEmpty()
+ ? operation.operationId()
+ : operation.sdkMethodName();
+ raw = stripOperationPrefix(raw);
+ String method = METHOD_RENAMES.getOrDefault(raw, raw);
+ if (operation.httpMethod().equals("GET") && method.startsWith("Get") && operation.summary().startsWith("List ")) method = "List" + method.substring(3);
+ boolean omitRequest = operation.parameters().isEmpty() && operation.requestBodySchema().isEmpty();
+ boolean paginated = operation.parameters().stream().anyMatch(p -> p.name().equals("cursor") || p.name().equals("sort_direction"));
+ return new OperationBinding(operation.operationId(), folder, serviceName, method, omitRequest, paginated, new LinkedHashMap<>());
+ }
+
+ private static String stripOperationPrefix(String operationName) {
+ return operationName.replaceFirst("^[A-Za-z][A-Za-z0-9]*_", "");
+ }
+
+ private static String canonicalServiceForFolder(SpecModels.Document document, String folder) {
+ for (SpecModels.Operation candidate : document.operations()) {
+ OperationBinding binding = derive(candidate);
+ if (folder.equals(binding.serviceFolder()) && !"Travel Rule".equals(firstTag(candidate))) {
+ return binding.serviceName();
+ }
+ }
+ return folderToService(folder);
+ }
+
+ private static String firstTag(SpecModels.Operation operation) {
+ return operation.tags().isEmpty() ? "Misc" : operation.tags().get(0);
+ }
+
+ private static String pascal(String value) {
+ StringBuilder result = new StringBuilder();
+ for (String part : Arrays.asList(value.replaceAll("[^A-Za-z0-9]+", " ").split(" +"))) {
+ if (!part.isEmpty()) {
+ result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1));
+ }
+ }
+ return result.toString();
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingValidator.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingValidator.java
new file mode 100644
index 00000000..ffe4a192
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingValidator.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Fails early when an operation cannot safely own one Java SDK surface. */
+public final class OperationBindingValidator {
+ private static final Pattern PATH_PARAMETER = Pattern.compile("\\{([^}]+)}");
+
+ private OperationBindingValidator() {}
+
+ public static void validate(SpecModels.Document document, List bindings) {
+ if (document.operations().size() != bindings.size()) {
+ throw new IllegalArgumentException("Every OpenAPI operation must have exactly one binding");
+ }
+ Set operationIds = new HashSet<>();
+ Set serviceMethods = new HashSet<>();
+ Map serviceFolders = new HashMap<>();
+ for (int index = 0; index < document.operations().size(); index++) {
+ SpecModels.Operation operation = document.operations().get(index);
+ OperationBinding binding = bindings.get(index);
+ if (!operationIds.add(binding.operationId())) throw new IllegalArgumentException("Duplicate operation binding: " + binding.operationId());
+ if (!operation.operationId().equals(binding.operationId())) throw new IllegalArgumentException("Bindings must remain operation-ID sorted");
+ if (!serviceMethods.add(binding.serviceFolder() + ":" + binding.sdkMethod())) {
+ throw new IllegalArgumentException("Duplicate Java service method: " + binding.serviceFolder() + ":" + binding.sdkMethod());
+ }
+ String existingFolder = serviceFolders.putIfAbsent(binding.serviceName(), binding.serviceFolder());
+ if (existingFolder != null && !existingFolder.equals(binding.serviceFolder())) {
+ throw new IllegalArgumentException(
+ "Java service " + binding.serviceName() + " is bound to both " + existingFolder + " and " + binding.serviceFolder());
+ }
+ Set parameterNames = new HashSet<>();
+ for (SpecModels.Parameter parameter : operation.parameters()) parameterNames.add(parameter.name());
+ Matcher matcher = PATH_PARAMETER.matcher(operation.path());
+ while (matcher.find()) {
+ if (!parameterNames.contains(matcher.group(1))) {
+ throw new IllegalArgumentException(operation.operationId() + " is missing path parameter " + matcher.group(1));
+ }
+ }
+ for (DeprecatedAlias alias : binding.deprecatedAliases()) {
+ if (alias.sdkMethod().equals(binding.sdkMethod())) {
+ throw new IllegalArgumentException(
+ "Deprecated alias must differ from the canonical Java method: " + binding.operationId());
+ }
+ if (!serviceMethods.add(binding.serviceFolder() + ":" + alias.sdkMethod())) {
+ throw new IllegalArgumentException(
+ "Duplicate Java service method: " + binding.serviceFolder() + ":" + alias.sdkMethod());
+ }
+ }
+ }
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/PostProcessor.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/PostProcessor.java
index 18651b4e..b7c7a744 100644
--- a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/PostProcessor.java
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/PostProcessor.java
@@ -26,7 +26,6 @@
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import java.util.stream.Collectors;
public class PostProcessor {
private static final Logger logger = LoggerFactory.getLogger(PostProcessor.class);
@@ -50,6 +49,9 @@ public class PostProcessor {
private final Path tempDir;
private final Path outputDir;
private final Path enumsDir;
+ private final Path errorsDir;
+ private final Path sourceRoot;
+ private final Path manifest;
private final EnumJavadocEnhancer enumJavadocEnhancer;
private final ModelJavadocEnhancer modelJavadocEnhancer;
@@ -58,12 +60,23 @@ public class PostProcessor {
private int removedStaleCount = 0;
private final Set writtenOutputFiles = new HashSet<>();
- public PostProcessor(Path tempDir, Path outputDir, Path enumsDir, Path specPath) throws IOException {
+ public PostProcessor(Path tempDir, Path outputDir, Path enumsDir, Path errorsDir, Path specPath) throws IOException {
+ this(tempDir, null, outputDir, enumsDir, errorsDir, specPath, null);
+ }
+
+ public PostProcessor(Path tempDir, Path sourceRoot, Path outputDir, Path enumsDir, Path errorsDir,
+ Path specPath, Path manifest) throws IOException {
this.tempDir = tempDir;
+ this.sourceRoot = sourceRoot == null ? null : sourceRoot.toAbsolutePath().normalize();
this.outputDir = outputDir;
this.enumsDir = enumsDir;
+ this.errorsDir = errorsDir;
+ this.manifest = manifest;
this.enumJavadocEnhancer = EnumJavadocEnhancer.load(specPath);
this.modelJavadocEnhancer = ModelJavadocEnhancer.load(specPath);
+ if (manifest != null && this.sourceRoot == null) {
+ throw new IllegalArgumentException("Model generation manifest requires a source root");
+ }
}
/** Maps an OpenAPI schema name to the SDK Java enum type name (prefix strip + acronym rules). */
@@ -94,6 +107,7 @@ public void processModels() throws IOException {
// Create output directories
Files.createDirectories(outputDir);
Files.createDirectories(enumsDir);
+ Files.createDirectories(errorsDir);
// Separate enum files from model files
List enumFiles = new ArrayList<>();
@@ -109,35 +123,24 @@ public void processModels() throws IOException {
logger.info("Found {} enum files and {} model files", enumFiles.size(), nonEnumFiles.size());
- // Process enums FIRST so they're available for import fixing
+ // Process enums first so they are available for import fixing. Do not clean stale output
+ // unless every source was processed successfully.
logger.info("Processing enums first...");
for (Path file : enumFiles) {
- String fileName = file.getFileName().toString();
- logger.info("Processing enum: {}", fileName);
-
- try {
- processEnumFile(file);
- } catch (Exception e) {
- logger.error("Error processing enum file: " + fileName, e);
- }
+ logger.info("Processing enum: {}", file.getFileName());
+ processEnumFile(file);
}
createEnumAliases();
- // Then process models with fixed enum imports
logger.info("Processing models...");
for (Path file : nonEnumFiles) {
- String fileName = file.getFileName().toString();
- logger.info("Processing model: {}", fileName);
-
- try {
- processModelFile(file);
- } catch (Exception e) {
- logger.error("Error processing model file: " + fileName, e);
- }
+ logger.info("Processing model: {}", file.getFileName());
+ processModelFile(file);
}
removeStaleOutputFiles();
+ writeModelManifest();
// Clean up temporary directory
logger.info("Cleaning up temporary files...");
@@ -153,53 +156,59 @@ public void processModels() throws IOException {
logger.info("==========================================");
}
- /**
- * Deletes model and enum files under the output directories that were not produced in this run.
- */
+ /** Deletes only files explicitly owned by the previous successful model generation. */
private void removeStaleOutputFiles() throws IOException {
removedStaleCount = 0;
- if (Files.exists(outputDir)) {
- try (DirectoryStream stream = Files.newDirectoryStream(outputDir, "*.java")) {
- for (Path file : stream) {
- removedStaleCount += deleteIfStale(file);
- }
- }
+ if (manifest == null) {
+ return;
}
- if (Files.exists(enumsDir)) {
- try (DirectoryStream stream = Files.newDirectoryStream(enumsDir, "*.java")) {
- for (Path file : stream) {
- removedStaleCount += deleteIfStale(file);
- }
+ for (String entry : GeneratedSourceReconciler.manifestEntries(manifest)) {
+ Path file = sourceRoot.resolve(entry).normalize();
+ if (!file.startsWith(sourceRoot)) {
+ throw new IOException("Model manifest contains unsafe path: " + entry);
+ }
+ if (!writtenOutputFiles.contains(file) && Files.deleteIfExists(file)) {
+ removedStaleCount++;
+ logger.info("Deleted stale generated model/enum file: {}", entry);
}
}
if (removedStaleCount > 0) {
- logger.info("Removed {} stale model/enum file(s) not produced by this generation run", removedStaleCount);
+ logger.info("Removed {} stale model/enum file(s) owned by the generation manifest", removedStaleCount);
}
}
- private int deleteIfStale(Path file) throws IOException {
- Path normalized = file.toAbsolutePath().normalize();
- if (writtenOutputFiles.contains(normalized)) {
- return 0;
+ private void writeModelManifest() throws IOException {
+ if (manifest == null) {
+ return;
}
- Files.delete(file);
- logger.info("Deleted stale file: {}", file.getFileName());
- return 1;
+ Set generated = new HashSet<>();
+ for (Path file : writtenOutputFiles) {
+ if (!file.startsWith(sourceRoot)) {
+ throw new IOException("Generated model was written outside the configured source root: " + file);
+ }
+ generated.add(sourceRoot.relativize(file));
+ }
+ GeneratedSourceReconciler.writeManifest(manifest, generated);
}
private List findGeneratedModelFiles() throws IOException {
List files = new ArrayList<>();
Path modelPath = tempDir.resolve("raw");
- if (Files.exists(modelPath)) {
+ if (!Files.isDirectory(modelPath)) {
+ throw new IOException("OpenAPI model output was not produced: " + modelPath);
+ }
+ {
Files.walkFileTree(modelPath, new SimpleFileVisitor() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
String fileName = file.getFileName().toString();
- if (file.toString().endsWith(".java") &&
- !fileName.contains("Test") &&
- !fileName.matches(".*Api\\.java$") && // Only skip files ending with "Api.java", not containing "Api"
- !isSkippedErrorSchema(fileName)) {
+ if (file.toString().endsWith(".java")
+ && !fileName.contains("Test")
+ && !fileName.matches(".*Api\\.java$")
+ && !fileName.endsWith("Request.java")
+ && !fileName.endsWith("Response.java")
+ && !isSkippedErrorSchema(fileName)) {
files.add(file);
}
return FileVisitResult.CONTINUE;
@@ -210,15 +219,9 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
return files;
}
- /**
- * Typed error-code / subcode schemas from the spec are omitted until the SDK
- * maps HTTP errors onto them. Matching files are also ignored by
- * {@code .openapi-generator-ignore} so they are not copied into {@code model/enums}.
- */
+ /** Returns schemas that must not enter the shared model namespace. */
static boolean isSkippedErrorSchema(String fileName) {
- return fileName.endsWith("ErrorCode.java")
- || fileName.endsWith("Subcode.java")
- || fileName.endsWith("ErrorResponse.java");
+ return fileName.endsWith("ErrorResponse.java");
}
private boolean isEnumFile(Path file) throws IOException {
@@ -227,52 +230,54 @@ private boolean isEnumFile(Path file) throws IOException {
}
/**
- * Fixes enum imports to use the enums package and applies special case enum name mappings.
- * Handles both import statements and type references throughout the content.
+ * Fixes enum imports to use their generated package and applies special case enum name mappings.
+ * Handles both domain enums and OpenAPI error Subcode enums.
*/
private String fixEnumImports(String content) {
- // Get list of all actual enum names from enums directory
- Set actualEnumNames = new HashSet<>();
- try {
- Files.walk(enumsDir, 1)
- .filter(p -> p.toString().endsWith(".java"))
- .forEach(p -> {
- String fileName = p.getFileName().toString();
- actualEnumNames.add(fileName.replace(".java", ""));
- });
- } catch (Exception e) {
- logger.warn("Could not read enums directory: {}", e.getMessage());
- }
-
- // First, apply special case enum name mappings (e.g., ActivityType -> PrimeActivityType)
- // This must happen BEFORE fixing import paths
+ Map enumPackages = collectEnumPackages();
+
+ // First, apply special case enum name mappings (e.g., ActivityType -> PrimeActivityType).
+ // This must happen before fixing import paths.
for (Map.Entry mapping : ENUM_NAME_MAPPINGS.entrySet()) {
String strippedName = mapping.getKey();
String actualEnumName = mapping.getValue();
-
- // Only apply mapping if the actual enum exists
- if (actualEnumNames.contains(actualEnumName)) {
- // Replace type references (but not in @JsonProperty annotations)
- // Pattern: word boundary + strippedName + word boundary (not followed by quotes)
+ if (enumPackages.containsKey(actualEnumName)) {
content = content.replaceAll(
- "\\b" + strippedName + "\\b(?![^@]*@JsonProperty)",
- actualEnumName
- );
+ "\\b" + strippedName + "\\b(?![^@]*@JsonProperty)", actualEnumName);
logger.debug("Applied enum mapping: {} -> {}", strippedName, actualEnumName);
}
}
- // Then, fix import paths for all enums (move from model to model.enums package)
- for (String enumName : actualEnumNames) {
- content = content.replace(
- "import com.coinbase.prime.model." + enumName + ";",
- "import com.coinbase.prime.model.enums." + enumName + ";"
- );
+ for (Map.Entry enumPackage : enumPackages.entrySet()) {
+ String enumName = enumPackage.getKey();
+ String targetImport = "import " + enumPackage.getValue() + "." + enumName + ";";
+ content = content.replace("import com.coinbase.prime.model." + enumName + ";", targetImport);
+ content = content.replace("import com.coinbase.prime.model.enums." + enumName + ";", targetImport);
}
return content;
}
+ private Map collectEnumPackages() {
+ Map enumPackages = new HashMap<>();
+ collectEnumPackages(enumsDir, GeneratedEnumKind.ENUMS_PACKAGE, enumPackages);
+ collectEnumPackages(errorsDir, GeneratedEnumKind.ERRORS_PACKAGE, enumPackages);
+ return enumPackages;
+ }
+
+ private void collectEnumPackages(Path directory, String packageName, Map enumPackages) {
+ if (!Files.isDirectory(directory)) {
+ return;
+ }
+ try (java.util.stream.Stream files = Files.walk(directory, 1)) {
+ files.filter(path -> path.toString().endsWith(".java"))
+ .forEach(path -> enumPackages.put(path.getFileName().toString().replace(".java", ""), packageName));
+ } catch (IOException e) {
+ // Partial enum maps would emit wrong imports; fail generation instead.
+ throw new java.io.UncheckedIOException("Could not read enum directory " + directory, e);
+ }
+ }
+
private void processEnumFile(Path file) throws IOException {
processFile(file, enumsDir, true);
}
@@ -306,7 +311,7 @@ private void processModelFile(Path file) throws IOException {
/**
* Unified file processing logic for both enums and models.
* @param file Source file to process
- * @param targetDir Target directory (enumsDir for enums, outputDir for models)
+ * @param targetDir Target directory for models; enum output is selected from the final type name
* @param isEnum Whether this is an enum file (affects package name and preserves SCREAMING_SNAKE_CASE)
*/
private void processFile(Path file, Path targetDir, boolean isEnum) throws IOException {
@@ -351,17 +356,17 @@ private void processFile(Path file, Path targetDir, boolean isEnum) throws IOExc
logger.info("Transformed {} filename: {} -> {}", isEnum ? "enum" : "model", originalFileName, fileName);
}
- Path outputPath = targetDir.resolve(fileName);
+ Path outputDirectory = isEnum && GeneratedEnumKind.isErrorEnum(className) ? errorsDir : targetDir;
+ Path outputPath = outputDirectory.resolve(fileName);
// Read copyright year before deleting case-variant paths (TS getHeaderYear parity)
String copyrightYear = GeneratedFileHeader.resolveStartYear(outputPath);
boolean existsBefore = Files.exists(outputPath);
- deleteCaseVariantFiles(targetDir, fileName, isEnum);
-
// Apply final transformations based on file type
if (isEnum) {
- // Fix package for enums
- content = content.replace("package com.coinbase.prime.model;", "package com.coinbase.prime.model.enums;");
+ String enumPackage = GeneratedEnumKind.packageFor(className);
+ content = content.replace("package com.coinbase.prime.model;", "package " + enumPackage + ";");
+ content = content.replace("package com.coinbase.prime.model.enums;", "package " + enumPackage + ";");
content = enumJavadocEnhancer.apply(content, className);
} else {
// Fix enum imports for models
@@ -386,8 +391,8 @@ private void processFile(Path file, Path targetDir, boolean isEnum) throws IOExc
if (!resolvedClassName.equals(className)) {
className = resolvedClassName;
fileName = className + ".java";
- outputPath = targetDir.resolve(fileName);
- deleteCaseVariantFiles(targetDir, fileName, isEnum);
+ outputDirectory = isEnum && GeneratedEnumKind.isErrorEnum(className) ? errorsDir : targetDir;
+ outputPath = outputDirectory.resolve(fileName);
}
Files.writeString(outputPath, content);
@@ -672,24 +677,6 @@ private String applyXMAcronymCasing(String content) {
return content.replaceAll("Xm([A-Z])", "XM$1");
}
- private void deleteCaseVariantFiles(Path targetDir, String fileName, boolean isEnum) throws IOException {
- try {
- List toDelete = Files.list(targetDir)
- .filter(p -> p.getFileName().toString().equalsIgnoreCase(fileName))
- .collect(Collectors.toList());
-
- for (Path p : toDelete) {
- Files.delete(p);
- if (!p.getFileName().toString().equals(fileName)) {
- logger.info("Deleted old {} file with different casing: {} -> {}",
- isEnum ? "enum" : "model", p.getFileName(), fileName);
- }
- }
- } catch (IOException e) {
- logger.warn("Could not delete old {} file variants: {}", isEnum ? "enum" : "model", e.getMessage());
- }
- }
-
private String applyDateOfBirthPrimitiveConversion(String content) {
content = content.replaceAll("\\bprivate Integer (year|month|day)\\b", "private int $1");
content = content.replaceAll("\\bpublic Integer (getYear|getMonth|getDay)\\b", "public int $1");
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/RequestPhase.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/RequestPhase.java
new file mode 100644
index 00000000..8964dd1d
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/RequestPhase.java
@@ -0,0 +1,299 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Emits mutable, SDK-style per-operation request DTOs. */
+public final class RequestPhase {
+ private RequestPhase() {}
+
+ public static Map render(SpecModels.Document document, List bindings,
+ JavaTypeResolver types, NamingResolver names) {
+ Map operations = byId(document);
+ Map sources = new LinkedHashMap<>();
+ for (OperationBinding binding : bindings) {
+ if (binding.omitRequest()) continue;
+ SpecModels.Operation operation = operations.get(binding.operationId());
+ String className = binding.sdkMethod() + "Request";
+ sources.put(path(binding, className), renderOne(operation, binding, className, types, names));
+ for (DeprecatedAlias alias : binding.deprecatedAliases()) {
+ String aliasClassName = alias.sdkMethod() + "Request";
+ sources.put(
+ path(binding, aliasClassName),
+ renderAliasOne(operation, binding, className, aliasClassName, alias, types, names));
+ }
+ }
+ return sources;
+ }
+
+ private static String renderOne(SpecModels.Operation operation, OperationBinding binding, String className,
+ JavaTypeResolver types, NamingResolver names) {
+ List fields = fields(operation, binding, types, names);
+ Set imports = new LinkedHashSet<>();
+ imports.add("com.fasterxml.jackson.annotation.JsonProperty");
+ boolean hasPath = fields.stream().anyMatch(field -> field.path);
+ if (hasPath) imports.add("com.fasterxml.jackson.annotation.JsonIgnore");
+ boolean validates = fields.stream().anyMatch(field -> field.path && field.required && field.type.name().equals("String"));
+ if (validates) { imports.add("com.coinbase.core.errors.CoinbaseClientException"); imports.add("static com.coinbase.core.utils.Utils.isNullOrEmpty"); }
+ if (binding.paginated()) {
+ imports.add("com.coinbase.prime.common.Pagination");
+ imports.add("com.coinbase.prime.common.PrimeListRequest");
+ imports.add("com.coinbase.prime.model.enums.SortDirection");
+ }
+ for (Field field : fields) imports.addAll(field.type.imports());
+ StringBuilder source = new StringBuilder(SourceTemplates.header())
+ .append("package com.coinbase.prime.").append(binding.serviceFolder()).append(";\n\n");
+ SourceTemplates.imports(source, imports);
+ SourceTemplates.javadoc(source, SourceTemplates.documentation(operation.summary(), operation.description()));
+ source.append("public class ").append(className);
+ if (binding.paginated()) source.append(" extends PrimeListRequest");
+ source.append(" {\n");
+ for (Field field : fields) {
+ SourceTemplates.javadoc(source, field.description);
+ source.append(" @JsonProperty(");
+ if (field.required) source.append("required = true, ");
+ source.append("value = \"").append(field.wireName).append("\")\n");
+ if (field.path) source.append(" @JsonIgnore\n");
+ source.append(" private ").append(field.type.name()).append(" ").append(field.name).append(";\n\n");
+ }
+ source.append(" public ").append(className).append("() {}\n\n");
+ appendConvenienceConstructor(source, className, fields, binding.convenienceConstructorParameters());
+ source.append(" public ").append(className).append("(Builder builder) {\n");
+ if (binding.paginated()) {
+ source.append(" super(builder.cursor, builder.sortDirection, builder.limit);\n");
+ }
+ for (Field field : fields) source.append(" this.").append(field.name).append(" = builder.").append(field.name).append(";\n");
+ source.append(" }\n\n");
+ for (Field field : fields) {
+ appendAccessors(source, field);
+ }
+ source.append(" public static class Builder {\n");
+ if (binding.paginated()) {
+ source.append(" private String cursor;\n")
+ .append(" private SortDirection sortDirection;\n")
+ .append(" private Integer limit;\n");
+ }
+ for (Field field : fields) source.append(" private ").append(field.type.name()).append(" ").append(field.name).append(";\n");
+ source.append("\n public Builder() {}\n\n");
+ if (binding.paginated()) {
+ source.append(" public Builder cursor(String cursor) {\n this.cursor = cursor;\n return this;\n }\n\n")
+ .append(" public Builder sortDirection(SortDirection sortDirection) {\n this.sortDirection = sortDirection;\n return this;\n }\n\n")
+ .append(" public Builder limit(Integer limit) {\n this.limit = limit;\n return this;\n }\n\n")
+ .append(" public Builder pagination(Pagination pagination) {\n this.cursor = pagination.getNextCursor();\n this.sortDirection = pagination.getSortDirection();\n return this;\n }\n\n");
+ }
+ for (Field field : fields) source.append(" public Builder ").append(field.name).append("(").append(field.type.name()).append(" ").append(field.name).append(") {\n this.").append(field.name).append(" = ").append(field.name).append(";\n return this;\n }\n\n");
+ source.append(" public ").append(className).append(" build()");
+ if (validates) source.append(" throws CoinbaseClientException");
+ source.append(" {\n");
+ if (validates) source.append(" validate();\n");
+ source.append(" return new ").append(className).append("(this);\n }\n");
+ if (validates) {
+ source.append("\n protected void validate() throws CoinbaseClientException {\n");
+ for (Field field : fields) if (field.path && field.required && field.type.name().equals("String"))
+ source.append(" if (isNullOrEmpty(this.").append(field.name).append(")) {\n throw new CoinbaseClientException(\"").append(SourceTemplates.cap(field.name)).append(" is required\");\n }\n");
+ source.append(" }\n");
+ }
+ return source.append(" }\n}\n").toString();
+ }
+
+ private static String renderAliasOne(
+ SpecModels.Operation operation,
+ OperationBinding binding,
+ String canonicalClassName,
+ String aliasClassName,
+ DeprecatedAlias alias,
+ JavaTypeResolver types,
+ NamingResolver names) {
+ List fields = fields(operation, binding, types, names);
+ boolean validates =
+ fields.stream()
+ .anyMatch(field -> field.path && field.required && field.type.name().equals("String"));
+ Set imports = new LinkedHashSet<>();
+ if (validates) imports.add("com.coinbase.core.errors.CoinbaseClientException");
+ if (binding.paginated()) {
+ imports.add("com.coinbase.prime.common.Pagination");
+ imports.add("com.coinbase.prime.model.enums.SortDirection");
+ }
+ for (Field field : fields) imports.addAll(field.type.imports());
+ StringBuilder source =
+ new StringBuilder(SourceTemplates.header())
+ .append("package com.coinbase.prime.").append(binding.serviceFolder()).append(";\n\n");
+ SourceTemplates.imports(source, imports);
+ String documentation = SourceTemplates.documentation(operation.summary(), operation.description());
+ source.append("/**\n");
+ if (!documentation.isEmpty()) {
+ source.append(" * ").append(documentation.replace("*/", "* /").replace("\n", " ")).append("\n");
+ }
+ source.append(" *\n * @deprecated ").append(alias.message()).append("\n */\n");
+ source.append("@Deprecated\n");
+ source.append("public class ").append(aliasClassName).append(" extends ").append(canonicalClassName).append(" {\n");
+ source.append(" public ").append(aliasClassName).append("() {}\n\n");
+ appendAliasConvenienceConstructor(source, aliasClassName, fields, binding.convenienceConstructorParameters());
+ source.append(" public ").append(aliasClassName).append("(").append(canonicalClassName).append(".Builder builder) {\n super(builder);\n }\n\n");
+ source.append(" /** @deprecated ").append(alias.message()).append(" */\n");
+ source.append(" @Deprecated\n");
+ source.append(" public static class Builder extends ").append(canonicalClassName).append(".Builder {\n");
+ if (binding.paginated()) {
+ source
+ .append(" @Override\n public Builder cursor(String cursor) {\n super.cursor(cursor);\n return this;\n }\n\n")
+ .append(" @Override\n public Builder sortDirection(SortDirection sortDirection) {\n super.sortDirection(sortDirection);\n return this;\n }\n\n")
+ .append(" @Override\n public Builder limit(Integer limit) {\n super.limit(limit);\n return this;\n }\n\n")
+ .append(" @Override\n public Builder pagination(Pagination pagination) {\n super.pagination(pagination);\n return this;\n }\n\n");
+ }
+ for (Field field : fields) {
+ source
+ .append(" @Override\n public Builder ").append(field.name).append("(").append(field.type.name()).append(" ").append(field.name).append(") {\n super.").append(field.name).append("(").append(field.name).append(");\n return this;\n }\n\n");
+ }
+ source.append(" @Override\n public ").append(aliasClassName).append(" build()");
+ if (validates) source.append(" throws CoinbaseClientException");
+ source.append(" {\n");
+ if (validates) source.append(" super.validate();\n");
+ source.append(" return new ").append(aliasClassName).append("(this);\n }\n");
+ return source.append(" }\n}\n").toString();
+ }
+
+ private static void appendAliasConvenienceConstructor(
+ StringBuilder source, String aliasClassName, List fields, List parameterNames) {
+ if (parameterNames.isEmpty()) {
+ return;
+ }
+ Map fieldsByWireName = new LinkedHashMap<>();
+ for (Field field : fields) {
+ fieldsByWireName.put(field.wireName, field);
+ }
+ source.append(" public ").append(aliasClassName).append("(");
+ for (int index = 0; index < parameterNames.size(); index++) {
+ if (index > 0) {
+ source.append(", ");
+ }
+ Field field = fieldsByWireName.get(parameterNames.get(index));
+ source.append(field.type.name()).append(" ").append(field.name);
+ }
+ source.append(") {\n super();\n");
+ for (String parameterName : parameterNames) {
+ Field field = fieldsByWireName.get(parameterName);
+ source.append(" this.set").append(SourceTemplates.cap(field.name)).append("(").append(field.name).append(");\n");
+ }
+ source.append(" }\n\n");
+ }
+
+ static List fields(SpecModels.Operation operation, OperationBinding binding, JavaTypeResolver types, NamingResolver names) {
+ Map fields = new LinkedHashMap<>();
+ for (SpecModels.Parameter parameter : operation.parameters()) {
+ if ("header".equals(parameter.location()) || "cookie".equals(parameter.location())) {
+ continue;
+ }
+ if (binding.paginated()
+ && (parameter.name().equals("cursor")
+ || parameter.name().equals("limit")
+ || parameter.name().equals("sort_direction"))) continue;
+ JavaTypeResolver.Type type =
+ binding.parameterTypeOverrides().containsKey(parameter.name())
+ ? types.configured(binding.parameterTypeOverrides().get(parameter.name()))
+ : types.resolve(parameter.schema());
+ fields.put(
+ parameter.name(),
+ new Field(
+ parameter.name(),
+ propertyName(binding, parameter.name(), names),
+ type,
+ parameter.required(),
+ "path".equals(parameter.location()),
+ parameter.description()));
+ }
+ Map body = types.dereference(operation.requestBodySchema());
+ Map required = new LinkedHashMap<>();
+ Object requiredValue = body.get("required");
+ if (requiredValue instanceof List) for (Object value : (List>) requiredValue) required.put(String.valueOf(value), Boolean.TRUE);
+ for (Map.Entry property : SpecParser.map(body.get("properties")).entrySet()) {
+ if (!fields.containsKey(property.getKey()))
+ fields.put(
+ property.getKey(),
+ new Field(
+ property.getKey(),
+ propertyName(binding, property.getKey(), names),
+ binding.parameterTypeOverrides().containsKey(property.getKey())
+ ? types.configured(binding.parameterTypeOverrides().get(property.getKey()))
+ : types.resolve(SpecParser.map(property.getValue())),
+ required.containsKey(property.getKey()),
+ false,
+ description(SpecParser.map(property.getValue()))));
+ }
+ return new ArrayList<>(fields.values());
+ }
+ private static void appendAccessors(StringBuilder source, Field field) {
+ String cap = SourceTemplates.cap(field.name);
+ boolean booleanIsPrefix = field.type.name().equals("boolean") && field.name.startsWith("is")
+ && field.name.length() > 2 && Character.isUpperCase(field.name.charAt(2));
+ String getter = booleanIsPrefix ? field.name : "get" + cap;
+ String setter = booleanIsPrefix ? "set" + field.name.substring(2) : "set" + cap;
+ source.append(" public ").append(field.type.name()).append(" ").append(getter).append("() {\n return ")
+ .append(field.name).append(";\n }\n\n")
+ .append(" public void ").append(setter).append("(").append(field.type.name()).append(" ")
+ .append(field.name).append(") {\n this.").append(field.name).append(" = ")
+ .append(field.name).append(";\n }\n\n");
+ }
+
+ private static String description(Map schema) {
+ Object value = schema.containsKey("description") ? schema.get("description") : schema.get("title");
+ return value == null ? "" : String.valueOf(value);
+ }
+
+ private static void appendConvenienceConstructor(
+ StringBuilder source,
+ String className,
+ List fields,
+ List parameterNames) {
+ if (parameterNames.isEmpty()) {
+ return;
+ }
+ Map fieldsByWireName = new LinkedHashMap<>();
+ for (Field field : fields) {
+ fieldsByWireName.put(field.wireName, field);
+ }
+ source.append(" public ").append(className).append("(");
+ for (int index = 0; index < parameterNames.size(); index++) {
+ if (index > 0) {
+ source.append(", ");
+ }
+ Field field = fieldsByWireName.get(parameterNames.get(index));
+ source.append(field.type.name()).append(" ").append(field.name);
+ }
+ source.append(") {\n");
+ for (String parameterName : parameterNames) {
+ Field field = fieldsByWireName.get(parameterName);
+ source.append(" this.").append(field.name).append(" = ").append(field.name).append(";\n");
+ }
+ source.append(" }\n\n");
+ }
+
+ private static String propertyName(
+ OperationBinding binding, String wireName, NamingResolver names) {
+ return binding.propertyNameOverrides().getOrDefault(wireName, names.propertyName(wireName));
+ }
+
+ private static Path path(OperationBinding binding, String className) { return Path.of("com/coinbase/prime/" + binding.serviceFolder() + "/" + className + ".java"); }
+ private static Map byId(SpecModels.Document document) { Map result = new LinkedHashMap<>(); for (SpecModels.Operation operation : document.operations()) result.put(operation.operationId(), operation); return result; }
+ static final class Field { final String wireName, name, description; final JavaTypeResolver.Type type; final boolean required, path; Field(String wireName, String name, JavaTypeResolver.Type type, boolean required, boolean path, String description) { this.wireName=wireName; this.name=name; this.type=type; this.required=required; this.path=path; this.description=description; } }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ResponsePhase.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ResponsePhase.java
new file mode 100644
index 00000000..9f80ee28
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ResponsePhase.java
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Emits mutable response wrapper DTOs from the operation's deterministic successful schema. */
+public final class ResponsePhase {
+ private ResponsePhase() {}
+
+ public static Map render(
+ SpecModels.Document document,
+ List bindings,
+ JavaTypeResolver types,
+ NamingResolver names) {
+ Map operations = new LinkedHashMap<>();
+ for (SpecModels.Operation operation : document.operations()) {
+ operations.put(operation.operationId(), operation);
+ }
+ Map sources = new LinkedHashMap<>();
+ for (OperationBinding binding : bindings) {
+ String name = binding.sdkMethod() + "Response";
+ sources.put(
+ Path.of("com/coinbase/prime/" + binding.serviceFolder() + "/" + name + ".java"),
+ renderOne(operations.get(binding.operationId()), name, binding, types, names));
+ for (DeprecatedAlias alias : binding.deprecatedAliases()) {
+ String aliasName = alias.sdkMethod() + "Response";
+ sources.put(
+ Path.of("com/coinbase/prime/" + binding.serviceFolder() + "/" + aliasName + ".java"),
+ renderAliasOne(operations.get(binding.operationId()), binding, name, aliasName, alias));
+ }
+ }
+ return sources;
+ }
+
+ private static String renderOne(
+ SpecModels.Operation operation,
+ String className,
+ OperationBinding binding,
+ JavaTypeResolver types,
+ NamingResolver names) {
+ Map schema = types.dereference(operation.successResponseSchema());
+ Map properties = SpecParser.map(schema.get("properties"));
+ Map fields = new LinkedHashMap<>();
+ for (Map.Entry property : properties.entrySet()) {
+ Map propertySchema = SpecParser.map(property.getValue());
+ fields.put(
+ property.getKey(),
+ new Field(
+ property.getKey(),
+ names.propertyName(property.getKey()),
+ type(binding, property.getKey(), propertySchema, types),
+ description(propertySchema)));
+ }
+ if (fields.isEmpty() && !schema.isEmpty()) {
+ fields.put("value", new Field("value", "value", types.resolve(operation.successResponseSchema()), ""));
+ }
+
+ Set imports = new LinkedHashSet<>();
+ if (!fields.isEmpty()) {
+ imports.add("com.fasterxml.jackson.annotation.JsonProperty");
+ }
+ for (Field field : fields.values()) {
+ imports.addAll(field.type.imports());
+ }
+ StringBuilder source =
+ new StringBuilder(SourceTemplates.header())
+ .append("package com.coinbase.prime.")
+ .append(binding.serviceFolder())
+ .append(";\n\n");
+ SourceTemplates.imports(source, imports);
+ SourceTemplates.javadoc(
+ source, SourceTemplates.documentation(operation.summary(), operation.description()));
+ source.append("public class ").append(className).append(" {\n");
+ for (Field field : fields.values()) {
+ SourceTemplates.javadoc(source, field.description);
+ appendField(source, field);
+ }
+ source.append(" public ").append(className).append("() {}\n\n");
+ for (Field field : fields.values()) {
+ appendAccessors(source, field);
+ }
+ return source.append("}\n").toString();
+ }
+
+ private static String renderAliasOne(
+ SpecModels.Operation operation,
+ OperationBinding binding,
+ String canonicalClassName,
+ String aliasClassName,
+ DeprecatedAlias alias) {
+ StringBuilder source =
+ new StringBuilder(SourceTemplates.header())
+ .append("package com.coinbase.prime.")
+ .append(binding.serviceFolder())
+ .append(";\n\n");
+ String documentation = SourceTemplates.documentation(operation.summary(), operation.description());
+ source.append("/**\n");
+ if (!documentation.isEmpty()) {
+ source.append(" * ").append(documentation.replace("*/", "* /").replace("\n", " ")).append("\n");
+ }
+ source.append(" *\n * @deprecated ").append(alias.message()).append("\n */\n");
+ source.append("@Deprecated\n");
+ source
+ .append("public class ")
+ .append(aliasClassName)
+ .append(" extends ")
+ .append(canonicalClassName)
+ .append(" {\n");
+ source.append(" public ").append(aliasClassName).append("() {}\n");
+ return source.append("}\n").toString();
+ }
+
+ private static JavaTypeResolver.Type type(
+ OperationBinding binding,
+ String wireName,
+ Map schema,
+ JavaTypeResolver types) {
+ return binding.responseTypeOverrides().containsKey(wireName)
+ ? types.configured(binding.responseTypeOverrides().get(wireName))
+ : types.resolve(schema);
+ }
+
+ private static String description(Map schema) {
+ Object value = schema.containsKey("description") ? schema.get("description") : schema.get("title");
+ return value == null ? "" : String.valueOf(value);
+ }
+
+ private static void appendField(StringBuilder source, Field field) {
+ source
+ .append(" @JsonProperty(\"")
+ .append(field.wireName)
+ .append("\")\n private ")
+ .append(field.type.name())
+ .append(" ")
+ .append(field.name)
+ .append(";\n\n");
+ }
+
+ private static void appendAccessors(StringBuilder source, Field field) {
+ String cap = SourceTemplates.cap(field.name);
+ source
+ .append(" public ")
+ .append(field.type.name())
+ .append(" get")
+ .append(cap)
+ .append("() {\n return ")
+ .append(field.name)
+ .append(";\n }\n\n public void set")
+ .append(cap)
+ .append("(")
+ .append(field.type.name())
+ .append(" ")
+ .append(field.name)
+ .append(") {\n this.")
+ .append(field.name)
+ .append(" = ")
+ .append(field.name)
+ .append(";\n }\n\n");
+ }
+
+ private static final class Field {
+ private final String wireName;
+ private final String name;
+ private final JavaTypeResolver.Type type;
+ private final String description;
+
+ Field(String wireName, String name, JavaTypeResolver.Type type, String description) {
+ this.wireName = wireName;
+ this.name = name;
+ this.type = type;
+ this.description = description;
+ }
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ServicePhase.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ServicePhase.java
new file mode 100644
index 00000000..b76bd64b
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ServicePhase.java
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Emits Prime synchronous service interfaces and transport-backed implementations. */
+public final class ServicePhase {
+ private ServicePhase() {}
+ public static Map render(SpecModels.Document document, List bindings,
+ GeneratorConfiguration configuration, NamingResolver names) {
+ Map operations = new LinkedHashMap<>();
+ for (SpecModels.Operation operation : document.operations()) operations.put(operation.operationId(), operation);
+ Map> groups = new LinkedHashMap<>();
+ for (OperationBinding binding : bindings) groups.computeIfAbsent(binding.serviceFolder(), ignored -> new ArrayList<>()).add(binding);
+ Map sources = new LinkedHashMap<>();
+ for (List group : groups.values()) {
+ group.sort(order(operations));
+ String service = group.get(0).serviceName();
+ String folder = group.get(0).serviceFolder();
+ sources.put(Path.of("com/coinbase/prime/" + folder + "/" + service + ".java"), interfaceSource(service, folder, group, operations, names));
+ sources.put(Path.of("com/coinbase/prime/" + folder + "/" + service + "Impl.java"), implementationSource(service, folder, group, operations, configuration, names));
+ }
+ return sources;
+ }
+ static Comparator order(Map operations) {
+ return Comparator.comparingInt((OperationBinding binding) -> verbOrder(operations.get(binding.operationId()).httpMethod()))
+ .thenComparingInt(binding -> pathDepth(operations.get(binding.operationId()).path()))
+ .thenComparing(binding -> operations.get(binding.operationId()).path()).thenComparing(OperationBinding::sdkMethod);
+ }
+ private static String interfaceSource(String service, String folder, List bindings,
+ Map operations, NamingResolver names) {
+ StringBuilder source = new StringBuilder(SourceTemplates.header()).append("package com.coinbase.prime.").append(folder).append(";\n\n")
+ .append("import com.coinbase.core.errors.CoinbaseClientException;\nimport com.coinbase.prime.errors.CoinbasePrimeException;\n\npublic interface ").append(service).append(" {\n");
+ for (OperationBinding binding : bindings) {
+ SpecModels.Operation operation = operations.get(binding.operationId());
+ appendInterfaceMethod(source, operation, binding, names, binding.sdkMethod(), null);
+ for (DeprecatedAlias alias : binding.deprecatedAliases()) {
+ appendInterfaceMethod(source, operation, binding, names, alias.sdkMethod(), alias.message());
+ }
+ }
+ return source.append("}\n").toString();
+ }
+
+ private static void appendInterfaceMethod(
+ StringBuilder source,
+ SpecModels.Operation operation,
+ OperationBinding binding,
+ NamingResolver names,
+ String methodSdkName,
+ String deprecationMessage) {
+ appendMethodJavadoc(source, operation, binding, true, methodSdkName, deprecationMessage);
+ if (deprecationMessage != null) source.append(" @Deprecated\n");
+ source.append(" ").append(methodSdkName).append("Response ").append(names.methodName(methodSdkName)).append("(");
+ if (!binding.omitRequest()) source.append(methodSdkName).append("Request request");
+ source.append(") throws CoinbaseClientException, CoinbasePrimeException;\n\n");
+ }
+
+ private static String implementationSource(String service, String folder, List bindings,
+ Map operations, GeneratorConfiguration configuration, NamingResolver names) {
+ boolean v2 = bindings.stream().anyMatch(binding -> version(operations.get(binding.operationId()).path()).equals("v2"));
+ StringBuilder source = new StringBuilder(SourceTemplates.header()).append("package com.coinbase.prime.").append(folder).append(";\n\n")
+ .append("import com.coinbase.core.common.HttpMethod;\nimport com.coinbase.core.service.CoinbaseServiceImpl;\nimport com.coinbase.prime.client.CoinbasePrimeClient;\nimport com.coinbase.prime.errors.CoinbasePrimeException;\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport java.util.List;\n");
+ if (v2) source.append("import com.coinbase.prime.utils.Constants;\n");
+ source.append("\npublic class ").append(service).append("Impl extends CoinbaseServiceImpl implements ").append(service).append(" {\n");
+ if (v2) source.append(" private final CoinbasePrimeClient primeClient;\n\n");
+ source.append(" public ").append(service).append("Impl(CoinbasePrimeClient client) {\n super(client);\n");
+ if (v2) source.append(" this.primeClient = client;\n");
+ source.append(" }\n");
+ for (OperationBinding binding : bindings) {
+ SpecModels.Operation operation = operations.get(binding.operationId());
+ appendImplMethod(source, operation, binding, configuration, names, binding.sdkMethod(), null);
+ for (DeprecatedAlias alias : binding.deprecatedAliases()) {
+ appendImplMethod(source, operation, binding, configuration, names, alias.sdkMethod(), alias.message());
+ }
+ }
+ return source.append("}\n").toString();
+ }
+
+ private static void appendImplMethod(
+ StringBuilder source,
+ SpecModels.Operation operation,
+ OperationBinding binding,
+ GeneratorConfiguration configuration,
+ NamingResolver names,
+ String methodSdkName,
+ String deprecationMessage) {
+ String method = names.methodName(methodSdkName);
+ String version = version(operation.path());
+ source.append("\n");
+ appendMethodJavadoc(source, operation, binding, false, methodSdkName, deprecationMessage);
+ if (deprecationMessage != null) source.append(" @Deprecated\n");
+ source.append(" @Override\n public ").append(methodSdkName).append("Response ").append(method).append("(");
+ if (!binding.omitRequest()) source.append(methodSdkName).append("Request request");
+ source.append(") throws CoinbasePrimeException {\n return ");
+ if (version.equals("v2")) {
+ source.append("this.primeClient.withBaseUrl(Constants.versionedBaseUrl(this.primeClient.getBaseUrl(), \"v2\")).sendRequest(");
+ source.append("HttpMethod.").append(operation.httpMethod()).append(",\n ")
+ .append(pathExpression(operation.path(), binding, names)).append(",\n ")
+ .append(statuses(operation, configuration)).append(",\n ")
+ .append(binding.omitRequest() ? "null" : "request").append(",\n new TypeReference<")
+ .append(methodSdkName).append("Response>() {});\n }\n");
+ } else {
+ source.append("this.request(");
+ source.append("HttpMethod.").append(operation.httpMethod()).append(",\n ")
+ .append(pathExpression(operation.path(), binding, names)).append(",\n ")
+ .append(binding.omitRequest() ? "null" : "request").append(",\n ")
+ .append(statuses(operation, configuration)).append(",\n new TypeReference<")
+ .append(methodSdkName).append("Response>() {});\n }\n");
+ }
+ }
+
+ private static void appendMethodJavadoc(
+ StringBuilder source,
+ SpecModels.Operation operation,
+ OperationBinding binding,
+ boolean interfaceMethod,
+ String responseSdkName,
+ String deprecationMessage) {
+ String documentation = SourceTemplates.documentation(operation.summary(), operation.description());
+ source.append(" /**\n");
+ if (!documentation.isEmpty()) {
+ source.append(" * ").append(documentation.replace("*/", "* /").replace("\n", " ")).append("\n");
+ }
+ if (!binding.omitRequest()) {
+ source.append(" * @param request request parameters and body for this operation\n");
+ }
+ source.append(" * @return the decoded ").append(responseSdkName).append(" response\n");
+ if (interfaceMethod) {
+ source.append(" * @throws CoinbaseClientException if the request cannot be sent\n");
+ }
+ source.append(" * @throws CoinbasePrimeException if the Prime API rejects the request\n");
+ if (deprecationMessage != null) {
+ source.append(" * @deprecated ").append(deprecationMessage).append("\n");
+ }
+ source.append(" */\n");
+ }
+
+ private static String pathExpression(String rawPath, OperationBinding binding, NamingResolver names) {
+ String path = rawPath.replaceFirst("^/v[12]", "");
+ if (!path.matches(".*\\{[^}]+}.*")) return "\"" + path + "\"";
+ List args = new ArrayList<>();
+ java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\{([^}]+)}").matcher(path);
+ StringBuffer format = new StringBuffer();
+ while (matcher.find()) { matcher.appendReplacement(format, "%s"); args.add("request.get" + SourceTemplates.cap(names.propertyName(matcher.group(1))) + "()"); }
+ matcher.appendTail(format);
+ return "String.format(\"" + format + "\", " + String.join(", ", args) + ")";
+ }
+ static List statusCodes(SpecModels.Operation operation, GeneratorConfiguration configuration) {
+ List defaults = defaultStatusCodes(operation);
+ GeneratorConfiguration.Override override = configuration.overrides().get(operation.operationId());
+ if (override != null && !override.statuses().isEmpty()) {
+ if (override.statuses().equals(defaults)) {
+ System.err.println("WARN redundant status override: " + operation.operationId());
+ }
+ return override.statuses();
+ }
+ return defaults;
+ }
+
+ private static List defaultStatusCodes(SpecModels.Operation operation) {
+ List values = new ArrayList<>(operation.successStatusCodes());
+ if (values.size() == 1
+ && values.get(0) == 200
+ && (operation.operationId().matches(".*_(Create|Claim|Submit).*")
+ || operation.operationId().endsWith("PreviewUnstake"))) {
+ values.clear();
+ values.add(201);
+ values.add(200);
+ }
+ values.sort(
+ (left, right) ->
+ left == 201 ? -1 : right == 201 ? 1 : Integer.compare(left, right));
+ return values;
+ }
+ private static String statuses(SpecModels.Operation operation, GeneratorConfiguration config) { StringBuilder result=new StringBuilder("List.of("); for (Integer code:statusCodes(operation, config)) { if (result.length()>8) result.append(", "); result.append(code); } return result.append(")").toString(); }
+ static String version(String path) { if (!path.startsWith("/v")) return "v1"; String value=path.substring(1, path.indexOf('/', 1)); if (!value.equals("v1") && !value.equals("v2")) throw new IllegalArgumentException("Unsupported Prime API path version in " + path); return value; }
+ private static int verbOrder(String verb) { switch (verb) { case "GET": return 0; case "POST": return 1; case "PUT": return 2; case "PATCH": return 3; case "DELETE": return 4; default: return 5; } }
+ private static int pathDepth(String path) { return (int) java.util.Arrays.stream(path.split("/")).filter(value -> !value.isEmpty()).count(); }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SharedTransforms.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SharedTransforms.java
new file mode 100644
index 00000000..cf6492d4
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SharedTransforms.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+/** Shared post-model name and property transforms; client emitters must not duplicate these rules. */
+public final class SharedTransforms {
+ private SharedTransforms() {}
+ public static String modelType(String schemaName) { return PostProcessor.deriveJavaModelName(schemaName); }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SourceTemplates.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SourceTemplates.java
new file mode 100644
index 00000000..2ef4b54f
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SourceTemplates.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.util.Set;
+import java.util.TreeSet;
+
+/** Small formatting primitives shared by all Java source emitters. */
+final class SourceTemplates {
+ private SourceTemplates() {}
+ static String header() { return "/*\n * Copyright 2026-present Coinbase Global, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n"; }
+ static String importBlock(Set imports) { StringBuilder value = new StringBuilder(); for (String name : new TreeSet<>(imports)) { if (name.startsWith("static ")) value.append("import static ").append(name.substring(7)).append(";\n"); else value.append("import ").append(name).append(";\n"); } return value.length() == 0 ? "" : value.append("\n").toString(); }
+ static void imports(StringBuilder source, Set imports) { source.append(importBlock(imports)); }
+ static void javadoc(StringBuilder source, String text) {
+ if (text != null && !text.trim().isEmpty()) {
+ source.append("/** ").append(text.replace("*/", "* /").replace("\n", " ")).append(" */\n");
+ }
+ }
+
+ static String documentation(String summary, String description) {
+ String concise = summary == null ? "" : summary.trim();
+ String detail = description == null ? "" : description.trim();
+ if (concise.isEmpty()) return detail;
+ if (detail.isEmpty() || concise.equals(detail)) return concise;
+ return concise + ". " + detail;
+ }
+ static String cap(String value) { return Character.toUpperCase(value.charAt(0)) + value.substring(1); }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecFetcher.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecFetcher.java
index c1373639..cb43b1b3 100644
--- a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecFetcher.java
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecFetcher.java
@@ -16,9 +16,6 @@
package com.coinbase.tools.modelgenerator;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
@@ -26,48 +23,98 @@
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
import java.time.Duration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+/** Downloads a configured OpenAPI specification to a committed or isolated destination. */
public final class SpecFetcher {
- private static final Logger logger = LoggerFactory.getLogger(SpecFetcher.class);
+ private static final Logger logger = LoggerFactory.getLogger(SpecFetcher.class);
+
+ static final String DEFAULT_SPEC_URL = "https://api.prime.coinbase.com/v1/openapi.yaml";
+ static final String SPEC_RELATIVE_PATH = "apiSpec/prime-public-spec.yaml";
- static final String DEFAULT_SPEC_URL = "https://api.prime.coinbase.com/v1/openapi.yaml";
- static final String SPEC_RELATIVE_PATH = "apiSpec/prime-public-spec.yaml";
+ private SpecFetcher() {}
- private SpecFetcher() {}
+ public static Path fetch(Path projectRoot) throws IOException, InterruptedException {
+ return fetch(projectRoot, DEFAULT_SPEC_URL, SPEC_RELATIVE_PATH);
+ }
- /**
- * Downloads the latest OpenAPI spec into {@code apiSpec/prime-public-spec.yaml} at the SDK root.
- */
- public static Path fetch(Path projectRoot) throws IOException, InterruptedException {
- return fetch(projectRoot, DEFAULT_SPEC_URL);
+ static Path fetch(Path projectRoot, String specUrl) throws IOException, InterruptedException {
+ return fetch(projectRoot, specUrl, SPEC_RELATIVE_PATH);
+ }
+
+ static Path fetch(Path projectRoot, String specUrl, String committedSpecPath)
+ throws IOException, InterruptedException {
+ return fetchTo(destination(projectRoot, committedSpecPath), specUrl);
+ }
+
+ static Path destination(Path projectRoot, String committedSpecPath) {
+ Path root = projectRoot.toAbsolutePath().normalize();
+ Path destination = root.resolve(committedSpecPath).normalize();
+ if (!destination.startsWith(root)) {
+ throw new IllegalArgumentException("Committed spec path must remain within the repository");
+ }
+ return destination;
+ }
+
+ /** Downloads a live spec to a temporary file without changing committed input or generated output. */
+ public static Path fetchToTemporary(Path projectRoot, String specUrl)
+ throws IOException, InterruptedException {
+ Path temporaryDirectory = projectRoot.resolve("generated");
+ Files.createDirectories(temporaryDirectory);
+ Path specPath = Files.createTempFile(temporaryDirectory, "prime-public-spec-", ".yaml");
+ try {
+ return fetchTo(specPath, specUrl);
+ } catch (IOException | InterruptedException exception) {
+ Files.deleteIfExists(specPath);
+ try {
+ Files.deleteIfExists(temporaryDirectory);
+ } catch (java.nio.file.DirectoryNotEmptyException ignored) {
+ // The directory existed before this fetch or contains unrelated generator output.
+ }
+ throw exception;
}
+ }
- static Path fetch(Path projectRoot, String specUrl) throws IOException, InterruptedException {
- Path specPath = projectRoot.resolve(SPEC_RELATIVE_PATH);
- Files.createDirectories(specPath.getParent());
+ private static Path fetchTo(Path specPath, String specUrl) throws IOException, InterruptedException {
+ Files.createDirectories(specPath.getParent());
- logger.info("Fetching OpenAPI spec from: {}", specUrl);
- logger.info("Writing spec to: {}", specPath);
+ logger.info("Fetching OpenAPI spec from: {}", specUrl);
+ logger.info("Writing spec to: {}", specPath);
- HttpClient client = HttpClient.newBuilder()
+ HttpClient client =
+ HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(30))
.build();
- HttpRequest request = HttpRequest.newBuilder()
+ HttpRequest request =
+ HttpRequest.newBuilder()
.uri(URI.create(specUrl))
.timeout(Duration.ofMinutes(2))
.GET()
.build();
- HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(specPath));
- if (response.statusCode() < 200 || response.statusCode() >= 300) {
- throw new IOException(
- "Failed to fetch OpenAPI spec: HTTP " + response.statusCode() + " from " + specUrl);
- }
-
- logger.info("OpenAPI spec fetched successfully ({} bytes)", Files.size(specPath));
- return specPath;
+ // Download beside the destination and replace it only after the body is a valid spec, so a
+ // failed fetch never overwrites the committed input.
+ Path download = Files.createTempFile(specPath.getParent(), "prime-public-spec-", ".download");
+ try {
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(download));
+ if (response.statusCode() < 200 || response.statusCode() >= 300) {
+ throw new IOException(
+ "Failed to fetch OpenAPI spec: HTTP " + response.statusCode() + " from " + specUrl);
+ }
+ if (SpecParser.load(download).operations().isEmpty()) {
+ throw new IOException("Fetched OpenAPI spec has no operations: " + specUrl);
+ }
+ Files.move(download, specPath, StandardCopyOption.REPLACE_EXISTING);
+ } finally {
+ Files.deleteIfExists(download);
}
+
+ logger.info("OpenAPI spec fetched successfully ({} bytes)", Files.size(specPath));
+ return specPath;
+ }
}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecModels.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecModels.java
new file mode 100644
index 00000000..11db22a3
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecModels.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/** Immutable operation inventory used by all non-model generator phases. */
+public final class SpecModels {
+ private SpecModels() {}
+
+ public static final class Document {
+ private final Map root;
+ private final List operations;
+
+ Document(Map root, List operations) {
+ this.root = root;
+ this.operations = Collections.unmodifiableList(operations);
+ }
+
+ public Map root() { return root; }
+ public List operations() { return operations; }
+ }
+
+ public static final class Operation {
+ private final String operationId;
+ private final String httpMethod;
+ private final String path;
+ private final List tags;
+ private final List parameters;
+ private final Map requestBodySchema;
+ private final Map successResponseSchema;
+ private final List successStatusCodes;
+ private final String summary;
+ private final String description;
+ private final String sdkMethodName;
+
+ Operation(String operationId, String httpMethod, String path, List tags,
+ List parameters, Map requestBodySchema,
+ Map successResponseSchema, List successStatusCodes,
+ String summary, String description, String sdkMethodName) {
+ this.operationId = operationId;
+ this.httpMethod = httpMethod;
+ this.path = path;
+ this.tags = Collections.unmodifiableList(tags);
+ this.parameters = Collections.unmodifiableList(parameters);
+ this.requestBodySchema = requestBodySchema;
+ this.successResponseSchema = successResponseSchema;
+ this.successStatusCodes = Collections.unmodifiableList(successStatusCodes);
+ this.summary = summary;
+ this.description = description;
+ this.sdkMethodName = sdkMethodName;
+ }
+
+ public String operationId() { return operationId; }
+ public String httpMethod() { return httpMethod; }
+ public String path() { return path; }
+ public List tags() { return tags; }
+ public List parameters() { return parameters; }
+ public Map requestBodySchema() { return requestBodySchema; }
+ public Map successResponseSchema() { return successResponseSchema; }
+ public List successStatusCodes() { return successStatusCodes; }
+ public String summary() { return summary; }
+ public String description() { return description; }
+ public String sdkMethodName() { return sdkMethodName; }
+ }
+
+ public static final class Parameter {
+ private final String name;
+ private final String location;
+ private final boolean required;
+ private final Map schema;
+ private final String description;
+
+ Parameter(String name, String location, boolean required, Map schema,
+ String description) {
+ this.name = name;
+ this.location = location;
+ this.required = required;
+ this.schema = schema;
+ this.description = description;
+ }
+
+ public String name() { return name; }
+ public String location() { return location; }
+ public boolean required() { return required; }
+ public Map schema() { return schema; }
+ public String description() { return description; }
+ }
+}
diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecParser.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecParser.java
new file mode 100644
index 00000000..5b722ff3
--- /dev/null
+++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecParser.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2026-present Coinbase Global, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.coinbase.tools.modelgenerator;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+
+/** Parses the committed OpenAPI document into a stable, generator-neutral operation inventory. */
+public final class SpecParser {
+ private static final List HTTP_METHODS =
+ Arrays.asList("get", "post", "put", "patch", "delete", "head", "options");
+
+ private SpecParser() {}
+
+ public static SpecModels.Document load(Path path) throws IOException {
+ Object value = new Yaml(new SafeConstructor(new LoaderOptions())).load(Files.newBufferedReader(path));
+ Map root = map(value);
+ List operations = new ArrayList<>();
+ for (Map.Entry pathEntry : sortedEntries(map(root.get("paths")))) {
+ Map pathItem = map(pathEntry.getValue());
+ List