@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,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `validate`: builds the whole OpenAPI model once per compile (document options, security schemes,
|
|
3
|
+
* operations, components, Standard Schema slots), reports its diagnostics and keeps it for
|
|
4
|
+
* `generate` (keyed by the application graph).
|
|
5
|
+
*/
|
|
6
|
+
import type { ApplicationGraph, Location, ValidateContext } from '@znight/compiler'
|
|
7
|
+
import type { JsonSchema, JsonValue, SecuritySchemeObject } from '../types.ts'
|
|
8
|
+
import type { OpenApiStored, SlotData } from './model.ts'
|
|
9
|
+
import type { OperationModel } from './operations.ts'
|
|
10
|
+
|
|
11
|
+
import { compareStrings, ERROR_COMPONENTS } from '../shared.ts'
|
|
12
|
+
import { documentTemplate, readDocument, SchemeRegistry } from './document.ts'
|
|
13
|
+
import { buildOperations } from './operations.ts'
|
|
14
|
+
import { Schemas, Slots } from './schemas.ts'
|
|
15
|
+
|
|
16
|
+
/** Everything `generate` emits. */
|
|
17
|
+
export interface OpenApiModel {
|
|
18
|
+
readonly document: Record<string, JsonValue>
|
|
19
|
+
readonly operations: readonly OperationModel[]
|
|
20
|
+
readonly components: Readonly<Record<string, JsonSchema>>
|
|
21
|
+
readonly securitySchemes: Readonly<Record<string, SecuritySchemeObject>>
|
|
22
|
+
readonly slots: readonly SlotData[]
|
|
23
|
+
readonly errors: { readonly contentType: string; readonly schema?: string } | undefined
|
|
24
|
+
readonly tagDescriptions: Readonly<Record<string, string>>
|
|
25
|
+
/** Root of the project (relative `source` locations). */
|
|
26
|
+
readonly root: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Models by compile. The key is `graph.extensions`: the validate and generate contexts of one
|
|
31
|
+
* compile get different graph objects (diagnostics, pruning) sharing this array.
|
|
32
|
+
*/
|
|
33
|
+
const models = new WeakMap<object, OpenApiModel>()
|
|
34
|
+
|
|
35
|
+
/** `validate` of the extension. */
|
|
36
|
+
export function validateOpenApi(ctx: ValidateContext<OpenApiStored>): void {
|
|
37
|
+
models.set(ctx.graph.extensions, buildModel(ctx))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The model built by `validate` for the compile of `graph`. */
|
|
41
|
+
export function modelOf(graph: ApplicationGraph): OpenApiModel | undefined {
|
|
42
|
+
return models.get(graph.extensions)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildModel(ctx: ValidateContext<OpenApiStored>): OpenApiModel {
|
|
46
|
+
const schemes = new SchemeRegistry(ctx)
|
|
47
|
+
const document = readDocument(ctx, schemes)
|
|
48
|
+
const reserved = new Set<string>(
|
|
49
|
+
document.errors === false ? [] : [ERROR_COMPONENTS.body, ERROR_COMPONENTS.validation],
|
|
50
|
+
)
|
|
51
|
+
const slots = new Slots(
|
|
52
|
+
diagnostic => ctx.report(diagnostic),
|
|
53
|
+
() => reserved,
|
|
54
|
+
ref => declarationOf(ctx, ref.identity),
|
|
55
|
+
)
|
|
56
|
+
const schemas = new Schemas(ctx, { jsdoc: document.jsdoc, strict: document.strict, reserved }, slots)
|
|
57
|
+
const errorsSlot =
|
|
58
|
+
document.errors === false || document.errors.schema === undefined
|
|
59
|
+
? undefined
|
|
60
|
+
: slots.use(document.errors.schema, 'output')
|
|
61
|
+
const { operations, tagDescriptions } = buildOperations({ ctx, document, schemes, schemas, slots })
|
|
62
|
+
schemas.settle()
|
|
63
|
+
const rootSecurity = document.security === undefined ? undefined : schemes.objects(document.security)
|
|
64
|
+
const securitySchemes: Record<string, SecuritySchemeObject> = {}
|
|
65
|
+
for (const name of [...schemes.used.keys()].sort(compareStrings)) {
|
|
66
|
+
securitySchemes[name] = schemes.used.get(name)?.definition as SecuritySchemeObject
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
document: documentTemplate(document, rootSecurity),
|
|
70
|
+
operations,
|
|
71
|
+
components: schemas.components(),
|
|
72
|
+
securitySchemes,
|
|
73
|
+
slots: slots.list(),
|
|
74
|
+
errors: errorsOf(document.errors, errorsSlot?.name),
|
|
75
|
+
tagDescriptions,
|
|
76
|
+
root: ctx.options.root,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function errorsOf(
|
|
81
|
+
errors: false | { readonly contentType: string },
|
|
82
|
+
schema: string | undefined,
|
|
83
|
+
): OpenApiModel['errors'] {
|
|
84
|
+
if (errors === false) return undefined
|
|
85
|
+
return schema === undefined ? { contentType: errors.contentType } : { contentType: errors.contentType, schema }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Location of the declaration of an identity (`export const CreateBook = ...`). */
|
|
89
|
+
function declarationOf(ctx: ValidateContext<OpenApiStored>, identity: string | undefined): Location | undefined {
|
|
90
|
+
if (identity === undefined) return undefined
|
|
91
|
+
const symbol = ctx.project.lookupIdentity(identity)
|
|
92
|
+
if (symbol.kind !== 'declaration') return undefined
|
|
93
|
+
const declaration = symbol.declarations[0]
|
|
94
|
+
if (declaration === undefined) return undefined
|
|
95
|
+
return ctx.project.getFile(declaration.file)?.location(declaration.span)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `{ file, line, column }` of a location, the file relative to `root`. */
|
|
99
|
+
export function sourceOf(location: Location, root: string): { file: string; line: number; column: number } {
|
|
100
|
+
const file = location.file.startsWith(`${root}/`) ? location.file.slice(root.length + 1) : location.file
|
|
101
|
+
return { file, line: location.line, column: location.column }
|
|
102
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading statically evaluated values (`StaticValue`) without importing the compiler: references
|
|
3
|
+
* to exported bindings (`ValueRef`) are recognised structurally.
|
|
4
|
+
*/
|
|
5
|
+
import type { StaticValue, ValueRef } from '@znight/compiler'
|
|
6
|
+
import type { JsonValue } from '../types.ts'
|
|
7
|
+
|
|
8
|
+
/** `true` for a `ValueRef` (a reference to an exported binding, nested in an evaluated value). */
|
|
9
|
+
export function isRef(value: unknown): value is ValueRef {
|
|
10
|
+
if (typeof value !== 'object' || value === null) return false
|
|
11
|
+
const prototype = Object.getPrototypeOf(value)
|
|
12
|
+
return prototype !== Object.prototype && prototype !== null && (value as { kind?: unknown }).kind === 'ref'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `true` for an evaluated object literal (not an array, not a reference). */
|
|
16
|
+
export function isRecord(value: unknown): value is Readonly<Record<string, StaticValue>> {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && !isRef(value)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The value when it is JSON (no `undefined` inside objects is kept, no bigint, no reference). */
|
|
21
|
+
export function toJson(value: StaticValue): JsonValue | undefined {
|
|
22
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
|
23
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
|
|
24
|
+
if (value === undefined || typeof value === 'bigint' || isRef(value)) return undefined
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
const items: JsonValue[] = []
|
|
27
|
+
for (const item of value as readonly StaticValue[]) {
|
|
28
|
+
const json = toJson(item)
|
|
29
|
+
if (json === undefined) return undefined
|
|
30
|
+
items.push(json)
|
|
31
|
+
}
|
|
32
|
+
return items
|
|
33
|
+
}
|
|
34
|
+
const result: Record<string, JsonValue> = {}
|
|
35
|
+
for (const [key, item] of Object.entries(value as Readonly<Record<string, StaticValue>>)) {
|
|
36
|
+
if (item === undefined) continue
|
|
37
|
+
const json = toJson(item)
|
|
38
|
+
if (json === undefined) return undefined
|
|
39
|
+
result[key] = json
|
|
40
|
+
}
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The first `$znight` key found in a value (reserved), as a path (`info.x.$znight`). */
|
|
45
|
+
export function findReservedKey(value: StaticValue, path = ''): string | undefined {
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
for (const [index, item] of (value as readonly StaticValue[]).entries()) {
|
|
48
|
+
const found = findReservedKey(item, `${path}[${index}]`)
|
|
49
|
+
if (found !== undefined) return found
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
if (!isRecord(value)) return undefined
|
|
54
|
+
for (const [key, item] of Object.entries(value)) {
|
|
55
|
+
const where = path === '' ? key : `${path}.${key}`
|
|
56
|
+
if (key === '$znight') return where
|
|
57
|
+
const found = findReservedKey(item, where)
|
|
58
|
+
if (found !== undefined) return found
|
|
59
|
+
}
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A short description for messages. */
|
|
64
|
+
export function describe(value: StaticValue): string {
|
|
65
|
+
if (value === null) return 'null'
|
|
66
|
+
if (Array.isArray(value)) return 'an array'
|
|
67
|
+
if (isRef(value)) return `a reference to '${value.text}'`
|
|
68
|
+
if (typeof value === 'object') return 'an object'
|
|
69
|
+
if (typeof value === 'string') return JSON.stringify(value)
|
|
70
|
+
return String(value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Absolute URL (`https://...`, `mailto:`...). */
|
|
74
|
+
export function isAbsoluteUrl(value: string): boolean {
|
|
75
|
+
return URL.canParse(value)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Absolute or relative URL (servers: `/api`, `https://api.example.com/v1`). */
|
|
79
|
+
export function isUrlReference(value: string): boolean {
|
|
80
|
+
return value !== '' && !/\s/.test(value) && (URL.canParse(value) || URL.canParse(value, 'https://example.com'))
|
|
81
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard Schema → JSON Schema converters (§7.3 of RFC 0002). The library that defines a schema is
|
|
3
|
+
* the only one that knows its JSON Schema: Zod, ArkType and the libraries implementing Standard
|
|
4
|
+
* JSON Schema convert their own schemas; Valibot goes through `@valibot/to-json-schema`.
|
|
5
|
+
*/
|
|
6
|
+
import type { StandardSchemaV1 } from '@znight/http'
|
|
7
|
+
import type { JsonSchema } from './types.ts'
|
|
8
|
+
|
|
9
|
+
/** What a converter receives next to the schema. */
|
|
10
|
+
export interface SchemaConverterContext {
|
|
11
|
+
/** `input` for requests (what clients send), `output` for responses (what the server sends). */
|
|
12
|
+
readonly io: 'input' | 'output'
|
|
13
|
+
readonly target: 'draft-2020-12'
|
|
14
|
+
/** Retry after a failure with `unrepresentable: 'warn'`: approximate instead of throwing. */
|
|
15
|
+
readonly lenient: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Converts Standard Schemas of one library to JSON Schema 2020-12. */
|
|
19
|
+
export interface SchemaConverter {
|
|
20
|
+
readonly name: string
|
|
21
|
+
/** `undefined`: not supported, the next converter is tried. Throwing reports ZNIGHT037. */
|
|
22
|
+
convert(
|
|
23
|
+
schema: StandardSchemaV1,
|
|
24
|
+
context: SchemaConverterContext,
|
|
25
|
+
): JsonSchema | undefined | Promise<JsonSchema | undefined>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The Standard JSON Schema interface (`~standard.jsonSchema`). */
|
|
29
|
+
interface StandardJsonSchemaProps {
|
|
30
|
+
readonly vendor?: unknown
|
|
31
|
+
readonly jsonSchema?: {
|
|
32
|
+
readonly input?: (options: { target: string; libraryOptions?: Record<string, unknown> }) => unknown
|
|
33
|
+
readonly output?: (options: { target: string; libraryOptions?: Record<string, unknown> }) => unknown
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function propsOf(schema: StandardSchemaV1): StandardJsonSchemaProps | undefined {
|
|
38
|
+
const props = (schema as unknown as { '~standard'?: unknown })['~standard']
|
|
39
|
+
return typeof props === 'object' && props !== null ? (props as StandardJsonSchemaProps) : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `vendor` of a Standard Schema (`zod`, `valibot`, `arktype`...). */
|
|
43
|
+
export function vendorOf(schema: StandardSchemaV1): string {
|
|
44
|
+
const vendor = propsOf(schema)?.vendor
|
|
45
|
+
return typeof vendor === 'string' ? vendor : 'unknown'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How a date travels in JSON: an ISO 8601 string. */
|
|
49
|
+
const DATE_TIME: JsonSchema = Object.freeze({ type: 'string', format: 'date-time' })
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Zod dates are serialised with `Date.prototype.toJSON`: in responses they are ISO strings. A
|
|
53
|
+
* coerced date (`z.coerce.date()`) reads a string in requests (query, path, body). Other
|
|
54
|
+
* unrepresentable parts throw (or become `{}` when lenient).
|
|
55
|
+
*/
|
|
56
|
+
function zodOptions(context: SchemaConverterContext): Record<string, unknown> {
|
|
57
|
+
return {
|
|
58
|
+
unrepresentable: (site: { zodSchema?: { _zod?: { def?: { type?: unknown; coerce?: unknown } } } }) => {
|
|
59
|
+
const def = site.zodSchema?._zod?.def
|
|
60
|
+
if (def?.type === 'date' && (context.io === 'output' || def.coerce === true)) return { ...DATE_TIME }
|
|
61
|
+
return context.lenient ? 'any' : 'throw'
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `@valibot/to-json-schema` options: dates of responses are ISO strings (`Date.prototype.toJSON`),
|
|
68
|
+
* other unrepresentable parts throw (or are left out when lenient).
|
|
69
|
+
*/
|
|
70
|
+
function valibotOptions(context: SchemaConverterContext): Record<string, unknown> {
|
|
71
|
+
return {
|
|
72
|
+
...(context.lenient ? { errorMode: 'ignore' } : {}),
|
|
73
|
+
overrideSchema: (site: { valibotSchema?: { type?: unknown } }) =>
|
|
74
|
+
context.io === 'output' && site.valibotSchema?.type === 'date' ? { ...DATE_TIME } : undefined,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function callStandard(
|
|
79
|
+
props: StandardJsonSchemaProps | undefined,
|
|
80
|
+
context: SchemaConverterContext,
|
|
81
|
+
libraryOptions?: Record<string, unknown>,
|
|
82
|
+
): unknown {
|
|
83
|
+
const convert = props?.jsonSchema?.[context.io]
|
|
84
|
+
if (typeof convert !== 'function') return undefined
|
|
85
|
+
return convert.call(props?.jsonSchema, {
|
|
86
|
+
target: context.target,
|
|
87
|
+
...(libraryOptions === undefined ? {} : { libraryOptions }),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Standard JSON Schema (`schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' })`): Zod ≥ 4.2,
|
|
93
|
+
* ArkType ≥ 2.1.28 and every library implementing the interface.
|
|
94
|
+
*/
|
|
95
|
+
export const standardJsonSchemaConverter: SchemaConverter = Object.freeze({
|
|
96
|
+
name: 'standard-json-schema',
|
|
97
|
+
convert(schema: StandardSchemaV1, context: SchemaConverterContext): JsonSchema | undefined {
|
|
98
|
+
const props = propsOf(schema)
|
|
99
|
+
if (typeof props?.jsonSchema?.[context.io] !== 'function') return undefined
|
|
100
|
+
const result = callStandard(props, context, props.vendor === 'zod' ? zodOptions(context) : undefined)
|
|
101
|
+
return result as JsonSchema | undefined
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/** Valibot schemas (no `~standard.jsonSchema`) through `toStandardJsonSchema()` of `@valibot/to-json-schema`. */
|
|
106
|
+
export const valibotConverter: SchemaConverter = Object.freeze({
|
|
107
|
+
name: 'valibot',
|
|
108
|
+
async convert(schema: StandardSchemaV1, context: SchemaConverterContext): Promise<JsonSchema | undefined> {
|
|
109
|
+
const props = propsOf(schema)
|
|
110
|
+
if (props?.vendor !== 'valibot' || props.jsonSchema !== undefined) return undefined
|
|
111
|
+
let module: { toStandardJsonSchema?: (schema: unknown) => unknown }
|
|
112
|
+
try {
|
|
113
|
+
module = (await import('@valibot/to-json-schema')) as typeof module
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'Valibot schemas need the optional peer @valibot/to-json-schema (npm install @valibot/to-json-schema)',
|
|
117
|
+
{
|
|
118
|
+
cause: error,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
if (typeof module.toStandardJsonSchema !== 'function') {
|
|
123
|
+
throw new Error('@valibot/to-json-schema has no toStandardJsonSchema(): install version 1.5 or later')
|
|
124
|
+
}
|
|
125
|
+
// options go through `libraryOptions` (toStandardJsonSchema takes the schema only)
|
|
126
|
+
const standard = module.toStandardJsonSchema(schema)
|
|
127
|
+
return callStandard(propsOf(standard as StandardSchemaV1), context, valibotOptions(context)) as
|
|
128
|
+
| JsonSchema
|
|
129
|
+
| undefined
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
/** Built-in converters, tried after the user's. */
|
|
134
|
+
export const BUILT_IN_CONVERTERS: readonly SchemaConverter[] = Object.freeze([
|
|
135
|
+
standardJsonSchemaConverter,
|
|
136
|
+
valibotConverter,
|
|
137
|
+
])
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decorators of `@znight/openapi`. Like every Znight decorator they do nothing at runtime: the
|
|
3
|
+
* compiler extension (`@znight/openapi/compiler`) reads them, type arguments included
|
|
4
|
+
* (`@ApiResponse<Book[]>(200)`), and the Vite plugin strips them from the bundle.
|
|
5
|
+
*/
|
|
6
|
+
import type { ClassMarker, MethodMarker } from '@znight/core'
|
|
7
|
+
import type { RouteDecorator, StandardSchemaV1 } from '@znight/http'
|
|
8
|
+
import type {
|
|
9
|
+
ExternalDocumentationObject,
|
|
10
|
+
HeaderObject,
|
|
11
|
+
InfoObject,
|
|
12
|
+
JsonValue,
|
|
13
|
+
SecuritySchemeObject,
|
|
14
|
+
ServerObject,
|
|
15
|
+
TagObject,
|
|
16
|
+
} from './types.ts'
|
|
17
|
+
|
|
18
|
+
function marker(): void {
|
|
19
|
+
// intentionally empty: decorators are compile-time markers
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const classMarker: ClassMarker = marker
|
|
23
|
+
const methodMarker: MethodMarker = marker
|
|
24
|
+
const classOrMethodMarker: ClassMarker & MethodMarker = marker
|
|
25
|
+
|
|
26
|
+
/** A security scheme declared with {@link securityScheme}. */
|
|
27
|
+
export interface SecurityScheme {
|
|
28
|
+
/** Key of the scheme in `components.securitySchemes`. */
|
|
29
|
+
readonly name: string
|
|
30
|
+
readonly definition: SecuritySchemeObject
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One scheme, several schemes that all apply (AND), or a scheme with scopes. */
|
|
34
|
+
export type SecurityRequirementInput =
|
|
35
|
+
| SecurityScheme
|
|
36
|
+
| readonly SecurityScheme[]
|
|
37
|
+
| { readonly scheme: SecurityScheme; readonly scopes: readonly string[] }
|
|
38
|
+
|
|
39
|
+
/** Security of the routes carrying a route decorator (§5.5 of RFC 0002). */
|
|
40
|
+
export interface SecurityDecision {
|
|
41
|
+
/** A route decorator (`createRouteDecorator`) found in the route's effective metadata. */
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: any route decorator value type is accepted
|
|
43
|
+
readonly when: RouteDecorator<any>
|
|
44
|
+
/** Requirements of the matching routes; `[]` means public. */
|
|
45
|
+
readonly security: readonly SecurityRequirementInput[]
|
|
46
|
+
/** `'value'`: the decorator value (a static string array) becomes the scopes of every requirement. */
|
|
47
|
+
readonly scopes?: 'value'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Style of the default operation ids. */
|
|
51
|
+
export type OperationIdStyle = 'controller.handler' | 'handler' | 'Controller_handler'
|
|
52
|
+
|
|
53
|
+
/** Options of {@link OpenApiDocument}. */
|
|
54
|
+
export interface OpenApiDocumentOptions {
|
|
55
|
+
/** Title, version, description... (defaults: the JSDoc of the class, then `package.json`). */
|
|
56
|
+
readonly info?: Partial<InfoObject>
|
|
57
|
+
readonly servers?: readonly ServerObject[]
|
|
58
|
+
/** Declared tags first, in this order; used tags are added, sorted. */
|
|
59
|
+
readonly tags?: readonly TagObject[]
|
|
60
|
+
readonly externalDocs?: ExternalDocumentationObject
|
|
61
|
+
/** Default requirements of every operation: entries are alternatives (OR). Default: none. */
|
|
62
|
+
readonly security?: readonly SecurityRequirementInput[]
|
|
63
|
+
/** Security derived from route decorators; the first matching entry wins. */
|
|
64
|
+
readonly decisions?: readonly SecurityDecision[]
|
|
65
|
+
/** Ids of operations without `@ApiOperation({ operationId })`. Default `'controller.handler'`. */
|
|
66
|
+
readonly operationIds?: OperationIdStyle
|
|
67
|
+
/** Standard error responses (400, 413, 415, declared 4XX/5XX). Default `true`. */
|
|
68
|
+
readonly errors?: boolean | { readonly schema: StandardSchemaV1; readonly contentType?: string }
|
|
69
|
+
/** ZNIGHT031 and ZNIGHT032 become errors. Default `false`. */
|
|
70
|
+
readonly strict?: boolean
|
|
71
|
+
/** Read the JSDoc of controllers, handlers and types. Default `true`. */
|
|
72
|
+
readonly jsdoc?: boolean
|
|
73
|
+
/** `x-*` fields of the root object. */
|
|
74
|
+
readonly extensions?: Readonly<Record<`x-${string}`, JsonValue>>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Options of the document (one per application, optional). The class is never instantiated nor
|
|
79
|
+
* registered as a provider; its JSDoc gives the title (first line) and the description (the rest).
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* /** Bookshelf API *\/
|
|
83
|
+
* @OpenApiDocument({ servers: [{ url: '/api' }], security: [ApiKey], decisions: [{ when: Public, security: [] }] })
|
|
84
|
+
* export class BookshelfApi {}
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function OpenApiDocument(_options?: OpenApiDocumentOptions): ClassMarker {
|
|
88
|
+
return classMarker
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Declares a security scheme, read statically by the compiler. At runtime it returns a frozen
|
|
93
|
+
* `{ name, definition }`.
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* export const ApiKey = securityScheme('apiKey', { type: 'apiKey', in: 'header', name: 'x-api-key' })
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export function securityScheme(name: string, definition: SecuritySchemeObject): SecurityScheme {
|
|
100
|
+
return Object.freeze({ name, definition })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Options of {@link ApiOperation}. */
|
|
104
|
+
export interface ApiOperationOptions {
|
|
105
|
+
readonly summary?: string
|
|
106
|
+
readonly description?: string
|
|
107
|
+
readonly operationId?: string
|
|
108
|
+
readonly deprecated?: boolean
|
|
109
|
+
readonly externalDocs?: ExternalDocumentationObject
|
|
110
|
+
readonly extensions?: Readonly<Record<`x-${string}`, JsonValue>>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Overrides the JSDoc and the defaults of an operation. */
|
|
114
|
+
export function ApiOperation(_options: ApiOperationOptions): MethodMarker {
|
|
115
|
+
return methodMarker
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Tags of the operations (class and method tags are merged, class tags first). */
|
|
119
|
+
export function ApiTags(..._names: [string, ...string[]]): ClassMarker & MethodMarker {
|
|
120
|
+
return classOrMethodMarker
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A response status: a code, `'2XX'`-like ranges, or `'default'`. */
|
|
124
|
+
export type ApiResponseStatus = number | `${1 | 2 | 3 | 4 | 5}XX` | 'default'
|
|
125
|
+
|
|
126
|
+
/** Options of {@link ApiResponse}. */
|
|
127
|
+
export interface ApiResponseOptions {
|
|
128
|
+
readonly description?: string
|
|
129
|
+
/** Body schema (a Standard Schema, documented with its output JSON Schema). */
|
|
130
|
+
readonly schema?: StandardSchemaV1
|
|
131
|
+
/** Media type(s) of the body. Default `application/json` (when the body has a schema). */
|
|
132
|
+
readonly contentType?: string | readonly string[]
|
|
133
|
+
readonly headers?: Readonly<Record<string, Partial<HeaderObject>>>
|
|
134
|
+
readonly example?: JsonValue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Declares or overrides a response (repeatable). The body type comes from `T`
|
|
139
|
+
* (`@ApiResponse<Book>(200)`) or from a Standard Schema in `schema`.
|
|
140
|
+
*/
|
|
141
|
+
export function ApiResponse<_T = never>(_status: ApiResponseStatus, _options?: ApiResponseOptions): MethodMarker {
|
|
142
|
+
return methodMarker
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Options of {@link ApiBody}. */
|
|
146
|
+
export interface ApiBodyOptions {
|
|
147
|
+
readonly description?: string
|
|
148
|
+
readonly required?: boolean
|
|
149
|
+
readonly contentType?: string | readonly string[]
|
|
150
|
+
readonly schema?: StandardSchemaV1
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The body of a route that reads it itself (`ctx.parseBody()`, `ctx.bodyStream()`). */
|
|
154
|
+
export function ApiBody<_T = never>(_options?: ApiBodyOptions): MethodMarker {
|
|
155
|
+
return methodMarker
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Options of {@link ApiQuery} and {@link ApiHeader}. */
|
|
159
|
+
export interface ApiParameterOptions {
|
|
160
|
+
readonly description?: string
|
|
161
|
+
readonly required?: boolean
|
|
162
|
+
readonly deprecated?: boolean
|
|
163
|
+
readonly example?: JsonValue
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A query parameter read outside the signature (`ctx.query`); its type is `T` (default `string`). */
|
|
167
|
+
export function ApiQuery<_T = string>(_name: string, _options?: ApiParameterOptions): MethodMarker {
|
|
168
|
+
return methodMarker
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A header read by a guard or an interceptor (`x-tenant`); its type is `T` (default `string`). */
|
|
172
|
+
export function ApiHeader<_T = string>(_name: string, _options?: ApiParameterOptions): ClassMarker & MethodMarker {
|
|
173
|
+
return classOrMethodMarker
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Security of the operations: the route's wins over the class's. Several arguments are
|
|
178
|
+
* alternatives (OR); no argument means public (`security: []`).
|
|
179
|
+
*/
|
|
180
|
+
export function ApiSecurity(..._requirements: SecurityRequirementInput[]): ClassMarker & MethodMarker {
|
|
181
|
+
return classOrMethodMarker
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The controller or route is not documented. */
|
|
185
|
+
export function ApiExclude(): ClassMarker & MethodMarker {
|
|
186
|
+
return classOrMethodMarker
|
|
187
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The child process of `emitOpenApi()` (§7.5 of RFC 0002): loads the generated module of the
|
|
3
|
+
* application (nothing is instantiated: no container, no plugin, no port), builds the document and
|
|
4
|
+
* writes the result as JSON. Module side effects and globals stay in this process.
|
|
5
|
+
*
|
|
6
|
+
* - Bun: the compiler's preload serves the generated module and blanks the decorators.
|
|
7
|
+
* - Node.js: a Vite server in middleware mode runs the module with the project config (the
|
|
8
|
+
* `znight()` plugin serves the generated module from memory).
|
|
9
|
+
*
|
|
10
|
+
* Usage: `<runtime> child <payload.json>` (the payload says where to write the result).
|
|
11
|
+
*/
|
|
12
|
+
import type { ZnightMetadata } from '@znight/core'
|
|
13
|
+
import type { SchemaConverter } from '../converters.ts'
|
|
14
|
+
import type { ChildPayload, ChildResult } from './protocol.ts'
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { createRequire } from 'node:module'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { pathToFileURL } from 'node:url'
|
|
20
|
+
|
|
21
|
+
import { buildOpenApiDocument } from '../build.ts'
|
|
22
|
+
|
|
23
|
+
const payload = JSON.parse(readFileSync(process.argv[2] ?? '', 'utf8')) as ChildPayload
|
|
24
|
+
|
|
25
|
+
/** Imports a package as installed in the project (falls back to `@znight/openapi`'s resolution). */
|
|
26
|
+
function importFromProject(specifier: string): Promise<unknown> {
|
|
27
|
+
let resolved: string | undefined
|
|
28
|
+
try {
|
|
29
|
+
resolved = createRequire(join(payload.root, 'package.json')).resolve(specifier)
|
|
30
|
+
} catch {
|
|
31
|
+
resolved = undefined
|
|
32
|
+
}
|
|
33
|
+
return import(resolved === undefined ? specifier : pathToFileURL(resolved).href)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function convertersOf(module: unknown, file: string): SchemaConverter[] {
|
|
37
|
+
const value = (module as { default?: unknown } | undefined)?.default
|
|
38
|
+
if (!Array.isArray(value) || value.some(converter => typeof (converter as SchemaConverter)?.convert !== 'function')) {
|
|
39
|
+
throw new Error(`${file} must export an array of SchemaConverter as default export`)
|
|
40
|
+
}
|
|
41
|
+
return value as SchemaConverter[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Loaded {
|
|
45
|
+
readonly metadata: ZnightMetadata
|
|
46
|
+
readonly converters: SchemaConverter[]
|
|
47
|
+
close(): Promise<void>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function loadWithBun(): Promise<Loaded> {
|
|
51
|
+
const compiler = (await importFromProject('@znight/compiler')) as {
|
|
52
|
+
createPreload(options: Record<string, unknown>): Promise<unknown>
|
|
53
|
+
loadZnightConfig(cwd: string): Promise<{ file: string | undefined; options: Record<string, unknown> }>
|
|
54
|
+
}
|
|
55
|
+
const config = await compiler.loadZnightConfig(payload.root)
|
|
56
|
+
const options = config.file === undefined ? payload.compilerOptions : config.options
|
|
57
|
+
await compiler.createPreload({ ...options, root: payload.root, mode: 'serve', logLevel: 'silent' })
|
|
58
|
+
const module = (await import(pathToFileURL(payload.outFile).href)) as { __ZNIGHT_METADATA__: ZnightMetadata }
|
|
59
|
+
const converters =
|
|
60
|
+
payload.converters === undefined
|
|
61
|
+
? []
|
|
62
|
+
: convertersOf(await import(pathToFileURL(payload.converters).href), payload.converters)
|
|
63
|
+
return { metadata: module.__ZNIGHT_METADATA__, converters, close: async () => {} }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ViteLike {
|
|
67
|
+
createServer(config: Record<string, unknown>): Promise<{
|
|
68
|
+
readonly environments: Record<string, unknown>
|
|
69
|
+
close(): Promise<void>
|
|
70
|
+
}>
|
|
71
|
+
isRunnableDevEnvironment?(environment: unknown): boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function loadWithVite(): Promise<Loaded> {
|
|
75
|
+
const vite = (await importFromProject('vite')) as ViteLike
|
|
76
|
+
const plugins: unknown[] = []
|
|
77
|
+
if (payload.viteConfig === false) {
|
|
78
|
+
const compiler = (await importFromProject('@znight/compiler')) as { znight(options: unknown): unknown }
|
|
79
|
+
plugins.push(compiler.znight({ ...payload.compilerOptions, root: payload.root }))
|
|
80
|
+
}
|
|
81
|
+
const server = await vite.createServer({
|
|
82
|
+
root: payload.root,
|
|
83
|
+
configFile: payload.viteConfig,
|
|
84
|
+
...(payload.configLoader === undefined ? {} : { configLoader: payload.configLoader }),
|
|
85
|
+
appType: 'custom',
|
|
86
|
+
logLevel: 'error',
|
|
87
|
+
clearScreen: false,
|
|
88
|
+
server: { middlewareMode: true, hmr: false, watch: null, ws: false },
|
|
89
|
+
plugins,
|
|
90
|
+
...(payload.sourceConditions === true
|
|
91
|
+
? {
|
|
92
|
+
environments: {
|
|
93
|
+
ssr: {
|
|
94
|
+
resolve: {
|
|
95
|
+
conditions: ['@znight/source', 'module', 'node', 'development|production'],
|
|
96
|
+
externalConditions: ['@znight/source', 'node'],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
: {}),
|
|
102
|
+
})
|
|
103
|
+
try {
|
|
104
|
+
const environment = server.environments['ssr'] as { runner?: { import(url: string): Promise<unknown> } }
|
|
105
|
+
if (typeof vite.isRunnableDevEnvironment === 'function' && !vite.isRunnableDevEnvironment(environment)) {
|
|
106
|
+
throw new Error('the ssr environment of the Vite config cannot run modules (isRunnableDevEnvironment)')
|
|
107
|
+
}
|
|
108
|
+
const runner = environment.runner
|
|
109
|
+
if (runner === undefined) throw new Error('the ssr environment of the Vite config has no module runner')
|
|
110
|
+
const module = (await runner.import(payload.outFile)) as { __ZNIGHT_METADATA__: ZnightMetadata }
|
|
111
|
+
const converters =
|
|
112
|
+
payload.converters === undefined ? [] : convertersOf(await runner.import(payload.converters), payload.converters)
|
|
113
|
+
return { metadata: module.__ZNIGHT_METADATA__, converters, close: () => server.close() }
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await server.close()
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let result: ChildResult
|
|
121
|
+
try {
|
|
122
|
+
const loaded = payload.runtime === 'bun' ? await loadWithBun() : await loadWithVite()
|
|
123
|
+
try {
|
|
124
|
+
const built = await buildOpenApiDocument(loaded.metadata, {
|
|
125
|
+
...(payload.prefix === undefined ? {} : { prefix: payload.prefix }),
|
|
126
|
+
converters: loaded.converters,
|
|
127
|
+
})
|
|
128
|
+
result = { ok: true, json: built.json, warnings: built.warnings, stats: built.stats }
|
|
129
|
+
} finally {
|
|
130
|
+
await loaded.close()
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const details = error as { code?: unknown; hints?: unknown; message?: unknown }
|
|
134
|
+
result = {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: {
|
|
137
|
+
code: typeof details.code === 'string' ? details.code : undefined,
|
|
138
|
+
message: error instanceof Error ? error.message : String(error),
|
|
139
|
+
hints: Array.isArray(details.hints) ? (details.hints as string[]) : [],
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
writeFileSync(payload.result, JSON.stringify(result))
|
|
144
|
+
process.exit(0)
|