From d13d995f8d867d21ba3f2d0ed2e91f9cba1dc836 Mon Sep 17 00:00:00 2001 From: "Sanket M." Date: Tue, 14 Jul 2026 01:29:01 +0530 Subject: [PATCH 1/3] feat(api-catalog): add drift guards for the embedded catalog (DEVX-549) Two complementary guards so the embedded API catalog can't silently drift from the upstream commerce.*-specification source of truth: 1. Always-on structural guard (cargo test, no network/credentials): a new EXPECTED_DOMAINS contract + committed_catalog_matches_expected_domains_and_manifest test asserts the committed catalog files and manifest.json match the intended domain set, and that each domain's endpoint count agrees between its file and the manifest. Adding/removing a domain now requires a deliberate list update. 2. Scheduled source-drift guard (.github/workflows/catalog-drift.yml): weekly (and on-demand) regenerates the catalog from the gdcorp-platform spec repos and fails if the committed domain schemas have drifted. Gated on a CATALOG_SPEC_TOKEN secret (the default GITHUB_TOKEN can't reach the spec org); no-ops without it. manifest.json's generated-at timestamp is excluded from the diff. EXPECTED_DOMAINS is #[cfg(test)] since only the guard test consumes it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/catalog-drift.yml | 66 ++++++++++++++ rust/tools/generate-api-catalog/src/main.rs | 96 +++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 .github/workflows/catalog-drift.yml diff --git a/.github/workflows/catalog-drift.yml b/.github/workflows/catalog-drift.yml new file mode 100644 index 00000000..4c84028a --- /dev/null +++ b/.github/workflows/catalog-drift.yml @@ -0,0 +1,66 @@ +name: API catalog drift + +# Regenerates the embedded API catalog from the upstream gdcorp-platform +# commerce.*-specification repos and fails if the committed catalog has drifted. +# This is the source-drift guard (catches per-operation / per-endpoint changes). +# The always-on structural guard (domains + endpoint counts vs the intentional +# list) lives in `cargo test` +# (committed_catalog_matches_expected_domains_and_manifest) and runs on every PR. +# +# Requires a token with read access to the gdcorp-platform spec repos, provisioned +# as the `CATALOG_SPEC_TOKEN` secret. The default GITHUB_TOKEN only reaches this +# repo, so the job no-ops when the secret is absent (e.g. on forks). + +on: + schedule: + - cron: "17 6 * * 1" # Mondays ~06:17 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + drift: + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Check for spec token + id: token + working-directory: . + env: + CATALOG_SPEC_TOKEN: ${{ secrets.CATALOG_SPEC_TOKEN }} + run: | + if [ -n "$CATALOG_SPEC_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::CATALOG_SPEC_TOKEN not set — skipping catalog drift check." + fi + + - name: Install Rust toolchain + if: steps.token.outputs.present == 'true' + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Regenerate catalog from upstream specs + if: steps.token.outputs.present == 'true' + env: + GITHUB_TOKEN: ${{ secrets.CATALOG_SPEC_TOKEN }} + run: cargo run -p generate-api-catalog + + - name: Fail on drift + if: steps.token.outputs.present == 'true' + working-directory: . + run: | + # manifest.json carries a generated-at timestamp that changes every run; + # exclude it and compare only the domain schema files. + if ! git diff --quiet -- rust/schemas/api ':(exclude)rust/schemas/api/manifest.json'; then + echo "::error::Embedded API catalog is stale vs upstream commerce.*-specification repos. Regenerate with: (cd rust && GITHUB_TOKEN= cargo run -p generate-api-catalog) and commit." + git diff --stat -- rust/schemas/api ':(exclude)rust/schemas/api/manifest.json' + exit 1 + fi + echo "API catalog is in sync with upstream specs." diff --git a/rust/tools/generate-api-catalog/src/main.rs b/rust/tools/generate-api-catalog/src/main.rs index 7e55a38a..a3a8c4d2 100644 --- a/rust/tools/generate-api-catalog/src/main.rs +++ b/rust/tools/generate-api-catalog/src/main.rs @@ -51,6 +51,37 @@ const HTTP_METHODS: &[&str] = &[ "get", "post", "put", "patch", "delete", "options", "head", "trace", ]; +/// The exact set of domains the committed catalog must contain — the deliberate, +/// reviewed contract. The `committed_catalog_matches_expected_domains_and_manifest` +/// test fails if the catalog files, or `manifest.json`, drift from this list, so +/// adding or removing a domain is a conscious change (update this list in the same +/// PR). This is the always-on, no-network guard; the scheduled regen-and-diff CI +/// job catches per-operation drift against the upstream spec repos. +#[cfg(test)] +const EXPECTED_DOMAINS: &[&str] = &[ + "bulk-operations", + "businesses", + "catalog-products", + "channels", + "chargebacks", + "customer-profiles", + "fulfillments", + "hosting-nodejs", + "location-addresses", + "metafields", + "onboarding", + "orders", + "payment-requests", + "payments", + "price-adjustments", + "recommendations", + "shipping", + "stores", + "subscriptions", + "taxes", + "transactions", +]; + // --------------------------------------------------------------------------- // Output catalog types // --------------------------------------------------------------------------- @@ -2070,4 +2101,69 @@ components: "Bar" ); } + + /// Drift guard: the committed catalog files and `manifest.json` must match the + /// intentional `EXPECTED_DOMAINS` contract, and every domain's endpoint count + /// must agree between its file and the manifest. Catches accidental drift + /// (a domain added/removed, a hand-edited catalog, a stale manifest) on every + /// `cargo test` run — no network or credentials required. + #[test] + fn committed_catalog_matches_expected_domains_and_manifest() { + let dir = resolve_output_dir(); + + // Domain files present on disk (excluding manifest.json). + let mut files: Vec = std::fs::read_dir(&dir) + .expect("read schemas/api dir") + .filter_map(|e| e.ok()) + .filter_map(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + match n.strip_suffix(".json") { + Some(stem) if n != "manifest.json" => Some(stem.to_owned()), + _ => None, + } + }) + .collect(); + files.sort(); + + let mut expected: Vec = EXPECTED_DOMAINS.iter().map(|s| (*s).to_owned()).collect(); + expected.sort(); + + assert_eq!( + files, expected, + "catalog domain files drifted from EXPECTED_DOMAINS — if adding/removing a \ + domain, update EXPECTED_DOMAINS in the same change" + ); + + // manifest.json must list exactly the same domains... + let manifest: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join("manifest.json")).expect("read manifest.json"), + ) + .expect("parse manifest.json"); + let domains = manifest["domains"] + .as_object() + .expect("manifest.domains is an object"); + + let mut manifest_domains: Vec = domains.keys().cloned().collect(); + manifest_domains.sort(); + assert_eq!( + manifest_domains, expected, + "manifest.json domains differ from EXPECTED_DOMAINS / catalog files" + ); + + // ...and each domain's endpoint count must match between file and manifest. + for domain in EXPECTED_DOMAINS { + let catalog: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join(format!("{domain}.json"))) + .unwrap_or_else(|e| panic!("read {domain}.json: {e}")), + ) + .unwrap_or_else(|e| panic!("parse {domain}.json: {e}")); + + let actual = catalog["endpoints"].as_array().map_or(0, Vec::len); + let manifest_count = domains[*domain]["endpointCount"].as_u64().unwrap_or(0) as usize; + assert_eq!( + actual, manifest_count, + "endpoint-count drift for '{domain}': file has {actual}, manifest says {manifest_count}" + ); + } + } } From 8aa4afdb3ec993cffecff5775aa9014c900a99bf Mon Sep 17 00:00:00 2001 From: "Sanket M." Date: Tue, 14 Jul 2026 16:50:30 +0530 Subject: [PATCH 2/3] chore(api-catalog): address review nits (DEVX-549) - guard test: fail loudly (panic with domain name) on a missing endpoints array or endpointCount instead of comparing two silent zeros - catalog-drift workflow: add timeout-minutes: 20 to bound a hung spec fetch - clarify the regen step uses the job-default rust/ working-directory Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/catalog-drift.yml | 3 +++ rust/tools/generate-api-catalog/src/main.rs | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/catalog-drift.yml b/.github/workflows/catalog-drift.yml index 4c84028a..3b742c20 100644 --- a/.github/workflows/catalog-drift.yml +++ b/.github/workflows/catalog-drift.yml @@ -22,6 +22,8 @@ permissions: jobs: drift: runs-on: ubuntu-latest + # Guard against a hung network fetch to the spec repos burning runner minutes. + timeout-minutes: 20 defaults: run: working-directory: rust @@ -48,6 +50,7 @@ jobs: - name: Regenerate catalog from upstream specs if: steps.token.outputs.present == 'true' + # Runs from the job-default working-directory (rust/) — cargo needs the workspace. env: GITHUB_TOKEN: ${{ secrets.CATALOG_SPEC_TOKEN }} run: cargo run -p generate-api-catalog diff --git a/rust/tools/generate-api-catalog/src/main.rs b/rust/tools/generate-api-catalog/src/main.rs index a3a8c4d2..c6f06fc4 100644 --- a/rust/tools/generate-api-catalog/src/main.rs +++ b/rust/tools/generate-api-catalog/src/main.rs @@ -2158,8 +2158,16 @@ components: ) .unwrap_or_else(|e| panic!("parse {domain}.json: {e}")); - let actual = catalog["endpoints"].as_array().map_or(0, Vec::len); - let manifest_count = domains[*domain]["endpointCount"].as_u64().unwrap_or(0) as usize; + // Fail loudly on a structurally broken file rather than comparing two + // silent zeros (which would hide a missing endpoints array / count). + let actual = catalog["endpoints"] + .as_array() + .unwrap_or_else(|| panic!("'{domain}.json' has no endpoints array")) + .len(); + let manifest_count = domains[*domain]["endpointCount"] + .as_u64() + .unwrap_or_else(|| panic!("manifest.json missing endpointCount for '{domain}'")) + as usize; assert_eq!( actual, manifest_count, "endpoint-count drift for '{domain}': file has {actual}, manifest says {manifest_count}" From be0c1df2360ee6139ff4cac9f9739797c0fc23c0 Mon Sep 17 00:00:00 2001 From: "Sanket M." Date: Wed, 15 Jul 2026 17:20:59 +0530 Subject: [PATCH 3/3] fix(api-catalog): enforce authoritative drift checks (DEVX-549) Make the reconciled source manifest drive generation and fail CI on partial or stale upstream catalogs. Co-authored-by: Cursor --- .github/workflows/catalog-drift.yml | 34 +- rust/Cargo.lock | 1 + rust/api-catalog-sources.json | 99 ++ rust/schemas/api/manifest.json | 4 +- rust/schemas/api/transactions.json | 990 +++++++++++++++++++- rust/tools/generate-api-catalog/Cargo.toml | 1 + rust/tools/generate-api-catalog/src/main.rs | 422 ++++----- 7 files changed, 1323 insertions(+), 228 deletions(-) create mode 100644 rust/api-catalog-sources.json diff --git a/.github/workflows/catalog-drift.yml b/.github/workflows/catalog-drift.yml index 3b742c20..bab1c6fb 100644 --- a/.github/workflows/catalog-drift.yml +++ b/.github/workflows/catalog-drift.yml @@ -9,11 +9,19 @@ name: API catalog drift # # Requires a token with read access to the gdcorp-platform spec repos, provisioned # as the `CATALOG_SPEC_TOKEN` secret. The default GITHUB_TOKEN only reaches this -# repo, so the job no-ops when the secret is absent (e.g. on forks). +# repo. A missing token is a configuration error and fails the job. +# +# Scheduled workflows only run from a repository's default branch. While +# `rust-port` is not the default, run this check on every push to `rust-port`; +# the weekly schedule activates when `rust-port` becomes the default branch. +# `workflow_dispatch` also allows an on-demand check of any branch. on: schedule: - - cron: "17 6 * * 1" # Mondays ~06:17 UTC + - cron: "17 6 * * 1" # Mondays ~06:17 UTC after rust-port becomes default + push: + branches: + - rust-port workflow_dispatch: permissions: @@ -32,38 +40,36 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check for spec token - id: token working-directory: . env: CATALOG_SPEC_TOKEN: ${{ secrets.CATALOG_SPEC_TOKEN }} run: | - if [ -n "$CATALOG_SPEC_TOKEN" ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "::notice::CATALOG_SPEC_TOKEN not set — skipping catalog drift check." + if [ -z "$CATALOG_SPEC_TOKEN" ]; then + echo "::error::CATALOG_SPEC_TOKEN must be configured with read access to gdcorp-platform specification repositories." + exit 1 fi - name: Install Rust toolchain - if: steps.token.outputs.present == 'true' uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - name: Regenerate catalog from upstream specs - if: steps.token.outputs.present == 'true' # Runs from the job-default working-directory (rust/) — cargo needs the workspace. env: GITHUB_TOKEN: ${{ secrets.CATALOG_SPEC_TOKEN }} run: cargo run -p generate-api-catalog - name: Fail on drift - if: steps.token.outputs.present == 'true' working-directory: . run: | # manifest.json carries a generated-at timestamp that changes every run; - # exclude it and compare only the domain schema files. - if ! git diff --quiet -- rust/schemas/api ':(exclude)rust/schemas/api/manifest.json'; then + # stage the generated directory, then unstage that timestamp-only file. + # The staged diff catches modifications, deletions, and new untracked domains. + git add -A -- rust/schemas/api + git restore --staged -- rust/schemas/api/manifest.json + + if ! git diff --cached --quiet -- rust/schemas/api; then echo "::error::Embedded API catalog is stale vs upstream commerce.*-specification repos. Regenerate with: (cd rust && GITHUB_TOKEN= cargo run -p generate-api-catalog) and commit." - git diff --stat -- rust/schemas/api ':(exclude)rust/schemas/api/manifest.json' + git diff --cached --stat -- rust/schemas/api exit 1 fi echo "API catalog is in sync with upstream specs." diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 87ad7fbb..9f68dd78 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1258,6 +1258,7 @@ name = "generate-api-catalog" version = "0.1.3" dependencies = [ "anyhow", + "base64 0.22.1", "chrono", "graphql-parser", "indexmap", diff --git a/rust/api-catalog-sources.json b/rust/api-catalog-sources.json new file mode 100644 index 00000000..6fcccde2 --- /dev/null +++ b/rust/api-catalog-sources.json @@ -0,0 +1,99 @@ +{ + "version": 1, + "remote": [ + { + "domain": "bulk-operations", + "repository": "commerce.bulk-operations-specification" + }, + { + "domain": "businesses", + "repository": "commerce.businesses-specification" + }, + { + "domain": "catalog-products", + "repository": "commerce.catalog-products-specification" + }, + { + "domain": "channels", + "repository": "commerce.channels-specification" + }, + { + "domain": "chargebacks", + "repository": "commerce.chargebacks-specification" + }, + { + "domain": "customer-profiles", + "repository": "commerce.customer-profiles-specification" + }, + { + "domain": "fulfillments", + "repository": "commerce.fulfillments-specification" + }, + { + "domain": "location-addresses", + "repository": "location.addresses-specification" + }, + { + "domain": "metafields", + "repository": "commerce.metafields-specification" + }, + { + "domain": "onboarding", + "repository": "commerce.onboarding-specification" + }, + { + "domain": "orders", + "repository": "commerce.orders-specification" + }, + { + "domain": "payment-requests", + "repository": "commerce.payment-requests-specification" + }, + { + "domain": "payments", + "repository": "commerce.payments-specification" + }, + { + "domain": "price-adjustments", + "repository": "commerce.price-adjustments-specification" + }, + { + "domain": "recommendations", + "repository": "commerce.recommendations-specification" + }, + { + "domain": "shipping", + "repository": "commerce.shipping-specification" + }, + { + "domain": "stores", + "repository": "commerce.stores-specification" + }, + { + "domain": "subscriptions", + "repository": "commerce.subscriptions-specification" + }, + { + "domain": "taxes", + "repository": "commerce.taxes-specification" + }, + { + "domain": "transactions", + "repository": "commerce.transactions-specification" + } + ], + "local": [ + { + "domain": "hosting-nodejs", + "path": "schemas/openapi/hosting-nodejs-public-v1.yaml" + } + ], + "legacyTypescript": { + "status": "retired-on-rust-port", + "sharedDomainCount": 20, + "rustOnlyDomains": [ + "hosting-nodejs" + ], + "rationale": "The legacy TypeScript catalog contains the 20 remote domains above. hosting-nodejs is intentionally Rust-only because it is generated from the CLI's vendored public hosting specification." + } +} diff --git a/rust/schemas/api/manifest.json b/rust/schemas/api/manifest.json index 89e31d46..575e012e 100644 --- a/rust/schemas/api/manifest.json +++ b/rust/schemas/api/manifest.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-02T22:25:00.564300686+00:00", + "generated": "2026-07-15T11:40:00.622620+00:00", "domains": { "subscriptions": { "file": "subscriptions.json", @@ -14,7 +14,7 @@ "transactions": { "file": "transactions.json", "title": "Transactions API", - "endpointCount": 7 + "endpointCount": 17 }, "orders": { "file": "orders.json", diff --git a/rust/schemas/api/transactions.json b/rust/schemas/api/transactions.json index b00b7737..e7a218a2 100644 --- a/rust/schemas/api/transactions.json +++ b/rust/schemas/api/transactions.json @@ -281,7 +281,7 @@ "$ref": "#/$defs/LegacyCardDetailData" }, "number": { - "description": "Card number", + "description": "Card number. On create, supply the full PAN. In GET responses, returns a masked representation (e.g. ****9990) — not the full card number.\n", "type": "string" }, "numberHashed": { @@ -692,6 +692,87 @@ "title": "Fee", "type": "object" }, + "FinancialInstrument": { + "$id": "https://godaddy.com/schemas/commerce/financial-instrument.v2", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A card or bank account stored on file, optionally associated with an owner reference. Vault context (stored for reuse) is expressed by this resource and its `status` — nested `card` and `bankAccount` reuse the standard funding-source models (`PaymentCard`, `BankAccount`), the same types used for one-time charges.\nThe `type` field identifies the persisted instrument kind (CARD or BANK_ACCOUNT) and determines which detail object is present in responses (`card` or `bankAccount`).\nOn create, supply exactly one input source: `card` with `type` CARD, `bankAccount` with `type` BANK_ACCOUNT, or `nonce` alone (the server resolves the nonce to a card or bank account before persistence — `type` is assigned in the response). `tokenContext` is required on create. Write-only fields (`nonce`, `tokenContext`, and raw card/bank account numbers) are accepted on create and never returned in responses.\n", + "properties": { + "bankAccount": { + "$ref": "#/$defs/BankAccount" + }, + "card": { + "$ref": "#/$defs/PaymentCard" + }, + "createdAt": { + "allOf": [ + { + "$ref": "#/$defs/date-time" + } + ], + "description": "Time the instrument was created. Sortable via list query parameter `sortBy=CREATED_AT`.\n", + "example": "2024-01-15T10:30:00Z", + "readOnly": true + }, + "id": { + "$ref": "#/$defs/Uuid" + }, + "nonce": { + "description": "Single-use tokenization nonce from a compliant card-capture SDK — alternative to supplying `card` or `bankAccount` directly (PCI-out-of-scope path). The server resolves this to a card or bank account before persistence; it is not a stored instrument type.\n", + "example": "550e8400-e29b-41d4-a716-446655440000", + "format": "uuid", + "type": "string", + "writeOnly": true, + "x-sensitivity": { + "classification": "restricted" + } + }, + "ownerReference": { + "allOf": [ + { + "$ref": "#/$defs/OwnerReference" + } + ], + "description": "External owner reference attached to this instrument", + "x-sensitivity": { + "classification": "confidential" + } + }, + "status": { + "description": "Status of the financial instrument. Currently accepted values: ACTIVE, INACTIVE.\n", + "example": "ACTIVE", + "readOnly": true, + "type": "string" + }, + "tokenContext": { + "$ref": "#/$defs/TokenContext" + }, + "tokens": { + "description": "Per-store charge handles. Populated on create when `tokenContext` is provided, and on list when `includeToken` is true (scoped to the required `storeId`). Each item includes the chargeable JWT in `token`. Use GET /financial-instruments/{id}/tokens to retrieve tokens explicitly.\n", + "items": { + "$ref": "#/$defs/Token" + }, + "readOnly": true, + "type": "array" + }, + "type": { + "description": "Discriminator representing the persisted instrument kind. Always present in responses. Required on create when supplying `card` or `bankAccount`; may be omitted when supplying `nonce` (the server assigns the type after resolving the nonce). Currently accepted values: CARD, BANK_ACCOUNT.\n", + "example": "CARD", + "type": "string" + }, + "updatedAt": { + "allOf": [ + { + "$ref": "#/$defs/date-time" + } + ], + "description": "Time the instrument was last updated. Sortable via list query parameter `sortBy=UPDATED_AT`.\n", + "example": "2024-01-15T10:30:00Z", + "readOnly": true + } + }, + "title": "Financial Instrument", + "type": "object" + }, "FundingSource": { "$id": "https://godaddy.com/schemas/commerce/transaction/funding-source.v2", "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -2869,6 +2950,41 @@ "type": "array", "x-auto-generated": true }, + "OwnerReference": { + "$id": "https://godaddy.com/schemas/commerce/financial-instrument/owner-reference.v2", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "External owner reference attached to a financial instrument, identifying the owner within an external system.", + "properties": { + "id": { + "description": "Alphanumeric owner identifier (e.g. numeric customer id or UUID without dashes)", + "example": "251727828", + "maxLength": 64, + "minLength": 1, + "pattern": "^[A-Za-z0-9]+$", + "type": "string", + "x-sensitivity": { + "classification": "confidential" + } + }, + "type": { + "description": "Letters-only namespace qualifier identifying the owner system (e.g. Shopper, GDCustomer)", + "example": "Shopper", + "maxLength": 32, + "minLength": 1, + "pattern": "^[A-Za-z]+$", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "Owner Reference", + "type": "object", + "x-sensitivity": { + "classification": "confidential" + } + }, "PaymentCard": { "$id": "https://godaddy.com/schemas/commerce/transaction/funding-source/payment-card.v2", "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -3098,6 +3214,61 @@ "title": "Tip Amount", "type": "object" }, + "Token": { + "$id": "https://godaddy.com/schemas/commerce/financial-instrument/token.v2", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A per-store charge handle that authorizes charging a stored financial instrument at a specific store. Use the `token` field in fundingSource.token to charge. The JWT encodes store and application scope — clients do not need separate scope fields on this resource.\n", + "properties": { + "createdAt": { + "allOf": [ + { + "$ref": "#/$defs/date-time" + } + ], + "description": "Time the token was created", + "example": "2024-01-15T10:30:00Z", + "readOnly": true + }, + "id": { + "$ref": "#/$defs/Uuid" + }, + "status": { + "description": "Status of this token. Currently accepted values: ACTIVE, INACTIVE.\n", + "example": "ACTIVE", + "type": "string" + }, + "token": { + "description": "The chargeable JWT payment token. Use this value in fundingSource.token when creating a transaction. Store and application scope are encoded in the JWT. Always present on mint and get responses. On list, each item in the nested `tokens` array includes the chargeable JWT when `includeToken` is true. Handle as a secret — do not log or expose to end users.\n", + "readOnly": true, + "type": "string", + "x-propagation": { + "optional": true + }, + "x-sensitivity": { + "classification": "restricted" + } + } + }, + "title": "Token", + "type": "object" + }, + "TokenContext": { + "$id": "https://godaddy.com/schemas/commerce/financial-instrument/token-context.v2", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Context for minting a per-store token. Used when vaulting a financial instrument (first token) and when associating an existing instrument with a new store. `storeId` is required. Application identity is derived from the authorization credential — clients do not supply `applicationId`.\n", + "properties": { + "storeId": { + "description": "Store the token is minted for (required)", + "example": "storeB", + "type": "string" + } + }, + "required": [ + "storeId" + ], + "title": "Token Context", + "type": "object" + }, "TokenVerificationData": { "$id": "https://godaddy.com/schemas/commerce/transaction/funding-source/token-verification.v2", "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -3547,6 +3718,22 @@ "title": "Error Details", "type": "object" }, + "financialInstrumentList": { + "allOf": [ + { + "$ref": "#/$defs/pagedList" + } + ], + "properties": { + "items": { + "description": "List of financial instruments matching the query", + "items": { + "$ref": "#/$defs/FinancialInstrument" + }, + "type": "array" + } + } + }, "geo-coordinates": { "$id": "https://godaddy.com/schema/common/geo-coordinates.v1", "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -3644,11 +3831,810 @@ "type": "array" } } + }, + "token": { + "$ref": "#/$defs/Token" + }, + "tokenContext": { + "$ref": "#/$defs/TokenContext" } }, "baseUrl": "https://api.godaddy.com/v2/commerce", "description": "This API is capable of creating, updating and fetching transactions across different stores.", "endpoints": [ + { + "description": "Retrieve financial instruments (cards and bank accounts on file) associated with a given owner reference. Only instruments with status ACTIVE are returned. Results are scoped to the required `storeId` for authorization and token retrieval. Optional filters include business, application, and instrument type.\n", + "method": "GET", + "operationId": "listFinancialInstruments", + "parameters": [ + { + "in": "header", + "name": "Request Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "The `id` portion of `ownerReference` (e.g., a UUID or numeric customer ID)\n", + "in": "query", + "name": "ownerReferenceId", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 1, + "type": "string" + } + }, + { + "description": "The `type` portion of `ownerReference` — letters only (e.g., Shopper, GDCustomer)\n", + "in": "query", + "name": "ownerReferenceType", + "required": true, + "schema": { + "maxLength": 32, + "minLength": 1, + "pattern": "^[A-Za-z]+$", + "type": "string" + } + }, + { + "description": "Store scope for authorization, filtering, and token retrieval. Required.\n", + "in": "query", + "name": "storeId", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "description": "Filter results to a specific business", + "in": "query", + "name": "businessId", + "required": false, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "description": "Filter results to a specific application", + "in": "query", + "name": "applicationId", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter by instrument type. Currently accepted values: CARD, BANK_ACCOUNT. Omit to return both cards and bank accounts.\n", + "in": "query", + "name": "instrumentType", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "When true, excludes CARD instruments where the card expiry (expMonth/expYear) is in the past. Has no effect on BANK_ACCOUNT instruments.\n", + "in": "query", + "name": "excludeExpired", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "description": "When true, includes the per-store `tokens` array on each instrument. Each token item includes the chargeable JWT in `token` (scoped to the required `storeId`). Use the JWT in fundingSource.token to charge. Applies to both CARD and BANK_ACCOUNT instruments. When false or omitted, `tokens` is not returned.\n", + "in": "query", + "name": "includeToken", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "description": "Sort field for results. Maps to FinancialInstrument timestamp properties: CREATED_AT (`createdAt`), UPDATED_AT (`updatedAt`).\n", + "in": "query", + "name": "sortBy", + "required": false, + "schema": { + "default": "CREATED_AT", + "type": "string" + } + }, + { + "description": "Sort direction. Currently accepted values: ASC, DESC.\n", + "in": "query", + "name": "sortOrder", + "required": false, + "schema": { + "default": "ASC", + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "type": "integer" + } + }, + { + "description": "Number of results per page (max 100)", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 10, + "maximum": 100, + "type": "integer" + } + }, + { + "in": "query", + "name": "totalRequired", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "path": "/financial-instruments", + "responses": { + "200": { + "description": "Financial Instrument List. Returns an empty items array when no instruments match — never 404.", + "schema": { + "$ref": "#/$defs/financialInstrumentList" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + } + }, + "scopes": [ + "commerce.financial-instrument:read" + ], + "summary": "List financial instruments by owner reference" + }, + { + "description": "Vaults a card or bank account on file and mints the first per-store token. Supply exactly one input source: `type` CARD with `card` for raw card details (server-to-server, PCI-in-scope), `type` BANK_ACCOUNT with `bankAccount` for ACH details, or `nonce` alone for a single-use token from a compliant card-capture SDK (PCI-out-of-scope path — the server resolves the nonce to CARD or BANK_ACCOUNT before persistence). `tokenContext` is required (supply `storeId` only — application identity is derived from the authorization credential). The customer consent agreement is derived internally — clients do not send it.\n", + "method": "POST", + "operationId": "createFinancialInstrument", + "parameters": [ + { + "in": "header", + "name": "Request Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "path": "/financial-instruments", + "requestBody": { + "contentType": "application/json", + "required": true, + "schema": { + "$ref": "#/$defs/FinancialInstrument" + } + }, + "responses": { + "201": { + "description": "Financial instrument created. The response includes the first store token (store context was provided in tokenContext).\n", + "schema": { + "$ref": "#/$defs/FinancialInstrument" + } + }, + "400": { + "description": "Bad Request — structurally invalid request (malformed JSON, wrong field type, or missing required field).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "409": { + "description": "Conflict — request conflicts with current state (e.g. duplicate token for the same store, or activating an already-active instrument or token).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "422": { + "description": "Unprocessable Entity — valid structure but violates a business rule (e.g. invalid card number, expired nonce, or inconsistent type and detail fields).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + } + }, + "scopes": [ + "commerce.financial-instrument:write" + ], + "summary": "Create a financial instrument" + }, + { + "description": "Retrieve a financial instrument by its UUID. Tokens are not included in this response — use GET /financial-instruments/{financialInstrumentId}/tokens to retrieve per-store tokens.\n", + "method": "GET", + "operationId": "getFinancialInstrument", + "parameters": [ + { + "description": "Financial instrument UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "in": "header", + "name": "Request Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "path": "/financial-instruments/{financialInstrumentId}", + "responses": { + "200": { + "description": "Financial instrument found", + "schema": { + "$ref": "#/$defs/FinancialInstrument" + } + }, + "404": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + } + }, + "scopes": [ + "commerce.financial-instrument:read" + ], + "summary": "Retrieve a financial instrument" + }, + { + "description": "Sets a financial instrument to ACTIVE.", + "method": "POST", + "operationId": "activateFinancialInstrument", + "parameters": [ + { + "description": "Financial instrument UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "in": "header", + "name": "Request Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "path": "/financial-instruments/{financialInstrumentId}/activate", + "responses": { + "200": { + "description": "Financial instrument activated", + "schema": { + "$ref": "#/$defs/FinancialInstrument" + } + }, + "400": { + "description": "Bad Request — structurally invalid request (malformed JSON, wrong field type, or missing required field).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "404": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + }, + "409": { + "description": "Conflict — request conflicts with current state (e.g. duplicate token for the same store, or activating an already-active instrument or token).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "422": { + "description": "Unprocessable Entity — valid structure but violates a business rule (e.g. invalid card number, expired nonce, or inconsistent type and detail fields).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + } + }, + "scopes": [ + "commerce.financial-instrument:write" + ], + "summary": "Activate a financial instrument" + }, + { + "description": "Sets a financial instrument to INACTIVE and cascades deactivation to all per-store tokens — the card can no longer be charged at any store.\n", + "method": "POST", + "operationId": "deactivateFinancialInstrument", + "parameters": [ + { + "description": "Financial instrument UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "in": "header", + "name": "Request Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "path": "/financial-instruments/{financialInstrumentId}/deactivate", + "responses": { + "200": { + "description": "Financial instrument deactivated", + "schema": { + "$ref": "#/$defs/FinancialInstrument" + } + }, + "400": { + "description": "Bad Request — structurally invalid request (malformed JSON, wrong field type, or missing required field).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "404": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + }, + "409": { + "description": "Conflict — request conflicts with current state (e.g. duplicate token for the same store, or activating an already-active instrument or token).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "422": { + "description": "Unprocessable Entity — valid structure but violates a business rule (e.g. invalid card number, expired nonce, or inconsistent type and detail fields).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + } + }, + "scopes": [ + "commerce.financial-instrument:write" + ], + "summary": "Deactivate a financial instrument (all stores)" + }, + { + "description": "Returns per-store tokens for the instrument. Filter by storeId to retrieve the token for a specific store.\n", + "method": "GET", + "operationId": "listTokens", + "parameters": [ + { + "description": "Financial instrument UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "in": "header", + "name": "Request Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Filter tokens to a specific store", + "in": "query", + "name": "storeId", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "type": "integer" + } + }, + { + "description": "Number of results per page (max 100)", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 10, + "maximum": 100, + "type": "integer" + } + }, + { + "in": "query", + "name": "totalRequired", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "path": "/financial-instruments/{financialInstrumentId}/tokens", + "responses": { + "200": { + "description": "Token list. Returns an empty items array when no tokens match — never 404.", + "schema": { + "allOf": [ + { + "$ref": "#/$defs/pagedList" + } + ], + "properties": { + "items": { + "description": "List of tokens associated with the financial instrument", + "items": { + "$ref": "#/$defs/token" + }, + "type": "array" + } + }, + "title": "Tokens" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + } + }, + "scopes": [ + "commerce.financial-instrument:read" + ], + "summary": "List tokens for a financial instrument" + }, + { + "description": "Associates an existing financial instrument with a new store and mints a per-store token. Supply `storeId` in the request body. Application identity is derived from the authorization credential. Store and application scope are encoded in the returned JWT — they are not duplicated as separate fields on the token resource. The resulting token can be used in fundingSource.token to charge the card at that store.\n", + "method": "POST", + "operationId": "createToken", + "parameters": [ + { + "description": "Financial instrument UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "in": "header", + "name": "Request Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "path": "/financial-instruments/{financialInstrumentId}/tokens", + "requestBody": { + "contentType": "application/json", + "required": true, + "schema": { + "$ref": "#/$defs/tokenContext" + } + }, + "responses": { + "201": { + "description": "Token minted", + "schema": { + "$ref": "#/$defs/token" + } + }, + "400": { + "description": "Bad Request — structurally invalid request (malformed JSON, wrong field type, or missing required field).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "404": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + }, + "409": { + "description": "Conflict — request conflicts with current state (e.g. duplicate token for the same store, or activating an already-active instrument or token).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "422": { + "description": "Unprocessable Entity — valid structure but violates a business rule (e.g. invalid card number, expired nonce, or inconsistent type and detail fields).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + } + }, + "scopes": [ + "commerce.financial-instrument:write" + ], + "summary": "Associate a store and mint a token" + }, + { + "description": "Retrieve a specific per-store token by its UUID. Useful for status checks and refreshing details after Card Account Updater runs.\n", + "method": "GET", + "operationId": "getToken", + "parameters": [ + { + "description": "Financial instrument UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "description": "Token UUID", + "in": "path", + "name": "tokenId", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "in": "header", + "name": "Request Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "path": "/financial-instruments/{financialInstrumentId}/tokens/{tokenId}", + "responses": { + "200": { + "description": "Token found", + "schema": { + "$ref": "#/$defs/token" + } + }, + "404": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + } + }, + "scopes": [ + "commerce.financial-instrument:read" + ], + "summary": "Retrieve a token" + }, + { + "description": "Sets a specific per-store token to ACTIVE.", + "method": "POST", + "operationId": "activateToken", + "parameters": [ + { + "description": "Financial instrument UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "description": "Token UUID", + "in": "path", + "name": "tokenId", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "in": "header", + "name": "Request Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "path": "/financial-instruments/{financialInstrumentId}/tokens/{tokenId}/activate", + "responses": { + "200": { + "description": "Token activated", + "schema": { + "$ref": "#/$defs/token" + } + }, + "400": { + "description": "Bad Request — structurally invalid request (malformed JSON, wrong field type, or missing required field).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "404": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + }, + "409": { + "description": "Conflict — request conflicts with current state (e.g. duplicate token for the same store, or activating an already-active instrument or token).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "422": { + "description": "Unprocessable Entity — valid structure but violates a business rule (e.g. invalid card number, expired nonce, or inconsistent type and detail fields).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + } + }, + "scopes": [ + "commerce.financial-instrument:write" + ], + "summary": "Activate a token" + }, + { + "description": "Sets a specific per-store token to INACTIVE. The card on file is removed for that store only — other stores are unaffected.\n", + "method": "POST", + "operationId": "deactivateToken", + "parameters": [ + { + "description": "Financial instrument UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "description": "Token UUID", + "in": "path", + "name": "tokenId", + "required": true, + "schema": { + "$ref": "#/$defs/Uuid" + } + }, + { + "in": "header", + "name": "Request Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "path": "/financial-instruments/{financialInstrumentId}/tokens/{tokenId}/deactivate", + "responses": { + "200": { + "description": "Token deactivated", + "schema": { + "$ref": "#/$defs/token" + } + }, + "400": { + "description": "Bad Request — structurally invalid request (malformed JSON, wrong field type, or missing required field).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "404": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + }, + "409": { + "description": "Conflict — request conflicts with current state (e.g. duplicate token for the same store, or activating an already-active instrument or token).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "422": { + "description": "Unprocessable Entity — valid structure but violates a business rule (e.g. invalid card number, expired nonce, or inconsistent type and detail fields).\n", + "schema": { + "$ref": "#/$defs/error" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/$defs/error" + } + } + }, + "scopes": [ + "commerce.financial-instrument:write" + ], + "summary": "Deactivate a token (per store)" + }, { "description": "Retrieve all the transactions for a specific store using the store ID.", "method": "GET", @@ -4119,5 +5105,5 @@ ], "name": "transactions", "title": "Transactions API", - "version": "2.1.0" + "version": "2.2.1" } \ No newline at end of file diff --git a/rust/tools/generate-api-catalog/Cargo.toml b/rust/tools/generate-api-catalog/Cargo.toml index 0369e751..2d594115 100644 --- a/rust/tools/generate-api-catalog/Cargo.toml +++ b/rust/tools/generate-api-catalog/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] anyhow = "1" +base64 = "0.22.1" chrono = { version = "0.4", default-features = false, features = ["clock"] } graphql-parser = "0.4" indexmap = "2" diff --git a/rust/tools/generate-api-catalog/src/main.rs b/rust/tools/generate-api-catalog/src/main.rs index c6f06fc4..d267f992 100644 --- a/rust/tools/generate-api-catalog/src/main.rs +++ b/rust/tools/generate-api-catalog/src/main.rs @@ -5,6 +5,7 @@ use std::{ }; use anyhow::{Context, Result, bail}; +use base64::Engine; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -16,72 +17,12 @@ use serde_json::Value; const GITHUB_ORG: &str = "gdcorp-platform"; const GITHUB_API_BASE: &str = "https://api.github.com"; const GITHUB_PAGE_SIZE: u32 = 100; - -const BOOTSTRAP_REPOS: &[&str] = &[ - "commerce.bulk-operations-specification", - "commerce.businesses-specification", - "commerce.catalog-products-specification", - "commerce.channels-specification", - "commerce.chargebacks-specification", - "commerce.customer-profiles-specification", - "commerce.fulfillments-specification", - "commerce.metafields-specification", - "commerce.onboarding-specification", - "commerce.orders-specification", - "commerce.payment-requests-specification", - "commerce.payments-specification", - "commerce.price-adjustments-specification", - "commerce.recommendations-specification", - "commerce.shipping-specification", - "commerce.stores-specification", - "commerce.subscriptions-specification", - "commerce.taxes-specification", - "commerce.transactions-specification", -]; - -const LEGACY_REPOS: &[&str] = &["location.addresses-specification"]; - -/// Local vendored OpenAPI specs (domain key, path relative to this crate's manifest dir). -const LOCAL_SPECS: &[(&str, &str)] = &[( - "hosting-nodejs", - "../../schemas/openapi/hosting-nodejs-public-v1.yaml", -)]; +const SOURCE_MANIFEST_JSON: &str = include_str!("../../../api-catalog-sources.json"); const HTTP_METHODS: &[&str] = &[ "get", "post", "put", "patch", "delete", "options", "head", "trace", ]; -/// The exact set of domains the committed catalog must contain — the deliberate, -/// reviewed contract. The `committed_catalog_matches_expected_domains_and_manifest` -/// test fails if the catalog files, or `manifest.json`, drift from this list, so -/// adding or removing a domain is a conscious change (update this list in the same -/// PR). This is the always-on, no-network guard; the scheduled regen-and-diff CI -/// job catches per-operation drift against the upstream spec repos. -#[cfg(test)] -const EXPECTED_DOMAINS: &[&str] = &[ - "bulk-operations", - "businesses", - "catalog-products", - "channels", - "chargebacks", - "customer-profiles", - "fulfillments", - "hosting-nodejs", - "location-addresses", - "metafields", - "onboarding", - "orders", - "payment-requests", - "payments", - "price-adjustments", - "recommendations", - "shipping", - "stores", - "subscriptions", - "taxes", - "transactions", -]; - // --------------------------------------------------------------------------- // Output catalog types // --------------------------------------------------------------------------- @@ -199,6 +140,49 @@ struct CatalogManifest { // Internal types // --------------------------------------------------------------------------- +#[derive(Debug, Deserialize)] +struct RemoteCatalogSource { + domain: String, + repository: String, +} + +#[derive(Debug, Deserialize)] +struct LocalCatalogSource { + domain: String, + path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyTypescriptParity { + status: String, + shared_domain_count: usize, + rust_only_domains: Vec, + rationale: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CatalogSourceManifest { + version: u32, + remote: Vec, + local: Vec, + legacy_typescript: LegacyTypescriptParity, +} + +impl CatalogSourceManifest { + fn expected_domains(&self) -> Vec { + let mut domains: Vec = self + .remote + .iter() + .map(|source| source.domain.clone()) + .chain(self.local.iter().map(|source| source.domain.clone())) + .collect(); + domains.sort(); + domains + } +} + #[derive(Debug, Deserialize)] struct GithubRepo { name: String, @@ -216,6 +200,55 @@ struct SpecSource { graphql_only: bool, } +fn load_source_manifest() -> Result { + let manifest: CatalogSourceManifest = serde_json::from_str(SOURCE_MANIFEST_JSON) + .context("failed to parse api-catalog-sources.json")?; + + if manifest.version != 1 { + bail!( + "unsupported api-catalog-sources.json version {}", + manifest.version + ); + } + if manifest.legacy_typescript.status != "retired-on-rust-port" { + bail!("legacyTypescript.status must document the Rust-port retirement"); + } + if manifest.legacy_typescript.shared_domain_count != manifest.remote.len() { + bail!( + "legacyTypescript.sharedDomainCount must match the {} remote domains", + manifest.remote.len() + ); + } + if manifest.legacy_typescript.rationale.trim().is_empty() { + bail!("legacyTypescript.rationale must document the catalog difference"); + } + + let expected = manifest.expected_domains(); + let unique: HashSet<&str> = expected.iter().map(String::as_str).collect(); + if unique.len() != expected.len() { + bail!("api-catalog-sources.json contains duplicate domain names"); + } + + let mut local_domains: Vec<&str> = manifest + .local + .iter() + .map(|source| source.domain.as_str()) + .collect(); + local_domains.sort(); + let mut rust_only_domains: Vec<&str> = manifest + .legacy_typescript + .rust_only_domains + .iter() + .map(String::as_str) + .collect(); + rust_only_domains.sort(); + if rust_only_domains != local_domains { + bail!("legacyTypescript.rustOnlyDomains must match the local source domains"); + } + + Ok(manifest) +} + // --------------------------------------------------------------------------- // GitHub discovery // --------------------------------------------------------------------------- @@ -337,54 +370,36 @@ fn find_latest_spec_file(repo_dir: &Path) -> Option<(String, PathBuf, bool)> { None } -fn derive_domain(repo_name: &str) -> String { - let without_suffix = repo_name - .strip_suffix("-specification") - .unwrap_or(repo_name); - let without_prefix = without_suffix - .strip_prefix("commerce.") - .unwrap_or(without_suffix); - let slug = without_prefix.to_lowercase().replace('.', "-"); - let slug = regex_replace_all(r"[^a-z0-9-]", &slug, "-"); - let slug = regex_replace_all(r"-+", &slug, "-"); - slug.trim_matches('-').to_owned() -} - -fn regex_replace_all(pattern: &str, input: &str, replacement: &str) -> String { - // Simple character-class based replace using char iteration for our limited patterns. - match pattern { - r"[^a-z0-9-]" => input - .chars() - .map(|c| { - if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' { - c.to_string() - } else { - replacement.to_owned() - } - }) - .collect(), - r"-+" => { - let mut out = String::with_capacity(input.len()); - let mut in_dash = false; - for c in input.chars() { - if c == '-' { - if !in_dash { - out.push_str(replacement); - in_dash = true; - } - } else { - in_dash = false; - out.push(c); - } - } - out - } - _ => input.to_owned(), +fn git_auth_config(token: Option<&str>) -> HashMap { + let mut config = HashMap::from([( + "url.https://github.com/.insteadOf".to_owned(), + "git@github.com:".to_owned(), + )]); + if let Some(token) = token.map(str::trim).filter(|token| !token.is_empty()) { + let credentials = + base64::engine::general_purpose::STANDARD.encode(format!("x-access-token:{token}")); + config.insert( + "http.https://github.com/.extraHeader".to_owned(), + format!("AUTHORIZATION: basic {credentials}"), + ); + } + config +} + +fn git_command() -> Command { + let token = std::env::var("GITHUB_TOKEN").ok(); + let config = git_auth_config(token.as_deref()); + let mut command = Command::new("git"); + command.env("GIT_CONFIG_COUNT", config.len().to_string()); + for (index, (key, value)) in config.into_iter().enumerate() { + command.env(format!("GIT_CONFIG_KEY_{index}"), key); + command.env(format!("GIT_CONFIG_VALUE_{index}"), value); } + command } fn git_run(args: &[&str]) -> Result<()> { - let status = Command::new("git") + let status = git_command() .args(args) .status() .context("failed to run git")?; @@ -456,13 +471,7 @@ fn parse_repo_ref_overrides() -> HashMap { map } -fn include_legacy_location() -> bool { - std::env::var("API_CATALOG_INCLUDE_LEGACY_LOCATION") - .map(|v| v.to_lowercase() != "false") - .unwrap_or(true) -} - -fn discover_spec_sources() -> Result<(Vec, PathBuf)> { +fn discover_spec_sources(manifest: &CatalogSourceManifest) -> Result<(Vec, PathBuf)> { let client = github_client()?; let all_repos = list_org_repos(&client); let repo_map: HashMap<&str, &GithubRepo> = @@ -470,54 +479,27 @@ fn discover_spec_sources() -> Result<(Vec, PathBuf)> { let overrides = parse_repo_overrides(); let ref_overrides = parse_repo_ref_overrides(); - - let mut selected: Vec = if let Some(ov) = overrides { - ov - } else { - let commerce_pattern = regex_is_match_commerce_spec; - let mut names: Vec = all_repos - .iter() - .filter(|r| commerce_pattern(&r.name)) - .map(|r| r.name.clone()) - .collect(); - if names.is_empty() { - eprintln!( - "WARNING: no commerce specs found via GitHub discovery; using bootstrap list" - ); - names = BOOTSTRAP_REPOS.iter().map(|s| s.to_string()).collect(); + let selected: Vec<&RemoteCatalogSource> = match overrides { + Some(repositories) => { + let selected: Vec<&RemoteCatalogSource> = repositories + .iter() + .map(|repository| { + manifest + .remote + .iter() + .find(|source| source.repository == *repository) + .with_context(|| { + format!( + "API_CATALOG_REPOS contains '{repository}', which is not declared in api-catalog-sources.json" + ) + }) + }) + .collect::>()?; + selected } - names + None => manifest.remote.iter().collect(), }; - if include_legacy_location() { - for r in LEGACY_REPOS { - if !selected.contains(&r.to_string()) { - selected.push(r.to_string()); - } - } - } - - selected.sort(); - - // Build list of (name, clone_url) filtering out archived/disabled/private - let repos_to_clone: Vec<(String, String)> = selected - .iter() - .filter_map(|name| { - if let Some(r) = repo_map.get(name.as_str()) { - if r.archived || r.disabled || r.private { - return None; - } - Some((name.clone(), r.clone_url.clone())) - } else { - // Not in discovered list — synthesize URL - Some(( - name.clone(), - format!("https://github.com/{GITHUB_ORG}/{name}.git"), - )) - } - }) - .collect(); - // Create temp directory let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -529,32 +511,32 @@ fn discover_spec_sources() -> Result<(Vec, PathBuf)> { // Clone common-types-specification let common_types_dir = tmpdir.join("__common-types"); let ct_url = format!("https://github.com/{GITHUB_ORG}/common-types-specification.git"); - if let Err(e) = clone_repo(&ct_url, &common_types_dir, None) { - eprintln!("WARNING: failed to clone common-types-specification: {e}"); - } + clone_repo(&ct_url, &common_types_dir, None) + .context("failed to clone declared common-types specification source")?; let mut sources = Vec::new(); let mut used_domains = HashSet::new(); - for (repo_name, clone_url) in &repos_to_clone { + for source in selected { + let repo_name = &source.repository; + let clone_url = if let Some(repo) = repo_map.get(repo_name.as_str()) { + if repo.archived || repo.disabled || repo.private { + bail!("catalog source repository '{repo_name}' is unavailable"); + } + repo.clone_url.clone() + } else { + format!("https://github.com/{GITHUB_ORG}/{repo_name}.git") + }; let repo_dir = tmpdir.join(repo_name); let git_ref = ref_overrides.get(repo_name.as_str()).map(String::as_str); - if let Err(e) = clone_repo(clone_url, &repo_dir, git_ref) { - eprintln!("WARNING: failed to clone {repo_name}: {e}"); - continue; - } + clone_repo(&clone_url, &repo_dir, git_ref) + .with_context(|| format!("failed to clone declared catalog source '{repo_name}'"))?; - let Some((version, spec_file, graphql_only)) = find_latest_spec_file(&repo_dir) else { - eprintln!("WARNING: {repo_name} has no versioned spec file — skipping"); - continue; - }; + let (version, spec_file, graphql_only) = find_latest_spec_file(&repo_dir) + .with_context(|| format!("catalog source '{repo_name}' has no versioned spec file"))?; - let domain = derive_domain(repo_name); - if domain.is_empty() { - eprintln!("WARNING: could not derive domain from '{repo_name}' — skipping"); - continue; - } + let domain = source.domain.clone(); if !used_domains.insert(domain.clone()) { eprintln!("WARNING: duplicate domain '{domain}' from '{repo_name}' — skipping"); continue; @@ -572,17 +554,14 @@ fn discover_spec_sources() -> Result<(Vec, PathBuf)> { Ok((sources, tmpdir)) } -fn local_spec_sources() -> Result> { +fn local_spec_sources(manifest: &CatalogSourceManifest) -> Result> { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let rust_dir = manifest_dir.join("../.."); let mut sources = Vec::new(); - for (domain, rel_path) in LOCAL_SPECS { - let spec_file = manifest_dir.join(rel_path); + for source in &manifest.local { + let spec_file = rust_dir.join(&source.path); if !spec_file.exists() { - eprintln!( - "WARNING: local spec {} not found — skipping", - spec_file.display() - ); - continue; + bail!("local catalog spec {} not found", spec_file.display()); } let src = std::fs::read_to_string(&spec_file) .with_context(|| format!("failed to read {}", spec_file.display()))?; @@ -598,8 +577,8 @@ fn local_spec_sources() -> Result> { format!("v{raw_version}") }; sources.push(SpecSource { - domain: (*domain).to_owned(), - repo_name: format!("local:{domain}"), + domain: source.domain.clone(), + repo_name: format!("local:{}", source.domain), spec_file, spec_version, graphql_only: false, @@ -608,19 +587,6 @@ fn local_spec_sources() -> Result> { Ok(sources) } -fn regex_is_match_commerce_spec(name: &str) -> bool { - // ^commerce\.[a-z0-9-]+-specification$ - if let Some(rest) = name.strip_prefix("commerce.") - && let Some(slug) = rest.strip_suffix("-specification") - { - return !slug.is_empty() - && slug - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); - } - false -} - // --------------------------------------------------------------------------- // YAML/JSON parsing // --------------------------------------------------------------------------- @@ -1721,9 +1687,10 @@ fn main() -> Result<()> { let output_dir = resolve_output_dir(); std::fs::create_dir_all(&output_dir).context("failed to create output dir")?; + let source_manifest = load_source_manifest()?; eprintln!("Discovering specification repositories..."); - let (mut sources, tmpdir) = discover_spec_sources()?; - sources.extend(local_spec_sources()?); + let (mut sources, tmpdir) = discover_spec_sources(&source_manifest)?; + sources.extend(local_spec_sources(&source_manifest)?); if sources.is_empty() { bail!("no specification repositories discovered — refusing to overwrite catalog output"); @@ -2102,14 +2069,51 @@ components: ); } + #[test] + fn source_manifest_defines_the_reconciled_catalog_domains() { + let manifest = load_source_manifest().expect("load source manifest"); + let expected = manifest.expected_domains(); + + assert_eq!(expected.len(), 21); + assert_eq!(manifest.remote.len(), 20); + assert_eq!( + manifest + .local + .iter() + .map(|source| source.domain.as_str()) + .collect::>(), + ["hosting-nodejs"] + ); + assert_eq!( + manifest.legacy_typescript.rust_only_domains, + ["hosting-nodejs"] + ); + } + + #[test] + fn git_auth_config_rewrites_ssh_submodules_without_exposing_the_token() { + let config = git_auth_config(Some("test-token")); + + assert_eq!( + config.get("url.https://github.com/.insteadOf"), + Some(&"git@github.com:".to_owned()) + ); + let header = config + .get("http.https://github.com/.extraHeader") + .expect("authorization header"); + assert!(header.starts_with("AUTHORIZATION: basic ")); + assert!(!header.contains("test-token")); + } + /// Drift guard: the committed catalog files and `manifest.json` must match the - /// intentional `EXPECTED_DOMAINS` contract, and every domain's endpoint count - /// must agree between its file and the manifest. Catches accidental drift - /// (a domain added/removed, a hand-edited catalog, a stale manifest) on every - /// `cargo test` run — no network or credentials required. + /// intentional source-manifest contract, and every domain's endpoint count must + /// agree between its file and the manifest. Catches accidental drift (a domain + /// added/removed, a hand-edited catalog, a stale manifest) on every `cargo test` + /// run — no network or credentials required. #[test] fn committed_catalog_matches_expected_domains_and_manifest() { let dir = resolve_output_dir(); + let source_manifest = load_source_manifest().expect("load source manifest"); // Domain files present on disk (excluding manifest.json). let mut files: Vec = std::fs::read_dir(&dir) @@ -2125,13 +2129,11 @@ components: .collect(); files.sort(); - let mut expected: Vec = EXPECTED_DOMAINS.iter().map(|s| (*s).to_owned()).collect(); - expected.sort(); + let expected = source_manifest.expected_domains(); assert_eq!( files, expected, - "catalog domain files drifted from EXPECTED_DOMAINS — if adding/removing a \ - domain, update EXPECTED_DOMAINS in the same change" + "catalog domain files drifted from api-catalog-sources.json" ); // manifest.json must list exactly the same domains... @@ -2147,11 +2149,11 @@ components: manifest_domains.sort(); assert_eq!( manifest_domains, expected, - "manifest.json domains differ from EXPECTED_DOMAINS / catalog files" + "manifest.json domains differ from api-catalog-sources.json / catalog files" ); // ...and each domain's endpoint count must match between file and manifest. - for domain in EXPECTED_DOMAINS { + for domain in &expected { let catalog: Value = serde_json::from_str( &std::fs::read_to_string(dir.join(format!("{domain}.json"))) .unwrap_or_else(|e| panic!("read {domain}.json: {e}")), @@ -2164,7 +2166,7 @@ components: .as_array() .unwrap_or_else(|| panic!("'{domain}.json' has no endpoints array")) .len(); - let manifest_count = domains[*domain]["endpointCount"] + let manifest_count = domains[domain]["endpointCount"] .as_u64() .unwrap_or_else(|| panic!("manifest.json missing endpointCount for '{domain}'")) as usize;