@kizunajs/openapi 2.0.0-beta.2 → 2.0.0-beta.21
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/dist/index.cjs +210 -85
- package/dist/index.d.cts +181 -170
- package/dist/index.d.mts +180 -169
- package/dist/index.mjs +205 -86
- package/package.json +6 -6
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { RoutePathSchema, definePlugin, route } from "kizunajs/plugin";
|
|
3
|
+
import { cacheHeaders, defineGenerator, deprecationHeaders, globalRegistrySchemas, isBinarySchema, isFileSchema, isNamedStream, isStreamResponse, isVoidSchema, parsePath, readDef, readDiscriminatedUnion, readDiscriminatorLiteral, readMeta, readMetaId, readObjectShape, resolveResponseBody, resolveResponseCache, resolveResponseContentType, resolveResponseEtag, resolveResponseHeaders, streamContentType, streamMode, walkApi } from "kizunajs/generator";
|
|
3
4
|
import { contractOf, rawResponse } from "kizunajs/adapter";
|
|
4
5
|
import { stringify } from "yaml";
|
|
5
|
-
import { cacheHeaders, createGenerator, deprecationHeaders, globalRegistrySchemas, isBinarySchema, isFileSchema, isNamedStream, isStreamResponse, isVoidSchema, parsePath, readDiscriminatedUnion, readDiscriminatorLiteral, readMeta, readMetaId, readObjectShape, resolveResponseBody, resolveResponseCache, resolveResponseContentType, resolveResponseEtag, resolveResponseHeaders, streamContentType, streamMode } from "kizunajs/generator";
|
|
6
6
|
import { getStatusText } from "kizunajs";
|
|
7
7
|
//#region src/generator.ts
|
|
8
8
|
const streamJsonSchema = (response, toJsonSchema) => {
|
|
@@ -79,12 +79,25 @@ const buildDiscriminatorBlock = (propertyName, options) => {
|
|
|
79
79
|
} : { propertyName };
|
|
80
80
|
};
|
|
81
81
|
/**
|
|
82
|
-
*
|
|
83
|
-
* `
|
|
84
|
-
* `examples` array; the message stays in outputs with a place for it, like
|
|
85
|
-
* Swift's `@available`.
|
|
82
|
+
* What the wire carries for a type JSON Schema has no keyword for, which
|
|
83
|
+
* `unrepresentable: 'any'` would otherwise leave as an empty schema.
|
|
86
84
|
*/
|
|
87
|
-
const
|
|
85
|
+
const JSON_ENCODING = {
|
|
86
|
+
date: {
|
|
87
|
+
type: "string",
|
|
88
|
+
format: "date-time"
|
|
89
|
+
},
|
|
90
|
+
bigint: { type: "string" }
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* kizuna's metadata widens JSON Schema: `deprecated` may carry a message,
|
|
94
|
+
* `example` holds one value or several, and `brand` has no keyword. Emit
|
|
95
|
+
* `deprecated: true`, an `examples` array and `x-kizuna-brand`; the message
|
|
96
|
+
* stays in outputs with a place for it, like Swift's `@available`.
|
|
97
|
+
*/
|
|
98
|
+
const normalizeMeta = ({ zodSchema, jsonSchema }) => {
|
|
99
|
+
const encoding = JSON_ENCODING[readDef(zodSchema).type ?? ""];
|
|
100
|
+
if (encoding && Object.keys(jsonSchema).length === 0) Object.assign(jsonSchema, encoding);
|
|
88
101
|
if (typeof jsonSchema.deprecated === "string") jsonSchema.deprecated = true;
|
|
89
102
|
if ("example" in jsonSchema) {
|
|
90
103
|
const given = Array.isArray(jsonSchema.example) ? jsonSchema.example : [jsonSchema.example];
|
|
@@ -92,6 +105,10 @@ const normalizeMeta = ({ jsonSchema }) => {
|
|
|
92
105
|
jsonSchema.examples = [...given, ...declared];
|
|
93
106
|
delete jsonSchema.example;
|
|
94
107
|
}
|
|
108
|
+
if ("brand" in jsonSchema) {
|
|
109
|
+
jsonSchema["x-kizuna-brand"] = jsonSchema.brand;
|
|
110
|
+
delete jsonSchema.brand;
|
|
111
|
+
}
|
|
95
112
|
};
|
|
96
113
|
const toJsonSchema = (schema, io = "output") => {
|
|
97
114
|
const id = readMetaId(schema);
|
|
@@ -148,10 +165,11 @@ const deriveHeadOperation = (getOperation) => {
|
|
|
148
165
|
for (const response of Object.values(operation.responses)) delete response.content;
|
|
149
166
|
return operation;
|
|
150
167
|
};
|
|
151
|
-
const
|
|
168
|
+
const openApiWalk = (options, api) => {
|
|
152
169
|
const paths = {};
|
|
153
170
|
return {
|
|
154
|
-
processRoute({ routeKey, route,
|
|
171
|
+
processRoute({ routeKey, route, routeGroups, deprecated, hidden }) {
|
|
172
|
+
if (hidden && options.includeHidden !== true) return;
|
|
155
173
|
const openApiPath = convertPath(route.path);
|
|
156
174
|
const method = route.method.toLowerCase();
|
|
157
175
|
let operation = {
|
|
@@ -161,9 +179,8 @@ const openApiGenerator = createGenerator((options, api) => {
|
|
|
161
179
|
};
|
|
162
180
|
if (options.setOperationId) operation.operationId = options.setOperationId === "concatenated-path" ? routeKey : routeKey.slice(routeKey.lastIndexOf(".") + 1);
|
|
163
181
|
if (deprecated) operation.deprecated = true;
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
if (mergedTags.length > 0) operation.tags = mergedTags;
|
|
182
|
+
const tags = [...new Set(routeGroups.flatMap((group) => options.groupTags?.get(group) ?? [group]))];
|
|
183
|
+
if (tags.length > 0) operation.tags = tags;
|
|
167
184
|
if (route.security && route.security.length > 0) {
|
|
168
185
|
const emittedSecurity = toOpenApiSecurity(route.security, api);
|
|
169
186
|
if (emittedSecurity.length > 0) operation.security = emittedSecurity;
|
|
@@ -206,6 +223,15 @@ const openApiGenerator = createGenerator((options, api) => {
|
|
|
206
223
|
schema: valueSchema
|
|
207
224
|
});
|
|
208
225
|
}
|
|
226
|
+
const streamsEvents = Object.values(route.responses).some((response) => isStreamResponse(response) && streamContentType(response) === "text/event-stream");
|
|
227
|
+
const declaresLastEventId = parameters.some((parameter) => parameter.in === "header" && parameter.name.toLowerCase() === "last-event-id");
|
|
228
|
+
if (streamsEvents && !declaresLastEventId) parameters.push({
|
|
229
|
+
name: "Last-Event-ID",
|
|
230
|
+
in: "header",
|
|
231
|
+
required: false,
|
|
232
|
+
description: "The id of the last event received, to resume a dropped stream after it",
|
|
233
|
+
schema: { type: "string" }
|
|
234
|
+
});
|
|
209
235
|
if (parameters.length > 0) operation.parameters = parameters;
|
|
210
236
|
if (route.body && !isVoidSchema(route.body)) {
|
|
211
237
|
const contentType = route.contentType ?? "application/json";
|
|
@@ -365,7 +391,7 @@ const openApiGenerator = createGenerator((options, api) => {
|
|
|
365
391
|
return renderer;
|
|
366
392
|
}
|
|
367
393
|
};
|
|
368
|
-
}
|
|
394
|
+
};
|
|
369
395
|
/**
|
|
370
396
|
* The permissions a route requires, as `x-kizuna-requires`: what is acted on,
|
|
371
397
|
* mapped to the verbs the caller has to hold.
|
|
@@ -424,19 +450,29 @@ const buildSecuritySchemes = (api) => {
|
|
|
424
450
|
}
|
|
425
451
|
return Object.keys(result).length > 0 ? result : void 0;
|
|
426
452
|
};
|
|
427
|
-
const buildTagLookup = (api) => new Map(Object.entries(api.tags?.tags ?? {}));
|
|
428
453
|
/**
|
|
429
|
-
*
|
|
430
|
-
*
|
|
454
|
+
* The tag for each group path: its top-level group's title, since OpenAPI 3.1.0
|
|
455
|
+
* has no nested tags.
|
|
456
|
+
*/
|
|
457
|
+
const buildGroupTags = (api) => {
|
|
458
|
+
const groups = api.groups?.groups;
|
|
459
|
+
const tags = /* @__PURE__ */ new Map();
|
|
460
|
+
if (!groups) return tags;
|
|
461
|
+
for (const group of groups.values()) tags.set(group.path, [groups.get(group.lineage[0]).title]);
|
|
462
|
+
return tags;
|
|
463
|
+
};
|
|
464
|
+
/**
|
|
465
|
+
* One tag per top-level group.
|
|
431
466
|
*/
|
|
432
467
|
const tagsFromApi = (api) => {
|
|
433
|
-
const
|
|
434
|
-
if (!
|
|
468
|
+
const groups = api.groups?.groups;
|
|
469
|
+
if (!groups) return [];
|
|
435
470
|
const tags = [];
|
|
436
|
-
for (const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (
|
|
471
|
+
for (const group of groups.values()) {
|
|
472
|
+
if (group.lineage.length > 1) continue;
|
|
473
|
+
const entry = { name: group.title };
|
|
474
|
+
if (group.description) entry.description = group.description;
|
|
475
|
+
if (group.externalDocs) entry.externalDocs = group.externalDocs;
|
|
440
476
|
tags.push(entry);
|
|
441
477
|
}
|
|
442
478
|
return tags;
|
|
@@ -445,17 +481,17 @@ const tagsFromApi = (api) => {
|
|
|
445
481
|
* So a build step does not restate the options and drift from what is served.
|
|
446
482
|
*/
|
|
447
483
|
const optionsFromInstalledPlugin = (api) => {
|
|
448
|
-
for (const
|
|
484
|
+
for (const plugin of Object.values(api.plugins ?? {})) if (plugin.definedSlug === "openApi") return plugin.options;
|
|
449
485
|
throw new Error("generateOpenApi reads its options from the config. Name `openApiPlugin` under `plugins` with the API's `info`.");
|
|
450
486
|
};
|
|
451
487
|
/**
|
|
452
488
|
* Render from options held directly, for the plugin's own routes.
|
|
453
489
|
*/
|
|
454
490
|
function renderOpenApi(api, options) {
|
|
455
|
-
const renderer =
|
|
491
|
+
const renderer = walkApi(api, openApiWalk({
|
|
456
492
|
...options,
|
|
457
|
-
|
|
458
|
-
});
|
|
493
|
+
groupTags: buildGroupTags(api)
|
|
494
|
+
}, api));
|
|
459
495
|
const tags = tagsFromApi(api);
|
|
460
496
|
if (tags.length > 0) renderer("json").tags = tags;
|
|
461
497
|
return renderer;
|
|
@@ -547,22 +583,6 @@ const renderDocsHtml = (options) => {
|
|
|
547
583
|
});
|
|
548
584
|
};
|
|
549
585
|
//#endregion
|
|
550
|
-
//#region src/document-target.ts
|
|
551
|
-
/**
|
|
552
|
-
* An OpenAPI document target for `kizuna.config.ts`.
|
|
553
|
-
*
|
|
554
|
-
* @example
|
|
555
|
-
* openApiDocument({ output: './openapi.yaml' });
|
|
556
|
-
*/
|
|
557
|
-
const openApiDocument = ({ output, ...overrides }) => ({
|
|
558
|
-
kind: "openapi",
|
|
559
|
-
output,
|
|
560
|
-
generate: (contract) => {
|
|
561
|
-
const render = generateOpenApi(contract, overrides);
|
|
562
|
-
return output.endsWith(".json") ? `${JSON.stringify(render("json"), null, 4)}\n` : render("yaml");
|
|
563
|
-
}
|
|
564
|
-
});
|
|
565
|
-
//#endregion
|
|
566
586
|
//#region src/server.ts
|
|
567
587
|
const HTML = "text/html; charset=utf-8";
|
|
568
588
|
const JSON_TYPE = "application/json";
|
|
@@ -572,23 +592,43 @@ const sent = (body, contentType) => new Response(body, {
|
|
|
572
592
|
headers: { "content-type": contentType }
|
|
573
593
|
});
|
|
574
594
|
/**
|
|
575
|
-
*
|
|
576
|
-
*
|
|
595
|
+
* The routes `openApiPlugin` serves: the reference UI, and the document itself
|
|
596
|
+
* where the options publish it. The document is rendered on the first request,
|
|
597
|
+
* once the api it describes has assembled.
|
|
577
598
|
*/
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
599
|
+
const openApiRoutes = (props, api) => {
|
|
600
|
+
let renderer;
|
|
601
|
+
const spec = ((format) => {
|
|
602
|
+
renderer ??= renderOpenApi(contractOf(api), props);
|
|
603
|
+
return renderer(format);
|
|
604
|
+
});
|
|
605
|
+
const routes = {};
|
|
606
|
+
if (props.docsPath !== void 0) routes.page = route({
|
|
607
|
+
method: "GET",
|
|
608
|
+
path: props.docsPath,
|
|
609
|
+
summary: "API reference",
|
|
610
|
+
responses: { 200: z.string() }
|
|
611
|
+
}).handler(() => rawResponse(sent(renderDocsHtml({
|
|
612
|
+
specUrl: props.jsonPath,
|
|
613
|
+
specContent: props.jsonPath === void 0 ? spec("json") : void 0,
|
|
614
|
+
provider: props.provider,
|
|
615
|
+
pageTitle: props.pageTitle ?? spec("json").info.title,
|
|
616
|
+
cdnUrl: props.cdnUrl,
|
|
617
|
+
configuration: props.configuration
|
|
618
|
+
}), HTML)));
|
|
619
|
+
if (props.jsonPath !== void 0) routes.json = route({
|
|
620
|
+
method: "GET",
|
|
621
|
+
path: props.jsonPath,
|
|
622
|
+
summary: "OpenAPI document",
|
|
623
|
+
responses: { 200: z.unknown() }
|
|
624
|
+
}).handler(() => rawResponse(sent(JSON.stringify(spec("json")), JSON_TYPE)));
|
|
625
|
+
if (props.yamlPath !== void 0) routes.yaml = route({
|
|
626
|
+
method: "GET",
|
|
627
|
+
path: props.yamlPath,
|
|
628
|
+
summary: "OpenAPI document, as YAML",
|
|
629
|
+
responses: { 200: z.string() }
|
|
630
|
+
}).handler(() => rawResponse(sent(spec("yaml"), YAML)));
|
|
631
|
+
return routes;
|
|
592
632
|
};
|
|
593
633
|
/**
|
|
594
634
|
* Serve an API reference UI for the OpenAPI document, and the document itself
|
|
@@ -607,38 +647,117 @@ const openApiServe = (props, api) => {
|
|
|
607
647
|
* title: 'My API',
|
|
608
648
|
* version: '1.0.0',
|
|
609
649
|
* },
|
|
650
|
+
* docsPath: '/docs',
|
|
610
651
|
* }),
|
|
611
652
|
* ],
|
|
612
653
|
* });
|
|
613
654
|
* ```
|
|
614
655
|
*/
|
|
615
|
-
const
|
|
616
|
-
slug,
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
656
|
+
const openApiPlugin = definePlugin({
|
|
657
|
+
slug: "openApi",
|
|
658
|
+
options: z.object({
|
|
659
|
+
/**
|
|
660
|
+
* The OpenAPI Specification version to declare in the document's `openapi` field.
|
|
661
|
+
*
|
|
662
|
+
* Defaults to `'3.1.0'`.
|
|
663
|
+
*/
|
|
664
|
+
openApiVersion: z.literal("3.1.0").optional(),
|
|
665
|
+
info: z.object({
|
|
666
|
+
title: z.string(),
|
|
667
|
+
version: z.string(),
|
|
668
|
+
description: z.string().optional()
|
|
669
|
+
}),
|
|
670
|
+
servers: z.array(z.object({
|
|
671
|
+
url: z.string(),
|
|
672
|
+
description: z.string().optional()
|
|
673
|
+
})).optional(),
|
|
674
|
+
externalDocs: z.object({
|
|
675
|
+
url: z.string(),
|
|
676
|
+
description: z.string().optional()
|
|
677
|
+
}).optional(),
|
|
678
|
+
setOperationId: z.union([z.boolean(), z.literal("concatenated-path")]).optional(),
|
|
679
|
+
/**
|
|
680
|
+
* Document `hidden` routes too, for a document your own team reads.
|
|
681
|
+
*
|
|
682
|
+
* @default false
|
|
683
|
+
*/
|
|
684
|
+
includeHidden: z.boolean().optional(),
|
|
685
|
+
operationMapper: z.custom((value) => typeof value === "function", { error: "must be a function" }).optional(),
|
|
686
|
+
/**
|
|
687
|
+
* Document the derived `head` operation on every `GET` path without a declared one.
|
|
688
|
+
*
|
|
689
|
+
* @default false
|
|
690
|
+
*/
|
|
691
|
+
derivedHead: z.boolean().optional()
|
|
692
|
+
}).extend({
|
|
693
|
+
/**
|
|
694
|
+
* Where `kizuna generate` writes the document. `.json` renders JSON,
|
|
695
|
+
* anything else YAML.
|
|
696
|
+
*
|
|
697
|
+
* @example
|
|
698
|
+
* output: './openapi.yaml',
|
|
699
|
+
*/
|
|
700
|
+
output: z.string().optional(),
|
|
701
|
+
/**
|
|
702
|
+
* Where the reference UI is served.
|
|
703
|
+
*/
|
|
704
|
+
docsPath: RoutePathSchema.optional(),
|
|
705
|
+
/**
|
|
706
|
+
* Where the document is served. The UI embeds it, so this is only for
|
|
707
|
+
* publishing the file.
|
|
708
|
+
*/
|
|
709
|
+
jsonPath: z.custom((value) => typeof value === "string" && value.startsWith("/") && value.endsWith(".json"), { error: "must start with / and end in .json" }).optional(),
|
|
710
|
+
/**
|
|
711
|
+
* Where the document is served as YAML.
|
|
712
|
+
*/
|
|
713
|
+
yamlPath: z.custom((value) => typeof value === "string" && value.startsWith("/") && value.endsWith(".yaml"), { error: "must start with / and end in .yaml" }).optional(),
|
|
714
|
+
/**
|
|
715
|
+
* Which API reference UI to render.
|
|
716
|
+
*
|
|
717
|
+
* @default 'scalar'
|
|
718
|
+
*/
|
|
719
|
+
provider: z.enum(["scalar", "swagger"]).optional(),
|
|
720
|
+
/**
|
|
721
|
+
* Where to load the UI's assets from, for a self-hosted copy. The script
|
|
722
|
+
* URL for `'scalar'`; the directory holding `swagger-ui.css` and
|
|
723
|
+
* `swagger-ui-bundle.js` for `'swagger'`.
|
|
724
|
+
*/
|
|
725
|
+
cdnUrl: z.string().optional(),
|
|
726
|
+
/**
|
|
727
|
+
* The page's `<title>`.
|
|
728
|
+
*
|
|
729
|
+
* @default the document's `info.title`
|
|
730
|
+
*/
|
|
731
|
+
pageTitle: z.string().optional(),
|
|
732
|
+
/**
|
|
733
|
+
* Merged into `Scalar.createApiReference` or `SwaggerUIBundle`.
|
|
734
|
+
*/
|
|
735
|
+
configuration: z.record(z.string(), z.unknown()).optional()
|
|
736
|
+
}),
|
|
737
|
+
setup: ({ options, api }) => {
|
|
738
|
+
const { output, ...document } = options;
|
|
739
|
+
return {
|
|
740
|
+
routes: openApiRoutes(options, api),
|
|
741
|
+
generators: output === void 0 ? [] : [openApiDocumentGenerator({
|
|
742
|
+
output,
|
|
743
|
+
json: output.endsWith(".json"),
|
|
744
|
+
document
|
|
745
|
+
})]
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
/**
|
|
750
|
+
* Writes one document, in the format its `output` names.
|
|
751
|
+
*/
|
|
752
|
+
const openApiDocumentGenerator = defineGenerator({
|
|
753
|
+
options: z.object({
|
|
754
|
+
json: z.boolean(),
|
|
755
|
+
document: z.custom()
|
|
756
|
+
}),
|
|
757
|
+
generate: ({ options, api }) => ({ finalize: () => {
|
|
758
|
+
const render = renderOpenApi(api, options.document);
|
|
759
|
+
return options.json ? `${JSON.stringify(render("json"), null, 4)}\n` : render("yaml");
|
|
760
|
+
} })
|
|
639
761
|
});
|
|
640
|
-
function openApiPlugin(props) {
|
|
641
|
-
return declare(props.slug ?? "openApi", props);
|
|
642
|
-
}
|
|
643
762
|
//#endregion
|
|
644
|
-
export { generateOpenApi,
|
|
763
|
+
export { generateOpenApi, openApiPlugin, renderDocsHtml, renderOpenApi };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kizunajs/openapi",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.21",
|
|
4
4
|
"description": "OpenAPI 3.1 generation for Kizuna.js APIs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kizunajs",
|
|
@@ -59,17 +59,17 @@
|
|
|
59
59
|
"yaml": "^2.8.4"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
|
-
"
|
|
63
|
-
"
|
|
62
|
+
"kizunajs": "2.0.0-beta.21",
|
|
63
|
+
"zod": "^4.0.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
+
"kizunajs": "2.0.0-beta.21",
|
|
67
|
+
"@kizunajs/express": "2.0.0-beta.21",
|
|
66
68
|
"@types/express": "^5.0.0",
|
|
67
69
|
"express": "^5.0.0",
|
|
68
70
|
"tsdown": "^0.21.0",
|
|
69
71
|
"typescript": "^6.0.3",
|
|
70
|
-
"zod": "^4.0.0"
|
|
71
|
-
"kizunajs": "2.0.0-beta.2",
|
|
72
|
-
"@kizunajs/express": "2.0.0-beta.2"
|
|
72
|
+
"zod": "^4.0.0"
|
|
73
73
|
},
|
|
74
74
|
"scripts": {
|
|
75
75
|
"build": "tsdown src/index.ts --format esm,cjs --dts --clean --external kizunajs --external zod",
|