openapi-versions 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -2
  3. package/dist/chunk-4O4PFYPM.js +288 -0
  4. package/dist/chunk-4O4PFYPM.js.map +1 -0
  5. package/dist/chunk-QZ47EPTW.js +96 -0
  6. package/dist/chunk-QZ47EPTW.js.map +1 -0
  7. package/dist/cli.js +20 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/codegen.d.ts +104 -0
  10. package/dist/codegen.js +29 -0
  11. package/dist/codegen.js.map +1 -0
  12. package/dist/core.d.ts +146 -0
  13. package/dist/core.js +19 -0
  14. package/dist/core.js.map +1 -0
  15. package/dist/index.d.ts +39 -0
  16. package/dist/index.js +120 -0
  17. package/dist/index.js.map +1 -0
  18. package/docs/README.md +22 -0
  19. package/docs/adr/0001-separate-package-not-upstream.md +16 -0
  20. package/docs/adr/0002-extensible-enums-via-x-extensible-enum.md +16 -0
  21. package/docs/adr/0003-gating-model.md +16 -0
  22. package/docs/adr/0004-oasdiff-breaking-gate.md +16 -0
  23. package/docs/adr/0005-patch-openapi-typescript-helpers.md +16 -0
  24. package/docs/adr/0006-tooling-mise-pnpm-biome.md +16 -0
  25. package/docs/adr/0007-mit-license.md +16 -0
  26. package/docs/adr/0008-prereleases-via-snapshots.md +16 -0
  27. package/docs/adr/README.md +14 -0
  28. package/docs/adr/template.md +16 -0
  29. package/docs/concepts.md +67 -0
  30. package/docs/ecosystem.md +39 -0
  31. package/docs/getting-started.md +99 -0
  32. package/docs/how-to.md +155 -0
  33. package/docs/reference.md +129 -0
  34. package/docs/troubleshooting.md +21 -0
  35. package/docs/upstream-drafts.md +166 -0
  36. package/openapi-versions.schema.json +57 -0
  37. package/package.json +111 -3
  38. package/patches/openapi-typescript-helpers@0.1.0.patch +51 -0
  39. package/skills/openapi-versions-add-spec/SKILL.md +37 -0
  40. package/skills/openapi-versions-gate-feature/SKILL.md +34 -0
  41. package/skills/openapi-versions-setup/SKILL.md +35 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicolas Zilli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,121 @@
1
- # Temporary Holding Version
1
+ # Version-aware API client (prototype)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One frontend build that works against **a range of minor versions of one API major**: features the backend
4
+ lacks are hidden like feature flags, unknown additions from newer backends are tolerated
5
+ (Tolerant Reader), and breaking changes are only allowed across majors.
6
+
7
+ Built on [openapi-typescript](https://openapi-ts.dev/) + openapi-fetch + openapi-react-query (TanStack Query),
8
+ as a wrapper, not a fork.
9
+
10
+ **Docs:** [docs/](docs/README.md) (concepts, getting started, how-tos, reference, troubleshooting) · **LLM agents:** [AGENTS.md](AGENTS.md) and [skills/](skills) · **Package:** `openapi-versions` (`pnpm build` → `dist/`: runtime, codegen, CLI) · **Playground:** `pnpm playground` (app) · `pnpm playground:api` (CLI demo)
11
+
12
+ ## Quick start
13
+
14
+ ```bash
15
+ mise install && pnpm install
16
+ # oasdiff (>= 1.34) on PATH or OASDIFF_BIN runs the breaking-change gate; without it codegen warns and skips it
17
+ # (use --require-gate or "gate": "required" in CI): https://github.com/oasdiff/oasdiff/releases
18
+ pnpm codegen # check + merge + generate for playground/ and test/fixtures/ (output is gitignored)
19
+ pnpm typecheck # includes compile-time guarantees in test/types.check.ts
20
+ pnpm test # 41 tests: merge, since maps, oasdiff gate, compat matrix, React, loaders, major transition
21
+ pnpm playground:api # one client build vs backends 1.2.0 … 2.0.0
22
+ pnpm exec tsx codegen/cli.ts --check-only --config test/fixtures/broken.config.json # shows the CI gate failing
23
+ ```
24
+
25
+ ## How it works
26
+
27
+ ```
28
+ playground/specs/1.3.0.json … 1.6.0.json playground/openapi-versions.config.json (major + floor)
29
+ │
30
+ ▼ codegen/build.ts (per major)
31
+ 1. keep specs >= floor
32
+ 2. oasdiff breaking on each consecutive pair → build fails on any break
33
+ 3. tolerant merge: paths/props ∪, required ∩, enums ∪ (codegen/merge.ts)
34
+ 4. since maps: ops + fields added after the floor (codegen/since.ts)
35
+ 5. openapi-typescript on the merged doc, open extensible enums (codegen/open-enums.ts)
36
+ │
37
+ ▼
38
+ playground/generated/vN/{schema.d.ts, meta.ts, merged.openapi.json}, playground/generated/index.ts
39
+ ```
40
+
41
+ Runtime:
42
+
43
+ | File | Role |
44
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
45
+ | `src/index.ts`, `src/core/index.ts` | Generic runtime entry points (`createVersionAware(meta)`); apps use the generated `v<n>/client.ts`, which binds them to the app's types |
46
+ | `src/core/compat.ts` | `resolveCompat(version, majors)` → ok / backend-too-old / app-too-old / invalid-version, `supports(op)`, `hasField(field)` |
47
+ | `src/core/client.ts` | One openapi-fetch client exposed as `core` (gated ops removed from types) and `gated` (full), `X-Api-Version` middleware |
48
+ | `src/core/types.ts` | `StripOps`: removes gated operations at the type level |
49
+ | `src/core/api.ts` | `Api<P, G>` = compat + `$api` / `$gated` (openapi-react-query) + `gate()`; `createApi()` builds it outside React |
50
+ | `src/react/ApiVersionProvider.tsx` | `/version` via React Query, blocked screens, `useApi`, `useSupports`, `<Feature>`, mid-session re-evaluation |
51
+ | `patches/` | One-line fix to `openapi-typescript-helpers` so open enums survive openapi-fetch's `Readable<T>` (applied by pnpm `patchedDependencies`; tracked upstream in openapi-ts/openapi-typescript#2766) |
52
+ | `playground/api/mock-backend.ts` | Fetch-compatible fake backend at any version, including future ones |
53
+ | `test/fixtures/` | Spec copies for the tests, a breaking 1.7.0 (CI gate demo) and a 2.0.0 (next major); generated client is gitignored |
54
+ | `playground/` | `api/` (fake backend + CLI demo), `app/` (Vite app), `specs/` + config for its own generated client |
55
+
56
+ ## Usage
57
+
58
+ ```tsx
59
+ <QueryClientProvider client={qc}>
60
+ <ApiVersionProvider baseUrl={API_URL}>
61
+ <App />
62
+ </ApiVersionProvider>
63
+ </QueryClientProvider>;
64
+
65
+ const { $api, $gated, gate } = useApi();
66
+
67
+ // operations every supported backend has: plain openapi-react-query
68
+ const user = $api.useQuery("get", "/users/{id}", { params: { path: { id } } });
69
+
70
+ // operations added after the floor: gate() turns the query into skipToken when unsupported
71
+ const activity = useQuery(gate($gated.queryOptions("get", "/users/{id}/activity", { params: { path: { id } } })));
72
+
73
+ // gated mutations: render the trigger inside <Feature>
74
+ const archive = $gated.useMutation("post", "/users/{id}/archive");
75
+ <Feature requires="post /users/{id}/archive">
76
+ <button onClick={() => archive.mutate({ params: { path: { id } } })}>Archive</button>
77
+ </Feature>;
78
+
79
+ // router loader / prefetch: same options, no hook
80
+ const api = await createApi({ baseUrl: API_URL });
81
+ await queryClient.ensureQueryData(api.$api.queryOptions("get", "/users/{id}", { params: { path: { id } } }));
82
+ ```
83
+
84
+ Compile-time rules (see `test/types.check.ts`): a gated operation is not on `$api`, so it can only be
85
+ reached through `$gated`; a gated query cannot be used with `useSuspenseQuery`.
86
+
87
+ ## One major per app release, majors side by side on the backend
88
+
89
+ The app supports one API major and points its `baseUrl` at that major's prefix:
90
+
91
+ ```
92
+ https://host/api/v1 ← every 1.x app build
93
+ https://host/api/v2 ← every 2.x app build
94
+ ```
95
+
96
+ Each major is served under its own prefix with its own `GET {prefix}/version` and `X-Api-Version`
97
+ header, so the client never needs to know other majors exist. During a transition the backend serves
98
+ both prefixes; v1 is frozen (patches only) and its responses carry a `Sunset` header (RFC 8594).
99
+
100
+ | Situation | What the 1.x app does |
101
+ | ------------------------------------------------------------------- | --------------------------------------------------------------------- |
102
+ | `/api/v1` live, no Sunset | Works normally |
103
+ | `/api/v1` live with `Sunset` | Works; `useSunset()` returns the date for an "update before …" banner |
104
+ | `/api/v1` answers a 2.x version, or the app is pointed at `/api/v2` | Blocked: "update the app" |
105
+
106
+ Moving the app to 2.x is a new release: set the major in the config, import from `…/v2/client` instead of `…/v1/client`,
107
+ point `baseUrl` at `/api/v2`, run codegen, fix what the compiler reports. `test/majors.test.tsx`
108
+ runs both majors side by side (`sideBySide()` in `playground/api/mock-backend.ts`).
109
+
110
+ ## Contract rules the backend must follow
111
+
112
+ - Within a major: additive changes only (oasdiff gate).
113
+ - Enums that may grow: `x-extensible-enum` (generated as open unions). Plain `enum` is closed; adding a value is breaking.
114
+ - Each major under its own prefix (`/api/v1`, `/api/v2`), each with `GET {prefix}/version` → `{ "version": "<semver>" }` and `X-Api-Version` on every response. Real semver only: `2026.10.1` parses as major 2026.
115
+ - A major being retired sends `Sunset: <HTTP-date>` on its responses. Optional: `GET /api/versions` listing live majors and their sunset dates.
116
+
117
+ ## Release cadence
118
+
119
+ - New API release: add `playground/specs/<version>.json` (take it from the backend CI artifact of the release commit), `pnpm codegen`.
120
+ The upcoming unreleased spec can be added before the backend ships: its features stay hidden until the backend reports that version.
121
+ - Raise a floor in `openapi-versions.config.json` when a version leaves the support window; gated ops become plain ops, and the compiler shows which checks are now dead.
@@ -0,0 +1,288 @@
1
+ // codegen/breaking.ts
2
+ import { spawnSync } from "child_process";
3
+ import semver from "semver";
4
+ var MIN_OASDIFF_VERSION = "1.34.0";
5
+ var OASDIFF_INSTALL_HINT = "Install it: `brew install oasdiff`, `go install github.com/oasdiff/oasdiff@latest`, or a release binary from https://github.com/oasdiff/oasdiff/releases (set OASDIFF_BIN if it is not on PATH).";
6
+ function probeOasdiff() {
7
+ const bin = process.env.OASDIFF_BIN ?? "oasdiff";
8
+ const r = spawnSync(bin, ["--version"], { encoding: "utf8" });
9
+ if (r.error) return { ok: false, bin, reason: `oasdiff not found (${bin}). ${OASDIFF_INSTALL_HINT}` };
10
+ const version = semver.coerce(r.stdout)?.version;
11
+ if (!version) return { ok: false, bin, reason: `could not read the version of ${bin} (got ${JSON.stringify(r.stdout.trim())})` };
12
+ if (semver.lt(version, MIN_OASDIFF_VERSION))
13
+ return { ok: false, bin, reason: `oasdiff ${version} is older than the minimum ${MIN_OASDIFF_VERSION}. ${OASDIFF_INSTALL_HINT}` };
14
+ return { ok: true, bin, version };
15
+ }
16
+ function checkPair(fromFile, toFile) {
17
+ const bin = process.env.OASDIFF_BIN ?? "oasdiff";
18
+ const r = spawnSync(bin, ["breaking", fromFile, toFile, "--fail-on", "ERR"], { encoding: "utf8" });
19
+ if (r.error) throw new Error(`Could not run oasdiff (${bin}): ${r.error.message}`);
20
+ return { ok: r.status === 0, report: (r.stdout + r.stderr).trim() };
21
+ }
22
+ function checkChain(files) {
23
+ const results = [];
24
+ for (let i = 1; i < files.length; i++) {
25
+ results.push({ from: files[i - 1].version, to: files[i].version, ...checkPair(files[i - 1].file, files[i].file) });
26
+ }
27
+ return results;
28
+ }
29
+
30
+ // codegen/config.ts
31
+ import fs from "fs";
32
+ import semver2 from "semver";
33
+ var GATE_MODES = ["auto", "required", "off"];
34
+ var DEFAULT_RUNTIME_IMPORT = "openapi-versions";
35
+ function validateConfig(raw, source = "config") {
36
+ const fail = (msg) => {
37
+ throw new Error(`${source}: ${msg}`);
38
+ };
39
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) fail("must be a JSON object");
40
+ const c = raw;
41
+ for (const key of Object.keys(c))
42
+ if (!["$schema", "specsDir", "outDir", "majors", "runtimeImport", "gate"].includes(key)) fail(`unknown key "${key}"`);
43
+ for (const key of ["specsDir", "outDir"]) {
44
+ if (typeof c[key] !== "string" || !c[key]) fail(`"${key}" must be a non-empty string`);
45
+ }
46
+ if (c.runtimeImport !== void 0 && (typeof c.runtimeImport !== "string" || !c.runtimeImport))
47
+ fail(`"runtimeImport" must be a non-empty string`);
48
+ if (c.gate !== void 0 && !GATE_MODES.includes(c.gate))
49
+ fail(`"gate" must be one of ${GATE_MODES.map((m) => `"${m}"`).join(", ")}`);
50
+ const majors = c.majors;
51
+ if (!majors || typeof majors !== "object" || Array.isArray(majors) || !Object.keys(majors).length)
52
+ fail(`"majors" must list at least one major, e.g. { "1": { "floor": "1.0.0" } }`);
53
+ for (const [major, entry] of Object.entries(majors)) {
54
+ if (!/^(0|[1-9]\d*)$/.test(major)) fail(`major "${major}" must be a number`);
55
+ const floor = entry?.floor;
56
+ if (typeof floor !== "string" || semver2.valid(floor) !== floor)
57
+ fail(`majors.${major}.floor must be a plain semver like "${major}.0.0" (got ${JSON.stringify(floor)})`);
58
+ if (semver2.major(floor) !== Number(major)) fail(`majors.${major}.floor ${floor} is not in major ${major}`);
59
+ }
60
+ return {
61
+ specsDir: c.specsDir,
62
+ outDir: c.outDir,
63
+ majors,
64
+ runtimeImport: c.runtimeImport,
65
+ gate: c.gate
66
+ };
67
+ }
68
+ function loadConfig(path2) {
69
+ let raw;
70
+ try {
71
+ raw = JSON.parse(fs.readFileSync(path2, "utf8"));
72
+ } catch (e) {
73
+ throw new Error(`Cannot read ${path2}: ${e.message}`);
74
+ }
75
+ return validateConfig(raw, path2);
76
+ }
77
+
78
+ // codegen/merge.ts
79
+ var isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x);
80
+ var isParamList = (a) => a.length > 0 && a.every((p) => isObj(p) && typeof p.name === "string" && typeof p.in === "string");
81
+ function mergeTolerant(older, newer) {
82
+ if (older === void 0) return newer;
83
+ if (newer === void 0) return older;
84
+ if (Array.isArray(older) && Array.isArray(newer)) {
85
+ if (isParamList(older) || isParamList(newer)) return mergeParams(older, newer);
86
+ if ([...older, ...newer].every((v) => !isObj(v) && !Array.isArray(v))) return [.../* @__PURE__ */ new Set([...older, ...newer])];
87
+ return newer;
88
+ }
89
+ if (isObj(older) && isObj(newer)) {
90
+ const out = {};
91
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(older), ...Object.keys(newer)])) {
92
+ if (key === "required") {
93
+ out.required = mergeRequired(older, newer);
94
+ continue;
95
+ }
96
+ out[key] = mergeTolerant(older[key], newer[key]);
97
+ }
98
+ if (out.required === void 0) delete out.required;
99
+ return out;
100
+ }
101
+ return newer;
102
+ }
103
+ function mergeRequired(older, newer) {
104
+ const a = older.required;
105
+ const b = newer.required;
106
+ if (typeof a === "boolean" || typeof b === "boolean") return a === true && b === true;
107
+ const aa = Array.isArray(a) ? a : [];
108
+ const bb = Array.isArray(b) ? b : [];
109
+ const both = aa.filter((r) => bb.includes(r));
110
+ return both.length || Array.isArray(a) || Array.isArray(b) ? both : void 0;
111
+ }
112
+ function mergeParams(older, newer) {
113
+ const key = (p) => `${p.in}:${p.name}`;
114
+ const olderByKey = new Map(older.map((p) => [key(p), p]));
115
+ const out = [];
116
+ for (const p of newer) {
117
+ const o = olderByKey.get(key(p));
118
+ out.push(o ? mergeTolerant(o, p) : { ...p, required: p.in === "path" });
119
+ olderByKey.delete(key(p));
120
+ }
121
+ for (const p of olderByKey.values()) out.push({ ...p, required: p.in === "path" });
122
+ return out;
123
+ }
124
+ var mergeAll = (docs) => docs.reduce((acc, d) => mergeTolerant(acc, d));
125
+
126
+ // codegen/open-enums.ts
127
+ import ts from "typescript";
128
+ var f = ts.factory;
129
+ var openEnums = (schema) => {
130
+ const s = schema;
131
+ const isString = s.type === "string" || Array.isArray(s.type) && s.type.includes("string");
132
+ const values = s["x-extensible-enum"];
133
+ if (!isString || !Array.isArray(values) || !values.every((v) => typeof v === "string")) return void 0;
134
+ const literals = values.map((v) => f.createLiteralTypeNode(f.createStringLiteral(v)));
135
+ const openString = f.createIntersectionTypeNode([f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), f.createTypeLiteralNode([])]);
136
+ const members = [...literals, openString];
137
+ if (Array.isArray(s.type) && s.type.includes("null")) members.push(f.createLiteralTypeNode(f.createNull()));
138
+ return f.createUnionTypeNode(members);
139
+ };
140
+
141
+ // codegen/since.ts
142
+ import semver3 from "semver";
143
+ var METHODS = ["get", "put", "post", "delete", "patch", "head", "options", "trace"];
144
+ function computeSince(group, floor) {
145
+ const sorted = [...group].sort((a, b) => semver3.compare(a.version, b.version));
146
+ const ops = {};
147
+ const fields = {};
148
+ const schemas = {};
149
+ const seen = /* @__PURE__ */ new Set();
150
+ for (const { version, doc } of sorted) {
151
+ for (const [path2, item] of Object.entries(doc.paths ?? {})) {
152
+ for (const m of METHODS) if (item?.[m]) ops[`${m} ${path2}`] ??= version;
153
+ }
154
+ for (const [name, schema] of Object.entries(doc.components?.schemas ?? {})) {
155
+ schemas[name] ??= version;
156
+ for (const prop of Object.keys(schema?.properties ?? {})) {
157
+ const key = `${name}.${prop}`;
158
+ if (seen.has(key)) continue;
159
+ seen.add(key);
160
+ if (semver3.gt(version, schemas[name])) fields[key] = version;
161
+ }
162
+ }
163
+ }
164
+ const gated = (m) => Object.fromEntries(
165
+ Object.entries(m).filter(([, v]) => semver3.gt(v, floor)).sort(([a], [b]) => a.localeCompare(b))
166
+ );
167
+ return { ops: Object.keys(ops).sort(), gatedOps: gated(ops), gatedFields: gated(fields) };
168
+ }
169
+
170
+ // codegen/build.ts
171
+ import fs2 from "fs";
172
+ import path from "path";
173
+ import openapiTS, { astToString } from "openapi-typescript";
174
+ import semver4 from "semver";
175
+ async function build(config, { checkOnly = false, log = console.log, gate } = {}) {
176
+ const gateMode = gate ?? config.gate ?? "auto";
177
+ let gateActive = gateMode !== "off";
178
+ if (gateActive) {
179
+ const probe = probeOasdiff();
180
+ if (!probe.ok) {
181
+ if (gateMode === "required" || checkOnly) throw new Error(`Breaking-change gate cannot run: ${probe.reason}`);
182
+ log(
183
+ `WARN breaking-change gate skipped: ${probe.reason}
184
+ Specs are merged without checking they are non-breaking. Use "gate": "required" (or --require-gate) in CI.`
185
+ );
186
+ gateActive = false;
187
+ }
188
+ } else if (checkOnly) {
189
+ throw new Error(`--check-only needs the gate, but "gate" is "off"`);
190
+ }
191
+ const files = fs2.readdirSync(config.specsDir).filter((f2) => f2.endsWith(".json") && semver4.valid(path.basename(f2, ".json"))).map((f2) => ({ version: path.basename(f2, ".json"), file: path.join(config.specsDir, f2) })).sort((a, b) => semver4.compare(a.version, b.version));
192
+ const report = [];
193
+ let failed = false;
194
+ for (const [majorStr, { floor }] of Object.entries(config.majors)) {
195
+ const major = Number(majorStr);
196
+ const group = files.filter((f2) => semver4.major(f2.version) === major && semver4.gte(f2.version, floor));
197
+ if (!group.length) throw new Error(`No spec found for major ${major} at or above floor ${floor}`);
198
+ if (group[0].version !== floor) throw new Error(`Floor ${floor} has no spec file (oldest found: ${group[0].version})`);
199
+ const breaking = gateActive ? checkChain(group) : [];
200
+ report.push({ major, specs: group.map((g) => g.version), breaking });
201
+ for (const b of breaking) {
202
+ log(`${b.ok ? "ok " : "FAIL"} v${major}: ${b.from} -> ${b.to}`);
203
+ if (!b.ok) {
204
+ failed = true;
205
+ log(
206
+ b.report.split("\n").map((l) => ` ${l}`).join("\n")
207
+ );
208
+ }
209
+ }
210
+ if (failed || checkOnly) continue;
211
+ const docs = group.map((g) => ({ version: g.version, doc: JSON.parse(fs2.readFileSync(g.file, "utf8")) }));
212
+ const merged = mergeAll(docs.map((d) => d.doc));
213
+ merged.info = { ...merged.info, version: `${floor} - ${group.at(-1).version}` };
214
+ const { ops, gatedOps, gatedFields } = computeSince(docs, floor);
215
+ const out = path.join(config.outDir, `v${major}`);
216
+ fs2.mkdirSync(out, { recursive: true });
217
+ fs2.writeFileSync(path.join(out, "merged.openapi.json"), `${JSON.stringify(merged, null, 2)}
218
+ `);
219
+ const ast = await openapiTS(merged, { transform: openEnums, silent: true });
220
+ fs2.writeFileSync(path.join(out, "schema.d.ts"), astToString(ast));
221
+ const meta = { major, floor, newest: group.at(-1).version, specs: group.map((g) => g.version), ops, gatedOps, gatedFields };
222
+ fs2.writeFileSync(
223
+ path.join(out, "meta.ts"),
224
+ `// generated by version-aware codegen \u2014 do not edit
225
+ export const meta = ${JSON.stringify(meta, null, 2)} as const;
226
+
227
+ export type Op = (typeof meta.ops)[number];
228
+ export type GatedOp = keyof typeof meta.gatedOps;
229
+ export type GatedField = keyof typeof meta.gatedFields;
230
+ `
231
+ );
232
+ fs2.writeFileSync(path.join(out, "client.ts"), clientSource(config.runtimeImport ?? DEFAULT_RUNTIME_IMPORT));
233
+ log(
234
+ `gen v${major}: ${meta.specs.join(", ")} -> ${out} (${Object.keys(gatedOps).length} gated ops, ${Object.keys(gatedFields).length} gated fields)`
235
+ );
236
+ }
237
+ if (failed) throw new BreakingChangeError(report);
238
+ if (!checkOnly) {
239
+ const majors = Object.keys(config.majors).map(Number);
240
+ fs2.writeFileSync(
241
+ path.join(config.outDir, "index.ts"),
242
+ `// generated by version-aware codegen \u2014 do not edit
243
+ ` + majors.map((m) => `import { meta as v${m} } from "./v${m}/meta";`).join("\n") + `
244
+
245
+ export const majors = { ${majors.map((m) => `${m}: v${m}`).join(", ")} } as const;
246
+ export type SupportedMajor = keyof typeof majors;
247
+ ` + majors.map((m) => `export type { paths as PathsV${m} } from "./v${m}/schema";`).join("\n") + "\n" + majors.map((m) => `export type { GatedOp as GatedOpV${m}, GatedField as GatedFieldV${m} } from "./v${m}/meta";`).join("\n") + "\n"
248
+ );
249
+ }
250
+ return report;
251
+ }
252
+ function clientSource(runtimeImport) {
253
+ return `// generated by version-aware codegen \u2014 do not edit
254
+ import { createVersionAware } from ${JSON.stringify(runtimeImport)};
255
+ import type { paths } from "./schema";
256
+ import { meta, type GatedOp, type GatedField } from "./meta";
257
+
258
+ const runtime = createVersionAware<paths, GatedOp, GatedField>(meta);
259
+
260
+ export const { ApiVersionProvider, useApi, useSupports, useHasField, useSunset, Feature, createApi, buildApi } = runtime;
261
+ export type Paths = paths;
262
+ export type { GatedOp, GatedField };
263
+ export type Api = ReturnType<typeof useApi>;
264
+ `;
265
+ }
266
+ var BreakingChangeError = class extends Error {
267
+ constructor(report) {
268
+ super("Breaking change inside a major version: bump the major or fix the spec");
269
+ this.report = report;
270
+ }
271
+ report;
272
+ };
273
+
274
+ export {
275
+ MIN_OASDIFF_VERSION,
276
+ probeOasdiff,
277
+ checkPair,
278
+ checkChain,
279
+ validateConfig,
280
+ loadConfig,
281
+ mergeTolerant,
282
+ mergeAll,
283
+ openEnums,
284
+ computeSince,
285
+ build,
286
+ BreakingChangeError
287
+ };
288
+ //# sourceMappingURL=chunk-4O4PFYPM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../codegen/breaking.ts","../codegen/config.ts","../codegen/merge.ts","../codegen/open-enums.ts","../codegen/since.ts","../codegen/build.ts"],"sourcesContent":["/**\n * Within a major, every consecutive pair of specs must be non-breaking.\n * Uses the oasdiff CLI (https://github.com/oasdiff/oasdiff). Set OASDIFF_BIN if it is not on PATH.\n */\nimport { spawnSync } from \"node:child_process\";\nimport semver from \"semver\";\n\n/** Oldest oasdiff the gate is documented for (tested with 1.34.1). */\nexport const MIN_OASDIFF_VERSION = \"1.34.0\";\n\nexport const OASDIFF_INSTALL_HINT =\n \"Install it: `brew install oasdiff`, `go install github.com/oasdiff/oasdiff@latest`, or a release binary from https://github.com/oasdiff/oasdiff/releases (set OASDIFF_BIN if it is not on PATH).\";\n\nexport type OasdiffProbe = { ok: true; bin: string; version: string } | { ok: false; bin: string; reason: string };\n\n/** Looks for a usable oasdiff: present, and at least MIN_OASDIFF_VERSION. */\nexport function probeOasdiff(): OasdiffProbe {\n const bin = process.env.OASDIFF_BIN ?? \"oasdiff\";\n const r = spawnSync(bin, [\"--version\"], { encoding: \"utf8\" });\n if (r.error) return { ok: false, bin, reason: `oasdiff not found (${bin}). ${OASDIFF_INSTALL_HINT}` };\n const version = semver.coerce(r.stdout)?.version;\n if (!version) return { ok: false, bin, reason: `could not read the version of ${bin} (got ${JSON.stringify(r.stdout.trim())})` };\n if (semver.lt(version, MIN_OASDIFF_VERSION))\n return { ok: false, bin, reason: `oasdiff ${version} is older than the minimum ${MIN_OASDIFF_VERSION}. ${OASDIFF_INSTALL_HINT}` };\n return { ok: true, bin, version };\n}\n\nexport interface BreakingResult {\n from: string;\n to: string;\n ok: boolean;\n report: string;\n}\n\nexport function checkPair(fromFile: string, toFile: string): Omit<BreakingResult, \"from\" | \"to\"> {\n const bin = process.env.OASDIFF_BIN ?? \"oasdiff\";\n const r = spawnSync(bin, [\"breaking\", fromFile, toFile, \"--fail-on\", \"ERR\"], { encoding: \"utf8\" });\n if (r.error) throw new Error(`Could not run oasdiff (${bin}): ${r.error.message}`);\n return { ok: r.status === 0, report: (r.stdout + r.stderr).trim() };\n}\n\nexport function checkChain(files: { version: string; file: string }[]): BreakingResult[] {\n const results: BreakingResult[] = [];\n for (let i = 1; i < files.length; i++) {\n results.push({ from: files[i - 1].version, to: files[i].version, ...checkPair(files[i - 1].file, files[i].file) });\n }\n return results;\n}\n","/**\n * Config shape of openapi-versions.config.json (JSON Schema: openapi-versions.schema.json)\n * and a loader that fails with messages that say what to fix.\n */\nimport fs from \"node:fs\";\nimport semver from \"semver\";\n\n/** auto: run the oasdiff gate when oasdiff is installed, warn and skip otherwise. required: fail if it cannot run. off: never run it. */\nexport type GateMode = \"auto\" | \"required\" | \"off\";\nexport const GATE_MODES: GateMode[] = [\"auto\", \"required\", \"off\"];\n\nexport interface Config {\n specsDir: string;\n outDir: string;\n majors: Record<string, { floor: string }>;\n /** Module the generated client.ts imports the runtime from. Default: the package name. */\n runtimeImport?: string;\n /** Breaking-change gate. Default \"auto\". */\n gate?: GateMode;\n}\n\nexport const DEFAULT_RUNTIME_IMPORT = \"openapi-versions\";\n\nexport function validateConfig(raw: unknown, source = \"config\"): Config {\n const fail = (msg: string): never => {\n throw new Error(`${source}: ${msg}`);\n };\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) fail(\"must be a JSON object\");\n const c = raw as Record<string, unknown>;\n for (const key of Object.keys(c))\n if (![\"$schema\", \"specsDir\", \"outDir\", \"majors\", \"runtimeImport\", \"gate\"].includes(key)) fail(`unknown key \"${key}\"`);\n for (const key of [\"specsDir\", \"outDir\"] as const) {\n if (typeof c[key] !== \"string\" || !c[key]) fail(`\"${key}\" must be a non-empty string`);\n }\n if (c.runtimeImport !== undefined && (typeof c.runtimeImport !== \"string\" || !c.runtimeImport))\n fail(`\"runtimeImport\" must be a non-empty string`);\n if (c.gate !== undefined && !GATE_MODES.includes(c.gate as GateMode))\n fail(`\"gate\" must be one of ${GATE_MODES.map((m) => `\"${m}\"`).join(\", \")}`);\n const majors = c.majors;\n if (!majors || typeof majors !== \"object\" || Array.isArray(majors) || !Object.keys(majors).length)\n fail(`\"majors\" must list at least one major, e.g. { \"1\": { \"floor\": \"1.0.0\" } }`);\n for (const [major, entry] of Object.entries(majors as Record<string, any>)) {\n if (!/^(0|[1-9]\\d*)$/.test(major)) fail(`major \"${major}\" must be a number`);\n const floor = entry?.floor;\n if (typeof floor !== \"string\" || semver.valid(floor) !== floor)\n fail(`majors.${major}.floor must be a plain semver like \"${major}.0.0\" (got ${JSON.stringify(floor)})`);\n if (semver.major(floor) !== Number(major)) fail(`majors.${major}.floor ${floor} is not in major ${major}`);\n }\n return {\n specsDir: c.specsDir as string,\n outDir: c.outDir as string,\n majors: majors as Config[\"majors\"],\n runtimeImport: c.runtimeImport as string | undefined,\n gate: c.gate as GateMode | undefined,\n };\n}\n\nexport function loadConfig(path: string): Config {\n let raw: unknown;\n try {\n raw = JSON.parse(fs.readFileSync(path, \"utf8\"));\n } catch (e) {\n throw new Error(`Cannot read ${path}: ${(e as Error).message}`);\n }\n return validateConfig(raw, path);\n}\n","/**\n * Tolerant merge of several OpenAPI documents of the SAME major version.\n *\n * Result describes every input version at once:\n * - paths, operations, properties, parameters: UNION\n * - `required` arrays (schema objects): INTERSECTION\n * - `required` booleans (params, bodies): AND (true only if every version says true)\n * - enums: UNION of values\n * - everything else (descriptions, formats…): newest wins\n *\n * Inputs MUST be non-breaking relative to each other (checked by oasdiff first),\n * otherwise the union can describe a shape no server ever had.\n */\n\ntype Json = any;\nconst isObj = (x: unknown): x is Record<string, Json> => !!x && typeof x === \"object\" && !Array.isArray(x);\nconst isParamList = (a: unknown[]) => a.length > 0 && a.every((p) => isObj(p) && typeof p.name === \"string\" && typeof p.in === \"string\");\n\nexport function mergeTolerant(older: Json, newer: Json): Json {\n if (older === undefined) return newer;\n if (newer === undefined) return older;\n\n if (Array.isArray(older) && Array.isArray(newer)) {\n if (isParamList(older) || isParamList(newer)) return mergeParams(older, newer);\n // enums, `type: [\"string\",\"null\"]`, tags… -> union of primitives, keep order\n if ([...older, ...newer].every((v) => !isObj(v) && !Array.isArray(v))) return [...new Set([...older, ...newer])];\n return newer;\n }\n\n if (isObj(older) && isObj(newer)) {\n const out: Record<string, Json> = {};\n for (const key of new Set([...Object.keys(older), ...Object.keys(newer)])) {\n if (key === \"required\") {\n out.required = mergeRequired(older, newer);\n continue;\n }\n out[key] = mergeTolerant(older[key], newer[key]);\n }\n if (out.required === undefined) delete out.required;\n return out;\n }\n\n return newer;\n}\n\nfunction mergeRequired(older: Record<string, Json>, newer: Record<string, Json>): Json {\n const a = older.required;\n const b = newer.required;\n // Parameter / requestBody: boolean. Missing means false.\n if (typeof a === \"boolean\" || typeof b === \"boolean\") return a === true && b === true;\n // Schema object: string[]. Missing means nothing required.\n const aa: string[] = Array.isArray(a) ? a : [];\n const bb: string[] = Array.isArray(b) ? b : [];\n const both = aa.filter((r) => bb.includes(r));\n return both.length || Array.isArray(a) || Array.isArray(b) ? both : undefined;\n}\n\nfunction mergeParams(older: Json[], newer: Json[]): Json[] {\n const key = (p: Json) => `${p.in}:${p.name}`;\n const olderByKey = new Map(older.map((p) => [key(p), p]));\n const out: Json[] = [];\n for (const p of newer) {\n const o = olderByKey.get(key(p));\n // param that only exists in some versions can never be required\n out.push(o ? mergeTolerant(o, p) : { ...p, required: p.in === \"path\" });\n olderByKey.delete(key(p));\n }\n for (const p of olderByKey.values()) out.push({ ...p, required: p.in === \"path\" });\n return out;\n}\n\nexport const mergeAll = (docs: Json[]) => docs.reduce((acc, d) => mergeTolerant(acc, d));\n","/**\n * openapi-typescript `transform` hook for EXTENSIBLE enums.\n *\n * The backend declares enums that may grow within a major with `x-extensible-enum`\n * (plain `enum` stays closed: adding a value to it is a breaking change, and oasdiff\n * flags it). Extensible enums become OPEN unions:\n * \"active\" | \"invited\" | (string & {})\n * Autocomplete still lists the known values, a value added by a newer backend\n * compiles, and `switch` statements need a `default` branch.\n */\n\nimport type { OpenAPITSOptions } from \"openapi-typescript\";\nimport ts from \"typescript\";\n\nconst f = ts.factory;\n\nexport const openEnums: NonNullable<OpenAPITSOptions[\"transform\"]> = (schema) => {\n const s = schema as any;\n const isString = s.type === \"string\" || (Array.isArray(s.type) && s.type.includes(\"string\"));\n const values = s[\"x-extensible-enum\"];\n if (!isString || !Array.isArray(values) || !values.every((v: unknown) => typeof v === \"string\")) return undefined;\n\n const literals = (values as string[]).map((v) => f.createLiteralTypeNode(f.createStringLiteral(v)));\n const openString = f.createIntersectionTypeNode([f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), f.createTypeLiteralNode([])]);\n const members: ts.TypeNode[] = [...literals, openString];\n if (Array.isArray(s.type) && s.type.includes(\"null\")) members.push(f.createLiteralTypeNode(f.createNull()));\n return f.createUnionTypeNode(members);\n};\n","/**\n * For each operation and each component-schema property, find the first version\n * that has it. Only things introduced AFTER the floor are \"gated\": everything at\n * the floor is guaranteed on every supported backend.\n */\nimport semver from \"semver\";\n\nexport interface VersionedDoc {\n version: string;\n doc: any;\n}\n\nconst METHODS = [\"get\", \"put\", \"post\", \"delete\", \"patch\", \"head\", \"options\", \"trace\"] as const;\n\nexport function computeSince(group: VersionedDoc[], floor: string) {\n const sorted = [...group].sort((a, b) => semver.compare(a.version, b.version));\n const ops: Record<string, string> = {};\n const fields: Record<string, string> = {};\n const schemas: Record<string, string> = {};\n const seen = new Set<string>();\n\n for (const { version, doc } of sorted) {\n for (const [path, item] of Object.entries<any>(doc.paths ?? {})) {\n for (const m of METHODS) if (item?.[m]) ops[`${m} ${path}`] ??= version;\n }\n for (const [name, schema] of Object.entries<any>(doc.components?.schemas ?? {})) {\n schemas[name] ??= version;\n for (const prop of Object.keys(schema?.properties ?? {})) {\n const key = `${name}.${prop}`;\n if (seen.has(key)) continue;\n seen.add(key);\n // fields born with their schema are covered by the operation that returns the schema\n if (semver.gt(version, schemas[name])) fields[key] = version;\n }\n }\n }\n\n const gated = (m: Record<string, string>) =>\n Object.fromEntries(\n Object.entries(m)\n .filter(([, v]) => semver.gt(v, floor))\n .sort(([a], [b]) => a.localeCompare(b)),\n );\n\n return { ops: Object.keys(ops).sort(), gatedOps: gated(ops), gatedFields: gated(fields) };\n}\n","/**\n * Version-aware codegen around openapi-typescript.\n *\n * specs/<semver>.json ──► per major:\n * 1. keep specs >= floor (from openapi-versions.config.json)\n * 2. oasdiff: every consecutive pair must be non-breaking (fails the build otherwise)\n * 3. tolerant merge (union / required ∩ / enum ∪)\n * 4. since maps for operations and fields introduced after the floor\n * 5. openapi-typescript on the merged doc, with open enums\n *\n * output: <outDir>/v<major>/{schema.d.ts, meta.ts, merged.openapi.json} + <outDir>/index.ts\n *\n * Library entry: build(config). The CLI lives in cli.ts.\n */\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport openapiTS, { astToString } from \"openapi-typescript\";\nimport semver from \"semver\";\nimport { checkChain, probeOasdiff } from \"./breaking\";\nimport { type Config, DEFAULT_RUNTIME_IMPORT, type GateMode } from \"./config\";\nimport { mergeAll } from \"./merge\";\nimport { openEnums } from \"./open-enums\";\nimport { computeSince, type VersionedDoc } from \"./since\";\n\n/** `gate` overrides config.gate (the CLI's --require-gate). */\nexport async function build(\n config: Config,\n { checkOnly = false, log = console.log, gate }: { checkOnly?: boolean; log?: (msg: string) => void; gate?: GateMode } = {},\n) {\n const gateMode = gate ?? config.gate ?? \"auto\";\n let gateActive = gateMode !== \"off\";\n if (gateActive) {\n const probe = probeOasdiff();\n if (!probe.ok) {\n // --check-only exists to run the gate, so a missing oasdiff is an error there too\n if (gateMode === \"required\" || checkOnly) throw new Error(`Breaking-change gate cannot run: ${probe.reason}`);\n log(\n `WARN breaking-change gate skipped: ${probe.reason}\\n Specs are merged without checking they are non-breaking. Use \"gate\": \"required\" (or --require-gate) in CI.`,\n );\n gateActive = false;\n }\n } else if (checkOnly) {\n throw new Error(`--check-only needs the gate, but \"gate\" is \"off\"`);\n }\n\n const files = fs\n .readdirSync(config.specsDir)\n .filter((f) => f.endsWith(\".json\") && semver.valid(path.basename(f, \".json\")))\n .map((f) => ({ version: path.basename(f, \".json\"), file: path.join(config.specsDir, f) }))\n .sort((a, b) => semver.compare(a.version, b.version));\n\n const report: { major: number; specs: string[]; breaking: ReturnType<typeof checkChain> }[] = [];\n let failed = false;\n\n for (const [majorStr, { floor }] of Object.entries(config.majors)) {\n const major = Number(majorStr);\n const group = files.filter((f) => semver.major(f.version) === major && semver.gte(f.version, floor));\n if (!group.length) throw new Error(`No spec found for major ${major} at or above floor ${floor}`);\n if (group[0].version !== floor) throw new Error(`Floor ${floor} has no spec file (oldest found: ${group[0].version})`);\n\n const breaking = gateActive ? checkChain(group) : [];\n report.push({ major, specs: group.map((g) => g.version), breaking });\n for (const b of breaking) {\n log(`${b.ok ? \"ok \" : \"FAIL\"} v${major}: ${b.from} -> ${b.to}`);\n if (!b.ok) {\n failed = true;\n log(\n b.report\n .split(\"\\n\")\n .map((l) => ` ${l}`)\n .join(\"\\n\"),\n );\n }\n }\n if (failed || checkOnly) continue;\n\n const docs: VersionedDoc[] = group.map((g) => ({ version: g.version, doc: JSON.parse(fs.readFileSync(g.file, \"utf8\")) }));\n const merged = mergeAll(docs.map((d) => d.doc));\n merged.info = { ...merged.info, version: `${floor} - ${group.at(-1)!.version}` };\n const { ops, gatedOps, gatedFields } = computeSince(docs, floor);\n\n const out = path.join(config.outDir, `v${major}`);\n fs.mkdirSync(out, { recursive: true });\n fs.writeFileSync(path.join(out, \"merged.openapi.json\"), `${JSON.stringify(merged, null, 2)}\\n`);\n\n const ast = await openapiTS(merged, { transform: openEnums, silent: true });\n fs.writeFileSync(path.join(out, \"schema.d.ts\"), astToString(ast));\n\n const meta = { major, floor, newest: group.at(-1)!.version, specs: group.map((g) => g.version), ops, gatedOps, gatedFields };\n fs.writeFileSync(\n path.join(out, \"meta.ts\"),\n `// generated by version-aware codegen — do not edit\\n` +\n `export const meta = ${JSON.stringify(meta, null, 2)} as const;\\n\\n` +\n `export type Op = (typeof meta.ops)[number];\\n` +\n `export type GatedOp = keyof typeof meta.gatedOps;\\n` +\n `export type GatedField = keyof typeof meta.gatedFields;\\n`,\n );\n fs.writeFileSync(path.join(out, \"client.ts\"), clientSource(config.runtimeImport ?? DEFAULT_RUNTIME_IMPORT));\n log(\n `gen v${major}: ${meta.specs.join(\", \")} -> ${out} (${Object.keys(gatedOps).length} gated ops, ${Object.keys(gatedFields).length} gated fields)`,\n );\n }\n\n if (failed) throw new BreakingChangeError(report);\n\n if (!checkOnly) {\n const majors = Object.keys(config.majors).map(Number);\n fs.writeFileSync(\n path.join(config.outDir, \"index.ts\"),\n `// generated by version-aware codegen — do not edit\\n` +\n majors.map((m) => `import { meta as v${m} } from \"./v${m}/meta\";`).join(\"\\n\") +\n `\\n\\nexport const majors = { ${majors.map((m) => `${m}: v${m}`).join(\", \")} } as const;\\n` +\n `export type SupportedMajor = keyof typeof majors;\\n` +\n majors.map((m) => `export type { paths as PathsV${m} } from \"./v${m}/schema\";`).join(\"\\n\") +\n \"\\n\" +\n majors.map((m) => `export type { GatedOp as GatedOpV${m}, GatedField as GatedFieldV${m} } from \"./v${m}/meta\";`).join(\"\\n\") +\n \"\\n\",\n );\n }\n return report;\n}\n\n/** Binds the generic runtime to this major's generated types: apps import everything from here. */\nfunction clientSource(runtimeImport: string) {\n return (\n `// generated by version-aware codegen — do not edit\\n` +\n `import { createVersionAware } from ${JSON.stringify(runtimeImport)};\\n` +\n `import type { paths } from \"./schema\";\\n` +\n `import { meta, type GatedOp, type GatedField } from \"./meta\";\\n\\n` +\n `const runtime = createVersionAware<paths, GatedOp, GatedField>(meta);\\n\\n` +\n `export const { ApiVersionProvider, useApi, useSupports, useHasField, useSunset, Feature, createApi, buildApi } = runtime;\\n` +\n `export type Paths = paths;\\n` +\n `export type { GatedOp, GatedField };\\n` +\n `export type Api = ReturnType<typeof useApi>;\\n`\n );\n}\n\nexport class BreakingChangeError extends Error {\n constructor(public report: Awaited<ReturnType<typeof build>>) {\n super(\"Breaking change inside a major version: bump the major or fix the spec\");\n }\n}\n"],"mappings":"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","names":["semver","path","semver","path","fs","semver","fs","f","semver"]}
@@ -0,0 +1,96 @@
1
+ // src/core/api.ts
2
+ import { skipToken } from "@tanstack/react-query";
3
+ import createQueryClient from "openapi-react-query";
4
+
5
+ // src/core/client.ts
6
+ import createFetchClient from "openapi-fetch";
7
+ var VERSION_HEADER = "x-api-version";
8
+ var SUNSET_HEADER = "sunset";
9
+ function apiHeadersMiddleware({ onVersionHeader, onSunsetHeader }) {
10
+ return {
11
+ onResponse({ response }) {
12
+ const v = response.headers.get(VERSION_HEADER);
13
+ if (v && onVersionHeader) onVersionHeader(v);
14
+ const sunset = response.headers.get(SUNSET_HEADER);
15
+ if (sunset && onSunsetHeader) {
16
+ const d = new Date(sunset);
17
+ if (!Number.isNaN(d.getTime())) onSunsetHeader(d);
18
+ }
19
+ return void 0;
20
+ }
21
+ };
22
+ }
23
+ function createApiClient(opts) {
24
+ const c = createFetchClient({ baseUrl: opts.baseUrl, fetch: opts.fetch });
25
+ if (opts.onVersionHeader || opts.onSunsetHeader) c.use(apiHeadersMiddleware(opts));
26
+ return { core: c, gated: c };
27
+ }
28
+ async function fetchBackendVersion(baseUrl, fetchImpl = fetch) {
29
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
30
+ const res = await fetchImpl(new URL("version", base));
31
+ if (!res.ok) throw new Error(`GET /version failed: ${res.status}`);
32
+ const body = await res.json();
33
+ if (typeof body.version !== "string") throw new Error("GET /version: missing `version` string");
34
+ return body.version;
35
+ }
36
+
37
+ // src/core/compat.ts
38
+ import semver from "semver";
39
+ function resolveCompat(rawVersion, majors, { prereleaseAsRelease = false } = {}) {
40
+ const parsed = semver.parse(rawVersion);
41
+ if (!parsed) return { status: "invalid-version", version: rawVersion };
42
+ const version = prereleaseAsRelease ? `${parsed.major}.${parsed.minor}.${parsed.patch}` : parsed.version;
43
+ const meta = majors[parsed.major];
44
+ if (!meta) return { status: "app-too-old", version, major: parsed.major, supportedMajors: Object.keys(majors).map(Number) };
45
+ if (semver.lt(version, meta.floor)) return { status: "backend-too-old", version, major: parsed.major, floor: meta.floor };
46
+ const atLeast = (since) => since === void 0 || semver.gte(version, since);
47
+ return {
48
+ status: "ok",
49
+ version,
50
+ major: parsed.major,
51
+ backendAhead: semver.gt(version, meta.newest),
52
+ // an op must exist in this major; if it is not in the gated map it exists since the floor
53
+ supports: (op) => meta.ops.includes(op) && atLeast(meta.gatedOps[op]),
54
+ hasField: (field) => atLeast(meta.gatedFields[field])
55
+ };
56
+ }
57
+
58
+ // src/core/api.ts
59
+ function buildApi(compat, client) {
60
+ const full = createQueryClient(client.gated);
61
+ return {
62
+ compat,
63
+ client,
64
+ $api: createQueryClient(client.core),
65
+ $gated: { queryOptions: full.queryOptions, useMutation: full.useMutation },
66
+ gate: (options) => {
67
+ const [method, path] = options.queryKey;
68
+ return compat.supports(`${method} ${path}`) ? options : { ...options, queryFn: skipToken };
69
+ }
70
+ };
71
+ }
72
+ async function createApi(meta, opts) {
73
+ const compat = resolveCompat(await fetchBackendVersion(opts.baseUrl, opts.fetch), { [meta.major]: meta }, opts);
74
+ if (compat.status !== "ok") throw new Error(`Unsupported backend: ${compat.status} (${compat.version})`);
75
+ return buildApi(compat, createApiClient({ baseUrl: opts.baseUrl, fetch: opts.fetch }));
76
+ }
77
+
78
+ // src/core/index.ts
79
+ function createVersionAwareCore(meta) {
80
+ return {
81
+ meta,
82
+ createApi: (opts) => createApi(meta, opts),
83
+ buildApi
84
+ };
85
+ }
86
+
87
+ export {
88
+ apiHeadersMiddleware,
89
+ createApiClient,
90
+ fetchBackendVersion,
91
+ resolveCompat,
92
+ buildApi,
93
+ createApi,
94
+ createVersionAwareCore
95
+ };
96
+ //# sourceMappingURL=chunk-QZ47EPTW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/api.ts","../src/core/client.ts","../src/core/compat.ts","../src/core/index.ts"],"sourcesContent":["/**\n * The object components and loaders use: resolved compatibility + typed\n * openapi-react-query clients + gating.\n *\n * $api operations every supported backend has (floor and older)\n * $gated operations added after the floor: queries go through gate(),\n * mutations are rendered inside <Feature>\n *\n * Generic over the generated types: `P` = `paths`, `G` = union of gated operation keys.\n * Apps get it pre-bound from the generated `v<major>/client.ts`.\n */\nimport { skipToken } from \"@tanstack/react-query\";\nimport createQueryClient, { type OpenapiQueryClient } from \"openapi-react-query\";\nimport { type ApiClient, createApiClient, fetchBackendVersion } from \"./client\";\nimport { type Compat, type CompatOptions, type MajorMeta, resolveCompat } from \"./compat\";\nimport type { StripOps } from \"./types\";\n\nexport type OkCompat = Extract<Compat, { status: \"ok\" }>;\n\ntype QueryOptionsLike = { queryKey: readonly unknown[]; queryFn?: unknown };\nexport type Gated<O extends QueryOptionsLike> = Omit<O, \"queryFn\"> & { queryFn: O[\"queryFn\"] | typeof skipToken };\n\nexport interface Api<P extends {}, G extends string> {\n compat: OkCompat;\n client: ApiClient<P, G>;\n $api: OpenapiQueryClient<StripOps<P, G>>;\n $gated: Pick<OpenapiQueryClient<P>, \"queryOptions\" | \"useMutation\">;\n /**\n * Replace queryFn with skipToken when the connected backend lacks the operation.\n * The operation is read from the key openapi-react-query builds ([method, path, init]),\n * and the result type includes skipToken, so useSuspenseQuery rejects it.\n */\n gate<O extends QueryOptionsLike>(options: O): Gated<O>;\n}\n\nexport function buildApi<P extends {}, G extends string>(compat: OkCompat, client: ApiClient<P, G>): Api<P, G> {\n const full = createQueryClient(client.gated);\n return {\n compat,\n client,\n $api: createQueryClient(client.core) as unknown as Api<P, G>[\"$api\"],\n $gated: { queryOptions: full.queryOptions, useMutation: full.useMutation },\n gate: <O extends QueryOptionsLike>(options: O): Gated<O> => {\n const [method, path] = options.queryKey as [string, string];\n return (compat.supports(`${method} ${path}`) ? options : { ...options, queryFn: skipToken }) as Gated<O>;\n },\n };\n}\n\nexport type CreateApiOptions = { baseUrl: string; fetch?: typeof fetch } & CompatOptions;\n\n/** Reads `<baseUrl>/version`, resolves compatibility, throws if the backend is not supported. */\nexport async function createApi<P extends {}, G extends string>(meta: MajorMeta, opts: CreateApiOptions): Promise<Api<P, G>> {\n const compat = resolveCompat(await fetchBackendVersion(opts.baseUrl, opts.fetch), { [meta.major]: meta }, opts);\n if (compat.status !== \"ok\") throw new Error(`Unsupported backend: ${compat.status} (${compat.version})`);\n return buildApi<P, G>(compat, createApiClient<P, G>({ baseUrl: opts.baseUrl, fetch: opts.fetch }));\n}\n","/**\n * One openapi-fetch client, exposed twice:\n * - `core`: typed with gated operations removed (safe on every supported backend)\n * - `gated`: full merged types, for operations that need a capability check\n * Both are the SAME runtime object; only the types differ.\n *\n * A middleware reads `X-Api-Version` on every response so the app notices a backend\n * deploy during a session (web tab left open, mobile app resumed), and `Sunset`\n * so it can warn before the backend stops serving this major.\n */\nimport createFetchClient, { type Client, type Middleware } from \"openapi-fetch\";\nimport type { StripOps } from \"./types\";\n\nexport const VERSION_HEADER = \"x-api-version\";\n\n/** `P` = generated `paths`, `G` = union of gated operation keys */\nexport type CorePaths<P extends {}, G extends string> = StripOps<P, G>;\nexport interface ApiClient<P extends {}, G extends string> {\n core: Client<CorePaths<P, G>>;\n gated: Client<P>;\n}\n\nexport const SUNSET_HEADER = \"sunset\"; // RFC 8594: when this API (major) stops being served\n\nexport interface ApiHeaderHandlers {\n onVersionHeader?: (version: string) => void;\n onSunsetHeader?: (sunset: Date) => void;\n}\n\n/** Reads X-Api-Version (deploys mid-session) and Sunset (major being retired) on every response. */\nexport function apiHeadersMiddleware({ onVersionHeader, onSunsetHeader }: ApiHeaderHandlers): Middleware {\n return {\n onResponse({ response }) {\n const v = response.headers.get(VERSION_HEADER);\n if (v && onVersionHeader) onVersionHeader(v);\n const sunset = response.headers.get(SUNSET_HEADER);\n if (sunset && onSunsetHeader) {\n const d = new Date(sunset);\n if (!Number.isNaN(d.getTime())) onSunsetHeader(d);\n }\n return undefined;\n },\n };\n}\n\n/**\n * `baseUrl` includes the major: https://host/api/v1. The backend serves each major\n * under its own prefix, with its own /version, so one app build talks to one major.\n */\nexport function createApiClient<P extends {}, G extends string>(\n opts: { baseUrl: string; fetch?: typeof globalThis.fetch } & ApiHeaderHandlers,\n): ApiClient<P, G> {\n const c = createFetchClient<P>({ baseUrl: opts.baseUrl, fetch: opts.fetch });\n if (opts.onVersionHeader || opts.onSunsetHeader) c.use(apiHeadersMiddleware(opts));\n return { core: c as unknown as Client<CorePaths<P, G>>, gated: c };\n}\n\n/** GET {baseUrl}/version, e.g. https://host/api/v1/version. Resolved RELATIVE to the base path. */\nexport async function fetchBackendVersion(baseUrl: string, fetchImpl: typeof fetch = fetch): Promise<string> {\n const base = baseUrl.endsWith(\"/\") ? baseUrl : `${baseUrl}/`;\n const res = await fetchImpl(new URL(\"version\", base));\n if (!res.ok) throw new Error(`GET /version failed: ${res.status}`);\n const body = (await res.json()) as { version?: unknown };\n if (typeof body.version !== \"string\") throw new Error(\"GET /version: missing `version` string\");\n return body.version;\n}\n","/**\n * Decide what this client can do against a given backend version.\n * Framework-free: used by the React provider, by mobile, by tests.\n */\nimport semver from \"semver\";\n\nexport interface MajorMeta {\n readonly major: number;\n readonly floor: string;\n readonly newest: string;\n readonly ops: readonly string[];\n readonly gatedOps: Readonly<Record<string, string>>;\n readonly gatedFields: Readonly<Record<string, string>>;\n}\n\nexport type Compat =\n | {\n status: \"ok\";\n version: string;\n major: number;\n /** backend is newer than the newest spec this client was built with (forward compatibility) */\n backendAhead: boolean;\n supports(op: string): boolean;\n hasField(field: string): boolean;\n }\n | { status: \"backend-too-old\"; version: string; major: number; floor: string }\n | { status: \"app-too-old\"; version: string; major: number; supportedMajors: number[] }\n | { status: \"invalid-version\"; version: string };\n\nexport interface CompatOptions {\n /**\n * Treat `1.6.0-rc.1` as `1.6.0` (staging running a release candidate gets the new features).\n * Default false: an rc of 1.6.0 counts as 1.5.x, which is the safe reading of semver.\n */\n prereleaseAsRelease?: boolean;\n}\n\nexport function resolveCompat(\n rawVersion: string,\n majors: Readonly<Record<number, MajorMeta>>,\n { prereleaseAsRelease = false }: CompatOptions = {},\n): Compat {\n const parsed = semver.parse(rawVersion);\n if (!parsed) return { status: \"invalid-version\", version: rawVersion };\n const version = prereleaseAsRelease ? `${parsed.major}.${parsed.minor}.${parsed.patch}` : parsed.version;\n\n const meta = majors[parsed.major];\n if (!meta) return { status: \"app-too-old\", version, major: parsed.major, supportedMajors: Object.keys(majors).map(Number) };\n if (semver.lt(version, meta.floor)) return { status: \"backend-too-old\", version, major: parsed.major, floor: meta.floor };\n\n const atLeast = (since: string | undefined) => since === undefined || semver.gte(version, since);\n return {\n status: \"ok\",\n version,\n major: parsed.major,\n backendAhead: semver.gt(version, meta.newest),\n // an op must exist in this major; if it is not in the gated map it exists since the floor\n supports: (op) => meta.ops.includes(op) && atLeast(meta.gatedOps[op]),\n hasField: (field) => atLeast(meta.gatedFields[field]),\n };\n}\n","/** Framework-free runtime: usable in loaders, scripts, React Native without React. */\n\nimport { buildApi, type CreateApiOptions, createApi } from \"./api\";\nimport type { MajorMeta } from \"./compat\";\n\nexport { type Api, buildApi, type CreateApiOptions, createApi, type Gated, type OkCompat } from \"./api\";\nexport { type ApiClient, apiHeadersMiddleware, type CorePaths, createApiClient, fetchBackendVersion } from \"./client\";\nexport { type Compat, type CompatOptions, type MajorMeta, resolveCompat } from \"./compat\";\nexport type { StripOps } from \"./types\";\n\n/**\n * Bind the runtime to one generated contract. Normally called by the generated\n * `v<major>/client.ts`; `P` = paths, `G` = gated operation keys.\n */\nexport function createVersionAwareCore<P extends {}, G extends string>(meta: MajorMeta) {\n return {\n meta,\n createApi: (opts: CreateApiOptions) => createApi<P, G>(meta, opts),\n buildApi: buildApi<P, G>,\n };\n}\n"],"mappings":"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","names":[]}
package/dist/cli.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ build,
4
+ loadConfig
5
+ } from "./chunk-4O4PFYPM.js";
6
+
7
+ // codegen/cli.ts
8
+ var args = process.argv.slice(2);
9
+ var configPath = args.includes("--config") ? args[args.indexOf("--config") + 1] : "openapi-versions.config.json";
10
+ try {
11
+ await build(loadConfig(configPath), {
12
+ checkOnly: args.includes("--check-only"),
13
+ gate: args.includes("--require-gate") ? "required" : void 0
14
+ });
15
+ } catch (e) {
16
+ console.error(`
17
+ ${e.message}`);
18
+ process.exit(1);
19
+ }
20
+ //# sourceMappingURL=cli.js.map