@kizunajs/openapi 2.0.0-beta.2 → 2.0.0-beta.20

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 CHANGED
@@ -1,9 +1,9 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let zod = require("zod");
3
3
  let kizunajs_plugin = require("kizunajs/plugin");
4
+ let kizunajs_generator = require("kizunajs/generator");
4
5
  let kizunajs_adapter = require("kizunajs/adapter");
5
6
  let yaml = require("yaml");
6
- let kizunajs_generator = require("kizunajs/generator");
7
7
  let kizunajs = require("kizunajs");
8
8
  //#region src/generator.ts
9
9
  const streamJsonSchema = (response, toJsonSchema) => {
@@ -80,12 +80,25 @@ const buildDiscriminatorBlock = (propertyName, options) => {
80
80
  } : { propertyName };
81
81
  };
82
82
  /**
83
- * kizuna's metadata widens JSON Schema: `deprecated` may carry a message and
84
- * `example` holds one value or several. Emit `deprecated: true` and an
85
- * `examples` array; the message stays in outputs with a place for it, like
86
- * Swift's `@available`.
83
+ * What the wire carries for a type JSON Schema has no keyword for, which
84
+ * `unrepresentable: 'any'` would otherwise leave as an empty schema.
87
85
  */
88
- const normalizeMeta = ({ jsonSchema }) => {
86
+ const JSON_ENCODING = {
87
+ date: {
88
+ type: "string",
89
+ format: "date-time"
90
+ },
91
+ bigint: { type: "string" }
92
+ };
93
+ /**
94
+ * kizuna's metadata widens JSON Schema: `deprecated` may carry a message,
95
+ * `example` holds one value or several, and `brand` has no keyword. Emit
96
+ * `deprecated: true`, an `examples` array and `x-kizuna-brand`; the message
97
+ * stays in outputs with a place for it, like Swift's `@available`.
98
+ */
99
+ const normalizeMeta = ({ zodSchema, jsonSchema }) => {
100
+ const encoding = JSON_ENCODING[(0, kizunajs_generator.readDef)(zodSchema).type ?? ""];
101
+ if (encoding && Object.keys(jsonSchema).length === 0) Object.assign(jsonSchema, encoding);
89
102
  if (typeof jsonSchema.deprecated === "string") jsonSchema.deprecated = true;
90
103
  if ("example" in jsonSchema) {
91
104
  const given = Array.isArray(jsonSchema.example) ? jsonSchema.example : [jsonSchema.example];
@@ -93,6 +106,10 @@ const normalizeMeta = ({ jsonSchema }) => {
93
106
  jsonSchema.examples = [...given, ...declared];
94
107
  delete jsonSchema.example;
95
108
  }
109
+ if ("brand" in jsonSchema) {
110
+ jsonSchema["x-kizuna-brand"] = jsonSchema.brand;
111
+ delete jsonSchema.brand;
112
+ }
96
113
  };
97
114
  const toJsonSchema = (schema, io = "output") => {
98
115
  const id = (0, kizunajs_generator.readMetaId)(schema);
@@ -149,10 +166,11 @@ const deriveHeadOperation = (getOperation) => {
149
166
  for (const response of Object.values(operation.responses)) delete response.content;
150
167
  return operation;
151
168
  };
152
- const openApiGenerator = (0, kizunajs_generator.createGenerator)((options, api) => {
169
+ const openApiWalk = (options, api) => {
153
170
  const paths = {};
154
171
  return {
155
- processRoute({ routeKey, route, routeTags, deprecated }) {
172
+ processRoute({ routeKey, route, routeGroups, deprecated, hidden }) {
173
+ if (hidden && options.includeHidden !== true) return;
156
174
  const openApiPath = convertPath(route.path);
157
175
  const method = route.method.toLowerCase();
158
176
  let operation = {
@@ -162,9 +180,8 @@ const openApiGenerator = (0, kizunajs_generator.createGenerator)((options, api)
162
180
  };
163
181
  if (options.setOperationId) operation.operationId = options.setOperationId === "concatenated-path" ? routeKey : routeKey.slice(routeKey.lastIndexOf(".") + 1);
164
182
  if (deprecated) operation.deprecated = true;
165
- const resolveTagTitle = (key) => options.tagLookup?.get(key)?.title ?? key;
166
- const mergedTags = [...new Set([...routeTags, ...route.tags ?? []].map(resolveTagTitle))];
167
- if (mergedTags.length > 0) operation.tags = mergedTags;
183
+ const tags = [...new Set(routeGroups.flatMap((group) => options.groupTags?.get(group) ?? [group]))];
184
+ if (tags.length > 0) operation.tags = tags;
168
185
  if (route.security && route.security.length > 0) {
169
186
  const emittedSecurity = toOpenApiSecurity(route.security, api);
170
187
  if (emittedSecurity.length > 0) operation.security = emittedSecurity;
@@ -366,7 +383,7 @@ const openApiGenerator = (0, kizunajs_generator.createGenerator)((options, api)
366
383
  return renderer;
367
384
  }
368
385
  };
369
- });
386
+ };
370
387
  /**
371
388
  * The permissions a route requires, as `x-kizuna-requires`: what is acted on,
372
389
  * mapped to the verbs the caller has to hold.
@@ -425,19 +442,29 @@ const buildSecuritySchemes = (api) => {
425
442
  }
426
443
  return Object.keys(result).length > 0 ? result : void 0;
427
444
  };
428
- const buildTagLookup = (api) => new Map(Object.entries(api.tags?.tags ?? {}));
429
445
  /**
430
- * Document-level tag definitions from the api's declared tag set, one
431
- * entry per declared tag, in declaration order, named by its `title`.
446
+ * The tag for each group path: its top-level group's title, since OpenAPI 3.1.0
447
+ * has no nested tags.
448
+ */
449
+ const buildGroupTags = (api) => {
450
+ const groups = api.groups?.groups;
451
+ const tags = /* @__PURE__ */ new Map();
452
+ if (!groups) return tags;
453
+ for (const group of groups.values()) tags.set(group.path, [groups.get(group.lineage[0]).title]);
454
+ return tags;
455
+ };
456
+ /**
457
+ * One tag per top-level group.
432
458
  */
433
459
  const tagsFromApi = (api) => {
434
- const declared = api.tags?.tags;
435
- if (!declared) return [];
460
+ const groups = api.groups?.groups;
461
+ if (!groups) return [];
436
462
  const tags = [];
437
- for (const options of Object.values(declared)) {
438
- const entry = { name: options.title };
439
- if (options.description) entry.description = options.description;
440
- if (options.externalDocs) entry.externalDocs = options.externalDocs;
463
+ for (const group of groups.values()) {
464
+ if (group.lineage.length > 1) continue;
465
+ const entry = { name: group.title };
466
+ if (group.description) entry.description = group.description;
467
+ if (group.externalDocs) entry.externalDocs = group.externalDocs;
441
468
  tags.push(entry);
442
469
  }
443
470
  return tags;
@@ -446,17 +473,17 @@ const tagsFromApi = (api) => {
446
473
  * So a build step does not restate the options and drift from what is served.
447
474
  */
448
475
  const optionsFromInstalledPlugin = (api) => {
449
- for (const declaration of Object.values(api.plugins ?? {})) if (declaration.slug === "openApi") return declaration.props;
476
+ for (const plugin of Object.values(api.plugins ?? {})) if (plugin.definedSlug === "openApi") return plugin.options;
450
477
  throw new Error("generateOpenApi reads its options from the config. Name `openApiPlugin` under `plugins` with the API's `info`.");
451
478
  };
452
479
  /**
453
480
  * Render from options held directly, for the plugin's own routes.
454
481
  */
455
482
  function renderOpenApi(api, options) {
456
- const renderer = openApiGenerator(api, {
483
+ const renderer = (0, kizunajs_generator.walkApi)(api, openApiWalk({
457
484
  ...options,
458
- tagLookup: buildTagLookup(api)
459
- });
485
+ groupTags: buildGroupTags(api)
486
+ }, api));
460
487
  const tags = tagsFromApi(api);
461
488
  if (tags.length > 0) renderer("json").tags = tags;
462
489
  return renderer;
@@ -548,22 +575,6 @@ const renderDocsHtml = (options) => {
548
575
  });
549
576
  };
550
577
  //#endregion
551
- //#region src/document-target.ts
552
- /**
553
- * An OpenAPI document target for `kizuna.config.ts`.
554
- *
555
- * @example
556
- * openApiDocument({ output: './openapi.yaml' });
557
- */
558
- const openApiDocument = ({ output, ...overrides }) => ({
559
- kind: "openapi",
560
- output,
561
- generate: (contract) => {
562
- const render = generateOpenApi(contract, overrides);
563
- return output.endsWith(".json") ? `${JSON.stringify(render("json"), null, 4)}\n` : render("yaml");
564
- }
565
- });
566
- //#endregion
567
578
  //#region src/server.ts
568
579
  const HTML = "text/html; charset=utf-8";
569
580
  const JSON_TYPE = "application/json";
@@ -573,24 +584,129 @@ const sent = (body, contentType) => new Response(body, {
573
584
  headers: { "content-type": contentType }
574
585
  });
575
586
  /**
576
- * What answers the routes `openApiPlugin` declares: the reference UI, and the
577
- * document itself where the declaration published it.
587
+ * The routes `openApiPlugin` serves: the reference UI, and the document itself
588
+ * where the options publish it. The document is rendered on the first request,
589
+ * once the api it describes has assembled.
578
590
  */
579
- const openApiServe = (props, api) => {
580
- const spec = renderOpenApi((0, kizunajs_adapter.contractOf)(api), props);
581
- return { router: {
582
- page: () => (0, kizunajs_adapter.rawResponse)(sent(renderDocsHtml({
583
- specUrl: props.jsonPath,
584
- specContent: props.jsonPath === void 0 ? spec("json") : void 0,
585
- provider: props.provider,
586
- pageTitle: props.pageTitle ?? spec("json").info.title,
587
- cdnUrl: props.cdnUrl,
588
- configuration: props.configuration
589
- }), HTML)),
590
- json: () => (0, kizunajs_adapter.rawResponse)(sent(JSON.stringify(spec("json")), JSON_TYPE)),
591
- yaml: () => (0, kizunajs_adapter.rawResponse)(sent(spec("yaml"), YAML))
592
- } };
591
+ const openApiRoutes = (props, api) => {
592
+ let renderer;
593
+ const spec = ((format) => {
594
+ renderer ??= renderOpenApi((0, kizunajs_adapter.contractOf)(api), props);
595
+ return renderer(format);
596
+ });
597
+ const routes = {};
598
+ if (props.docsPath !== void 0) routes.page = (0, kizunajs_plugin.route)({
599
+ method: "GET",
600
+ path: props.docsPath,
601
+ summary: "API reference",
602
+ responses: { 200: zod.z.string() }
603
+ }).handler(() => (0, kizunajs_adapter.rawResponse)(sent(renderDocsHtml({
604
+ specUrl: props.jsonPath,
605
+ specContent: props.jsonPath === void 0 ? spec("json") : void 0,
606
+ provider: props.provider,
607
+ pageTitle: props.pageTitle ?? spec("json").info.title,
608
+ cdnUrl: props.cdnUrl,
609
+ configuration: props.configuration
610
+ }), HTML)));
611
+ if (props.jsonPath !== void 0) routes.json = (0, kizunajs_plugin.route)({
612
+ method: "GET",
613
+ path: props.jsonPath,
614
+ summary: "OpenAPI document",
615
+ responses: { 200: zod.z.unknown() }
616
+ }).handler(() => (0, kizunajs_adapter.rawResponse)(sent(JSON.stringify(spec("json")), JSON_TYPE)));
617
+ if (props.yamlPath !== void 0) routes.yaml = (0, kizunajs_plugin.route)({
618
+ method: "GET",
619
+ path: props.yamlPath,
620
+ summary: "OpenAPI document, as YAML",
621
+ responses: { 200: zod.z.string() }
622
+ }).handler(() => (0, kizunajs_adapter.rawResponse)(sent(spec("yaml"), YAML)));
623
+ return routes;
593
624
  };
625
+ //#endregion
626
+ //#region src/types.ts
627
+ const GenerateOpenApiOptionsSchema = zod.z.object({
628
+ /**
629
+ * The OpenAPI Specification version to declare in the document's `openapi` field.
630
+ *
631
+ * Defaults to `'3.1.0'`.
632
+ */
633
+ openApiVersion: zod.z.literal("3.1.0").optional(),
634
+ info: zod.z.object({
635
+ title: zod.z.string(),
636
+ version: zod.z.string(),
637
+ description: zod.z.string().optional()
638
+ }),
639
+ servers: zod.z.array(zod.z.object({
640
+ url: zod.z.string(),
641
+ description: zod.z.string().optional()
642
+ })).optional(),
643
+ externalDocs: zod.z.object({
644
+ url: zod.z.string(),
645
+ description: zod.z.string().optional()
646
+ }).optional(),
647
+ setOperationId: zod.z.union([zod.z.boolean(), zod.z.literal("concatenated-path")]).optional(),
648
+ /**
649
+ * Document `hidden` routes too, for a document your own team reads.
650
+ *
651
+ * @default false
652
+ */
653
+ includeHidden: zod.z.boolean().optional(),
654
+ operationMapper: zod.z.custom((value) => typeof value === "function", { error: "must be a function" }).optional(),
655
+ /**
656
+ * Document the derived `head` operation on every `GET` path without a declared one.
657
+ *
658
+ * @default false
659
+ */
660
+ derivedHead: zod.z.boolean().optional()
661
+ });
662
+ //#endregion
663
+ //#region src/plugin.ts
664
+ const OPENAPI_PLUGIN_SLUG = "openApi";
665
+ const OpenApiPluginOptionsSchema = GenerateOpenApiOptionsSchema.extend({
666
+ /**
667
+ * Where `kizuna generate` writes the document. `.json` renders JSON,
668
+ * anything else YAML.
669
+ *
670
+ * @example
671
+ * output: './openapi.yaml',
672
+ */
673
+ output: zod.z.string().optional(),
674
+ /**
675
+ * Where the reference UI is served.
676
+ */
677
+ docsPath: kizunajs_plugin.RoutePathSchema.optional(),
678
+ /**
679
+ * Where the document is served. The UI embeds it, so this is only for
680
+ * publishing the file.
681
+ */
682
+ jsonPath: zod.z.custom((value) => typeof value === "string" && value.startsWith("/") && value.endsWith(".json"), { error: "must start with / and end in .json" }).optional(),
683
+ /**
684
+ * Where the document is served as YAML.
685
+ */
686
+ yamlPath: zod.z.custom((value) => typeof value === "string" && value.startsWith("/") && value.endsWith(".yaml"), { error: "must start with / and end in .yaml" }).optional(),
687
+ /**
688
+ * Which API reference UI to render.
689
+ *
690
+ * @default 'scalar'
691
+ */
692
+ provider: zod.z.enum(["scalar", "swagger"]).optional(),
693
+ /**
694
+ * Where to load the UI's assets from, for a self-hosted copy. The script
695
+ * URL for `'scalar'`; the directory holding `swagger-ui.css` and
696
+ * `swagger-ui-bundle.js` for `'swagger'`.
697
+ */
698
+ cdnUrl: zod.z.string().optional(),
699
+ /**
700
+ * The page's `<title>`.
701
+ *
702
+ * @default the document's `info.title`
703
+ */
704
+ pageTitle: zod.z.string().optional(),
705
+ /**
706
+ * Merged into `Scalar.createApiReference` or `SwaggerUIBundle`.
707
+ */
708
+ configuration: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
709
+ });
594
710
  /**
595
711
  * Serve an API reference UI for the OpenAPI document, and the document itself
596
712
  * if you publish it.
@@ -608,42 +724,42 @@ const openApiServe = (props, api) => {
608
724
  * title: 'My API',
609
725
  * version: '1.0.0',
610
726
  * },
727
+ * docsPath: '/docs',
611
728
  * }),
612
729
  * ],
613
730
  * });
614
731
  * ```
615
732
  */
616
- const declare = (slug, props) => (0, kizunajs_plugin.createPlugin)({
617
- slug,
618
- routes: {
619
- ...props.docsPath === void 0 ? {} : { page: {
620
- method: "GET",
621
- path: props.docsPath,
622
- summary: "API reference",
623
- responses: { 200: zod.z.string() }
624
- } },
625
- ...props.jsonPath === void 0 ? {} : { json: {
626
- method: "GET",
627
- path: props.jsonPath,
628
- summary: "OpenAPI document",
629
- responses: { 200: zod.z.unknown() }
630
- } },
631
- ...props.yamlPath === void 0 ? {} : { yaml: {
632
- method: "GET",
633
- path: props.yamlPath,
634
- summary: "OpenAPI document, as YAML",
635
- responses: { 200: zod.z.string() }
636
- } }
637
- },
638
- props,
639
- serve: (pluginProps, api) => openApiServe(pluginProps, api)
733
+ const openApiPlugin = (0, kizunajs_plugin.definePlugin)({
734
+ slug: OPENAPI_PLUGIN_SLUG,
735
+ options: OpenApiPluginOptionsSchema,
736
+ setup: ({ options, api }) => {
737
+ const { output, ...document } = options;
738
+ return {
739
+ routes: openApiRoutes(options, api),
740
+ generators: output === void 0 ? [] : [openApiDocumentGenerator({
741
+ output,
742
+ json: output.endsWith(".json"),
743
+ document
744
+ })]
745
+ };
746
+ }
747
+ });
748
+ /**
749
+ * Writes one document, in the format its `output` names.
750
+ */
751
+ const openApiDocumentGenerator = (0, kizunajs_generator.defineGenerator)({
752
+ options: zod.z.object({
753
+ json: zod.z.boolean(),
754
+ document: zod.z.custom()
755
+ }),
756
+ generate: ({ options, api }) => ({ finalize: () => {
757
+ const render = renderOpenApi(api, options.document);
758
+ return options.json ? `${JSON.stringify(render("json"), null, 4)}\n` : render("yaml");
759
+ } })
640
760
  });
641
- function openApiPlugin(props) {
642
- return declare(props.slug ?? "openApi", props);
643
- }
644
761
  //#endregion
645
762
  exports.generateOpenApi = generateOpenApi;
646
- exports.openApiDocument = openApiDocument;
647
763
  exports.openApiPlugin = openApiPlugin;
648
764
  exports.renderDocsHtml = renderDocsHtml;
649
765
  exports.renderOpenApi = renderOpenApi;