@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.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
- import { createPlugin } from "kizunajs/plugin";
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
- * kizuna's metadata widens JSON Schema: `deprecated` may carry a message and
83
- * `example` holds one value or several. Emit `deprecated: true` and an
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 normalizeMeta = ({ jsonSchema }) => {
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 openApiGenerator = createGenerator((options, api) => {
168
+ const openApiWalk = (options, api) => {
152
169
  const paths = {};
153
170
  return {
154
- processRoute({ routeKey, route, routeTags, deprecated }) {
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 resolveTagTitle = (key) => options.tagLookup?.get(key)?.title ?? key;
165
- const mergedTags = [...new Set([...routeTags, ...route.tags ?? []].map(resolveTagTitle))];
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;
@@ -365,7 +382,7 @@ const openApiGenerator = createGenerator((options, api) => {
365
382
  return renderer;
366
383
  }
367
384
  };
368
- });
385
+ };
369
386
  /**
370
387
  * The permissions a route requires, as `x-kizuna-requires`: what is acted on,
371
388
  * mapped to the verbs the caller has to hold.
@@ -424,19 +441,29 @@ const buildSecuritySchemes = (api) => {
424
441
  }
425
442
  return Object.keys(result).length > 0 ? result : void 0;
426
443
  };
427
- const buildTagLookup = (api) => new Map(Object.entries(api.tags?.tags ?? {}));
428
444
  /**
429
- * Document-level tag definitions from the api's declared tag set, one
430
- * entry per declared tag, in declaration order, named by its `title`.
445
+ * The tag for each group path: its top-level group's title, since OpenAPI 3.1.0
446
+ * has no nested tags.
447
+ */
448
+ const buildGroupTags = (api) => {
449
+ const groups = api.groups?.groups;
450
+ const tags = /* @__PURE__ */ new Map();
451
+ if (!groups) return tags;
452
+ for (const group of groups.values()) tags.set(group.path, [groups.get(group.lineage[0]).title]);
453
+ return tags;
454
+ };
455
+ /**
456
+ * One tag per top-level group.
431
457
  */
432
458
  const tagsFromApi = (api) => {
433
- const declared = api.tags?.tags;
434
- if (!declared) return [];
459
+ const groups = api.groups?.groups;
460
+ if (!groups) return [];
435
461
  const tags = [];
436
- for (const options of Object.values(declared)) {
437
- const entry = { name: options.title };
438
- if (options.description) entry.description = options.description;
439
- if (options.externalDocs) entry.externalDocs = options.externalDocs;
462
+ for (const group of groups.values()) {
463
+ if (group.lineage.length > 1) continue;
464
+ const entry = { name: group.title };
465
+ if (group.description) entry.description = group.description;
466
+ if (group.externalDocs) entry.externalDocs = group.externalDocs;
440
467
  tags.push(entry);
441
468
  }
442
469
  return tags;
@@ -445,17 +472,17 @@ const tagsFromApi = (api) => {
445
472
  * So a build step does not restate the options and drift from what is served.
446
473
  */
447
474
  const optionsFromInstalledPlugin = (api) => {
448
- for (const declaration of Object.values(api.plugins ?? {})) if (declaration.slug === "openApi") return declaration.props;
475
+ for (const plugin of Object.values(api.plugins ?? {})) if (plugin.definedSlug === "openApi") return plugin.options;
449
476
  throw new Error("generateOpenApi reads its options from the config. Name `openApiPlugin` under `plugins` with the API's `info`.");
450
477
  };
451
478
  /**
452
479
  * Render from options held directly, for the plugin's own routes.
453
480
  */
454
481
  function renderOpenApi(api, options) {
455
- const renderer = openApiGenerator(api, {
482
+ const renderer = walkApi(api, openApiWalk({
456
483
  ...options,
457
- tagLookup: buildTagLookup(api)
458
- });
484
+ groupTags: buildGroupTags(api)
485
+ }, api));
459
486
  const tags = tagsFromApi(api);
460
487
  if (tags.length > 0) renderer("json").tags = tags;
461
488
  return renderer;
@@ -547,22 +574,6 @@ const renderDocsHtml = (options) => {
547
574
  });
548
575
  };
549
576
  //#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
577
  //#region src/server.ts
567
578
  const HTML = "text/html; charset=utf-8";
568
579
  const JSON_TYPE = "application/json";
@@ -572,23 +583,43 @@ const sent = (body, contentType) => new Response(body, {
572
583
  headers: { "content-type": contentType }
573
584
  });
574
585
  /**
575
- * What answers the routes `openApiPlugin` declares: the reference UI, and the
576
- * document itself where the declaration published it.
586
+ * The routes `openApiPlugin` serves: the reference UI, and the document itself
587
+ * where the options publish it. The document is rendered on the first request,
588
+ * once the api it describes has assembled.
577
589
  */
578
- const openApiServe = (props, api) => {
579
- const spec = renderOpenApi(contractOf(api), props);
580
- return { router: {
581
- page: () => rawResponse(sent(renderDocsHtml({
582
- specUrl: props.jsonPath,
583
- specContent: props.jsonPath === void 0 ? spec("json") : void 0,
584
- provider: props.provider,
585
- pageTitle: props.pageTitle ?? spec("json").info.title,
586
- cdnUrl: props.cdnUrl,
587
- configuration: props.configuration
588
- }), HTML)),
589
- json: () => rawResponse(sent(JSON.stringify(spec("json")), JSON_TYPE)),
590
- yaml: () => rawResponse(sent(spec("yaml"), YAML))
591
- } };
590
+ const openApiRoutes = (props, api) => {
591
+ let renderer;
592
+ const spec = ((format) => {
593
+ renderer ??= renderOpenApi(contractOf(api), props);
594
+ return renderer(format);
595
+ });
596
+ const routes = {};
597
+ if (props.docsPath !== void 0) routes.page = route({
598
+ method: "GET",
599
+ path: props.docsPath,
600
+ summary: "API reference",
601
+ responses: { 200: z.string() }
602
+ }).handler(() => rawResponse(sent(renderDocsHtml({
603
+ specUrl: props.jsonPath,
604
+ specContent: props.jsonPath === void 0 ? spec("json") : void 0,
605
+ provider: props.provider,
606
+ pageTitle: props.pageTitle ?? spec("json").info.title,
607
+ cdnUrl: props.cdnUrl,
608
+ configuration: props.configuration
609
+ }), HTML)));
610
+ if (props.jsonPath !== void 0) routes.json = route({
611
+ method: "GET",
612
+ path: props.jsonPath,
613
+ summary: "OpenAPI document",
614
+ responses: { 200: z.unknown() }
615
+ }).handler(() => rawResponse(sent(JSON.stringify(spec("json")), JSON_TYPE)));
616
+ if (props.yamlPath !== void 0) routes.yaml = route({
617
+ method: "GET",
618
+ path: props.yamlPath,
619
+ summary: "OpenAPI document, as YAML",
620
+ responses: { 200: z.string() }
621
+ }).handler(() => rawResponse(sent(spec("yaml"), YAML)));
622
+ return routes;
592
623
  };
593
624
  /**
594
625
  * Serve an API reference UI for the OpenAPI document, and the document itself
@@ -607,38 +638,117 @@ const openApiServe = (props, api) => {
607
638
  * title: 'My API',
608
639
  * version: '1.0.0',
609
640
  * },
641
+ * docsPath: '/docs',
610
642
  * }),
611
643
  * ],
612
644
  * });
613
645
  * ```
614
646
  */
615
- const declare = (slug, props) => createPlugin({
616
- slug,
617
- routes: {
618
- ...props.docsPath === void 0 ? {} : { page: {
619
- method: "GET",
620
- path: props.docsPath,
621
- summary: "API reference",
622
- responses: { 200: z.string() }
623
- } },
624
- ...props.jsonPath === void 0 ? {} : { json: {
625
- method: "GET",
626
- path: props.jsonPath,
627
- summary: "OpenAPI document",
628
- responses: { 200: z.unknown() }
629
- } },
630
- ...props.yamlPath === void 0 ? {} : { yaml: {
631
- method: "GET",
632
- path: props.yamlPath,
633
- summary: "OpenAPI document, as YAML",
634
- responses: { 200: z.string() }
635
- } }
636
- },
637
- props,
638
- serve: (pluginProps, api) => openApiServe(pluginProps, api)
647
+ const openApiPlugin = definePlugin({
648
+ slug: "openApi",
649
+ options: z.object({
650
+ /**
651
+ * The OpenAPI Specification version to declare in the document's `openapi` field.
652
+ *
653
+ * Defaults to `'3.1.0'`.
654
+ */
655
+ openApiVersion: z.literal("3.1.0").optional(),
656
+ info: z.object({
657
+ title: z.string(),
658
+ version: z.string(),
659
+ description: z.string().optional()
660
+ }),
661
+ servers: z.array(z.object({
662
+ url: z.string(),
663
+ description: z.string().optional()
664
+ })).optional(),
665
+ externalDocs: z.object({
666
+ url: z.string(),
667
+ description: z.string().optional()
668
+ }).optional(),
669
+ setOperationId: z.union([z.boolean(), z.literal("concatenated-path")]).optional(),
670
+ /**
671
+ * Document `hidden` routes too, for a document your own team reads.
672
+ *
673
+ * @default false
674
+ */
675
+ includeHidden: z.boolean().optional(),
676
+ operationMapper: z.custom((value) => typeof value === "function", { error: "must be a function" }).optional(),
677
+ /**
678
+ * Document the derived `head` operation on every `GET` path without a declared one.
679
+ *
680
+ * @default false
681
+ */
682
+ derivedHead: z.boolean().optional()
683
+ }).extend({
684
+ /**
685
+ * Where `kizuna generate` writes the document. `.json` renders JSON,
686
+ * anything else YAML.
687
+ *
688
+ * @example
689
+ * output: './openapi.yaml',
690
+ */
691
+ output: z.string().optional(),
692
+ /**
693
+ * Where the reference UI is served.
694
+ */
695
+ docsPath: RoutePathSchema.optional(),
696
+ /**
697
+ * Where the document is served. The UI embeds it, so this is only for
698
+ * publishing the file.
699
+ */
700
+ jsonPath: z.custom((value) => typeof value === "string" && value.startsWith("/") && value.endsWith(".json"), { error: "must start with / and end in .json" }).optional(),
701
+ /**
702
+ * Where the document is served as YAML.
703
+ */
704
+ yamlPath: z.custom((value) => typeof value === "string" && value.startsWith("/") && value.endsWith(".yaml"), { error: "must start with / and end in .yaml" }).optional(),
705
+ /**
706
+ * Which API reference UI to render.
707
+ *
708
+ * @default 'scalar'
709
+ */
710
+ provider: z.enum(["scalar", "swagger"]).optional(),
711
+ /**
712
+ * Where to load the UI's assets from, for a self-hosted copy. The script
713
+ * URL for `'scalar'`; the directory holding `swagger-ui.css` and
714
+ * `swagger-ui-bundle.js` for `'swagger'`.
715
+ */
716
+ cdnUrl: z.string().optional(),
717
+ /**
718
+ * The page's `<title>`.
719
+ *
720
+ * @default the document's `info.title`
721
+ */
722
+ pageTitle: z.string().optional(),
723
+ /**
724
+ * Merged into `Scalar.createApiReference` or `SwaggerUIBundle`.
725
+ */
726
+ configuration: z.record(z.string(), z.unknown()).optional()
727
+ }),
728
+ setup: ({ options, api }) => {
729
+ const { output, ...document } = options;
730
+ return {
731
+ routes: openApiRoutes(options, api),
732
+ generators: output === void 0 ? [] : [openApiDocumentGenerator({
733
+ output,
734
+ json: output.endsWith(".json"),
735
+ document
736
+ })]
737
+ };
738
+ }
739
+ });
740
+ /**
741
+ * Writes one document, in the format its `output` names.
742
+ */
743
+ const openApiDocumentGenerator = defineGenerator({
744
+ options: z.object({
745
+ json: z.boolean(),
746
+ document: z.custom()
747
+ }),
748
+ generate: ({ options, api }) => ({ finalize: () => {
749
+ const render = renderOpenApi(api, options.document);
750
+ return options.json ? `${JSON.stringify(render("json"), null, 4)}\n` : render("yaml");
751
+ } })
639
752
  });
640
- function openApiPlugin(props) {
641
- return declare(props.slug ?? "openApi", props);
642
- }
643
753
  //#endregion
644
- export { generateOpenApi, openApiDocument, openApiPlugin, renderDocsHtml, renderOpenApi };
754
+ 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.2",
3
+ "version": "2.0.0-beta.20",
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
- "zod": "^4.0.0",
63
- "kizunajs": "2.0.0-beta.2"
62
+ "kizunajs": "2.0.0-beta.20",
63
+ "zod": "^4.0.0"
64
64
  },
65
65
  "devDependencies": {
66
+ "kizunajs": "2.0.0-beta.20",
67
+ "@kizunajs/express": "2.0.0-beta.20",
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",