|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Catalog API contract drift checker. |
| 4 | + * |
| 5 | + * Fetches ``openapi.json`` from a pinned ``wdc-catalog-api`` GitHub |
| 6 | + * release (or a local file via --spec) and verifies the endpoints / |
| 7 | + * required fields the C# ``CatalogClient.cs`` depends on are still |
| 8 | + * present with the expected shape. Emits a non-zero exit when the |
| 9 | + * contract drifts so CI can block a breaking merge. |
| 10 | + * |
| 11 | + * Usage (CI): |
| 12 | + * CATALOG_API_VERSION=0.2.0 node scripts/check-catalog-drift.mjs |
| 13 | + * |
| 14 | + * Usage (local): |
| 15 | + * node scripts/check-catalog-drift.mjs --spec /path/to/openapi.json |
| 16 | + */ |
| 17 | + |
| 18 | +import fs from "node:fs"; |
| 19 | +import path from "node:path"; |
| 20 | +import { fileURLToPath } from "node:url"; |
| 21 | + |
| 22 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 23 | +const REPO_ROOT = path.resolve(__dirname, ".."); |
| 24 | + |
| 25 | +// Endpoints + DTOs the C# daemon consumes. Add to this list when |
| 26 | +// ``CatalogClient.cs`` grows a new dependency. |
| 27 | +const REQUIRED_PATHS = [ |
| 28 | + "/healthz", |
| 29 | + "/api/v1/catalog", |
| 30 | + "/api/v1/catalog/{app_name}", |
| 31 | + "/api/v1/sync/config", |
| 32 | + "/api/v1/sync/config/{device_id}", |
| 33 | +]; |
| 34 | + |
| 35 | +// Wire-format fields on ``CatalogDocument`` / related DTOs. Values are |
| 36 | +// snake_case because ``CatalogClient.cs`` serializes with |
| 37 | +// ``JsonNamingPolicy.SnakeCaseLower``. |
| 38 | +const REQUIRED_FIELDS_BY_SCHEMA = { |
| 39 | + TokenResponse: ["token", "email"], |
| 40 | + ConfigSyncEntry: ["device_id", "updated_at", "payload"], |
| 41 | + ConfigSyncUploadRequest: ["device_id", "payload"], |
| 42 | +}; |
| 43 | + |
| 44 | +function fail(msg) { |
| 45 | + console.error(`[drift-check] ${msg}`); |
| 46 | + process.exitCode = 1; |
| 47 | +} |
| 48 | + |
| 49 | +function ok(msg) { |
| 50 | + console.log(`[drift-check] ${msg}`); |
| 51 | +} |
| 52 | + |
| 53 | +async function loadSpec(args) { |
| 54 | + const explicit = args.indexOf("--spec"); |
| 55 | + if (explicit !== -1 && args[explicit + 1]) { |
| 56 | + const file = args[explicit + 1]; |
| 57 | + ok(`Loading spec from ${file}`); |
| 58 | + return JSON.parse(fs.readFileSync(file, "utf-8")); |
| 59 | + } |
| 60 | + const version = process.env.CATALOG_API_VERSION; |
| 61 | + if (!version) { |
| 62 | + fail("CATALOG_API_VERSION not set (and no --spec passed)."); |
| 63 | + fail("Either export CATALOG_API_VERSION=x.y.z or pass --spec path."); |
| 64 | + process.exit(2); |
| 65 | + } |
| 66 | + const tag = version.startsWith("v") ? version : `v${version}`; |
| 67 | + const url = |
| 68 | + `https://github.com/nks-hub/wdc-catalog-api/releases/download/` + |
| 69 | + `${tag}/openapi.json`; |
| 70 | + ok(`Fetching spec: ${url}`); |
| 71 | + const res = await fetch(url); |
| 72 | + if (!res.ok) { |
| 73 | + fail(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); |
| 74 | + process.exit(2); |
| 75 | + } |
| 76 | + return await res.json(); |
| 77 | +} |
| 78 | + |
| 79 | +function checkPaths(spec) { |
| 80 | + const present = spec.paths ?? {}; |
| 81 | + for (const p of REQUIRED_PATHS) { |
| 82 | + if (!(p in present)) { |
| 83 | + fail(`Missing required path: ${p}`); |
| 84 | + } else { |
| 85 | + ok(`Path present: ${p}`); |
| 86 | + } |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +function checkSchemas(spec) { |
| 91 | + const schemas = spec.components?.schemas ?? {}; |
| 92 | + for (const [name, fields] of Object.entries(REQUIRED_FIELDS_BY_SCHEMA)) { |
| 93 | + const schema = schemas[name]; |
| 94 | + if (!schema) { |
| 95 | + fail(`Missing schema: ${name}`); |
| 96 | + continue; |
| 97 | + } |
| 98 | + const props = schema.properties ?? {}; |
| 99 | + for (const field of fields) { |
| 100 | + if (!(field in props)) { |
| 101 | + fail(`Schema ${name} is missing field '${field}'`); |
| 102 | + } |
| 103 | + } |
| 104 | + ok(`Schema ${name} has required fields [${fields.join(", ")}]`); |
| 105 | + } |
| 106 | +} |
| 107 | + |
| 108 | +function checkCSharpStillCompiles() { |
| 109 | + // Light sanity check: the C# client file still exists and mentions |
| 110 | + // the critical DTO class names. Full type-level check needs dotnet |
| 111 | + // build, which the CI's downstream job already runs. |
| 112 | + const clientPath = path.join( |
| 113 | + REPO_ROOT, |
| 114 | + "src/daemon/NKS.WebDevConsole.Daemon/Binaries/CatalogClient.cs", |
| 115 | + ); |
| 116 | + if (!fs.existsSync(clientPath)) { |
| 117 | + fail(`Missing CatalogClient.cs at ${clientPath}`); |
| 118 | + return; |
| 119 | + } |
| 120 | + const source = fs.readFileSync(clientPath, "utf-8"); |
| 121 | + for (const token of ["CatalogDocument", "ReleaseDoc", "DownloadDoc"]) { |
| 122 | + if (!source.includes(token)) { |
| 123 | + fail(`CatalogClient.cs no longer references '${token}'`); |
| 124 | + } |
| 125 | + } |
| 126 | + ok("CatalogClient.cs references CatalogDocument/ReleaseDoc/DownloadDoc"); |
| 127 | +} |
| 128 | + |
| 129 | +const args = process.argv.slice(2); |
| 130 | +const spec = await loadSpec(args); |
| 131 | +checkPaths(spec); |
| 132 | +checkSchemas(spec); |
| 133 | +checkCSharpStillCompiles(); |
| 134 | + |
| 135 | +if (process.exitCode) { |
| 136 | + console.error("[drift-check] FAILED — catalog-api contract has drifted."); |
| 137 | + process.exit(1); |
| 138 | +} |
| 139 | +ok("All contract checks passed."); |
0 commit comments