From 9d623ed57659a073d39205185f39db4ab593d981 Mon Sep 17 00:00:00 2001 From: Nick Morgan Date: Wed, 2 Sep 2026 16:29:17 -0400 Subject: [PATCH] feat(generator): derive operation bindings --- .../modelgenerator/OperationBinding.java | 56 +++++++++++++ .../OperationBindingGenerator.java | 84 +++++++++++++++++++ .../OperationBindingValidator.java | 54 ++++++++++++ .../OperationBindingGeneratorTest.java | 46 ++++++++++ 4 files changed, 240 insertions(+) create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBinding.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingGenerator.java create mode 100644 tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingValidator.java create mode 100644 tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java 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..1df74083 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBinding.java @@ -0,0 +1,56 @@ +/* + * 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; + +/** 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; + + OperationBinding( + String operationId, + String serviceFolder, + String serviceName, + String sdkMethod, + boolean omitRequest, + boolean paginated, + Map parameterTypeOverrides) { + 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)); + } + + 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; } +} 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..f062b7f8 --- /dev/null +++ b/tools/model-generator/src/main/java/com/coinbase/tools/modelgenerator/OperationBindingGenerator.java @@ -0,0 +1,84 @@ +/* + * 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.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) { + List bindings = new ArrayList<>(); + for (SpecModels.Operation operation : document.operations()) bindings.add(derive(operation)); + bindings.sort(Comparator.comparing(OperationBinding::operationId)); + OperationBindingValidator.validate(document, bindings); + return Collections.unmodifiableList(bindings); + } + + static OperationBinding derive(SpecModels.Operation operation) { + String tag = operation.tags().isEmpty() ? "Misc" : operation.tags().get(0); + String folder = "Travel Rule".equals(tag) ? "transactions" : tag.replaceAll("[^A-Za-z0-9]", "").replace(" ", "").toLowerCase(Locale.ROOT); + String serviceName = pascal(tag) + "Service"; + String raw = operation.sdkMethodName().isEmpty() ? operation.operationId().replaceFirst("^" + OPERATION_ID_PREFIX, "") : operation.sdkMethodName(); + 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 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/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..e2cc24b9 --- /dev/null +++ b/tools/model-generator/src/test/java/com/coinbase/tools/modelgenerator/OperationBindingGeneratorTest.java @@ -0,0 +1,46 @@ +/* + * 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 OperationBindingGeneratorTest { + @Test + void derivesStableBindingsForTheCommittedSpec() throws Exception { + Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath().getParent().getParent(); + List bindings = OperationBindingGenerator.deriveAll( + SpecParser.load(root.resolve("apiSpec/prime-public-spec.yaml"))); + + assertEquals(103, 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()); + } +}