@znight/openapi 0.0.0-stage → 0.1.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.
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
@@ -0,0 +1,377 @@
1
+ /**
2
+ * `@znight/openapi/emit`: writes `openapi.json` (or YAML) at build time (§7.5 of RFC 0002), for
3
+ * `znight openapi`, `znight build --openapi` and the `openapi()` Vite plugin.
4
+ *
5
+ * 1. the compiler options are resolved like `znight generate` does (`znight.config.*`, else the
6
+ * `znight()` plugin of the Vite config);
7
+ * 2. `compile()` in `serve` mode: errors stop everything before any module runs;
8
+ * 3. a **child process** loads the generated module (Bun: the compiler preload; Node.js: a Vite
9
+ * server in middleware mode) and runs `buildOpenApiDocument`;
10
+ * 4. each output is written atomically, only when it changed; a checked output (`check`) is never
11
+ * written: when it is missing or stale, it is reported.
12
+ */
13
+ import type { Diagnostic } from '@znight/compiler'
14
+ import type { OpenApiWarning } from '../build.ts'
15
+ import type { OpenApiObject } from '../types.ts'
16
+ import type { ChildPayload, ChildResult } from './protocol.ts'
17
+
18
+ import { spawn } from 'node:child_process'
19
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
20
+ import { createRequire } from 'node:module'
21
+ import { tmpdir } from 'node:os'
22
+ import { dirname, join, resolve } from 'node:path'
23
+ import { fileURLToPath, pathToFileURL } from 'node:url'
24
+ import { ErrorCode, ZnightError } from '@znight/core'
25
+
26
+ import { toYaml } from '../openapi.ts'
27
+
28
+ /** Runtime loading the application modules: `'auto'` is the runtime running the caller. */
29
+ export type EmitRuntime = 'auto' | 'node' | 'bun'
30
+
31
+ /** Options of {@link emitOpenApi}. */
32
+ export interface EmitOpenApiOptions {
33
+ /** Project root. Default `process.cwd()`. */
34
+ readonly root?: string
35
+ /** Output files, relative to `root`; `.yaml` / `.yml` write YAML (optional peer `yaml`). Default `'openapi.json'`. */
36
+ readonly output?: string | readonly string[]
37
+ /**
38
+ * Never write: report missing or stale outputs (`stale`, `staleFiles`). `true` checks every output;
39
+ * a list checks only these outputs (relative to `root`, each one an `output`) and writes the others
40
+ * as usual (the `openapi()` Vite plugin checks the committed file and writes the build output).
41
+ */
42
+ readonly check?: boolean | readonly string[]
43
+ /** The HTTP prefix: `servers` defaults to `[{ url: prefix }]` when `@OpenApiDocument` declares none. */
44
+ readonly prefix?: string
45
+ /** Default `'auto'`. */
46
+ readonly runtime?: EmitRuntime
47
+ /** Module (relative to `root`) whose default export is a `SchemaConverter[]`. */
48
+ readonly converters?: string
49
+ /** Vite config file (default: `vite.config.*` of the root). */
50
+ readonly config?: string
51
+ /** How Vite loads its config (`bundle`, `runner`, `native`). */
52
+ readonly configLoader?: string
53
+ /**
54
+ * The compiler options, already resolved (the CLI and the Vite plugin pass theirs). Default:
55
+ * `znight.config.*`, else the options of the `znight()` plugin of the Vite config.
56
+ */
57
+ readonly compilerOptions?: Readonly<Record<string, unknown>>
58
+ /** Environment of the child process. Default `process.env`, with `NODE_ENV` defaulting to `production`. */
59
+ readonly env?: Readonly<Record<string, string | undefined>>
60
+ }
61
+
62
+ /** Result of {@link emitOpenApi}. */
63
+ export interface EmitOpenApiResult {
64
+ /** `false` when the compile failed (see `diagnostics`): nothing ran and nothing was written. */
65
+ readonly ok: boolean
66
+ /** Outputs written (absolute paths); never a checked output. */
67
+ readonly written: readonly string[]
68
+ /** `check`: a checked output is missing or differs (listed in `staleFiles`). */
69
+ readonly stale: boolean
70
+ readonly staleFiles: readonly string[]
71
+ /** The document (`undefined` when the compile failed). */
72
+ readonly document: OpenApiObject | undefined
73
+ /** Compiler diagnostics (warnings included). */
74
+ readonly diagnostics: readonly Diagnostic[]
75
+ /** Warnings of the assembly (`unrepresentable: 'warn'`...). */
76
+ readonly warnings: readonly OpenApiWarning[]
77
+ /** Absolute paths of the outputs. */
78
+ readonly outputs: readonly string[]
79
+ readonly runtime: 'node' | 'bun'
80
+ }
81
+
82
+ interface CompilerModule {
83
+ compile(options: Record<string, unknown>): Promise<{
84
+ readonly ok: boolean
85
+ readonly diagnostics: readonly Diagnostic[]
86
+ readonly outFile: string
87
+ }>
88
+ loadZnightConfig?(cwd: string): Promise<{ file: string | undefined; options: Record<string, unknown> }>
89
+ }
90
+
91
+ const VITE_CONFIGS = [
92
+ 'vite.config.js',
93
+ 'vite.config.mjs',
94
+ 'vite.config.ts',
95
+ 'vite.config.cjs',
96
+ 'vite.config.mts',
97
+ 'vite.config.cts',
98
+ ]
99
+
100
+ /** Compiles the application and writes (or checks) its OpenAPI document. */
101
+ export async function emitOpenApi(options: EmitOpenApiOptions = {}): Promise<EmitOpenApiResult> {
102
+ const root = resolve(options.root ?? process.cwd())
103
+ const outputs = (typeof options.output === 'string' ? [options.output] : (options.output ?? ['openapi.json'])).map(
104
+ file => resolve(root, file),
105
+ )
106
+ const checked = checkedOutputs(root, outputs, options.check)
107
+ const runtime = resolveRuntime(options.runtime ?? 'auto')
108
+ const compiler = (await importFromProject(root, '@znight/compiler')) as CompilerModule
109
+ const viteConfig = findViteConfig(root, options.config)
110
+ const compilerOptions =
111
+ options.compilerOptions ?? (await resolveCompilerOptions(root, compiler, viteConfig, options.configLoader))
112
+ const compiled = await compiler.compile({
113
+ ...compilerOptions,
114
+ root: resolve(root, String(compilerOptions['root'] ?? '.')),
115
+ mode: 'serve',
116
+ })
117
+ const base = { outputs, runtime, diagnostics: compiled.diagnostics }
118
+ if (!compiled.ok) {
119
+ return { ...base, ok: false, written: [], stale: false, staleFiles: [], document: undefined, warnings: [] }
120
+ }
121
+ const converters = options.converters === undefined ? undefined : resolve(root, options.converters)
122
+ if (converters !== undefined && !existsSync(converters)) {
123
+ throw new ZnightError(
124
+ ErrorCode.InvalidOpenApiDefinition,
125
+ `The converters module ${options.converters} does not exist`,
126
+ )
127
+ }
128
+ const result = await runChild(
129
+ {
130
+ root,
131
+ runtime,
132
+ outFile: compiled.outFile,
133
+ viteConfig: viteConfig ?? false,
134
+ ...(options.configLoader === undefined ? {} : { configLoader: options.configLoader }),
135
+ compilerOptions: jsonOnly(compilerOptions),
136
+ ...(converters === undefined ? {} : { converters }),
137
+ ...(options.prefix === undefined ? {} : { prefix: options.prefix }),
138
+ },
139
+ options.env ?? process.env,
140
+ )
141
+ if (!result.ok) {
142
+ const code =
143
+ Object.values(ErrorCode).find(candidate => candidate === result.error.code) ?? ErrorCode.SchemaConversionFailed
144
+ const message = result.error.message.replace(/^ZNIGHT\d{3}: /, '')
145
+ throw new ZnightError(code, message, { hints: result.error.hints })
146
+ }
147
+ const document = JSON.parse(result.json) as OpenApiObject
148
+ const contents = new Map<string, string>()
149
+ for (const output of outputs) {
150
+ contents.set(output, /\.ya?ml$/i.test(output) ? await toYaml(document) : result.json)
151
+ }
152
+ const changed = outputs.filter(output => readText(output) !== contents.get(output))
153
+ const staleFiles = changed.filter(output => checked.has(output))
154
+ const written: string[] = []
155
+ for (const output of changed) {
156
+ if (checked.has(output)) continue
157
+ writeAtomically(output, contents.get(output) ?? '')
158
+ written.push(output)
159
+ }
160
+ return {
161
+ ...base,
162
+ ok: true,
163
+ written,
164
+ stale: staleFiles.length > 0,
165
+ staleFiles,
166
+ document,
167
+ warnings: result.warnings,
168
+ }
169
+ }
170
+
171
+ /** The outputs that `check` checks (absolute paths). */
172
+ function checkedOutputs(
173
+ root: string,
174
+ outputs: readonly string[],
175
+ check: EmitOpenApiOptions['check'],
176
+ ): ReadonlySet<string> {
177
+ if (check === undefined || check === false) return new Set()
178
+ if (check === true) return new Set(outputs)
179
+ const checked = new Set(check.map(file => resolve(root, file)))
180
+ const unknown = check.filter(file => !outputs.includes(resolve(root, file)))
181
+ if (unknown.length > 0) {
182
+ throw new ZnightError(
183
+ ErrorCode.InvalidOpenApiDefinition,
184
+ `check lists ${unknown.join(', ')}, which ${unknown.length > 1 ? 'are not outputs' : 'is not an output'}`,
185
+ { hints: ['check: true checks every output; a list names outputs (relative to the root)'] },
186
+ )
187
+ }
188
+ return checked
189
+ }
190
+
191
+ function resolveRuntime(runtime: EmitRuntime): 'node' | 'bun' {
192
+ if (runtime === 'node' || runtime === 'bun') return runtime
193
+ return process.versions['bun'] === undefined ? 'node' : 'bun'
194
+ }
195
+
196
+ /** The Vite config file of the project, if any. */
197
+ export function findViteConfig(root: string, config?: string): string | undefined {
198
+ if (config !== undefined) return resolve(root, config)
199
+ return VITE_CONFIGS.map(file => join(root, file)).find(file => existsSync(file))
200
+ }
201
+
202
+ async function resolveCompilerOptions(
203
+ root: string,
204
+ compiler: CompilerModule,
205
+ viteConfig: string | undefined,
206
+ configLoader: string | undefined,
207
+ ): Promise<Record<string, unknown>> {
208
+ const config = await compiler.loadZnightConfig?.(root)
209
+ if (config?.file !== undefined) {
210
+ const { logLevel: _logLevel, ...rest } = config.options
211
+ return { ...rest, root: resolve(root, String(rest['root'] ?? '.')) }
212
+ }
213
+ if (viteConfig === undefined) return { root }
214
+ const vite = (await importFromProject(root, 'vite')) as {
215
+ resolveConfig?(
216
+ config: Record<string, unknown>,
217
+ command: string,
218
+ mode?: string,
219
+ env?: string,
220
+ ): Promise<{ plugins: readonly unknown[]; root: string }>
221
+ }
222
+ if (typeof vite.resolveConfig !== 'function') return { root }
223
+ const nodeEnv = process.env['NODE_ENV']
224
+ let resolved: { plugins: readonly unknown[]; root: string }
225
+ try {
226
+ resolved = await vite.resolveConfig(
227
+ {
228
+ configFile: viteConfig,
229
+ root,
230
+ mode: 'production',
231
+ logLevel: 'silent',
232
+ ...(configLoader === undefined ? {} : { configLoader }),
233
+ },
234
+ 'build',
235
+ 'production',
236
+ 'production',
237
+ )
238
+ } finally {
239
+ if (nodeEnv === undefined) delete process.env['NODE_ENV']
240
+ else process.env['NODE_ENV'] = nodeEnv
241
+ }
242
+ for (const plugin of resolved.plugins) {
243
+ const api = (plugin as { name?: unknown; api?: { options?: unknown; compiler?: { options?: { root?: unknown } } } })
244
+ .api
245
+ if ((plugin as { name?: unknown }).name !== 'znight' || api === undefined) continue
246
+ const given =
247
+ typeof api.options === 'object' && api.options !== null ? (api.options as Record<string, unknown>) : {}
248
+ const compilerRoot = api.compiler?.options?.root
249
+ return {
250
+ ...given,
251
+ root: typeof compilerRoot === 'string' ? compilerRoot : resolve(resolved.root, String(given['root'] ?? '.')),
252
+ }
253
+ }
254
+ return { root }
255
+ }
256
+
257
+ async function importFromProject(root: string, specifier: string): Promise<unknown> {
258
+ let resolved: string | undefined
259
+ try {
260
+ resolved = createRequire(join(root, 'package.json')).resolve(specifier)
261
+ } catch {
262
+ resolved = undefined
263
+ }
264
+ try {
265
+ return await import(resolved === undefined ? specifier : pathToFileURL(resolved).href)
266
+ } catch (error) {
267
+ throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, `Cannot load ${specifier}`, {
268
+ cause: error,
269
+ hints: [`Install it in the project: npm install -D ${specifier}`],
270
+ })
271
+ }
272
+ }
273
+
274
+ /** The JSON-compatible part of the compiler options (sent to the child process). */
275
+ function jsonOnly(options: Readonly<Record<string, unknown>>): Record<string, unknown> {
276
+ const result: Record<string, unknown> = {}
277
+ for (const [key, value] of Object.entries(options)) {
278
+ try {
279
+ const json = JSON.stringify(value)
280
+ if (json !== undefined && JSON.stringify(JSON.parse(json)) === json && typeof value !== 'function') {
281
+ result[key] = JSON.parse(json)
282
+ }
283
+ } catch {
284
+ // functions, extension objects: the child reads znight.config.* itself
285
+ }
286
+ }
287
+ return result
288
+ }
289
+
290
+ /** The script of the child process (source or built). */
291
+ function childScript(): { file: string; source: boolean } {
292
+ const source = import.meta.url.endsWith('.ts')
293
+ return { file: fileURLToPath(new URL(source ? './child.ts' : './emit-child.mjs', import.meta.url)), source }
294
+ }
295
+
296
+ function runChild(
297
+ payload: Omit<ChildPayload, 'result'>,
298
+ env: Readonly<Record<string, string | undefined>>,
299
+ ): Promise<ChildResult> {
300
+ const directory = mkdtempSync(join(tmpdir(), 'znight-openapi-emit-'))
301
+ const payloadFile = join(directory, 'payload.json')
302
+ const resultFile = join(directory, 'result.json')
303
+ const script = childScript()
304
+ writeFileSync(
305
+ payloadFile,
306
+ JSON.stringify({
307
+ ...payload,
308
+ ...(script.source ? { sourceConditions: true } : {}),
309
+ result: resultFile,
310
+ } satisfies ChildPayload),
311
+ )
312
+ const bun = process.versions['bun'] !== undefined
313
+ let executable: string
314
+ if (payload.runtime === 'bun') executable = bun ? process.execPath : 'bun'
315
+ else executable = bun ? 'node' : process.execPath
316
+ const args = [...(script.source ? ['--conditions=@znight/source'] : []), script.file, payloadFile]
317
+ const childEnv: Record<string, string> = {}
318
+ for (const [key, value] of Object.entries(env)) if (value !== undefined) childEnv[key] = value
319
+ childEnv['NODE_ENV'] ??= 'production'
320
+ return new Promise<ChildResult>((resolvePromise, reject) => {
321
+ const child = spawn(executable, args, { cwd: payload.root, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] })
322
+ let output = ''
323
+ child.stdout?.on('data', (chunk: Buffer) => {
324
+ output += chunk.toString()
325
+ })
326
+ child.stderr?.on('data', (chunk: Buffer) => {
327
+ output += chunk.toString()
328
+ })
329
+ child.on('error', error => {
330
+ rmSync(directory, { recursive: true, force: true })
331
+ reject(
332
+ new ZnightError(
333
+ ErrorCode.InvalidOpenApiDefinition,
334
+ `Cannot start ${executable} to load the application modules`,
335
+ {
336
+ cause: error,
337
+ hints: [
338
+ payload.runtime === 'bun'
339
+ ? 'Install Bun, or use --runtime node'
340
+ : 'Install Node.js 24, or use --runtime bun',
341
+ ],
342
+ },
343
+ ),
344
+ )
345
+ })
346
+ child.on('close', code => {
347
+ const text = readText(resultFile)
348
+ rmSync(directory, { recursive: true, force: true })
349
+ if (text === undefined) {
350
+ reject(
351
+ new ZnightError(
352
+ ErrorCode.InvalidOpenApiDefinition,
353
+ `Loading the application modules failed (${executable} exited with code ${String(code)})${output.trim() === '' ? '' : `:\n${output.trim()}`}`,
354
+ ),
355
+ )
356
+ return
357
+ }
358
+ resolvePromise(JSON.parse(text) as ChildResult)
359
+ })
360
+ })
361
+ }
362
+
363
+ function readText(file: string): string | undefined {
364
+ try {
365
+ return readFileSync(file, 'utf8')
366
+ } catch {
367
+ return undefined
368
+ }
369
+ }
370
+
371
+ /** Writes through a temporary file and a rename (readers never see a partial file). */
372
+ function writeAtomically(file: string, content: string): void {
373
+ mkdirSync(dirname(file), { recursive: true })
374
+ const temporary = `${file}.${process.pid}.tmp`
375
+ writeFileSync(temporary, content)
376
+ renameSync(temporary, file)
377
+ }
@@ -0,0 +1,35 @@
1
+ /** Messages between `emitOpenApi()` and its child process (JSON files). */
2
+ import type { OpenApiWarning } from '../build.ts'
3
+
4
+ /** What the child process receives. */
5
+ export interface ChildPayload {
6
+ readonly root: string
7
+ readonly runtime: 'node' | 'bun'
8
+ /** Absolute path of the generated module. */
9
+ readonly outFile: string
10
+ /** Vite config file (Node.js), `false` without one. */
11
+ readonly viteConfig: string | false
12
+ readonly configLoader?: string
13
+ /** JSON-compatible compiler options (used when the project has no `znight.config.*`). */
14
+ readonly compilerOptions: Readonly<Record<string, unknown>>
15
+ /** Absolute path of a module whose default export is a `SchemaConverter[]`. */
16
+ readonly converters?: string
17
+ readonly prefix?: string
18
+ /** The workspace runs from sources: resolve `@znight/*` packages with the `@znight/source` condition. */
19
+ readonly sourceConditions?: boolean
20
+ /** Where the child writes its {@link ChildResult}. */
21
+ readonly result: string
22
+ }
23
+
24
+ /** What the child process writes. */
25
+ export type ChildResult =
26
+ | {
27
+ readonly ok: true
28
+ readonly json: string
29
+ readonly warnings: readonly OpenApiWarning[]
30
+ readonly stats: { readonly operations: number; readonly schemas: number; readonly durationMs: number }
31
+ }
32
+ | {
33
+ readonly ok: false
34
+ readonly error: { readonly code: string | undefined; readonly message: string; readonly hints: readonly string[] }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `@znight/openapi`: OpenAPI 3.1 documents built at compile time from the controllers of
3
+ * `@znight/http` (types, JSDoc, Standard Schemas, a few `@Api*` decorators), served with Scalar or
4
+ * Swagger UI by `OpenApiPlugin`, and emitted at build time (`znight openapi`).
5
+ */
6
+ import './metadata.ts'
7
+
8
+ export type {
9
+ OpenApiDocumentTemplate,
10
+ OpenApiMetadata,
11
+ OpenApiOperationDefinition,
12
+ OpenApiSchemaSlot,
13
+ OperationTemplate,
14
+ ParametersPlaceholder,
15
+ SchemaPlaceholder,
16
+ SchemaTemplate,
17
+ } from './metadata.ts'
18
+ export type {
19
+ ComponentsObject,
20
+ ContactObject,
21
+ EncodingObjectOf,
22
+ ExampleObject,
23
+ ExternalDocumentationObject,
24
+ HeaderObject,
25
+ HeaderObjectOf,
26
+ InfoObject,
27
+ JsonSchema,
28
+ JsonValue,
29
+ LicenseObject,
30
+ MediaTypeObject,
31
+ MediaTypeObjectOf,
32
+ OAuthFlowObject,
33
+ OAuthFlowsObject,
34
+ OpenApiMethod,
35
+ OpenApiObject,
36
+ OperationObject,
37
+ OperationObjectOf,
38
+ ParameterLocation,
39
+ ParameterObject,
40
+ ParameterObjectOf,
41
+ PathItemObject,
42
+ ReferenceObject,
43
+ RequestBodyObject,
44
+ RequestBodyObjectOf,
45
+ ResponseObject,
46
+ ResponseObjectOf,
47
+ SecurityRequirementObject,
48
+ SecuritySchemeObject,
49
+ ServerObject,
50
+ ServerVariableObject,
51
+ SpecificationExtensions,
52
+ TagObject,
53
+ } from './types.ts'
54
+
55
+ export {
56
+ type BuildOpenApiOptions,
57
+ buildOpenApiDocument,
58
+ type OpenApiBuildResult,
59
+ type OpenApiOperationView,
60
+ type OpenApiWarning,
61
+ } from './build.ts'
62
+ export {
63
+ BUILT_IN_CONVERTERS,
64
+ type SchemaConverter,
65
+ type SchemaConverterContext,
66
+ standardJsonSchemaConverter,
67
+ valibotConverter,
68
+ } from './converters.ts'
69
+ export {
70
+ ApiBody,
71
+ type ApiBodyOptions,
72
+ ApiExclude,
73
+ ApiHeader,
74
+ ApiOperation,
75
+ type ApiOperationOptions,
76
+ type ApiParameterOptions,
77
+ ApiQuery,
78
+ ApiResponse,
79
+ type ApiResponseOptions,
80
+ type ApiResponseStatus,
81
+ ApiSecurity,
82
+ ApiTags,
83
+ OpenApiDocument,
84
+ type OpenApiDocumentOptions,
85
+ type OperationIdStyle,
86
+ type SecurityDecision,
87
+ type SecurityRequirementInput,
88
+ type SecurityScheme,
89
+ securityScheme,
90
+ } from './decorators.ts'
91
+ export { OPENAPI_METADATA_VERSION } from './metadata.ts'
92
+ export { OpenApi, OpenApiTokens } from './openapi.ts'
93
+ export { OpenApiPlugin, type OpenApiPluginOptions, type OpenApiUiOption } from './plugin.ts'
94
+ export { CDN_ORIGIN, UI_PACKAGES, type UiAsset, type UiKind, type UiPackage } from './ui.ts'
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `metadata.extensions.openapi` (format 1): the contract between the compiler extension
3
+ * (`@znight/openapi/compiler`) and the runtime (`buildOpenApiDocument`, `OpenApiPlugin`).
4
+ *
5
+ * Everything is JSON-compatible, except the `schema` values of {@link OpenApiSchemaSlot}s (Standard
6
+ * Schemas imported by the generated module). Where a schema is expected, the compiler writes JSON
7
+ * Schema or a {@link SchemaPlaceholder} that the assembly replaces.
8
+ */
9
+ import type { Token } from '@znight/core'
10
+ import type { StandardSchemaV1 } from '@znight/http'
11
+ import type {
12
+ ExternalDocumentationObject,
13
+ InfoObject,
14
+ JsonSchema,
15
+ JsonValue,
16
+ OpenApiMethod,
17
+ OperationObjectOf,
18
+ SecurityRequirementObject,
19
+ SecuritySchemeObject,
20
+ ServerObject,
21
+ TagObject,
22
+ } from './types.ts'
23
+
24
+ /** Format version of {@link OpenApiMetadata}. */
25
+ export const OPENAPI_METADATA_VERSION = 1
26
+
27
+ /** A Standard Schema converted at assembly: `{ $ref }` to its component once replaced. */
28
+ export interface SchemaPlaceholder {
29
+ readonly $znight: 'schema'
30
+ /** Slot name ({@link OpenApiSchemaSlot.name}). */
31
+ readonly name: string
32
+ readonly io: 'input' | 'output'
33
+ }
34
+
35
+ /**
36
+ * In `parameters` arrays: one parameter per property of the (input) object schema of a slot
37
+ * (`@Query(SearchQuery)`), `required` from its `required` list.
38
+ */
39
+ export interface ParametersPlaceholder {
40
+ readonly $znight: 'parameters'
41
+ readonly in: 'path' | 'query' | 'header' | 'cookie'
42
+ /** Slot name. */
43
+ readonly name: string
44
+ readonly io: 'input'
45
+ }
46
+
47
+ /** JSON Schema, or a placeholder of a Standard Schema. */
48
+ export type SchemaTemplate = JsonSchema | SchemaPlaceholder
49
+
50
+ /** An Operation Object whose schemas are {@link SchemaTemplate}s and whose parameters may be {@link ParametersPlaceholder}s. */
51
+ export type OperationTemplate = OperationObjectOf<SchemaTemplate, ParametersPlaceholder>
52
+
53
+ /** Root fields of the document known at build time. */
54
+ export interface OpenApiDocumentTemplate {
55
+ readonly openapi: string
56
+ readonly info: InfoObject
57
+ readonly servers?: readonly ServerObject[]
58
+ readonly tags?: readonly TagObject[]
59
+ readonly externalDocs?: ExternalDocumentationObject
60
+ readonly security?: readonly SecurityRequirementObject[]
61
+ readonly [extension: `x-${string}`]: JsonValue | undefined
62
+ }
63
+
64
+ /** A documented operation. */
65
+ export interface OpenApiOperationDefinition {
66
+ /** Controller token. */
67
+ readonly controller: Token
68
+ readonly handler: string
69
+ /** The compiled route method (`'ALL'` gives one definition per documented method). */
70
+ readonly route: string
71
+ readonly method: OpenApiMethod
72
+ /** OpenAPI path template, without the HTTP prefix (`/books/{id}`). */
73
+ readonly path: string
74
+ readonly operation: OperationTemplate
75
+ }
76
+
77
+ /** A Standard Schema converted when the document is assembled. */
78
+ export interface OpenApiSchemaSlot {
79
+ /** Component name (export name of the binding: `CreateBook`). */
80
+ readonly name: string
81
+ /** The imported schema value. */
82
+ readonly schema: StandardSchemaV1
83
+ /** The JSON Schemas needed: `input` for requests, `output` for responses. */
84
+ readonly io: readonly ('input' | 'output')[]
85
+ /** Where the schema is declared (relative path), for messages. */
86
+ readonly source?: { readonly file: string; readonly line: number; readonly column: number }
87
+ }
88
+
89
+ /** `metadata.extensions.openapi`. */
90
+ export interface OpenApiMetadata {
91
+ readonly version: 1
92
+ /** SHA-256 (base64url) of the section, schema values replaced by their names (stale-file detection). */
93
+ readonly fingerprint: string
94
+ readonly document: OpenApiDocumentTemplate
95
+ /** Sorted by path, then by method (get, put, post, delete, options, head, patch). */
96
+ readonly operations: readonly OpenApiOperationDefinition[]
97
+ /** JSON Schemas computed by the compiler from TypeScript declarations, by component name (sorted). */
98
+ readonly components: Readonly<Record<string, SchemaTemplate>>
99
+ readonly securitySchemes: Readonly<Record<string, SecuritySchemeObject>>
100
+ /** Standard Schemas converted at assembly, sorted by name. */
101
+ readonly schemas: readonly OpenApiSchemaSlot[]
102
+ /** Standard error components; absent with `errors: false`. */
103
+ readonly errors?: { readonly contentType: string; readonly schema?: string }
104
+ /** Descriptions of the default tags (JSDoc of the controllers), by tag name; absent when empty. */
105
+ readonly tagDescriptions?: Readonly<Record<string, string>>
106
+ }
107
+
108
+ declare module '@znight/core' {
109
+ interface ZnightExtensionsMetadata {
110
+ readonly openapi?: OpenApiMetadata
111
+ }
112
+ }
package/src/openapi.ts ADDED
@@ -0,0 +1,55 @@
1
+ /** The injectable `OpenApi` external and the YAML serialisation. */
2
+ import type { OpenApiOperationView } from './build.ts'
3
+ import type { OpenApiObject } from './types.ts'
4
+
5
+ import { ErrorCode, type TypedToken, ZnightError } from '@znight/core'
6
+
7
+ /**
8
+ * The document of the application, provided by `OpenApiPlugin` (a singleton external declared in
9
+ * the manifest of `@znight/openapi`).
10
+ *
11
+ * ```ts
12
+ * @Injectable()
13
+ * export class GatewayRegistration implements OnReady {
14
+ * constructor(private readonly openapi: OpenApi, private readonly gateway: GatewayClient) {}
15
+ * async onReady(): Promise<void> {
16
+ * await this.gateway.publish(await this.openapi.json())
17
+ * }
18
+ * }
19
+ * ```
20
+ */
21
+ export abstract class OpenApi {
22
+ /** The frozen document (built on the first call in `'first-request'` mode, or when the plugin is disabled). */
23
+ abstract document(): Promise<OpenApiObject>
24
+ /** The served JSON text (2 spaces, final newline). */
25
+ abstract json(): Promise<string>
26
+ /** The YAML text (optional peer `yaml`). */
27
+ abstract yaml(): Promise<string>
28
+ /** Static view of the operations, without any schema conversion. */
29
+ abstract readonly operations: readonly OpenApiOperationView[]
30
+ }
31
+
32
+ /** Tokens of the externals provided by `OpenApiPlugin` (declared in the package manifest). */
33
+ export const OpenApiTokens: {
34
+ /** The {@link OpenApi} singleton. */
35
+ readonly OpenApi: TypedToken<OpenApi>
36
+ } = Object.freeze({ OpenApi: '@znight/openapi#OpenApi' })
37
+
38
+ /** YAML text of a document (`yaml` is an optional peer, loaded lazily). */
39
+ export async function toYaml(document: OpenApiObject): Promise<string> {
40
+ let module: { stringify?: (value: unknown, options?: Record<string, unknown>) => string }
41
+ try {
42
+ module = (await import('yaml')) as typeof module
43
+ } catch (error) {
44
+ throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, 'YAML output needs the optional peer dependency "yaml"', {
45
+ cause: error,
46
+ hints: ['npm install yaml'],
47
+ })
48
+ }
49
+ if (typeof module.stringify !== 'function') {
50
+ throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, 'The installed "yaml" package has no stringify()', {
51
+ hints: ['npm install yaml@2'],
52
+ })
53
+ }
54
+ return module.stringify(document, { aliasDuplicateObjects: false, lineWidth: 0 })
55
+ }