@znight/openapi 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +572 -2
  3. package/dist/build-BTUC96mY.d.mts +174 -0
  4. package/dist/build-DXunOzlI.mjs +733 -0
  5. package/dist/build-DXunOzlI.mjs.map +1 -0
  6. package/dist/compiler.d.mts +16 -0
  7. package/dist/compiler.mjs +2639 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/emit-child.d.mts +1 -0
  10. package/dist/emit-child.mjs +124 -0
  11. package/dist/emit-child.mjs.map +1 -0
  12. package/dist/emit.d.mts +2 -0
  13. package/dist/emit.mjs +252 -0
  14. package/dist/emit.mjs.map +1 -0
  15. package/dist/index-CqQ6IRnN.d.mts +60 -0
  16. package/dist/index.d.mts +260 -0
  17. package/dist/index.mjs +777 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/openapi-CQk5dHqc.mjs +39 -0
  20. package/dist/openapi-CQk5dHqc.mjs.map +1 -0
  21. package/dist/shared-BZxtyaB0.mjs +147 -0
  22. package/dist/shared-BZxtyaB0.mjs.map +1 -0
  23. package/dist/testing.d.mts +39 -0
  24. package/dist/testing.mjs +155 -0
  25. package/dist/testing.mjs.map +1 -0
  26. package/dist/types-VzBexMKj.d.mts +213 -0
  27. package/dist/vite.d.mts +24 -0
  28. package/dist/vite.mjs +64 -0
  29. package/dist/vite.mjs.map +1 -0
  30. package/package.json +115 -4
  31. package/src/build.ts +898 -0
  32. package/src/compiler/analyze.ts +81 -0
  33. package/src/compiler/document.ts +612 -0
  34. package/src/compiler/generate.ts +79 -0
  35. package/src/compiler/index.ts +47 -0
  36. package/src/compiler/model.ts +141 -0
  37. package/src/compiler/operations.ts +1475 -0
  38. package/src/compiler/schemas.ts +706 -0
  39. package/src/compiler/validate.ts +102 -0
  40. package/src/compiler/values.ts +81 -0
  41. package/src/converters.ts +137 -0
  42. package/src/decorators.ts +187 -0
  43. package/src/emit/child.ts +144 -0
  44. package/src/emit/index.ts +377 -0
  45. package/src/emit/protocol.ts +35 -0
  46. package/src/index.ts +94 -0
  47. package/src/metadata.ts +112 -0
  48. package/src/openapi.ts +55 -0
  49. package/src/plugin.ts +810 -0
  50. package/src/shared.ts +154 -0
  51. package/src/testing.ts +224 -0
  52. package/src/types.ts +240 -0
  53. package/src/ui.ts +215 -0
  54. package/src/vite.ts +126 -0
@@ -0,0 +1,612 @@
1
+ /**
2
+ * `@OpenApiDocument` options and `securityScheme()` definitions, read statically and checked
3
+ * (ZNIGHT030 for malformed values, ZNIGHT036 for security requirements).
4
+ */
5
+ import type { DefinitionInfo, Location, StaticValue, ValidateContext, ValueRef } from '@znight/compiler'
6
+ import type { InfoObject, JsonValue, SecurityRequirementObject, SecuritySchemeObject, TagObject } from '../types.ts'
7
+ import type { OpenApiStored } from './model.ts'
8
+
9
+ import { OPENAPI_VERSION } from '../shared.ts'
10
+ import { Codes, HttpIds, Ids } from './model.ts'
11
+ import { describe, findReservedKey, isAbsoluteUrl, isRecord, isRef, isUrlReference, toJson } from './values.ts'
12
+
13
+ type Context = ValidateContext<OpenApiStored>
14
+
15
+ /** Style of default operation ids. */
16
+ export type OperationIdStyle = 'controller.handler' | 'handler' | 'Controller_handler'
17
+
18
+ /** A resolved `securityScheme()`. */
19
+ export interface SchemeModel {
20
+ readonly identity: string
21
+ readonly name: string
22
+ readonly definition: SecuritySchemeObject
23
+ readonly location: Location
24
+ /** Scopes declared by the flows (oauth2), `undefined` when the scheme takes no scope check. */
25
+ readonly scopes: ReadonlySet<string> | undefined
26
+ readonly scoped: boolean
27
+ }
28
+
29
+ /** A requirement input resolved to schemes (AND), each with its scopes. */
30
+ export type RequirementModel = readonly {
31
+ readonly scheme: SchemeModel
32
+ readonly scopes: readonly string[] | undefined
33
+ }[]
34
+
35
+ /** A `decisions` entry. */
36
+ export interface DecisionModel {
37
+ readonly identity: string
38
+ readonly text: string
39
+ readonly location: Location
40
+ readonly security: readonly RequirementModel[]
41
+ readonly scopesFromValue: boolean
42
+ used: boolean
43
+ }
44
+
45
+ /** The checked document options. */
46
+ export interface DocumentModel {
47
+ readonly identity: string | undefined
48
+ readonly location: Location | undefined
49
+ readonly info: InfoObject
50
+ readonly servers: readonly JsonValue[] | undefined
51
+ readonly tags: readonly TagObject[]
52
+ readonly externalDocs: JsonValue | undefined
53
+ readonly security: readonly RequirementModel[] | undefined
54
+ readonly decisions: readonly DecisionModel[]
55
+ readonly operationIds: OperationIdStyle
56
+ readonly errors: false | { readonly contentType: string; readonly schema: ValueRef | undefined }
57
+ readonly strict: boolean
58
+ readonly jsdoc: boolean
59
+ readonly extensions: Readonly<Record<string, JsonValue>>
60
+ }
61
+
62
+ const OPTION_KEYS = new Set([
63
+ 'info',
64
+ 'servers',
65
+ 'tags',
66
+ 'externalDocs',
67
+ 'security',
68
+ 'decisions',
69
+ 'operationIds',
70
+ 'errors',
71
+ 'strict',
72
+ 'jsdoc',
73
+ 'extensions',
74
+ ])
75
+ const INFO_KEYS = new Set(['title', 'version', 'summary', 'description', 'termsOfService', 'contact', 'license'])
76
+ const SPDX = /^[A-Za-z0-9.+-]+(?:\s+(?:AND|OR|WITH)\s+[A-Za-z0-9.+-]+)*$/
77
+
78
+ /** Security schemes declared with `securityScheme(name, definition)`, by identity of their `const`. */
79
+ export class SchemeRegistry {
80
+ readonly #ctx: Context
81
+ readonly #schemes = new Map<string, SchemeModel | null>()
82
+ readonly #byName = new Map<string, SchemeModel>()
83
+ readonly #definitions: ReadonlyMap<string, DefinitionInfo>
84
+ /** Schemes referenced by the document, by name. */
85
+ readonly used: Map<string, SchemeModel> = new Map()
86
+
87
+ constructor(ctx: Context) {
88
+ this.#ctx = ctx
89
+ this.#definitions = new Map(
90
+ ctx.definitionsOf(Ids.securityScheme).map(definition => [definition.identity, definition]),
91
+ )
92
+ }
93
+
94
+ /** The scheme a reference points to (`undefined` and a ZNIGHT036 when it is not a scheme). */
95
+ resolve(ref: ValueRef): SchemeModel | undefined {
96
+ const identity = ref.identity
97
+ if (identity !== undefined && this.#schemes.has(identity)) return this.#schemes.get(identity) ?? undefined
98
+ const definition = identity === undefined ? undefined : this.#definitions.get(identity)
99
+ if (definition === undefined || identity === undefined) {
100
+ this.#ctx.report({
101
+ code: Codes.InvalidSecurityRequirement,
102
+ severity: 'error',
103
+ message: `${ref.text} is not a security scheme declared with securityScheme()`,
104
+ location: ref.location,
105
+ hints: [
106
+ "Declare it: export const ApiKey = securityScheme('apiKey', { type: 'apiKey', in: 'header', name: 'x-api-key' })",
107
+ ],
108
+ })
109
+ return undefined
110
+ }
111
+ const scheme = this.#parse(definition)
112
+ this.#schemes.set(identity, scheme ?? null)
113
+ return scheme
114
+ }
115
+
116
+ #parse(definition: DefinitionInfo): SchemeModel | undefined {
117
+ const [nameArg, definitionArg] = definition.args
118
+ const name = nameArg?.evaluated ? nameArg.value : undefined
119
+ const value = definitionArg?.evaluated ? definitionArg.value : undefined
120
+ const malformed = (message: string, location: Location = definition.call): undefined => {
121
+ this.#ctx.report({
122
+ code: Codes.InvalidOpenApiDefinition,
123
+ severity: 'error',
124
+ message: `Invalid security scheme ${definition.displayName}: ${message}`,
125
+ location,
126
+ hints: ['securityScheme(name, definition) takes a static name and an OpenAPI Security Scheme Object'],
127
+ })
128
+ return undefined
129
+ }
130
+ if (typeof name !== 'string' || !/^[A-Za-z0-9._-]+$/.test(name)) {
131
+ return malformed('the name must be a static string of [A-Za-z0-9._-]', nameArg?.location)
132
+ }
133
+ if (!isRecord(value)) return malformed('the definition must be a static object', definitionArg?.location)
134
+ const reserved = findReservedKey(value)
135
+ if (reserved !== undefined) return malformed(`"$znight" is a reserved key (${reserved})`, definitionArg?.location)
136
+ const json = toJson(value)
137
+ if (!isRecord(json)) return malformed('the definition must be JSON', definitionArg?.location)
138
+ const where = definitionArg?.location
139
+ const text = (key: string, required: boolean): string | undefined => {
140
+ const field = json[key]
141
+ if (field === undefined && !required) return undefined
142
+ if (typeof field !== 'string' || field === '') throw new SchemeError(`"${key}" must be a non-empty string`)
143
+ return field
144
+ }
145
+ let scopes: Set<string> | undefined
146
+ try {
147
+ for (const key of Object.keys(json)) {
148
+ if (!ALLOWED_SCHEME_KEYS[String(json['type'])]?.has(key) && !key.startsWith('x-') && key !== 'description') {
149
+ throw new SchemeError(`unknown field "${key}" for a ${String(json['type'])} scheme`)
150
+ }
151
+ }
152
+ switch (json['type']) {
153
+ case 'apiKey': {
154
+ text('name', true)
155
+ const location = text('in', true)
156
+ if (location !== 'query' && location !== 'header' && location !== 'cookie') {
157
+ throw new SchemeError('"in" must be "query", "header" or "cookie"')
158
+ }
159
+ break
160
+ }
161
+ case 'http':
162
+ text('scheme', true)
163
+ text('bearerFormat', false)
164
+ break
165
+ case 'oauth2': {
166
+ const flows = json['flows']
167
+ if (!isRecord(flows) || Object.keys(flows).length === 0) throw new SchemeError('"flows" must declare a flow')
168
+ scopes = new Set()
169
+ for (const [flow, body] of Object.entries(flows)) {
170
+ if (!FLOW_URLS[flow]) throw new SchemeError(`unknown flow "${flow}"`)
171
+ if (!isRecord(body) || !isRecord(body['scopes'])) throw new SchemeError(`the ${flow} flow needs "scopes"`)
172
+ for (const url of FLOW_URLS[flow] ?? []) {
173
+ const field = body[url]
174
+ if (typeof field !== 'string' || !isUrlReference(field)) {
175
+ throw new SchemeError(`the ${flow} flow needs a "${url}" URL`)
176
+ }
177
+ }
178
+ for (const scope of Object.keys(body['scopes'])) scopes.add(scope)
179
+ }
180
+ break
181
+ }
182
+ case 'openIdConnect': {
183
+ const url = text('openIdConnectUrl', true) as string
184
+ if (!isUrlReference(url)) throw new SchemeError('"openIdConnectUrl" must be a URL')
185
+ break
186
+ }
187
+ case 'mutualTLS':
188
+ break
189
+ default:
190
+ throw new SchemeError('"type" must be "apiKey", "http", "oauth2", "openIdConnect" or "mutualTLS"')
191
+ }
192
+ } catch (error) {
193
+ if (error instanceof SchemeError) return malformed(error.message, where)
194
+ throw error
195
+ }
196
+ const scheme: SchemeModel = {
197
+ identity: definition.identity,
198
+ name,
199
+ definition: json as unknown as SecuritySchemeObject,
200
+ location: definition.location,
201
+ scopes,
202
+ scoped: json['type'] === 'oauth2' || json['type'] === 'openIdConnect',
203
+ }
204
+ const other = this.#byName.get(name)
205
+ if (other !== undefined && JSON.stringify(other.definition) !== JSON.stringify(scheme.definition)) {
206
+ this.#ctx.report({
207
+ code: Codes.InvalidSecurityRequirement,
208
+ severity: 'error',
209
+ message: `Two security schemes are named "${name}" with different definitions: ${definition.displayName} and ${other.identity}`,
210
+ location: definition.location,
211
+ related: [{ message: 'the other scheme', location: other.location }],
212
+ hints: ['Give each scheme its own name, or reuse one declaration'],
213
+ })
214
+ return undefined
215
+ }
216
+ this.#byName.set(name, scheme)
217
+ return scheme
218
+ }
219
+
220
+ /**
221
+ * Resolves a requirement input (`ApiKey`, `[Bearer, Tenant]`, `{ scheme, scopes }`) to schemes.
222
+ * Reports ZNIGHT036 and returns `undefined` when it is invalid.
223
+ */
224
+ requirement(value: StaticValue, location: Location): RequirementModel | undefined {
225
+ const report = (message: string, hints: readonly string[] = REQUIREMENT_HINTS): undefined => {
226
+ this.#ctx.report({ code: Codes.InvalidSecurityRequirement, severity: 'error', message, location, hints })
227
+ return undefined
228
+ }
229
+ const one = (item: StaticValue, scopes: readonly string[] | undefined): RequirementModel[number] | undefined => {
230
+ if (!isRef(item)) return report(`${describe(item)} is not a security scheme declared with securityScheme()`)
231
+ const scheme = this.resolve(item)
232
+ if (scheme === undefined) return undefined
233
+ if (scopes !== undefined && scopes.length > 0) {
234
+ if (!scheme.scoped) {
235
+ return report(
236
+ `Scopes are only allowed on oauth2 and openIdConnect schemes, not on "${scheme.name}" (${String(scheme.definition.type)})`,
237
+ ['Remove the scopes, or use an oauth2 / openIdConnect scheme'],
238
+ )
239
+ }
240
+ const unknown = scheme.scopes === undefined ? [] : scopes.filter(scope => !scheme.scopes?.has(scope))
241
+ if (unknown.length > 0) {
242
+ return report(
243
+ `The scope${unknown.length > 1 ? 's' : ''} ${unknown.map(scope => `"${scope}"`).join(', ')} ${unknown.length > 1 ? 'are' : 'is'} not declared by the flows of "${scheme.name}"`,
244
+ [`Declared scopes: ${[...(scheme.scopes ?? [])].join(', ') || 'none'}`],
245
+ )
246
+ }
247
+ }
248
+ return { scheme, scopes }
249
+ }
250
+ if (Array.isArray(value)) {
251
+ const items: RequirementModel[number][] = []
252
+ for (const item of value as readonly StaticValue[]) {
253
+ const resolved = one(item, undefined)
254
+ if (resolved === undefined) return undefined
255
+ items.push(resolved)
256
+ }
257
+ return items
258
+ }
259
+ if (isRecord(value)) {
260
+ const scopes = value['scopes']
261
+ if (!Array.isArray(scopes) || !(scopes as readonly StaticValue[]).every(scope => typeof scope === 'string')) {
262
+ return report('{ scheme, scopes } needs a static array of scope names')
263
+ }
264
+ const resolved = one(value['scheme'] as StaticValue, scopes as readonly string[])
265
+ return resolved === undefined ? undefined : [resolved]
266
+ }
267
+ const resolved = one(value, undefined)
268
+ return resolved === undefined ? undefined : [resolved]
269
+ }
270
+
271
+ /** Requirement objects of resolved requirements (`scopes` overrides every scheme's scopes). */
272
+ objects(requirements: readonly RequirementModel[], scopes?: readonly string[]): SecurityRequirementObject[] {
273
+ return requirements.map(requirement => {
274
+ const object: Record<string, readonly string[]> = {}
275
+ for (const { scheme, scopes: own } of requirement) {
276
+ this.used.set(scheme.name, scheme)
277
+ object[scheme.name] = [...(scopes ?? own ?? [])]
278
+ }
279
+ return object
280
+ })
281
+ }
282
+ }
283
+
284
+ class SchemeError extends Error {}
285
+
286
+ const REQUIREMENT_HINTS = [
287
+ 'A requirement is a scheme (ApiKey), several schemes that all apply ([Bearer, Tenant]) or { scheme: OAuth, scopes: [...] }',
288
+ ]
289
+
290
+ const ALLOWED_SCHEME_KEYS: Readonly<Record<string, ReadonlySet<string>>> = {
291
+ apiKey: new Set(['type', 'name', 'in']),
292
+ http: new Set(['type', 'scheme', 'bearerFormat']),
293
+ oauth2: new Set(['type', 'flows']),
294
+ openIdConnect: new Set(['type', 'openIdConnectUrl']),
295
+ mutualTLS: new Set(['type']),
296
+ }
297
+
298
+ const FLOW_URLS: Readonly<Record<string, readonly string[]>> = {
299
+ implicit: ['authorizationUrl'],
300
+ password: ['tokenUrl'],
301
+ clientCredentials: ['tokenUrl'],
302
+ authorizationCode: ['authorizationUrl', 'tokenUrl'],
303
+ }
304
+
305
+ /** Reads and checks the `@OpenApiDocument` options (and the defaults of `package.json`). */
306
+ export function readDocument(ctx: Context, schemes: SchemeRegistry): DocumentModel {
307
+ const documents = ctx.stored.filter(data => data.kind === 'document')
308
+ for (const extra of documents.slice(1)) {
309
+ const first = documents[0]
310
+ ctx.report({
311
+ code: Codes.InvalidOpenApiDefinition,
312
+ severity: 'error',
313
+ message: `An application has one @OpenApiDocument: ${extra.displayName} and ${first?.displayName ?? '?'} both declare one`,
314
+ location: extra.location,
315
+ ...(first === undefined
316
+ ? {}
317
+ : { related: [{ message: 'the other @OpenApiDocument', location: first.location }] }),
318
+ hints: ['Keep one class with @OpenApiDocument; filter operations with OpenApiPlugin.configure({ filter })'],
319
+ })
320
+ }
321
+ const stored = documents[0]
322
+ const cls = stored === undefined ? undefined : ctx.classInfo(stored.identity)
323
+ const decorator = cls?.decorators.find(candidate => candidate.identity === Ids.OpenApiDocument)
324
+ const arg = decorator?.args[0]
325
+ const value: StaticValue = arg?.evaluated === true ? arg.value : undefined
326
+ const location = arg?.location ?? decorator?.location
327
+ const invalid = (message: string, hints: readonly string[] = []): void => {
328
+ ctx.report({
329
+ code: Codes.InvalidOpenApiDefinition,
330
+ severity: 'error',
331
+ message: `Invalid @OpenApiDocument options: ${message}`,
332
+ ...(location === undefined ? {} : { location }),
333
+ hints,
334
+ })
335
+ }
336
+ const options: Readonly<Record<string, StaticValue>> = isRecord(value) ? value : {}
337
+ if (value !== undefined && !isRecord(value)) invalid(`expected an object, received ${describe(value)}`)
338
+ for (const key of Object.keys(options)) {
339
+ if (!OPTION_KEYS.has(key)) invalid(`unknown option "${key}"`, [`Options: ${[...OPTION_KEYS].join(', ')}`])
340
+ }
341
+ const reserved = findReservedKey(options)
342
+ if (reserved !== undefined) invalid(`"$znight" is a reserved key (${reserved})`)
343
+
344
+ const bool = (key: string, fallback: boolean): boolean => {
345
+ const option = options[key]
346
+ if (option === undefined) return fallback
347
+ if (typeof option !== 'boolean') {
348
+ invalid(`"${key}" must be true or false`)
349
+ return fallback
350
+ }
351
+ return option
352
+ }
353
+ const json = (key: string): JsonValue | undefined => {
354
+ const option = options[key]
355
+ if (option === undefined) return undefined
356
+ const converted = toJson(option)
357
+ if (converted === undefined) invalid(`"${key}" must be a static JSON value`)
358
+ return converted
359
+ }
360
+
361
+ // info
362
+ const doc = bool('jsdoc', true) ? cls?.doc : undefined
363
+ const manifest = ctx.project.rootPackage?.json ?? {}
364
+ const infoOption = json('info')
365
+ const info: Record<string, JsonValue> = {}
366
+ if (infoOption !== undefined && !isRecord(infoOption)) invalid('"info" must be an object')
367
+ const given = isRecord(infoOption) ? (infoOption as Readonly<Record<string, JsonValue>>) : {}
368
+ for (const key of Object.keys(given)) {
369
+ if (!INFO_KEYS.has(key) && !key.startsWith('x-')) invalid(`unknown field "info.${key}"`)
370
+ }
371
+ const manifestString = (key: string): string | undefined => {
372
+ const field = manifest[key]
373
+ return typeof field === 'string' && field !== '' ? field : undefined
374
+ }
375
+ const title = given['title'] ?? doc?.summary ?? manifestString('name') ?? 'API'
376
+ const version = given['version'] ?? manifestString('version') ?? '0.0.0'
377
+ if (typeof title !== 'string' || title === '') invalid('"info.title" must be a non-empty string')
378
+ if (typeof version !== 'string' || version === '') invalid('"info.version" must be a non-empty string')
379
+ info['title'] = typeof title === 'string' ? title : 'API'
380
+ info['version'] = typeof version === 'string' ? version : '0.0.0'
381
+ for (const key of ['summary', 'termsOfService', 'contact', 'license']) {
382
+ if (given[key] !== undefined) info[key] = given[key] as JsonValue
383
+ }
384
+ const description = given['description'] ?? doc?.description ?? manifestString('description')
385
+ if (description !== undefined) info['description'] = description
386
+ if (info['license'] === undefined) {
387
+ const license = manifestString('license')
388
+ if (license !== undefined && SPDX.test(license) && license !== 'UNLICENSED') {
389
+ info['license'] = { name: license, identifier: license }
390
+ }
391
+ }
392
+ for (const [key, field] of Object.entries(given)) if (key.startsWith('x-')) info[key] = field
393
+ checkInfo(info, invalid)
394
+
395
+ // servers, tags, externalDocs, extensions
396
+ const servers = json('servers')
397
+ if (servers !== undefined) {
398
+ if (
399
+ !Array.isArray(servers) ||
400
+ servers.some(server => !isRecord(server) || !isUrlReference(String(server['url'])))
401
+ ) {
402
+ invalid('"servers" must be an array of { url, description?, variables? } with valid URLs')
403
+ }
404
+ }
405
+ const tagsOption = json('tags')
406
+ const tags: TagObject[] = []
407
+ if (tagsOption !== undefined) {
408
+ if (Array.isArray(tagsOption)) {
409
+ const seen = new Set<string>()
410
+ for (const tag of tagsOption) {
411
+ if (!isRecord(tag) || typeof tag['name'] !== 'string' || tag['name'] === '') {
412
+ invalid('every tag needs a non-empty "name"')
413
+ continue
414
+ }
415
+ if (seen.has(tag['name'])) invalid(`the tag "${tag['name']}" is declared twice`)
416
+ seen.add(tag['name'])
417
+ tags.push(tag as unknown as TagObject)
418
+ }
419
+ } else invalid('"tags" must be an array of { name, description? }')
420
+ }
421
+ const externalDocs = json('externalDocs')
422
+ if (externalDocs !== undefined && (!isRecord(externalDocs) || !isAbsoluteUrl(String(externalDocs['url'])))) {
423
+ invalid('"externalDocs" must be { url, description? } with an absolute URL')
424
+ }
425
+ const extensionsOption = json('extensions')
426
+ const extensions: Record<string, JsonValue> = {}
427
+ if (extensionsOption !== undefined) {
428
+ if (isRecord(extensionsOption)) {
429
+ for (const [key, field] of Object.entries(extensionsOption as Readonly<Record<string, JsonValue>>)) {
430
+ if (key.startsWith('x-')) extensions[key] = field
431
+ else invalid(`the extension "${key}" must start with "x-"`)
432
+ }
433
+ } else invalid('"extensions" must be an object of x-* fields')
434
+ }
435
+
436
+ // operation ids
437
+ const style = options['operationIds'] ?? 'controller.handler'
438
+ if (style !== 'controller.handler' && style !== 'handler' && style !== 'Controller_handler') {
439
+ invalid('"operationIds" must be "controller.handler", "handler" or "Controller_handler"')
440
+ }
441
+
442
+ // errors
443
+ let errors: DocumentModel['errors'] = { contentType: 'application/json', schema: undefined }
444
+ const errorsOption = options['errors']
445
+ if (errorsOption === false) errors = false
446
+ else if (isRecord(errorsOption)) {
447
+ const schema = errorsOption['schema']
448
+ const contentType = errorsOption['contentType'] ?? 'application/json'
449
+ if (!isRef(schema)) invalid('"errors.schema" must be a Standard Schema (an exported schema)')
450
+ if (typeof contentType !== 'string') invalid('"errors.contentType" must be a media type')
451
+ errors = {
452
+ contentType: typeof contentType === 'string' ? contentType : 'application/json',
453
+ schema: isRef(schema) ? schema : undefined,
454
+ }
455
+ } else if (errorsOption !== undefined && errorsOption !== true) {
456
+ invalid('"errors" must be true, false or { schema, contentType? }')
457
+ }
458
+
459
+ // security and decisions
460
+ const where = location ?? decorator?.location
461
+ let security: RequirementModel[] | undefined
462
+ const securityOption = options['security']
463
+ if (securityOption !== undefined && where !== undefined) {
464
+ if (Array.isArray(securityOption)) {
465
+ security = []
466
+ for (const item of securityOption as readonly StaticValue[]) {
467
+ const requirement = schemes.requirement(item, where)
468
+ if (requirement !== undefined) security.push(requirement)
469
+ }
470
+ } else invalid('"security" must be an array of requirements')
471
+ }
472
+ const decisions = readDecisions(ctx, schemes, options['decisions'], where, invalid)
473
+
474
+ return {
475
+ identity: stored?.identity,
476
+ location: decorator?.location,
477
+ info: info as unknown as InfoObject,
478
+ servers: Array.isArray(servers) ? servers : undefined,
479
+ tags,
480
+ externalDocs,
481
+ security,
482
+ decisions,
483
+ operationIds: (typeof style === 'string' ? style : 'controller.handler') as OperationIdStyle,
484
+ errors,
485
+ strict: bool('strict', false),
486
+ jsdoc: bool('jsdoc', true),
487
+ extensions,
488
+ }
489
+ }
490
+
491
+ function checkInfo(info: Readonly<Record<string, JsonValue>>, invalid: (message: string) => void): void {
492
+ const terms = info['termsOfService']
493
+ if (terms !== undefined && (typeof terms !== 'string' || !isAbsoluteUrl(terms))) {
494
+ invalid('"info.termsOfService" must be an absolute URL')
495
+ }
496
+ const contact = info['contact']
497
+ if (contact !== undefined) {
498
+ if (isRecord(contact)) {
499
+ if (contact['url'] !== undefined && !isAbsoluteUrl(String(contact['url']))) {
500
+ invalid('"info.contact.url" must be an absolute URL')
501
+ }
502
+ if (contact['email'] !== undefined && !/^[^\s@]+@[^\s@]+$/.test(String(contact['email']))) {
503
+ invalid('"info.contact.email" must be an email address')
504
+ }
505
+ } else invalid('"info.contact" must be { name?, url?, email? }')
506
+ }
507
+ const license = info['license']
508
+ if (license !== undefined) {
509
+ if (!isRecord(license) || typeof license['name'] !== 'string' || license['name'] === '') {
510
+ invalid('"info.license" must be { name, identifier? } or { name, url? }')
511
+ } else {
512
+ if (license['identifier'] !== undefined && license['url'] !== undefined) {
513
+ invalid('"info.license" takes "identifier" or "url", not both')
514
+ }
515
+ if (license['identifier'] !== undefined && !SPDX.test(String(license['identifier']))) {
516
+ invalid('"info.license.identifier" must be an SPDX expression (MIT, Apache-2.0...)')
517
+ }
518
+ if (license['url'] !== undefined && !isAbsoluteUrl(String(license['url']))) {
519
+ invalid('"info.license.url" must be an absolute URL')
520
+ }
521
+ }
522
+ }
523
+ }
524
+
525
+ function readDecisions(
526
+ ctx: Context,
527
+ schemes: SchemeRegistry,
528
+ value: StaticValue,
529
+ location: Location | undefined,
530
+ invalid: (message: string) => void,
531
+ ): DecisionModel[] {
532
+ if (value === undefined || location === undefined) return []
533
+ if (!Array.isArray(value)) {
534
+ invalid('"decisions" must be an array of { when, security, scopes? }')
535
+ return []
536
+ }
537
+ const routeDecorators = new Set(
538
+ ctx.definitionsOf(HttpIds.createRouteDecorator).map(definition => definition.identity),
539
+ )
540
+ const decisions: DecisionModel[] = []
541
+ for (const entry of value as readonly StaticValue[]) {
542
+ if (!isRecord(entry)) {
543
+ invalid('every decision is { when, security, scopes? }')
544
+ continue
545
+ }
546
+ const when = entry['when']
547
+ if (!isRef(when) || when.identity === undefined || !routeDecorators.has(when.identity)) {
548
+ ctx.report({
549
+ code: Codes.InvalidSecurityRequirement,
550
+ severity: 'error',
551
+ message: `decisions[].when must be a route decorator created with createRouteDecorator(), not ${describe(when)}`,
552
+ location: isRef(when) ? when.location : location,
553
+ hints: ["Declare it: export const Public = createRouteDecorator('public')"],
554
+ })
555
+ continue
556
+ }
557
+ const scopes = entry['scopes']
558
+ if (scopes !== undefined && scopes !== 'value') {
559
+ ctx.report({
560
+ code: Codes.InvalidSecurityRequirement,
561
+ severity: 'error',
562
+ message: `decisions[].scopes must be 'value' (received ${describe(scopes)})`,
563
+ location: when.location,
564
+ hints: [
565
+ "{ when: Roles, security: [OAuth], scopes: 'value' } turns @Roles(['admin']) into the scopes ['admin']",
566
+ ],
567
+ })
568
+ continue
569
+ }
570
+ const security = entry['security']
571
+ if (!Array.isArray(security)) {
572
+ ctx.report({
573
+ code: Codes.InvalidSecurityRequirement,
574
+ severity: 'error',
575
+ message: `decisions[].security of ${when.text} must be an array of requirements ([] means public)`,
576
+ location: when.location,
577
+ })
578
+ continue
579
+ }
580
+ const requirements: RequirementModel[] = []
581
+ let valid = true
582
+ for (const item of security as readonly StaticValue[]) {
583
+ const requirement = schemes.requirement(item, when.location)
584
+ if (requirement === undefined) valid = false
585
+ else requirements.push(requirement)
586
+ }
587
+ if (!valid) continue
588
+ decisions.push({
589
+ identity: when.identity,
590
+ text: when.text,
591
+ location: when.location,
592
+ security: requirements,
593
+ scopesFromValue: scopes === 'value',
594
+ used: false,
595
+ })
596
+ }
597
+ return decisions
598
+ }
599
+
600
+ /** The document template: `openapi`, `info`, declared `servers`, `tags`... and the default security. */
601
+ export function documentTemplate(
602
+ model: DocumentModel,
603
+ security: readonly SecurityRequirementObject[] | undefined,
604
+ ): Record<string, JsonValue> {
605
+ const template: Record<string, JsonValue> = { openapi: OPENAPI_VERSION, info: model.info as unknown as JsonValue }
606
+ if (model.servers !== undefined) template['servers'] = model.servers as JsonValue
607
+ if (model.tags.length > 0) template['tags'] = model.tags as unknown as JsonValue
608
+ if (model.externalDocs !== undefined) template['externalDocs'] = model.externalDocs
609
+ if (security !== undefined) template['security'] = security as unknown as JsonValue
610
+ for (const [key, value] of Object.entries(model.extensions)) template[key] = value
611
+ return template
612
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `generate`: emits `metadata.extensions.openapi` (§7.1 of RFC 0002). Every value is a JSON literal,
3
+ * except the Standard Schema slots, imported by the generated module (once, like `invoke` does).
4
+ */
5
+ import type { GenerateContext, GenNode, LiteralValue } from '@znight/compiler'
6
+ import type { OpenApiStored } from './model.ts'
7
+ import type { OpenApiModel } from './validate.ts'
8
+
9
+ import { createHash } from 'node:crypto'
10
+
11
+ import { canonicalJson, OPENAPI_VERSION } from '../shared.ts'
12
+ import { modelOf, sourceOf } from './validate.ts'
13
+
14
+ /** `generate` of the extension. */
15
+ export function generateOpenApi(ctx: GenerateContext<OpenApiStored>): GenNode | undefined {
16
+ const model = modelOf(ctx.graph) ?? emptyModel(ctx.options.root)
17
+ const operations = model.operations.map(operation => ({
18
+ controller: operation.controller,
19
+ handler: operation.handler,
20
+ route: operation.route,
21
+ method: operation.method,
22
+ path: operation.path,
23
+ operation: operation.operation,
24
+ }))
25
+ const slots = model.slots.map(slot => ({ name: slot.name, io: [...slot.io].sort() }))
26
+ const fingerprint = fingerprintOf({
27
+ version: 1,
28
+ document: model.document,
29
+ operations,
30
+ components: model.components,
31
+ securitySchemes: model.securitySchemes,
32
+ schemas: slots,
33
+ errors: model.errors,
34
+ tagDescriptions: model.tagDescriptions,
35
+ })
36
+ const lit = (value: unknown): GenNode => ctx.lit(value as LiteralValue)
37
+ const entries: [string, GenNode][] = [
38
+ ['version', lit(1)],
39
+ ['fingerprint', lit(fingerprint)],
40
+ ['document', lit(model.document)],
41
+ ['operations', lit(operations)],
42
+ ['components', lit(model.components)],
43
+ ['securitySchemes', lit(model.securitySchemes)],
44
+ [
45
+ 'schemas',
46
+ ctx.arr(
47
+ model.slots.map(slot =>
48
+ ctx.obj([
49
+ ['name', slot.name],
50
+ ['schema', ctx.importValue(slot.ref)],
51
+ ['io', lit([...slot.io].sort())],
52
+ ['source', lit(sourceOf(slot.location, model.root))],
53
+ ]),
54
+ ),
55
+ ),
56
+ ],
57
+ ]
58
+ if (model.errors !== undefined) entries.push(['errors', lit(model.errors)])
59
+ if (Object.keys(model.tagDescriptions).length > 0) entries.push(['tagDescriptions', lit(model.tagDescriptions)])
60
+ return ctx.obj(entries)
61
+ }
62
+
63
+ /** SHA-256 (base64url) of the canonical JSON of the section (schema values replaced by their names). */
64
+ export function fingerprintOf(section: unknown): string {
65
+ return createHash('sha256').update(canonicalJson(section)).digest('base64url')
66
+ }
67
+
68
+ function emptyModel(root: string): OpenApiModel {
69
+ return {
70
+ document: { openapi: OPENAPI_VERSION, info: { title: 'API', version: '0.0.0' } },
71
+ operations: [],
72
+ components: {},
73
+ securitySchemes: {},
74
+ slots: [],
75
+ errors: { contentType: 'application/json' },
76
+ tagDescriptions: {},
77
+ root,
78
+ }
79
+ }