@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,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
|
+
}
|