@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.
- package/LICENSE +21 -0
- package/README.md +572 -2
- package/dist/build-BTUC96mY.d.mts +174 -0
- package/dist/build-DXunOzlI.mjs +733 -0
- package/dist/build-DXunOzlI.mjs.map +1 -0
- package/dist/compiler.d.mts +16 -0
- package/dist/compiler.mjs +2639 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/emit-child.d.mts +1 -0
- package/dist/emit-child.mjs +124 -0
- package/dist/emit-child.mjs.map +1 -0
- package/dist/emit.d.mts +2 -0
- package/dist/emit.mjs +252 -0
- package/dist/emit.mjs.map +1 -0
- package/dist/index-CqQ6IRnN.d.mts +60 -0
- package/dist/index.d.mts +260 -0
- package/dist/index.mjs +777 -0
- package/dist/index.mjs.map +1 -0
- package/dist/openapi-CQk5dHqc.mjs +39 -0
- package/dist/openapi-CQk5dHqc.mjs.map +1 -0
- package/dist/shared-BZxtyaB0.mjs +147 -0
- package/dist/shared-BZxtyaB0.mjs.map +1 -0
- package/dist/testing.d.mts +39 -0
- package/dist/testing.mjs +155 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-VzBexMKj.d.mts +213 -0
- package/dist/vite.d.mts +24 -0
- package/dist/vite.mjs +64 -0
- package/dist/vite.mjs.map +1 -0
- package/package.json +115 -4
- package/src/build.ts +898 -0
- package/src/compiler/analyze.ts +81 -0
- package/src/compiler/document.ts +612 -0
- package/src/compiler/generate.ts +79 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/model.ts +141 -0
- package/src/compiler/operations.ts +1475 -0
- package/src/compiler/schemas.ts +706 -0
- package/src/compiler/validate.ts +102 -0
- package/src/compiler/values.ts +81 -0
- package/src/converters.ts +137 -0
- package/src/decorators.ts +187 -0
- package/src/emit/child.ts +144 -0
- package/src/emit/index.ts +377 -0
- package/src/emit/protocol.ts +35 -0
- package/src/index.ts +94 -0
- package/src/metadata.ts +112 -0
- package/src/openapi.ts +55 -0
- package/src/plugin.ts +810 -0
- package/src/shared.ts +154 -0
- package/src/testing.ts +224 -0
- package/src/types.ts +240 -0
- package/src/ui.ts +215 -0
- 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'
|
package/src/metadata.ts
ADDED
|
@@ -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
|
+
}
|