@erenthedeveloper0/zen-openapi 0.1.0-alpha.1
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 +9 -0
- package/README.md +76 -0
- package/dist/diff.d.ts +37 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +320 -0
- package/dist/diff.js.map +1 -0
- package/dist/document.d.ts +75 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +689 -0
- package/dist/document.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +29 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +84 -0
- package/dist/plugin.js.map +1 -0
- package/dist/schema.d.ts +103 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +434 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +154 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +19 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +192 -0
- package/dist/ui.js.map +1 -0
- package/package.json +65 -0
- package/src/diff.ts +439 -0
- package/src/document.ts +882 -0
- package/src/index.ts +20 -0
- package/src/plugin.ts +135 -0
- package/src/schema.ts +497 -0
- package/src/types.ts +166 -0
- package/src/ui.ts +198 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@erenthedeveloper0/zen-openapi` — `AppGraph → OpenAPI 3.1`, plus breaking-change detection.
|
|
3
|
+
*
|
|
4
|
+
* rfcs/0001 §29. Depends on `@erenthedeveloper0/zen-core` and nothing else — in particular not
|
|
5
|
+
* on the router, which is why param-type schemas arrive through the frozen
|
|
6
|
+
* graph rather than through a second copy of the built-in table (§24.3).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { openapiDocument, pathVariants, type OpenApiOptions, type OpenApiResult, type PathVariant } from './document.ts'
|
|
10
|
+
export { openapiPlugin, type OpenApiPluginOptions } from './plugin.ts'
|
|
11
|
+
export { renderReference } from './ui.ts'
|
|
12
|
+
export {
|
|
13
|
+
diffDocuments, type ApiChange, type ChangeKind, type DiffResult,
|
|
14
|
+
} from './diff.ts'
|
|
15
|
+
export {
|
|
16
|
+
Components, canonical, declaredName, isObjectShape, projectSchema, sanitizeName,
|
|
17
|
+
type DocDiagnostic, type ProjectOptions,
|
|
18
|
+
} from './schema.ts'
|
|
19
|
+
export type * from './types.ts'
|
|
20
|
+
export { DOC_META_KEYS } from './types.ts'
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { AppGraph, Diagnostic, Plugin } from '@erenthedeveloper0/zen-core'
|
|
2
|
+
import { BootError, definePlugin } from '@erenthedeveloper0/zen-core'
|
|
3
|
+
import { openapiDocument, type OpenApiOptions, type OpenApiResult } from './document.ts'
|
|
4
|
+
import type { DocDiagnostic } from './schema.ts'
|
|
5
|
+
import { renderReference } from './ui.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Serve the generated document — rfcs/0001 §29.2.
|
|
9
|
+
*
|
|
10
|
+
* The document is built **once, in `onBoot`, from the frozen graph**, and what
|
|
11
|
+
* the endpoint serves is a pre-encoded string. Nothing here runs per request
|
|
12
|
+
* except an ETag comparison: the documentation costs one string in memory and
|
|
13
|
+
* zero work on the hot path, which is the whole reason it is architecture (§29.1)
|
|
14
|
+
* rather than a middleware that walks a registry.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface OpenApiPluginOptions extends OpenApiOptions {
|
|
18
|
+
/** Where the JSON document is served. `false` disables the endpoint. */
|
|
19
|
+
readonly json?: string | false | undefined
|
|
20
|
+
/** Where the reference viewer is served. `false` disables it. */
|
|
21
|
+
readonly ui?: string | false | undefined
|
|
22
|
+
/**
|
|
23
|
+
* Turn generator warnings into a boot failure.
|
|
24
|
+
*
|
|
25
|
+
* Worth switching on in CI once an API is public: "this route has no response
|
|
26
|
+
* schema" is a documentation hole *and* a missing serializer contract, and the
|
|
27
|
+
* cheapest moment to notice either is before the process starts.
|
|
28
|
+
*/
|
|
29
|
+
readonly strict?: boolean | undefined
|
|
30
|
+
/** Receives the finished document — how `zen openapi --out` will write it. */
|
|
31
|
+
readonly onDocument?: ((result: OpenApiResult) => void | Promise<void>) | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Served {
|
|
35
|
+
result: OpenApiResult | null
|
|
36
|
+
json: string
|
|
37
|
+
etag: string
|
|
38
|
+
html: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const openapiPlugin: Plugin<OpenApiPluginOptions, {}> = definePlugin<OpenApiPluginOptions, {}>({
|
|
42
|
+
name: 'openapi',
|
|
43
|
+
version: '0.1.0',
|
|
44
|
+
|
|
45
|
+
setup(app, options) {
|
|
46
|
+
const jsonPath = options.json === undefined ? '/openapi.json' : options.json
|
|
47
|
+
const uiPath = options.ui === undefined ? '/docs' : options.ui
|
|
48
|
+
const served: Served = { result: null, json: '{}', etag: '', html: '' }
|
|
49
|
+
|
|
50
|
+
if (jsonPath !== false) {
|
|
51
|
+
app.route({
|
|
52
|
+
method: 'GET',
|
|
53
|
+
path: jsonPath,
|
|
54
|
+
name: 'openapi.json',
|
|
55
|
+
// Excluded from the document it serves. A docs endpoint that documents
|
|
56
|
+
// itself is noise in every generated client.
|
|
57
|
+
meta: { hidden: true },
|
|
58
|
+
handler: (ctx: DocsContext) => {
|
|
59
|
+
if (ctx.headers['if-none-match'] === served.etag) return ctx.empty(304)
|
|
60
|
+
return ctx.text(served.json, {
|
|
61
|
+
media: 'application/json; charset=utf-8',
|
|
62
|
+
headers: { etag: served.etag, 'cache-control': 'no-cache' },
|
|
63
|
+
})
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (uiPath !== false) {
|
|
69
|
+
app.route({
|
|
70
|
+
method: 'GET',
|
|
71
|
+
path: uiPath,
|
|
72
|
+
name: 'openapi.ui',
|
|
73
|
+
meta: { hidden: true },
|
|
74
|
+
handler: (ctx: DocsContext) => ctx.html(served.html),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
app.onBoot(async (graph) => {
|
|
79
|
+
const result = openapiDocument(graph as AppGraph, options)
|
|
80
|
+
served.result = result
|
|
81
|
+
served.json = JSON.stringify(result.document, null, 2)
|
|
82
|
+
served.etag = weakEtag(served.json)
|
|
83
|
+
served.html = uiPath === false
|
|
84
|
+
? ''
|
|
85
|
+
: renderReference(result.document, { jsonPath: jsonPath === false ? null : jsonPath })
|
|
86
|
+
|
|
87
|
+
if (options.strict === true && result.diagnostics.length > 0) {
|
|
88
|
+
throw new BootError(result.diagnostics.map(toBootDiagnostic))
|
|
89
|
+
}
|
|
90
|
+
await options.onDocument?.(result)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
exports: {
|
|
95
|
+
/** The finished document, once `ready()` has run. */
|
|
96
|
+
document: () => served.result?.document ?? null,
|
|
97
|
+
diagnostics: () => served.result?.diagnostics ?? [],
|
|
98
|
+
json: () => served.json,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
/** Only the two accessors the handlers use — the plugin needs no decorations. */
|
|
105
|
+
interface DocsContext {
|
|
106
|
+
readonly headers: Readonly<Record<string, string | undefined>>
|
|
107
|
+
empty(status?: 204 | 205 | 304): unknown
|
|
108
|
+
text(body: string, init?: { media?: string; headers?: Record<string, string> }): unknown
|
|
109
|
+
html(body: string): unknown
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toBootDiagnostic(diagnostic: DocDiagnostic): Diagnostic {
|
|
113
|
+
return {
|
|
114
|
+
severity: diagnostic.severity === 'info' ? 'warning' : diagnostic.severity,
|
|
115
|
+
code: diagnostic.code,
|
|
116
|
+
message: diagnostic.message,
|
|
117
|
+
hint: diagnostic.hint,
|
|
118
|
+
locations: [diagnostic.where],
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* FNV-1a over the document string. Weak, and correctly labelled `W/`: the
|
|
124
|
+
* document is regenerated on every boot and two boots of the same code produce
|
|
125
|
+
* byte-identical output (every map is emitted sorted), so this is a content
|
|
126
|
+
* hash, not a resource version.
|
|
127
|
+
*/
|
|
128
|
+
function weakEtag(body: string): string {
|
|
129
|
+
let hash = 0x811c9dc5
|
|
130
|
+
for (let i = 0; i < body.length; i++) {
|
|
131
|
+
hash ^= body.charCodeAt(i)
|
|
132
|
+
hash = Math.imul(hash, 0x01000193)
|
|
133
|
+
}
|
|
134
|
+
return `W/"${body.length.toString(36)}-${(hash >>> 0).toString(36)}"`
|
|
135
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import type { JsonSchema, JsonSchemaNode, JsonType } from '@erenthedeveloper0/zen-core'
|
|
2
|
+
import { resolveRef } from '@erenthedeveloper0/zen-core'
|
|
3
|
+
import type { OpenApiSchema } from './types.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* JSON Schema → OpenAPI 3.1 Schema Object — rfcs/0001 §29.2, §29.3.
|
|
7
|
+
*
|
|
8
|
+
* Two things happen here, and only one of them is mechanical.
|
|
9
|
+
*
|
|
10
|
+
* The mechanical part is hoisting `$defs` into `components.schemas`, rewriting
|
|
11
|
+
* local `$ref`s, and up-converting the OpenAPI 3.0 spellings (`nullable`) that
|
|
12
|
+
* real converters still emit.
|
|
13
|
+
*
|
|
14
|
+
* The load-bearing part is **closure**. Zen's serializer inverts JSON Schema's
|
|
15
|
+
* default: an object with no `additionalProperties` drops undeclared keys rather
|
|
16
|
+
* than passing them through (§13.3.1). A document generated from the raw schema
|
|
17
|
+
* would therefore describe an endpoint that permits fields the wire will never
|
|
18
|
+
* carry — documentation that is wrong in the one direction that matters, since a
|
|
19
|
+
* client author would write code to read a field that can never arrive. So
|
|
20
|
+
* response schemas are projected *closed*, mirroring `serializer-ir.ts` node for
|
|
21
|
+
* node, and `test/openapi.test.ts` proves the two agree by running values
|
|
22
|
+
* through the real compiled serializer.
|
|
23
|
+
*
|
|
24
|
+
* Request schemas are **not** closed. There the validator is the authority, the
|
|
25
|
+
* validator is the user's Standard Schema library, and its converter's output is
|
|
26
|
+
* the honest description of what it accepts.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface DocDiagnostic {
|
|
30
|
+
readonly severity: 'error' | 'warning' | 'info'
|
|
31
|
+
readonly code: string
|
|
32
|
+
readonly message: string
|
|
33
|
+
/** Where the problem is, in human terms: `GET /users/:id → response 200`. */
|
|
34
|
+
readonly where: string
|
|
35
|
+
readonly hint?: string | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
// Component registry — §29.3
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Deduplication runs in three passes, cheapest and most authoritative first:
|
|
44
|
+
* schema *identity* (the same imported object used by four routes is one
|
|
45
|
+
* component), then a *declared* name (`$id` / `title`), then *structural*
|
|
46
|
+
* equality. The third pass is a backstop, not the strategy — two schemas that
|
|
47
|
+
* happen to have the same shape today are not necessarily the same concept, so
|
|
48
|
+
* structural merging only ever fires for schemas that had no name to begin with.
|
|
49
|
+
*/
|
|
50
|
+
export class Components {
|
|
51
|
+
readonly #byName = new Map<string, OpenApiSchema>()
|
|
52
|
+
readonly #byKey = new Map<string, string>()
|
|
53
|
+
/** `name + shape` → the name that owns it. What makes two arrivals one component. */
|
|
54
|
+
readonly #byIdentity = new Map<string, string>()
|
|
55
|
+
/** A name that turned out to duplicate another, and the name it defers to. */
|
|
56
|
+
readonly #aliases = new Map<string, string>()
|
|
57
|
+
readonly #reserved = new Set<string>()
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Claim a name for a shape that is already projected.
|
|
61
|
+
*
|
|
62
|
+
* `key` is the identity the caller wants deduplicated on, and it is a
|
|
63
|
+
* parameter rather than always the shape for a reason: two *named* schemas
|
|
64
|
+
* that happen to look alike today are not the same concept, so the caller
|
|
65
|
+
* folds the name into the key and they stay separate. Anonymous schemas pass
|
|
66
|
+
* the shape and do collapse. Different keys wanting the same name get `Name2`.
|
|
67
|
+
*/
|
|
68
|
+
claim(preferred: string, schema: OpenApiSchema, key: string, variant?: string): string {
|
|
69
|
+
const existing = this.#byKey.get(key)
|
|
70
|
+
if (existing !== undefined) return existing
|
|
71
|
+
|
|
72
|
+
const identity = identityOf(preferred, schema)
|
|
73
|
+
const owner = this.#byIdentity.get(identity)
|
|
74
|
+
if (owner !== undefined) {
|
|
75
|
+
this.#byKey.set(key, owner)
|
|
76
|
+
return owner
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const name = this.#free(preferred, variant)
|
|
80
|
+
this.#reserved.add(name)
|
|
81
|
+
this.#byName.set(name, schema)
|
|
82
|
+
this.#byKey.set(key, name)
|
|
83
|
+
this.#byIdentity.set(identity, name)
|
|
84
|
+
return name
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reserve a name for a shape that cannot be projected yet — a `$def` whose body
|
|
89
|
+
* may refer back to itself, or refer to a sibling def not yet visited. Keyed on
|
|
90
|
+
* the *raw* schema so recursion terminates.
|
|
91
|
+
*
|
|
92
|
+
* The reservation is provisional: the same concept can arrive twice, once as a
|
|
93
|
+
* converter's `$defs` entry and once as an inline titled object, and which one
|
|
94
|
+
* is seen first depends on route order. `fill` reconciles that.
|
|
95
|
+
*/
|
|
96
|
+
reserve(preferred: string, rawKey: string, variant?: string): { readonly name: string; readonly fresh: boolean } {
|
|
97
|
+
const existing = this.#byKey.get(rawKey)
|
|
98
|
+
if (existing !== undefined) return { name: existing, fresh: false }
|
|
99
|
+
const name = this.#free(preferred, variant)
|
|
100
|
+
this.#reserved.add(name)
|
|
101
|
+
this.#byKey.set(rawKey, name)
|
|
102
|
+
return { name, fresh: true }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Attach a body to a reserved name — and collapse it onto an existing
|
|
107
|
+
* component if it turns out to be the same concept under the same name.
|
|
108
|
+
*
|
|
109
|
+
* This is the fix for a bug the first real application found: Zod hoists a
|
|
110
|
+
* schema carrying an `id` into `$defs` when it is nested, and emits it inline
|
|
111
|
+
* when it is not, so `PublicUser` arrived by both routes and the document
|
|
112
|
+
* published `PublicUser` *and* `PublicUser2`. Two names for one type in a
|
|
113
|
+
* generated client is exactly the drift this subsystem exists to prevent.
|
|
114
|
+
*/
|
|
115
|
+
fill(name: string, schema: OpenApiSchema): void {
|
|
116
|
+
const identity = identityOf(name, schema)
|
|
117
|
+
const owner = this.#byIdentity.get(identity)
|
|
118
|
+
if (owner !== undefined && owner !== name) {
|
|
119
|
+
this.#aliases.set(name, owner)
|
|
120
|
+
this.#reserved.delete(name)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
this.#byIdentity.set(identity, name)
|
|
124
|
+
this.#byName.set(name, schema)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `duplicate → canonical`, for the final `$ref` rewrite. Empty in the common case. */
|
|
128
|
+
aliases(): ReadonlyMap<string, string> {
|
|
129
|
+
return this.#aliases
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
has(name: string): boolean {
|
|
133
|
+
return this.#byName.has(name)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get size(): number {
|
|
137
|
+
return this.#byName.size
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Sorted, because a document that reorders itself between runs cannot be diffed. */
|
|
141
|
+
toRecord(): Record<string, OpenApiSchema> {
|
|
142
|
+
const out: Record<string, OpenApiSchema> = {}
|
|
143
|
+
for (const name of [...this.#byName.keys()].sort()) {
|
|
144
|
+
if (this.#aliases.has(name)) continue
|
|
145
|
+
out[name] = this.#byName.get(name) as OpenApiSchema
|
|
146
|
+
}
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `variant` is tried before the numeric fallback, and it exists for one very
|
|
152
|
+
* common case: the same named schema appears on both sides of the API, and the
|
|
153
|
+
* two are not identical — a converter describes a request body as it is
|
|
154
|
+
* *received* (extra keys tolerated) and a response as it is *emitted* (extra
|
|
155
|
+
* keys impossible, §13.3.1).
|
|
156
|
+
*
|
|
157
|
+
* They are genuinely two schemas, so they must be two components. But
|
|
158
|
+
* `OrderLine` and `OrderLineInput` tell a client author which is which, and
|
|
159
|
+
* `OrderLine` and `OrderLine2` do not.
|
|
160
|
+
*/
|
|
161
|
+
#free(preferred: string, variant?: string): string {
|
|
162
|
+
const base = sanitizeName(preferred)
|
|
163
|
+
if (!this.#reserved.has(base)) return base
|
|
164
|
+
if (variant !== undefined) {
|
|
165
|
+
const varied = `${base}${variant}`
|
|
166
|
+
if (!this.#reserved.has(varied)) return varied
|
|
167
|
+
}
|
|
168
|
+
for (let n = 2; ; n++) {
|
|
169
|
+
const candidate = `${base}${n}`
|
|
170
|
+
if (!this.#reserved.has(candidate)) return candidate
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A component's identity: its name plus its *shape*, with the top-level
|
|
177
|
+
* annotations removed.
|
|
178
|
+
*
|
|
179
|
+
* `title` and `description` are stripped because converters do not emit them
|
|
180
|
+
* consistently between an inline occurrence and a `$defs` entry for the very
|
|
181
|
+
* same schema — and two schemas with the same name and the same structure are
|
|
182
|
+
* one type whichever prose happens to be attached. Nested annotations are left
|
|
183
|
+
* alone; they are part of the shape as far as a client generator is concerned.
|
|
184
|
+
*/
|
|
185
|
+
function identityOf(name: string, schema: OpenApiSchema): string {
|
|
186
|
+
const { title: _title, description: _description, ...rest } = schema as Record<string, unknown>
|
|
187
|
+
return `${sanitizeName(name)}::${canonical(rest)}`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const NAME_ILLEGAL = /[^A-Za-z0-9_.-]/g
|
|
191
|
+
|
|
192
|
+
export function sanitizeName(raw: string): string {
|
|
193
|
+
const cleaned = raw.replace(NAME_ILLEGAL, '_').replace(/^_+|_+$/g, '')
|
|
194
|
+
return cleaned.length > 0 ? cleaned : 'Schema'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Key-sorted JSON. Used for structural identity, so it must be stable across
|
|
199
|
+
* runs and across property insertion order — `{a,b}` and `{b,a}` are one shape.
|
|
200
|
+
*/
|
|
201
|
+
export function canonical(value: unknown): string {
|
|
202
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
|
|
203
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
204
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
205
|
+
.filter(([, v]) => v !== undefined)
|
|
206
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
207
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
211
|
+
// Projection
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
export interface ProjectOptions {
|
|
215
|
+
readonly components: Components
|
|
216
|
+
/** True for responses: apply Zen's drop-by-default (§13.3.1). */
|
|
217
|
+
readonly closed: boolean
|
|
218
|
+
readonly diagnostics: DocDiagnostic[]
|
|
219
|
+
readonly where: string
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Keywords handled structurally; everything else is copied through verbatim. */
|
|
223
|
+
const STRUCTURAL = new Set([
|
|
224
|
+
'$ref', '$defs', 'definitions', '$schema', '$id',
|
|
225
|
+
'type', 'nullable', 'properties', 'required', 'additionalProperties',
|
|
226
|
+
'items', 'prefixItems', 'anyOf', 'oneOf', 'allOf',
|
|
227
|
+
])
|
|
228
|
+
|
|
229
|
+
export function projectSchema(raw: JsonSchema, options: ProjectOptions): OpenApiSchema {
|
|
230
|
+
return new Projector(raw, options).walk(raw, new Set(), 0)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
class Projector {
|
|
234
|
+
readonly #root: JsonSchema
|
|
235
|
+
readonly #opts: ProjectOptions
|
|
236
|
+
/** `#/$defs/User` → component name. Populated before the body is walked. */
|
|
237
|
+
readonly #refs = new Map<string, string>()
|
|
238
|
+
/** Name suffix for the open (request) side, so both sides can coexist. */
|
|
239
|
+
readonly #variant: string | undefined
|
|
240
|
+
|
|
241
|
+
constructor(root: JsonSchema, options: ProjectOptions) {
|
|
242
|
+
this.#root = root
|
|
243
|
+
this.#opts = options
|
|
244
|
+
this.#variant = options.closed ? undefined : 'Input'
|
|
245
|
+
this.#hoistDefs(root.$defs, '#/$defs/')
|
|
246
|
+
this.#hoistDefs(root.definitions, '#/definitions/')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#hoistDefs(defs: Readonly<Record<string, JsonSchemaNode>> | undefined, prefix: string): void {
|
|
250
|
+
if (defs === undefined) return
|
|
251
|
+
// Names are reserved for every def first, then bodies are projected. A
|
|
252
|
+
// recursive `$def` refers to itself through a name that must already exist.
|
|
253
|
+
const pending: Array<{ name: string; schema: JsonSchemaNode }> = []
|
|
254
|
+
for (const key of Object.keys(defs)) {
|
|
255
|
+
const schema = defs[key]
|
|
256
|
+
if (schema === undefined) continue
|
|
257
|
+
// The closure flag is part of the identity: the same raw schema projected
|
|
258
|
+
// open (a request) and closed (a response) is two different documents.
|
|
259
|
+
const rawKey = `def:${this.#opts.closed ? 'closed' : 'open'}:${canonical(schema)}`
|
|
260
|
+
const claimed = this.#opts.components.reserve(key, rawKey, this.#variant)
|
|
261
|
+
this.#refs.set(`${prefix}${key}`, claimed.name)
|
|
262
|
+
if (claimed.fresh) pending.push({ name: claimed.name, schema })
|
|
263
|
+
}
|
|
264
|
+
for (const job of pending) {
|
|
265
|
+
this.#opts.components.fill(job.name, this.walk(job.schema, new Set(), 0))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* `depth` exists for one rule: **a titled subschema becomes a component
|
|
271
|
+
* wherever it appears.**
|
|
272
|
+
*
|
|
273
|
+
* A response of `{ users: PublicUser[] }` should say `$ref: PublicUser`, not
|
|
274
|
+
* inline a second copy of the same object — otherwise a generated client
|
|
275
|
+
* grows `UserListUsersItem` alongside `PublicUser` and the two drift the first
|
|
276
|
+
* time someone edits one. A `title` is the author naming a type, so it is
|
|
277
|
+
* honoured at every level. Anonymous subschemas stay inline, because hoisting
|
|
278
|
+
* those would fill `components` with `Schema1..Schema40`.
|
|
279
|
+
*
|
|
280
|
+
* Depth 0 is exempt: the caller decides whether the *root* is hoisted, since
|
|
281
|
+
* only it knows how many operations share it (§29.3).
|
|
282
|
+
*/
|
|
283
|
+
walk(node: JsonSchema | boolean | undefined, seen: ReadonlySet<string>, depth: number): OpenApiSchema {
|
|
284
|
+
if (node === undefined || node === true) return {}
|
|
285
|
+
if (node === false) return { not: {} }
|
|
286
|
+
if (typeof node !== 'object') return {}
|
|
287
|
+
|
|
288
|
+
if (typeof node.$ref === 'string') return this.#ref(node.$ref, seen, depth)
|
|
289
|
+
|
|
290
|
+
const title = depth > 0 ? declaredName(node) : null
|
|
291
|
+
if (title !== null) {
|
|
292
|
+
const body = this.#body(node, seen, depth)
|
|
293
|
+
const name = this.#opts.components.claim(title, body, `named:${title}:${canonical(body)}`, this.#variant)
|
|
294
|
+
if (name !== title && name !== `${title}${this.#variant ?? ''}`) {
|
|
295
|
+
this.#opts.diagnostics.push({
|
|
296
|
+
severity: 'warning',
|
|
297
|
+
code: 'ZEN_OAS_TITLE_COLLISION',
|
|
298
|
+
message: `Two different schemas are both named "${title}"; this one was published as "${name}".`,
|
|
299
|
+
where: this.#opts.where,
|
|
300
|
+
hint: 'Give them distinct titles — a generated client names its types from these.',
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
return { $ref: `#/components/schemas/${name}` }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return this.#body(node, seen, depth)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#body(node: JsonSchema, seen: ReadonlySet<string>, depth: number): OpenApiSchema {
|
|
310
|
+
const out: Record<string, unknown> = {}
|
|
311
|
+
for (const key of Object.keys(node)) {
|
|
312
|
+
if (STRUCTURAL.has(key)) continue
|
|
313
|
+
const value = node[key]
|
|
314
|
+
if (value !== undefined) out[key] = value
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// `const` and `enum` short-circuit shape in the IR, so they do here too:
|
|
318
|
+
// closing an object that can only ever be one literal adds nothing.
|
|
319
|
+
if (node.const !== undefined || Array.isArray(node.enum)) {
|
|
320
|
+
this.#applyType(node, out)
|
|
321
|
+
return out as OpenApiSchema
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const allOf = node.allOf
|
|
325
|
+
if (Array.isArray(allOf) && allOf.length > 0) return this.#allOf(node, allOf, out, seen, depth)
|
|
326
|
+
|
|
327
|
+
const union = node.oneOf ?? node.anyOf
|
|
328
|
+
if (Array.isArray(union) && union.length > 0) {
|
|
329
|
+
const keyword = node.oneOf !== undefined ? 'oneOf' : 'anyOf'
|
|
330
|
+
out[keyword] = union.map((member) => this.walk(member, seen, depth + 1))
|
|
331
|
+
this.#applyType(node, out)
|
|
332
|
+
return out as OpenApiSchema
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this.#applyType(node, out)
|
|
336
|
+
|
|
337
|
+
if (isObjectShape(node)) this.#object(node, out, seen, depth)
|
|
338
|
+
else if (node.items !== undefined || node.prefixItems !== undefined) this.#array(node, out, seen, depth)
|
|
339
|
+
|
|
340
|
+
return out as OpenApiSchema
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** OAS 3.1 has no `nullable`; `type: [T, 'null']` is the same statement. */
|
|
344
|
+
#applyType(node: JsonSchema, out: Record<string, unknown>): void {
|
|
345
|
+
const declared = node.type
|
|
346
|
+
if (declared === undefined) {
|
|
347
|
+
if (node.nullable === true) out['type'] = 'null'
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
const types: readonly JsonType[] = Array.isArray(declared) ? declared : [declared]
|
|
351
|
+
const withNull = node.nullable === true && !types.includes('null') ? [...types, 'null' as JsonType] : types
|
|
352
|
+
out['type'] = withNull.length === 1 ? withNull[0] : withNull
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#object(node: JsonSchema, out: Record<string, unknown>, seen: ReadonlySet<string>, depth: number): void {
|
|
356
|
+
const properties = node.properties
|
|
357
|
+
if (properties !== undefined) {
|
|
358
|
+
const projected: Record<string, OpenApiSchema> = {}
|
|
359
|
+
for (const key of Object.keys(properties)) projected[key] = this.walk(properties[key], seen, depth + 1)
|
|
360
|
+
out['properties'] = projected
|
|
361
|
+
}
|
|
362
|
+
if (node.required !== undefined && node.required.length > 0) out['required'] = [...node.required]
|
|
363
|
+
|
|
364
|
+
const additional = node.additionalProperties
|
|
365
|
+
if (additional === undefined) {
|
|
366
|
+
// §13.3.1 — the one deliberate deviation, written down in the document
|
|
367
|
+
// rather than left as folklore. Absent means *drop*, so the document says
|
|
368
|
+
// `false` and a generated client will not offer a field that cannot arrive.
|
|
369
|
+
if (this.#opts.closed) out['additionalProperties'] = false
|
|
370
|
+
} else if (typeof additional === 'boolean') {
|
|
371
|
+
out['additionalProperties'] = additional
|
|
372
|
+
} else {
|
|
373
|
+
out['additionalProperties'] = this.walk(additional, seen, depth + 1)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#array(node: JsonSchema, out: Record<string, unknown>, seen: ReadonlySet<string>, depth: number): void {
|
|
378
|
+
if (Array.isArray(node.prefixItems)) {
|
|
379
|
+
out['prefixItems'] = node.prefixItems.map((item) => this.walk(item, seen, depth + 1))
|
|
380
|
+
// Draft 2020-12: with `prefixItems`, `items` constrains the tail. `false`
|
|
381
|
+
// is how a fixed-length tuple is spelled, and the IR reads it the same way.
|
|
382
|
+
out['items'] = node.items === undefined || node.items === false ? false : this.walk(node.items, seen, depth + 1)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
if (node.items !== undefined) out['items'] = this.walk(node.items, seen, depth + 1)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* `allOf` is an intersection, and the serializer resolves it by *merging* the
|
|
390
|
+
* members into one object (§13.3, `Builder#allOf`). The document must say the
|
|
391
|
+
* same thing — and there is a second reason beyond fidelity: an `allOf` of two
|
|
392
|
+
* closed objects is unsatisfiable, because every property of one member is
|
|
393
|
+
* "additional" to the other. Emitting the merge avoids publishing a schema
|
|
394
|
+
* that nothing can validate against.
|
|
395
|
+
*/
|
|
396
|
+
#allOf(
|
|
397
|
+
node: JsonSchema,
|
|
398
|
+
members: readonly JsonSchema[],
|
|
399
|
+
out: Record<string, unknown>,
|
|
400
|
+
seen: ReadonlySet<string>,
|
|
401
|
+
depth: number,
|
|
402
|
+
): OpenApiSchema {
|
|
403
|
+
if (!this.#opts.closed) {
|
|
404
|
+
out['allOf'] = members.map((member) => this.walk(member, seen, depth + 1))
|
|
405
|
+
this.#applyType(node, out)
|
|
406
|
+
return out as OpenApiSchema
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const resolved: JsonSchema[] = []
|
|
410
|
+
for (const member of members) {
|
|
411
|
+
const target = typeof member.$ref === 'string' ? resolveRef(this.#root, member.$ref) : member
|
|
412
|
+
if (target === null || !isObjectShape(target)) {
|
|
413
|
+
this.#opts.diagnostics.push({
|
|
414
|
+
severity: 'info',
|
|
415
|
+
code: 'ZEN_OAS_ALLOF_UNMERGED',
|
|
416
|
+
message: 'allOf member is not an object schema, so the document keeps allOf rather than merging it.',
|
|
417
|
+
where: this.#opts.where,
|
|
418
|
+
hint: 'The serializer reports the same case as a boot diagnostic; see rfcs/0001 §13.3.',
|
|
419
|
+
})
|
|
420
|
+
out['allOf'] = members.map((m) => this.walk(m, seen, depth + 1))
|
|
421
|
+
this.#applyType(node, out)
|
|
422
|
+
return out as OpenApiSchema
|
|
423
|
+
}
|
|
424
|
+
resolved.push(target)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const properties: Record<string, JsonSchemaNode> = {}
|
|
428
|
+
const required = new Set<string>()
|
|
429
|
+
let additional: JsonSchemaNode | undefined
|
|
430
|
+
for (const member of [...resolved, node]) {
|
|
431
|
+
for (const [key, value] of Object.entries(member.properties ?? {})) properties[key] = value
|
|
432
|
+
for (const key of member.required ?? []) required.add(key)
|
|
433
|
+
if (member.additionalProperties !== undefined) additional = member.additionalProperties
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const merged: JsonSchema = {
|
|
437
|
+
type: 'object',
|
|
438
|
+
properties,
|
|
439
|
+
...(required.size > 0 ? { required: [...required] } : {}),
|
|
440
|
+
...(additional !== undefined ? { additionalProperties: additional } : {}),
|
|
441
|
+
}
|
|
442
|
+
const projected = this.#body(merged, seen, depth) as Record<string, unknown>
|
|
443
|
+
return { ...out, ...projected } as OpenApiSchema
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
#ref(ref: string, seen: ReadonlySet<string>, depth: number): OpenApiSchema {
|
|
447
|
+
const hoisted = this.#refs.get(ref)
|
|
448
|
+
if (hoisted !== undefined) return { $ref: `#/components/schemas/${hoisted}` }
|
|
449
|
+
|
|
450
|
+
// Not a `$defs` entry: resolve and inline. A pointer into `properties` is
|
|
451
|
+
// legal JSON Schema and rare, but inlining beats emitting a `$ref` that
|
|
452
|
+
// points nowhere in the published document.
|
|
453
|
+
if (seen.has(ref)) {
|
|
454
|
+
this.#opts.diagnostics.push({
|
|
455
|
+
severity: 'warning',
|
|
456
|
+
code: 'ZEN_OAS_REF_CYCLE',
|
|
457
|
+
message: `Circular $ref "${ref}" outside $defs; the document inlines it as an unconstrained schema.`,
|
|
458
|
+
where: this.#opts.where,
|
|
459
|
+
hint: 'Move the recursive schema into $defs so it can be hoisted into components.schemas.',
|
|
460
|
+
})
|
|
461
|
+
return {}
|
|
462
|
+
}
|
|
463
|
+
const target = resolveRef(this.#root, ref)
|
|
464
|
+
if (target === null) {
|
|
465
|
+
this.#opts.diagnostics.push({
|
|
466
|
+
severity: 'warning',
|
|
467
|
+
code: 'ZEN_OAS_REF_UNRESOLVED',
|
|
468
|
+
message: `Unresolvable $ref "${ref}"; the document describes this position as unconstrained.`,
|
|
469
|
+
where: this.#opts.where,
|
|
470
|
+
hint: 'Only same-document refs are supported (#/$defs/Name, #/definitions/Name).',
|
|
471
|
+
})
|
|
472
|
+
return {}
|
|
473
|
+
}
|
|
474
|
+
return this.walk(target, new Set([...seen, ref]), depth)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Mirrors `Builder#byType`/`#inferShape`: when does the IR build an object node? */
|
|
479
|
+
export function isObjectShape(node: JsonSchema): boolean {
|
|
480
|
+
const declared = node.type
|
|
481
|
+
const types: readonly JsonType[] =
|
|
482
|
+
declared === undefined ? [] : Array.isArray(declared) ? declared : [declared]
|
|
483
|
+
if (types.includes('object')) return true
|
|
484
|
+
if (types.filter((t) => t !== 'null').length > 0) return false
|
|
485
|
+
return node.properties !== undefined || node.required !== undefined || node.additionalProperties !== undefined
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** A component name to suggest for a schema that carries one. */
|
|
489
|
+
export function declaredName(schema: JsonSchema): string | null {
|
|
490
|
+
const id = schema['$id']
|
|
491
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
492
|
+
const tail = id.split('/').pop()
|
|
493
|
+
if (tail !== undefined && tail.length > 0) return tail.replace(/\.json$/, '')
|
|
494
|
+
}
|
|
495
|
+
const title = schema.title
|
|
496
|
+
return typeof title === 'string' && title.length > 0 ? title : null
|
|
497
|
+
}
|