From feb55e700dd9da849f76995bc8682800c2e82c5c Mon Sep 17 00:00:00 2001 From: Nick Morgan Date: Fri, 6 Nov 2026 13:21:19 -0800 Subject: [PATCH 1/4] feat(generator): parse the OpenAPI spec into SDK operation bindings Parse the committed spec into a stable, operation-ID sorted inventory and derive one Java binding per operation (service folder, service name, method, request/pagination shape). Config-driven overrides keep the vended SDK names and types; the validator rejects missing path parameters, duplicate methods, and one service name bound to two folders. - SpecParser fails on operations without an operationId. - SpecFetcher replaces the committed spec only after a 2xx response that parses with operations. - JavaTypeResolver maps schemas to vended model/enum types, keeps DTO booleans boxed, and rejects ambiguous inline enums. - operation-inventory.json locks the vended service and method of all 111 current operations; new spec operations are allowed. --- .../config/generator-config.json | 26 ++ .../config/operations-overrides.json | 189 ++++++++++ .../modelgenerator/GeneratedEnumKind.java | 40 ++ .../GeneratorConfiguration.java | 343 ++++++++++++++++++ .../tools/modelgenerator/GeneratorPaths.java | 51 +++ .../modelgenerator/JavaTypeResolver.java | 149 ++++++++ .../tools/modelgenerator/NamingResolver.java | 59 +++ .../modelgenerator/OperationBinding.java | 123 +++++++ .../OperationBindingGenerator.java | 220 +++++++++++ .../OperationBindingValidator.java | 62 ++++ .../modelgenerator/SharedTransforms.java | 22 ++ .../tools/modelgenerator/SpecFetcher.java | 101 ++++-- .../tools/modelgenerator/SpecModels.java | 104 ++++++ .../tools/modelgenerator/SpecParser.java | 132 +++++++ .../GeneratorConfigurationTest.java | 61 ++++ .../OperationBindingGeneratorTest.java | 114 ++++++ .../OperationInventoryTest.java | 70 ++++ .../tools/modelgenerator/SpecFetcherTest.java | 72 ++++ .../tools/modelgenerator/SpecParserTest.java | 70 ++++ .../test/resources/operation-inventory.json | 113 ++++++ 20 files changed, 2094 insertions(+), 27 deletions(-) create mode 100644 tools/model-generator/config/generator-config.json create mode 100644 tools/model-generator/config/operations-overrides.json create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedEnumKind.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorConfiguration.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorPaths.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/JavaTypeResolver.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/NamingResolver.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBinding.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingGenerator.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingValidator.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SharedTransforms.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecModels.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecParser.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/GeneratorConfigurationTest.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationInventoryTest.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/SpecFetcherTest.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/SpecParserTest.java create mode 100644 tools/model-generator/src/test/resources/operation-inventory.json 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/operations-overrides.json b/tools/model-generator/config/operations-overrides.json new file mode 100644 index 00000000..e8fdb901 --- /dev/null +++ b/tools/model-generator/config/operations-overrides.json @@ -0,0 +1,189 @@ +[ + { + "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", + "parameterTypeOverrides": { + "symbols": "String[]", + "categories": "com.coinbase.prime.model.enums.ActivityCategory[]", + "statuses": "com.coinbase.prime.model.enums.ActivityStatus[]" + } + }, + { + "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", + "sdkMethod": "GetOrderByOrderId" + }, + { + "operationId": "PrimeRESTAPI_GetEntityPaymentMethods", + "sdkMethod": "ListPaymentMethods" + }, + { + "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": "ClaimRewards" + }, + { + "operationId": "PrimeRESTAPI_StakingInitiate", + "sdkMethod": "CreateStake" + }, + { + "operationId": "PrimeRESTAPI_StakingUnstake", + "sdkMethod": "CreateUnstake" + }, + { + "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_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_GetFcmBalance", + "sdkMethod": "GetEntityFcmBalance" + } +] 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/GeneratorConfiguration.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorConfiguration.java new file mode 100644 index 00000000..2f6db802 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorConfiguration.java @@ -0,0 +1,343 @@ +/* + * 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.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 Override( + String sdkMethod, + String serviceFolder, + String serviceName, + Boolean omitRequest, + Boolean paginated, + Map parameterTypes, + Map propertyNames, + Map responseTypes, + List convenienceConstructorParameters, + List statuses) { + 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; + } + + 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); + } + 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))); + } + + 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; + } + } +} 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/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/OperationBinding.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBinding.java new file mode 100644 index 00000000..be782e66 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBinding.java @@ -0,0 +1,123 @@ +/* + * 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.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; + + 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()); + } + + OperationBinding( + String operationId, + String serviceFolder, + String serviceName, + String sdkMethod, + boolean omitRequest, + boolean paginated, + Map parameterTypeOverrides, + Map propertyNameOverrides, + Map responseTypeOverrides, + List convenienceConstructorParameters) { + 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); + } + + 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; + } +} 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..592943c4 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingGenerator.java @@ -0,0 +1,220 @@ +/* + * 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("GetAllocationsByClientNettingId", "ListAllocationsByNettingId"); + METHOD_RENAMES.put("GetEntityAssets", "ListAssets"); + METHOD_RENAMES.put("GetEntityPaymentMethodDetails", "GetPaymentMethodDetails"); + METHOD_RENAMES.put("GetEntityUsers", "ListEntityUsers"); + METHOD_RENAMES.put("GetFuturesSweeps", "ListEntityFuturesSweeps"); + METHOD_RENAMES.put("GetLocateAvailabilities", "GetEntityLocateAvailabilities"); + METHOD_RENAMES.put("GetMarginSummaries", "ListMarginCallSummaries"); + METHOD_RENAMES.put("GetPortfolioAddressBook", "ListAddressBook"); + 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()) { + 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()); + } + + 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..07f25255 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingValidator.java @@ -0,0 +1,62 @@ +/* + * 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)); + } + } + } + } +} 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/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> pathParameters = parameterMaps(pathItem.get("parameters")); + for (String method : HTTP_METHODS) { + if (!pathItem.containsKey(method)) continue; + Map operation = map(pathItem.get(method)); + String operationId = string(operation.get("operationId")); + if (operationId.isEmpty()) { + throw new IllegalArgumentException( + "OpenAPI operation has no operationId: " + method.toUpperCase(java.util.Locale.ROOT) + " " + pathEntry.getKey()); + } + Map> mergedParameters = new LinkedHashMap<>(); + for (Map parameter : pathParameters) { + mergedParameters.put(parameterKey(parameter), parameter); + } + // Operation-level declarations override path-level declarations with the same in/name pair. + for (Map parameter : parameterMaps(operation.get("parameters"))) { + mergedParameters.put(parameterKey(parameter), parameter); + } + List parameters = mergedParameters.values().stream() + .map(SpecParser::parameter).collect(Collectors.toList()); + Map response = firstSuccessResponse(operation); + operations.add(new SpecModels.Operation( + operationId, method.toUpperCase(), pathEntry.getKey(), strings(operation.get("tags")), parameters, + requestSchema(operation), schema(response), successCodes(operation), string(operation.get("summary")), + string(operation.get("description")), string(operation.get("x-sdk-method-name")))); + } + } + operations.sort(Comparator.comparing(SpecModels.Operation::operationId)); + return new SpecModels.Document(root, operations); + } + + private static String parameterKey(Map parameter) { + return string(parameter.get("in")) + "\u0000" + string(parameter.get("name")); + } + + private static SpecModels.Parameter parameter(Map parameter) { + return new SpecModels.Parameter(string(parameter.get("name")), string(parameter.get("in")), + Boolean.TRUE.equals(parameter.get("required")), map(parameter.get("schema")), + string(parameter.get("description"))); + } + + private static Map requestSchema(Map operation) { + return schema(map(map(map(operation.get("requestBody")).get("content")).get("application/json"))); + } + + private static Map firstSuccessResponse(Map operation) { + for (Map.Entry response : sortedEntries(map(operation.get("responses")))) { + if (response.getKey().matches("2[0-9]{2}")) { + Map json = map(map(map(response.getValue()).get("content")).get("application/json")); + if (!schema(json).isEmpty()) return json; + } + } + return Collections.emptyMap(); + } + + private static Map schema(Map contentOrMediaType) { + return map(contentOrMediaType.get("schema")); + } + + private static List successCodes(Map operation) { + return sortedEntries(map(operation.get("responses"))).stream() + .map(Map.Entry::getKey).filter(k -> k.matches("2[0-9]{2}")) + .map(Integer::valueOf).collect(Collectors.toList()); + } + + private static List> parameterMaps(Object object) { + if (!(object instanceof List)) return Collections.emptyList(); + List> result = new ArrayList<>(); + for (Object value : (List) object) result.add(map(value)); + return result; + } + + private static List strings(Object object) { + if (!(object instanceof List)) return Collections.emptyList(); + return ((List) object).stream().map(SpecParser::string).collect(Collectors.toList()); + } + + @SuppressWarnings("unchecked") + static Map map(Object object) { + return object instanceof Map ? new LinkedHashMap<>((Map) object) : Collections.emptyMap(); + } + + private static String string(Object object) { return object == null ? "" : String.valueOf(object); } + + private static List> sortedEntries(Map map) { + return map.entrySet().stream().sorted(Map.Entry.comparingByKey()).collect(Collectors.toList()); + } +} diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/GeneratorConfigurationTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/GeneratorConfigurationTest.java new file mode 100644 index 00000000..b7f4df5f --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/GeneratorConfigurationTest.java @@ -0,0 +1,61 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +class GeneratorConfigurationTest { + @Test + void usesCommittedSpecPathForFetchDestinationAndNormalizesStatusOverrides() throws Exception { + GeneratorConfiguration configuration = configuration( + "{\"specUrl\":\"https://example.test/openapi.yaml\",\"committedSpecPath\":\"apiSpec/custom.yaml\"}", + "[{\"operationId\":\"PrimeRESTAPI_Test\",\"statusCodes\":[204,200,204,201]}]"); + + assertEquals( + Path.of("/tmp/prime-sdk/apiSpec/custom.yaml"), + SpecFetcher.destination(Path.of("/tmp/prime-sdk"), configuration.committedSpecPath())); + assertEquals(List.of(200, 201, 204), + configuration.overrides().get("PrimeRESTAPI_Test").statuses()); + } + + @Test + void rejectsUnsafeOutputPathsAndInvalidJavaNames() throws Exception { + assertThrows(IllegalArgumentException.class, () -> configuration( + "{\"specUrl\":\"x\",\"committedSpecPath\":\"../outside.yaml\"}", "[]")); + assertThrows(IllegalArgumentException.class, () -> configuration( + "{\"specUrl\":\"x\",\"committedSpecPath\":\"apiSpec/spec.yaml\",\"tagToFolderOverrides\":{\"Tag\":\"bad-folder\"}}", + "[]")); + assertThrows(IllegalArgumentException.class, () -> configuration( + "{\"specUrl\":\"x\",\"committedSpecPath\":\"apiSpec/spec.yaml\"}", + "[{\"operationId\":\"PrimeRESTAPI_Test\",\"sdkMethod\":\"not-valid\"}]")); + } + + private static GeneratorConfiguration configuration(String configContent, String overridesContent) + throws Exception { + Path directory = Files.createTempDirectory("generator-configuration"); + Path config = directory.resolve("generator.json"); + Path overrides = directory.resolve("overrides.json"); + Files.writeString(config, configContent); + Files.writeString(overrides, overridesContent); + return GeneratorConfiguration.loadForTests(config, overrides); + } +} diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java new file mode 100644 index 00000000..eb6af95f --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java @@ -0,0 +1,114 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class OperationBindingGeneratorTest { + @Test + void derivesStableBindingsForTheCommittedSpec() throws Exception { + Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath().getParent().getParent(); + SpecModels.Document document = SpecParser.load(root.resolve("apiSpec/prime-public-spec.yaml")); + GeneratorConfiguration configuration = GeneratorConfiguration.load(GeneratorPaths.forRoot(root)); + List bindings = OperationBindingGenerator.deriveAll(document, configuration); + + assertEquals(document.operations().size(), bindings.size()); + OperationBinding createOrder = bindings.stream() + .filter(binding -> binding.operationId().equals("PrimeRESTAPI_CreateOrder")) + .findFirst().orElseThrow(); + assertEquals("orders", createOrder.serviceFolder()); + assertEquals("OrdersService", createOrder.serviceName()); + assertEquals("CreateOrder", createOrder.sdkMethod()); + assertTrue(!createOrder.omitRequest()); + + OperationBinding travelRule = bindings.stream() + .filter(binding -> binding.operationId().equals("PrimeRESTAPI_SubmitDepositTravelRuleData")) + .findFirst().orElseThrow(); + assertEquals("transactions", travelRule.serviceFolder()); + assertEquals("TransactionsService", travelRule.serviceName()); + } + + @Test + void preservesConfiguredCompatibilityNamesAndRequestShapes() throws Exception { + Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath().getParent().getParent(); + GeneratorPaths paths = GeneratorPaths.forRoot(root); + SpecModels.Document document = SpecParser.load(root.resolve("apiSpec/prime-public-spec.yaml")); + GeneratorConfiguration configuration = GeneratorConfiguration.load(paths); + List bindings = OperationBindingGenerator.deriveAll(document, configuration); + + assertEquals("GetEntityFcmBalance", binding(bindings, "PrimeRESTAPI_GetFcmBalance").sdkMethod()); + assertEquals("ListPortfolioOrders", binding(bindings, "PrimeRESTAPI_GetOrders").sdkMethod()); + assertEquals("GetOrderByOrderId", binding(bindings, "PrimeRESTAPI_GetOrder").sdkMethod()); + assertEquals("ClaimRewards", binding(bindings, "PrimeRESTAPI_StakingClaimRewards").sdkMethod()); + assertEquals("GetCrossMarginLiquidation", binding(bindings, "PrimeRESTAPI_GetXMLiquidation").sdkMethod()); + assertEquals("GetDerivativePositions", binding(bindings, "PrimeRESTAPI_GetDerivativePositions").sdkMethod()); + assertEquals("ListOnchainWalletBalances", binding(bindings, "PrimeRESTAPI_ListWeb3WalletBalances").sdkMethod()); + assertEquals("apikey", binding(bindings, "PrimeRESTAPI_RotateAPIKey").serviceFolder()); + assertEquals("ApiKeyService", binding(bindings, "PrimeRESTAPI_RotateAPIKey").serviceName()); + + NamingResolver names = + new NamingResolver(configuration.modelTypeMappings()); + assertEquals( + "PrimeXMControlStatus", + names.typeName("CoinbasePublicRestApiXMControlStatus")); + assertEquals( + "PrimeXMMarginLevel", names.typeName("CoinbasePublicRestApiXMMarginLevel")); + } + + @Test + void resolvesEveryCommittedInlineRequestEnumToAPublicNamedEnum() throws Exception { + Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath().getParent().getParent(); + GeneratorConfiguration configuration = GeneratorConfiguration.load(GeneratorPaths.forRoot(root)); + SpecModels.Document document = SpecParser.load(root.resolve("apiSpec/prime-public-spec.yaml")); + JavaTypeResolver types = + new JavaTypeResolver( + document, + new NamingResolver(configuration.modelTypeMappings()), + configuration.sharedModelMappings()); + int inlineEnumCount = 0; + for (SpecModels.Operation operation : document.operations()) { + for (SpecModels.Parameter parameter : operation.parameters()) { + if (parameter.schema().containsKey("enum")) { + assertFalse(types.resolve(parameter.schema()).name().equals("String")); + inlineEnumCount++; + } + } + Map body = types.dereference(operation.requestBodySchema()); + for (Object property : SpecParser.map(body.get("properties")).values()) { + Map propertySchema = SpecParser.map(property); + if (propertySchema.containsKey("enum")) { + assertFalse(types.resolve(propertySchema).name().equals("String")); + inlineEnumCount++; + } + } + } + assertTrue(inlineEnumCount > 0); + } + + private static OperationBinding binding(List bindings, String operationId) { + return bindings.stream() + .filter(binding -> binding.operationId().equals(operationId)) + .findFirst() + .orElseThrow(); + } +} diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationInventoryTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationInventoryTest.java new file mode 100644 index 00000000..c4de7c3c --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationInventoryTest.java @@ -0,0 +1,70 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.junit.jupiter.api.Test; + +/** + * Locks the vended Java surface for every known operation. + * + *

Spec updates may add operations freely, but a spec or derivation change that renames, moves, or + * drops an existing SDK method fails here. Add an override to {@code operations-overrides.json} to + * keep the vended name, or update {@code operation-inventory.json} deliberately. + */ +class OperationInventoryTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void existingOperationsKeepTheirVendedJavaSurface() throws Exception { + Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath().getParent().getParent(); + SpecModels.Document document = SpecParser.load(root.resolve("apiSpec/prime-public-spec.yaml")); + GeneratorConfiguration configuration = GeneratorConfiguration.load(GeneratorPaths.forRoot(root)); + Map actual = inventory(OperationBindingGenerator.deriveAll(document, configuration)); + @SuppressWarnings("unchecked") + Map expected = + JSON.readValue( + root.resolve("tools/model-generator/src/test/resources/operation-inventory.json").toFile(), + TreeMap.class); + + List failures = new ArrayList<>(); + for (Map.Entry entry : expected.entrySet()) { + String surface = actual.get(entry.getKey()); + if (!entry.getValue().equals(surface)) { + failures.add(entry.getKey() + ": expected " + entry.getValue() + " but was " + surface); + } + } + assertTrue(failures.isEmpty(), "Vended Java surface changed:\n" + String.join("\n", failures)); + } + + static Map inventory(List bindings) { + Map inventory = new TreeMap<>(); + for (OperationBinding binding : bindings) { + inventory.put( + binding.operationId(), + binding.serviceFolder() + "/" + binding.serviceName() + "#" + binding.sdkMethod()); + } + return inventory; + } +} diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/SpecFetcherTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/SpecFetcherTest.java new file mode 100644 index 00000000..ec7578c5 --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/SpecFetcherTest.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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.sun.net.httpserver.HttpServer; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +class SpecFetcherTest { + private static final String COMMITTED = "openapi: 3.0.0\npaths: {}\n"; + + @Test + void failedFetchLeavesCommittedSpecUntouched() throws Exception { + assertFetchFails(500, "upstream error"); + } + + @Test + void fetchWithoutOperationsLeavesCommittedSpecUntouched() throws Exception { + assertFetchFails(200, "maintenance"); + } + + private static void assertFetchFails(int status, String body) throws Exception { + Path root = Files.createTempDirectory("spec-fetcher"); + Path spec = root.resolve("apiSpec/prime-public-spec.yaml"); + Files.createDirectories(spec.getParent()); + Files.writeString(spec, COMMITTED); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/openapi.yaml", + exchange -> { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + }); + server.start(); + try { + String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/openapi.yaml"; + assertThrows( + Exception.class, () -> SpecFetcher.fetch(root, url, "apiSpec/prime-public-spec.yaml")); + assertEquals(COMMITTED, Files.readString(spec)); + try (Stream files = Files.list(spec.getParent())) { + assertEquals(1, files.count(), "temporary download must be removed"); + } + } finally { + server.stop(0); + } + } +} diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/SpecParserTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/SpecParserTest.java new file mode 100644 index 00000000..9dbb4a5d --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/SpecParserTest.java @@ -0,0 +1,70 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; + +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +class SpecParserTest { + @Test + void parsesCompleteStableOperationInventory() throws Exception { + Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath().getParent().getParent(); + SpecModels.Document document = SpecParser.load(root.resolve("apiSpec/prime-public-spec.yaml")); + + // Count independently of the parser so a silently skipped operation fails here. + long declaredOperations = + Files.readAllLines(root.resolve("apiSpec/prime-public-spec.yaml")).stream() + .filter(line -> line.trim().startsWith("operationId:")) + .count(); + assertEquals(declaredOperations, document.operations().size()); + SpecModels.Operation createOrder = document.operations().stream() + .filter(operation -> operation.operationId().equals("PrimeRESTAPI_CreateOrder")) + .findFirst().orElseThrow(); + assertEquals("POST", createOrder.httpMethod()); + assertEquals("/v1/portfolios/{portfolio_id}/order", createOrder.path()); + assertEquals(List.of("Orders"), createOrder.tags()); + assertTrue(createOrder.parameters().stream() + .anyMatch(parameter -> parameter.name().equals("portfolio_id") && parameter.location().equals("path") && parameter.required())); + assertEquals(List.of(200), createOrder.successStatusCodes()); + assertTrue(!createOrder.requestBodySchema().isEmpty()); + } + + @Test + void rejectsOperationsWithoutAnOperationId() throws Exception { + Path spec = Files.createTempFile("spec-without-operation-id", ".yaml"); + Files.writeString( + spec, + String.join( + "\n", + "openapi: 3.0.0", + "paths:", + " /v1/things:", + " get:", + " responses: { '200': { description: ok } }", + "")); + + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> SpecParser.load(spec)); + assertTrue(error.getMessage().contains("GET /v1/things"), error.getMessage()); + } +} diff --git a/tools/model-generator/src/test/resources/operation-inventory.json b/tools/model-generator/src/test/resources/operation-inventory.json new file mode 100644 index 00000000..d0fa9d35 --- /dev/null +++ b/tools/model-generator/src/test/resources/operation-inventory.json @@ -0,0 +1,113 @@ +{ + "PrimeBeta_GetEntityRewardsRate": "financing/FinancingService#GetEntityRewardsRate", + "PrimeBeta_GetPortfolioRewardsRate": "financing/FinancingService#GetPortfolioRewardsRate", + "PrimeRESTAPI_AcceptQuote": "orders/OrdersService#AcceptQuote", + "PrimeRESTAPI_CancelAdvancedTransfer": "advancedtransfer/AdvancedTransferService#CancelAdvancedTransfer", + "PrimeRESTAPI_CancelFuturesSweep": "futures/FuturesService#CancelEntityFuturesSweep", + "PrimeRESTAPI_CancelOrder": "orders/OrdersService#CancelOrder", + "PrimeRESTAPI_CreateAdvancedTransfer": "advancedtransfer/AdvancedTransferService#CreateAdvancedTransfer", + "PrimeRESTAPI_CreateAllocation": "allocations/AllocationsService#CreateAllocation", + "PrimeRESTAPI_CreateConversion": "transactions/TransactionsService#CreateConversion", + "PrimeRESTAPI_CreateNetAllocation": "allocations/AllocationsService#CreateNetAllocation", + "PrimeRESTAPI_CreateNewLocates": "financing/FinancingService#CreateNewLocates", + "PrimeRESTAPI_CreateOnchainAddressGroup": "onchainaddressbook/OnchainAddressBookService#CreateOnchainAddressBookEntry", + "PrimeRESTAPI_CreateOnchainTransaction": "transactions/TransactionsService#CreateOnchainTransaction", + "PrimeRESTAPI_CreateOrder": "orders/OrdersService#CreateOrder", + "PrimeRESTAPI_CreatePortfolioAddressBookEntry": "addressbook/AddressBookService#CreateAddressBookEntry", + "PrimeRESTAPI_CreateQuoteRequest": "orders/OrdersService#CreateQuote", + "PrimeRESTAPI_CreateWallet": "wallets/WalletsService#CreateWallet", + "PrimeRESTAPI_CreateWalletDepositAddress": "wallets/WalletsService#CreateWalletDepositAddress", + "PrimeRESTAPI_CreateWalletTransfer": "transactions/TransactionsService#CreateWalletTransfer", + "PrimeRESTAPI_CreateWalletWithdrawal": "transactions/TransactionsService#CreateWalletWithdrawal", + "PrimeRESTAPI_DeleteOnchainAddressGroup": "onchainaddressbook/OnchainAddressBookService#DeleteOnchainAddressGroup", + "PrimeRESTAPI_EditOrder": "orders/OrdersService#EditOrder", + "PrimeRESTAPI_GetActivity": "activities/ActivitiesService#GetActivity", + "PrimeRESTAPI_GetAllocation": "allocations/AllocationsService#GetAllocation", + "PrimeRESTAPI_GetAllocationsByClientNettingId": "allocations/AllocationsService#ListAllocationsByNettingId", + "PrimeRESTAPI_GetBuyingPower": "financing/FinancingService#GetPortfolioBuyingPower", + "PrimeRESTAPI_GetCandles": "products/ProductsService#GetCandles", + "PrimeRESTAPI_GetConversionFees": "financing/FinancingService#GetConversionFees", + "PrimeRESTAPI_GetCrossMarginOverview": "financing/FinancingService#GetCrossMarginOverview", + "PrimeRESTAPI_GetCrossMarginPrimeOverview": "financing/FinancingService#GetCrossMarginPrimeOverview", + "PrimeRESTAPI_GetCrossMarginRiskParameters": "financing/FinancingService#GetCrossMarginRiskParameters", + "PrimeRESTAPI_GetDerivativePositions": "futures/FuturesService#GetDerivativePositions", + "PrimeRESTAPI_GetDerivativesCurrencySummary": "futures/FuturesService#GetDerivativesCurrencySummary", + "PrimeRESTAPI_GetEntityActivities": "activities/ActivitiesService#ListEntityActivities", + "PrimeRESTAPI_GetEntityAssets": "assets/AssetsService#ListAssets", + "PrimeRESTAPI_GetEntityPaymentMethodDetails": "paymentmethods/PaymentMethodsService#GetPaymentMethodDetails", + "PrimeRESTAPI_GetEntityPaymentMethods": "paymentmethods/PaymentMethodsService#ListPaymentMethods", + "PrimeRESTAPI_GetEntityUsers": "users/UsersService#ListEntityUsers", + "PrimeRESTAPI_GetExistingLocates": "financing/FinancingService#ListExistingLocates", + "PrimeRESTAPI_GetFcmBalance": "futures/FuturesService#GetEntityFcmBalance", + "PrimeRESTAPI_GetFcmEquity": "futures/FuturesService#GetFcmEquity", + "PrimeRESTAPI_GetFcmMarginCallDetails": "futures/FuturesService#GetFcmMarginCallDetails", + "PrimeRESTAPI_GetFcmRiskLimits": "futures/FuturesService#GetFcmRiskLimits", + "PrimeRESTAPI_GetFcmSettings": "futures/FuturesService#GetFcmSettings", + "PrimeRESTAPI_GetFuturesSweeps": "futures/FuturesService#ListEntityFuturesSweeps", + "PrimeRESTAPI_GetInterestAccruals": "financing/FinancingService#ListInterestAccruals", + "PrimeRESTAPI_GetInvoices": "invoice/InvoiceService#ListInvoices", + "PrimeRESTAPI_GetLocateAvailabilities": "financing/FinancingService#GetEntityLocateAvailabilities", + "PrimeRESTAPI_GetMarginConversions": "financing/FinancingService#ListMarginConversions", + "PrimeRESTAPI_GetMarginInformation": "financing/FinancingService#GetMarginInformation", + "PrimeRESTAPI_GetMarginSummaries": "financing/FinancingService#ListMarginCallSummaries", + "PrimeRESTAPI_GetMarketData": "financing/FinancingService#GetMarketData", + "PrimeRESTAPI_GetOpenOrders": "orders/OrdersService#ListOpenOrders", + "PrimeRESTAPI_GetOrder": "orders/OrdersService#GetOrderByOrderId", + "PrimeRESTAPI_GetOrderEditHistory": "orders/OrdersService#ListOrderEditHistory", + "PrimeRESTAPI_GetOrderFills": "orders/OrdersService#ListOrderFills", + "PrimeRESTAPI_GetOrders": "orders/OrdersService#ListPortfolioOrders", + "PrimeRESTAPI_GetPortfolio": "portfolios/PortfoliosService#GetPortfolio", + "PrimeRESTAPI_GetPortfolioActivities": "activities/ActivitiesService#ListPortfolioActivities", + "PrimeRESTAPI_GetPortfolioActivity": "activities/ActivitiesService#GetPortfolioActivity", + "PrimeRESTAPI_GetPortfolioAddressBook": "addressbook/AddressBookService#ListAddressBook", + "PrimeRESTAPI_GetPortfolioAllocations": "allocations/AllocationsService#ListPortfolioAllocations", + "PrimeRESTAPI_GetPortfolioBalances": "balances/BalancesService#ListPortfolioBalances", + "PrimeRESTAPI_GetPortfolioCommission": "commission/CommissionService#GetPortfolioCommission", + "PrimeRESTAPI_GetPortfolioCounterpartyID": "advancedtransfer/AdvancedTransferService#GetPortfolioCounterpartyId", + "PrimeRESTAPI_GetPortfolioFills": "orders/OrdersService#ListPortfolioFills", + "PrimeRESTAPI_GetPortfolioInterestAccruals": "financing/FinancingService#ListInterestAccrualsForPortfolio", + "PrimeRESTAPI_GetPortfolioProducts": "products/ProductsService#ListPortfolioProducts", + "PrimeRESTAPI_GetPortfolioTransactions": "transactions/TransactionsService#ListPortfolioTransactions", + "PrimeRESTAPI_GetPortfolioUsers": "users/UsersService#ListPortfolioUsers", + "PrimeRESTAPI_GetPortfolios": "portfolios/PortfoliosService#ListPortfolios", + "PrimeRESTAPI_GetPositions": "futures/FuturesService#GetPositions", + "PrimeRESTAPI_GetPostTradeCredit": "financing/FinancingService#GetPortfolioCreditInformation", + "PrimeRESTAPI_GetStakingStatus": "staking/StakingService#GetStakingStatus", + "PrimeRESTAPI_GetTFTieredPricingFees": "financing/FinancingService#GetTradeFinanceTieredPricingFees", + "PrimeRESTAPI_GetTransaction": "transactions/TransactionsService#GetTransaction", + "PrimeRESTAPI_GetTransactionTravelRuleData": "transactions/TransactionsService#GetTransactionTravelRuleData", + "PrimeRESTAPI_GetUnstakingStatus": "staking/StakingService#GetUnstakingStatus", + "PrimeRESTAPI_GetWallet": "wallets/WalletsService#GetWallet", + "PrimeRESTAPI_GetWalletBalance": "balances/BalancesService#GetWalletBalance", + "PrimeRESTAPI_GetWalletDepositInstructions": "wallets/WalletsService#GetWalletDepositInstructions", + "PrimeRESTAPI_GetWalletTransactions": "transactions/TransactionsService#ListWalletTransactions", + "PrimeRESTAPI_GetWallets": "wallets/WalletsService#ListWallets", + "PrimeRESTAPI_GetWithdrawalPower": "financing/FinancingService#GetPortfolioWithdrawalPower", + "PrimeRESTAPI_GetXMLiquidation": "financing/FinancingService#GetCrossMarginLiquidation", + "PrimeRESTAPI_ListAdvancedTransferTransactions": "advancedtransfer/AdvancedTransferService#ListAdvancedTransferTransactions", + "PrimeRESTAPI_ListAdvancedTransfers": "advancedtransfer/AdvancedTransferService#ListAdvancedTransfers", + "PrimeRESTAPI_ListAggregateEntityPositions": "positions/PositionsService#ListAggregateEntityPositions", + "PrimeRESTAPI_ListEntityBalances": "balances/BalancesService#ListEntityBalances", + "PrimeRESTAPI_ListEntityPositions": "positions/PositionsService#ListEntityPositions", + "PrimeRESTAPI_ListFinancingEligibleAssets": "financing/FinancingService#ListFinancingEligibleAssets", + "PrimeRESTAPI_ListOnchainAddressGroups": "onchainaddressbook/OnchainAddressBookService#ListOnchainAddressGroups", + "PrimeRESTAPI_ListTFObligations": "financing/FinancingService#ListTradeFinanceObligations", + "PrimeRESTAPI_ListTransactionValidators": "staking/StakingService#ListTransactionValidators", + "PrimeRESTAPI_ListWalletAddresses": "wallets/WalletsService#ListWalletAddresses", + "PrimeRESTAPI_ListWeb3WalletBalances": "balances/BalancesService#ListOnchainWalletBalances", + "PrimeRESTAPI_ListXMLiquidations": "financing/FinancingService#ListCrossMarginLiquidations", + "PrimeRESTAPI_OrderPreview": "orders/OrdersService#GetOrderPreview", + "PrimeRESTAPI_PortfolioStakingInitiate": "staking/StakingService#PortfolioStakingInitiate", + "PrimeRESTAPI_PortfolioStakingUnstake": "staking/StakingService#PortfolioStakingUnstake", + "PrimeRESTAPI_PreviewUnstake": "staking/StakingService#PreviewUnstake", + "PrimeRESTAPI_RotateAPIKey": "apikey/ApiKeyService#RotateApiKey", + "PrimeRESTAPI_ScheduleFuturesSweep": "futures/FuturesService#ScheduleEntityFuturesSweep", + "PrimeRESTAPI_SetAutoSweep": "futures/FuturesService#SetAutoSweep", + "PrimeRESTAPI_SetFcmSettings": "futures/FuturesService#SetFcmSettings", + "PrimeRESTAPI_StakingClaimRewards": "staking/StakingService#ClaimRewards", + "PrimeRESTAPI_StakingInitiate": "staking/StakingService#CreateStake", + "PrimeRESTAPI_StakingUnstake": "staking/StakingService#CreateUnstake", + "PrimeRESTAPI_SubmitDepositTravelRuleData": "transactions/TransactionsService#SubmitDepositTravelRuleData", + "PrimeRESTAPI_UpdateFundingSettings": "financing/FinancingService#UpdateFundingSettings", + "PrimeRESTAPI_UpdateOnchainAddressGroup": "onchainaddressbook/OnchainAddressBookService#UpdateOnchainAddressBookEntry" +} From fcfc755f0a2498b14f8357f54c831a4dbd875070 Mon Sep 17 00:00:00 2001 From: Nick Morgan Date: Fri, 6 Nov 2026 13:21:40 -0800 Subject: [PATCH 2/4] feat(generator): route error enums and own generated model files Post-process raw OpenAPI models so *ErrorCode/*Subcode enums land in com.coinbase.prime.model.errors and every other enum keeps its vended package. Model output is tracked in a manifest, so stale files are removed only when the previous generation owned them, and enum directory read errors fail generation instead of emitting partial imports. --- .../model-generator/.openapi-generator-ignore | 7 +- .../GeneratedSourceReconciler.java | 187 ++++++++++++++++ .../coinbase/tools/modelgenerator/Main.java | 3 +- .../modelgenerator/OpenApiGenerator.java | 12 +- .../tools/modelgenerator/PostProcessor.java | 211 ++++++++---------- .../ErrorSubcodeGenerationTest.java | 140 ++++++++++++ .../PostProcessorJavadocTest.java | 6 +- 7 files changed, 443 insertions(+), 123 deletions(-) create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedSourceReconciler.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/ErrorSubcodeGenerationTest.java 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/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/Main.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/Main.java index e95d6080..4db37d09 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 @@ -36,6 +36,7 @@ public static void main(String[] args) { Path projectRoot = findProjectRoot(); Path outputDir = projectRoot.resolve("src/main/java/com/coinbase/prime/model"); Path enumsDir = outputDir.resolve("enums"); + Path errorsDir = outputDir.resolve("errors"); Path tempDir = projectRoot.resolve("generated"); logger.info("Project Root: {}", projectRoot); @@ -54,7 +55,7 @@ public static void main(String[] args) { // 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 postProcessor = new PostProcessor(tempDir, outputDir, enumsDir, errorsDir, specPath); postProcessor.processModels(); logger.info("\nModel generation completed successfully!"); 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/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/test/java/com/coinbase/tools/modelgenerator/ErrorSubcodeGenerationTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/ErrorSubcodeGenerationTest.java new file mode 100644 index 00000000..85a1aef8 --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/ErrorSubcodeGenerationTest.java @@ -0,0 +1,140 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +class ErrorSubcodeGenerationTest { + @Test + void classifiesErrorCodeAndSubcodeEnumsSeparatelyFromDomainEnums() { + assertTrue(GeneratedEnumKind.isSubcode("CreateOrderBadRequestSubcode")); + assertTrue(GeneratedEnumKind.isErrorCode("BadRequestErrorCode")); + assertTrue(GeneratedEnumKind.isErrorEnum("CreateOrderBadRequestSubcode")); + assertTrue(GeneratedEnumKind.isErrorEnum("BadRequestErrorCode")); + assertFalse(GeneratedEnumKind.isErrorEnum("OrderSide")); + assertEquals( + GeneratedEnumKind.ERRORS_PACKAGE, + GeneratedEnumKind.packageFor("CreateOrderBadRequestSubcode")); + assertEquals(GeneratedEnumKind.ERRORS_PACKAGE, GeneratedEnumKind.packageFor("BadRequestErrorCode")); + assertEquals(GeneratedEnumKind.ENUMS_PACKAGE, GeneratedEnumKind.packageFor("OrderSide")); + } + + @Test + void postProcessorRoutesSubcodesAndFixesModelImports() throws Exception { + Path root = Files.createTempDirectory("error-subcode-generation"); + Path generatedModels = root.resolve("generated/raw/src/main/java/com/coinbase/prime/model"); + Path modelRoot = root.resolve("src/main/java/com/coinbase/prime/model"); + Path enumsRoot = modelRoot.resolve("enums"); + Path errorsRoot = modelRoot.resolve("errors"); + Path spec = root.resolve("openapi.yaml"); + Files.createDirectories(generatedModels); + Files.writeString(spec, "openapi: 3.0.0\ncomponents:\n schemas: {}\n"); + Files.writeString( + generatedModels.resolve("PrimeRESTAPICreateThingBadRequestSubcode.java"), + "package com.coinbase.prime.model;\n" + + "public enum PrimeRESTAPICreateThingBadRequestSubcode { INVALID }\n"); + Files.writeString( + generatedModels.resolve("BadRequestErrorCode.java"), + "package com.coinbase.prime.model;\n" + + "public enum BadRequestErrorCode { INVALID }\n"); + Files.writeString( + generatedModels.resolve("ErrorEnvelope.java"), + "package com.coinbase.prime.model;\n" + + "import com.coinbase.prime.model.PrimeRESTAPICreateThingBadRequestSubcode;\n" + + "public class ErrorEnvelope {\n" + + " private PrimeRESTAPICreateThingBadRequestSubcode subcode;\n" + + "}\n"); + + new PostProcessor(root.resolve("generated"), modelRoot, enumsRoot, errorsRoot, spec).processModels(); + + Path errorEnum = errorsRoot.resolve("CreateThingBadRequestSubcode.java"); + assertTrue(Files.exists(errorEnum)); + assertFalse(Files.exists(enumsRoot.resolve("CreateThingBadRequestSubcode.java"))); + assertTrue(Files.readString(errorEnum).contains("package com.coinbase.prime.model.errors;")); + assertTrue(Files.exists(errorsRoot.resolve("BadRequestErrorCode.java"))); + assertTrue(Files.readString(modelRoot.resolve("ErrorEnvelope.java")) + .contains("import com.coinbase.prime.model.errors.CreateThingBadRequestSubcode;")); + } + + @Test + void actualOpenApiGenerationDoesNotIgnoreErrorEnumsBeforeErrorsRouting() throws Exception { + Path root = Files.createTempDirectory("raw-error-enum-generation"); + Path spec = root.resolve("openapi.yaml"); + Files.writeString( + spec, + "openapi: 3.0.0\n" + + "info: { title: test, version: 1.0.0 }\n" + + "paths: {}\n" + + "components:\n" + + " schemas:\n" + + " PrimeRESTAPI_CreateThingBadRequestSubcode:\n" + + " type: string\n" + + " enum: [INVALID]\n"); + Path projectRoot = Path.of(System.getProperty("user.dir")).toAbsolutePath().getParent().getParent(); + Path generated = root.resolve("generated"); + Path sourceRoot = root.resolve("src/main/java"); + Path modelRoot = sourceRoot.resolve("com/coinbase/prime/model"); + + new OpenApiGenerator(spec.toString(), generated, projectRoot).generateModels(); + new PostProcessor( + generated, + sourceRoot, + modelRoot, + modelRoot.resolve("enums"), + modelRoot.resolve("errors"), + spec, + root.resolve("generated-model-files.json")) + .processModels(); + + assertTrue(Files.exists(modelRoot.resolve("errors/CreateThingBadRequestSubcode.java"))); + } + + @Test + void cleansOnlyManifestOwnedModelsAndSkipsIgnoredResponseSchemas() throws Exception { + Path root = Files.createTempDirectory("model-manifest-generation"); + Path rawModels = root.resolve("generated/raw/src/main/java/com/coinbase/prime/model"); + Path sourceRoot = root.resolve("src/main/java"); + Path modelRoot = sourceRoot.resolve("com/coinbase/prime/model"); + Path manifest = root.resolve("generated-model-files.json"); + Path spec = root.resolve("openapi.yaml"); + Files.createDirectories(rawModels); + Files.createDirectories(modelRoot); + Files.writeString(spec, "openapi: 3.0.0\ncomponents:\n schemas: {}\n"); + Files.writeString(rawModels.resolve("Fresh.java"), + "package com.coinbase.prime.model;\npublic class Fresh {}\n"); + Files.writeString(rawModels.resolve("IgnoredResponse.java"), + "package com.coinbase.prime.model;\npublic class IgnoredResponse {}\n"); + Files.writeString(modelRoot.resolve("Stale.java"), "stale"); + Files.writeString(modelRoot.resolve("HandWritten.java"), "keep"); + Files.writeString(manifest, "[\n \"com/coinbase/prime/model/Stale.java\"\n]\n"); + + new PostProcessor( + root.resolve("generated"), sourceRoot, modelRoot, modelRoot.resolve("enums"), + modelRoot.resolve("errors"), spec, manifest).processModels(); + + assertTrue(Files.exists(modelRoot.resolve("Fresh.java"))); + assertFalse(Files.exists(modelRoot.resolve("IgnoredResponse.java"))); + assertFalse(Files.exists(modelRoot.resolve("Stale.java"))); + assertTrue(Files.exists(modelRoot.resolve("HandWritten.java"))); + assertTrue(Files.readString(manifest).contains("Fresh.java")); + } +} diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/PostProcessorJavadocTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/PostProcessorJavadocTest.java index c7813640..5b6a6db3 100644 --- a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/PostProcessorJavadocTest.java +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/PostProcessorJavadocTest.java @@ -25,9 +25,9 @@ class PostProcessorJavadocTest { @Test - void isSkippedErrorSchema_skipsTypedErrorEnumsAndResponses() { - assertTrue(PostProcessor.isSkippedErrorSchema("BadRequestErrorCode.java")); - assertTrue(PostProcessor.isSkippedErrorSchema("RotateAPIKeyForbiddenSubcode.java")); + void isSkippedErrorSchema_preservesErrorEnumsAndSkipsOnlyErrorResponses() { + assertFalse(PostProcessor.isSkippedErrorSchema("BadRequestErrorCode.java")); + assertFalse(PostProcessor.isSkippedErrorSchema("RotateAPIKeyForbiddenSubcode.java")); assertTrue(PostProcessor.isSkippedErrorSchema("GetConversionFeesForbiddenErrorResponse.java")); assertFalse(PostProcessor.isSkippedErrorSchema("TransactionType.java")); assertFalse(PostProcessor.isSkippedErrorSchema("Order.java")); From 8534ea3c429bcaca4fbf56cffd664d19d574f33a Mon Sep 17 00:00:00 2001 From: Nick Morgan Date: Fri, 6 Nov 2026 13:22:09 -0800 Subject: [PATCH 3/4] feat(generator): generate requests, responses, services, and factory Emit the client surface from operation bindings: request DTOs with builders, validation, and PrimeListRequest pagination; response DTOs; service interfaces and implementations with /v1 and /v2 routing; and PrimeServiceFactory. Main renders and validates the whole surface before writing any file, and adds isolated --check and --live-diff modes that never mutate the checkout (make check-generated, make generate-live-diff). CoinbasePrimeClient exposes its base URL and keeps an injected HttpClient in withBaseUrl, which generated /v2 calls use. --- .gitignore | 1 + Makefile | 16 +- .../prime/client/CoinbasePrimeClient.java | 26 +- .../prime/client/CoinbasePrimeClientTest.java | 57 +++ .../tools/modelgenerator/FactoryPhase.java | 39 ++ .../coinbase/tools/modelgenerator/Main.java | 303 ++++++++++++---- .../tools/modelgenerator/RequestPhase.java | 212 +++++++++++ .../tools/modelgenerator/ResponsePhase.java | 159 +++++++++ .../tools/modelgenerator/ServicePhase.java | 163 +++++++++ .../tools/modelgenerator/SourceTemplates.java | 41 +++ .../ClientSurfaceGenerationTest.java | 337 ++++++++++++++++++ .../tools/modelgenerator/MainCheckTest.java | 248 +++++++++++++ .../OperationBindingGeneratorTest.java | 12 + 13 files changed, 1529 insertions(+), 85 deletions(-) create mode 100644 src/test/java/com/coinbase/prime/client/CoinbasePrimeClientTest.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/FactoryPhase.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/RequestPhase.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ResponsePhase.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ServicePhase.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SourceTemplates.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/ClientSurfaceGenerationTest.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/MainCheckTest.java 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/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/Main.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/Main.java index 4db37d09..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,93 +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 errorsDir = outputDir.resolve("errors"); - 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, errorsDir, 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/RequestPhase.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/RequestPhase.java new file mode 100644 index 00000000..ca1eee0e --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/RequestPhase.java @@ -0,0 +1,212 @@ +/* + * 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)); + } + 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 private 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(); + } + + 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..eb35ca7a --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ResponsePhase.java @@ -0,0 +1,159 @@ +/* + * 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)); + } + 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 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..e5af3dc6 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ServicePhase.java @@ -0,0 +1,163 @@ +/* + * 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()); + appendMethodJavadoc(source, operation, binding, true); + source.append(" ").append(binding.sdkMethod()).append("Response ").append(names.methodName(binding.sdkMethod())).append("("); + if (!binding.omitRequest()) source.append(binding.sdkMethod()).append("Request request"); + source.append(") throws CoinbaseClientException, CoinbasePrimeException;\n\n"); + } + return source.append("}\n").toString(); + } + 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()); + String method = names.methodName(binding.sdkMethod()); String version = version(operation.path()); + source.append("\n"); + appendMethodJavadoc(source, operation, binding, false); + source.append(" @Override\n public ").append(binding.sdkMethod()).append("Response ").append(method).append("("); + if (!binding.omitRequest()) source.append(binding.sdkMethod()).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(binding.sdkMethod()).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(binding.sdkMethod()).append("Response>() {});\n }\n"); + } + } + return source.append("}\n").toString(); + } + private static void appendMethodJavadoc( + StringBuilder source, + SpecModels.Operation operation, + OperationBinding binding, + boolean interfaceMethod) { + 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(binding.sdkMethod()).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"); + 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/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/test/java/com/coinbase/tools/modelgenerator/ClientSurfaceGenerationTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/ClientSurfaceGenerationTest.java new file mode 100644 index 00000000..89967f5f --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/ClientSurfaceGenerationTest.java @@ -0,0 +1,337 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ClientSurfaceGenerationTest { + @Test + void rendersRequestsResponsesServicesAndFactoryFromFixture() throws Exception { + SpecModels.Document document = fixture(); + List bindings = OperationBindingGenerator.deriveAll(document); + NamingResolver names = new NamingResolver(); + JavaTypeResolver types = new JavaTypeResolver(document, names); + 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)); + String listRequest = sources.get(Path.of("com/coinbase/prime/orders/ListThingsRequest.java")); + assertTrue(listRequest.contains("extends PrimeListRequest"), listRequest); + assertTrue(listRequest.contains("super(builder.cursor, builder.sortDirection, builder.limit);"), listRequest); + assertTrue(listRequest.contains("Builder cursor(String cursor)"), listRequest); + assertTrue(listRequest.contains("Builder sortDirection(SortDirection sortDirection)"), listRequest); + assertTrue(listRequest.contains("Builder limit(Integer limit)"), listRequest); + assertTrue(listRequest.contains("Builder pagination(Pagination pagination)"), listRequest); + assertTrue(listRequest.contains("private ThingState state;"), listRequest); + assertTrue(listRequest.contains("List state"), listRequest); + assertFalse(listRequest.contains("request_id"), listRequest); + assertFalse(listRequest.contains("session_id"), listRequest); + String request = sources.get(Path.of("com/coinbase/prime/orders/CreateThingRequest.java")); + assertTrue(request.contains("@JsonIgnore")); + assertTrue(request.contains("private Thing[] things")); + assertTrue(request.contains("PortfolioId is required")); + // Optional booleans stay boxed like the vended DTOs so unset values are omitted. + assertTrue(request.contains("private Boolean isBuyExact;"), request); + assertTrue(request.contains("public Boolean getIsBuyExact()"), request); + assertTrue(request.contains("public void setIsBuyExact(Boolean isBuyExact)"), request); + assertTrue(request.contains("Buy exact flag"), request); + String response = sources.get(Path.of("com/coinbase/prime/orders/CreateThingResponse.java")); + assertTrue(response.contains("private OnchainThing thing")); + assertTrue(response.contains("Created thing"), response); + String implementation = sources.get(Path.of("com/coinbase/prime/orders/OrdersServiceImpl.java")); + assertTrue(implementation.contains("String.format(\"/portfolios/%s/things\", request.getPortfolioId())")); + assertTrue(implementation.contains("List.of(201, 200)")); + assertOrdered(implementation, "listThings", "createThing"); + String service = sources.get(Path.of("com/coinbase/prime/orders/OrdersService.java")); + assertTrue(service.contains("List Things. Includes query filtering."), service); + assertTrue(service.contains("@param request request parameters and body for this operation"), service); + assertTrue(service.contains("@return the decoded ListThings response"), service); + assertTrue(service.contains("@throws CoinbaseClientException"), service); + assertTrue(sources.get(Path.of("com/coinbase/prime/factory/PrimeServiceFactory.java")).contains("createOrdersService")); + } + + @Test + void rendersV2TransportWithSendRequestStatusBeforeBody() throws Exception { + SpecModels.Document document = fixture(); + List bindings = OperationBindingGenerator.deriveAll(document); + NamingResolver names = new NamingResolver(); + String implementation = ServicePhase.render(document, bindings, configuration(), names) + .get(Path.of("com/coinbase/prime/financing/FinancingServiceImpl.java")); + + assertTrue(implementation.contains(".sendRequest(HttpMethod.GET,"), implementation); + assertTrue(implementation.contains("String.format(\"/entities/%s/cross_margin/prime\", request.getEntityId())"), implementation); + assertOrdered(implementation, "List.of(200),", "request,"); + } + + @Test + void rendersConfiguredStatusCodes() throws Exception { + SpecModels.Document document = fixture(); + GeneratorConfiguration configuration = + configuration( + "{\"specUrl\":\"x\",\"committedSpecPath\":\"x\"}", + "[{\"operationId\":\"PrimeRESTAPI_CreateThing\",\"statusCodes\":[202]}]"); + String implementation = + ServicePhase.render( + document, + OperationBindingGenerator.deriveAll(document, configuration), + configuration, + new NamingResolver()) + .get(Path.of("com/coinbase/prime/orders/OrdersServiceImpl.java")); + + assertTrue(implementation.contains("List.of(202)"), implementation); + } + + @Test + void rejectsOneServiceNameBoundToTwoFolders() throws Exception { + SpecModels.Document document = fixture(); + GeneratorConfiguration configuration = + configuration( + "{\"specUrl\":\"x\",\"committedSpecPath\":\"x\"}", + "[{\"operationId\":\"PrimeRESTAPI_GetCrossMarginPrimeOverview\",\"serviceName\":\"OrdersService\"}]"); + + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> OperationBindingGenerator.deriveAll(document, configuration)); + assertTrue(error.getMessage().contains("OrdersService"), error.getMessage()); + } + + @Test + void routesTravelRuleToTheCanonicalTransactionsService() throws Exception { + SpecModels.Document document = fixture(); + List bindings = OperationBindingGenerator.deriveAll(document, routingConfiguration()); + NamingResolver names = new NamingResolver(); + Map services = ServicePhase.render(document, bindings, routingConfiguration(), names); + Map factory = FactoryPhase.render(bindings); + + assertTrue(services.containsKey(Path.of("com/coinbase/prime/transactions/TransactionsService.java"))); + assertFalse(services.containsKey(Path.of("com/coinbase/prime/transactions/TravelRuleService.java"))); + assertTrue(factory.get(Path.of("com/coinbase/prime/factory/PrimeServiceFactory.java")) + .contains("createTransactionsService")); + assertFalse(factory.get(Path.of("com/coinbase/prime/factory/PrimeServiceFactory.java")) + .contains("TravelRuleService")); + } + + @Test + void resolvesPostProcessorModelNamesWithoutPreNormalizationReplacement() throws Exception { + NamingResolver names = + new NamingResolver( + Map.of( + "CreateOnchainTransactionRequestEVMParams", "EvmParams", + "FcmFuturesSweep", "FuturesSweep")); + JavaTypeResolver types = new JavaTypeResolver(fixture(), names); + + assertEquals( + "EvmParams", + types + .resolve( + Collections.singletonMap( + "$ref", + "#/components/schemas/CoinbasePublicRestApiCreateOnchainTransactionRequestEVMParams")) + .name()); + assertEquals( + "FuturesSweep", + types + .resolve( + Collections.singletonMap( + "$ref", "#/components/schemas/FcmFuturesSweep")) + .name()); + } + + @Test + void rejectsCaseInsensitiveGeneratedFilenameCollisions() throws Exception { + Path root = Files.createTempDirectory("generator-case-collision"); + Files.writeString(root.resolve("RotateApiKeyRequest.java"), "existing", StandardCharsets.UTF_8); + Map generated = + Collections.singletonMap(Path.of("RotateAPIKeyRequest.java"), "public class RotateAPIKeyRequest {}\n"); + + IOException exception = + assertThrows( + IOException.class, + () -> GeneratedSourceReconciler.write(root, generated, Collections.emptySet(), null)); + + assertTrue(exception.getMessage().contains("case-insensitively"), exception.getMessage()); + } + + @Test + void resolvesConfiguredSharedModelsWithoutGeneratingModelCopies() throws Exception { + JavaTypeResolver types = new JavaTypeResolver( + fixture(), new NamingResolver(), + Collections.singletonMap("PaginatedResponse", "com.coinbase.prime.common.Pagination")); + JavaTypeResolver.Type pagination = types.resolve( + Collections.singletonMap("$ref", "#/components/schemas/coinbase.public_rest_api.PaginatedResponse")); + + assertEquals("Pagination", pagination.name()); + assertEquals(Collections.singleton("com.coinbase.prime.common.Pagination"), pagination.imports()); + } + + @Test + void resolvesEnumsArraysMapsAndVersionedPaths() throws Exception { + JavaTypeResolver types = new JavaTypeResolver(fixture(), new NamingResolver()); + assertEquals("ThingState", types.resolve(Collections.singletonMap("$ref", "#/components/schemas/ThingState")).name()); + JavaTypeResolver.Type subcode = types.resolve(Collections.singletonMap("$ref", "#/components/schemas/ThingProblemSubcode")); + assertEquals("ThingProblemSubcode", subcode.name()); + assertEquals(Collections.singleton("com.coinbase.prime.model.errors.ThingProblemSubcode"), subcode.imports()); + assertEquals("Map", types.resolve(map("type", "object", "additionalProperties", map("type", "string"))).name()); + assertEquals("v2", ServicePhase.version("/v2/things")); + assertThrows(IllegalArgumentException.class, () -> ServicePhase.version("/v3/things")); + } + + @Test + void failsFastForInlineEnumWithoutANamedPublicType() throws Exception { + JavaTypeResolver types = new JavaTypeResolver(fixture(), new NamingResolver()); + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> types.resolve(map("type", "string", "enum", List.of("NOT_A_PUBLIC_ENUM")))); + assertTrue(exception.getMessage().contains("Inline enum cannot be safely resolved")); + } + + @Test + void emitsNonObjectResponsesWithoutSummaryAndWithImports() throws Exception { + Path spec = Files.createTempFile("non-object-response", ".yaml"); + Files.writeString( + spec, + "openapi: 3.0.0\n" + + "paths:\n" + + " /v1/value:\n" + + " get:\n" + + " operationId: PrimeRESTAPI_GetValue\n" + + " tags: [Values]\n" + + " responses:\n" + + " '200':\n" + + " content:\n" + + " application/json:\n" + + " schema: { type: array, items: { type: string } }\n"); + SpecModels.Document document = SpecParser.load(spec); + List bindings = OperationBindingGenerator.deriveAll(document); + String response = + ResponsePhase.render(document, bindings, new JavaTypeResolver(document, new NamingResolver()), new NamingResolver()) + .get(Path.of("com/coinbase/prime/values/GetValueResponse.java")); + assertTrue(response.contains("import com.fasterxml.jackson.annotation.JsonProperty;"), response); + assertTrue(response.contains("private String[] value;"), response); + } + + @Test + void preservesExistingClientSourceHeaderAndSignalsProtectedDrift() throws Exception { + Path root = Files.createTempDirectory("client-source-header"); + Path relative = Path.of("com/coinbase/prime/orders/Thing.java"); + Path existing = root.resolve(relative); + Files.createDirectories(existing.getParent()); + Files.writeString( + existing, + "/*\n * Copyright 2021-present Coinbase Global, Inc.\n */\nclass Thing {}\n", + StandardCharsets.UTF_8); + Map sources = Collections.singletonMap(relative, SourceTemplates.header() + "class Thing {}\n"); + Map headed = Main.preserveClientSourceHeaders(sources, root); + assertTrue(headed.get(relative).contains("Copyright 2021-present")); + assertTrue( + GeneratedSourceReconciler.diff(root, headed, Collections.singleton(relative.toString()), null) + .contains("SKIP " + relative)); + } + + @Test + void reconcilesOnlyManifestOwnedFiles() throws Exception { + Path root = Files.createTempDirectory("generator-reconcile"); + Path manifest = root.resolve("manifest.json"); + Files.writeString(root.resolve("owned.java"), "old", StandardCharsets.UTF_8); + Files.writeString(root.resolve("hand-written.java"), "keep", StandardCharsets.UTF_8); + Files.writeString(manifest, "[\n \"owned.java\",\n \"gone.java\"\n]\n", StandardCharsets.UTF_8); + Map generated = Collections.singletonMap(Path.of("owned.java"), "new"); + GeneratedSourceReconciler.write(root, generated, Collections.emptySet(), manifest); + assertEquals("new", Files.readString(root.resolve("owned.java"))); + assertEquals("keep", Files.readString(root.resolve("hand-written.java"))); + assertFalse(Files.exists(root.resolve("gone.java"))); + } + + private static void assertOrdered(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue(firstIndex >= 0 && secondIndex >= 0, source); + assertTrue(firstIndex < secondIndex, source); + } + + private static GeneratorConfiguration configuration() throws Exception { + return configuration("{\"specUrl\":\"x\",\"committedSpecPath\":\"x\"}", "[]"); + } + + private static GeneratorConfiguration routingConfiguration() throws Exception { + return configuration( + "{\"specUrl\":\"x\",\"committedSpecPath\":\"x\",\"tagToFolderOverrides\":{\"Travel Rule\":\"transactions\"}}", + "[]"); + } + + private static GeneratorConfiguration configuration(String configContent, String overridesContent) + throws Exception { + Path directory = Files.createTempDirectory("generator-config"); + Path config = directory.resolve("generator.json"); + Path overrides = directory.resolve("overrides.json"); + Files.writeString(config, configContent); + Files.writeString(overrides, overridesContent); + return GeneratorConfiguration.loadForTests(config, overrides); + } + + private static SpecModels.Document fixture() throws Exception { + Path spec = Files.createTempFile("prime-generator-fixture", ".yaml"); + Files.writeString(spec, String.join("\n", + "openapi: 3.0.0", "paths:", " /v1/portfolios/{portfolio_id}/things:", " parameters:", + " - name: portfolio_id", " in: path", " required: true", " schema: { type: string }", + " get:", " operationId: PrimeRESTAPI_GetThings", " tags: [Orders]", " summary: List Things", " description: Includes query filtering.", + " parameters:", " - name: cursor", " in: query", " schema: { type: string }", + " - name: state", " in: query", " description: List state", " schema: { type: string, enum: [OPEN] }", + " - name: request_id", " in: header", " schema: { type: string }", + " - name: session_id", " in: cookie", " schema: { type: string }", + " responses:", " '200':", " content:", " application/json:", " schema:", + " type: object", " properties:", " items:", " type: array", + " items: { $ref: '#/components/schemas/Web3Thing' }", + " post:", " operationId: PrimeRESTAPI_CreateThing", " tags: [Orders]", " summary: Create Thing", + " requestBody:", " content:", " application/json:", " schema:", " type: object", + " properties:", " things:", " type: array", " items: { $ref: '#/components/schemas/Thing' }", + " is_buy_exact: { type: boolean, description: Buy exact flag }", + " responses:", " '200':", " content:", " application/json:", " schema:", + " type: object", " properties:", " thing: { $ref: '#/components/schemas/Web3Thing', description: Created thing }", + " /v2/entities/{entity_id}/cross_margin/prime:", " get:", " operationId: PrimeRESTAPI_GetCrossMarginPrimeOverview", " tags: [Financing]", " summary: Get Cross Margin Prime Overview", + " parameters:", " - name: entity_id", " in: path", " required: true", " schema: { type: string }", + " responses:", " '200':", " content:", " application/json:", " schema: { type: object }", + " /v1/portfolios/{portfolio_id}/transactions/{transaction_id}/travel_rule:", " get:", " operationId: PrimeRESTAPI_GetTransactionTravelRuleData", " tags: [Travel Rule]", " summary: Get Transaction Travel Rule Data", + " parameters:", " - name: portfolio_id", " in: path", " required: true", " schema: { type: string }", + " - name: transaction_id", " in: path", " required: true", " schema: { type: string }", + " responses:", " '200':", " content:", " application/json:", " schema: { type: object }", + "components:", " schemas:", " Thing: { type: object }", " Web3Thing: { type: object }", " PaginatedResponse: { type: object }", " ThingState: { type: string, enum: [OPEN] }", + " ThingProblemSubcode: { type: string, enum: [INVALID] }", "")); + return SpecParser.load(spec); + } + + private static Map map(Object... entries) { + Map value = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) value.put((String) entries[index], entries[index + 1]); + return value; + } +} diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/MainCheckTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/MainCheckTest.java new file mode 100644 index 00000000..4594e02a --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/MainCheckTest.java @@ -0,0 +1,248 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.Test; + +class MainCheckTest { + @Test + void checkCliLeavesFixtureSourcesAndManifestStateByteForByteUnchanged() throws Exception { + Path root = Files.createTempDirectory("isolated-generator-check"); + try { + writeFixture(root); + Map before = snapshot(root); + + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> { + Main.run(new String[] {"--check"}, GeneratorPaths.forRoot(root)); + }); + + assertTrue(exception.getMessage().contains("out of date"), exception.getMessage()); + assertEquals(before, snapshot(root)); + assertFalse(Files.exists(root.resolve("generated"))); + assertFalse(Files.exists(root.resolve("tools/model-generator/generated-files.json"))); + assertFalse(Files.exists(root.resolve("tools/model-generator/generated-model-files.json"))); + assertFalse(Files.exists(root.resolve("src/main/java/com/coinbase/prime/things/ListThingsResponse.java"))); + assertFalse(Files.exists(root.resolve("src/main/java/com/coinbase/prime/model/Thing.java"))); + } finally { + FileUtils.deleteDirectory(root.toFile()); + } + } + + @Test + void checkReportsProtectedCompatibilityDriftWithoutChangingProjectFiles() throws Exception { + Path root = Files.createTempDirectory("protected-generator-check"); + Path protectedRelative = Path.of("com/coinbase/prime/things/ListThingsResponse.java"); + Path protectedFile = root.resolve("src/main/java").resolve(protectedRelative); + try { + writeFixture(root); + Files.writeString( + root.resolve("tools/model-generator/config/generator-config.json"), + "{\"specUrl\":\"unused\",\"committedSpecPath\":\"apiSpec/openapi.yaml\"," + + "\"protectedCompatibilityFiles\":[\"" + + protectedRelative + + "\"]}\n"); + Files.createDirectories(protectedFile.getParent()); + Files.writeString( + protectedFile, + "package com.coinbase.prime.things;\npublic class ListThingsResponse {}\n"); + Main.run(new String[0], GeneratorPaths.forRoot(root)); + Main.formatStagedSources(root); + Files.writeString( + protectedFile, + "package com.coinbase.prime.things;\n// Compatibility implementation.\n" + + "public class ListThingsResponse {}\n"); + Map before = snapshot(root); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + PrintStream originalOut = System.out; + try { + System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8)); + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> Main.run(new String[] {"--check"}, GeneratorPaths.forRoot(root))); + assertTrue(exception.getMessage().contains("out of date"), exception.getMessage()); + } finally { + System.setOut(originalOut); + } + + assertTrue( + output.toString(StandardCharsets.UTF_8).contains("SKIP " + protectedRelative), + output.toString(StandardCharsets.UTF_8)); + assertEquals(before, snapshot(root)); + assertFalse(Files.exists(root.resolve("generated"))); + } finally { + FileUtils.deleteDirectory(root.toFile()); + } + } + + @Test + void liveDiffUsesFormattedIsolatedOutputAndDeletesDownloadedSpec() throws Exception { + Path root = Files.createTempDirectory("live-generator-diff"); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + try { + writeFixture(root); + byte[] spec = Files.readAllBytes(root.resolve("apiSpec/openapi.yaml")); + server.createContext( + "/openapi.yaml", + exchange -> { + exchange.sendResponseHeaders(200, spec.length); + exchange.getResponseBody().write(spec); + exchange.close(); + }); + server.start(); + Files.writeString( + root.resolve("tools/model-generator/config/generator-config.json"), + "{\"specUrl\":\"http://127.0.0.1:" + + server.getAddress().getPort() + + "/openapi.yaml\",\"committedSpecPath\":\"apiSpec/openapi.yaml\"}\n"); + + Main.run(new String[0], GeneratorPaths.forRoot(root)); + Main.formatStagedSources(root); + Map beforeLiveDiff = snapshot(root); + Main.run(new String[] {"--live-diff"}, GeneratorPaths.forRoot(root)); + + assertEquals(beforeLiveDiff, snapshot(root)); + assertFalse(Files.exists(root.resolve("generated"))); + } finally { + server.stop(0); + FileUtils.deleteDirectory(root.toFile()); + } + } + + @Test + void failedLiveFetchDeletesPartialTemporarySpec() throws Exception { + Path root = Files.createTempDirectory("failed-live-generator-diff"); + try { + writeFixture(root); + Files.writeString( + root.resolve("tools/model-generator/config/generator-config.json"), + "{\"specUrl\":\"http://127.0.0.1:1/unavailable\",\"committedSpecPath\":\"apiSpec/openapi.yaml\"}\n"); + assertThrows(Exception.class, () -> Main.run(new String[] {"--live-diff"}, GeneratorPaths.forRoot(root))); + Path generated = root.resolve("generated"); + assertFalse(Files.exists(generated.resolve("prime-public-spec-.yaml"))); + if (Files.exists(generated)) { + try (Stream paths = Files.list(generated)) { + assertFalse(paths.anyMatch(path -> path.getFileName().toString().endsWith(".yaml"))); + } + } + } finally { + FileUtils.deleteDirectory(root.toFile()); + } + } + + private static void writeFixture(Path root) throws Exception { + Path sourceRoot = root.resolve("src/main/java/com/coinbase/prime"); + Files.createDirectories(sourceRoot); + Files.createDirectories(root.resolve("apiSpec")); + Files.createDirectories(root.resolve("tools/model-generator/config")); + Files.writeString(root.resolve("pom.xml"), fixturePom()); + Files.writeString(sourceRoot.resolve("Existing.java"), "package com.coinbase.prime;\nclass Existing {}\n"); + Files.writeString( + root.resolve("tools/model-generator/config/generator-config.json"), + "{\"specUrl\":\"unused\",\"committedSpecPath\":\"apiSpec/openapi.yaml\"}\n"); + Files.writeString(root.resolve("tools/model-generator/config/operations-overrides.json"), "[]\n"); + Files.writeString( + root.resolve("apiSpec/openapi.yaml"), + String.join( + "\n", + "openapi: 3.0.0", + "info: { title: test, version: 1.0.0 }", + "paths:", + " /v1/things:", + " get:", + " operationId: PrimeRESTAPI_ListThings", + " tags: [Things]", + " responses:", + " '200':", + " description: Success", + " content:", + " application/json:", + " schema:", + " type: object", + " properties:", + " thing: { $ref: '#/components/schemas/Thing' }", + "components:", + " schemas:", + " Thing:", + " type: object", + " properties:", + " id: { type: string }", + "")); + } + + private static String fixturePom() { + return String.join( + "\n", + "", + " 4.0.0", + " test", + " fixture", + " 1.0.0", + " ", + " com.diffplug.spotless", + " spotless-maven-plugin", + " 2.43.0", + " ", + " 1.24.0", + " ", + " ", + "", + ""); + } + + private static Map snapshot(Path root) throws Exception { + Map files = new LinkedHashMap<>(); + try (Stream paths = Files.walk(root)) { + List regularFiles = paths.filter(Files::isRegularFile).sorted().collect(Collectors.toList()); + for (Path file : regularFiles) { + files.put(root.relativize(file).toString(), sha256(Files.readAllBytes(file))); + } + } + return files; + } + + private static String sha256(byte[] content) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(content); + StringBuilder hex = new StringBuilder(); + for (byte value : digest) { + hex.append(String.format("%02x", value)); + } + return hex.toString(); + } +} diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java index eb6af95f..91761378 100644 --- a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java @@ -68,6 +68,18 @@ void preservesConfiguredCompatibilityNamesAndRequestShapes() throws Exception { NamingResolver names = new NamingResolver(configuration.modelTypeMappings()); + JavaTypeResolver types = + new JavaTypeResolver(document, names, configuration.sharedModelMappings()); + String orders = + RequestPhase.render(document, bindings, types, names) + .get(Path.of("com/coinbase/prime/orders/ListPortfolioOrdersRequest.java")); + assertTrue(orders.contains("private OrderStatus[] orderStatuses;"), orders); + assertTrue(orders.contains("private String[] productIds;"), orders); + String wallet = + RequestPhase.render(document, bindings, types, names) + .get(Path.of("com/coinbase/prime/wallets/CreateWalletRequest.java")); + assertTrue(wallet.contains("private WalletType type;"), wallet); + assertTrue(wallet.contains("Builder type(WalletType type)"), wallet); assertEquals( "PrimeXMControlStatus", names.typeName("CoinbasePublicRestApiXMControlStatus")); From 4d3c5e4167d110702bb33eea6c123e17048ad505 Mon Sep 17 00:00:00 2001 From: Nick Morgan Date: Fri, 6 Nov 2026 13:22:09 -0800 Subject: [PATCH 4/4] feat(generator): generate deprecated Java aliases for canonical names Converge Java method and type names on the canonical names shared with the .NET, TypeScript, Python, and Go Prime SDKs. Each rename is declared in operations-overrides.json with deprecatedAliases; the generator emits a @Deprecated request subclass with a covariant Builder, a @Deprecated response subclass, and @Deprecated forwarding service methods, so existing callers keep compiling. naming-parity.json and NamingParityTest lock the cross-SDK mapping, and the operation inventory records the 11 renames with their aliases. --- .../model-generator/config/naming-parity.json | 100 +++++++ .../config/operations-overrides.json | 102 ++++++- .../tools/modelgenerator/DeprecatedAlias.java | 44 +++ .../GeneratorConfiguration.java | 31 +- .../modelgenerator/OperationBinding.java | 11 +- .../OperationBindingGenerator.java | 11 +- .../OperationBindingValidator.java | 10 + .../tools/modelgenerator/RequestPhase.java | 89 +++++- .../tools/modelgenerator/ResponsePhase.java | 34 +++ .../tools/modelgenerator/ServicePhase.java | 89 ++++-- .../DeprecatedAliasGenerationTest.java | 281 ++++++++++++++++++ .../modelgenerator/NamingParityTest.java | 171 +++++++++++ .../OperationBindingGeneratorTest.java | 10 +- .../OperationInventoryTest.java | 16 +- .../test/resources/operation-inventory.json | 22 +- 15 files changed, 965 insertions(+), 56 deletions(-) create mode 100644 tools/model-generator/config/naming-parity.json create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/DeprecatedAlias.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/DeprecatedAliasGenerationTest.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/NamingParityTest.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 index e8fdb901..ffd38120 100644 --- a/tools/model-generator/config/operations-overrides.json +++ b/tools/model-generator/config/operations-overrides.json @@ -21,11 +21,18 @@ }, { "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", @@ -71,11 +78,32 @@ }, { "operationId": "PrimeRESTAPI_GetOrder", - "sdkMethod": "GetOrderByOrderId" + "deprecatedAliases": [ + { + "sdkMethod": "GetOrderByOrderId", + "message": "Use getOrder instead." + } + ] }, { "operationId": "PrimeRESTAPI_GetEntityPaymentMethods", - "sdkMethod": "ListPaymentMethods" + "sdkMethod": "ListEntityPaymentMethods", + "deprecatedAliases": [ + { + "sdkMethod": "ListPaymentMethods", + "message": "Use listEntityPaymentMethods instead." + } + ] + }, + { + "operationId": "PrimeRESTAPI_GetEntityPaymentMethodDetails", + "sdkMethod": "GetEntityPaymentMethod", + "deprecatedAliases": [ + { + "sdkMethod": "GetPaymentMethodDetails", + "message": "Use getEntityPaymentMethod instead." + } + ] }, { "operationId": "PrimeRESTAPI_GetPortfolios", @@ -124,7 +152,13 @@ }, { "operationId": "PrimeRESTAPI_StakingClaimRewards", - "sdkMethod": "ClaimRewards" + "sdkMethod": "ClaimStakingRewards", + "deprecatedAliases": [ + { + "sdkMethod": "ClaimRewards", + "message": "Use claimStakingRewards instead." + } + ] }, { "operationId": "PrimeRESTAPI_StakingInitiate", @@ -134,6 +168,26 @@ "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": { @@ -159,6 +213,26 @@ "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" @@ -182,6 +256,26 @@ "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/GeneratorConfiguration.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorConfiguration.java index 2f6db802..3c1a6a62 100644 --- 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 @@ -21,6 +21,8 @@ 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; @@ -217,6 +219,7 @@ public static final class Override { private final Map responseTypes; private final List convenienceConstructorParameters; private final List statuses; + private final List deprecatedAliases; private Override( String sdkMethod, @@ -228,7 +231,8 @@ private Override( Map propertyNames, Map responseTypes, List convenienceConstructorParameters, - List statuses) { + List statuses, + List deprecatedAliases) { this.sdkMethod = sdkMethod; this.serviceFolder = serviceFolder; this.serviceName = serviceName; @@ -239,6 +243,7 @@ private Override( this.responseTypes = responseTypes; this.convenienceConstructorParameters = convenienceConstructorParameters; this.statuses = statuses; + this.deprecatedAliases = deprecatedAliases; } static Override from(JsonNode node) { @@ -287,6 +292,23 @@ static Override from(JsonNode node) { } 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, @@ -297,7 +319,8 @@ static Override from(JsonNode node) { Collections.unmodifiableMap(new LinkedHashMap<>(propertyNames)), Collections.unmodifiableMap(new LinkedHashMap<>(responseTypes)), Collections.unmodifiableList(convenienceConstructorParameters), - Collections.unmodifiableList(new ArrayList<>(normalizedStatuses))); + Collections.unmodifiableList(new ArrayList<>(normalizedStatuses)), + Collections.unmodifiableList(deprecatedAliases)); } public String sdkMethod() { @@ -339,5 +362,9 @@ public List convenienceConstructorParameters() { public List statuses() { return statuses; } + + public List deprecatedAliases() { + return deprecatedAliases; + } } } 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 index be782e66..7dcfb800 100644 --- 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 @@ -15,6 +15,7 @@ */ package com.coinbase.tools.modelgenerator; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; @@ -32,6 +33,7 @@ public final class OperationBinding { private final Map propertyNameOverrides; private final Map responseTypeOverrides; private final List convenienceConstructorParameters; + private final List deprecatedAliases; OperationBinding( String operationId, @@ -51,6 +53,7 @@ public final class OperationBinding { parameterTypeOverrides, Collections.emptyMap(), Collections.emptyMap(), + Collections.emptyList(), Collections.emptyList()); } @@ -64,7 +67,8 @@ public final class OperationBinding { Map parameterTypeOverrides, Map propertyNameOverrides, Map responseTypeOverrides, - List convenienceConstructorParameters) { + List convenienceConstructorParameters, + List deprecatedAliases) { this.operationId = operationId; this.serviceFolder = serviceFolder; this.serviceName = serviceName; @@ -79,6 +83,7 @@ public final class OperationBinding { Collections.unmodifiableMap(new LinkedHashMap<>(responseTypeOverrides)); this.convenienceConstructorParameters = Collections.unmodifiableList(convenienceConstructorParameters); + this.deprecatedAliases = Collections.unmodifiableList(new ArrayList<>(deprecatedAliases)); } public String operationId() { @@ -120,4 +125,8 @@ public Map 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 index 592943c4..473810e6 100644 --- 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 @@ -36,14 +36,11 @@ public final class OperationBindingGenerator { METHOD_RENAMES.put("CreateOnchainAddressGroup", "CreateOnchainAddressBookEntry"); METHOD_RENAMES.put("CreatePortfolioAddressBookEntry", "CreateAddressBookEntry"); METHOD_RENAMES.put("CreateQuoteRequest", "CreateQuote"); - METHOD_RENAMES.put("GetAllocationsByClientNettingId", "ListAllocationsByNettingId"); METHOD_RENAMES.put("GetEntityAssets", "ListAssets"); - METHOD_RENAMES.put("GetEntityPaymentMethodDetails", "GetPaymentMethodDetails"); METHOD_RENAMES.put("GetEntityUsers", "ListEntityUsers"); METHOD_RENAMES.put("GetFuturesSweeps", "ListEntityFuturesSweeps"); METHOD_RENAMES.put("GetLocateAvailabilities", "GetEntityLocateAvailabilities"); METHOD_RENAMES.put("GetMarginSummaries", "ListMarginCallSummaries"); - METHOD_RENAMES.put("GetPortfolioAddressBook", "ListAddressBook"); METHOD_RENAMES.put("GetPortfolioInterestAccruals", "ListInterestAccrualsForPortfolio"); METHOD_RENAMES.put("GetPostTradeCredit", "GetPortfolioCreditInformation"); METHOD_RENAMES.put("GetTFTieredPricingFees", "GetTradeFinanceTieredPricingFees"); @@ -121,7 +118,8 @@ private static OperationBinding applyOverride( && override.propertyNames().isEmpty() && override.responseTypes().isEmpty() && override.convenienceConstructorParameters().isEmpty() - && override.statuses().isEmpty()) { + && override.statuses().isEmpty() + && override.deprecatedAliases().isEmpty()) { System.err.println("WARN redundant operation override: " + binding.operationId()); } return new OperationBinding( @@ -138,7 +136,10 @@ private static OperationBinding applyOverride( override.responseTypes().isEmpty() ? binding.responseTypeOverrides() : override.responseTypes(), override.convenienceConstructorParameters().isEmpty() ? binding.convenienceConstructorParameters() - : override.convenienceConstructorParameters()); + : override.convenienceConstructorParameters(), + override.deprecatedAliases().isEmpty() + ? binding.deprecatedAliases() + : override.deprecatedAliases()); } private static Map dereference( 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 index 07f25255..ffe4a192 100644 --- 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 @@ -57,6 +57,16 @@ public static void validate(SpecModels.Document document, List 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/RequestPhase.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/RequestPhase.java index ca1eee0e..8964dd1d 100644 --- 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 @@ -37,6 +37,12 @@ public static Map render(SpecModels.Document document, 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()) { 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 index eb35ca7a..9f80ee28 100644 --- 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 @@ -41,6 +41,12 @@ public static Map render( 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; } @@ -95,6 +101,34 @@ private static String renderOne( 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, 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 index e5af3dc6..b76bd64b 100644 --- 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 @@ -54,13 +54,28 @@ private static String interfaceSource(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")); @@ -74,35 +89,54 @@ private static String implementationSource(String service, String folder, List() {});\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(binding.sdkMethod()).append("Response>() {});\n }\n"); + 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) { + boolean interfaceMethod, + String responseSdkName, + String deprecationMessage) { String documentation = SourceTemplates.documentation(operation.summary(), operation.description()); source.append(" /**\n"); if (!documentation.isEmpty()) { @@ -111,11 +145,14 @@ private static void appendMethodJavadoc( if (!binding.omitRequest()) { source.append(" * @param request request parameters and body for this operation\n"); } - source.append(" * @return the decoded ").append(binding.sdkMethod()).append(" response\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"); } diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/DeprecatedAliasGenerationTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/DeprecatedAliasGenerationTest.java new file mode 100644 index 00000000..2fcabe98 --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/DeprecatedAliasGenerationTest.java @@ -0,0 +1,281 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Verifies that declarative {@code deprecatedAliases} configuration produces canonical Java SDK + * names plus source-compatible deprecated request/response subclasses and forwarding service + * methods, entirely driven from {@code operations-overrides.json}. + */ +class DeprecatedAliasGenerationTest { + + @Test + void deriveAllAssignsDeprecatedAliasesFromOverrides() throws Exception { + GeneratorConfiguration configuration = + configuration( + "[{\"operationId\":\"PrimeRESTAPI_CreateWidget\",\"sdkMethod\":\"CreateWidget\"," + + "\"convenienceConstructorParameters\":[\"display_name\"]," + + "\"deprecatedAliases\":[{\"sdkMethod\":\"CreateLegacyWidget\",\"message\":\"Use createWidget instead.\"}]}," + + "{\"operationId\":\"PrimeRESTAPI_ListWidgets\",\"sdkMethod\":\"ListWidgets\"," + + "\"deprecatedAliases\":[{\"sdkMethod\":\"ListLegacyWidgets\",\"message\":\"Use listWidgets instead.\"}]}]"); + List bindings = OperationBindingGenerator.deriveAll(fixture(), configuration); + + OperationBinding createWidget = binding(bindings, "PrimeRESTAPI_CreateWidget"); + assertEquals("CreateWidget", createWidget.sdkMethod()); + assertEquals(1, createWidget.deprecatedAliases().size()); + assertEquals("CreateLegacyWidget", createWidget.deprecatedAliases().get(0).sdkMethod()); + assertEquals("Use createWidget instead.", createWidget.deprecatedAliases().get(0).message()); + + OperationBinding listWidgets = binding(bindings, "PrimeRESTAPI_ListWidgets"); + assertEquals("ListLegacyWidgets", listWidgets.deprecatedAliases().get(0).sdkMethod()); + assertTrue(listWidgets.paginated()); + } + + @Test + void rejectsAliasThatMatchesItsOwnCanonicalNameAtConfigParseTime() throws Exception { + assertThrows( + IllegalArgumentException.class, + () -> + configuration( + "[{\"operationId\":\"PrimeRESTAPI_CreateWidget\",\"sdkMethod\":\"CreateWidget\"," + + "\"deprecatedAliases\":[{\"sdkMethod\":\"CreateWidget\",\"message\":\"noop\"}]}]")); + } + + @Test + void rejectsAliasThatMatchesTheDefaultDerivedCanonicalName() throws Exception { + GeneratorConfiguration configuration = + configuration( + "[{\"operationId\":\"PrimeRESTAPI_CreateWidget\"," + + "\"deprecatedAliases\":[{\"sdkMethod\":\"CreateWidget\",\"message\":\"noop\"}]}]"); + SpecModels.Document document = fixture(); + assertThrows( + IllegalArgumentException.class, + () -> OperationBindingGenerator.deriveAll(document, configuration)); + } + + @Test + void rejectsAliasThatCollidesWithAnotherServiceMethod() throws Exception { + GeneratorConfiguration configuration = + configuration( + "[{\"operationId\":\"PrimeRESTAPI_CreateWidget\",\"sdkMethod\":\"CreateWidget\"," + + "\"deprecatedAliases\":[{\"sdkMethod\":\"ListWidgets\",\"message\":\"noop\"}]}]"); + SpecModels.Document document = fixture(); + assertThrows( + IllegalArgumentException.class, + () -> OperationBindingGenerator.deriveAll(document, configuration)); + } + + @Test + void requestPhaseEmitsSourceCompatibleDeprecatedSubclassWithCovariantBuilder() throws Exception { + SpecModels.Document document = fixture(); + GeneratorConfiguration configuration = + configuration( + "[{\"operationId\":\"PrimeRESTAPI_CreateWidget\",\"sdkMethod\":\"CreateWidget\"," + + "\"convenienceConstructorParameters\":[\"display_name\"]," + + "\"deprecatedAliases\":[{\"sdkMethod\":\"CreateLegacyWidget\",\"message\":\"Use createWidget instead.\"}]}]"); + List bindings = OperationBindingGenerator.deriveAll(document, configuration); + NamingResolver names = new NamingResolver(); + JavaTypeResolver types = new JavaTypeResolver(document, names); + + Map sources = RequestPhase.render(document, bindings, types, names); + String alias = sources.get(Path.of("com/coinbase/prime/orders/CreateLegacyWidgetRequest.java")); + assertTrue(alias != null, sources.keySet().toString()); + assertTrue(alias.contains("@deprecated Use createWidget instead."), alias); + assertTrue(alias.contains("@Deprecated"), alias); + assertTrue(alias.contains("public class CreateLegacyWidgetRequest extends CreateWidgetRequest {"), alias); + assertTrue( + alias.contains("public CreateLegacyWidgetRequest(CreateWidgetRequest.Builder builder) {\n super(builder);\n }"), + alias); + assertTrue(alias.contains("public static class Builder extends CreateWidgetRequest.Builder {"), alias); + assertTrue( + alias.contains( + "@Override\n public Builder displayName(String displayName) {\n super.displayName(displayName);\n return this;\n }"), + alias); + assertTrue(alias.contains("public CreateLegacyWidgetRequest build() throws CoinbaseClientException {\n super.validate();\n return new CreateLegacyWidgetRequest(this);\n }"), alias); + assertTrue( + alias.contains("public CreateLegacyWidgetRequest(String displayName) {\n super();\n this.setDisplayName(displayName);\n }"), + alias); + + String canonical = sources.get(Path.of("com/coinbase/prime/orders/CreateWidgetRequest.java")); + assertTrue(canonical != null && !canonical.contains("@Deprecated"), canonical); + } + + @Test + void requestPhaseCovariantBuilderCoversPaginationMethods() throws Exception { + SpecModels.Document document = fixture(); + GeneratorConfiguration configuration = + configuration( + "[{\"operationId\":\"PrimeRESTAPI_ListWidgets\",\"sdkMethod\":\"ListWidgets\"," + + "\"deprecatedAliases\":[{\"sdkMethod\":\"ListLegacyWidgets\",\"message\":\"Use listWidgets instead.\"}]}]"); + List bindings = OperationBindingGenerator.deriveAll(document, configuration); + NamingResolver names = new NamingResolver(); + JavaTypeResolver types = new JavaTypeResolver(document, names); + + String alias = + RequestPhase.render(document, bindings, types, names) + .get(Path.of("com/coinbase/prime/orders/ListLegacyWidgetsRequest.java")); + assertTrue(alias != null, alias); + assertTrue(alias.contains("extends ListWidgetsRequest"), alias); + assertTrue( + alias.contains("@Override\n public Builder cursor(String cursor) {\n super.cursor(cursor);\n return this;\n }"), + alias); + assertTrue( + alias.contains( + "@Override\n public Builder pagination(Pagination pagination) {\n super.pagination(pagination);\n return this;\n }"), + alias); + assertTrue( + alias.contains("@Override\n public Builder status(String status) {\n super.status(status);\n return this;\n }"), + alias); + } + + @Test + void responsePhaseEmitsDeprecatedSubclassWithNoDuplicateFields() throws Exception { + SpecModels.Document document = fixture(); + GeneratorConfiguration configuration = + configuration( + "[{\"operationId\":\"PrimeRESTAPI_CreateWidget\",\"sdkMethod\":\"CreateWidget\"," + + "\"deprecatedAliases\":[{\"sdkMethod\":\"CreateLegacyWidget\",\"message\":\"Use createWidget instead.\"}]}]"); + List bindings = OperationBindingGenerator.deriveAll(document, configuration); + NamingResolver names = new NamingResolver(); + JavaTypeResolver types = new JavaTypeResolver(document, names); + + String alias = + ResponsePhase.render(document, bindings, types, names) + .get(Path.of("com/coinbase/prime/orders/CreateLegacyWidgetResponse.java")); + assertTrue(alias != null, alias); + assertTrue(alias.contains("@Deprecated"), alias); + assertTrue(alias.contains("public class CreateLegacyWidgetResponse extends CreateWidgetResponse {"), alias); + assertTrue(alias.contains("public CreateLegacyWidgetResponse() {}"), alias); + assertTrue(!alias.contains("@JsonProperty"), alias); + } + + @Test + void servicePhaseEmitsDeprecatedForwardingInterfaceAndImplementationMethods() throws Exception { + SpecModels.Document document = fixture(); + GeneratorConfiguration configuration = + configuration( + "[{\"operationId\":\"PrimeRESTAPI_CreateWidget\",\"sdkMethod\":\"CreateWidget\"," + + "\"deprecatedAliases\":[{\"sdkMethod\":\"CreateLegacyWidget\",\"message\":\"Use createWidget instead.\"}]}]"); + List bindings = OperationBindingGenerator.deriveAll(document, configuration); + NamingResolver names = new NamingResolver(); + + Map services = ServicePhase.render(document, bindings, configuration, names); + String iface = services.get(Path.of("com/coinbase/prime/orders/OrdersService.java")); + assertTrue(iface.contains("@deprecated Use createWidget instead."), iface); + assertTrue( + iface.contains( + "@Deprecated\n CreateLegacyWidgetResponse createLegacyWidget(CreateLegacyWidgetRequest request) throws CoinbaseClientException, CoinbasePrimeException;"), + iface); + assertTrue(iface.contains("CreateWidgetResponse createWidget(CreateWidgetRequest request)"), iface); + + String impl = services.get(Path.of("com/coinbase/prime/orders/OrdersServiceImpl.java")); + assertTrue( + impl.contains( + "@Deprecated\n @Override\n public CreateLegacyWidgetResponse createLegacyWidget(CreateLegacyWidgetRequest request) throws CoinbasePrimeException {"), + impl); + assertTrue(impl.contains("new TypeReference() {}"), impl); + } + + private static GeneratorConfiguration configuration(String overridesContent) throws Exception { + Path directory = Files.createTempDirectory("deprecated-alias-config"); + Path config = directory.resolve("generator.json"); + Path overrides = directory.resolve("overrides.json"); + Files.writeString(config, "{\"specUrl\":\"x\",\"committedSpecPath\":\"x\"}"); + Files.writeString(overrides, overridesContent); + return GeneratorConfiguration.loadForTests(config, overrides); + } + + private static OperationBinding binding(List bindings, String operationId) { + return bindings.stream() + .filter(candidate -> candidate.operationId().equals(operationId)) + .findFirst() + .orElseThrow(); + } + + private static SpecModels.Document fixture() throws Exception { + Path spec = Files.createTempFile("deprecated-alias-fixture", ".yaml"); + Files.writeString( + spec, + String.join( + "\n", + "openapi: 3.0.0", + "paths:", + " /v1/portfolios/{portfolio_id}/widgets:", + " parameters:", + " - name: portfolio_id", + " in: path", + " required: true", + " schema: { type: string }", + " get:", + " operationId: PrimeRESTAPI_ListWidgets", + " tags: [Orders]", + " summary: List Widgets", + " parameters:", + " - name: cursor", + " in: query", + " schema: { type: string }", + " - name: status", + " in: query", + " schema: { type: string }", + " responses:", + " '200':", + " content:", + " application/json:", + " schema:", + " type: object", + " properties:", + " widgets:", + " type: array", + " items: { $ref: '#/components/schemas/Widget' }", + " post:", + " operationId: PrimeRESTAPI_CreateWidget", + " tags: [Orders]", + " summary: Create Widget", + " requestBody:", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [display_name]", + " properties:", + " display_name: { type: string, description: Display name }", + " responses:", + " '200':", + " content:", + " application/json:", + " schema:", + " type: object", + " properties:", + " widget: { $ref: '#/components/schemas/Widget' }", + "components:", + " schemas:", + " Widget: { type: object, properties: { id: { type: string } } }", + "")); + return SpecParser.load(spec); + } +} diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/NamingParityTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/NamingParityTest.java new file mode 100644 index 00000000..17b01787 --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/NamingParityTest.java @@ -0,0 +1,171 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +/** + * Locks the cross-SDK naming decisions recorded in {@code config/naming-parity.json} against the + * generator's actual operation bindings for the committed OpenAPI spec. + * + *

Java converges on canonical names that match the prevailing name across the .NET, TypeScript, + * Python, and Go Prime SDKs (or an explicit Java-language convention), while every previously + * public Java name is preserved as a declarative, generated deprecated alias. This fixture is the + * regression net for that decision: it does not clone or otherwise depend on the other SDK + * repositories, it only asserts that the Java generator continues to emit the names recorded here + * from {@code config/operations-overrides.json}. + */ +class NamingParityTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void generatorBindingsMatchTheRecordedCrossSdkNamingParityFixture() throws Exception { + Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath().getParent().getParent(); + SpecModels.Document document = SpecParser.load(root.resolve("apiSpec/prime-public-spec.yaml")); + GeneratorConfiguration configuration = GeneratorConfiguration.load(GeneratorPaths.forRoot(root)); + List bindings = OperationBindingGenerator.deriveAll(document, configuration); + NamingResolver names = + new NamingResolver(configuration.modelTypeMappings()); + + JsonNode fixture = JSON.readTree(root.resolve("tools/model-generator/config/naming-parity.json").toFile()); + assertTrue(fixture.isArray() && fixture.size() > 0, "naming-parity.json must be a non-empty array"); + + List failures = new ArrayList<>(); + for (JsonNode entry : fixture) { + String operationId = entry.path("operationId").asText(); + String expectedCanonical = entry.path("canonicalJavaMethod").asText(); + String expectedFolder = entry.path("serviceFolder").asText(); + List expectedAliases = new ArrayList<>(); + for (JsonNode alias : entry.path("legacyJavaAliases")) expectedAliases.add(alias.asText()); + + OperationBinding binding = + bindings.stream() + .filter(candidate -> candidate.operationId().equals(operationId)) + .findFirst() + .orElse(null); + if (binding == null) { + failures.add(operationId + ": no binding found (has the spec operation been removed or renamed?)"); + continue; + } + + String actualCanonical = names.methodName(binding.sdkMethod()); + if (!expectedCanonical.equals(actualCanonical)) { + failures.add( + operationId + + ": expected canonical method '" + + expectedCanonical + + "' but generator produced '" + + actualCanonical + + "'"); + } + if (!expectedFolder.equals(binding.serviceFolder())) { + failures.add( + operationId + + ": expected service folder '" + + expectedFolder + + "' but generator produced '" + + binding.serviceFolder() + + "'"); + } + List actualAliases = + binding.deprecatedAliases().stream().map(alias -> names.methodName(alias.sdkMethod())).collect(Collectors.toList()); + if (!expectedAliases.equals(actualAliases)) { + failures.add( + operationId + + ": expected deprecated aliases " + + expectedAliases + + " but generator produced " + + actualAliases); + } + } + + if (!failures.isEmpty()) { + fail("Naming parity fixture drift:\n " + String.join("\n ", failures)); + } + } + + @Test + void everyFixtureEntryWithAliasesHasGeneratedRequestResponseSubclassesAndForwardingMethods() + throws Exception { + Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath().getParent().getParent(); + SpecModels.Document document = SpecParser.load(root.resolve("apiSpec/prime-public-spec.yaml")); + GeneratorConfiguration configuration = GeneratorConfiguration.load(GeneratorPaths.forRoot(root)); + List bindings = OperationBindingGenerator.deriveAll(document, configuration); + NamingResolver names = + new NamingResolver(configuration.modelTypeMappings()); + JavaTypeResolver types = + new JavaTypeResolver(document, names, configuration.sharedModelMappings()); + + var requestSources = RequestPhase.render(document, bindings, types, names); + var responseSources = ResponsePhase.render(document, bindings, types, names); + var serviceSources = ServicePhase.render(document, bindings, configuration, names); + + JsonNode fixture = JSON.readTree(root.resolve("tools/model-generator/config/naming-parity.json").toFile()); + for (JsonNode entry : fixture) { + if (!"requestResponseSubclass".equals(entry.path("aliasBehavior").asText())) { + continue; + } + String operationId = entry.path("operationId").asText(); + String folder = entry.path("serviceFolder").asText(); + OperationBinding binding = + bindings.stream().filter(candidate -> candidate.operationId().equals(operationId)).findFirst().orElseThrow(); + assertEquals(1, binding.deprecatedAliases().size(), operationId); + DeprecatedAlias alias = binding.deprecatedAliases().get(0); + + String aliasRequestPath = "com/coinbase/prime/" + folder + "/" + alias.sdkMethod() + "Request.java"; + String aliasRequest = requestSources.get(Path.of(aliasRequestPath)); + assertTrue(aliasRequest != null, "missing generated alias request: " + aliasRequestPath); + assertTrue(aliasRequest.contains("@Deprecated"), aliasRequest); + assertTrue( + aliasRequest.contains("extends " + binding.sdkMethod() + "Request"), + "alias request must subclass the canonical request: " + aliasRequestPath); + + String aliasResponsePath = "com/coinbase/prime/" + folder + "/" + alias.sdkMethod() + "Response.java"; + String aliasResponse = responseSources.get(Path.of(aliasResponsePath)); + assertTrue(aliasResponse != null, "missing generated alias response: " + aliasResponsePath); + assertTrue(aliasResponse.contains("@Deprecated"), aliasResponse); + assertTrue( + aliasResponse.contains("extends " + binding.sdkMethod() + "Response"), + "alias response must subclass the canonical response: " + aliasResponsePath); + + String serviceInterface = serviceSources.get(Path.of("com/coinbase/prime/" + folder + "/" + binding.serviceName() + ".java")); + assertTrue(serviceInterface != null, "missing generated service interface for folder: " + folder); + String aliasMethodName = names.methodName(alias.sdkMethod()); + assertTrue( + serviceInterface.contains(alias.sdkMethod() + "Response " + aliasMethodName + "("), + "service interface must declare the deprecated forwarding method: " + aliasMethodName); + assertTrue(serviceInterface.contains("@deprecated " + alias.message()), serviceInterface); + + String serviceImpl = + serviceSources.get(Path.of("com/coinbase/prime/" + folder + "/" + binding.serviceName() + "Impl.java")); + assertTrue(serviceImpl != null, "missing generated service implementation for folder: " + folder); + assertTrue( + serviceImpl.contains("public " + alias.sdkMethod() + "Response " + aliasMethodName + "("), + "service implementation must implement the deprecated forwarding method: " + aliasMethodName); + } + } +} diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java index 91761378..b760b1f0 100644 --- a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java @@ -58,8 +58,14 @@ void preservesConfiguredCompatibilityNamesAndRequestShapes() throws Exception { assertEquals("GetEntityFcmBalance", binding(bindings, "PrimeRESTAPI_GetFcmBalance").sdkMethod()); assertEquals("ListPortfolioOrders", binding(bindings, "PrimeRESTAPI_GetOrders").sdkMethod()); - assertEquals("GetOrderByOrderId", binding(bindings, "PrimeRESTAPI_GetOrder").sdkMethod()); - assertEquals("ClaimRewards", binding(bindings, "PrimeRESTAPI_StakingClaimRewards").sdkMethod()); + assertEquals("GetOrder", binding(bindings, "PrimeRESTAPI_GetOrder").sdkMethod()); + assertEquals( + "GetOrderByOrderId", + binding(bindings, "PrimeRESTAPI_GetOrder").deprecatedAliases().get(0).sdkMethod()); + assertEquals("ClaimStakingRewards", binding(bindings, "PrimeRESTAPI_StakingClaimRewards").sdkMethod()); + assertEquals( + "ClaimRewards", + binding(bindings, "PrimeRESTAPI_StakingClaimRewards").deprecatedAliases().get(0).sdkMethod()); assertEquals("GetCrossMarginLiquidation", binding(bindings, "PrimeRESTAPI_GetXMLiquidation").sdkMethod()); assertEquals("GetDerivativePositions", binding(bindings, "PrimeRESTAPI_GetDerivativePositions").sdkMethod()); assertEquals("ListOnchainWalletBalances", binding(bindings, "PrimeRESTAPI_ListWeb3WalletBalances").sdkMethod()); diff --git a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationInventoryTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationInventoryTest.java index c4de7c3c..754e5573 100644 --- a/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationInventoryTest.java +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationInventoryTest.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; /** @@ -58,12 +59,19 @@ void existingOperationsKeepTheirVendedJavaSurface() throws Exception { assertTrue(failures.isEmpty(), "Vended Java surface changed:\n" + String.join("\n", failures)); } - static Map inventory(List bindings) { + private static Map inventory(List bindings) { Map inventory = new TreeMap<>(); for (OperationBinding binding : bindings) { - inventory.put( - binding.operationId(), - binding.serviceFolder() + "/" + binding.serviceName() + "#" + binding.sdkMethod()); + StringBuilder surface = + new StringBuilder(binding.serviceFolder()) + .append('/').append(binding.serviceName()).append('#').append(binding.sdkMethod()); + if (!binding.deprecatedAliases().isEmpty()) { + surface.append(" deprecated:").append( + binding.deprecatedAliases().stream() + .map(DeprecatedAlias::sdkMethod) + .collect(Collectors.joining(","))); + } + inventory.put(binding.operationId(), surface.toString()); } return inventory; } diff --git a/tools/model-generator/src/test/resources/operation-inventory.json b/tools/model-generator/src/test/resources/operation-inventory.json index d0fa9d35..943efaad 100644 --- a/tools/model-generator/src/test/resources/operation-inventory.json +++ b/tools/model-generator/src/test/resources/operation-inventory.json @@ -17,13 +17,13 @@ "PrimeRESTAPI_CreateQuoteRequest": "orders/OrdersService#CreateQuote", "PrimeRESTAPI_CreateWallet": "wallets/WalletsService#CreateWallet", "PrimeRESTAPI_CreateWalletDepositAddress": "wallets/WalletsService#CreateWalletDepositAddress", - "PrimeRESTAPI_CreateWalletTransfer": "transactions/TransactionsService#CreateWalletTransfer", - "PrimeRESTAPI_CreateWalletWithdrawal": "transactions/TransactionsService#CreateWalletWithdrawal", + "PrimeRESTAPI_CreateWalletTransfer": "transactions/TransactionsService#CreateTransfer deprecated:CreateWalletTransfer", + "PrimeRESTAPI_CreateWalletWithdrawal": "transactions/TransactionsService#CreateWithdrawal deprecated:CreateWalletWithdrawal", "PrimeRESTAPI_DeleteOnchainAddressGroup": "onchainaddressbook/OnchainAddressBookService#DeleteOnchainAddressGroup", "PrimeRESTAPI_EditOrder": "orders/OrdersService#EditOrder", "PrimeRESTAPI_GetActivity": "activities/ActivitiesService#GetActivity", "PrimeRESTAPI_GetAllocation": "allocations/AllocationsService#GetAllocation", - "PrimeRESTAPI_GetAllocationsByClientNettingId": "allocations/AllocationsService#ListAllocationsByNettingId", + "PrimeRESTAPI_GetAllocationsByClientNettingId": "allocations/AllocationsService#GetAllocationsByClientNettingId deprecated:ListAllocationsByNettingId", "PrimeRESTAPI_GetBuyingPower": "financing/FinancingService#GetPortfolioBuyingPower", "PrimeRESTAPI_GetCandles": "products/ProductsService#GetCandles", "PrimeRESTAPI_GetConversionFees": "financing/FinancingService#GetConversionFees", @@ -34,8 +34,8 @@ "PrimeRESTAPI_GetDerivativesCurrencySummary": "futures/FuturesService#GetDerivativesCurrencySummary", "PrimeRESTAPI_GetEntityActivities": "activities/ActivitiesService#ListEntityActivities", "PrimeRESTAPI_GetEntityAssets": "assets/AssetsService#ListAssets", - "PrimeRESTAPI_GetEntityPaymentMethodDetails": "paymentmethods/PaymentMethodsService#GetPaymentMethodDetails", - "PrimeRESTAPI_GetEntityPaymentMethods": "paymentmethods/PaymentMethodsService#ListPaymentMethods", + "PrimeRESTAPI_GetEntityPaymentMethodDetails": "paymentmethods/PaymentMethodsService#GetEntityPaymentMethod deprecated:GetPaymentMethodDetails", + "PrimeRESTAPI_GetEntityPaymentMethods": "paymentmethods/PaymentMethodsService#ListEntityPaymentMethods deprecated:ListPaymentMethods", "PrimeRESTAPI_GetEntityUsers": "users/UsersService#ListEntityUsers", "PrimeRESTAPI_GetExistingLocates": "financing/FinancingService#ListExistingLocates", "PrimeRESTAPI_GetFcmBalance": "futures/FuturesService#GetEntityFcmBalance", @@ -52,14 +52,14 @@ "PrimeRESTAPI_GetMarginSummaries": "financing/FinancingService#ListMarginCallSummaries", "PrimeRESTAPI_GetMarketData": "financing/FinancingService#GetMarketData", "PrimeRESTAPI_GetOpenOrders": "orders/OrdersService#ListOpenOrders", - "PrimeRESTAPI_GetOrder": "orders/OrdersService#GetOrderByOrderId", + "PrimeRESTAPI_GetOrder": "orders/OrdersService#GetOrder deprecated:GetOrderByOrderId", "PrimeRESTAPI_GetOrderEditHistory": "orders/OrdersService#ListOrderEditHistory", "PrimeRESTAPI_GetOrderFills": "orders/OrdersService#ListOrderFills", "PrimeRESTAPI_GetOrders": "orders/OrdersService#ListPortfolioOrders", "PrimeRESTAPI_GetPortfolio": "portfolios/PortfoliosService#GetPortfolio", - "PrimeRESTAPI_GetPortfolioActivities": "activities/ActivitiesService#ListPortfolioActivities", + "PrimeRESTAPI_GetPortfolioActivities": "activities/ActivitiesService#ListActivities deprecated:ListPortfolioActivities", "PrimeRESTAPI_GetPortfolioActivity": "activities/ActivitiesService#GetPortfolioActivity", - "PrimeRESTAPI_GetPortfolioAddressBook": "addressbook/AddressBookService#ListAddressBook", + "PrimeRESTAPI_GetPortfolioAddressBook": "addressbook/AddressBookService#GetAddressBook deprecated:ListAddressBook", "PrimeRESTAPI_GetPortfolioAllocations": "allocations/AllocationsService#ListPortfolioAllocations", "PrimeRESTAPI_GetPortfolioBalances": "balances/BalancesService#ListPortfolioBalances", "PrimeRESTAPI_GetPortfolioCommission": "commission/CommissionService#GetPortfolioCommission", @@ -97,14 +97,14 @@ "PrimeRESTAPI_ListWeb3WalletBalances": "balances/BalancesService#ListOnchainWalletBalances", "PrimeRESTAPI_ListXMLiquidations": "financing/FinancingService#ListCrossMarginLiquidations", "PrimeRESTAPI_OrderPreview": "orders/OrdersService#GetOrderPreview", - "PrimeRESTAPI_PortfolioStakingInitiate": "staking/StakingService#PortfolioStakingInitiate", - "PrimeRESTAPI_PortfolioStakingUnstake": "staking/StakingService#PortfolioStakingUnstake", + "PrimeRESTAPI_PortfolioStakingInitiate": "staking/StakingService#CreatePortfolioStake deprecated:PortfolioStakingInitiate", + "PrimeRESTAPI_PortfolioStakingUnstake": "staking/StakingService#CreatePortfolioUnstake deprecated:PortfolioStakingUnstake", "PrimeRESTAPI_PreviewUnstake": "staking/StakingService#PreviewUnstake", "PrimeRESTAPI_RotateAPIKey": "apikey/ApiKeyService#RotateApiKey", "PrimeRESTAPI_ScheduleFuturesSweep": "futures/FuturesService#ScheduleEntityFuturesSweep", "PrimeRESTAPI_SetAutoSweep": "futures/FuturesService#SetAutoSweep", "PrimeRESTAPI_SetFcmSettings": "futures/FuturesService#SetFcmSettings", - "PrimeRESTAPI_StakingClaimRewards": "staking/StakingService#ClaimRewards", + "PrimeRESTAPI_StakingClaimRewards": "staking/StakingService#ClaimStakingRewards deprecated:ClaimRewards", "PrimeRESTAPI_StakingInitiate": "staking/StakingService#CreateStake", "PrimeRESTAPI_StakingUnstake": "staking/StakingService#CreateUnstake", "PrimeRESTAPI_SubmitDepositTravelRuleData": "transactions/TransactionsService#SubmitDepositTravelRuleData",