@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
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OpenApiPlugin` (§5.6, §8 of RFC 0002): serves the document (`/openapi.json`, optional YAML) and
|
|
3
|
+
* the API reference UI through the plugin routes of `@znight/http`, and provides the `OpenApi`
|
|
4
|
+
* external.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
Container,
|
|
8
|
+
CoreLogger,
|
|
9
|
+
Plugin,
|
|
10
|
+
PluginContext,
|
|
11
|
+
PluginOptions,
|
|
12
|
+
PluginRootsContext,
|
|
13
|
+
ProviderKey,
|
|
14
|
+
ZnightMetadata,
|
|
15
|
+
} from '@znight/core'
|
|
16
|
+
import type {
|
|
17
|
+
GuardRef,
|
|
18
|
+
HttpContext,
|
|
19
|
+
HttpPlugin,
|
|
20
|
+
PluginHttpRoutes,
|
|
21
|
+
PluginHttpRoutesContext,
|
|
22
|
+
PluginRoute,
|
|
23
|
+
RouteMetaEntry,
|
|
24
|
+
} from '@znight/http'
|
|
25
|
+
import type { BuildOpenApiOptions, OpenApiOperationView } from './build.ts'
|
|
26
|
+
import type { SchemaConverter } from './converters.ts'
|
|
27
|
+
import type { InfoObject, JsonValue, OpenApiObject, ServerObject } from './types.ts'
|
|
28
|
+
import type { LocalPackage, UiKind } from './ui.ts'
|
|
29
|
+
|
|
30
|
+
import { createHash } from 'node:crypto'
|
|
31
|
+
import { readFile } from 'node:fs/promises'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
import { ErrorCode, resolveOptions, ZnightError } from '@znight/core'
|
|
34
|
+
import { HttpResponse, NotFoundException } from '@znight/http'
|
|
35
|
+
|
|
36
|
+
import { applyDocumentOptions, buildOpenApiDocument, openApiSection, operationViews } from './build.ts'
|
|
37
|
+
import { OpenApi, OpenApiTokens, toYaml } from './openapi.ts'
|
|
38
|
+
import { findLocalPackage, renderUiPage, UI_PACKAGES } from './ui.ts'
|
|
39
|
+
|
|
40
|
+
/** The UI option: a kind, or a kind with its JSON-serialisable configuration. */
|
|
41
|
+
export type OpenApiUiOption =
|
|
42
|
+
| UiKind
|
|
43
|
+
| false
|
|
44
|
+
| { readonly scalar: Readonly<Record<string, JsonValue>> }
|
|
45
|
+
| { readonly swagger: Readonly<Record<string, JsonValue>> }
|
|
46
|
+
|
|
47
|
+
/** Options of {@link OpenApiPlugin.configure}. */
|
|
48
|
+
export interface OpenApiPluginOptions {
|
|
49
|
+
/** `false` serves nothing and builds nothing (the `OpenApi` external still works). Default `true`. */
|
|
50
|
+
readonly enabled?: boolean
|
|
51
|
+
/** Path of the JSON document, relative to the HTTP prefix. Default `'/openapi.json'`. */
|
|
52
|
+
readonly path?: string
|
|
53
|
+
/** A path (`'/openapi.yaml'`) also serves YAML (`application/yaml`, optional peer `yaml`). Default `false`. */
|
|
54
|
+
readonly yaml?: string | false
|
|
55
|
+
/** `'scalar'` (default), `'swagger'`, `false`, or `{ scalar: config }` / `{ swagger: config }`. */
|
|
56
|
+
readonly ui?: OpenApiUiOption
|
|
57
|
+
/** Path of the UI page. Local assets are under `${uiPath}/_assets/`. Default `'/docs'`. */
|
|
58
|
+
readonly uiPath?: string
|
|
59
|
+
/**
|
|
60
|
+
* `'local'` serves the UI package from `node_modules`, `'cdn'` the jsDelivr copy pinned by this
|
|
61
|
+
* release (with SRI), `'auto'` (default) local when the package is installed.
|
|
62
|
+
*/
|
|
63
|
+
readonly assets?: 'auto' | 'local' | 'cdn'
|
|
64
|
+
/** `'build'` (default) assembles the document from the metadata; `{ file }` serves a prebuilt `openapi.json`. */
|
|
65
|
+
readonly document?: 'build' | { readonly file: string | URL }
|
|
66
|
+
/** `'boot'` (default): built in `onBootstrap` (a failure fails the boot); `'first-request'`: built lazily. */
|
|
67
|
+
readonly build?: 'boot' | 'first-request'
|
|
68
|
+
/** Merged over the static `info`. */
|
|
69
|
+
readonly info?: Partial<InfoObject>
|
|
70
|
+
/** Replaces the declared `servers`. */
|
|
71
|
+
readonly servers?: readonly ServerObject[]
|
|
72
|
+
readonly filter?: (operation: OpenApiOperationView) => boolean
|
|
73
|
+
readonly transform?: (document: OpenApiObject) => OpenApiObject
|
|
74
|
+
/** Extra converters, tried before the built-in ones. */
|
|
75
|
+
readonly converters?: readonly SchemaConverter[]
|
|
76
|
+
/** `'warn'`: a schema that cannot be converted becomes `{}` (ZNIGHT037 warning). Default `'error'`. */
|
|
77
|
+
readonly unrepresentable?: 'error' | 'warn'
|
|
78
|
+
/** Route metadata of the docs routes (`[Public.meta()]`): seen by guards and by the route decision check. */
|
|
79
|
+
readonly meta?: readonly RouteMetaEntry[]
|
|
80
|
+
/** Extra guards of the docs routes (classes or instances), after the global guards. */
|
|
81
|
+
readonly guards?: readonly GuardRef[]
|
|
82
|
+
/** `cache-control` of the document (revalidated with the ETag). Default `'no-cache'`. */
|
|
83
|
+
readonly cacheControl?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Resolved {
|
|
87
|
+
readonly enabled: boolean
|
|
88
|
+
readonly path: string
|
|
89
|
+
readonly yaml: string | undefined
|
|
90
|
+
readonly ui: { readonly kind: UiKind; readonly config: Readonly<Record<string, JsonValue>> } | undefined
|
|
91
|
+
readonly uiPath: string
|
|
92
|
+
readonly assets: 'auto' | 'local' | 'cdn'
|
|
93
|
+
readonly file: string | undefined
|
|
94
|
+
readonly lazy: boolean
|
|
95
|
+
readonly build: BuildOpenApiOptions
|
|
96
|
+
readonly meta: readonly RouteMetaEntry[]
|
|
97
|
+
readonly guards: readonly GuardRef[]
|
|
98
|
+
readonly cacheControl: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A precomputed response body. */
|
|
102
|
+
interface Body {
|
|
103
|
+
readonly bytes: Uint8Array
|
|
104
|
+
readonly etag: string
|
|
105
|
+
readonly contentType: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Everything served, computed once. */
|
|
109
|
+
interface Served {
|
|
110
|
+
readonly document: OpenApiObject
|
|
111
|
+
readonly json: Body
|
|
112
|
+
readonly yaml: Body | undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ENCODER = new TextEncoder()
|
|
116
|
+
const ASSET_CACHE = 'public, max-age=31536000, immutable'
|
|
117
|
+
const PATH = /^\/[A-Za-z0-9\-._~!$&'()*+,;=:@/]*$/
|
|
118
|
+
|
|
119
|
+
/** The OpenAPI plugin: `OpenApiPlugin.configure()` (name `openapi`). */
|
|
120
|
+
export class OpenApiPlugin implements Plugin, PluginHttpRoutes {
|
|
121
|
+
readonly name = 'openapi'
|
|
122
|
+
readonly #options: PluginOptions<OpenApiPluginOptions>
|
|
123
|
+
#resolved: Resolved | undefined
|
|
124
|
+
#container: Container | undefined
|
|
125
|
+
#metadata: ZnightMetadata | undefined
|
|
126
|
+
#logger: CoreLogger | undefined
|
|
127
|
+
#http: HttpPlugin | undefined
|
|
128
|
+
#served: Served | undefined
|
|
129
|
+
#building: Promise<Served> | undefined
|
|
130
|
+
#ui: { html: Uint8Array; csp: string; etag: string } | undefined
|
|
131
|
+
#local: LocalPackage | undefined
|
|
132
|
+
#operations: readonly OpenApiOperationView[] = []
|
|
133
|
+
#active = false
|
|
134
|
+
|
|
135
|
+
private constructor(options: PluginOptions<OpenApiPluginOptions>) {
|
|
136
|
+
this.#options = options
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Creates the plugin. `options` may be a function of the container, evaluated once the container
|
|
141
|
+
* is ready: `OpenApiPlugin.configure(c => ({ enabled: c.get(ServerConfig).docs }))`.
|
|
142
|
+
*/
|
|
143
|
+
static configure(options: PluginOptions<OpenApiPluginOptions> = {}): OpenApiPlugin {
|
|
144
|
+
return new OpenApiPlugin(options)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The classes of the `guards` option (instances are left out): `TestApp` creates them at boot. */
|
|
148
|
+
initRoots(_context?: PluginRootsContext): readonly ProviderKey[] {
|
|
149
|
+
const options: unknown = this.#options
|
|
150
|
+
if (options === null || typeof options !== 'object') return []
|
|
151
|
+
try {
|
|
152
|
+
const guards: unknown = (options as OpenApiPluginOptions).guards
|
|
153
|
+
return Array.isArray(guards) ? guards.filter((guard): guard is ProviderKey => typeof guard === 'function') : []
|
|
154
|
+
} catch {
|
|
155
|
+
return []
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
register(ctx: PluginContext): void {
|
|
160
|
+
if (this.#active) {
|
|
161
|
+
throw new ZnightError(ErrorCode.DuplicateProvider, 'This OpenApiPlugin is already used by another application', {
|
|
162
|
+
hints: ['Call OpenApiPlugin.configure() once per application'],
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
openApiSection(ctx.metadata)
|
|
166
|
+
if (ctx.getPlugin('http') === undefined) {
|
|
167
|
+
throw new ZnightError(
|
|
168
|
+
ErrorCode.InvalidOpenApiDefinition,
|
|
169
|
+
'OpenApiPlugin serves its routes through HttpPlugin, which is not registered',
|
|
170
|
+
{
|
|
171
|
+
hints: ['Add HttpPlugin.configure() to the plugins of the application'],
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
this.#active = true
|
|
176
|
+
this.#resolved = undefined
|
|
177
|
+
this.#served = undefined
|
|
178
|
+
this.#building = undefined
|
|
179
|
+
this.#ui = undefined
|
|
180
|
+
this.#local = undefined
|
|
181
|
+
this.#container = ctx.container
|
|
182
|
+
this.#metadata = ctx.metadata
|
|
183
|
+
this.#logger = ctx.logger
|
|
184
|
+
this.#http = ctx.getPlugin<HttpPlugin>('http')
|
|
185
|
+
this.#operations = operationViews(ctx.metadata)
|
|
186
|
+
ctx.container.registerValue(OpenApiTokens.OpenApi, new OpenApiService(this))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
httpRoutes(context: PluginHttpRoutesContext): readonly PluginRoute[] {
|
|
190
|
+
this.#container ??= context.container
|
|
191
|
+
const options = this.#resolve()
|
|
192
|
+
if (!options.enabled) return []
|
|
193
|
+
const common = {
|
|
194
|
+
...(options.meta.length > 0 ? { meta: options.meta } : {}),
|
|
195
|
+
...(options.guards.length > 0 ? { guards: options.guards } : {}),
|
|
196
|
+
}
|
|
197
|
+
const routes: PluginRoute[] = [
|
|
198
|
+
{
|
|
199
|
+
method: 'GET',
|
|
200
|
+
path: options.path,
|
|
201
|
+
name: 'document',
|
|
202
|
+
handler: ctx => this.#serveDocument(ctx, 'json'),
|
|
203
|
+
...common,
|
|
204
|
+
},
|
|
205
|
+
]
|
|
206
|
+
if (options.yaml !== undefined) {
|
|
207
|
+
routes.push({
|
|
208
|
+
method: 'GET',
|
|
209
|
+
path: options.yaml,
|
|
210
|
+
name: 'yaml',
|
|
211
|
+
handler: ctx => this.#serveDocument(ctx, 'yaml'),
|
|
212
|
+
...common,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
if (options.ui !== undefined) {
|
|
216
|
+
routes.push({
|
|
217
|
+
method: 'GET',
|
|
218
|
+
path: options.uiPath,
|
|
219
|
+
name: 'ui',
|
|
220
|
+
handler: ctx => this.#serveUi(ctx, context.prefix),
|
|
221
|
+
...common,
|
|
222
|
+
})
|
|
223
|
+
if (this.#localPackage(options) !== undefined) {
|
|
224
|
+
routes.push({
|
|
225
|
+
method: 'GET',
|
|
226
|
+
path: `${options.uiPath === '/' ? '' : options.uiPath}/_assets/*`,
|
|
227
|
+
name: 'assets',
|
|
228
|
+
handler: ctx => this.#serveAsset(ctx),
|
|
229
|
+
...common,
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return routes
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async onBootstrap(ctx: PluginContext): Promise<void> {
|
|
237
|
+
this.#container = ctx.container
|
|
238
|
+
const options = this.#resolve()
|
|
239
|
+
if (!options.enabled) return
|
|
240
|
+
this.#checkServers(options)
|
|
241
|
+
if (options.lazy && options.file === undefined) return
|
|
242
|
+
await this.served()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
onReady(ctx: PluginContext): void {
|
|
246
|
+
const options = this.#resolved
|
|
247
|
+
const address = this.#http?.address
|
|
248
|
+
if (options === undefined || !options.enabled || options.ui === undefined || address === undefined) return
|
|
249
|
+
ctx.logger.info(`API reference: ${String(address.url).replace(/\/$/, '')}${this.#prefix()}${options.uiPath}`)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
onDispose(): void {
|
|
253
|
+
this.#active = false
|
|
254
|
+
this.#served = undefined
|
|
255
|
+
this.#building = undefined
|
|
256
|
+
this.#ui = undefined
|
|
257
|
+
this.#resolved = undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The views of the operations (no schema conversion). */
|
|
261
|
+
get operations(): readonly OpenApiOperationView[] {
|
|
262
|
+
return this.#operations
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The served document and bodies, built once (shared promise; a failure is retried on the next call). */
|
|
266
|
+
served(): Promise<Served> {
|
|
267
|
+
if (this.#served !== undefined) return Promise.resolve(this.#served)
|
|
268
|
+
if (this.#building === undefined) {
|
|
269
|
+
this.#building = this.#build().then(
|
|
270
|
+
served => {
|
|
271
|
+
this.#served = served
|
|
272
|
+
return served
|
|
273
|
+
},
|
|
274
|
+
(error: unknown) => {
|
|
275
|
+
this.#building = undefined
|
|
276
|
+
throw error
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
return this.#building
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async #build(): Promise<Served> {
|
|
284
|
+
const options = this.#resolve()
|
|
285
|
+
const metadata = this.#metadata
|
|
286
|
+
if (metadata === undefined) {
|
|
287
|
+
throw new ZnightError(
|
|
288
|
+
ErrorCode.LifecycleFailed,
|
|
289
|
+
'OpenApiPlugin: the document is only available once the plugin is registered',
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
let document: OpenApiObject
|
|
293
|
+
let json: string
|
|
294
|
+
if (options.file === undefined) {
|
|
295
|
+
const result = await buildOpenApiDocument(metadata, { ...options.build, prefix: this.#prefix() })
|
|
296
|
+
for (const warning of result.warnings) this.#logger?.warn(`${warning.code}: ${warning.message}`)
|
|
297
|
+
this.#logger?.debug('OpenAPI document built', {
|
|
298
|
+
operations: result.stats.operations,
|
|
299
|
+
schemas: result.stats.schemas,
|
|
300
|
+
ms: Math.round(result.stats.durationMs * 100) / 100,
|
|
301
|
+
})
|
|
302
|
+
document = result.document
|
|
303
|
+
json = result.json
|
|
304
|
+
} else {
|
|
305
|
+
json = await readFile(options.file, 'utf8')
|
|
306
|
+
document = parseDocumentFile(json, options.file)
|
|
307
|
+
const expected = openApiSection(metadata).fingerprint
|
|
308
|
+
const actual = (document as { 'x-znight-fingerprint'?: unknown })['x-znight-fingerprint']
|
|
309
|
+
if (actual !== expected) {
|
|
310
|
+
this.#logger?.warn(
|
|
311
|
+
`ZNIGHT030: ${options.file} is stale: it was not emitted from this build of the application (x-znight-fingerprint differs)`,
|
|
312
|
+
{ hints: ['Regenerate it: znight openapi (or znight build --openapi)'] },
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
const prefix = this.#prefix()
|
|
316
|
+
if (options.build.servers === undefined) this.#checkFileServers(options.file, document.servers, prefix)
|
|
317
|
+
// the runtime options apply to the file as to a build (filter, info, servers, prefix, transform)
|
|
318
|
+
const applied = applyDocumentOptions(json, metadata, { ...options.build, prefix })
|
|
319
|
+
if (applied !== undefined) {
|
|
320
|
+
document = applied.document
|
|
321
|
+
json = applied.json
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const yaml = options.yaml === undefined ? undefined : body(await toYaml(document), 'application/yaml')
|
|
325
|
+
return { document, json: body(json, 'application/json; charset=utf-8'), yaml }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ------------------------------------------------------------------------------------------------
|
|
329
|
+
// handlers
|
|
330
|
+
|
|
331
|
+
async #serveDocument(ctx: HttpContext, format: 'json' | 'yaml'): Promise<HttpResponse<Uint8Array | undefined>> {
|
|
332
|
+
const options = this.#resolve()
|
|
333
|
+
let served: Served
|
|
334
|
+
try {
|
|
335
|
+
served = await this.served()
|
|
336
|
+
} catch (error) {
|
|
337
|
+
this.#logger?.error('OpenApiPlugin: the document cannot be built', {
|
|
338
|
+
error: error instanceof Error ? error.message : String(error),
|
|
339
|
+
})
|
|
340
|
+
throw error
|
|
341
|
+
}
|
|
342
|
+
const selected = format === 'json' ? served.json : served.yaml
|
|
343
|
+
if (selected === undefined) throw new NotFoundException()
|
|
344
|
+
return conditional(ctx, selected, options.cacheControl)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async #serveUi(ctx: HttpContext, prefix: string): Promise<HttpResponse<Uint8Array | undefined>> {
|
|
348
|
+
const options = this.#resolve()
|
|
349
|
+
if (this.#ui === undefined) {
|
|
350
|
+
const ui = options.ui
|
|
351
|
+
if (ui === undefined) throw new NotFoundException()
|
|
352
|
+
// the CSP allows the servers of the served document, whatever the build mode
|
|
353
|
+
let document: Pick<OpenApiObject, 'info' | 'servers'> | undefined
|
|
354
|
+
try {
|
|
355
|
+
document = (await this.served()).document
|
|
356
|
+
} catch {
|
|
357
|
+
// the document cannot be built: the declared servers, the page is not cached
|
|
358
|
+
document = this.#declared(options)
|
|
359
|
+
}
|
|
360
|
+
const local = this.#localPackage(options)
|
|
361
|
+
const base = prefix === '/' ? '' : prefix
|
|
362
|
+
const uiPath = options.uiPath === '/' ? '' : options.uiPath
|
|
363
|
+
const page = renderUiPage({
|
|
364
|
+
kind: ui.kind,
|
|
365
|
+
documentUrl: `${base}${options.path}`,
|
|
366
|
+
assetsBase: local === undefined ? undefined : `${base}${uiPath}/_assets/${ui.kind}@${local.version}`,
|
|
367
|
+
title: document?.info.title === undefined ? 'API reference' : `${document.info.title} — API reference`,
|
|
368
|
+
config: ui.config,
|
|
369
|
+
connect: serverOrigins(document?.servers ?? []),
|
|
370
|
+
})
|
|
371
|
+
const bytes = ENCODER.encode(page.html)
|
|
372
|
+
const rendered = { html: bytes, csp: page.csp, etag: etagOf(bytes) }
|
|
373
|
+
if (this.#served === undefined) return uiResponse(ctx, rendered)
|
|
374
|
+
this.#ui = rendered
|
|
375
|
+
}
|
|
376
|
+
return uiResponse(ctx, this.#ui)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** `info` and `servers` as declared (static template and runtime options), without building. */
|
|
380
|
+
#declared(options: Resolved): Pick<OpenApiObject, 'info' | 'servers'> | undefined {
|
|
381
|
+
const metadata = this.#metadata
|
|
382
|
+
if (metadata === undefined) return undefined
|
|
383
|
+
let template: OpenApiObject['info'] | undefined
|
|
384
|
+
let servers: readonly ServerObject[] | undefined
|
|
385
|
+
try {
|
|
386
|
+
const section = openApiSection(metadata)
|
|
387
|
+
template = section.document.info
|
|
388
|
+
servers = section.document.servers
|
|
389
|
+
} catch {
|
|
390
|
+
return undefined
|
|
391
|
+
}
|
|
392
|
+
const info = { ...template, ...options.build.info } as OpenApiObject['info']
|
|
393
|
+
const resolved = options.build.servers ?? servers
|
|
394
|
+
return resolved === undefined ? { info } : { info, servers: resolved }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#serveAsset(ctx: HttpContext): HttpResponse {
|
|
398
|
+
const options = this.#resolve()
|
|
399
|
+
const ui = options.ui
|
|
400
|
+
const local = this.#localPackage(options)
|
|
401
|
+
if (ui === undefined || local === undefined) throw new NotFoundException()
|
|
402
|
+
const requested = ctx.params['*'] ?? ''
|
|
403
|
+
const prefix = `${ui.kind}@${local.version}/`
|
|
404
|
+
const asset = requested.startsWith(prefix)
|
|
405
|
+
? UI_PACKAGES[ui.kind].assets.find(
|
|
406
|
+
candidate =>
|
|
407
|
+
candidate.file.endsWith(`/${requested.slice(prefix.length)}`) ||
|
|
408
|
+
candidate.file === requested.slice(prefix.length),
|
|
409
|
+
)
|
|
410
|
+
: undefined
|
|
411
|
+
if (asset === undefined) throw new NotFoundException()
|
|
412
|
+
return HttpResponse.file(asset.file, { root: local.dir }).withHeader('cache-control', ASSET_CACHE)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ------------------------------------------------------------------------------------------------
|
|
416
|
+
// options
|
|
417
|
+
|
|
418
|
+
#localPackage(options: Resolved): LocalPackage | undefined {
|
|
419
|
+
if (options.ui === undefined || options.assets === 'cdn') return undefined
|
|
420
|
+
if (this.#local === undefined) {
|
|
421
|
+
this.#local = findLocalPackage(UI_PACKAGES[options.ui.kind])
|
|
422
|
+
if (this.#local === undefined && options.assets === 'local') {
|
|
423
|
+
throw new ZnightError(
|
|
424
|
+
ErrorCode.InvalidOpenApiDefinition,
|
|
425
|
+
`OpenApiPlugin: assets: 'local' needs ${UI_PACKAGES[options.ui.kind].name}, which is not installed`,
|
|
426
|
+
{
|
|
427
|
+
hints: [`npm install ${UI_PACKAGES[options.ui.kind].name}`, "Or serve the pinned CDN copy: assets: 'cdn'"],
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return this.#local
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#prefix(): string {
|
|
436
|
+
try {
|
|
437
|
+
return this.#http?.prefix ?? ''
|
|
438
|
+
} catch {
|
|
439
|
+
return ''
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#checkServers(options: Resolved): void {
|
|
444
|
+
const prefix = this.#prefix()
|
|
445
|
+
// a prebuilt file is checked once read (#checkFileServers), unless `servers` replaces its servers
|
|
446
|
+
if (prefix === '' || (options.file !== undefined && options.build.servers === undefined)) return
|
|
447
|
+
const metadata = this.#metadata
|
|
448
|
+
const declared =
|
|
449
|
+
options.build.servers ?? (metadata === undefined ? undefined : openApiSection(metadata).document.servers)
|
|
450
|
+
this.#checkPrefix(declared ?? [], prefix)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The servers of a prebuilt file against the HTTP prefix (the file may be emitted without the
|
|
455
|
+
* prefix, or with servers of another deployment): ZNIGHT030 when it declares no servers, when a
|
|
456
|
+
* relative server does not end with the prefix, or when no server ends with it (`servers: []`,
|
|
457
|
+
* absolute servers of another path). Runtime `servers` replace the file's and skip this check.
|
|
458
|
+
*/
|
|
459
|
+
#checkFileServers(file: string, servers: readonly ServerObject[] | undefined, prefix: string): void {
|
|
460
|
+
if (prefix === '' || prefix === '/') return
|
|
461
|
+
if (servers === undefined) {
|
|
462
|
+
this.#logger?.warn(
|
|
463
|
+
`ZNIGHT030: ${file} declares no servers, but the HTTP prefix is "${prefix}": it is served with the server "${prefix}", and clients generated from the file would call the wrong paths`,
|
|
464
|
+
{
|
|
465
|
+
hints: [
|
|
466
|
+
`Emit it with the prefix: znight openapi --prefix ${prefix}, znight build --openapi --openapi-prefix ${prefix}, or openapi({ prefix: '${prefix}' }) in vite.config`,
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
// a hand-edited file may hold anything: only well-formed servers count
|
|
473
|
+
const declared = Array.isArray(servers) ? servers.filter(server => typeof server?.url === 'string') : []
|
|
474
|
+
// a relative server not ending with the prefix is reported on its own
|
|
475
|
+
if (this.#checkPrefix(declared, prefix) > 0) return
|
|
476
|
+
if (declared.some(server => reflectsPrefix(server.url, prefix))) return
|
|
477
|
+
const listed = declared.length === 0 ? '[]' : declared.map(server => `"${server.url}"`).join(', ')
|
|
478
|
+
this.#logger?.warn(
|
|
479
|
+
`ZNIGHT030: ${file} declares no server ending with the HTTP prefix "${prefix}" (servers: ${listed}): clients generated from the file would call the wrong paths`,
|
|
480
|
+
{
|
|
481
|
+
hints: [
|
|
482
|
+
`Declare a server ending with "${prefix}" (@OpenApiDocument({ servers: [{ url: '${prefix}' }] })) and emit the file again`,
|
|
483
|
+
'Behind a gateway that rewrites the paths, replace the servers at runtime: OpenApiPlugin.configure({ servers })',
|
|
484
|
+
],
|
|
485
|
+
},
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Warns about each relative server that does not end with the HTTP prefix; returns how many. */
|
|
490
|
+
#checkPrefix(servers: readonly ServerObject[], prefix: string): number {
|
|
491
|
+
let mismatched = 0
|
|
492
|
+
for (const server of servers) {
|
|
493
|
+
if (server.url.startsWith('/') && !server.url.replace(/\/$/, '').endsWith(prefix)) {
|
|
494
|
+
mismatched++
|
|
495
|
+
this.#logger?.warn(
|
|
496
|
+
`ZNIGHT030: the OpenAPI server "${server.url}" does not end with the HTTP prefix "${prefix}": generated clients would call the wrong paths`,
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return mismatched
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#resolve(): Resolved {
|
|
504
|
+
if (this.#resolved !== undefined) return this.#resolved
|
|
505
|
+
let raw: OpenApiPluginOptions
|
|
506
|
+
if (typeof this.#options === 'function') {
|
|
507
|
+
const container = this.#container
|
|
508
|
+
if (container === undefined) {
|
|
509
|
+
throw new ZnightError(
|
|
510
|
+
ErrorCode.LifecycleFailed,
|
|
511
|
+
'OpenApiPlugin: its options are a function of the container, which is not ready',
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
raw = resolveOptions(this.#options, container)
|
|
515
|
+
} else {
|
|
516
|
+
raw = this.#options
|
|
517
|
+
}
|
|
518
|
+
this.#resolved = resolvePluginOptions(raw)
|
|
519
|
+
return this.#resolved
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Implementation of the {@link OpenApi} external. */
|
|
524
|
+
class OpenApiService extends OpenApi {
|
|
525
|
+
readonly #plugin: OpenApiPlugin
|
|
526
|
+
|
|
527
|
+
constructor(plugin: OpenApiPlugin) {
|
|
528
|
+
super()
|
|
529
|
+
this.#plugin = plugin
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
get operations(): readonly OpenApiOperationView[] {
|
|
533
|
+
return this.#plugin.operations
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async document(): Promise<OpenApiObject> {
|
|
537
|
+
return (await this.#plugin.served()).document
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async json(): Promise<string> {
|
|
541
|
+
return new TextDecoder().decode((await this.#plugin.served()).json.bytes)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async yaml(): Promise<string> {
|
|
545
|
+
const served = await this.#plugin.served()
|
|
546
|
+
return served.yaml === undefined ? toYaml(served.document) : new TextDecoder().decode(served.yaml.bytes)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** The UI page: HTML, its Content-Security-Policy, revalidated with its ETag. */
|
|
551
|
+
function uiResponse(
|
|
552
|
+
ctx: HttpContext,
|
|
553
|
+
page: { readonly html: Uint8Array; readonly csp: string; readonly etag: string },
|
|
554
|
+
): HttpResponse<Uint8Array | undefined> {
|
|
555
|
+
const response = conditional(
|
|
556
|
+
ctx,
|
|
557
|
+
{ bytes: page.html, etag: page.etag, contentType: 'text/html; charset=utf-8' },
|
|
558
|
+
'no-cache',
|
|
559
|
+
)
|
|
560
|
+
return response.withHeader('content-security-policy', page.csp).withHeader('x-content-type-options', 'nosniff')
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function body(text: string, contentType: string): Body {
|
|
564
|
+
const bytes = ENCODER.encode(text)
|
|
565
|
+
return { bytes, etag: etagOf(bytes), contentType }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Strong ETag: base64url SHA-256, 27 characters. */
|
|
569
|
+
function etagOf(bytes: Uint8Array): string {
|
|
570
|
+
return `"${createHash('sha256').update(bytes).digest('base64url').slice(0, 27)}"`
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function conditional(ctx: HttpContext, served: Body, cacheControl: string): HttpResponse<Uint8Array | undefined> {
|
|
574
|
+
const headers = { etag: served.etag, 'cache-control': cacheControl }
|
|
575
|
+
if (matches(ctx.header('if-none-match'), served.etag))
|
|
576
|
+
return new HttpResponse<Uint8Array | undefined>(undefined, { status: 304, headers })
|
|
577
|
+
return new HttpResponse<Uint8Array | undefined>(served.bytes, {
|
|
578
|
+
status: 200,
|
|
579
|
+
headers: { ...headers, 'content-type': served.contentType },
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function matches(header: string | undefined, etag: string): boolean {
|
|
584
|
+
if (header === undefined) return false
|
|
585
|
+
return header.split(',').some(candidate => {
|
|
586
|
+
const tag = candidate.trim()
|
|
587
|
+
return tag === '*' || tag === etag || tag === `W/${etag}`
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Origins of the absolute server URLs (`connect-src` of the UI). */
|
|
592
|
+
function serverOrigins(servers: readonly ServerObject[]): string[] {
|
|
593
|
+
const origins = new Set<string>()
|
|
594
|
+
for (const server of servers) {
|
|
595
|
+
try {
|
|
596
|
+
const url = new URL(server.url)
|
|
597
|
+
if (url.protocol === 'http:' || url.protocol === 'https:') origins.add(url.origin)
|
|
598
|
+
} catch {
|
|
599
|
+
// relative server: 'self'
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return [...origins].sort()
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function parseDocumentFile(text: string, file: string): OpenApiObject {
|
|
606
|
+
let document: unknown
|
|
607
|
+
try {
|
|
608
|
+
document = JSON.parse(text)
|
|
609
|
+
} catch (error) {
|
|
610
|
+
throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, `OpenApiPlugin: ${file} is not valid JSON`, {
|
|
611
|
+
cause: error,
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
const version = (document as { openapi?: unknown } | null)?.openapi
|
|
615
|
+
if (typeof version !== 'string' || !version.startsWith('3.1.')) {
|
|
616
|
+
throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, `OpenApiPlugin: ${file} is not an OpenAPI 3.1 document`, {
|
|
617
|
+
hints: ['Emit it with znight openapi (or znight build --openapi)'],
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
return deepFreeze(document as OpenApiObject)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function deepFreeze<T>(value: T): T {
|
|
624
|
+
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
|
|
625
|
+
Object.freeze(value)
|
|
626
|
+
for (const item of Object.values(value)) deepFreeze(item)
|
|
627
|
+
}
|
|
628
|
+
return value
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function invalid(option: string, expected: string, value: unknown): ZnightError {
|
|
632
|
+
let shown: string
|
|
633
|
+
try {
|
|
634
|
+
shown = typeof value === 'function' ? 'a function' : (JSON.stringify(value) ?? String(value))
|
|
635
|
+
} catch {
|
|
636
|
+
shown = typeof value
|
|
637
|
+
}
|
|
638
|
+
return new ZnightError(
|
|
639
|
+
ErrorCode.InvalidOpenApiDefinition,
|
|
640
|
+
`Invalid OpenApiPlugin option "${option}": expected ${expected}, received ${shown}`,
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const OPTION_KEYS = new Set([
|
|
645
|
+
'enabled',
|
|
646
|
+
'path',
|
|
647
|
+
'yaml',
|
|
648
|
+
'ui',
|
|
649
|
+
'uiPath',
|
|
650
|
+
'assets',
|
|
651
|
+
'document',
|
|
652
|
+
'build',
|
|
653
|
+
'info',
|
|
654
|
+
'servers',
|
|
655
|
+
'filter',
|
|
656
|
+
'transform',
|
|
657
|
+
'converters',
|
|
658
|
+
'unrepresentable',
|
|
659
|
+
'meta',
|
|
660
|
+
'guards',
|
|
661
|
+
'cacheControl',
|
|
662
|
+
])
|
|
663
|
+
|
|
664
|
+
/** Checks and defaults the options (ZNIGHT030). */
|
|
665
|
+
export function resolvePluginOptions(options: OpenApiPluginOptions): Resolved {
|
|
666
|
+
if (options === null || typeof options !== 'object') throw invalid('options', 'an object', options)
|
|
667
|
+
for (const key of Object.keys(options)) {
|
|
668
|
+
if (!OPTION_KEYS.has(key)) throw invalid(key, `one of ${[...OPTION_KEYS].join(', ')} (unknown option)`, key)
|
|
669
|
+
}
|
|
670
|
+
const enabled = options.enabled ?? true
|
|
671
|
+
if (typeof enabled !== 'boolean') throw invalid('enabled', 'true or false', enabled)
|
|
672
|
+
const route = (key: 'path' | 'uiPath', fallback: string): string => {
|
|
673
|
+
const value = options[key] ?? fallback
|
|
674
|
+
if (
|
|
675
|
+
typeof value !== 'string' ||
|
|
676
|
+
!PATH.test(value) ||
|
|
677
|
+
value.includes('//') ||
|
|
678
|
+
(value.length > 1 && value.endsWith('/'))
|
|
679
|
+
) {
|
|
680
|
+
throw invalid(key, "a static path starting with '/' ('/openapi.json')", value)
|
|
681
|
+
}
|
|
682
|
+
return value
|
|
683
|
+
}
|
|
684
|
+
const path = route('path', '/openapi.json')
|
|
685
|
+
const uiPath = route('uiPath', '/docs')
|
|
686
|
+
const yamlOption = options.yaml ?? false
|
|
687
|
+
if (yamlOption !== false && (typeof yamlOption !== 'string' || !PATH.test(yamlOption))) {
|
|
688
|
+
throw invalid('yaml', "false or a path ('/openapi.yaml')", yamlOption)
|
|
689
|
+
}
|
|
690
|
+
const yaml = yamlOption === false ? undefined : yamlOption
|
|
691
|
+
const uiOption = options.ui ?? 'scalar'
|
|
692
|
+
let ui: Resolved['ui']
|
|
693
|
+
if (uiOption === 'scalar' || uiOption === 'swagger') ui = { kind: uiOption, config: {} }
|
|
694
|
+
else if (uiOption === false) ui = undefined
|
|
695
|
+
else if (typeof uiOption === 'object' && uiOption !== null && Object.keys(uiOption).length === 1) {
|
|
696
|
+
const kind = Object.keys(uiOption)[0] as UiKind
|
|
697
|
+
const config = (uiOption as Record<string, unknown>)[kind]
|
|
698
|
+
if ((kind !== 'scalar' && kind !== 'swagger') || typeof config !== 'object' || config === null || !isJson(config)) {
|
|
699
|
+
throw invalid(
|
|
700
|
+
'ui',
|
|
701
|
+
"'scalar', 'swagger', false, { scalar: config } or { swagger: config } (JSON config)",
|
|
702
|
+
uiOption,
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
ui = { kind, config: config as Readonly<Record<string, JsonValue>> }
|
|
706
|
+
} else throw invalid('ui', "'scalar', 'swagger', false, { scalar: config } or { swagger: config }", uiOption)
|
|
707
|
+
const used = [path, ...(yaml === undefined ? [] : [yaml]), ...(ui === undefined ? [] : [uiPath])]
|
|
708
|
+
if (new Set(used).size !== used.length)
|
|
709
|
+
throw invalid('path', 'distinct paths for the document, the YAML and the UI', used)
|
|
710
|
+
const assets = options.assets ?? 'auto'
|
|
711
|
+
if (assets !== 'auto' && assets !== 'local' && assets !== 'cdn')
|
|
712
|
+
throw invalid('assets', "'auto', 'local' or 'cdn'", assets)
|
|
713
|
+
const documentOption = options.document ?? 'build'
|
|
714
|
+
let file: string | undefined
|
|
715
|
+
if (documentOption !== 'build') {
|
|
716
|
+
const target = (documentOption as { file?: unknown } | null)?.file
|
|
717
|
+
if (typeof target === 'string' && target !== '') file = target
|
|
718
|
+
else if (target instanceof URL) file = fileURLToPath(target)
|
|
719
|
+
else throw invalid('document', "'build' or { file: string | URL }", documentOption)
|
|
720
|
+
}
|
|
721
|
+
const build = options.build ?? 'boot'
|
|
722
|
+
if (build !== 'boot' && build !== 'first-request') throw invalid('build', "'boot' or 'first-request'", build)
|
|
723
|
+
for (const key of ['filter', 'transform'] as const) {
|
|
724
|
+
if (options[key] !== undefined && typeof options[key] !== 'function') throw invalid(key, 'a function', options[key])
|
|
725
|
+
}
|
|
726
|
+
if (options.converters !== undefined) {
|
|
727
|
+
if (
|
|
728
|
+
!Array.isArray(options.converters) ||
|
|
729
|
+
options.converters.some(converter => typeof converter?.convert !== 'function')
|
|
730
|
+
) {
|
|
731
|
+
throw invalid('converters', 'an array of { name, convert(schema, context) }', options.converters)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (
|
|
735
|
+
options.unrepresentable !== undefined &&
|
|
736
|
+
options.unrepresentable !== 'error' &&
|
|
737
|
+
options.unrepresentable !== 'warn'
|
|
738
|
+
) {
|
|
739
|
+
throw invalid('unrepresentable', "'error' or 'warn'", options.unrepresentable)
|
|
740
|
+
}
|
|
741
|
+
if (options.info !== undefined && (typeof options.info !== 'object' || options.info === null))
|
|
742
|
+
throw invalid('info', 'an object', options.info)
|
|
743
|
+
if (
|
|
744
|
+
options.servers !== undefined &&
|
|
745
|
+
(!Array.isArray(options.servers) || options.servers.some(server => typeof server?.url !== 'string'))
|
|
746
|
+
) {
|
|
747
|
+
throw invalid('servers', 'an array of { url, description? }', options.servers)
|
|
748
|
+
}
|
|
749
|
+
const meta = options.meta ?? []
|
|
750
|
+
if (!Array.isArray(meta)) throw invalid('meta', 'an array of route metadata entries (Public.meta())', meta)
|
|
751
|
+
const guards = options.guards ?? []
|
|
752
|
+
if (!Array.isArray(guards)) throw invalid('guards', 'an array of guards (classes or instances)', guards)
|
|
753
|
+
const cacheControl = options.cacheControl ?? 'no-cache'
|
|
754
|
+
if (typeof cacheControl !== 'string' || cacheControl === '')
|
|
755
|
+
throw invalid('cacheControl', 'a cache-control value', cacheControl)
|
|
756
|
+
const buildOptions: BuildOpenApiOptions = {
|
|
757
|
+
...(options.info === undefined ? {} : { info: options.info }),
|
|
758
|
+
...(options.servers === undefined ? {} : { servers: options.servers }),
|
|
759
|
+
...(options.filter === undefined ? {} : { filter: options.filter }),
|
|
760
|
+
...(options.transform === undefined ? {} : { transform: options.transform }),
|
|
761
|
+
...(options.converters === undefined ? {} : { converters: options.converters }),
|
|
762
|
+
...(options.unrepresentable === undefined ? {} : { unrepresentable: options.unrepresentable }),
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
enabled,
|
|
766
|
+
path,
|
|
767
|
+
yaml,
|
|
768
|
+
ui,
|
|
769
|
+
uiPath,
|
|
770
|
+
assets,
|
|
771
|
+
file,
|
|
772
|
+
lazy: build === 'first-request',
|
|
773
|
+
build: buildOptions,
|
|
774
|
+
meta,
|
|
775
|
+
guards,
|
|
776
|
+
cacheControl,
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Whether a server URL ends with the HTTP prefix: a relative URL (`/api`, `/v2/api/`), or the path
|
|
782
|
+
* of an absolute one (`https://api.example.com/api`). A URL with variables (`{basePath}`) or that
|
|
783
|
+
* cannot be parsed is not judged.
|
|
784
|
+
*/
|
|
785
|
+
function reflectsPrefix(url: string, prefix: string): boolean {
|
|
786
|
+
if (url.includes('{')) return true
|
|
787
|
+
let path = url
|
|
788
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(url)) {
|
|
789
|
+
try {
|
|
790
|
+
path = new URL(url).pathname
|
|
791
|
+
} catch {
|
|
792
|
+
return true
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return path
|
|
796
|
+
.replace(/[?#].*$/, '')
|
|
797
|
+
.replace(/\/+$/, '')
|
|
798
|
+
.endsWith(prefix.replace(/\/+$/, ''))
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function isJson(value: unknown): boolean {
|
|
802
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
|
|
803
|
+
if (typeof value === 'number') return Number.isFinite(value)
|
|
804
|
+
if (Array.isArray(value)) return value.every(isJson)
|
|
805
|
+
if (typeof value === 'object') {
|
|
806
|
+
const prototype = Object.getPrototypeOf(value)
|
|
807
|
+
return (prototype === Object.prototype || prototype === null) && Object.values(value).every(isJson)
|
|
808
|
+
}
|
|
809
|
+
return false
|
|
810
|
+
}
|