@tetsujs/openapi 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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The document's `components/schemas` section.
3
+ *
4
+ * The failures the framework fills in repeat across every operation — a
5
+ * `500` on all of them, a `413` on every route with a body — and inlining
6
+ * the same envelope each time makes a document that is mostly boilerplate.
7
+ * They are defined once here and referenced with `$ref`, which is also how
8
+ * a client generator ends up with one `MalformedJson` type instead of one
9
+ * per endpoint.
10
+ *
11
+ * Only the framework's own shapes go here. A schema that came from a
12
+ * validator is still inlined: it is the author's, it has no name the
13
+ * generator could give it, and `$ref` would invent one.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ /** A reference to a definition in `components/schemas`. */
19
+ export interface SchemaRef {
20
+ readonly $ref: string;
21
+ }
22
+
23
+ /** The definitions collected while generating a document. */
24
+ export interface SchemaComponents {
25
+ /**
26
+ * Registers a schema and returns the reference to it.
27
+ *
28
+ * Identical schemas share one definition, whatever name each caller
29
+ * asked for; different schemas that want the same name get a numbered
30
+ * one, so a document is never silently wrong about what a `$ref` points
31
+ * at.
32
+ */
33
+ ref(preferred: string, schema: Record<string, unknown>): SchemaRef;
34
+
35
+ /** What was registered, in registration order. */
36
+ readonly schemas: Record<string, unknown>;
37
+ }
38
+
39
+ export function schemaComponents(): SchemaComponents {
40
+ const schemas: Record<string, unknown> = {};
41
+ const registered = new Map<string, string>();
42
+
43
+ return {
44
+ schemas,
45
+
46
+ ref(preferred: string, schema: Record<string, unknown>): SchemaRef {
47
+ const fingerprint = JSON.stringify(schema);
48
+ const already = registered.get(fingerprint);
49
+
50
+ if (already) {
51
+ return { $ref: `#/components/schemas/${already}` };
52
+ }
53
+
54
+ let name = preferred;
55
+
56
+ for (let attempt = 2; name in schemas; attempt += 1) {
57
+ name = `${preferred}${attempt}`;
58
+ }
59
+
60
+ schemas[name] = schema;
61
+ registered.set(fingerprint, name);
62
+
63
+ return { $ref: `#/components/schemas/${name}` };
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * The name a failure is defined under: its `error` code in PascalCase —
70
+ * `MALFORMED_JSON` becomes `MalformedJson` — so the definition and the code
71
+ * a client branches on read as the same thing.
72
+ *
73
+ * A failure whose code only the hook knows, and which the hook did not
74
+ * declare, is named after its status instead.
75
+ */
76
+ export function failureName(status: number, error?: string): string {
77
+ const name = (error ?? "")
78
+ .toLowerCase()
79
+ .split(/[^a-z0-9]+/)
80
+ .filter(Boolean)
81
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
82
+ .join("");
83
+
84
+ // A code with nothing ASCII in it sanitizes to nothing — a code in
85
+ // Cyrillic or Japanese, or one that is all punctuation. That is the same
86
+ // situation as no code at all, and gets the same name: the alternative
87
+ // was a component keyed `""`, which the specification does not allow, and
88
+ // a second such code keyed `"2"` by the collision numbering below it.
89
+ return name || `Failure${status}`;
90
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The documentation endpoints as a mountable controller.
3
+ *
4
+ * `openapi()` returns data and mounts nothing, which is the right default
5
+ * for a generator but a chore in the ordinary case: every application ends
6
+ * up writing the same controller, holding the same application, to serve
7
+ * the same two routes. {@link docs} is that controller, written once.
8
+ *
9
+ * It is a controller like any other — mounted in `routes`, groupable,
10
+ * hookable — that receives the application through `onMount` and generates
11
+ * the document from it at startup.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import type { App, RouteDef, SchemaConfig, ValidatePath } from "@tetsujs/core";
17
+ import { onMount, route } from "@tetsujs/core";
18
+ import type { OpenApiDocument } from "./document.ts";
19
+ import type { GeneratorWarning } from "./index.ts";
20
+ import { openapi } from "./index.ts";
21
+ import type { DocsAssets, DocsUi } from "./page.ts";
22
+ import { docsPage } from "./page.ts";
23
+
24
+ /** What the documentation controller serves, and from where. */
25
+ export interface DocsOptions<Path extends string, UiPath extends string> {
26
+ /** Title, version and the rest of the document's `info` block. */
27
+ readonly info: OpenApiDocument["info"];
28
+
29
+ /** Servers the API is reachable at. */
30
+ readonly servers?: OpenApiDocument["servers"];
31
+
32
+ /** Where the document is served. Defaults to `/openapi.json`. */
33
+ readonly path?: Path & ValidatePath<Path>;
34
+
35
+ /** Where the page is served. Defaults to `/docs`. */
36
+ readonly uiPath?: UiPath & ValidatePath<UiPath>;
37
+
38
+ /** Which renderer the page bootstraps. Defaults to `"scalar"`. */
39
+ readonly ui?: DocsUi;
40
+
41
+ /** Browser title of the page. Defaults to the document's own title. */
42
+ readonly title?: string;
43
+
44
+ /** Overrides the renderer's CDN URLs, for a pinned or self-hosted copy. */
45
+ readonly assets?: DocsAssets;
46
+
47
+ /**
48
+ * Documents the two endpoints this controller serves.
49
+ *
50
+ * Off by default: a reader who is looking at the document has already
51
+ * found the document, and an operation for "the page you are reading"
52
+ * is one a generated client would happily produce a method for.
53
+ */
54
+ readonly documentSelf?: boolean;
55
+
56
+ /**
57
+ * What to do with what the generator could not describe.
58
+ *
59
+ * Defaults to one `console.warn` per warning, at startup — a document
60
+ * that silently omits a body is worse than a noisy one.
61
+ */
62
+ readonly onWarning?: (warning: GeneratorWarning) => void;
63
+ }
64
+
65
+ /**
66
+ * The controller {@link docs} returns: two routes and the `onMount` that
67
+ * fills them.
68
+ *
69
+ * Instantiated through the factory, never directly — the routes carry the
70
+ * configured paths in their types, which only the factory can infer.
71
+ */
72
+ export class DocsController<Path extends string, UiPath extends string> {
73
+ /** The document itself, as `application/json`. */
74
+ readonly json: RouteDef<Path, SchemaConfig, never, undefined, "GET">;
75
+
76
+ /** The page that renders it. */
77
+ readonly ui: RouteDef<UiPath, SchemaConfig, never, undefined, "GET">;
78
+
79
+ private document: OpenApiDocument | undefined;
80
+
81
+ constructor(
82
+ private readonly options: DocsOptions<Path, UiPath>,
83
+ documentPath: string,
84
+ uiPath: string,
85
+ page: string,
86
+ ) {
87
+ const hidden = !options.documentSelf;
88
+
89
+ const json = route({
90
+ method: "GET",
91
+ path: "/openapi.json",
92
+ docs: { summary: "This document", tags: ["docs"], hidden },
93
+ handler: () => this.document,
94
+ });
95
+
96
+ const ui = route({
97
+ method: "GET",
98
+ path: "/docs",
99
+ docs: { summary: "Rendered documentation", tags: ["docs"], hidden },
100
+ handler: () =>
101
+ new Response(page, {
102
+ headers: { "content-type": "text/html; charset=utf-8" },
103
+ }),
104
+ });
105
+
106
+ /**
107
+ * The configured paths, put back on the routes.
108
+ *
109
+ * `route()` validates its path at the type level, and that check
110
+ * cannot be satisfied by a type variable — `ValidatePath<Path>` stays
111
+ * unresolved while `Path` is generic. The routes are therefore built
112
+ * with the default literals and re-typed here, which is also where the
113
+ * configured path lands at runtime.
114
+ */
115
+ this.json = { ...json, path: documentPath } as unknown as this["json"];
116
+ this.ui = { ...ui, path: uiPath } as unknown as this["ui"];
117
+ }
118
+
119
+ /** Generates the document of the application this was mounted in. */
120
+ [onMount](app: App): void {
121
+ const generated = openapi(app, {
122
+ info: this.options.info,
123
+ ...(this.options.servers ? { servers: this.options.servers } : {}),
124
+ });
125
+
126
+ this.document = generated.document;
127
+
128
+ const report = this.options.onWarning ?? warn;
129
+
130
+ for (const warning of generated.warnings) {
131
+ report(warning);
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * The documentation of an application, as a controller to mount.
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * createApp({
142
+ * routes: [
143
+ * group("/api", { children: [new UsersController(users)] }),
144
+ * docs({ info: { title: "Users API", version: "1.0.0" } }),
145
+ * ],
146
+ * });
147
+ * ```
148
+ *
149
+ * The document is generated once, at startup, from the application the
150
+ * controller was mounted in. Its own two routes stay out of it — pass
151
+ * `documentSelf` to put them in.
152
+ *
153
+ * The page loads its renderer from a CDN; see {@link docsPage} for the
154
+ * trade and for how to point it at a self-hosted copy.
155
+ */
156
+ export function docs<
157
+ const Path extends string = "/openapi.json",
158
+ const UiPath extends string = "/docs",
159
+ >(options: DocsOptions<Path, UiPath>): DocsController<Path, UiPath> {
160
+ const documentPath = options.path ?? "/openapi.json";
161
+ const uiPath = options.uiPath ?? "/docs";
162
+
163
+ const page = docsPage({
164
+ ui: options.ui ?? "scalar",
165
+ documentUrl: documentPath,
166
+ title: options.title ?? options.info.title,
167
+ ...(options.assets ? { assets: options.assets } : {}),
168
+ });
169
+
170
+ return new DocsController(options, documentPath, uiPath, page);
171
+ }
172
+
173
+ function warn(warning: GeneratorWarning): void {
174
+ console.warn(`[openapi] ${warning.route}: ${warning.message}`);
175
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The shape of the documents this package produces.
3
+ *
4
+ * Typed only as far as the generator fills it in: an OpenAPI document is
5
+ * far larger than what a route table can honestly describe, and declaring
6
+ * the rest would promise support that does not exist. Everything typed
7
+ * here is emitted; everything a caller adds by hand passes through the
8
+ * index signatures.
9
+ *
10
+ * OpenAPI 3.1 only. It is JSON Schema 2020-12 verbatim, which is what
11
+ * validators emit through Standard Schema — 3.0 would mean converting the
12
+ * dialect and being subtly wrong about `nullable`, `examples` and binary
13
+ * payloads.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ /** Metadata every OpenAPI document must carry. */
19
+ export interface DocumentInfo {
20
+ readonly title: string;
21
+ readonly version: string;
22
+ readonly description?: string;
23
+ readonly [key: string]: unknown;
24
+ }
25
+
26
+ /** A server the API is reachable at. */
27
+ export interface DocumentServer {
28
+ readonly url: string;
29
+ readonly description?: string;
30
+ }
31
+
32
+ /** One request parameter: a path segment, a query key or a header. */
33
+ export interface ParameterObject {
34
+ readonly name: string;
35
+ readonly in: "path" | "query" | "header" | "cookie";
36
+ readonly required: boolean;
37
+ readonly description?: string;
38
+ readonly schema?: Record<string, unknown>;
39
+ }
40
+
41
+ /** A body or response payload, keyed by media type. */
42
+ export type ContentMap = Record<
43
+ string,
44
+ { readonly schema?: Record<string, unknown> }
45
+ >;
46
+
47
+ /** One response of an operation. */
48
+ export interface ResponseObject {
49
+ readonly description: string;
50
+ readonly content?: ContentMap;
51
+ }
52
+
53
+ /** One endpoint: a method on a path. */
54
+ export interface OperationObject {
55
+ readonly operationId: string;
56
+ readonly summary?: string;
57
+ readonly description?: string;
58
+ readonly tags?: readonly string[];
59
+ readonly deprecated?: boolean;
60
+ readonly parameters?: readonly ParameterObject[];
61
+ readonly requestBody?: {
62
+ readonly required: boolean;
63
+ readonly content: ContentMap;
64
+ };
65
+ readonly security?: readonly Record<string, readonly string[]>[];
66
+ readonly responses: Record<string, ResponseObject>;
67
+ }
68
+
69
+ /** Every operation declared on one path template. */
70
+ export type PathItemObject = Record<string, OperationObject>;
71
+
72
+ /** The generated document. */
73
+ export interface OpenApiDocument {
74
+ readonly openapi: "3.1.0";
75
+ readonly info: DocumentInfo;
76
+ readonly servers?: readonly DocumentServer[];
77
+ readonly paths: Record<string, PathItemObject>;
78
+
79
+ /**
80
+ * What the operations refer to rather than repeat: the security schemes
81
+ * the guards registered, and the failure envelopes the framework fills
82
+ * in. Absent when neither has anything to say.
83
+ */
84
+ readonly components?: {
85
+ readonly securitySchemes?: Record<string, unknown>;
86
+ readonly schemas?: Record<string, unknown>;
87
+ };
88
+
89
+ readonly [key: string]: unknown;
90
+ }
package/src/emit.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Asking a schema to describe itself, without letting it stop the document.
3
+ *
4
+ * Internal to the package.
5
+ *
6
+ * @module
7
+ */
8
+
9
+ import { toJsonSchema } from "@tetsujs/core";
10
+
11
+ /**
12
+ * The JSON Schema dialect a schema is asked for.
13
+ *
14
+ * OpenAPI 3.1 takes JSON Schema 2020-12 as it is, and `draft-2020-12` is a
15
+ * target the Standard JSON Schema specification names — so every library
16
+ * that implements it answers. This used to be `openapi-3.1`, which only
17
+ * Zod accepts: ArkType and Valibot refused it, and their bodies went into
18
+ * the document without a shape and with a warning each.
19
+ */
20
+ const target = { target: "draft-2020-12" } as const;
21
+
22
+ /**
23
+ * A JSON Schema as the generator reads it: any keyword, with the two it
24
+ * looks into named, so reading them does not depend on the reader's
25
+ * `noPropertyAccessFromIndexSignature`.
26
+ */
27
+ export interface JsonSchemaObject {
28
+ properties?: unknown;
29
+ required?: unknown;
30
+ [keyword: string]: unknown;
31
+ }
32
+
33
+ /** Which shape of a schema to describe: what arrives, or what leaves. */
34
+ type Direction = "input" | "output";
35
+
36
+ /**
37
+ * The JSON Schema of a declaration, or `undefined` and a warning saying why.
38
+ *
39
+ * A schema can fail to describe itself in two ways, and the generator owes
40
+ * the same answer to both: an adapter without JSON Schema support returns
41
+ * nothing, and an adapter that has it can still throw — a converter meeting
42
+ * a shape it cannot express, a structure too deep to walk, a `$ref` that
43
+ * leads back to where it started.
44
+ *
45
+ * Only the first was handled, and the difference between them is not the
46
+ * document: it is whether the application starts at all. `docs()` builds
47
+ * the document from `onMount`, which `createApp` calls while it is still
48
+ * assembling itself, so an escaping error is not a gap in a description —
49
+ * it is a server that never listens, over a route that would have worked.
50
+ *
51
+ * `subject` names the declaration for the message, as the reader of a
52
+ * warning would name it: `"the body"`, `"a response"`.
53
+ */
54
+ export function emitted(
55
+ schema: unknown,
56
+ subject: string,
57
+ warn: (message: string) => void,
58
+ direction: Direction = "output",
59
+ ): JsonSchemaObject | undefined {
60
+ let described: JsonSchemaObject | undefined;
61
+
62
+ try {
63
+ described = toJsonSchema(schema, target, direction);
64
+ } catch (error) {
65
+ warn(`${subject} schema failed to emit JSON Schema: ${reason(error)}`);
66
+
67
+ return undefined;
68
+ }
69
+
70
+ if (!described) {
71
+ warn(`${subject} schema does not emit JSON Schema`);
72
+
73
+ return undefined;
74
+ }
75
+
76
+ return withoutDialect(described);
77
+ }
78
+
79
+ /**
80
+ * A schema as the document embeds it: without its own `$schema`.
81
+ *
82
+ * Asked for draft 2020-12, Zod and ArkType name the dialect at the root of
83
+ * every schema they emit. The document declares it once for all of them,
84
+ * and the same line on every body and response is noise.
85
+ */
86
+ function withoutDialect(schema: JsonSchemaObject): JsonSchemaObject {
87
+ if (!("$schema" in schema)) {
88
+ return schema;
89
+ }
90
+
91
+ const { $schema: _, ...rest } = schema;
92
+
93
+ return rest;
94
+ }
95
+
96
+ function reason(error: unknown): string {
97
+ return error instanceof Error ? error.message : String(error);
98
+ }
package/src/index.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * OpenAPI generation for the framework.
3
+ *
4
+ * Reads a compiled application — `app.entries` and the options it actually
5
+ * runs with — and returns an OpenAPI 3.1 document. Nothing is registered,
6
+ * served or mutated: the document is data, and serving it is a route the
7
+ * application declares like any other.
8
+ *
9
+ * Serving it is a route the application declares — or `docs()`, the
10
+ * controller that declares those routes for you.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const { document, warnings } = openapi(app, {
15
+ * info: { title: "Users API", version: "1.0.0" },
16
+ * });
17
+ * ```
18
+ *
19
+ * @module
20
+ */
21
+
22
+ import type { App } from "@tetsujs/core";
23
+ import { contributionsOf } from "./annotations.ts";
24
+ import { schemaComponents } from "./components.ts";
25
+ import type { OpenApiDocument, PathItemObject } from "./document.ts";
26
+ import { operationIds, operationOf } from "./operation.ts";
27
+ import { toTemplate } from "./parameters.ts";
28
+
29
+ export type {
30
+ DocumentedResponse,
31
+ HookContributions,
32
+ HookDocs,
33
+ SecurityRequirement,
34
+ SecurityScheme,
35
+ } from "./annotations.ts";
36
+ export { documented, secured } from "./annotations.ts";
37
+ export type { DocsOptions } from "./controller.ts";
38
+ export { DocsController, docs } from "./controller.ts";
39
+ export type {
40
+ ContentMap,
41
+ DocumentInfo,
42
+ DocumentServer,
43
+ OpenApiDocument,
44
+ OperationObject,
45
+ ParameterObject,
46
+ PathItemObject,
47
+ ResponseObject,
48
+ } from "./document.ts";
49
+ export type { DocsAssets, DocsPageOptions, DocsUi } from "./page.ts";
50
+ export { docsPage } from "./page.ts";
51
+
52
+ /**
53
+ * What the generator cannot read off the application.
54
+ */
55
+ export interface OpenApiOptions {
56
+ /** Title, version and the rest of the document's `info` block. */
57
+ readonly info: OpenApiDocument["info"];
58
+
59
+ /** Servers the API is reachable at. */
60
+ readonly servers?: OpenApiDocument["servers"];
61
+ }
62
+
63
+ /**
64
+ * What the generator could not describe faithfully.
65
+ *
66
+ * Each entry names a route and what was missing — almost always a schema
67
+ * from a validator that emits no JSON Schema. Documentation that silently
68
+ * omits a body is worse than documentation that says it does not know, so
69
+ * these are returned rather than swallowed.
70
+ */
71
+ export interface GeneratorWarning {
72
+ readonly route: string;
73
+ readonly message: string;
74
+ }
75
+
76
+ /** A generated document together with what could not be described. */
77
+ export interface GeneratorResult {
78
+ readonly document: OpenApiDocument;
79
+ readonly warnings: readonly GeneratorWarning[];
80
+ }
81
+
82
+ /**
83
+ * Generates the OpenAPI document of an application.
84
+ *
85
+ * Socket endpoints are skipped: a handshake is a `GET` in the table, but
86
+ * OpenAPI has no way to describe what happens after it, and documenting
87
+ * the upgrade as an ordinary operation would describe a request that
88
+ * clients must not make. AsyncAPI is that format, and a separate document.
89
+ *
90
+ * So are routes marked `docs: { hidden: true }`, and everything they would
91
+ * have contributed with them: a security scheme only a hidden route
92
+ * requires has nothing left to describe.
93
+ *
94
+ * Every route of the table becomes an operation under its path template,
95
+ * with the parameters, body and responses its schemas describe. What the
96
+ * framework itself can answer — a validation failure, an unparsable body,
97
+ * a body over the limit — is filled in from the options the application
98
+ * runs with, so the document states the status actually configured.
99
+ */
100
+ export function openapi(app: App, options: OpenApiOptions): GeneratorResult {
101
+ const paths: Record<string, PathItemObject> = {};
102
+ const warnings: GeneratorWarning[] = [];
103
+ const schemes: Record<string, unknown> = {};
104
+ const components = schemaComponents();
105
+ const ids = operationIds();
106
+
107
+ // Which route already occupies each slot of the document. Two routes the
108
+ // router keeps apart can still meet here, because a template says less
109
+ // than a path does: `/files/*` and `/files/:wildcard` both become
110
+ // `/files/{wildcard}`, and a literal `/users/{id}` — which Bun matches as
111
+ // the string it is — is spelled the same as `/users/:id`. The second one
112
+ // then replaces the first, and the document quietly loses an endpoint
113
+ // that works. It still loses it; it no longer does so without saying.
114
+ const occupied = new Map<string, string>();
115
+
116
+ for (const entry of app.entries) {
117
+ if (entry.ws || entry.def.docs?.hidden) {
118
+ continue;
119
+ }
120
+
121
+ const template = toTemplate(entry.path);
122
+ const route = `${entry.method} ${entry.path}`;
123
+
124
+ const operation = operationOf(
125
+ entry,
126
+ app.options,
127
+ components,
128
+ ids,
129
+ (message) => {
130
+ warnings.push({ route, message });
131
+ },
132
+ );
133
+
134
+ // First definition wins, as it does among the hooks of one route: a
135
+ // name is the identity of a scheme, and the operations that reference
136
+ // it say only the name. A second, different scheme under that name
137
+ // cannot be described without renaming it out from under everything
138
+ // already pointing at it, so it is refused rather than silently
139
+ // swapped in — which used to make every earlier operation describe an
140
+ // authentication its route does not perform.
141
+ for (const requirement of contributionsOf(entry.hooks).security) {
142
+ const defined = schemes[requirement.name];
143
+
144
+ if (defined === undefined) {
145
+ schemes[requirement.name] = requirement.scheme;
146
+
147
+ continue;
148
+ }
149
+
150
+ if (JSON.stringify(defined) !== JSON.stringify(requirement.scheme)) {
151
+ warnings.push({
152
+ route,
153
+ message: `the security scheme "${requirement.name}" is already defined differently; this definition is ignored`,
154
+ });
155
+ }
156
+ }
157
+
158
+ const slot = `${entry.method.toLowerCase()} ${template}`;
159
+ const taken = occupied.get(slot);
160
+
161
+ if (taken) {
162
+ warnings.push({
163
+ route,
164
+ message: `${template} is already described by ${taken}, whose operation this replaces`,
165
+ });
166
+ }
167
+
168
+ occupied.set(slot, route);
169
+
170
+ paths[template] = {
171
+ ...paths[template],
172
+ [entry.method.toLowerCase()]: operation,
173
+ };
174
+ }
175
+
176
+ return {
177
+ document: {
178
+ openapi: "3.1.0",
179
+ info: options.info,
180
+ ...(options.servers ? { servers: options.servers } : {}),
181
+ paths,
182
+ ...componentsOf(schemes, components.schemas),
183
+ },
184
+ warnings,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * The `components` block, present only when something goes in it: the
190
+ * security schemes the guards registered, and the failure envelopes the
191
+ * operations refer to.
192
+ */
193
+ function componentsOf(
194
+ securitySchemes: Record<string, unknown>,
195
+ schemas: Record<string, unknown>,
196
+ ): { components?: Record<string, unknown> } {
197
+ const components = {
198
+ ...(Object.keys(securitySchemes).length > 0 ? { securitySchemes } : {}),
199
+ ...(Object.keys(schemas).length > 0 ? { schemas } : {}),
200
+ };
201
+
202
+ return Object.keys(components).length > 0 ? { components } : {};
203
+ }