Skip to content

Commit 9f96026

Browse files
ci: OpenAPI contract validation (#37 phase 1) (#44)
* ci: add OpenAPI contract validation (#37 phase 1) Add a zero-dependency drift check for the website-mirrored public OpenAPI spec (docs/openapi/qveris-public-api.openapi.json): asserts the file exists and is valid JSON, info.version is present, the core agent paths exist, and the response schemas the toolkit deserializes are present. Mirrors the ecosystem-validate workflow pattern (validator + regression test + path-scoped workflow). This is phase 1 only — type generation for MCP/Python SDK (phases 2-3) is intentionally deferred to separate PRs per the issue, so existing MCP, CLI, and Python SDK public APIs are unchanged. Closes #37 phase 1. * ci: address review on OpenAPI contract checks (#37) - Use fileURLToPath(import.meta.url) for REPO_ROOT in both scripts (portable on Windows and paths with spaces). - Guard that the parsed spec is a non-null object before accessing properties, so `null`/array JSON yields a clean validation error instead of a TypeError crash. Covered by a new regression test. * Update scripts/validate-openapi-contract.mjs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * ci: fix duplicated try/catch block in OpenAPI validator A suggestion applied via the GitHub UI (cd0fa09) re-inserted the JSON.parse catch block, leaving an orphaned `} catch (error) {` that crashed CI with `SyntaxError: Unexpected token 'catch'`. Remove the duplicate; validator + 7 regression tests pass. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent b82cecc commit 9f96026

3 files changed

Lines changed: 250 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Validate OpenAPI contract
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "docs/openapi/**"
7+
- "scripts/validate-openapi-contract.mjs"
8+
- "scripts/test-openapi-contract.mjs"
9+
- ".github/workflows/openapi-contract.yml"
10+
push:
11+
branches:
12+
- main
13+
paths:
14+
- "docs/openapi/**"
15+
- "scripts/validate-openapi-contract.mjs"
16+
- "scripts/test-openapi-contract.mjs"
17+
- ".github/workflows/openapi-contract.yml"
18+
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
validate:
24+
runs-on: ubuntu-latest
25+
26+
steps:
27+
- name: Checkout
28+
uses: actions/checkout@v4
29+
30+
- name: Setup Node.js
31+
uses: actions/setup-node@v4
32+
with:
33+
node-version: 20
34+
35+
- name: Validate OpenAPI contract
36+
run: node scripts/validate-openapi-contract.mjs
37+
38+
- name: Test contract validator
39+
run: node scripts/test-openapi-contract.mjs

scripts/test-openapi-contract.mjs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/usr/bin/env node
2+
3+
// Regression tests for scripts/validate-openapi-contract.mjs.
4+
// Runs the validator as a subprocess against the real spec and against
5+
// deliberately broken temp copies.
6+
7+
import assert from "node:assert/strict";
8+
import { spawnSync } from "node:child_process";
9+
import fs from "node:fs";
10+
import os from "node:os";
11+
import path from "node:path";
12+
import process from "node:process";
13+
import { fileURLToPath } from "node:url";
14+
15+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
16+
const VALIDATOR = path.join(REPO_ROOT, "scripts/validate-openapi-contract.mjs");
17+
const REAL_SPEC = path.join(REPO_ROOT, "docs/openapi/qveris-public-api.openapi.json");
18+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qveris-openapi-contract-"));
19+
20+
function run(specPath) {
21+
return spawnSync(process.execPath, [VALIDATOR, specPath], { encoding: "utf8" });
22+
}
23+
24+
function writeSpec(name, mutate) {
25+
const spec = JSON.parse(fs.readFileSync(REAL_SPEC, "utf8"));
26+
mutate(spec);
27+
const target = path.join(tmpRoot, name);
28+
fs.writeFileSync(target, JSON.stringify(spec));
29+
return target;
30+
}
31+
32+
const tests = [
33+
["accepts the real checked-in spec", () => {
34+
const result = run(REAL_SPEC);
35+
assert.equal(result.status, 0, result.stderr);
36+
assert.match(result.stdout, /OpenAPI contract OK/);
37+
}],
38+
["rejects a missing file", () => {
39+
const result = run(path.join(tmpRoot, "does-not-exist.json"));
40+
assert.equal(result.status, 1);
41+
assert.match(result.stderr, /not found/);
42+
}],
43+
["rejects invalid JSON", () => {
44+
const target = path.join(tmpRoot, "broken.json");
45+
fs.writeFileSync(target, "{ not json");
46+
const result = run(target);
47+
assert.equal(result.status, 1);
48+
assert.match(result.stderr, /not valid JSON/);
49+
}],
50+
["rejects valid JSON that is not an object", () => {
51+
const target = path.join(tmpRoot, "null.json");
52+
fs.writeFileSync(target, "null");
53+
const result = run(target);
54+
assert.equal(result.status, 1);
55+
assert.match(result.stderr, /not a valid OpenAPI object/);
56+
}],
57+
["rejects missing info.version", () => {
58+
const target = writeSpec("no-version.json", (spec) => {
59+
delete spec.info.version;
60+
});
61+
const result = run(target);
62+
assert.equal(result.status, 1);
63+
assert.match(result.stderr, /info\.version/);
64+
}],
65+
["rejects a missing core path", () => {
66+
const target = writeSpec("no-search.json", (spec) => {
67+
delete spec.paths["/search"];
68+
});
69+
const result = run(target);
70+
assert.equal(result.status, 1);
71+
assert.match(result.stderr, /missing required path: \/search/);
72+
}],
73+
["rejects a missing component schema", () => {
74+
const target = writeSpec("no-schema.json", (spec) => {
75+
delete spec.components.schemas.PublicSearchResponse;
76+
});
77+
const result = run(target);
78+
assert.equal(result.status, 1);
79+
assert.match(result.stderr, /missing required component schema: PublicSearchResponse/);
80+
}],
81+
];
82+
83+
try {
84+
for (const [name, testFn] of tests) {
85+
testFn();
86+
console.log(`ok ${name}`);
87+
}
88+
console.log(`\n${tests.length} OpenAPI contract regression test(s) passed.`);
89+
} finally {
90+
fs.rmSync(tmpRoot, { recursive: true, force: true });
91+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env node
2+
3+
// Issue #37 Phase 1: validate the mirrored public OpenAPI contract.
4+
//
5+
// The website (qveris-website) owns the public REST contract and mirrors
6+
// docs/openapi/qveris-public-api.openapi.json into this repo. This script is
7+
// a zero-dependency drift check: it fails CI when the checked-in contract is
8+
// missing the version, the core paths, or the response schemas the toolkit
9+
// (CLI / MCP / Python SDK) depends on. It does NOT generate types — that is
10+
// Phase 2 of the issue and ships as a separate PR.
11+
12+
import fs from "node:fs";
13+
import path from "node:path";
14+
import process from "node:process";
15+
import { fileURLToPath } from "node:url";
16+
17+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
18+
const DEFAULT_SPEC = path.join("docs", "openapi", "qveris-public-api.openapi.json");
19+
20+
// Core agent-path endpoints the toolkit calls. Listed in the issue.
21+
const REQUIRED_PATHS = [
22+
"/search",
23+
"/tools/by-ids",
24+
"/tools/execute",
25+
"/auth/usage/history/v2",
26+
"/auth/credits/ledger",
27+
];
28+
29+
// Response/component schemas the toolkit clients deserialize. Keeping this
30+
// list intentionally focused on what the toolkit consumes so the check
31+
// catches contract drift without being brittle to unrelated backend schemas.
32+
const REQUIRED_SCHEMAS = [
33+
"PublicSearchResponse",
34+
"PublicCapabilityResult",
35+
"PublicToolParameter",
36+
"PublicToolStats",
37+
"PublicBillingRule",
38+
"PublicExecuteToolResponse",
39+
"PublicCompactBillingStatement",
40+
"APIResponse_UsageEventsResponse_",
41+
"UsageEventsResponse",
42+
"UsageEventItem",
43+
"APIResponse_CreditsLedgerResponse_",
44+
"CreditsLedgerResponse",
45+
"CreditsLedgerItem",
46+
];
47+
48+
function fail(errors) {
49+
console.error("OpenAPI contract validation FAILED:");
50+
for (const error of errors) console.error(` - ${error}`);
51+
console.error(
52+
"\nThe public OpenAPI contract is mirrored from qveris-website. " +
53+
"If this drift is intentional, re-mirror the spec; otherwise the backend contract changed."
54+
);
55+
process.exit(1);
56+
}
57+
58+
function main() {
59+
const specArg = process.argv[2];
60+
const specPath = path.resolve(REPO_ROOT, specArg || DEFAULT_SPEC);
61+
const rel = path.relative(REPO_ROOT, specPath);
62+
const errors = [];
63+
64+
if (!fs.existsSync(specPath)) {
65+
fail([`OpenAPI file not found: ${rel}`]);
66+
return;
67+
}
68+
69+
let spec;
70+
try {
71+
spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
72+
} catch (error) {
73+
fail([`${rel} is not valid JSON: ${error.message}`]);
74+
return;
75+
}
76+
77+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
78+
fail([`${rel} is not a valid OpenAPI object`]);
79+
return;
80+
}
81+
82+
if (typeof spec.openapi !== "string" || !spec.openapi) {
83+
errors.push("missing top-level `openapi` version string");
84+
}
85+
86+
const infoVersion = spec.info && spec.info.version;
87+
if (typeof infoVersion !== "string" || !infoVersion.trim()) {
88+
errors.push("missing `info.version`");
89+
}
90+
91+
const paths = spec.paths && typeof spec.paths === "object" ? spec.paths : {};
92+
for (const required of REQUIRED_PATHS) {
93+
if (!Object.prototype.hasOwnProperty.call(paths, required)) {
94+
errors.push(`missing required path: ${required}`);
95+
}
96+
}
97+
98+
const schemas =
99+
spec.components && spec.components.schemas && typeof spec.components.schemas === "object"
100+
? spec.components.schemas
101+
: {};
102+
for (const required of REQUIRED_SCHEMAS) {
103+
if (!Object.prototype.hasOwnProperty.call(schemas, required)) {
104+
errors.push(`missing required component schema: ${required}`);
105+
}
106+
}
107+
108+
if (errors.length > 0) {
109+
fail(errors);
110+
return;
111+
}
112+
113+
console.log(`OpenAPI contract OK: ${rel}`);
114+
console.log(` openapi: ${spec.openapi}`);
115+
console.log(` info.version: ${infoVersion}`);
116+
console.log(` paths checked: ${REQUIRED_PATHS.length}`);
117+
console.log(` schemas checked: ${REQUIRED_SCHEMAS.length}`);
118+
}
119+
120+
main();

0 commit comments

Comments
 (0)