@znight/openapi 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +572 -2
  3. package/dist/build-BTUC96mY.d.mts +174 -0
  4. package/dist/build-DXunOzlI.mjs +733 -0
  5. package/dist/build-DXunOzlI.mjs.map +1 -0
  6. package/dist/compiler.d.mts +16 -0
  7. package/dist/compiler.mjs +2639 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/emit-child.d.mts +1 -0
  10. package/dist/emit-child.mjs +124 -0
  11. package/dist/emit-child.mjs.map +1 -0
  12. package/dist/emit.d.mts +2 -0
  13. package/dist/emit.mjs +252 -0
  14. package/dist/emit.mjs.map +1 -0
  15. package/dist/index-CqQ6IRnN.d.mts +60 -0
  16. package/dist/index.d.mts +260 -0
  17. package/dist/index.mjs +777 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/openapi-CQk5dHqc.mjs +39 -0
  20. package/dist/openapi-CQk5dHqc.mjs.map +1 -0
  21. package/dist/shared-BZxtyaB0.mjs +147 -0
  22. package/dist/shared-BZxtyaB0.mjs.map +1 -0
  23. package/dist/testing.d.mts +39 -0
  24. package/dist/testing.mjs +155 -0
  25. package/dist/testing.mjs.map +1 -0
  26. package/dist/types-VzBexMKj.d.mts +213 -0
  27. package/dist/vite.d.mts +24 -0
  28. package/dist/vite.mjs +64 -0
  29. package/dist/vite.mjs.map +1 -0
  30. package/package.json +115 -4
  31. package/src/build.ts +898 -0
  32. package/src/compiler/analyze.ts +81 -0
  33. package/src/compiler/document.ts +612 -0
  34. package/src/compiler/generate.ts +79 -0
  35. package/src/compiler/index.ts +47 -0
  36. package/src/compiler/model.ts +141 -0
  37. package/src/compiler/operations.ts +1475 -0
  38. package/src/compiler/schemas.ts +706 -0
  39. package/src/compiler/validate.ts +102 -0
  40. package/src/compiler/values.ts +81 -0
  41. package/src/converters.ts +137 -0
  42. package/src/decorators.ts +187 -0
  43. package/src/emit/child.ts +144 -0
  44. package/src/emit/index.ts +377 -0
  45. package/src/emit/protocol.ts +35 -0
  46. package/src/index.ts +94 -0
  47. package/src/metadata.ts +112 -0
  48. package/src/openapi.ts +55 -0
  49. package/src/plugin.ts +810 -0
  50. package/src/shared.ts +154 -0
  51. package/src/testing.ts +224 -0
  52. package/src/types.ts +240 -0
  53. package/src/ui.ts +215 -0
  54. package/src/vite.ts +126 -0
@@ -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)