@znight/openapi 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +572 -2
  3. package/dist/build-BTUC96mY.d.mts +174 -0
  4. package/dist/build-DXunOzlI.mjs +733 -0
  5. package/dist/build-DXunOzlI.mjs.map +1 -0
  6. package/dist/compiler.d.mts +16 -0
  7. package/dist/compiler.mjs +2639 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/emit-child.d.mts +1 -0
  10. package/dist/emit-child.mjs +124 -0
  11. package/dist/emit-child.mjs.map +1 -0
  12. package/dist/emit.d.mts +2 -0
  13. package/dist/emit.mjs +252 -0
  14. package/dist/emit.mjs.map +1 -0
  15. package/dist/index-CqQ6IRnN.d.mts +60 -0
  16. package/dist/index.d.mts +260 -0
  17. package/dist/index.mjs +777 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/openapi-CQk5dHqc.mjs +39 -0
  20. package/dist/openapi-CQk5dHqc.mjs.map +1 -0
  21. package/dist/shared-BZxtyaB0.mjs +147 -0
  22. package/dist/shared-BZxtyaB0.mjs.map +1 -0
  23. package/dist/testing.d.mts +39 -0
  24. package/dist/testing.mjs +155 -0
  25. package/dist/testing.mjs.map +1 -0
  26. package/dist/types-VzBexMKj.d.mts +213 -0
  27. package/dist/vite.d.mts +24 -0
  28. package/dist/vite.mjs +64 -0
  29. package/dist/vite.mjs.map +1 -0
  30. package/package.json +115 -4
  31. package/src/build.ts +898 -0
  32. package/src/compiler/analyze.ts +81 -0
  33. package/src/compiler/document.ts +612 -0
  34. package/src/compiler/generate.ts +79 -0
  35. package/src/compiler/index.ts +47 -0
  36. package/src/compiler/model.ts +141 -0
  37. package/src/compiler/operations.ts +1475 -0
  38. package/src/compiler/schemas.ts +706 -0
  39. package/src/compiler/validate.ts +102 -0
  40. package/src/compiler/values.ts +81 -0
  41. package/src/converters.ts +137 -0
  42. package/src/decorators.ts +187 -0
  43. package/src/emit/child.ts +144 -0
  44. package/src/emit/index.ts +377 -0
  45. package/src/emit/protocol.ts +35 -0
  46. package/src/index.ts +94 -0
  47. package/src/metadata.ts +112 -0
  48. package/src/openapi.ts +55 -0
  49. package/src/plugin.ts +810 -0
  50. package/src/shared.ts +154 -0
  51. package/src/testing.ts +224 -0
  52. package/src/types.ts +240 -0
  53. package/src/ui.ts +215 -0
  54. package/src/vite.ts +126 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,777 @@
1
+ import { a as BUILT_IN_CONVERTERS, c as OPENAPI_METADATA_VERSION, i as operationViews, n as buildOpenApiDocument, o as standardJsonSchemaConverter, r as openApiSection, s as valibotConverter, t as applyDocumentOptions } from "./build-DXunOzlI.mjs";
2
+ import { n as OpenApiTokens, r as toYaml, t as OpenApi } from "./openapi-CQk5dHqc.mjs";
3
+ import { createRequire } from "node:module";
4
+ import { ErrorCode, ZnightError, resolveOptions } from "@znight/core";
5
+ import { createHash } from "node:crypto";
6
+ import { readFile } from "node:fs/promises";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { HttpResponse, NotFoundException } from "@znight/http";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ // src/decorators.ts
12
+ function marker() {}
13
+ const classMarker = marker;
14
+ const methodMarker = marker;
15
+ const classOrMethodMarker = marker;
16
+ /**
17
+ * Options of the document (one per application, optional). The class is never instantiated nor
18
+ * registered as a provider; its JSDoc gives the title (first line) and the description (the rest).
19
+ *
20
+ * ```ts
21
+ * /** Bookshelf API *\/
22
+ * @OpenApiDocument({ servers: [{ url: '/api' }], security: [ApiKey], decisions: [{ when: Public, security: [] }] })
23
+ * export class BookshelfApi {}
24
+ * ```
25
+ */
26
+ function OpenApiDocument(_options) {
27
+ return classMarker;
28
+ }
29
+ /**
30
+ * Declares a security scheme, read statically by the compiler. At runtime it returns a frozen
31
+ * `{ name, definition }`.
32
+ *
33
+ * ```ts
34
+ * export const ApiKey = securityScheme('apiKey', { type: 'apiKey', in: 'header', name: 'x-api-key' })
35
+ * ```
36
+ */
37
+ function securityScheme(name, definition) {
38
+ return Object.freeze({
39
+ name,
40
+ definition
41
+ });
42
+ }
43
+ /** Overrides the JSDoc and the defaults of an operation. */
44
+ function ApiOperation(_options) {
45
+ return methodMarker;
46
+ }
47
+ /** Tags of the operations (class and method tags are merged, class tags first). */
48
+ function ApiTags(..._names) {
49
+ return classOrMethodMarker;
50
+ }
51
+ /**
52
+ * Declares or overrides a response (repeatable). The body type comes from `T`
53
+ * (`@ApiResponse<Book>(200)`) or from a Standard Schema in `schema`.
54
+ */
55
+ function ApiResponse(_status, _options) {
56
+ return methodMarker;
57
+ }
58
+ /** The body of a route that reads it itself (`ctx.parseBody()`, `ctx.bodyStream()`). */
59
+ function ApiBody(_options) {
60
+ return methodMarker;
61
+ }
62
+ /** A query parameter read outside the signature (`ctx.query`); its type is `T` (default `string`). */
63
+ function ApiQuery(_name, _options) {
64
+ return methodMarker;
65
+ }
66
+ /** A header read by a guard or an interceptor (`x-tenant`); its type is `T` (default `string`). */
67
+ function ApiHeader(_name, _options) {
68
+ return classOrMethodMarker;
69
+ }
70
+ /**
71
+ * Security of the operations: the route's wins over the class's. Several arguments are
72
+ * alternatives (OR); no argument means public (`security: []`).
73
+ */
74
+ function ApiSecurity(..._requirements) {
75
+ return classOrMethodMarker;
76
+ }
77
+ /** The controller or route is not documented. */
78
+ function ApiExclude() {
79
+ return classOrMethodMarker;
80
+ }
81
+ // src/ui.ts
82
+ /** jsDelivr. */
83
+ const CDN_ORIGIN = "https://cdn.jsdelivr.net";
84
+ /** Pinned versions and SRI hashes of the UI assets. */
85
+ const UI_PACKAGES = Object.freeze({
86
+ scalar: Object.freeze({
87
+ name: "@scalar/api-reference",
88
+ version: "1.75.1",
89
+ assets: Object.freeze([{
90
+ file: "dist/browser/standalone.js",
91
+ integrity: "sha384-G+2Q2i9l3PPfmYkk0b4BAIUrs7xb/DNaTL1wCYYab4c9dIKlCyiqzC0Qx3eq9Wf6",
92
+ kind: "script"
93
+ }])
94
+ }),
95
+ swagger: Object.freeze({
96
+ name: "swagger-ui-dist",
97
+ version: "5.33.1",
98
+ assets: Object.freeze([{
99
+ file: "swagger-ui.css",
100
+ integrity: "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW",
101
+ kind: "style"
102
+ }, {
103
+ file: "swagger-ui-bundle.js",
104
+ integrity: "sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw",
105
+ kind: "script"
106
+ }])
107
+ })
108
+ });
109
+ /** URL of a pinned asset on the CDN. */
110
+ function cdnUrl(pkg, asset) {
111
+ return `${CDN_ORIGIN}/npm/${pkg.name}@${pkg.version}/${asset.file}`;
112
+ }
113
+ /**
114
+ * Finds an installed UI package: from `@znight/openapi` (it is an optional peer), then from the
115
+ * working directory. `undefined` when it is not installed or misses an asset. The same on Node.js and
116
+ * Bun, whatever the exports map of the package says (`@scalar/api-reference` does not export its
117
+ * `package.json`).
118
+ */
119
+ function findLocalPackage(pkg, from = defaultBases()) {
120
+ for (const base of from) for (const manifest of manifestCandidates(pkg.name, base)) {
121
+ const found = readLocalPackage(pkg, manifest);
122
+ if (found !== void 0) return found;
123
+ }
124
+ }
125
+ /**
126
+ * Where the manifest of `name` may be, seen from `base`: the `package.json` subpath when it resolves
127
+ * (Bun always resolves it, Node.js only when the exports map exports it), then `<dir>/<name>/package.json`
128
+ * in the `node_modules` directories the resolution searches.
129
+ */
130
+ function manifestCandidates(name, base) {
131
+ let require;
132
+ try {
133
+ require = createRequire(base);
134
+ } catch {
135
+ return [];
136
+ }
137
+ const candidates = [];
138
+ try {
139
+ candidates.push(require.resolve(`${name}/package.json`));
140
+ } catch {}
141
+ for (const dir of require.resolve.paths(name) ?? []) candidates.push(join(dir, name, "package.json"));
142
+ return [...new Set(candidates)];
143
+ }
144
+ function readLocalPackage(pkg, manifest) {
145
+ try {
146
+ if (!existsSync(manifest)) return void 0;
147
+ const { name, version } = JSON.parse(readFileSync(manifest, "utf8"));
148
+ if (name !== pkg.name || typeof version !== "string") return void 0;
149
+ const dir = dirname(manifest);
150
+ return pkg.assets.every((asset) => existsSync(join(dir, asset.file))) ? {
151
+ dir,
152
+ version
153
+ } : void 0;
154
+ } catch {
155
+ return;
156
+ }
157
+ }
158
+ function defaultBases() {
159
+ return [import.meta.url, pathToFileURL(join(process.cwd(), "package.json")).href];
160
+ }
161
+ /** The HTML page of the UI and its Content-Security-Policy. */
162
+ function renderUiPage(input) {
163
+ const pkg = UI_PACKAGES[input.kind];
164
+ const local = input.assetsBase !== void 0;
165
+ const tags = pkg.assets.map((asset) => {
166
+ const url = local ? `${input.assetsBase}/${asset.file.slice(asset.file.lastIndexOf("/") + 1)}` : cdnUrl(pkg, asset);
167
+ const integrity = local ? "" : ` integrity="${asset.integrity}" crossorigin="anonymous"`;
168
+ return asset.kind === "style" ? `<link rel="stylesheet" href="${escapeHtml(url)}"${integrity}>` : `<script src="${escapeHtml(url)}"${integrity}><\/script>`;
169
+ });
170
+ const styles = tags.filter((tag) => tag.startsWith("<link"));
171
+ const scripts = tags.filter((tag) => tag.startsWith("<script"));
172
+ const json = scriptJson(input.kind === "scalar" ? {
173
+ url: input.documentUrl,
174
+ withDefaultFonts: false,
175
+ telemetry: false,
176
+ ...input.config
177
+ } : {
178
+ url: input.documentUrl,
179
+ dom_id: "#swagger-ui",
180
+ validatorUrl: null,
181
+ ...input.config
182
+ });
183
+ const inline = input.kind === "scalar" ? `Scalar.createApiReference('#app', ${json})` : `window.ui = SwaggerUIBundle(${json})`;
184
+ const hash = `'sha256-${createHash("sha256").update(inline).digest("base64")}'`;
185
+ const container = input.kind === "scalar" ? "<div id=\"app\"></div>" : "<div id=\"swagger-ui\"></div>";
186
+ const html = [
187
+ "<!doctype html>",
188
+ "<html lang=\"en\">",
189
+ "<head>",
190
+ "<meta charset=\"utf-8\">",
191
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
192
+ `<title>${escapeHtml(input.title)}</title>`,
193
+ ...styles,
194
+ "</head>",
195
+ "<body>",
196
+ container,
197
+ ...scripts,
198
+ `<script>${inline}<\/script>`,
199
+ "</body>",
200
+ "</html>",
201
+ ""
202
+ ].join("\n");
203
+ const cdn = local ? "" : ` ${CDN_ORIGIN}`;
204
+ const connect = ["'self'", ...input.connect].join(" ");
205
+ return {
206
+ html,
207
+ csp: [
208
+ "default-src 'none'",
209
+ `script-src 'self'${cdn} ${hash}`,
210
+ `style-src 'self' 'unsafe-inline'${cdn}`,
211
+ "img-src 'self' data: https:",
212
+ `font-src 'self' data:${cdn}`,
213
+ `connect-src ${connect}`,
214
+ "frame-ancestors 'none'",
215
+ "base-uri 'none'",
216
+ "form-action 'none'"
217
+ ].join("; ")
218
+ };
219
+ }
220
+ /** JSON safe inside an inline `<script>` (`<` escaped, line separators escaped). */
221
+ function scriptJson(value) {
222
+ return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
223
+ }
224
+ function escapeHtml(text) {
225
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
226
+ }
227
+ // src/plugin.ts
228
+ const ENCODER = new TextEncoder();
229
+ const ASSET_CACHE = "public, max-age=31536000, immutable";
230
+ const PATH = /^\/[A-Za-z0-9\-._~!$&'()*+,;=:@/]*$/;
231
+ /** The OpenAPI plugin: `OpenApiPlugin.configure()` (name `openapi`). */
232
+ var OpenApiPlugin = class OpenApiPlugin {
233
+ name = "openapi";
234
+ #options;
235
+ #resolved;
236
+ #container;
237
+ #metadata;
238
+ #logger;
239
+ #http;
240
+ #served;
241
+ #building;
242
+ #ui;
243
+ #local;
244
+ #operations = [];
245
+ #active = false;
246
+ constructor(options) {
247
+ this.#options = options;
248
+ }
249
+ /**
250
+ * Creates the plugin. `options` may be a function of the container, evaluated once the container
251
+ * is ready: `OpenApiPlugin.configure(c => ({ enabled: c.get(ServerConfig).docs }))`.
252
+ */
253
+ static configure(options = {}) {
254
+ return new OpenApiPlugin(options);
255
+ }
256
+ /** The classes of the `guards` option (instances are left out): `TestApp` creates them at boot. */
257
+ initRoots(_context) {
258
+ const options = this.#options;
259
+ if (options === null || typeof options !== "object") return [];
260
+ try {
261
+ const guards = options.guards;
262
+ return Array.isArray(guards) ? guards.filter((guard) => typeof guard === "function") : [];
263
+ } catch {
264
+ return [];
265
+ }
266
+ }
267
+ register(ctx) {
268
+ if (this.#active) throw new ZnightError(ErrorCode.DuplicateProvider, "This OpenApiPlugin is already used by another application", { hints: ["Call OpenApiPlugin.configure() once per application"] });
269
+ openApiSection(ctx.metadata);
270
+ if (ctx.getPlugin("http") === void 0) throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, "OpenApiPlugin serves its routes through HttpPlugin, which is not registered", { hints: ["Add HttpPlugin.configure() to the plugins of the application"] });
271
+ this.#active = true;
272
+ this.#resolved = void 0;
273
+ this.#served = void 0;
274
+ this.#building = void 0;
275
+ this.#ui = void 0;
276
+ this.#local = void 0;
277
+ this.#container = ctx.container;
278
+ this.#metadata = ctx.metadata;
279
+ this.#logger = ctx.logger;
280
+ this.#http = ctx.getPlugin("http");
281
+ this.#operations = operationViews(ctx.metadata);
282
+ ctx.container.registerValue(OpenApiTokens.OpenApi, new OpenApiService(this));
283
+ }
284
+ httpRoutes(context) {
285
+ this.#container ??= context.container;
286
+ const options = this.#resolve();
287
+ if (!options.enabled) return [];
288
+ const common = {
289
+ ...options.meta.length > 0 ? { meta: options.meta } : {},
290
+ ...options.guards.length > 0 ? { guards: options.guards } : {}
291
+ };
292
+ const routes = [{
293
+ method: "GET",
294
+ path: options.path,
295
+ name: "document",
296
+ handler: (ctx) => this.#serveDocument(ctx, "json"),
297
+ ...common
298
+ }];
299
+ if (options.yaml !== void 0) routes.push({
300
+ method: "GET",
301
+ path: options.yaml,
302
+ name: "yaml",
303
+ handler: (ctx) => this.#serveDocument(ctx, "yaml"),
304
+ ...common
305
+ });
306
+ if (options.ui !== void 0) {
307
+ routes.push({
308
+ method: "GET",
309
+ path: options.uiPath,
310
+ name: "ui",
311
+ handler: (ctx) => this.#serveUi(ctx, context.prefix),
312
+ ...common
313
+ });
314
+ if (this.#localPackage(options) !== void 0) routes.push({
315
+ method: "GET",
316
+ path: `${options.uiPath === "/" ? "" : options.uiPath}/_assets/*`,
317
+ name: "assets",
318
+ handler: (ctx) => this.#serveAsset(ctx),
319
+ ...common
320
+ });
321
+ }
322
+ return routes;
323
+ }
324
+ async onBootstrap(ctx) {
325
+ this.#container = ctx.container;
326
+ const options = this.#resolve();
327
+ if (!options.enabled) return;
328
+ this.#checkServers(options);
329
+ if (options.lazy && options.file === void 0) return;
330
+ await this.served();
331
+ }
332
+ onReady(ctx) {
333
+ const options = this.#resolved;
334
+ const address = this.#http?.address;
335
+ if (options === void 0 || !options.enabled || options.ui === void 0 || address === void 0) return;
336
+ ctx.logger.info(`API reference: ${String(address.url).replace(/\/$/, "")}${this.#prefix()}${options.uiPath}`);
337
+ }
338
+ onDispose() {
339
+ this.#active = false;
340
+ this.#served = void 0;
341
+ this.#building = void 0;
342
+ this.#ui = void 0;
343
+ this.#resolved = void 0;
344
+ }
345
+ /** The views of the operations (no schema conversion). */
346
+ get operations() {
347
+ return this.#operations;
348
+ }
349
+ /** The served document and bodies, built once (shared promise; a failure is retried on the next call). */
350
+ served() {
351
+ if (this.#served !== void 0) return Promise.resolve(this.#served);
352
+ if (this.#building === void 0) this.#building = this.#build().then((served) => {
353
+ this.#served = served;
354
+ return served;
355
+ }, (error) => {
356
+ this.#building = void 0;
357
+ throw error;
358
+ });
359
+ return this.#building;
360
+ }
361
+ async #build() {
362
+ const options = this.#resolve();
363
+ const metadata = this.#metadata;
364
+ if (metadata === void 0) throw new ZnightError(ErrorCode.LifecycleFailed, "OpenApiPlugin: the document is only available once the plugin is registered");
365
+ let document;
366
+ let json;
367
+ if (options.file === void 0) {
368
+ const result = await buildOpenApiDocument(metadata, {
369
+ ...options.build,
370
+ prefix: this.#prefix()
371
+ });
372
+ for (const warning of result.warnings) this.#logger?.warn(`${warning.code}: ${warning.message}`);
373
+ this.#logger?.debug("OpenAPI document built", {
374
+ operations: result.stats.operations,
375
+ schemas: result.stats.schemas,
376
+ ms: Math.round(result.stats.durationMs * 100) / 100
377
+ });
378
+ document = result.document;
379
+ json = result.json;
380
+ } else {
381
+ json = await readFile(options.file, "utf8");
382
+ document = parseDocumentFile(json, options.file);
383
+ const expected = openApiSection(metadata).fingerprint;
384
+ if (document["x-znight-fingerprint"] !== expected) this.#logger?.warn(`ZNIGHT030: ${options.file} is stale: it was not emitted from this build of the application (x-znight-fingerprint differs)`, { hints: ["Regenerate it: znight openapi (or znight build --openapi)"] });
385
+ const prefix = this.#prefix();
386
+ if (options.build.servers === void 0) this.#checkFileServers(options.file, document.servers, prefix);
387
+ const applied = applyDocumentOptions(json, metadata, {
388
+ ...options.build,
389
+ prefix
390
+ });
391
+ if (applied !== void 0) {
392
+ document = applied.document;
393
+ json = applied.json;
394
+ }
395
+ }
396
+ const yaml = options.yaml === void 0 ? void 0 : body(await toYaml(document), "application/yaml");
397
+ return {
398
+ document,
399
+ json: body(json, "application/json; charset=utf-8"),
400
+ yaml
401
+ };
402
+ }
403
+ async #serveDocument(ctx, format) {
404
+ const options = this.#resolve();
405
+ let served;
406
+ try {
407
+ served = await this.served();
408
+ } catch (error) {
409
+ this.#logger?.error("OpenApiPlugin: the document cannot be built", { error: error instanceof Error ? error.message : String(error) });
410
+ throw error;
411
+ }
412
+ const selected = format === "json" ? served.json : served.yaml;
413
+ if (selected === void 0) throw new NotFoundException();
414
+ return conditional(ctx, selected, options.cacheControl);
415
+ }
416
+ async #serveUi(ctx, prefix) {
417
+ const options = this.#resolve();
418
+ if (this.#ui === void 0) {
419
+ const ui = options.ui;
420
+ if (ui === void 0) throw new NotFoundException();
421
+ let document;
422
+ try {
423
+ document = (await this.served()).document;
424
+ } catch {
425
+ document = this.#declared(options);
426
+ }
427
+ const local = this.#localPackage(options);
428
+ const base = prefix === "/" ? "" : prefix;
429
+ const uiPath = options.uiPath === "/" ? "" : options.uiPath;
430
+ const page = renderUiPage({
431
+ kind: ui.kind,
432
+ documentUrl: `${base}${options.path}`,
433
+ assetsBase: local === void 0 ? void 0 : `${base}${uiPath}/_assets/${ui.kind}@${local.version}`,
434
+ title: document?.info.title === void 0 ? "API reference" : `${document.info.title} — API reference`,
435
+ config: ui.config,
436
+ connect: serverOrigins(document?.servers ?? [])
437
+ });
438
+ const bytes = ENCODER.encode(page.html);
439
+ const rendered = {
440
+ html: bytes,
441
+ csp: page.csp,
442
+ etag: etagOf(bytes)
443
+ };
444
+ if (this.#served === void 0) return uiResponse(ctx, rendered);
445
+ this.#ui = rendered;
446
+ }
447
+ return uiResponse(ctx, this.#ui);
448
+ }
449
+ /** `info` and `servers` as declared (static template and runtime options), without building. */
450
+ #declared(options) {
451
+ const metadata = this.#metadata;
452
+ if (metadata === void 0) return void 0;
453
+ let template;
454
+ let servers;
455
+ try {
456
+ const section = openApiSection(metadata);
457
+ template = section.document.info;
458
+ servers = section.document.servers;
459
+ } catch {
460
+ return;
461
+ }
462
+ const info = {
463
+ ...template,
464
+ ...options.build.info
465
+ };
466
+ const resolved = options.build.servers ?? servers;
467
+ return resolved === void 0 ? { info } : {
468
+ info,
469
+ servers: resolved
470
+ };
471
+ }
472
+ #serveAsset(ctx) {
473
+ const options = this.#resolve();
474
+ const ui = options.ui;
475
+ const local = this.#localPackage(options);
476
+ if (ui === void 0 || local === void 0) throw new NotFoundException();
477
+ const requested = ctx.params["*"] ?? "";
478
+ const prefix = `${ui.kind}@${local.version}/`;
479
+ const asset = requested.startsWith(prefix) ? UI_PACKAGES[ui.kind].assets.find((candidate) => candidate.file.endsWith(`/${requested.slice(prefix.length)}`) || candidate.file === requested.slice(prefix.length)) : void 0;
480
+ if (asset === void 0) throw new NotFoundException();
481
+ return HttpResponse.file(asset.file, { root: local.dir }).withHeader("cache-control", ASSET_CACHE);
482
+ }
483
+ #localPackage(options) {
484
+ if (options.ui === void 0 || options.assets === "cdn") return void 0;
485
+ if (this.#local === void 0) {
486
+ this.#local = findLocalPackage(UI_PACKAGES[options.ui.kind]);
487
+ if (this.#local === void 0 && options.assets === "local") throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, `OpenApiPlugin: assets: 'local' needs ${UI_PACKAGES[options.ui.kind].name}, which is not installed`, { hints: [`npm install ${UI_PACKAGES[options.ui.kind].name}`, "Or serve the pinned CDN copy: assets: 'cdn'"] });
488
+ }
489
+ return this.#local;
490
+ }
491
+ #prefix() {
492
+ try {
493
+ return this.#http?.prefix ?? "";
494
+ } catch {
495
+ return "";
496
+ }
497
+ }
498
+ #checkServers(options) {
499
+ const prefix = this.#prefix();
500
+ if (prefix === "" || options.file !== void 0 && options.build.servers === void 0) return;
501
+ const metadata = this.#metadata;
502
+ const declared = options.build.servers ?? (metadata === void 0 ? void 0 : openApiSection(metadata).document.servers);
503
+ this.#checkPrefix(declared ?? [], prefix);
504
+ }
505
+ /**
506
+ * The servers of a prebuilt file against the HTTP prefix (the file may be emitted without the
507
+ * prefix, or with servers of another deployment): ZNIGHT030 when it declares no servers, when a
508
+ * relative server does not end with the prefix, or when no server ends with it (`servers: []`,
509
+ * absolute servers of another path). Runtime `servers` replace the file's and skip this check.
510
+ */
511
+ #checkFileServers(file, servers, prefix) {
512
+ if (prefix === "" || prefix === "/") return;
513
+ if (servers === void 0) {
514
+ this.#logger?.warn(`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`, { hints: [`Emit it with the prefix: znight openapi --prefix ${prefix}, znight build --openapi --openapi-prefix ${prefix}, or openapi({ prefix: '${prefix}' }) in vite.config`] });
515
+ return;
516
+ }
517
+ const declared = Array.isArray(servers) ? servers.filter((server) => typeof server?.url === "string") : [];
518
+ if (this.#checkPrefix(declared, prefix) > 0) return;
519
+ if (declared.some((server) => reflectsPrefix(server.url, prefix))) return;
520
+ const listed = declared.length === 0 ? "[]" : declared.map((server) => `"${server.url}"`).join(", ");
521
+ this.#logger?.warn(`ZNIGHT030: ${file} declares no server ending with the HTTP prefix "${prefix}" (servers: ${listed}): clients generated from the file would call the wrong paths`, { hints: [`Declare a server ending with "${prefix}" (@OpenApiDocument({ servers: [{ url: '${prefix}' }] })) and emit the file again`, "Behind a gateway that rewrites the paths, replace the servers at runtime: OpenApiPlugin.configure({ servers })"] });
522
+ }
523
+ /** Warns about each relative server that does not end with the HTTP prefix; returns how many. */
524
+ #checkPrefix(servers, prefix) {
525
+ let mismatched = 0;
526
+ for (const server of servers) if (server.url.startsWith("/") && !server.url.replace(/\/$/, "").endsWith(prefix)) {
527
+ mismatched++;
528
+ this.#logger?.warn(`ZNIGHT030: the OpenAPI server "${server.url}" does not end with the HTTP prefix "${prefix}": generated clients would call the wrong paths`);
529
+ }
530
+ return mismatched;
531
+ }
532
+ #resolve() {
533
+ if (this.#resolved !== void 0) return this.#resolved;
534
+ let raw;
535
+ if (typeof this.#options === "function") {
536
+ const container = this.#container;
537
+ if (container === void 0) throw new ZnightError(ErrorCode.LifecycleFailed, "OpenApiPlugin: its options are a function of the container, which is not ready");
538
+ raw = resolveOptions(this.#options, container);
539
+ } else raw = this.#options;
540
+ this.#resolved = resolvePluginOptions(raw);
541
+ return this.#resolved;
542
+ }
543
+ };
544
+ /** Implementation of the {@link OpenApi} external. */
545
+ var OpenApiService = class extends OpenApi {
546
+ #plugin;
547
+ constructor(plugin) {
548
+ super();
549
+ this.#plugin = plugin;
550
+ }
551
+ get operations() {
552
+ return this.#plugin.operations;
553
+ }
554
+ async document() {
555
+ return (await this.#plugin.served()).document;
556
+ }
557
+ async json() {
558
+ return new TextDecoder().decode((await this.#plugin.served()).json.bytes);
559
+ }
560
+ async yaml() {
561
+ const served = await this.#plugin.served();
562
+ return served.yaml === void 0 ? toYaml(served.document) : new TextDecoder().decode(served.yaml.bytes);
563
+ }
564
+ };
565
+ /** The UI page: HTML, its Content-Security-Policy, revalidated with its ETag. */
566
+ function uiResponse(ctx, page) {
567
+ return conditional(ctx, {
568
+ bytes: page.html,
569
+ etag: page.etag,
570
+ contentType: "text/html; charset=utf-8"
571
+ }, "no-cache").withHeader("content-security-policy", page.csp).withHeader("x-content-type-options", "nosniff");
572
+ }
573
+ function body(text, contentType) {
574
+ const bytes = ENCODER.encode(text);
575
+ return {
576
+ bytes,
577
+ etag: etagOf(bytes),
578
+ contentType
579
+ };
580
+ }
581
+ /** Strong ETag: base64url SHA-256, 27 characters. */
582
+ function etagOf(bytes) {
583
+ return `"${createHash("sha256").update(bytes).digest("base64url").slice(0, 27)}"`;
584
+ }
585
+ function conditional(ctx, served, cacheControl) {
586
+ const headers = {
587
+ etag: served.etag,
588
+ "cache-control": cacheControl
589
+ };
590
+ if (matches(ctx.header("if-none-match"), served.etag)) return new HttpResponse(void 0, {
591
+ status: 304,
592
+ headers
593
+ });
594
+ return new HttpResponse(served.bytes, {
595
+ status: 200,
596
+ headers: {
597
+ ...headers,
598
+ "content-type": served.contentType
599
+ }
600
+ });
601
+ }
602
+ function matches(header, etag) {
603
+ if (header === void 0) return false;
604
+ return header.split(",").some((candidate) => {
605
+ const tag = candidate.trim();
606
+ return tag === "*" || tag === etag || tag === `W/${etag}`;
607
+ });
608
+ }
609
+ /** Origins of the absolute server URLs (`connect-src` of the UI). */
610
+ function serverOrigins(servers) {
611
+ const origins = /* @__PURE__ */ new Set();
612
+ for (const server of servers) try {
613
+ const url = new URL(server.url);
614
+ if (url.protocol === "http:" || url.protocol === "https:") origins.add(url.origin);
615
+ } catch {}
616
+ return [...origins].sort();
617
+ }
618
+ function parseDocumentFile(text, file) {
619
+ let document;
620
+ try {
621
+ document = JSON.parse(text);
622
+ } catch (error) {
623
+ throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, `OpenApiPlugin: ${file} is not valid JSON`, { cause: error });
624
+ }
625
+ const version = document?.openapi;
626
+ if (typeof version !== "string" || !version.startsWith("3.1.")) throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, `OpenApiPlugin: ${file} is not an OpenAPI 3.1 document`, { hints: ["Emit it with znight openapi (or znight build --openapi)"] });
627
+ return deepFreeze(document);
628
+ }
629
+ function deepFreeze(value) {
630
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
631
+ Object.freeze(value);
632
+ for (const item of Object.values(value)) deepFreeze(item);
633
+ }
634
+ return value;
635
+ }
636
+ function invalid(option, expected, value) {
637
+ let shown;
638
+ try {
639
+ shown = typeof value === "function" ? "a function" : JSON.stringify(value) ?? String(value);
640
+ } catch {
641
+ shown = typeof value;
642
+ }
643
+ return new ZnightError(ErrorCode.InvalidOpenApiDefinition, `Invalid OpenApiPlugin option "${option}": expected ${expected}, received ${shown}`);
644
+ }
645
+ const OPTION_KEYS = /* @__PURE__ */ new Set([
646
+ "enabled",
647
+ "path",
648
+ "yaml",
649
+ "ui",
650
+ "uiPath",
651
+ "assets",
652
+ "document",
653
+ "build",
654
+ "info",
655
+ "servers",
656
+ "filter",
657
+ "transform",
658
+ "converters",
659
+ "unrepresentable",
660
+ "meta",
661
+ "guards",
662
+ "cacheControl"
663
+ ]);
664
+ /** Checks and defaults the options (ZNIGHT030). */
665
+ function resolvePluginOptions(options) {
666
+ if (options === null || typeof options !== "object") throw invalid("options", "an object", options);
667
+ for (const key of Object.keys(options)) if (!OPTION_KEYS.has(key)) throw invalid(key, `one of ${[...OPTION_KEYS].join(", ")} (unknown option)`, key);
668
+ const enabled = options.enabled ?? true;
669
+ if (typeof enabled !== "boolean") throw invalid("enabled", "true or false", enabled);
670
+ const route = (key, fallback) => {
671
+ const value = options[key] ?? fallback;
672
+ if (typeof value !== "string" || !PATH.test(value) || value.includes("//") || value.length > 1 && value.endsWith("/")) throw invalid(key, "a static path starting with '/' ('/openapi.json')", value);
673
+ return value;
674
+ };
675
+ const path = route("path", "/openapi.json");
676
+ const uiPath = route("uiPath", "/docs");
677
+ const yamlOption = options.yaml ?? false;
678
+ if (yamlOption !== false && (typeof yamlOption !== "string" || !PATH.test(yamlOption))) throw invalid("yaml", "false or a path ('/openapi.yaml')", yamlOption);
679
+ const yaml = yamlOption === false ? void 0 : yamlOption;
680
+ const uiOption = options.ui ?? "scalar";
681
+ let ui;
682
+ if (uiOption === "scalar" || uiOption === "swagger") ui = {
683
+ kind: uiOption,
684
+ config: {}
685
+ };
686
+ else if (uiOption === false) ui = void 0;
687
+ else if (typeof uiOption === "object" && uiOption !== null && Object.keys(uiOption).length === 1) {
688
+ const kind = Object.keys(uiOption)[0];
689
+ const config = uiOption[kind];
690
+ if (kind !== "scalar" && kind !== "swagger" || typeof config !== "object" || config === null || !isJson(config)) throw invalid("ui", "'scalar', 'swagger', false, { scalar: config } or { swagger: config } (JSON config)", uiOption);
691
+ ui = {
692
+ kind,
693
+ config
694
+ };
695
+ } else throw invalid("ui", "'scalar', 'swagger', false, { scalar: config } or { swagger: config }", uiOption);
696
+ const used = [
697
+ path,
698
+ ...yaml === void 0 ? [] : [yaml],
699
+ ...ui === void 0 ? [] : [uiPath]
700
+ ];
701
+ if (new Set(used).size !== used.length) throw invalid("path", "distinct paths for the document, the YAML and the UI", used);
702
+ const assets = options.assets ?? "auto";
703
+ if (assets !== "auto" && assets !== "local" && assets !== "cdn") throw invalid("assets", "'auto', 'local' or 'cdn'", assets);
704
+ const documentOption = options.document ?? "build";
705
+ let file;
706
+ if (documentOption !== "build") {
707
+ const target = documentOption?.file;
708
+ if (typeof target === "string" && target !== "") file = target;
709
+ else if (target instanceof URL) file = fileURLToPath(target);
710
+ else throw invalid("document", "'build' or { file: string | URL }", documentOption);
711
+ }
712
+ const build = options.build ?? "boot";
713
+ if (build !== "boot" && build !== "first-request") throw invalid("build", "'boot' or 'first-request'", build);
714
+ for (const key of ["filter", "transform"]) if (options[key] !== void 0 && typeof options[key] !== "function") throw invalid(key, "a function", options[key]);
715
+ if (options.converters !== void 0) {
716
+ if (!Array.isArray(options.converters) || options.converters.some((converter) => typeof converter?.convert !== "function")) throw invalid("converters", "an array of { name, convert(schema, context) }", options.converters);
717
+ }
718
+ if (options.unrepresentable !== void 0 && options.unrepresentable !== "error" && options.unrepresentable !== "warn") throw invalid("unrepresentable", "'error' or 'warn'", options.unrepresentable);
719
+ if (options.info !== void 0 && (typeof options.info !== "object" || options.info === null)) throw invalid("info", "an object", options.info);
720
+ if (options.servers !== void 0 && (!Array.isArray(options.servers) || options.servers.some((server) => typeof server?.url !== "string"))) throw invalid("servers", "an array of { url, description? }", options.servers);
721
+ const meta = options.meta ?? [];
722
+ if (!Array.isArray(meta)) throw invalid("meta", "an array of route metadata entries (Public.meta())", meta);
723
+ const guards = options.guards ?? [];
724
+ if (!Array.isArray(guards)) throw invalid("guards", "an array of guards (classes or instances)", guards);
725
+ const cacheControl = options.cacheControl ?? "no-cache";
726
+ if (typeof cacheControl !== "string" || cacheControl === "") throw invalid("cacheControl", "a cache-control value", cacheControl);
727
+ const buildOptions = {
728
+ ...options.info === void 0 ? {} : { info: options.info },
729
+ ...options.servers === void 0 ? {} : { servers: options.servers },
730
+ ...options.filter === void 0 ? {} : { filter: options.filter },
731
+ ...options.transform === void 0 ? {} : { transform: options.transform },
732
+ ...options.converters === void 0 ? {} : { converters: options.converters },
733
+ ...options.unrepresentable === void 0 ? {} : { unrepresentable: options.unrepresentable }
734
+ };
735
+ return {
736
+ enabled,
737
+ path,
738
+ yaml,
739
+ ui,
740
+ uiPath,
741
+ assets,
742
+ file,
743
+ lazy: build === "first-request",
744
+ build: buildOptions,
745
+ meta,
746
+ guards,
747
+ cacheControl
748
+ };
749
+ }
750
+ /**
751
+ * Whether a server URL ends with the HTTP prefix: a relative URL (`/api`, `/v2/api/`), or the path
752
+ * of an absolute one (`https://api.example.com/api`). A URL with variables (`{basePath}`) or that
753
+ * cannot be parsed is not judged.
754
+ */
755
+ function reflectsPrefix(url, prefix) {
756
+ if (url.includes("{")) return true;
757
+ let path = url;
758
+ if (/^[a-z][a-z\d+.-]*:/i.test(url)) try {
759
+ path = new URL(url).pathname;
760
+ } catch {
761
+ return true;
762
+ }
763
+ return path.replace(/[?#].*$/, "").replace(/\/+$/, "").endsWith(prefix.replace(/\/+$/, ""));
764
+ }
765
+ function isJson(value) {
766
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
767
+ if (typeof value === "number") return Number.isFinite(value);
768
+ if (Array.isArray(value)) return value.every(isJson);
769
+ if (typeof value === "object") {
770
+ const prototype = Object.getPrototypeOf(value);
771
+ return (prototype === Object.prototype || prototype === null) && Object.values(value).every(isJson);
772
+ }
773
+ return false;
774
+ }
775
+ export { ApiBody, ApiExclude, ApiHeader, ApiOperation, ApiQuery, ApiResponse, ApiSecurity, ApiTags, BUILT_IN_CONVERTERS, CDN_ORIGIN, OPENAPI_METADATA_VERSION, OpenApi, OpenApiDocument, OpenApiPlugin, OpenApiTokens, UI_PACKAGES, buildOpenApiDocument, securityScheme, standardJsonSchemaConverter, valibotConverter };
776
+
777
+ //# sourceMappingURL=index.mjs.map