@znight/openapi 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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +572 -2
  3. package/dist/build-BTUC96mY.d.mts +174 -0
  4. package/dist/build-DXunOzlI.mjs +733 -0
  5. package/dist/build-DXunOzlI.mjs.map +1 -0
  6. package/dist/compiler.d.mts +16 -0
  7. package/dist/compiler.mjs +2639 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/emit-child.d.mts +1 -0
  10. package/dist/emit-child.mjs +124 -0
  11. package/dist/emit-child.mjs.map +1 -0
  12. package/dist/emit.d.mts +2 -0
  13. package/dist/emit.mjs +252 -0
  14. package/dist/emit.mjs.map +1 -0
  15. package/dist/index-CqQ6IRnN.d.mts +60 -0
  16. package/dist/index.d.mts +260 -0
  17. package/dist/index.mjs +777 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/openapi-CQk5dHqc.mjs +39 -0
  20. package/dist/openapi-CQk5dHqc.mjs.map +1 -0
  21. package/dist/shared-BZxtyaB0.mjs +147 -0
  22. package/dist/shared-BZxtyaB0.mjs.map +1 -0
  23. package/dist/testing.d.mts +39 -0
  24. package/dist/testing.mjs +155 -0
  25. package/dist/testing.mjs.map +1 -0
  26. package/dist/types-VzBexMKj.d.mts +213 -0
  27. package/dist/vite.d.mts +24 -0
  28. package/dist/vite.mjs +64 -0
  29. package/dist/vite.mjs.map +1 -0
  30. package/package.json +115 -4
  31. package/src/build.ts +898 -0
  32. package/src/compiler/analyze.ts +81 -0
  33. package/src/compiler/document.ts +612 -0
  34. package/src/compiler/generate.ts +79 -0
  35. package/src/compiler/index.ts +47 -0
  36. package/src/compiler/model.ts +141 -0
  37. package/src/compiler/operations.ts +1475 -0
  38. package/src/compiler/schemas.ts +706 -0
  39. package/src/compiler/validate.ts +102 -0
  40. package/src/compiler/values.ts +81 -0
  41. package/src/converters.ts +137 -0
  42. package/src/decorators.ts +187 -0
  43. package/src/emit/child.ts +144 -0
  44. package/src/emit/index.ts +377 -0
  45. package/src/emit/protocol.ts +35 -0
  46. package/src/index.ts +94 -0
  47. package/src/metadata.ts +112 -0
  48. package/src/openapi.ts +55 -0
  49. package/src/plugin.ts +810 -0
  50. package/src/shared.ts +154 -0
  51. package/src/testing.ts +224 -0
  52. package/src/types.ts +240 -0
  53. package/src/ui.ts +215 -0
  54. package/src/vite.ts +126 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,810 @@
1
+ /**
2
+ * `OpenApiPlugin` (§5.6, §8 of RFC 0002): serves the document (`/openapi.json`, optional YAML) and
3
+ * the API reference UI through the plugin routes of `@znight/http`, and provides the `OpenApi`
4
+ * external.
5
+ */
6
+ import type {
7
+ Container,
8
+ CoreLogger,
9
+ Plugin,
10
+ PluginContext,
11
+ PluginOptions,
12
+ PluginRootsContext,
13
+ ProviderKey,
14
+ ZnightMetadata,
15
+ } from '@znight/core'
16
+ import type {
17
+ GuardRef,
18
+ HttpContext,
19
+ HttpPlugin,
20
+ PluginHttpRoutes,
21
+ PluginHttpRoutesContext,
22
+ PluginRoute,
23
+ RouteMetaEntry,
24
+ } from '@znight/http'
25
+ import type { BuildOpenApiOptions, OpenApiOperationView } from './build.ts'
26
+ import type { SchemaConverter } from './converters.ts'
27
+ import type { InfoObject, JsonValue, OpenApiObject, ServerObject } from './types.ts'
28
+ import type { LocalPackage, UiKind } from './ui.ts'
29
+
30
+ import { createHash } from 'node:crypto'
31
+ import { readFile } from 'node:fs/promises'
32
+ import { fileURLToPath } from 'node:url'
33
+ import { ErrorCode, resolveOptions, ZnightError } from '@znight/core'
34
+ import { HttpResponse, NotFoundException } from '@znight/http'
35
+
36
+ import { applyDocumentOptions, buildOpenApiDocument, openApiSection, operationViews } from './build.ts'
37
+ import { OpenApi, OpenApiTokens, toYaml } from './openapi.ts'
38
+ import { findLocalPackage, renderUiPage, UI_PACKAGES } from './ui.ts'
39
+
40
+ /** The UI option: a kind, or a kind with its JSON-serialisable configuration. */
41
+ export type OpenApiUiOption =
42
+ | UiKind
43
+ | false
44
+ | { readonly scalar: Readonly<Record<string, JsonValue>> }
45
+ | { readonly swagger: Readonly<Record<string, JsonValue>> }
46
+
47
+ /** Options of {@link OpenApiPlugin.configure}. */
48
+ export interface OpenApiPluginOptions {
49
+ /** `false` serves nothing and builds nothing (the `OpenApi` external still works). Default `true`. */
50
+ readonly enabled?: boolean
51
+ /** Path of the JSON document, relative to the HTTP prefix. Default `'/openapi.json'`. */
52
+ readonly path?: string
53
+ /** A path (`'/openapi.yaml'`) also serves YAML (`application/yaml`, optional peer `yaml`). Default `false`. */
54
+ readonly yaml?: string | false
55
+ /** `'scalar'` (default), `'swagger'`, `false`, or `{ scalar: config }` / `{ swagger: config }`. */
56
+ readonly ui?: OpenApiUiOption
57
+ /** Path of the UI page. Local assets are under `${uiPath}/_assets/`. Default `'/docs'`. */
58
+ readonly uiPath?: string
59
+ /**
60
+ * `'local'` serves the UI package from `node_modules`, `'cdn'` the jsDelivr copy pinned by this
61
+ * release (with SRI), `'auto'` (default) local when the package is installed.
62
+ */
63
+ readonly assets?: 'auto' | 'local' | 'cdn'
64
+ /** `'build'` (default) assembles the document from the metadata; `{ file }` serves a prebuilt `openapi.json`. */
65
+ readonly document?: 'build' | { readonly file: string | URL }
66
+ /** `'boot'` (default): built in `onBootstrap` (a failure fails the boot); `'first-request'`: built lazily. */
67
+ readonly build?: 'boot' | 'first-request'
68
+ /** Merged over the static `info`. */
69
+ readonly info?: Partial<InfoObject>
70
+ /** Replaces the declared `servers`. */
71
+ readonly servers?: readonly ServerObject[]
72
+ readonly filter?: (operation: OpenApiOperationView) => boolean
73
+ readonly transform?: (document: OpenApiObject) => OpenApiObject
74
+ /** Extra converters, tried before the built-in ones. */
75
+ readonly converters?: readonly SchemaConverter[]
76
+ /** `'warn'`: a schema that cannot be converted becomes `{}` (ZNIGHT037 warning). Default `'error'`. */
77
+ readonly unrepresentable?: 'error' | 'warn'
78
+ /** Route metadata of the docs routes (`[Public.meta()]`): seen by guards and by the route decision check. */
79
+ readonly meta?: readonly RouteMetaEntry[]
80
+ /** Extra guards of the docs routes (classes or instances), after the global guards. */
81
+ readonly guards?: readonly GuardRef[]
82
+ /** `cache-control` of the document (revalidated with the ETag). Default `'no-cache'`. */
83
+ readonly cacheControl?: string
84
+ }
85
+
86
+ interface Resolved {
87
+ readonly enabled: boolean
88
+ readonly path: string
89
+ readonly yaml: string | undefined
90
+ readonly ui: { readonly kind: UiKind; readonly config: Readonly<Record<string, JsonValue>> } | undefined
91
+ readonly uiPath: string
92
+ readonly assets: 'auto' | 'local' | 'cdn'
93
+ readonly file: string | undefined
94
+ readonly lazy: boolean
95
+ readonly build: BuildOpenApiOptions
96
+ readonly meta: readonly RouteMetaEntry[]
97
+ readonly guards: readonly GuardRef[]
98
+ readonly cacheControl: string
99
+ }
100
+
101
+ /** A precomputed response body. */
102
+ interface Body {
103
+ readonly bytes: Uint8Array
104
+ readonly etag: string
105
+ readonly contentType: string
106
+ }
107
+
108
+ /** Everything served, computed once. */
109
+ interface Served {
110
+ readonly document: OpenApiObject
111
+ readonly json: Body
112
+ readonly yaml: Body | undefined
113
+ }
114
+
115
+ const ENCODER = new TextEncoder()
116
+ const ASSET_CACHE = 'public, max-age=31536000, immutable'
117
+ const PATH = /^\/[A-Za-z0-9\-._~!$&'()*+,;=:@/]*$/
118
+
119
+ /** The OpenAPI plugin: `OpenApiPlugin.configure()` (name `openapi`). */
120
+ export class OpenApiPlugin implements Plugin, PluginHttpRoutes {
121
+ readonly name = 'openapi'
122
+ readonly #options: PluginOptions<OpenApiPluginOptions>
123
+ #resolved: Resolved | undefined
124
+ #container: Container | undefined
125
+ #metadata: ZnightMetadata | undefined
126
+ #logger: CoreLogger | undefined
127
+ #http: HttpPlugin | undefined
128
+ #served: Served | undefined
129
+ #building: Promise<Served> | undefined
130
+ #ui: { html: Uint8Array; csp: string; etag: string } | undefined
131
+ #local: LocalPackage | undefined
132
+ #operations: readonly OpenApiOperationView[] = []
133
+ #active = false
134
+
135
+ private constructor(options: PluginOptions<OpenApiPluginOptions>) {
136
+ this.#options = options
137
+ }
138
+
139
+ /**
140
+ * Creates the plugin. `options` may be a function of the container, evaluated once the container
141
+ * is ready: `OpenApiPlugin.configure(c => ({ enabled: c.get(ServerConfig).docs }))`.
142
+ */
143
+ static configure(options: PluginOptions<OpenApiPluginOptions> = {}): OpenApiPlugin {
144
+ return new OpenApiPlugin(options)
145
+ }
146
+
147
+ /** The classes of the `guards` option (instances are left out): `TestApp` creates them at boot. */
148
+ initRoots(_context?: PluginRootsContext): readonly ProviderKey[] {
149
+ const options: unknown = this.#options
150
+ if (options === null || typeof options !== 'object') return []
151
+ try {
152
+ const guards: unknown = (options as OpenApiPluginOptions).guards
153
+ return Array.isArray(guards) ? guards.filter((guard): guard is ProviderKey => typeof guard === 'function') : []
154
+ } catch {
155
+ return []
156
+ }
157
+ }
158
+
159
+ register(ctx: PluginContext): void {
160
+ if (this.#active) {
161
+ throw new ZnightError(ErrorCode.DuplicateProvider, 'This OpenApiPlugin is already used by another application', {
162
+ hints: ['Call OpenApiPlugin.configure() once per application'],
163
+ })
164
+ }
165
+ openApiSection(ctx.metadata)
166
+ if (ctx.getPlugin('http') === undefined) {
167
+ throw new ZnightError(
168
+ ErrorCode.InvalidOpenApiDefinition,
169
+ 'OpenApiPlugin serves its routes through HttpPlugin, which is not registered',
170
+ {
171
+ hints: ['Add HttpPlugin.configure() to the plugins of the application'],
172
+ },
173
+ )
174
+ }
175
+ this.#active = true
176
+ this.#resolved = undefined
177
+ this.#served = undefined
178
+ this.#building = undefined
179
+ this.#ui = undefined
180
+ this.#local = undefined
181
+ this.#container = ctx.container
182
+ this.#metadata = ctx.metadata
183
+ this.#logger = ctx.logger
184
+ this.#http = ctx.getPlugin<HttpPlugin>('http')
185
+ this.#operations = operationViews(ctx.metadata)
186
+ ctx.container.registerValue(OpenApiTokens.OpenApi, new OpenApiService(this))
187
+ }
188
+
189
+ httpRoutes(context: PluginHttpRoutesContext): readonly PluginRoute[] {
190
+ this.#container ??= context.container
191
+ const options = this.#resolve()
192
+ if (!options.enabled) return []
193
+ const common = {
194
+ ...(options.meta.length > 0 ? { meta: options.meta } : {}),
195
+ ...(options.guards.length > 0 ? { guards: options.guards } : {}),
196
+ }
197
+ const routes: PluginRoute[] = [
198
+ {
199
+ method: 'GET',
200
+ path: options.path,
201
+ name: 'document',
202
+ handler: ctx => this.#serveDocument(ctx, 'json'),
203
+ ...common,
204
+ },
205
+ ]
206
+ if (options.yaml !== undefined) {
207
+ routes.push({
208
+ method: 'GET',
209
+ path: options.yaml,
210
+ name: 'yaml',
211
+ handler: ctx => this.#serveDocument(ctx, 'yaml'),
212
+ ...common,
213
+ })
214
+ }
215
+ if (options.ui !== undefined) {
216
+ routes.push({
217
+ method: 'GET',
218
+ path: options.uiPath,
219
+ name: 'ui',
220
+ handler: ctx => this.#serveUi(ctx, context.prefix),
221
+ ...common,
222
+ })
223
+ if (this.#localPackage(options) !== undefined) {
224
+ routes.push({
225
+ method: 'GET',
226
+ path: `${options.uiPath === '/' ? '' : options.uiPath}/_assets/*`,
227
+ name: 'assets',
228
+ handler: ctx => this.#serveAsset(ctx),
229
+ ...common,
230
+ })
231
+ }
232
+ }
233
+ return routes
234
+ }
235
+
236
+ async onBootstrap(ctx: PluginContext): Promise<void> {
237
+ this.#container = ctx.container
238
+ const options = this.#resolve()
239
+ if (!options.enabled) return
240
+ this.#checkServers(options)
241
+ if (options.lazy && options.file === undefined) return
242
+ await this.served()
243
+ }
244
+
245
+ onReady(ctx: PluginContext): void {
246
+ const options = this.#resolved
247
+ const address = this.#http?.address
248
+ if (options === undefined || !options.enabled || options.ui === undefined || address === undefined) return
249
+ ctx.logger.info(`API reference: ${String(address.url).replace(/\/$/, '')}${this.#prefix()}${options.uiPath}`)
250
+ }
251
+
252
+ onDispose(): void {
253
+ this.#active = false
254
+ this.#served = undefined
255
+ this.#building = undefined
256
+ this.#ui = undefined
257
+ this.#resolved = undefined
258
+ }
259
+
260
+ /** The views of the operations (no schema conversion). */
261
+ get operations(): readonly OpenApiOperationView[] {
262
+ return this.#operations
263
+ }
264
+
265
+ /** The served document and bodies, built once (shared promise; a failure is retried on the next call). */
266
+ served(): Promise<Served> {
267
+ if (this.#served !== undefined) return Promise.resolve(this.#served)
268
+ if (this.#building === undefined) {
269
+ this.#building = this.#build().then(
270
+ served => {
271
+ this.#served = served
272
+ return served
273
+ },
274
+ (error: unknown) => {
275
+ this.#building = undefined
276
+ throw error
277
+ },
278
+ )
279
+ }
280
+ return this.#building
281
+ }
282
+
283
+ async #build(): Promise<Served> {
284
+ const options = this.#resolve()
285
+ const metadata = this.#metadata
286
+ if (metadata === undefined) {
287
+ throw new ZnightError(
288
+ ErrorCode.LifecycleFailed,
289
+ 'OpenApiPlugin: the document is only available once the plugin is registered',
290
+ )
291
+ }
292
+ let document: OpenApiObject
293
+ let json: string
294
+ if (options.file === undefined) {
295
+ const result = await buildOpenApiDocument(metadata, { ...options.build, prefix: this.#prefix() })
296
+ for (const warning of result.warnings) this.#logger?.warn(`${warning.code}: ${warning.message}`)
297
+ this.#logger?.debug('OpenAPI document built', {
298
+ operations: result.stats.operations,
299
+ schemas: result.stats.schemas,
300
+ ms: Math.round(result.stats.durationMs * 100) / 100,
301
+ })
302
+ document = result.document
303
+ json = result.json
304
+ } else {
305
+ json = await readFile(options.file, 'utf8')
306
+ document = parseDocumentFile(json, options.file)
307
+ const expected = openApiSection(metadata).fingerprint
308
+ const actual = (document as { 'x-znight-fingerprint'?: unknown })['x-znight-fingerprint']
309
+ if (actual !== expected) {
310
+ this.#logger?.warn(
311
+ `ZNIGHT030: ${options.file} is stale: it was not emitted from this build of the application (x-znight-fingerprint differs)`,
312
+ { hints: ['Regenerate it: znight openapi (or znight build --openapi)'] },
313
+ )
314
+ }
315
+ const prefix = this.#prefix()
316
+ if (options.build.servers === undefined) this.#checkFileServers(options.file, document.servers, prefix)
317
+ // the runtime options apply to the file as to a build (filter, info, servers, prefix, transform)
318
+ const applied = applyDocumentOptions(json, metadata, { ...options.build, prefix })
319
+ if (applied !== undefined) {
320
+ document = applied.document
321
+ json = applied.json
322
+ }
323
+ }
324
+ const yaml = options.yaml === undefined ? undefined : body(await toYaml(document), 'application/yaml')
325
+ return { document, json: body(json, 'application/json; charset=utf-8'), yaml }
326
+ }
327
+
328
+ // ------------------------------------------------------------------------------------------------
329
+ // handlers
330
+
331
+ async #serveDocument(ctx: HttpContext, format: 'json' | 'yaml'): Promise<HttpResponse<Uint8Array | undefined>> {
332
+ const options = this.#resolve()
333
+ let served: Served
334
+ try {
335
+ served = await this.served()
336
+ } catch (error) {
337
+ this.#logger?.error('OpenApiPlugin: the document cannot be built', {
338
+ error: error instanceof Error ? error.message : String(error),
339
+ })
340
+ throw error
341
+ }
342
+ const selected = format === 'json' ? served.json : served.yaml
343
+ if (selected === undefined) throw new NotFoundException()
344
+ return conditional(ctx, selected, options.cacheControl)
345
+ }
346
+
347
+ async #serveUi(ctx: HttpContext, prefix: string): Promise<HttpResponse<Uint8Array | undefined>> {
348
+ const options = this.#resolve()
349
+ if (this.#ui === undefined) {
350
+ const ui = options.ui
351
+ if (ui === undefined) throw new NotFoundException()
352
+ // the CSP allows the servers of the served document, whatever the build mode
353
+ let document: Pick<OpenApiObject, 'info' | 'servers'> | undefined
354
+ try {
355
+ document = (await this.served()).document
356
+ } catch {
357
+ // the document cannot be built: the declared servers, the page is not cached
358
+ document = this.#declared(options)
359
+ }
360
+ const local = this.#localPackage(options)
361
+ const base = prefix === '/' ? '' : prefix
362
+ const uiPath = options.uiPath === '/' ? '' : options.uiPath
363
+ const page = renderUiPage({
364
+ kind: ui.kind,
365
+ documentUrl: `${base}${options.path}`,
366
+ assetsBase: local === undefined ? undefined : `${base}${uiPath}/_assets/${ui.kind}@${local.version}`,
367
+ title: document?.info.title === undefined ? 'API reference' : `${document.info.title} — API reference`,
368
+ config: ui.config,
369
+ connect: serverOrigins(document?.servers ?? []),
370
+ })
371
+ const bytes = ENCODER.encode(page.html)
372
+ const rendered = { html: bytes, csp: page.csp, etag: etagOf(bytes) }
373
+ if (this.#served === undefined) return uiResponse(ctx, rendered)
374
+ this.#ui = rendered
375
+ }
376
+ return uiResponse(ctx, this.#ui)
377
+ }
378
+
379
+ /** `info` and `servers` as declared (static template and runtime options), without building. */
380
+ #declared(options: Resolved): Pick<OpenApiObject, 'info' | 'servers'> | undefined {
381
+ const metadata = this.#metadata
382
+ if (metadata === undefined) return undefined
383
+ let template: OpenApiObject['info'] | undefined
384
+ let servers: readonly ServerObject[] | undefined
385
+ try {
386
+ const section = openApiSection(metadata)
387
+ template = section.document.info
388
+ servers = section.document.servers
389
+ } catch {
390
+ return undefined
391
+ }
392
+ const info = { ...template, ...options.build.info } as OpenApiObject['info']
393
+ const resolved = options.build.servers ?? servers
394
+ return resolved === undefined ? { info } : { info, servers: resolved }
395
+ }
396
+
397
+ #serveAsset(ctx: HttpContext): HttpResponse {
398
+ const options = this.#resolve()
399
+ const ui = options.ui
400
+ const local = this.#localPackage(options)
401
+ if (ui === undefined || local === undefined) throw new NotFoundException()
402
+ const requested = ctx.params['*'] ?? ''
403
+ const prefix = `${ui.kind}@${local.version}/`
404
+ const asset = requested.startsWith(prefix)
405
+ ? UI_PACKAGES[ui.kind].assets.find(
406
+ candidate =>
407
+ candidate.file.endsWith(`/${requested.slice(prefix.length)}`) ||
408
+ candidate.file === requested.slice(prefix.length),
409
+ )
410
+ : undefined
411
+ if (asset === undefined) throw new NotFoundException()
412
+ return HttpResponse.file(asset.file, { root: local.dir }).withHeader('cache-control', ASSET_CACHE)
413
+ }
414
+
415
+ // ------------------------------------------------------------------------------------------------
416
+ // options
417
+
418
+ #localPackage(options: Resolved): LocalPackage | undefined {
419
+ if (options.ui === undefined || options.assets === 'cdn') return undefined
420
+ if (this.#local === undefined) {
421
+ this.#local = findLocalPackage(UI_PACKAGES[options.ui.kind])
422
+ if (this.#local === undefined && options.assets === 'local') {
423
+ throw new ZnightError(
424
+ ErrorCode.InvalidOpenApiDefinition,
425
+ `OpenApiPlugin: assets: 'local' needs ${UI_PACKAGES[options.ui.kind].name}, which is not installed`,
426
+ {
427
+ hints: [`npm install ${UI_PACKAGES[options.ui.kind].name}`, "Or serve the pinned CDN copy: assets: 'cdn'"],
428
+ },
429
+ )
430
+ }
431
+ }
432
+ return this.#local
433
+ }
434
+
435
+ #prefix(): string {
436
+ try {
437
+ return this.#http?.prefix ?? ''
438
+ } catch {
439
+ return ''
440
+ }
441
+ }
442
+
443
+ #checkServers(options: Resolved): void {
444
+ const prefix = this.#prefix()
445
+ // a prebuilt file is checked once read (#checkFileServers), unless `servers` replaces its servers
446
+ if (prefix === '' || (options.file !== undefined && options.build.servers === undefined)) return
447
+ const metadata = this.#metadata
448
+ const declared =
449
+ options.build.servers ?? (metadata === undefined ? undefined : openApiSection(metadata).document.servers)
450
+ this.#checkPrefix(declared ?? [], prefix)
451
+ }
452
+
453
+ /**
454
+ * The servers of a prebuilt file against the HTTP prefix (the file may be emitted without the
455
+ * prefix, or with servers of another deployment): ZNIGHT030 when it declares no servers, when a
456
+ * relative server does not end with the prefix, or when no server ends with it (`servers: []`,
457
+ * absolute servers of another path). Runtime `servers` replace the file's and skip this check.
458
+ */
459
+ #checkFileServers(file: string, servers: readonly ServerObject[] | undefined, prefix: string): void {
460
+ if (prefix === '' || prefix === '/') return
461
+ if (servers === undefined) {
462
+ this.#logger?.warn(
463
+ `ZNIGHT030: ${file} declares no servers, but the HTTP prefix is "${prefix}": it is served with the server "${prefix}", and clients generated from the file would call the wrong paths`,
464
+ {
465
+ hints: [
466
+ `Emit it with the prefix: znight openapi --prefix ${prefix}, znight build --openapi --openapi-prefix ${prefix}, or openapi({ prefix: '${prefix}' }) in vite.config`,
467
+ ],
468
+ },
469
+ )
470
+ return
471
+ }
472
+ // a hand-edited file may hold anything: only well-formed servers count
473
+ const declared = Array.isArray(servers) ? servers.filter(server => typeof server?.url === 'string') : []
474
+ // a relative server not ending with the prefix is reported on its own
475
+ if (this.#checkPrefix(declared, prefix) > 0) return
476
+ if (declared.some(server => reflectsPrefix(server.url, prefix))) return
477
+ const listed = declared.length === 0 ? '[]' : declared.map(server => `"${server.url}"`).join(', ')
478
+ this.#logger?.warn(
479
+ `ZNIGHT030: ${file} declares no server ending with the HTTP prefix "${prefix}" (servers: ${listed}): clients generated from the file would call the wrong paths`,
480
+ {
481
+ hints: [
482
+ `Declare a server ending with "${prefix}" (@OpenApiDocument({ servers: [{ url: '${prefix}' }] })) and emit the file again`,
483
+ 'Behind a gateway that rewrites the paths, replace the servers at runtime: OpenApiPlugin.configure({ servers })',
484
+ ],
485
+ },
486
+ )
487
+ }
488
+
489
+ /** Warns about each relative server that does not end with the HTTP prefix; returns how many. */
490
+ #checkPrefix(servers: readonly ServerObject[], prefix: string): number {
491
+ let mismatched = 0
492
+ for (const server of servers) {
493
+ if (server.url.startsWith('/') && !server.url.replace(/\/$/, '').endsWith(prefix)) {
494
+ mismatched++
495
+ this.#logger?.warn(
496
+ `ZNIGHT030: the OpenAPI server "${server.url}" does not end with the HTTP prefix "${prefix}": generated clients would call the wrong paths`,
497
+ )
498
+ }
499
+ }
500
+ return mismatched
501
+ }
502
+
503
+ #resolve(): Resolved {
504
+ if (this.#resolved !== undefined) return this.#resolved
505
+ let raw: OpenApiPluginOptions
506
+ if (typeof this.#options === 'function') {
507
+ const container = this.#container
508
+ if (container === undefined) {
509
+ throw new ZnightError(
510
+ ErrorCode.LifecycleFailed,
511
+ 'OpenApiPlugin: its options are a function of the container, which is not ready',
512
+ )
513
+ }
514
+ raw = resolveOptions(this.#options, container)
515
+ } else {
516
+ raw = this.#options
517
+ }
518
+ this.#resolved = resolvePluginOptions(raw)
519
+ return this.#resolved
520
+ }
521
+ }
522
+
523
+ /** Implementation of the {@link OpenApi} external. */
524
+ class OpenApiService extends OpenApi {
525
+ readonly #plugin: OpenApiPlugin
526
+
527
+ constructor(plugin: OpenApiPlugin) {
528
+ super()
529
+ this.#plugin = plugin
530
+ }
531
+
532
+ get operations(): readonly OpenApiOperationView[] {
533
+ return this.#plugin.operations
534
+ }
535
+
536
+ async document(): Promise<OpenApiObject> {
537
+ return (await this.#plugin.served()).document
538
+ }
539
+
540
+ async json(): Promise<string> {
541
+ return new TextDecoder().decode((await this.#plugin.served()).json.bytes)
542
+ }
543
+
544
+ async yaml(): Promise<string> {
545
+ const served = await this.#plugin.served()
546
+ return served.yaml === undefined ? toYaml(served.document) : new TextDecoder().decode(served.yaml.bytes)
547
+ }
548
+ }
549
+
550
+ /** The UI page: HTML, its Content-Security-Policy, revalidated with its ETag. */
551
+ function uiResponse(
552
+ ctx: HttpContext,
553
+ page: { readonly html: Uint8Array; readonly csp: string; readonly etag: string },
554
+ ): HttpResponse<Uint8Array | undefined> {
555
+ const response = conditional(
556
+ ctx,
557
+ { bytes: page.html, etag: page.etag, contentType: 'text/html; charset=utf-8' },
558
+ 'no-cache',
559
+ )
560
+ return response.withHeader('content-security-policy', page.csp).withHeader('x-content-type-options', 'nosniff')
561
+ }
562
+
563
+ function body(text: string, contentType: string): Body {
564
+ const bytes = ENCODER.encode(text)
565
+ return { bytes, etag: etagOf(bytes), contentType }
566
+ }
567
+
568
+ /** Strong ETag: base64url SHA-256, 27 characters. */
569
+ function etagOf(bytes: Uint8Array): string {
570
+ return `"${createHash('sha256').update(bytes).digest('base64url').slice(0, 27)}"`
571
+ }
572
+
573
+ function conditional(ctx: HttpContext, served: Body, cacheControl: string): HttpResponse<Uint8Array | undefined> {
574
+ const headers = { etag: served.etag, 'cache-control': cacheControl }
575
+ if (matches(ctx.header('if-none-match'), served.etag))
576
+ return new HttpResponse<Uint8Array | undefined>(undefined, { status: 304, headers })
577
+ return new HttpResponse<Uint8Array | undefined>(served.bytes, {
578
+ status: 200,
579
+ headers: { ...headers, 'content-type': served.contentType },
580
+ })
581
+ }
582
+
583
+ function matches(header: string | undefined, etag: string): boolean {
584
+ if (header === undefined) return false
585
+ return header.split(',').some(candidate => {
586
+ const tag = candidate.trim()
587
+ return tag === '*' || tag === etag || tag === `W/${etag}`
588
+ })
589
+ }
590
+
591
+ /** Origins of the absolute server URLs (`connect-src` of the UI). */
592
+ function serverOrigins(servers: readonly ServerObject[]): string[] {
593
+ const origins = new Set<string>()
594
+ for (const server of servers) {
595
+ try {
596
+ const url = new URL(server.url)
597
+ if (url.protocol === 'http:' || url.protocol === 'https:') origins.add(url.origin)
598
+ } catch {
599
+ // relative server: 'self'
600
+ }
601
+ }
602
+ return [...origins].sort()
603
+ }
604
+
605
+ function parseDocumentFile(text: string, file: string): OpenApiObject {
606
+ let document: unknown
607
+ try {
608
+ document = JSON.parse(text)
609
+ } catch (error) {
610
+ throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, `OpenApiPlugin: ${file} is not valid JSON`, {
611
+ cause: error,
612
+ })
613
+ }
614
+ const version = (document as { openapi?: unknown } | null)?.openapi
615
+ if (typeof version !== 'string' || !version.startsWith('3.1.')) {
616
+ throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, `OpenApiPlugin: ${file} is not an OpenAPI 3.1 document`, {
617
+ hints: ['Emit it with znight openapi (or znight build --openapi)'],
618
+ })
619
+ }
620
+ return deepFreeze(document as OpenApiObject)
621
+ }
622
+
623
+ function deepFreeze<T>(value: T): T {
624
+ if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
625
+ Object.freeze(value)
626
+ for (const item of Object.values(value)) deepFreeze(item)
627
+ }
628
+ return value
629
+ }
630
+
631
+ function invalid(option: string, expected: string, value: unknown): ZnightError {
632
+ let shown: string
633
+ try {
634
+ shown = typeof value === 'function' ? 'a function' : (JSON.stringify(value) ?? String(value))
635
+ } catch {
636
+ shown = typeof value
637
+ }
638
+ return new ZnightError(
639
+ ErrorCode.InvalidOpenApiDefinition,
640
+ `Invalid OpenApiPlugin option "${option}": expected ${expected}, received ${shown}`,
641
+ )
642
+ }
643
+
644
+ const OPTION_KEYS = new Set([
645
+ 'enabled',
646
+ 'path',
647
+ 'yaml',
648
+ 'ui',
649
+ 'uiPath',
650
+ 'assets',
651
+ 'document',
652
+ 'build',
653
+ 'info',
654
+ 'servers',
655
+ 'filter',
656
+ 'transform',
657
+ 'converters',
658
+ 'unrepresentable',
659
+ 'meta',
660
+ 'guards',
661
+ 'cacheControl',
662
+ ])
663
+
664
+ /** Checks and defaults the options (ZNIGHT030). */
665
+ export function resolvePluginOptions(options: OpenApiPluginOptions): Resolved {
666
+ if (options === null || typeof options !== 'object') throw invalid('options', 'an object', options)
667
+ for (const key of Object.keys(options)) {
668
+ if (!OPTION_KEYS.has(key)) throw invalid(key, `one of ${[...OPTION_KEYS].join(', ')} (unknown option)`, key)
669
+ }
670
+ const enabled = options.enabled ?? true
671
+ if (typeof enabled !== 'boolean') throw invalid('enabled', 'true or false', enabled)
672
+ const route = (key: 'path' | 'uiPath', fallback: string): string => {
673
+ const value = options[key] ?? fallback
674
+ if (
675
+ typeof value !== 'string' ||
676
+ !PATH.test(value) ||
677
+ value.includes('//') ||
678
+ (value.length > 1 && value.endsWith('/'))
679
+ ) {
680
+ throw invalid(key, "a static path starting with '/' ('/openapi.json')", value)
681
+ }
682
+ return value
683
+ }
684
+ const path = route('path', '/openapi.json')
685
+ const uiPath = route('uiPath', '/docs')
686
+ const yamlOption = options.yaml ?? false
687
+ if (yamlOption !== false && (typeof yamlOption !== 'string' || !PATH.test(yamlOption))) {
688
+ throw invalid('yaml', "false or a path ('/openapi.yaml')", yamlOption)
689
+ }
690
+ const yaml = yamlOption === false ? undefined : yamlOption
691
+ const uiOption = options.ui ?? 'scalar'
692
+ let ui: Resolved['ui']
693
+ if (uiOption === 'scalar' || uiOption === 'swagger') ui = { kind: uiOption, config: {} }
694
+ else if (uiOption === false) ui = undefined
695
+ else if (typeof uiOption === 'object' && uiOption !== null && Object.keys(uiOption).length === 1) {
696
+ const kind = Object.keys(uiOption)[0] as UiKind
697
+ const config = (uiOption as Record<string, unknown>)[kind]
698
+ if ((kind !== 'scalar' && kind !== 'swagger') || typeof config !== 'object' || config === null || !isJson(config)) {
699
+ throw invalid(
700
+ 'ui',
701
+ "'scalar', 'swagger', false, { scalar: config } or { swagger: config } (JSON config)",
702
+ uiOption,
703
+ )
704
+ }
705
+ ui = { kind, config: config as Readonly<Record<string, JsonValue>> }
706
+ } else throw invalid('ui', "'scalar', 'swagger', false, { scalar: config } or { swagger: config }", uiOption)
707
+ const used = [path, ...(yaml === undefined ? [] : [yaml]), ...(ui === undefined ? [] : [uiPath])]
708
+ if (new Set(used).size !== used.length)
709
+ throw invalid('path', 'distinct paths for the document, the YAML and the UI', used)
710
+ const assets = options.assets ?? 'auto'
711
+ if (assets !== 'auto' && assets !== 'local' && assets !== 'cdn')
712
+ throw invalid('assets', "'auto', 'local' or 'cdn'", assets)
713
+ const documentOption = options.document ?? 'build'
714
+ let file: string | undefined
715
+ if (documentOption !== 'build') {
716
+ const target = (documentOption as { file?: unknown } | null)?.file
717
+ if (typeof target === 'string' && target !== '') file = target
718
+ else if (target instanceof URL) file = fileURLToPath(target)
719
+ else throw invalid('document', "'build' or { file: string | URL }", documentOption)
720
+ }
721
+ const build = options.build ?? 'boot'
722
+ if (build !== 'boot' && build !== 'first-request') throw invalid('build', "'boot' or 'first-request'", build)
723
+ for (const key of ['filter', 'transform'] as const) {
724
+ if (options[key] !== undefined && typeof options[key] !== 'function') throw invalid(key, 'a function', options[key])
725
+ }
726
+ if (options.converters !== undefined) {
727
+ if (
728
+ !Array.isArray(options.converters) ||
729
+ options.converters.some(converter => typeof converter?.convert !== 'function')
730
+ ) {
731
+ throw invalid('converters', 'an array of { name, convert(schema, context) }', options.converters)
732
+ }
733
+ }
734
+ if (
735
+ options.unrepresentable !== undefined &&
736
+ options.unrepresentable !== 'error' &&
737
+ options.unrepresentable !== 'warn'
738
+ ) {
739
+ throw invalid('unrepresentable', "'error' or 'warn'", options.unrepresentable)
740
+ }
741
+ if (options.info !== undefined && (typeof options.info !== 'object' || options.info === null))
742
+ throw invalid('info', 'an object', options.info)
743
+ if (
744
+ options.servers !== undefined &&
745
+ (!Array.isArray(options.servers) || options.servers.some(server => typeof server?.url !== 'string'))
746
+ ) {
747
+ throw invalid('servers', 'an array of { url, description? }', options.servers)
748
+ }
749
+ const meta = options.meta ?? []
750
+ if (!Array.isArray(meta)) throw invalid('meta', 'an array of route metadata entries (Public.meta())', meta)
751
+ const guards = options.guards ?? []
752
+ if (!Array.isArray(guards)) throw invalid('guards', 'an array of guards (classes or instances)', guards)
753
+ const cacheControl = options.cacheControl ?? 'no-cache'
754
+ if (typeof cacheControl !== 'string' || cacheControl === '')
755
+ throw invalid('cacheControl', 'a cache-control value', cacheControl)
756
+ const buildOptions: BuildOpenApiOptions = {
757
+ ...(options.info === undefined ? {} : { info: options.info }),
758
+ ...(options.servers === undefined ? {} : { servers: options.servers }),
759
+ ...(options.filter === undefined ? {} : { filter: options.filter }),
760
+ ...(options.transform === undefined ? {} : { transform: options.transform }),
761
+ ...(options.converters === undefined ? {} : { converters: options.converters }),
762
+ ...(options.unrepresentable === undefined ? {} : { unrepresentable: options.unrepresentable }),
763
+ }
764
+ return {
765
+ enabled,
766
+ path,
767
+ yaml,
768
+ ui,
769
+ uiPath,
770
+ assets,
771
+ file,
772
+ lazy: build === 'first-request',
773
+ build: buildOptions,
774
+ meta,
775
+ guards,
776
+ cacheControl,
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Whether a server URL ends with the HTTP prefix: a relative URL (`/api`, `/v2/api/`), or the path
782
+ * of an absolute one (`https://api.example.com/api`). A URL with variables (`{basePath}`) or that
783
+ * cannot be parsed is not judged.
784
+ */
785
+ function reflectsPrefix(url: string, prefix: string): boolean {
786
+ if (url.includes('{')) return true
787
+ let path = url
788
+ if (/^[a-z][a-z\d+.-]*:/i.test(url)) {
789
+ try {
790
+ path = new URL(url).pathname
791
+ } catch {
792
+ return true
793
+ }
794
+ }
795
+ return path
796
+ .replace(/[?#].*$/, '')
797
+ .replace(/\/+$/, '')
798
+ .endsWith(prefix.replace(/\/+$/, ''))
799
+ }
800
+
801
+ function isJson(value: unknown): boolean {
802
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
803
+ if (typeof value === 'number') return Number.isFinite(value)
804
+ if (Array.isArray(value)) return value.every(isJson)
805
+ if (typeof value === 'object') {
806
+ const prototype = Object.getPrototypeOf(value)
807
+ return (prototype === Object.prototype || prototype === null) && Object.values(value).every(isJson)
808
+ }
809
+ return false
810
+ }