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..c7781cb4 100644 --- a/src/main/java/com/coinbase/prime/client/CoinbasePrimeClient.java +++ b/src/main/java/com/coinbase/prime/client/CoinbasePrimeClient.java @@ -29,21 +29,31 @@ import java.util.List; public class CoinbasePrimeClient extends CoinbaseNetHttpClient { + private final String baseUrl; + public CoinbasePrimeClient(CoinbasePrimeCredentials credentials, String baseUrl) { super(credentials, baseUrl); + this.baseUrl = baseUrl; } public CoinbasePrimeClient( CoinbasePrimeCredentials credentials, String baseUrl, HttpClient client) { super(credentials, baseUrl, client); + this.baseUrl = baseUrl; } public CoinbasePrimeClient(CoinbasePrimeCredentials credentials) { super(credentials, Constants.CB_PRIME_BASE_URL); + this.baseUrl = Constants.CB_PRIME_BASE_URL; } public CoinbasePrimeClient(CoinbasePrimeCredentials credentials, HttpClient client) { super(credentials, Constants.CB_PRIME_BASE_URL, client); + this.baseUrl = Constants.CB_PRIME_BASE_URL; + } + + public String getBaseUrl() { + return baseUrl; } /** diff --git a/tools/model-generator/.openapi-generator-ignore b/tools/model-generator/.openapi-generator-ignore index bdfbf8b2..24843965 100644 --- a/tools/model-generator/.openapi-generator-ignore +++ b/tools/model-generator/.openapi-generator-ignore @@ -6,12 +6,9 @@ src/main/java/com/coinbase/prime/model/*Request.java src/main/java/com/coinbase/prime/model/*Response.java -# Per-operation typed error payloads and codes (not consumed by the SDK yet) +# Per-operation typed error payloads are not consumed by the SDK yet. ErrorCode and +# Subcode enums are intentionally generated and routed to model/errors by PostProcessor. src/main/java/com/coinbase/prime/model/*ErrorResponse.java -src/main/java/com/coinbase/prime/model/*ErrorCode.java -src/main/java/com/coinbase/prime/model/*Subcode.java -src/main/java/com/coinbase/prime/model/enums/*ErrorCode.java -src/main/java/com/coinbase/prime/model/enums/*Subcode.java # Google infrastructure types (google.type.Date is generated as GoogleTypeDate and renamed to DateOfBirth) src/main/java/com/coinbase/prime/model/GoogleProtobufAny.java diff --git a/tools/model-generator/config/generator-config.json b/tools/model-generator/config/generator-config.json new file mode 100644 index 00000000..5ec14f99 --- /dev/null +++ b/tools/model-generator/config/generator-config.json @@ -0,0 +1,26 @@ +{ + "specUrl": "https://api.prime.coinbase.com/v1/openapi.yaml", + "committedSpecPath": "apiSpec/prime-public-spec.yaml", + "tagToFolderOverrides": { + "Travel Rule": "transactions" + }, + "modelTypeMappings": { + "CreateOnchainTransactionRequestEVMParams": "EvmParams", + "CreateOnchainTransactionRequestEvmParams": "EvmParams", + "FcmFuturesSweepRequestAmount": "SweepAmount", + "FcmFuturesSweep": "FuturesSweep", + "EvmParam": "EvmParams", + "RPC": "RpcConfig", + "XMControlStatus": "PrimeXMControlStatus", + "XMMarginLevel": "PrimeXMMarginLevel" + }, + "sharedModelMappings": { + "PaginatedResponse": "com.coinbase.prime.common.Pagination" + }, + "protectedCompatibilityFiles": [ + "com/coinbase/prime/portfolios/GetPortfolioCounterpartyIdRequest.java", + "com/coinbase/prime/portfolios/GetPortfolioCounterpartyIdResponse.java", + "com/coinbase/prime/portfolios/PortfoliosService.java", + "com/coinbase/prime/portfolios/PortfoliosServiceImpl.java" + ] +} diff --git a/tools/model-generator/config/operations-overrides.json b/tools/model-generator/config/operations-overrides.json new file mode 100644 index 00000000..385de609 --- /dev/null +++ b/tools/model-generator/config/operations-overrides.json @@ -0,0 +1,182 @@ +[ + { + "operationId": "PrimeRESTAPI_GetActivity", + "convenienceConstructorParameters": ["activity_id"] + }, + { + "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/ClientSurfacePhase.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ClientSurfacePhase.java new file mode 100644 index 00000000..06d553f8 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/ClientSurfacePhase.java @@ -0,0 +1,37 @@ +/* + * 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.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Groups validated bindings in the deterministic order consumed by source emitters. */ +public final class ClientSurfacePhase { + private ClientSurfacePhase() {} + + public static Map> groupByService(List bindings) { + Map> services = new LinkedHashMap<>(); + List ordered = new ArrayList<>(bindings); + ordered.sort(Comparator.comparing(OperationBinding::serviceFolder).thenComparing(OperationBinding::sdkMethod)); + for (OperationBinding binding : ordered) { + services.computeIfAbsent(binding.serviceFolder(), ignored -> new ArrayList<>()).add(binding); + } + return services; + } +} diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/FactoryPhase.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/FactoryPhase.java new file mode 100644 index 00000000..36ccfe7c --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/FactoryPhase.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026-present Coinbase Global, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.coinbase.tools.modelgenerator; + +import java.nio.file.Path; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Emits the static factory surface for all generated Prime services. */ +public final class FactoryPhase { + private FactoryPhase() {} + public static Map render(List bindings) { + Map services = new LinkedHashMap<>(); + for (OperationBinding binding : bindings) services.putIfAbsent(binding.serviceName(), binding); + List ordered = new java.util.ArrayList<>(services.values()); ordered.sort(Comparator.comparing(OperationBinding::serviceName)); + Set imports = new LinkedHashSet<>(); imports.add("com.coinbase.prime.client.CoinbasePrimeClient"); + for (OperationBinding binding : ordered) { String prefix="com.coinbase.prime." + binding.serviceFolder() + "."; imports.add(prefix + binding.serviceName()); imports.add(prefix + binding.serviceName() + "Impl"); } + StringBuilder source=new StringBuilder(SourceTemplates.header()).append("package com.coinbase.prime.factory;\n\n"); SourceTemplates.imports(source, imports); source.append("public class PrimeServiceFactory {\n"); + for (OperationBinding binding : ordered) source.append(" public static ").append(binding.serviceName()).append(" create").append(binding.serviceName()).append("(CoinbasePrimeClient client) {\n return new ").append(binding.serviceName()).append("Impl(client);\n }\n\n"); + source.append("}\n"); Map result=new LinkedHashMap<>(); result.put(Path.of("com/coinbase/prime/factory/PrimeServiceFactory.java"),source.toString()); return result; + } +} diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedEnumKind.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedEnumKind.java new file mode 100644 index 00000000..b76e4e33 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedEnumKind.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026-present Coinbase Global, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.coinbase.tools.modelgenerator; + +/** Distinguishes domain enums from OpenAPI error Subcode and ErrorCode enums. */ +public final class GeneratedEnumKind { + public static final String ENUMS_PACKAGE = "com.coinbase.prime.model.enums"; + public static final String ERRORS_PACKAGE = "com.coinbase.prime.model.errors"; + + private GeneratedEnumKind() {} + + public static boolean isSubcode(String typeName) { + return typeName.endsWith("Subcode"); + } + + public static boolean isErrorCode(String typeName) { + return typeName.endsWith("ErrorCode"); + } + + public static boolean isErrorEnum(String typeName) { + return isSubcode(typeName) || isErrorCode(typeName); + } + + public static String packageFor(String typeName) { + return isErrorEnum(typeName) ? ERRORS_PACKAGE : ENUMS_PACKAGE; + } +} diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedSourceReconciler.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedSourceReconciler.java new file mode 100644 index 00000000..38e09e52 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratedSourceReconciler.java @@ -0,0 +1,187 @@ +/* + * Copyright 2026-present Coinbase Global, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.coinbase.tools.modelgenerator; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Locale; +import java.util.TreeMap; + +/** Reconciles only manifest-owned generated files; hand-authored SDK files are never glob-deleted. */ +public final class GeneratedSourceReconciler { + private static final ObjectMapper JSON = new ObjectMapper(); + + private GeneratedSourceReconciler() {} + + public static List diff(Path outputRoot, Map generated) throws IOException { + return diff(outputRoot, generated, Collections.emptySet(), null); + } + + public static List diff( + Path outputRoot, + Map generated, + Set protectedFiles, + Path manifest) + throws IOException { + validateCaseInsensitivePaths(outputRoot, generated.keySet()); + List changes = new ArrayList<>(); + for (Map.Entry entry : new TreeMap<>(generated).entrySet()) { + String relative = entry.getKey().toString().replace('\\', '/'); + Path target = outputRoot.resolve(entry.getKey()); + String existing = Files.exists(target) ? Files.readString(target) : null; + if (protectedFiles.contains(relative)) { + if (!entry.getValue().equals(existing)) { + changes.add("SKIP " + entry.getKey()); + } + continue; + } + if (!entry.getValue().equals(existing)) { + changes.add((existing == null ? "ADD " : "CHANGE ") + entry.getKey()); + } + } + if (manifest != null) { + for (String stale : manifestEntries(manifest)) { + if (!generated.containsKey(Path.of(stale)) + && !protectedFiles.contains(stale) + && Files.exists(outputRoot.resolve(stale))) { + changes.add("DELETE " + stale); + } + } + } + Collections.sort(changes); + return Collections.unmodifiableList(changes); + } + + public static void write(Path outputRoot, Map generated) throws IOException { + write(outputRoot, generated, Collections.emptySet(), null); + } + + public static void write( + Path outputRoot, Map generated, Set protectedFiles, Path manifest) + throws IOException { + validateCaseInsensitivePaths(outputRoot, generated.keySet()); + for (Map.Entry entry : new TreeMap<>(generated).entrySet()) { + String relative = entry.getKey().toString().replace('\\', '/'); + if (protectedFiles.contains(relative)) { + continue; + } + Path target = outputRoot.resolve(entry.getKey()); + if (Files.exists(target) && entry.getValue().equals(Files.readString(target))) { + continue; + } + Files.createDirectories(target.getParent()); + Files.writeString(target, entry.getValue(), StandardCharsets.UTF_8); + } + if (manifest != null) { + for (String stale : manifestEntries(manifest)) { + if (!generated.containsKey(Path.of(stale)) && !protectedFiles.contains(stale)) { + Files.deleteIfExists(outputRoot.resolve(stale)); + } + } + writeManifest(manifest, generated.keySet()); + } + } + + private static void validateCaseInsensitivePaths(Path outputRoot, Set generated) + throws IOException { + Map generatedByFoldedPath = new LinkedHashMap<>(); + for (Path relative : generated) { + String normalized = relative.toString().replace('\\', '/'); + Path previous = generatedByFoldedPath.putIfAbsent(normalized.toLowerCase(Locale.ROOT), relative); + if (previous != null && !previous.equals(relative)) { + throw new IOException( + "Generated source paths differ only by case: " + previous + " and " + relative); + } + Path target = outputRoot.resolve(relative); + Path parent = target.getParent(); + if (parent == null || !Files.isDirectory(parent)) { + continue; + } + try (java.util.stream.Stream children = Files.list(parent)) { + Path collision = + children + .filter(child -> !child.getFileName().equals(target.getFileName())) + .filter( + child -> + child.getFileName() + .toString() + .equalsIgnoreCase(target.getFileName().toString())) + .findFirst() + .orElse(null); + if (collision != null) { + throw new IOException( + "Generated source path collides case-insensitively with existing file: " + + relative + + " and " + + outputRoot.relativize(collision)); + } + } + } + } + + /** Loads manifest-owned source content for isolated generation/check comparisons. */ + static Map readOwnedSources(Path outputRoot, Path manifest) throws IOException { + Map sources = new LinkedHashMap<>(); + for (String entry : manifestEntries(manifest)) { + Path relative = Path.of(entry); + Path source = outputRoot.resolve(relative); + if (!Files.isRegularFile(source)) { + throw new IOException("Generated manifest references missing source: " + entry); + } + sources.put(relative, Files.readString(source)); + } + return sources; + } + + static Set manifestEntries(Path manifest) throws IOException { + if (!Files.exists(manifest)) { + return Collections.emptySet(); + } + Set entries = new LinkedHashSet<>(); + for (String entry : JSON.readValue(Files.readString(manifest), new TypeReference>() {})) { + Path relative = Path.of(entry).normalize(); + if (relative.isAbsolute() || relative.startsWith("..")) { + throw new IOException("Generated manifest contains unsafe path: " + entry); + } + entries.add(relative.toString().replace('\\', '/')); + } + return entries; + } + + static void writeManifest(Path manifest, Set paths) throws IOException { + List entries = new ArrayList<>(); + for (Path path : paths) { + entries.add(path.toString().replace('\\', '/')); + } + Collections.sort(entries); + Files.createDirectories(manifest.getParent()); + Files.writeString( + manifest, + JSON.writerWithDefaultPrettyPrinter().writeValueAsString(entries) + "\n", + StandardCharsets.UTF_8); + } +} diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorConfiguration.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorConfiguration.java new file mode 100644 index 00000000..3b22dcad --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/GeneratorConfiguration.java @@ -0,0 +1,355 @@ +/* + * 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 nameReplacements; + 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 nameReplacements, + Map modelTypeMappings, + Map sharedModelMappings, + Set protectedFiles, + Map overrides) { + this.specUrl = specUrl; + this.committedSpecPath = committedSpecPath; + this.tagFolders = Collections.unmodifiableMap(tagFolders); + this.nameReplacements = Collections.unmodifiableMap(nameReplacements); + 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 replacements = strings(config.path("nameReplacements")); + for (Map.Entry entry : replacements.entrySet()) { + validateJavaIdentifier(entry.getValue(), "nameReplacements." + 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, + replacements, + 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 nameReplacements() { + return nameReplacements; + } + + 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..e0aaa98c --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/JavaTypeResolver.java @@ -0,0 +1,143 @@ +/* + * 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()); + 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"); + for (Map.Entry entry : schemas.entrySet()) { + Map candidate = SpecParser.map(entry.getValue()); + if (values.equals(candidate.get("enum"))) { + String typeName = names.typeName(schemaTypeName(entry.getKey())); + return external(GeneratedEnumKind.packageFor(typeName) + "." + typeName); + } + } + throw new IllegalArgumentException( + "Inline enum cannot be safely resolved to a named SDK enum: " + values); + } + + private Type map(Type value) { + Set imports = new LinkedHashSet<>(value.imports()); + imports.add("java.util.Map"); + return new Type("Map", imports); + } + private static String schemaTypeName(String schemaName) { + StringBuilder result = new StringBuilder(); + for (String part : schemaName.split("[._]")) { + if (!part.isEmpty()) { + result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + } + return result.toString(); + } + + private static String string(Object value) { return value == null ? "" : String.valueOf(value); } + + public static final class Type { + private final String name; private final Set imports; + Type(String name, Set imports) { this.name = name; this.imports = Collections.unmodifiableSet(imports); } + public String name() { return name; } public Set imports() { return imports; } + } +} diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/Main.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/Main.java index e95d6080..eae1ba23 100644 --- a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/Main.java +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/Main.java @@ -1,92 +1,240 @@ /* - * Copyright 2025-present Coinbase Global, Inc. + * Copyright 2026-present Coinbase Global, Inc. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ - package com.coinbase.tools.modelgenerator; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.commons.io.FileUtils; + +/** CLI entry point for committed-spec generation, isolated checks, and live client-surface diffs. */ +public final class Main { + private Main() {} + + public static void main(String[] args) { + try { + run(args, GeneratorPaths.fromWorkingDirectory()); + } catch (Exception exception) { + exception.printStackTrace(System.err); + System.exit(1); + } + } + + static void run(String[] args, GeneratorPaths paths) throws Exception { + GeneratorConfiguration configuration = GeneratorConfiguration.load(paths); + if (has(args, "--fetch-spec")) { + SpecFetcher.fetch(paths.root(), configuration.specUrl(), configuration.committedSpecPath()); + return; + } -public class Main { - private static final Logger logger = LoggerFactory.getLogger(Main.class); + boolean check = has(args, "--check"); + boolean liveDiff = has(args, "--live-diff"); + boolean skipModels = has(args, "--skip-models"); + Path spec = paths.root().resolve(configuration.committedSpecPath()); + Path temporaryLiveSpec = null; + try { + if (liveDiff) { + temporaryLiveSpec = SpecFetcher.fetchToTemporary(paths.root(), configuration.specUrl()); + spec = temporaryLiveSpec; + check = true; + } - public static void main(String[] args) { + if (check) { + // Live diffs use the identical isolated rendering and Spotless normalization as --check. + List changes = checkGeneratedInIsolation(paths, spec, configuration); + reportChanges(changes); + return; + } + + 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(); + } + + Map sources = renderClientSources(spec, configuration, paths.sourceRoot()); + GeneratedSourceReconciler.write( + paths.sourceRoot(), sources, configuration.protectedFiles(), paths.manifest()); + System.out.println("Generated " + sources.size() + " client-surface files"); + } finally { + if (temporaryLiveSpec != null) { + Files.deleteIfExists(temporaryLiveSpec); try { - logger.info("Coinbase Prime Java SDK Model Generator"); - logger.info("========================================"); - logger.info("Mode: FULL GENERATION (regenerates all models from OpenAPI spec)"); - logger.info(""); - - // Find project root - Path projectRoot = findProjectRoot(); - Path outputDir = projectRoot.resolve("src/main/java/com/coinbase/prime/model"); - Path enumsDir = outputDir.resolve("enums"); - Path tempDir = projectRoot.resolve("generated"); - - logger.info("Project Root: {}", projectRoot); - logger.info("Output Directory: {}", outputDir); - logger.info("Enums Directory: {}", enumsDir); - logger.info("Temp Directory: {}", tempDir); - - // Phase 0: Always fetch the latest spec into apiSpec/ (tracked in Git) - logger.info("\nPhase 0: Fetching latest OpenAPI spec..."); - Path specPath = SpecFetcher.fetch(projectRoot); - - // Phase 1: Generate raw models using OpenAPI Generator - logger.info("\nPhase 1: Generating raw models with OpenAPI Generator..."); - OpenApiGenerator generator = new OpenApiGenerator(specPath.toString(), tempDir); - generator.generateModels(); - - // Phase 2: Post-process models to match existing patterns - logger.info("\nPhase 2: Post-processing models..."); - PostProcessor postProcessor = new PostProcessor(tempDir, outputDir, enumsDir, specPath); - postProcessor.processModels(); - - logger.info("\nModel generation completed successfully!"); - logger.info("\nGenerated models are in: {}", outputDir); - logger.info("Generated enums are in: {}", enumsDir); - logger.info("\nNext steps:"); - logger.info("1. Review apiSpec/prime-public-spec.yaml and generated models"); - logger.info("2. Run: mvn spotless:apply && mvn test"); - logger.info("3. Commit spec + model changes together"); - - } catch (Exception e) { - logger.error("Error during model generation", e); - System.exit(1); + Files.deleteIfExists(temporaryLiveSpec.getParent()); + } catch (java.nio.file.DirectoryNotEmptyException ignored) { + // The generator output directory pre-existed or has unrelated content. } + } } + } - private static Path findProjectRoot() { - Path current = Paths.get(System.getProperty("user.dir")); - while (current != null) { - if (current.resolve("pom.xml").toFile().exists() && - current.resolve("src/main/java/com/coinbase/prime").toFile().exists()) { - return current; - } - current = current.getParent(); - } - - // If we can't find it, check if we're in the tools directory - Path toolsPath = Paths.get(System.getProperty("user.dir")); - if (toolsPath.toString().contains("tools/model-generator")) { - return toolsPath.getParent().getParent(); - } - - throw new RuntimeException("Could not find project root (looking for pom.xml and src/main/java/com/coinbase/prime)"); + /** + * Runs the complete write-mode pipeline against a disposable copy of the source tree. + * + *

The staged project receives the same Spotless normalization as {@code make generate}; only its + * rendered, manifest-owned files are compared with the repository. The repository itself is never + * used as an output directory in check mode. + */ + static List checkGeneratedInIsolation( + GeneratorPaths paths, Path spec, GeneratorConfiguration configuration) throws Exception { + Path stagingRoot = Files.createTempDirectory("prime-sdk-java-generator-check-"); + try { + Path stagedSourceRoot = stagingRoot.resolve("src/main/java"); + Path stagedModelRoot = stagedSourceRoot.resolve("com/coinbase/prime/model"); + Path stagedModelManifest = stagingRoot.resolve("tools/model-generator/generated-model-files.json"); + Path stagedClientManifest = stagingRoot.resolve("tools/model-generator/generated-files.json"); + + copyProjectInputs(paths, stagingRoot, stagedSourceRoot, stagedModelManifest, stagedClientManifest); + Path stagedRawRoot = stagingRoot.resolve("generated"); + new OpenApiGenerator(spec.toString(), stagedRawRoot, paths.root()).generateModels(); + new PostProcessor( + stagedRawRoot, + stagedSourceRoot, + stagedModelRoot, + stagedModelRoot.resolve("enums"), + stagedModelRoot.resolve("errors"), + spec, + stagedModelManifest) + .processModels(); + // Stage protected proposals too so Spotless can normalize and compare them without touching the + // repository's compatibility files. + GeneratedSourceReconciler.write( + stagedSourceRoot, + renderClientSources(spec, configuration, stagedSourceRoot), + Collections.emptySet(), + stagedClientManifest); + formatStagedSources(stagingRoot); + + List changes = new ArrayList<>(); + changes.addAll( + GeneratedSourceReconciler.diff( + paths.sourceRoot(), + GeneratedSourceReconciler.readOwnedSources(stagedSourceRoot, stagedModelManifest), + Collections.emptySet(), + paths.modelManifest())); + changes.addAll( + GeneratedSourceReconciler.diff( + paths.sourceRoot(), + GeneratedSourceReconciler.readOwnedSources(stagedSourceRoot, stagedClientManifest), + configuration.protectedFiles(), + paths.manifest())); + addManifestChangeIfPresent(changes, paths.modelManifest(), stagedModelManifest); + addManifestChangeIfPresent(changes, paths.manifest(), stagedClientManifest); + Collections.sort(changes); + return Collections.unmodifiableList(changes); + } finally { + FileUtils.deleteDirectory(stagingRoot.toFile()); + } + } + + private static void copyProjectInputs( + GeneratorPaths paths, + Path stagingRoot, + Path stagedSourceRoot, + Path stagedModelManifest, + Path stagedClientManifest) + throws IOException { + Files.copy(paths.root().resolve("pom.xml"), stagingRoot.resolve("pom.xml")); + FileUtils.copyDirectory(paths.sourceRoot().toFile(), stagedSourceRoot.toFile()); + copyIfPresent(paths.modelManifest(), stagedModelManifest); + copyIfPresent(paths.manifest(), stagedClientManifest); + } + + private static void copyIfPresent(Path source, Path target) throws IOException { + if (Files.exists(source)) { + Files.createDirectories(target.getParent()); + Files.copy(source, target); + } + } + + static void formatStagedSources(Path stagingRoot) throws IOException, InterruptedException { + Process process = + new ProcessBuilder( + "mvn", "-B", "-f", stagingRoot.resolve("pom.xml").toString(), "spotless:apply") + .redirectErrorStream(true) + .start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (process.waitFor() != 0) { + throw new IOException("Could not format staged generated sources:\n" + output); + } + } + + private static void addManifestChangeIfPresent( + List changes, Path committedManifest, Path stagedManifest) throws IOException { + if (Files.exists(committedManifest) + && !Files.readString(committedManifest).equals(Files.readString(stagedManifest))) { + changes.add("CHANGE " + committedManifest.getFileName()); + } + } + + private static Map renderClientSources( + Path spec, GeneratorConfiguration configuration, Path sourceRoot) throws IOException { + SpecModels.Document document = SpecParser.load(spec); + NamingResolver names = + new NamingResolver(configuration.nameReplacements(), configuration.modelTypeMappings()); + List bindings = OperationBindingGenerator.deriveAll(document, configuration); + JavaTypeResolver types = new JavaTypeResolver(document, names, configuration.sharedModelMappings()); + Map sources = new LinkedHashMap<>(); + sources.putAll(RequestPhase.render(document, bindings, types, names)); + sources.putAll(ResponsePhase.render(document, bindings, types, names)); + sources.putAll(ServicePhase.render(document, bindings, configuration, names)); + sources.putAll(FactoryPhase.render(bindings)); + return preserveClientSourceHeaders(sources, sourceRoot); + } + + static Map preserveClientSourceHeaders(Map sources, Path sourceRoot) + throws IOException { + Map headedSources = new LinkedHashMap<>(); + for (Map.Entry source : sources.entrySet()) { + headedSources.put( + source.getKey(), + GeneratedFileHeader.applyStartYear( + source.getValue(), GeneratedFileHeader.resolveStartYear(sourceRoot.resolve(source.getKey())))); + } + return headedSources; + } + + private static void reportChanges(List changes) { + for (String change : changes) { + System.out.println(change); + } + if (!changes.isEmpty()) { + throw new IllegalStateException("Generated source is out of date (" + changes.size() + " changes)"); + } + } + + private static boolean has(String[] args, String value) { + for (String arg : args) { + if (value.equals(arg)) { + return true; + } } -} \ No newline at end of file + return false; + } +} diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/NamingResolver.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/NamingResolver.java new file mode 100644 index 00000000..2fd9274c --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/NamingResolver.java @@ -0,0 +1,63 @@ +/* + * 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 replacements; + private final Map modelTypeMappings; + + public NamingResolver(Map replacements) { + this(replacements, Collections.emptyMap()); + } + + public NamingResolver( + Map replacements, Map modelTypeMappings) { + this.replacements = new LinkedHashMap<>(replacements); + this.modelTypeMappings = new LinkedHashMap<>(modelTypeMappings); + } + + public String typeName(String schemaName) { + // PostProcessor owns acronym and prefix normalization. Applying configurable replacements + // before that normalization diverges references from emitted model filenames (for example, + // EVMParams and FCMFuturesSweep), so type resolution starts from the raw schema name. + String result = SharedTransforms.modelType(schemaName == null ? "Object" : schemaName); + return modelTypeMappings.getOrDefault(result, result); + } + + public String propertyName(String wireName) { + StringBuilder result = new StringBuilder(); + boolean upper = false; + for (char character : wireName.toCharArray()) { + if (!Character.isLetterOrDigit(character)) { + upper = true; + continue; + } + if (result.length() == 0) result.append(Character.toLowerCase(character)); + else result.append(upper ? Character.toUpperCase(character) : character); + upper = false; + } + return result.length() == 0 ? "value" : result.toString(); + } + + public String methodName(String sdkMethod) { + return Character.toLowerCase(sdkMethod.charAt(0)) + sdkMethod.substring(1); + } +} diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OpenApiGenerator.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OpenApiGenerator.java index cce77e26..4d86fd88 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 @@ -34,11 +34,18 @@ public class OpenApiGenerator { private final String specLocation; private final Path outputDir; + private final Path projectRoot; private final ObjectMapper objectMapper = new ObjectMapper(); 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 +148,9 @@ public void generateModels() throws IOException { } private Path findProjectRoot() { + if (projectRoot != null) { + return projectRoot; + } Path current = outputDir.getParent(); while (current != null) { if (current.resolve("pom.xml").toFile().exists() && diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBinding.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBinding.java new file mode 100644 index 00000000..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..1ba4254d --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingGenerator.java @@ -0,0 +1,221 @@ +/* + * Copyright 2026-present Coinbase Global, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.coinbase.tools.modelgenerator; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; + +/** Derives deterministic Java SDK names from parsed OpenAPI operations. */ +public final class OperationBindingGenerator { + private static final String OPERATION_ID_PREFIX = "PrimeRESTAPI_"; + 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..e5ab89b4 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingValidator.java @@ -0,0 +1,54 @@ +/* + * 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.HashSet; +import java.util.List; +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<>(); + 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()); + } + 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/PostProcessor.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/PostProcessor.java index 18651b4e..873ac71d 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; + } + 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)); } - Files.delete(file); - logger.info("Deleted stale file: {}", file.getFileName()); - return 1; + 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,51 @@ 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()); - } + Map enumPackages = collectEnumPackages(); - // First, apply special case enum name mappings (e.g., ActivityType -> PrimeActivityType) - // This must happen BEFORE fixing import paths + // 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) { + try { + Files.walk(directory, 1) + .filter(path -> path.toString().endsWith(".java")) + .forEach(path -> enumPackages.put(path.getFileName().toString().replace(".java", ""), packageName)); + } catch (Exception e) { + logger.warn("Could not read enum directory {}: {}", directory, e.getMessage()); + } + } + private void processEnumFile(Path file) throws IOException { processFile(file, enumsDir, true); } @@ -306,7 +308,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 +353,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 +388,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 +674,6 @@ private String applyXMAcronymCasing(String content) { return content.replaceAll("Xm([A-Z])", "XM$1"); } - private void deleteCaseVariantFiles(Path targetDir, String fileName, boolean isEnum) throws IOException { - try { - List toDelete = Files.list(targetDir) - .filter(p -> p.getFileName().toString().equalsIgnoreCase(fileName)) - .collect(Collectors.toList()); - - for (Path p : toDelete) { - Files.delete(p); - if (!p.getFileName().toString().equals(fileName)) { - logger.info("Deleted old {} file with different casing: {} -> {}", - isEnum ? "enum" : "model", p.getFileName(), fileName); - } - } - } catch (IOException e) { - logger.warn("Could not delete old {} file variants: {}", isEnum ? "enum" : "model", e.getMessage()); - } - } - private String applyDateOfBirthPrimitiveConversion(String content) { content = content.replaceAll("\\bprivate Integer (year|month|day)\\b", "private int $1"); content = content.replaceAll("\\bpublic Integer (getYear|getMonth|getDay)\\b", "public int $1"); diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/RequestPhase.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/RequestPhase.java new file mode 100644 index 00000000..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/SharedTransforms.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SharedTransforms.java new file mode 100644 index 00000000..cf6492d4 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SharedTransforms.java @@ -0,0 +1,22 @@ +/* + * Copyright 2026-present Coinbase Global, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.coinbase.tools.modelgenerator; + +/** Shared post-model name and property transforms; client emitters must not duplicate these rules. */ +public final class SharedTransforms { + private SharedTransforms() {} + public static String modelType(String schemaName) { return PostProcessor.deriveJavaModelName(schemaName); } +} diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SourceTemplates.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SourceTemplates.java new file mode 100644 index 00000000..2ef4b54f --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SourceTemplates.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026-present Coinbase Global, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.coinbase.tools.modelgenerator; + +import java.util.Set; +import java.util.TreeSet; + +/** Small formatting primitives shared by all Java source emitters. */ +final class SourceTemplates { + private SourceTemplates() {} + static String header() { return "/*\n * Copyright 2026-present Coinbase Global, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n"; } + static String importBlock(Set imports) { StringBuilder value = new StringBuilder(); for (String name : new TreeSet<>(imports)) { if (name.startsWith("static ")) value.append("import static ").append(name.substring(7)).append(";\n"); else value.append("import ").append(name).append(";\n"); } return value.length() == 0 ? "" : value.append("\n").toString(); } + static void imports(StringBuilder source, Set imports) { source.append(importBlock(imports)); } + static void javadoc(StringBuilder source, String text) { + if (text != null && !text.trim().isEmpty()) { + source.append("/** ").append(text.replace("*/", "* /").replace("\n", " ")).append(" */\n"); + } + } + + static String documentation(String summary, String description) { + String concise = summary == null ? "" : summary.trim(); + String detail = description == null ? "" : description.trim(); + if (concise.isEmpty()) return detail; + if (detail.isEmpty() || concise.equals(detail)) return concise; + return concise + ". " + detail; + } + static String cap(String value) { return Character.toUpperCase(value.charAt(0)) + value.substring(1); } +} diff --git a/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecFetcher.java b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecFetcher.java index c1373639..6e4b677a 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; @@ -27,47 +24,85 @@ import java.nio.file.Files; import java.nio.file.Path; 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; + 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; + } } 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..940b4a74 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/SpecParser.java @@ -0,0 +1,129 @@ +/* + * 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()) continue; + 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/ClientSurfaceGenerationTest.java b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/ClientSurfaceGenerationTest.java new file mode 100644 index 00000000..6ab2cbf0 --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/ClientSurfaceGenerationTest.java @@ -0,0 +1,297 @@ +/* + * 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(Collections.singletonMap("Web3", "Onchain")); + 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")); + assertTrue(request.contains("private boolean isBuyExact;"), request); + assertTrue(request.contains("public boolean isBuyExact()"), request); + assertTrue(request.contains("public void setBuyExact(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)")); + assertTrue(implementation.indexOf("listThings") < implementation.indexOf("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(Collections.emptyMap()); + 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); + assertTrue(implementation.indexOf("List.of(200),") < implementation.indexOf("request,"), implementation); + } + + @Test + void routesTravelRuleToTheCanonicalTransactionsService() throws Exception { + SpecModels.Document document = fixture(); + List bindings = OperationBindingGenerator.deriveAll(document, routingConfiguration()); + NamingResolver names = new NamingResolver(Collections.emptyMap()); + 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( + Collections.singletonMap("Evm", "EVM"), + 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.emptyMap()), + 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(Collections.emptyMap())); + 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(Collections.emptyMap())); + 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(Collections.emptyMap())), new NamingResolver(Collections.emptyMap())) + .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 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/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/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/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 new file mode 100644 index 00000000..d37ab38e --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java @@ -0,0 +1,126 @@ +/* + * 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.nameReplacements(), 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")); + 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.nameReplacements(), 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/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")); 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..5c68ad41 --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/SpecParserTest.java @@ -0,0 +1,43 @@ +/* + * 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 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")); + + assertTrue(!document.operations().isEmpty()); + 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()); + } +}