openapi-versions 0.0.0-stage → 0.0.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.
- package/LICENSE +21 -0
- package/README.md +120 -2
- package/dist/chunk-4O4PFYPM.js +288 -0
- package/dist/chunk-4O4PFYPM.js.map +1 -0
- package/dist/chunk-QZ47EPTW.js +96 -0
- package/dist/chunk-QZ47EPTW.js.map +1 -0
- package/dist/cli.js +20 -0
- package/dist/cli.js.map +1 -0
- package/dist/codegen.d.ts +104 -0
- package/dist/codegen.js +29 -0
- package/dist/codegen.js.map +1 -0
- package/dist/core.d.ts +146 -0
- package/dist/core.js +19 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/docs/README.md +22 -0
- package/docs/adr/0001-separate-package-not-upstream.md +16 -0
- package/docs/adr/0002-extensible-enums-via-x-extensible-enum.md +16 -0
- package/docs/adr/0003-gating-model.md +16 -0
- package/docs/adr/0004-oasdiff-breaking-gate.md +16 -0
- package/docs/adr/0005-patch-openapi-typescript-helpers.md +16 -0
- package/docs/adr/0006-tooling-mise-pnpm-biome.md +16 -0
- package/docs/adr/0007-mit-license.md +16 -0
- package/docs/adr/README.md +13 -0
- package/docs/adr/template.md +16 -0
- package/docs/concepts.md +67 -0
- package/docs/ecosystem.md +39 -0
- package/docs/getting-started.md +99 -0
- package/docs/how-to.md +155 -0
- package/docs/reference.md +129 -0
- package/docs/troubleshooting.md +21 -0
- package/docs/upstream-drafts.md +166 -0
- package/openapi-versions.schema.json +57 -0
- package/package.json +114 -4
- package/patches/openapi-typescript-helpers@0.1.0.patch +51 -0
- package/skills/openapi-versions-add-spec/SKILL.md +37 -0
- package/skills/openapi-versions-gate-feature/SKILL.md +34 -0
- package/skills/openapi-versions-setup/SKILL.md +35 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# NNNN. Title
|
|
2
|
+
|
|
3
|
+
- Status: proposed | accepted | superseded by NNNN
|
|
4
|
+
- Date: YYYY-MM-DD
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
What forces the decision.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
What we do.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
What gets easier, what gets harder, what to revisit and when.
|
package/docs/concepts.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Concepts
|
|
2
|
+
|
|
3
|
+
## Why this exists
|
|
4
|
+
|
|
5
|
+
Web, mobile and backend ship on different clocks. The API releases every two weeks, the web app several times a day, and mobile builds stay installed for months. At any moment several client and API versions talk to each other. A client generated from one spec silently assumes the backend matches it. This tool makes the assumption explicit and safe.
|
|
6
|
+
|
|
7
|
+
## Vocabulary
|
|
8
|
+
|
|
9
|
+
| Term | Meaning |
|
|
10
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| **Major** | The `1` in `1.6.0`. Breaking changes allowed. Served under its own prefix: `/api/v1`, `/api/v2`. |
|
|
12
|
+
| **Minor** | The `6` in `1.6.0`. Additive changes only. |
|
|
13
|
+
| **Spec** | One OpenAPI JSON document per released version, stored as `specs/<semver>.json`. |
|
|
14
|
+
| **Floor** | The oldest minor of a major that the app still supports. Everything present at the floor is assumed on every supported backend. |
|
|
15
|
+
| **Newest spec** | The highest spec in `specsDir`. It may be a release the backend has not deployed yet. |
|
|
16
|
+
| **Gated operation / field** | Introduced after the floor. Available only when the connected backend reports a version at or above the one that introduced it. |
|
|
17
|
+
| **Backend ahead** | The backend is newer than the newest spec. Works: unknown fields are ignored, unknown enum values are tolerated. |
|
|
18
|
+
|
|
19
|
+
## The three rules
|
|
20
|
+
|
|
21
|
+
1. **Within a major, changes are additive.** New endpoints, new optional request fields, new response fields, new values of _extensible_ enums. Removing, renaming, retyping, or adding a required request field needs a new major.
|
|
22
|
+
2. **Each app release supports one major**, over a range of its minors (floor to newest spec). Moving to the next major is a new app release.
|
|
23
|
+
3. **Each major has its own prefix and its own `GET <prefix>/version`**, plus an `X-Api-Version` header on every response, as real semver. The app's base URL includes the major, so it never needs to know other majors exist.
|
|
24
|
+
|
|
25
|
+
## What the client does for each backend
|
|
26
|
+
|
|
27
|
+
| Connected backend | Behaviour |
|
|
28
|
+
| ----------------------------------- | ------------------------------------------------------------------------------------- |
|
|
29
|
+
| Same major, older than newest spec | Works. Operations added later are hidden or disabled. |
|
|
30
|
+
| Same major, newer than newest spec | Works. Everything known is on. Unknown fields ignored, unknown enum values tolerated. |
|
|
31
|
+
| Same major, below the floor | Blocked: `backend-too-old`. |
|
|
32
|
+
| Same major, `Sunset` header present | Works. `useSunset()` returns the date for a banner. |
|
|
33
|
+
| Another major | Blocked: `app-too-old`. |
|
|
34
|
+
| Version changes mid-session | The next response's `X-Api-Version` triggers re-evaluation and a refetch. |
|
|
35
|
+
| `/version` not valid semver | Blocked: `invalid-version`. |
|
|
36
|
+
|
|
37
|
+
## How the pieces fit
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
specs/1.3.0.json … 1.6.0.json + openapi-versions.config.json
|
|
41
|
+
│ codegen (build time, in CI)
|
|
42
|
+
│ 1. keep specs >= floor
|
|
43
|
+
│ 2. oasdiff breaking on each consecutive pair → fail on any break
|
|
44
|
+
│ 3. tolerant merge into one document
|
|
45
|
+
│ 4. since maps: which version introduced each op / field
|
|
46
|
+
│ 5. openapi-typescript on the merged doc, extensible enums as open unions
|
|
47
|
+
▼
|
|
48
|
+
src/generated/v1/{schema.d.ts, meta.ts, merged.openapi.json}
|
|
49
|
+
│ runtime
|
|
50
|
+
▼
|
|
51
|
+
resolveCompat(version) → supports(op) / hasField(field) ← the single decision point
|
|
52
|
+
│
|
|
53
|
+
├─ $api typed WITHOUT gated operations (cannot call newer ops: compile error)
|
|
54
|
+
└─ $gated typed WITH everything; queries go through gate(), mutations inside <Feature>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
No component compares version numbers. Everything asks `supports()`.
|
|
58
|
+
|
|
59
|
+
## Tolerant reader
|
|
60
|
+
|
|
61
|
+
Depend on what you need, ignore the rest. Concretely: merged types make fields added after the floor optional, extensible enums are open (`"a" | "b" | (string & {})`) so a `switch` needs a `default`, and unknown response fields are never an error.
|
|
62
|
+
|
|
63
|
+
## Trade-offs to accept
|
|
64
|
+
|
|
65
|
+
- It relies on backend discipline. The oasdiff gate belongs in the **backend** CI as well, otherwise a breaking change shipped in a minor makes the merged types describe a shape no server has.
|
|
66
|
+
- Fields added after the floor are typed optional, so the compiler no longer reminds you newer servers always send them. Use `hasField()` for explicit checks.
|
|
67
|
+
- Open enums are open in request bodies too: a typo in a sent value compiles.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Fit with the openapi-ts ecosystem
|
|
2
|
+
|
|
3
|
+
`openapi-versions` is deliberately a thin layer on the openapi-ts packages. It adds one capability they do not have: **one client, a range of server versions**.
|
|
4
|
+
|
|
5
|
+
| openapi-ts piece | Role here | How it is used |
|
|
6
|
+
| ---------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| `openapi-typescript` | Generates `schema.d.ts` | Called through its Node API (`openapiTS`, `astToString`) on the _merged_ spec. The only extension point used is the documented `transform` hook (for `x-extensible-enum`). Not forked. |
|
|
8
|
+
| `openapi-fetch` | The HTTP client | One `createClient<paths>` instance, plus one middleware (`onResponse`) for `X-Api-Version` and `Sunset`. |
|
|
9
|
+
| `openapi-react-query` | Hooks | `createClient(fetchClient)` twice: `$api` (types without gated operations) and `$gated` (everything). Query keys are its own, so loaders and `prefetchQuery` work unchanged. |
|
|
10
|
+
| `openapi-typescript-helpers` | Shared types | `StripOps` uses `HttpMethod`. A one-line fix to `Readable<T>` is needed for open enums (below). |
|
|
11
|
+
| `swr-openapi` | SWR hooks | Not supported yet. |
|
|
12
|
+
|
|
13
|
+
Conventions followed: peer dependencies on the user's `openapi-typescript` / `openapi-fetch` / `openapi-react-query` (no bundled copies), ESM, types for every entry point, `$`-prefixed query client as in the openapi-react-query docs, one generated `schema.d.ts` per major.
|
|
14
|
+
|
|
15
|
+
## Gaps this fills
|
|
16
|
+
|
|
17
|
+
| Tool | What is missing for multi-version clients |
|
|
18
|
+
| ----------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
19
|
+
| openapi-typescript | One spec in, one `paths` out. No merging of minors, no since-versions, closed enums |
|
|
20
|
+
| openapi-fetch / openapi-react-query | Typed against one spec. No capability check at runtime |
|
|
21
|
+
| Hey API `openapi-ts` | Merges _different_ APIs, prefixing colliding paths; not a union of versions of the _same_ API |
|
|
22
|
+
| oasdiff | Detects breaks, produces no client |
|
|
23
|
+
|
|
24
|
+
## What could be upstreamed
|
|
25
|
+
|
|
26
|
+
1. **`Readable<T>` / `Writable<T>` primitives fix** in `openapi-typescript-helpers`. The local patch (`patches/`) makes both helpers return primitives as-is, so `string & {}` open unions survive. Without it, `openapi-fetch` response types lose the known values of open enums. Smallest, most valuable contribution; independent of this package.
|
|
27
|
+
2. **`x-extensible-enum` (or an option) as built-in open enums** in openapi-typescript, replacing the `transform` hook in `codegen/open-enums.ts`.
|
|
28
|
+
3. **Multi-spec input** (merge + since map) as a documented recipe or plugin in the openapi-typescript docs, with this package as the reference implementation.
|
|
29
|
+
|
|
30
|
+
## Naming and docs placement
|
|
31
|
+
|
|
32
|
+
- Package name: `openapi-versions` (unclaimed on npm when checked; reserve it before publishing).
|
|
33
|
+
- Fits next to `openapi-fetch` and `openapi-react-query` in the docs sidebar as "Versioning": the pages in `docs/` map to _Introduction_ (concepts), _Setup_ (getting-started), _Recipes_ (how-to), _API_ (reference). The openapi-ts docs are VitePress/Markdown, so these files port as they are.
|
|
34
|
+
- Before proposing it upstream, confirm with the maintainers whether they prefer it as a separate package (as here) or an openapi-typescript feature.
|
|
35
|
+
|
|
36
|
+
## Known constraints
|
|
37
|
+
|
|
38
|
+
- openapi-typescript 7 requires TypeScript 5 or 6: TypeScript 7 (native port, `latest` on npm) has no `ts.factory`. Upstream issue #2841; open PRs #2867 (bundle a compiler) and #2872 (no runtime compiler dependency).
|
|
39
|
+
- The package is not published yet; `pnpm build` then `npm pack` produces the tarball. A packed install in a fresh app was verified: codegen, generated `client.ts`, and a typecheck of the gating guarantees all pass.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- Node 20+, **TypeScript 5 or 6** (verified on 5.9 and 6.0; openapi-typescript 7 does not run on TypeScript 7, the native compiler, tracked in openapi-ts/openapi-typescript#2841).
|
|
6
|
+
- [`oasdiff`](https://github.com/oasdiff/oasdiff) on the `PATH` (or `OASDIFF_BIN=/path/to/oasdiff`), version 1.34 or newer. `brew install oasdiff` on macOS. Optional locally (codegen warns and skips the gate without it); set `"gate": "required"` or pass `--require-gate` in CI.
|
|
7
|
+
- A backend that: serves each major under its own prefix, answers `GET <prefix>/version` with `{ "version": "1.6.0" }`, sends `X-Api-Version` on every response, and publishes one OpenAPI JSON spec per release.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm i openapi-versions
|
|
13
|
+
npm i openapi-fetch openapi-react-query @tanstack/react-query react # peers: the openapi-ts runtime you already use
|
|
14
|
+
npm i -D openapi-typescript "typescript@<7"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`openapi-versions` is a layer on the openapi-ts packages, not a replacement: it generates types with `openapi-typescript`, calls with `openapi-fetch`, hooks with `openapi-react-query`. React and TanStack Query are optional; without them use `openapi-versions/core`.
|
|
18
|
+
|
|
19
|
+
Open enums need a one-line fix to `openapi-typescript-helpers` (see [troubleshooting](troubleshooting.md)); apply `patches/` with `patch-package` in your app until it lands upstream.
|
|
20
|
+
|
|
21
|
+
## 1. Collect the specs
|
|
22
|
+
|
|
23
|
+
Put one spec per released version in `specs/`, named `<semver>.json`. Take each from the backend CI artifact of the release commit, not from a branch tip.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
specs/1.3.0.json
|
|
27
|
+
specs/1.4.0.json
|
|
28
|
+
specs/1.5.0.json
|
|
29
|
+
specs/1.6.0.json # may be a release the backend has not deployed yet
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Mark enums that may grow with `x-extensible-enum` in the spec (see [how-to](how-to.md#declare-an-enum-that-can-grow)).
|
|
33
|
+
|
|
34
|
+
## 2. Write the config
|
|
35
|
+
|
|
36
|
+
`openapi-versions.config.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"$schema": "./openapi-versions.schema.json",
|
|
41
|
+
"specsDir": "specs",
|
|
42
|
+
"outDir": "src/generated",
|
|
43
|
+
"majors": { "1": { "floor": "1.3.0" } }
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The floor must be a real spec file (`specs/1.3.0.json`). See [reference](reference.md#config).
|
|
48
|
+
|
|
49
|
+
## 3. Generate
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx openapi-versions # or an npm script
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
You get `src/generated/v1/{schema.d.ts, meta.ts, merged.openapi.json}` and `src/generated/index.ts`. Do not edit them. Commit them or regenerate in CI, as you prefer, but be consistent.
|
|
56
|
+
|
|
57
|
+
## 4. Import from the generated client
|
|
58
|
+
|
|
59
|
+
Codegen writes `<outDir>/v<major>/client.ts`: the runtime already bound to this major's types. The import path is the only place the major appears in app code:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { ApiVersionProvider, useApi, Feature, createApi, type Paths } from "./api/v1/client";
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`runtimeImport` in the config sets what `client.ts` imports the runtime from (default `openapi-versions`).
|
|
66
|
+
|
|
67
|
+
## 5. Use it in React
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
<QueryClientProvider client={queryClient}>
|
|
71
|
+
<ApiVersionProvider baseUrl="https://host/api/v1">
|
|
72
|
+
<App />
|
|
73
|
+
</ApiVersionProvider>
|
|
74
|
+
</QueryClientProvider>;
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
const { $api, $gated, gate } = useApi();
|
|
79
|
+
|
|
80
|
+
// available on every supported backend: plain openapi-react-query
|
|
81
|
+
const user = $api.useQuery("get", "/users/{id}", { params: { path: { id } } });
|
|
82
|
+
|
|
83
|
+
// added after the floor: gate() turns it into skipToken when the backend lacks it
|
|
84
|
+
const activity = useQuery(gate($gated.queryOptions("get", "/users/{id}/activity", { params: { path: { id } } })));
|
|
85
|
+
|
|
86
|
+
// gated mutation: render the trigger inside <Feature>
|
|
87
|
+
const archive = $gated.useMutation("post", "/users/{id}/archive");
|
|
88
|
+
<Feature requires="post /users/{id}/archive">
|
|
89
|
+
<button onClick={() => archive.mutate({ params: { path: { id } } })}>Archive</button>
|
|
90
|
+
</Feature>;
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 6. Try it without a backend
|
|
94
|
+
|
|
95
|
+
`playground/api/mock-backend.ts` is a fetch-compatible fake backend at any version. `pnpm playground` starts a small app with a control panel that "deploys" versions while the app is open. `pnpm playground:api` prints a table of one build against seven backends.
|
|
96
|
+
|
|
97
|
+
## 7. Wire CI
|
|
98
|
+
|
|
99
|
+
See [how-to: CI](how-to.md#wire-ci).
|
package/docs/how-to.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# How-to
|
|
2
|
+
|
|
3
|
+
Each recipe lists the exact steps. Run `pnpm codegen`, `pnpm typecheck` and `npm test` after changes.
|
|
4
|
+
- [Add the spec of a new backend release](#add-the-spec-of-a-new-backend-release)
|
|
5
|
+
- [Use a feature before the backend ships it](#use-a-feature-before-the-backend-ships-it)
|
|
6
|
+
- [Gate a query, a mutation, a field](#gate-a-query-a-mutation-a-field)
|
|
7
|
+
- [Declare an enum that can grow](#declare-an-enum-that-can-grow)
|
|
8
|
+
- [Raise the floor](#raise-the-floor)
|
|
9
|
+
- [Handle a breaking-change failure](#handle-a-breaking-change-failure)
|
|
10
|
+
- [Move the app to the next major](#move-the-app-to-the-next-major)
|
|
11
|
+
- [Use the API in a router loader or outside React](#use-the-api-in-a-router-loader-or-outside-react)
|
|
12
|
+
- [Mobile (React Native)](#mobile-react-native)
|
|
13
|
+
- [Show an "update before" banner](#show-an-update-before-banner)
|
|
14
|
+
- [Test against several backend versions](#test-against-several-backend-versions)
|
|
15
|
+
- [Wire CI](#wire-ci)
|
|
16
|
+
|
|
17
|
+
## Add the spec of a new backend release
|
|
18
|
+
|
|
19
|
+
1. Download the spec from the backend release artifact. Save it as `specs/<version>.json` (e.g. `specs/1.7.0.json`). The file name must be the exact semver.
|
|
20
|
+
2. `pnpm codegen`. The oasdiff gate checks `1.6.0 → 1.7.0`; new operations land in the gated maps automatically.
|
|
21
|
+
3. `git diff src/generated/v1/meta.ts` shows what is newly gated.
|
|
22
|
+
4. Build the feature (see next recipe).
|
|
23
|
+
|
|
24
|
+
If the spec changes again before the release, overwrite the file and re-run codegen. The gate catches anything that stopped being additive.
|
|
25
|
+
|
|
26
|
+
## Use a feature before the backend ships it
|
|
27
|
+
|
|
28
|
+
This is the point of the tool. Add the upcoming spec (as above), build the feature behind `gate()` / `<Feature>`, and release. Users on the old backend never see it and no request is sent. The moment the backend deploys the new version, the next response carries `X-Api-Version`, the provider re-evaluates and the feature appears without a reload.
|
|
29
|
+
|
|
30
|
+
## Gate a query, a mutation, a field
|
|
31
|
+
|
|
32
|
+
Operation introduced after the floor, **query**:
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
const { $gated, gate } = useApi();
|
|
36
|
+
const activity = useQuery(gate($gated.queryOptions("get", "/users/{id}/activity", init)));
|
|
37
|
+
// activity.data is undefined while the backend lacks the operation (skipToken: never fetched)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`gate()` output cannot be passed to `useSuspenseQuery` (compile error, by design: it may be skipped).
|
|
41
|
+
|
|
42
|
+
**Mutation**: render the trigger only when supported.
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
const archive = $gated.useMutation("post", "/users/{id}/archive");
|
|
46
|
+
<Feature requires="post /users/{id}/archive" fallback={null}>
|
|
47
|
+
…
|
|
48
|
+
</Feature>;
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Anywhere**: `useSupports("get /users/{id}/activity")` returns a boolean; outside React use `api.compat.supports(op)`.
|
|
52
|
+
|
|
53
|
+
**Field** added after the floor: its type is optional. Check explicitly when it matters.
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
const showAvatar = useHasField("User.avatarUrl");
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Operation keys are `"<method> <path>"` in lower-case method, e.g. `"post /users/{id}/archive"`; field keys are `"<Schema>.<property>"`. Both are typed (`GatedOp`, `GatedField`), so a typo is a compile error. Calling a gated operation through `$api` is also a compile error: it exists only on `$gated`.
|
|
60
|
+
|
|
61
|
+
## Declare an enum that can grow
|
|
62
|
+
|
|
63
|
+
Plain `enum` is closed: adding a value is a **breaking** change and oasdiff blocks it. For an enum that will grow within a major, the backend declares it with `x-extensible-enum`:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
"status": { "type": "string", "x-extensible-enum": ["active", "invited", "archived"] }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Codegen turns it into an open union, `"active" | "invited" | "archived" | (string & {})`. Autocomplete lists the known values, unknown ones compile, and a `switch` needs a `default` branch:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
switch (user.status) {
|
|
73
|
+
case "active": …
|
|
74
|
+
default: return user.status; // value from a newer backend: show raw, do not crash
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Converting an existing closed `enum` to `x-extensible-enum` is itself an API-contract decision: do it at a major boundary, or by adding the extension to every spec of the range.
|
|
79
|
+
|
|
80
|
+
## Raise the floor
|
|
81
|
+
|
|
82
|
+
When a backend minor leaves the support window:
|
|
83
|
+
1. Edit `majors.<n>.floor` in `openapi-versions.config.json` (the new floor needs a spec file).
|
|
84
|
+
2. Optionally delete older specs.
|
|
85
|
+
3. `pnpm codegen`. Operations that were gated only against versions below the new floor become ordinary operations.
|
|
86
|
+
4. `pnpm typecheck`. `useSupports(...)` / `<Feature requires=…>` for operations that are no longer gated now error or become dead; remove them.
|
|
87
|
+
|
|
88
|
+
## Handle a breaking-change failure
|
|
89
|
+
|
|
90
|
+
`FAIL v1: 1.6.0 -> 1.7.0` with an oasdiff report means a spec removed, renamed or retyped something, or added a required request field, or added a value to a closed enum. Options, in order of preference:
|
|
91
|
+
1. Fix the **backend** so the change is additive (add a new field, keep the old one).
|
|
92
|
+
2. If it was a growable enum, declare it `x-extensible-enum`.
|
|
93
|
+
3. If the break is intended, it belongs in a new major: [Move to the next major](#move-the-app-to-the-next-major).
|
|
94
|
+
|
|
95
|
+
Do not hand-edit specs to silence the gate: the merged types would then describe a server that does not exist.
|
|
96
|
+
|
|
97
|
+
## Move the app to the next major
|
|
98
|
+
|
|
99
|
+
The backend serves both majors side by side for a bounded window; the app switches only by a new release.
|
|
100
|
+
1. Backend ships `/api/v2` and keeps `/api/v1` running, frozen (patches only). Existing apps are unaffected.
|
|
101
|
+
2. Backend adds `Sunset` to v1 responses. 1.x apps show the banner ([recipe](#show-an-update-before-banner)).
|
|
102
|
+
3. In the app repo: put the 2.x specs in `specsDir`, set `majors` to `{ "2": { "floor": "2.0.0" } }`, run codegen, change imports from `…/v1/client` to `…/v2/client`, point `baseUrl` at `/api/v2`, fix compile errors.
|
|
103
|
+
4. When the sunset date passes, the backend removes `/api/v1`. Remaining 1.x builds get the "update the app" screen.
|
|
104
|
+
|
|
105
|
+
## Use the API in a router loader or outside React
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { createApi } from "./api/v1/client";
|
|
109
|
+
const api = await createApi({ baseUrl: "https://host/api/v1" }); // reads /version, throws if unsupported
|
|
110
|
+
await queryClient.ensureQueryData(api.$api.queryOptions("get", "/users/{id}", init));
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Without React (scripts, workers, React Native without hooks) import the core from `openapi-versions/core`; the generated client already re-exports `createApi`.
|
|
114
|
+
|
|
115
|
+
Query keys are openapi-react-query's own (`[method, path, params]`), so the same options work in components, loaders and `prefetchQuery`.
|
|
116
|
+
|
|
117
|
+
## Mobile (React Native)
|
|
118
|
+
|
|
119
|
+
`src/core` is framework-free and the React layer works as is. Only the re-check trigger differs: the provider already refetches `/version` on window focus; on React Native wire app resume with TanStack Query's `focusManager` and `AppState`:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { AppState } from "react-native";
|
|
123
|
+
import { focusManager } from "@tanstack/react-query";
|
|
124
|
+
AppState.addEventListener("change", (s) => focusManager.setFocused(s === "active"));
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Show an "update before" banner
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
const sunset = useSunset(); // Date | undefined, set once a response carries a Sunset header
|
|
131
|
+
return sunset && <Banner>Update before {sunset.toDateString()}</Banner>;
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Test against several backend versions
|
|
135
|
+
|
|
136
|
+
`createMockBackend(version, { sunset? })` from `playground/api/mock-backend.ts` (a test helper in this repo; copy it) returns `{ fetch, calls, deploy(next) }`. Pass `fetch` to `ApiVersionProvider` or `createApi`, assert on `calls` that no gated request was sent, and call `deploy("1.6.0")` then invalidate queries to simulate a deploy mid-session. `sideBySide({ "/api/v1": …, "/api/v2": … })` simulates two majors. See `test/react.test.tsx` and `test/majors.test.tsx`. Pin the compile-time guarantees with `@ts-expect-error` as in `test/types.check.ts`.
|
|
137
|
+
|
|
138
|
+
## Wire CI
|
|
139
|
+
|
|
140
|
+
Backend CI (the important one): run oasdiff on the previous released spec and the new one and block the merge.
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
oasdiff breaking specs/1.6.0.json new-spec.json --fail-on ERR
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Client CI:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npm ci
|
|
150
|
+
pnpm codegen # fails on a breaking pair, bad config, or missing floor spec
|
|
151
|
+
git diff --exit-code src/generated # if generated code is committed
|
|
152
|
+
pnpm typecheck && npm test
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`pnpm check:breaking` runs only the gate and writes nothing.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Reference
|
|
2
|
+
|
|
3
|
+
## Config
|
|
4
|
+
|
|
5
|
+
File: `openapi-versions.config.json`. JSON Schema: [`openapi-versions.schema.json`](../openapi-versions.schema.json); add `"$schema": "./openapi-versions.schema.json"` for editor autocompletion. The CLI also validates it and reports what to fix.
|
|
6
|
+
|
|
7
|
+
| Key | Type | Meaning |
|
|
8
|
+
| ------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
9
|
+
| `specsDir` | string | Folder of `<semver>.json` specs. Other file names are ignored. |
|
|
10
|
+
| `outDir` | string | Generated output folder. Safe to delete and regenerate. |
|
|
11
|
+
| `majors` | object | Keyed by major number (`"1"`). Normally one entry per app release. |
|
|
12
|
+
| `majors.<n>.floor` | plain semver | Oldest supported minor of major `n`. Must be in major `n` and must have a spec file. |
|
|
13
|
+
| `gate` | `"auto"` \| `"required"` \| `"off"`, optional | Breaking-change gate. `auto` (default): run oasdiff if installed, otherwise warn and skip. `required`: fail when oasdiff is missing or older than 1.34.0. `off`: never run it. |
|
|
14
|
+
| `runtimeImport` | string, optional | Module the generated `client.ts` imports the runtime from. Default `openapi-versions`. A relative path when developing the runtime in the same repo. |
|
|
15
|
+
|
|
16
|
+
## CLI
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
openapi-versions [--config <path>] [--check-only] [--require-gate]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Flag | Effect |
|
|
23
|
+
| ---------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `--config` | Config path. Default `openapi-versions.config.json`. |
|
|
25
|
+
| `--check-only` | Run only the oasdiff gate; write nothing. Fails if oasdiff is missing. |
|
|
26
|
+
| `--require-gate` | Fail when oasdiff is missing or too old, instead of skipping the gate. Same as `"gate": "required"`. Use in CI. |
|
|
27
|
+
|
|
28
|
+
Exit code 1 on: invalid config, missing floor spec, any breaking pair, and (with `--require-gate`, `"gate": "required"` or `--check-only`) a missing or too-old oasdiff (minimum 1.34.0). Environment: `OASDIFF_BIN` overrides the oasdiff binary.
|
|
29
|
+
|
|
30
|
+
## Package entry points
|
|
31
|
+
|
|
32
|
+
| Import | Content |
|
|
33
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
34
|
+
| `openapi-versions` | Runtime: `createVersionAware(meta)` (core + React), plus the core exports |
|
|
35
|
+
| `openapi-versions/core` | Framework-free runtime: `resolveCompat`, `createApi`, `createApiClient`, `fetchBackendVersion`, `createVersionAwareCore(meta)` |
|
|
36
|
+
| `openapi-versions/codegen` | Programmatic codegen API |
|
|
37
|
+
| `openapi-versions/schema.json` | JSON Schema of the config |
|
|
38
|
+
| `npx openapi-versions` | The CLI |
|
|
39
|
+
|
|
40
|
+
Apps rarely call `createVersionAware` themselves: the generated `client.ts` does, with the app's types. Peers: `openapi-typescript`, `openapi-fetch`, and optionally `openapi-react-query`, `@tanstack/react-query`, `react`.
|
|
41
|
+
|
|
42
|
+
In this repo: `pnpm codegen`, `pnpm check:breaking`, `pnpm build` (bundles with tsup into `dist/`).
|
|
43
|
+
|
|
44
|
+
## Programmatic API
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { build, loadConfig, mergeAll, computeSince, checkChain, openEnums, BreakingChangeError } from "openapi-versions/codegen";
|
|
48
|
+
|
|
49
|
+
const report = await build(loadConfig("openapi-versions.config.json"), { checkOnly: false, log: console.log });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Export | Purpose |
|
|
53
|
+
| ------------------------------------------ | ------------------------------------------------------------------------------------ |
|
|
54
|
+
| `build(config, { checkOnly?, log? })` | Whole pipeline. Throws `BreakingChangeError` (with `.report`) if a pair is breaking. |
|
|
55
|
+
| `loadConfig(path)` / `validateConfig(obj)` | Read and validate config. |
|
|
56
|
+
| `mergeAll(docs)` / `mergeTolerant(a, b)` | Tolerant merge of OpenAPI documents. |
|
|
57
|
+
| `computeSince(docs, floor)` | `{ ops, gatedOps, gatedFields }` |
|
|
58
|
+
| `checkChain(files)` / `checkPair(a, b)` | oasdiff gate. |
|
|
59
|
+
| `openEnums` | openapi-typescript `transform` hook for `x-extensible-enum`. |
|
|
60
|
+
|
|
61
|
+
## Pipeline, per major
|
|
62
|
+
|
|
63
|
+
1. Select specs with the same major and version `>=` floor. The floor spec must exist.
|
|
64
|
+
2. `oasdiff breaking <prev> <next> --fail-on ERR` for each consecutive pair.
|
|
65
|
+
3. Tolerant merge (rules below). `info.version` of the merged doc becomes `"<floor> - <newest>"`.
|
|
66
|
+
4. Since maps.
|
|
67
|
+
5. `openapi-typescript` on the merged doc with the `openEnums` transform.
|
|
68
|
+
|
|
69
|
+
## Merge rules
|
|
70
|
+
|
|
71
|
+
| Element | Rule |
|
|
72
|
+
| ----------------------------------------- | --------------------------------------------------------- |
|
|
73
|
+
| paths, operations, properties, parameters | union |
|
|
74
|
+
| `required` array on a schema | intersection (required only if every version requires it) |
|
|
75
|
+
| `required` boolean on a parameter or body | AND |
|
|
76
|
+
| parameter present in only some versions | never required (path params stay required) |
|
|
77
|
+
| `enum`, primitive arrays | union of values, order kept |
|
|
78
|
+
| everything else (descriptions, formats …) | newest wins |
|
|
79
|
+
|
|
80
|
+
The inputs must be non-breaking relative to each other (step 2); otherwise the union can describe a shape no server ever had.
|
|
81
|
+
|
|
82
|
+
## Since maps
|
|
83
|
+
|
|
84
|
+
- `gatedOps`: `"<method> <path>" → first version that has it`, only for versions above the floor.
|
|
85
|
+
- `gatedFields`: `"<Schema>.<property>" → first version`, only for properties added to a schema that already existed in an earlier version (fields born with their schema are covered by the operation that returns it).
|
|
86
|
+
- `ops`: every operation key in the merged contract.
|
|
87
|
+
|
|
88
|
+
## Generated files
|
|
89
|
+
|
|
90
|
+
| File | Content |
|
|
91
|
+
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
92
|
+
| `<outDir>/v<n>/schema.d.ts` | openapi-typescript types of the merged contract; extensible enums are open unions |
|
|
93
|
+
| `<outDir>/v<n>/meta.ts` | `meta = { major, floor, newest, specs, ops, gatedOps, gatedFields }` plus `Op`, `GatedOp`, `GatedField` types |
|
|
94
|
+
| `<outDir>/v<n>/merged.openapi.json` | the merged document (useful to inspect or diff) |
|
|
95
|
+
| `<outDir>/v<n>/client.ts` | the runtime bound to this major: `ApiVersionProvider`, `useApi`, `useSupports`, `useHasField`, `useSunset`, `Feature`, `createApi`, types `Paths`, `GatedOp`, `GatedField`, `Api` |
|
|
96
|
+
| `<outDir>/index.ts` | `majors`, `SupportedMajor`, and `PathsV<n>`, `GatedOpV<n>`, `GatedFieldV<n>` aliases |
|
|
97
|
+
|
|
98
|
+
## Runtime API
|
|
99
|
+
|
|
100
|
+
Exported from the generated `client.ts` (bound to your types) and, unbound, from `openapi-versions` / `openapi-versions/core`:
|
|
101
|
+
|
|
102
|
+
| Symbol | Meaning |
|
|
103
|
+
| ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
104
|
+
| `resolveCompat(version, majors, { prereleaseAsRelease? })` | Returns `{ status: "ok", version, major, backendAhead, supports(op), hasField(field) }` or `{ status: "backend-too-old", floor }`, `{ status: "app-too-old", supportedMajors }`, `{ status: "invalid-version" }` |
|
|
105
|
+
| `createApi({ baseUrl, fetch?, prereleaseAsRelease? })` (unbound: `createApi(meta, opts)`) | Reads `<baseUrl>/version`, returns `Api`; throws if not `ok` |
|
|
106
|
+
| `Api` | `{ compat, client: { core, gated }, $api, $gated, gate() }` |
|
|
107
|
+
| `$api` | openapi-react-query client typed **without** gated operations |
|
|
108
|
+
| `$gated` | `{ queryOptions, useMutation }` typed with everything |
|
|
109
|
+
| `gate(options)` | Replaces `queryFn` with `skipToken` when the backend lacks the operation |
|
|
110
|
+
| `createApiClient({ baseUrl, fetch?, onVersionHeader?, onSunsetHeader? })` | The single openapi-fetch client; middleware reads `X-Api-Version` and `Sunset` |
|
|
111
|
+
|
|
112
|
+
React part:
|
|
113
|
+
|
|
114
|
+
| Symbol | Meaning |
|
|
115
|
+
| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
116
|
+
| `<ApiVersionProvider baseUrl fetch? loading? renderBlocked? prereleaseAsRelease?>` | Fetches `/version` (React Query key `["api-version"]`, stale 5 min, refetch on focus), renders `renderBlocked(compat)` when not `ok`, re-evaluates when `X-Api-Version` changes |
|
|
117
|
+
| `useApi()` | The `Api` object; throws outside the provider |
|
|
118
|
+
| `useSupports(op)` / `useHasField(field)` | Booleans |
|
|
119
|
+
| `<Feature requires fallback?>` | Renders children only if the operation is supported |
|
|
120
|
+
| `useSunset()` | `Date | undefined`from the`Sunset` header |
|
|
121
|
+
|
|
122
|
+
## Backend contract
|
|
123
|
+
|
|
124
|
+
- Additive changes only inside a major (oasdiff gate).
|
|
125
|
+
- Growable enums declared `x-extensible-enum`.
|
|
126
|
+
- Each major under its own prefix; `GET <prefix>/version` → `{ "version": "<semver>" }`; `X-Api-Version` on every response; real semver.
|
|
127
|
+
- `/api/v1/version` reports the version of the v1 **contract**, not of the deployment; after v2 ships, v1 is frozen (patch versions only).
|
|
128
|
+
- A major being retired sends `Sunset: <HTTP-date>` (RFC 8594) on all its responses.
|
|
129
|
+
- Each release publishes its spec as a CI artifact from the release commit.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
| Symptom | Cause | Fix |
|
|
4
|
+
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
5
|
+
| `Breaking-change gate cannot run: oasdiff not found` (or `WARN breaking-change gate skipped`) | oasdiff not on `PATH`, or older than 1.34.0 | Install it (`brew install oasdiff`) or set `OASDIFF_BIN=/abs/path/oasdiff`. The error only appears with `"gate": "required"`, `--require-gate` or `--check-only`; otherwise codegen warns and continues |
|
|
6
|
+
| `Floor 1.3.0 has no spec file (oldest found: 1.4.0)` | The floor needs its own `specs/1.3.0.json` | Add the spec or lower/raise the floor to an existing one |
|
|
7
|
+
| `No spec found for major 1 at or above floor 1.3.0` | Wrong `specsDir`, or files not named `<semver>.json` | Check the directory and file names; non-semver names are ignored |
|
|
8
|
+
| `config: majors.1.floor 2.0.0 is not in major 1` | Floor belongs to another major | Fix the floor or the major key |
|
|
9
|
+
| `FAIL v1: a -> b` | A spec pair is not additive | [How-to: breaking-change failure](how-to.md#handle-a-breaking-change-failure) |
|
|
10
|
+
| oasdiff reports `response-property-enum-value-added` | A closed `enum` gained a value | Declare it `x-extensible-enum` (see how-to) or move it to a new major |
|
|
11
|
+
| Calling a newer operation through `$api` does not compile | By design: it exists only on `$gated` | Use `$gated` with `gate()` or `<Feature>` |
|
|
12
|
+
| `useSuspenseQuery(gate(...))` does not compile | A gated query may be skipped | Use `useQuery` |
|
|
13
|
+
| Enum type collapses to `string` and loses autocompletion in response data | `openapi-fetch`'s `Readable<T>` maps over `string & {}` | Apply the one-line patch in `patches/` to `openapi-typescript-helpers` with `patch-package` (add `"postinstall": "patch-package"` to your app). Until it is fixed upstream |
|
|
14
|
+
| App says "app too old" for a date-style version such as `2026.10.1` | Valid semver with major 2026 | The backend must report real `major.minor.patch` |
|
|
15
|
+
| `GET /version failed: 404` | `/version` resolved from the host root | `baseUrl` must include the major prefix; `/version` is read relative to it (`https://host/api/v1/version`) |
|
|
16
|
+
| Endless refetching after a deploy | Version header seen again before React re-rendered | The provider records the new version synchronously; do not clone this logic without that |
|
|
17
|
+
| Staging on `1.6.0-rc.1` hides 1.6 features | A prerelease sorts before its release | Pass `prereleaseAsRelease` to `ApiVersionProvider` / `createApi` for staging builds |
|
|
18
|
+
| A gated field is `undefined` on a new backend | Fields added after the floor are typed optional and may be absent | Use `hasField()` and handle absence |
|
|
19
|
+
| `src/generated` missing, imports fail | Codegen not run | `pnpm codegen` |
|
|
20
|
+
| `Cannot read properties of undefined (reading 'createKeywordTypeNode')` when generating | openapi-typescript 7 needs the TypeScript 5 or 6 JS compiler API; TypeScript 7 (native, now `latest` on npm) does not expose it (upstream #2841) | Install `typescript@<7` (5 or 6) |
|
|
21
|
+
| `Cannot find module 'openapi-versions'` from a generated `client.ts` | Runtime not installed, or `runtimeImport` wrong | `npm i openapi-versions`, or set `runtimeImport` in the config |
|