diff --git a/.github/workflows/openapi-contract.yml b/.github/workflows/openapi-contract.yml new file mode 100644 index 0000000..d6e2eca --- /dev/null +++ b/.github/workflows/openapi-contract.yml @@ -0,0 +1,39 @@ +name: Validate OpenAPI contract + +on: + pull_request: + paths: + - "docs/openapi/**" + - "scripts/validate-openapi-contract.mjs" + - "scripts/test-openapi-contract.mjs" + - ".github/workflows/openapi-contract.yml" + push: + branches: + - main + paths: + - "docs/openapi/**" + - "scripts/validate-openapi-contract.mjs" + - "scripts/test-openapi-contract.mjs" + - ".github/workflows/openapi-contract.yml" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Validate OpenAPI contract + run: node scripts/validate-openapi-contract.mjs + + - name: Test contract validator + run: node scripts/test-openapi-contract.mjs diff --git a/scripts/test-openapi-contract.mjs b/scripts/test-openapi-contract.mjs new file mode 100644 index 0000000..6297e73 --- /dev/null +++ b/scripts/test-openapi-contract.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +// Regression tests for scripts/validate-openapi-contract.mjs. +// Runs the validator as a subprocess against the real spec and against +// deliberately broken temp copies. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const VALIDATOR = path.join(REPO_ROOT, "scripts/validate-openapi-contract.mjs"); +const REAL_SPEC = path.join(REPO_ROOT, "docs/openapi/qveris-public-api.openapi.json"); +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qveris-openapi-contract-")); + +function run(specPath) { + return spawnSync(process.execPath, [VALIDATOR, specPath], { encoding: "utf8" }); +} + +function writeSpec(name, mutate) { + const spec = JSON.parse(fs.readFileSync(REAL_SPEC, "utf8")); + mutate(spec); + const target = path.join(tmpRoot, name); + fs.writeFileSync(target, JSON.stringify(spec)); + return target; +} + +const tests = [ + ["accepts the real checked-in spec", () => { + const result = run(REAL_SPEC); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /OpenAPI contract OK/); + }], + ["rejects a missing file", () => { + const result = run(path.join(tmpRoot, "does-not-exist.json")); + assert.equal(result.status, 1); + assert.match(result.stderr, /not found/); + }], + ["rejects invalid JSON", () => { + const target = path.join(tmpRoot, "broken.json"); + fs.writeFileSync(target, "{ not json"); + const result = run(target); + assert.equal(result.status, 1); + assert.match(result.stderr, /not valid JSON/); + }], + ["rejects valid JSON that is not an object", () => { + const target = path.join(tmpRoot, "null.json"); + fs.writeFileSync(target, "null"); + const result = run(target); + assert.equal(result.status, 1); + assert.match(result.stderr, /not a valid OpenAPI object/); + }], + ["rejects missing info.version", () => { + const target = writeSpec("no-version.json", (spec) => { + delete spec.info.version; + }); + const result = run(target); + assert.equal(result.status, 1); + assert.match(result.stderr, /info\.version/); + }], + ["rejects a missing core path", () => { + const target = writeSpec("no-search.json", (spec) => { + delete spec.paths["/search"]; + }); + const result = run(target); + assert.equal(result.status, 1); + assert.match(result.stderr, /missing required path: \/search/); + }], + ["rejects a missing component schema", () => { + const target = writeSpec("no-schema.json", (spec) => { + delete spec.components.schemas.PublicSearchResponse; + }); + const result = run(target); + assert.equal(result.status, 1); + assert.match(result.stderr, /missing required component schema: PublicSearchResponse/); + }], +]; + +try { + for (const [name, testFn] of tests) { + testFn(); + console.log(`ok ${name}`); + } + console.log(`\n${tests.length} OpenAPI contract regression test(s) passed.`); +} finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); +} diff --git a/scripts/validate-openapi-contract.mjs b/scripts/validate-openapi-contract.mjs new file mode 100644 index 0000000..36dcde8 --- /dev/null +++ b/scripts/validate-openapi-contract.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node + +// Issue #37 Phase 1: validate the mirrored public OpenAPI contract. +// +// The website (qveris-website) owns the public REST contract and mirrors +// docs/openapi/qveris-public-api.openapi.json into this repo. This script is +// a zero-dependency drift check: it fails CI when the checked-in contract is +// missing the version, the core paths, or the response schemas the toolkit +// (CLI / MCP / Python SDK) depends on. It does NOT generate types — that is +// Phase 2 of the issue and ships as a separate PR. + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const DEFAULT_SPEC = path.join("docs", "openapi", "qveris-public-api.openapi.json"); + +// Core agent-path endpoints the toolkit calls. Listed in the issue. +const REQUIRED_PATHS = [ + "/search", + "/tools/by-ids", + "/tools/execute", + "/auth/usage/history/v2", + "/auth/credits/ledger", +]; + +// Response/component schemas the toolkit clients deserialize. Keeping this +// list intentionally focused on what the toolkit consumes so the check +// catches contract drift without being brittle to unrelated backend schemas. +const REQUIRED_SCHEMAS = [ + "PublicSearchResponse", + "PublicCapabilityResult", + "PublicToolParameter", + "PublicToolStats", + "PublicBillingRule", + "PublicExecuteToolResponse", + "PublicCompactBillingStatement", + "APIResponse_UsageEventsResponse_", + "UsageEventsResponse", + "UsageEventItem", + "APIResponse_CreditsLedgerResponse_", + "CreditsLedgerResponse", + "CreditsLedgerItem", +]; + +function fail(errors) { + console.error("OpenAPI contract validation FAILED:"); + for (const error of errors) console.error(` - ${error}`); + console.error( + "\nThe public OpenAPI contract is mirrored from qveris-website. " + + "If this drift is intentional, re-mirror the spec; otherwise the backend contract changed." + ); + process.exit(1); +} + +function main() { + const specArg = process.argv[2]; + const specPath = path.resolve(REPO_ROOT, specArg || DEFAULT_SPEC); + const rel = path.relative(REPO_ROOT, specPath); + const errors = []; + + if (!fs.existsSync(specPath)) { + fail([`OpenAPI file not found: ${rel}`]); + return; + } + + let spec; + try { + spec = JSON.parse(fs.readFileSync(specPath, "utf8")); + } catch (error) { + fail([`${rel} is not valid JSON: ${error.message}`]); + return; + } + + if (!spec || typeof spec !== "object" || Array.isArray(spec)) { + fail([`${rel} is not a valid OpenAPI object`]); + return; + } + + if (typeof spec.openapi !== "string" || !spec.openapi) { + errors.push("missing top-level `openapi` version string"); + } + + const infoVersion = spec.info && spec.info.version; + if (typeof infoVersion !== "string" || !infoVersion.trim()) { + errors.push("missing `info.version`"); + } + + const paths = spec.paths && typeof spec.paths === "object" ? spec.paths : {}; + for (const required of REQUIRED_PATHS) { + if (!Object.prototype.hasOwnProperty.call(paths, required)) { + errors.push(`missing required path: ${required}`); + } + } + + const schemas = + spec.components && spec.components.schemas && typeof spec.components.schemas === "object" + ? spec.components.schemas + : {}; + for (const required of REQUIRED_SCHEMAS) { + if (!Object.prototype.hasOwnProperty.call(schemas, required)) { + errors.push(`missing required component schema: ${required}`); + } + } + + if (errors.length > 0) { + fail(errors); + return; + } + + console.log(`OpenAPI contract OK: ${rel}`); + console.log(` openapi: ${spec.openapi}`); + console.log(` info.version: ${infoVersion}`); + console.log(` paths checked: ${REQUIRED_PATHS.length}`); + console.log(` schemas checked: ${REQUIRED_SCHEMAS.length}`); +} + +main();