@rhythmjs/openapi 0.0.1
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/LICENSE +15 -0
- package/README.md +149 -0
- package/dist/body/body.d.ts +18 -0
- package/dist/body/body.js +18 -0
- package/dist/callback/callback.d.ts +6 -0
- package/dist/callback/callback.js +7 -0
- package/dist/cookie/cookie.d.ts +10 -0
- package/dist/cookie/cookie.js +14 -0
- package/dist/docs/docs.d.ts +19 -0
- package/dist/docs/docs.js +66 -0
- package/dist/document/document.d.ts +16 -0
- package/dist/document/document.js +6 -0
- package/dist/exclude/exclude.d.ts +5 -0
- package/dist/exclude/exclude.js +7 -0
- package/dist/extension/extension.d.ts +5 -0
- package/dist/extension/extension.js +8 -0
- package/dist/generate/generate.d.ts +21 -0
- package/dist/generate/generate.js +169 -0
- package/dist/header/header.d.ts +10 -0
- package/dist/header/header.js +14 -0
- package/dist/metadata/metadata.d.ts +2 -0
- package/dist/metadata/metadata.js +17 -0
- package/dist/metadata-CT05Tw6j.d.ts +62 -0
- package/dist/operation/operation.d.ts +7 -0
- package/dist/operation/operation.js +7 -0
- package/dist/param/param.d.ts +10 -0
- package/dist/param/param.js +14 -0
- package/dist/query/query.d.ts +10 -0
- package/dist/query/query.js +14 -0
- package/dist/resolver/resolver.d.ts +2 -0
- package/dist/resolver/resolver.js +37 -0
- package/dist/resolver-sb66uMQ-.d.ts +13 -0
- package/dist/response/response.d.ts +18 -0
- package/dist/response/response.js +19 -0
- package/dist/runtime-CCU9lE2d.js +137 -0
- package/dist/runtime-CtmUGPnb.d.ts +24 -0
- package/dist/security/security.d.ts +11 -0
- package/dist/security/security.js +25 -0
- package/dist/tags/tags.d.ts +5 -0
- package/dist/tags/tags.js +7 -0
- package/dist/types/types.d.ts +2 -0
- package/dist/types/types.js +1 -0
- package/dist/types-DeDU0OMv.d.ts +197 -0
- package/package.json +145 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rhythmjs
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# @rhythmjs/openapi
|
|
2
|
+
|
|
3
|
+
OpenAPI 3.1 documentation for [Rhythm](https://github.com/rhythmjs/rhythm) routers and handlers. Routes are
|
|
4
|
+
documented by small single-purpose middlewares (`apiBody`, `apiResponse`, `apiTags`, …); a generator walks the
|
|
5
|
+
router and produces the document; a docs middleware serves it with an interactive reference UI. Each module is
|
|
6
|
+
exported by its own subpath — there is no root barrel export.
|
|
7
|
+
|
|
8
|
+
Schemas are [Standard Schema v1](https://standardschema.dev): zod and valibot convert to JSON Schema out of the
|
|
9
|
+
box, any other vendor via a custom converter.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm add @rhythmjs/openapi @rhythmjs/rhythm @rhythmjs/router
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`@rhythmjs/router` >= 0.0.6 is required (the generator reads `router.entries`). `zod` (v4) and
|
|
18
|
+
`@valibot/to-json-schema` are optional peers — install whichever your schemas need.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Rhythm } from "@rhythmjs/rhythm";
|
|
24
|
+
import { RhythmRouter } from "@rhythmjs/router";
|
|
25
|
+
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
|
|
26
|
+
import { apiBody, type Validated } from "@rhythmjs/openapi/body";
|
|
27
|
+
import { apiParam } from "@rhythmjs/openapi/param";
|
|
28
|
+
import { apiOperation } from "@rhythmjs/openapi/operation";
|
|
29
|
+
import { apiResponse } from "@rhythmjs/openapi/response";
|
|
30
|
+
import { apiTags } from "@rhythmjs/openapi/tags";
|
|
31
|
+
import { apiBearerAuth } from "@rhythmjs/openapi/security";
|
|
32
|
+
import { defineDocument } from "@rhythmjs/openapi/document";
|
|
33
|
+
import { apiDocument, apiReference } from "@rhythmjs/openapi/docs";
|
|
34
|
+
import { z } from "zod";
|
|
35
|
+
|
|
36
|
+
const User = z.object({ id: z.string(), name: z.string() });
|
|
37
|
+
const CreateUser = z.object({ name: z.string().min(1) });
|
|
38
|
+
|
|
39
|
+
const users = new RhythmRouter({ prefix: "/users" })
|
|
40
|
+
.use(apiTags("users"))
|
|
41
|
+
.post<Validated<"body", typeof CreateUser>>(
|
|
42
|
+
"/",
|
|
43
|
+
apiBody(CreateUser),
|
|
44
|
+
apiOperation({ summary: "Create user", operationId: "createUser" }),
|
|
45
|
+
apiBearerAuth(),
|
|
46
|
+
apiResponse(201, { description: "Created", schema: User }),
|
|
47
|
+
(ctx) => {
|
|
48
|
+
// ctx.valid.body is fully typed as the schema output ({ name: string })
|
|
49
|
+
ctx.json({ id: "1", ...ctx.valid.body }, 201);
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
.get(
|
|
53
|
+
"/:id",
|
|
54
|
+
apiParam(z.object({ id: z.string() })),
|
|
55
|
+
apiResponse(200, { description: "The user", schema: User }),
|
|
56
|
+
(ctx) => {
|
|
57
|
+
ctx.json({ id: ctx.params.id, name: "Ada" });
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const config = defineDocument({
|
|
62
|
+
info: { title: "My API", version: "1.0.0" },
|
|
63
|
+
servers: [{ url: "https://api.example.com" }],
|
|
64
|
+
securitySchemes: { bearer: { type: "http", scheme: "bearer", bearerFormat: "JWT" } },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const app = new Rhythm<RhythmHttpContext>()
|
|
68
|
+
.use(apiDocument({ router: users, config })) // GET /openapi.json
|
|
69
|
+
.use(apiReference()) // GET /docs (Scalar; { ui: "swagger" } for Swagger UI)
|
|
70
|
+
.use(users.middleware());
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Request middlewares (validate + document)
|
|
74
|
+
|
|
75
|
+
These take a Standard Schema, validate the request at runtime (exposing the typed output on
|
|
76
|
+
`ctx.valid[target]`, same contract and 400 `ValidationFailure` shape as `@rhythmjs/middleware/validate`), and
|
|
77
|
+
document the corresponding OpenAPI object. Use either these or `@rhythmjs/middleware/validate` on a route — not
|
|
78
|
+
both, or the request is validated twice.
|
|
79
|
+
|
|
80
|
+
- `apiBody(schema, options?)` — Request Body Object. Options: `description`, `required`, `contentType`
|
|
81
|
+
(drives extraction too: JSON, forms, or raw text), `example(s)`, `encoding`, or a full `content` map.
|
|
82
|
+
- `apiQuery(schema, options?)` — query Parameter Objects, one per schema property; repeated keys become arrays.
|
|
83
|
+
- `apiParam(schema, options?)` — path Parameter Objects (always `required: true`).
|
|
84
|
+
- `apiHeader(schema, options?)` — header Parameter Objects (header names are lowercased).
|
|
85
|
+
- `apiCookie(schema, options?)` — cookie Parameter Objects, parsed from the `cookie` header.
|
|
86
|
+
|
|
87
|
+
Parameter middlewares accept per-property `overrides` for everything a schema cannot express:
|
|
88
|
+
`description`, `required`, `deprecated`, `style`, `explode`, `allowReserved`, `allowEmptyValue`, `example(s)`.
|
|
89
|
+
|
|
90
|
+
## Documentation-only middlewares
|
|
91
|
+
|
|
92
|
+
Runtime no-ops that carry spec fragments. Apply per-route, or router-wide with `router.use(...)` — a
|
|
93
|
+
router-level fragment applies to every route registered after it.
|
|
94
|
+
|
|
95
|
+
- `apiOperation({ summary, description, operationId, deprecated, externalDocs, servers })`
|
|
96
|
+
- `apiResponse(status, { description, schema?, contentType?, example(s)?, content?, headers?, links? })` —
|
|
97
|
+
stackable; `status` is a code, a range (`"5XX"`), or `"default"`.
|
|
98
|
+
- `apiTags(...names)`
|
|
99
|
+
- `apiSecurity(name, scopes?)` plus presets `apiBearerAuth()`, `apiBasicAuth()`, `apiCookieAuth()`,
|
|
100
|
+
`apiKeyAuth()`, `apiOAuth2(scopes)`, and `apiNoSecurity()` (documents `security: []`).
|
|
101
|
+
- `apiExclude()` — hide a route (or a whole router via `use`).
|
|
102
|
+
- `apiExtension("x-...", value)`
|
|
103
|
+
- `apiCallback(name, callbackObject)`
|
|
104
|
+
|
|
105
|
+
There are no model-level annotations: property documentation lives in the schema itself (zod `.describe()` /
|
|
106
|
+
`.meta()`, valibot equivalents) and flows through the JSON Schema conversion.
|
|
107
|
+
|
|
108
|
+
## Document config and generation
|
|
109
|
+
|
|
110
|
+
Everything that is not a route concern is a plain config object (Rhythm's counterpart to NestJS's
|
|
111
|
+
`DocumentBuilder`):
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { defineDocument } from "@rhythmjs/openapi/document";
|
|
115
|
+
import { generate } from "@rhythmjs/openapi/generate";
|
|
116
|
+
|
|
117
|
+
const config = defineDocument({
|
|
118
|
+
info: { title: "My API", version: "1.0.0" },
|
|
119
|
+
servers: [{ url: "https://api.example.com" }],
|
|
120
|
+
tags: [{ name: "users", description: "User management" }],
|
|
121
|
+
security: [{ bearer: [] }], // global security; apiNoSecurity() opts a route out
|
|
122
|
+
securitySchemes: { bearer: { type: "http", scheme: "bearer" } },
|
|
123
|
+
webhooks: { "user.created": { post: { responses: { "200": { description: "Received" } } } } },
|
|
124
|
+
components: {}, // reusable components, merged verbatim
|
|
125
|
+
extensions: { "x-audience": "public" },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const doc = await generate(router, config, {
|
|
129
|
+
// openapi: "3.1.1", includeUndocumented: true,
|
|
130
|
+
// converters: { arktype: (schema) => schema.toJsonSchema() },
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`generate` walks `router.entries`, merges fragments per route, converts `:id` to `{id}`, resolves schemas
|
|
135
|
+
(request schemas on their input side, response schemas on their output side), and hoists `$defs` into
|
|
136
|
+
`components.schemas`. Undocumented routes are included with a default `200` response
|
|
137
|
+
(`includeUndocumented: false` drops them).
|
|
138
|
+
|
|
139
|
+
## Serving the docs
|
|
140
|
+
|
|
141
|
+
- `apiDocument({ router, config, path? })` — serves the generated JSON (default `/openapi.json`), generated
|
|
142
|
+
lazily once and cached.
|
|
143
|
+
- `apiReference({ path?, specUrl?, ui?, title? })` — serves an interactive reference page (default `/docs`,
|
|
144
|
+
Scalar; `ui: "swagger"` for Swagger UI).
|
|
145
|
+
|
|
146
|
+
## Not covered
|
|
147
|
+
|
|
148
|
+
`OPTIONS`/`HEAD`/`TRACE` operations (the router does not route them) and Path Item-level fields (`summary`,
|
|
149
|
+
per-path `servers`) — the `components.pathItems` escape hatch in the config covers the latter.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { S as ReferenceObject, a as ExampleObject, i as EncodingObject } from "../types-DeDU0OMv.js";
|
|
2
|
+
import { n as MediaTypeSpec } from "../metadata-CT05Tw6j.js";
|
|
3
|
+
import { a as ValidationTarget, i as ValidationIssue, n as ValidationContext, r as ValidationFailure, t as Validated } from "../runtime-CtmUGPnb.js";
|
|
4
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
5
|
+
import { DeriveMiddleware } from "@rhythmjs/rhythm/types";
|
|
6
|
+
//#region src/body/body.d.ts
|
|
7
|
+
export interface ApiBodyOptions {
|
|
8
|
+
description?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
contentType?: string;
|
|
11
|
+
example?: unknown;
|
|
12
|
+
examples?: Record<string, ExampleObject | ReferenceObject>;
|
|
13
|
+
encoding?: Record<string, EncodingObject>;
|
|
14
|
+
content?: Record<string, MediaTypeSpec>;
|
|
15
|
+
}
|
|
16
|
+
export declare function apiBody<TSchema extends StandardSchemaV1>(schema: TSchema, options?: ApiBodyOptions): DeriveMiddleware<ValidationContext, Validated<"body", TSchema>>;
|
|
17
|
+
//#endregion
|
|
18
|
+
export type { Validated, ValidationContext, ValidationFailure, ValidationIssue, ValidationTarget };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { a as schemaMiddleware, t as bodyExtractor } from "../runtime-CCU9lE2d.js";
|
|
2
|
+
//#region src/body/body.ts
|
|
3
|
+
function apiBody(schema, options = {}) {
|
|
4
|
+
const contentType = options.contentType ?? "application/json";
|
|
5
|
+
const content = options.content ?? { [contentType]: {
|
|
6
|
+
schema,
|
|
7
|
+
...options.example !== void 0 ? { example: options.example } : {},
|
|
8
|
+
...options.examples ? { examples: options.examples } : {},
|
|
9
|
+
...options.encoding ? { encoding: options.encoding } : {}
|
|
10
|
+
} };
|
|
11
|
+
return schemaMiddleware("body", schema, { requestBody: {
|
|
12
|
+
...options.description ? { description: options.description } : {},
|
|
13
|
+
required: options.required ?? true,
|
|
14
|
+
content
|
|
15
|
+
} }, bodyExtractor(contentType));
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { apiBody };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { S as ReferenceObject, t as CallbackObject } from "../types-DeDU0OMv.js";
|
|
2
|
+
import { Middleware } from "@rhythmjs/rhythm/types";
|
|
3
|
+
import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
|
|
4
|
+
//#region src/callback/callback.d.ts
|
|
5
|
+
export declare function apiCallback(name: string, callback: CallbackObject | ReferenceObject): Middleware<RhythmHttpContext>;
|
|
6
|
+
//#endregion
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { s as ParameterOverride } from "../metadata-CT05Tw6j.js";
|
|
2
|
+
import { n as ValidationContext, t as Validated } from "../runtime-CtmUGPnb.js";
|
|
3
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
4
|
+
import { DeriveMiddleware } from "@rhythmjs/rhythm/types";
|
|
5
|
+
//#region src/cookie/cookie.d.ts
|
|
6
|
+
export interface ApiCookieOptions {
|
|
7
|
+
overrides?: Record<string, ParameterOverride>;
|
|
8
|
+
}
|
|
9
|
+
export declare function apiCookie<TSchema extends StandardSchemaV1>(schema: TSchema, options?: ApiCookieOptions): DeriveMiddleware<ValidationContext, Validated<"cookie", TSchema>>;
|
|
10
|
+
//#endregion
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { a as schemaMiddleware, n as collectCookies } from "../runtime-CCU9lE2d.js";
|
|
2
|
+
//#region src/cookie/cookie.ts
|
|
3
|
+
function apiCookie(schema, options = {}) {
|
|
4
|
+
return schemaMiddleware("cookie", schema, { parameters: [{
|
|
5
|
+
in: "cookie",
|
|
6
|
+
schema,
|
|
7
|
+
...options.overrides ? { overrides: options.overrides } : {}
|
|
8
|
+
}] }, (ctx) => ({
|
|
9
|
+
ok: true,
|
|
10
|
+
value: collectCookies(ctx.request)
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
export { apiCookie };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { OpenAPIConfig } from "../document/document.js";
|
|
2
|
+
import { GenerateOptions, RouterSource } from "../generate/generate.js";
|
|
3
|
+
import { Middleware } from "@rhythmjs/rhythm/types";
|
|
4
|
+
import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
|
|
5
|
+
//#region src/docs/docs.d.ts
|
|
6
|
+
export interface ApiDocumentOptions extends GenerateOptions {
|
|
7
|
+
path?: string;
|
|
8
|
+
router: RouterSource;
|
|
9
|
+
config: OpenAPIConfig;
|
|
10
|
+
}
|
|
11
|
+
export declare function apiDocument(options: ApiDocumentOptions): Middleware<RhythmHttpContext>;
|
|
12
|
+
export interface ApiReferenceOptions {
|
|
13
|
+
path?: string;
|
|
14
|
+
specUrl?: string;
|
|
15
|
+
ui?: "scalar" | "swagger";
|
|
16
|
+
title?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function apiReference(options?: ApiReferenceOptions): Middleware<RhythmHttpContext>;
|
|
19
|
+
//#endregion
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { generate } from "../generate/generate.js";
|
|
2
|
+
//#region src/docs/docs.ts
|
|
3
|
+
function apiDocument(options) {
|
|
4
|
+
const path = options.path ?? "/openapi.json";
|
|
5
|
+
let cached;
|
|
6
|
+
return async (ctx, next) => {
|
|
7
|
+
if (ctx.request.method !== "GET" || new URL(ctx.request.url).pathname !== path) {
|
|
8
|
+
await next();
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
cached ??= generate(options.router, options.config, options);
|
|
12
|
+
ctx.json(await cached);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function escapeHtml(value) {
|
|
16
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
17
|
+
}
|
|
18
|
+
function scalarPage(specUrl, title) {
|
|
19
|
+
return `<!doctype html>
|
|
20
|
+
<html>
|
|
21
|
+
<head>
|
|
22
|
+
<title>${title}</title>
|
|
23
|
+
<meta charset="utf-8" />
|
|
24
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
<script id="api-reference" data-url="${specUrl}"><\/script>
|
|
28
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"><\/script>
|
|
29
|
+
</body>
|
|
30
|
+
</html>`;
|
|
31
|
+
}
|
|
32
|
+
function swaggerPage(specUrl, title) {
|
|
33
|
+
return `<!doctype html>
|
|
34
|
+
<html>
|
|
35
|
+
<head>
|
|
36
|
+
<title>${title}</title>
|
|
37
|
+
<meta charset="utf-8" />
|
|
38
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
39
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
40
|
+
</head>
|
|
41
|
+
<body>
|
|
42
|
+
<div id="swagger-ui"></div>
|
|
43
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"><\/script>
|
|
44
|
+
<script>
|
|
45
|
+
window.onload = () => {
|
|
46
|
+
window.ui = SwaggerUIBundle({ url: "${specUrl}", dom_id: "#swagger-ui" });
|
|
47
|
+
};
|
|
48
|
+
<\/script>
|
|
49
|
+
</body>
|
|
50
|
+
</html>`;
|
|
51
|
+
}
|
|
52
|
+
function apiReference(options = {}) {
|
|
53
|
+
const path = options.path ?? "/docs";
|
|
54
|
+
const specUrl = escapeHtml(options.specUrl ?? "/openapi.json");
|
|
55
|
+
const title = escapeHtml(options.title ?? "API Reference");
|
|
56
|
+
const page = options.ui === "swagger" ? swaggerPage(specUrl, title) : scalarPage(specUrl, title);
|
|
57
|
+
return async (ctx, next) => {
|
|
58
|
+
if (ctx.request.method !== "GET" || new URL(ctx.request.url).pathname !== path) {
|
|
59
|
+
await next();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
ctx.html(page);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
export { apiDocument, apiReference };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { D as SecurityRequirementObject, O as SecuritySchemeObject, S as ReferenceObject, b as PathItemObject, j as TagObject, k as ServerObject, l as InfoObject, n as ComponentsObject, s as ExternalDocsObject } from "../types-DeDU0OMv.js";
|
|
2
|
+
//#region src/document/document.d.ts
|
|
3
|
+
export interface OpenAPIConfig {
|
|
4
|
+
info: InfoObject;
|
|
5
|
+
jsonSchemaDialect?: string;
|
|
6
|
+
servers?: ServerObject[];
|
|
7
|
+
tags?: TagObject[];
|
|
8
|
+
externalDocs?: ExternalDocsObject;
|
|
9
|
+
security?: SecurityRequirementObject[];
|
|
10
|
+
securitySchemes?: Record<string, SecuritySchemeObject | ReferenceObject>;
|
|
11
|
+
components?: ComponentsObject;
|
|
12
|
+
webhooks?: Record<string, PathItemObject>;
|
|
13
|
+
extensions?: Record<`x-${string}`, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export declare function defineDocument(config: OpenAPIConfig): OpenAPIConfig;
|
|
16
|
+
//#endregion
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Middleware } from "@rhythmjs/rhythm/types";
|
|
2
|
+
import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
|
|
3
|
+
//#region src/extension/extension.d.ts
|
|
4
|
+
export declare function apiExtension(name: `x-${string}`, value: unknown): Middleware<RhythmHttpContext>;
|
|
5
|
+
//#endregion
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { docOnly } from "../metadata/metadata.js";
|
|
2
|
+
//#region src/extension/extension.ts
|
|
3
|
+
function apiExtension(name, value) {
|
|
4
|
+
if (!name.startsWith("x-")) throw new TypeError(`OpenAPI extension names must start with "x-", got "${String(name)}"`);
|
|
5
|
+
return docOnly({ extensions: { [name]: value } });
|
|
6
|
+
}
|
|
7
|
+
//#endregion
|
|
8
|
+
export { apiExtension };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { h as OpenAPIObject } from "../types-DeDU0OMv.js";
|
|
2
|
+
import { OpenAPIConfig } from "../document/document.js";
|
|
3
|
+
import { t as ResolverOptions } from "../resolver-sb66uMQ-.js";
|
|
4
|
+
//#region src/generate/generate.d.ts
|
|
5
|
+
export interface RouterSource {
|
|
6
|
+
readonly entries: ReadonlyArray<{
|
|
7
|
+
readonly kind: "middleware";
|
|
8
|
+
readonly fn: unknown;
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: "route";
|
|
11
|
+
readonly method: string;
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly handlers: readonly unknown[];
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
export interface GenerateOptions extends ResolverOptions {
|
|
17
|
+
openapi?: string;
|
|
18
|
+
includeUndocumented?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function generate(router: RouterSource, config: OpenAPIConfig, options?: GenerateOptions): Promise<OpenAPIObject>;
|
|
21
|
+
//#endregion
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { fragmentOf } from "../metadata/metadata.js";
|
|
2
|
+
import { resolveSchema } from "../resolver/resolver.js";
|
|
3
|
+
//#region src/generate/generate.ts
|
|
4
|
+
const DEFS_PREFIX = "#/$defs/";
|
|
5
|
+
function rewriteRefs(node, rename) {
|
|
6
|
+
if (Array.isArray(node)) return node.map((item) => rewriteRefs(item, rename));
|
|
7
|
+
if (typeof node !== "object" || node === null) return node;
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const [key, value] of Object.entries(node)) if (key === "$ref" && typeof value === "string" && value.startsWith(DEFS_PREFIX)) {
|
|
10
|
+
const name = value.slice(8);
|
|
11
|
+
out[key] = `#/components/schemas/${rename.get(name) ?? name}`;
|
|
12
|
+
} else out[key] = rewriteRefs(value, rename);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
function hoistDefs(schema, sink) {
|
|
16
|
+
if (typeof schema !== "object" || schema === null || !("$defs" in schema)) return schema;
|
|
17
|
+
const { $defs, ...rest } = schema;
|
|
18
|
+
const defs = $defs;
|
|
19
|
+
const rename = /* @__PURE__ */ new Map();
|
|
20
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
21
|
+
let final = name;
|
|
22
|
+
let suffix = 2;
|
|
23
|
+
while (final in sink && JSON.stringify(sink[final]) !== JSON.stringify(def)) final = `${name}_${suffix++}`;
|
|
24
|
+
rename.set(name, final);
|
|
25
|
+
}
|
|
26
|
+
for (const [name, def] of Object.entries(defs)) sink[rename.get(name)] = rewriteRefs(def, rename);
|
|
27
|
+
return rewriteRefs(rest, rename);
|
|
28
|
+
}
|
|
29
|
+
function isReference(value) {
|
|
30
|
+
return "$ref" in value;
|
|
31
|
+
}
|
|
32
|
+
function toOpenAPIPath(path) {
|
|
33
|
+
return path.split("/").map((segment) => {
|
|
34
|
+
if (segment.startsWith(":")) return `{${segment.slice(1)}}`;
|
|
35
|
+
if (segment === "*") return "{wildcard}";
|
|
36
|
+
return segment;
|
|
37
|
+
}).join("/");
|
|
38
|
+
}
|
|
39
|
+
async function toMediaType(spec, io, resolve) {
|
|
40
|
+
const { schema, ...rest } = spec;
|
|
41
|
+
return {
|
|
42
|
+
...rest,
|
|
43
|
+
...schema !== void 0 ? { schema: await resolve(schema, io) } : {}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function toContent(content, io, resolve) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [mediaType, spec] of Object.entries(content)) out[mediaType] = await toMediaType(spec, io, resolve);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
async function toRequestBody(spec, resolve) {
|
|
52
|
+
const { content, ...rest } = spec;
|
|
53
|
+
return {
|
|
54
|
+
...rest,
|
|
55
|
+
content: await toContent(content, "input", resolve)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async function toResponse(spec, resolve) {
|
|
59
|
+
const { content, headers, ...rest } = spec;
|
|
60
|
+
const out = { ...rest };
|
|
61
|
+
if (content) out.content = await toContent(content, "output", resolve);
|
|
62
|
+
if (headers) {
|
|
63
|
+
const converted = {};
|
|
64
|
+
for (const [name, header] of Object.entries(headers)) if (isReference(header)) converted[name] = header;
|
|
65
|
+
else {
|
|
66
|
+
const { schema, ...headerRest } = header;
|
|
67
|
+
converted[name] = {
|
|
68
|
+
...headerRest,
|
|
69
|
+
...schema !== void 0 ? { schema: await resolve(schema, "output") } : {}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
out.headers = converted;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
async function expandParameters(group, sink, resolve) {
|
|
77
|
+
const resolved = await resolve(group.schema, "input");
|
|
78
|
+
if (typeof resolved !== "object" || resolved === null) return;
|
|
79
|
+
const properties = resolved.properties ?? {};
|
|
80
|
+
const required = Array.isArray(resolved.required) ? resolved.required : [];
|
|
81
|
+
for (const [name, propertySchema] of Object.entries(properties)) {
|
|
82
|
+
const { required: overrideRequired, ...overrideRest } = group.overrides?.[name] ?? {};
|
|
83
|
+
const parameter = {
|
|
84
|
+
name,
|
|
85
|
+
in: group.in,
|
|
86
|
+
required: group.in === "path" ? true : overrideRequired ?? required.includes(name),
|
|
87
|
+
schema: propertySchema,
|
|
88
|
+
...overrideRest
|
|
89
|
+
};
|
|
90
|
+
sink.set(`${group.in}:${name}`, parameter);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function buildOperation(fragments, resolve) {
|
|
94
|
+
const tags = [];
|
|
95
|
+
const security = [];
|
|
96
|
+
let noSecurity = false;
|
|
97
|
+
const parameters = /* @__PURE__ */ new Map();
|
|
98
|
+
let requestBody;
|
|
99
|
+
const responses = {};
|
|
100
|
+
const callbacks = {};
|
|
101
|
+
const fields = {};
|
|
102
|
+
const extensions = {};
|
|
103
|
+
for (const fragment of fragments) {
|
|
104
|
+
if (fragment.exclude) return void 0;
|
|
105
|
+
if (fragment.operation) Object.assign(fields, fragment.operation);
|
|
106
|
+
for (const tag of fragment.tags ?? []) if (!tags.includes(tag)) tags.push(tag);
|
|
107
|
+
if (fragment.security === "none") noSecurity = true;
|
|
108
|
+
else for (const requirement of fragment.security ?? []) if (!security.some((existing) => JSON.stringify(existing) === JSON.stringify(requirement))) security.push(requirement);
|
|
109
|
+
for (const group of fragment.parameters ?? []) await expandParameters(group, parameters, resolve);
|
|
110
|
+
if (fragment.requestBody) requestBody = await toRequestBody(fragment.requestBody, resolve);
|
|
111
|
+
for (const [status, spec] of Object.entries(fragment.responses ?? {})) responses[status] = isReference(spec) ? spec : await toResponse(spec, resolve);
|
|
112
|
+
Object.assign(callbacks, fragment.callbacks);
|
|
113
|
+
Object.assign(extensions, fragment.extensions);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
...tags.length ? { tags } : {},
|
|
117
|
+
...fields,
|
|
118
|
+
...parameters.size ? { parameters: [...parameters.values()] } : {},
|
|
119
|
+
...requestBody ? { requestBody } : {},
|
|
120
|
+
responses: Object.keys(responses).length ? responses : { "200": { description: "Successful response" } },
|
|
121
|
+
...Object.keys(callbacks).length ? { callbacks } : {},
|
|
122
|
+
...noSecurity ? { security: [] } : security.length ? { security } : {},
|
|
123
|
+
...extensions
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function generate(router, config, options = {}) {
|
|
127
|
+
const schemas = {};
|
|
128
|
+
const resolve = async (schema, io) => hoistDefs(await resolveSchema(schema, io, options), schemas);
|
|
129
|
+
const paths = {};
|
|
130
|
+
const inherited = [];
|
|
131
|
+
for (const entry of router.entries) {
|
|
132
|
+
if (entry.kind === "middleware") {
|
|
133
|
+
const fragment = fragmentOf(entry.fn);
|
|
134
|
+
if (fragment) inherited.push(fragment);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const own = entry.handlers.map(fragmentOf).filter((fragment) => !!fragment);
|
|
138
|
+
if (!own.length && !(options.includeUndocumented ?? true)) continue;
|
|
139
|
+
const operation = await buildOperation([...inherited, ...own], resolve);
|
|
140
|
+
if (!operation) continue;
|
|
141
|
+
const path = toOpenAPIPath(entry.path);
|
|
142
|
+
const method = entry.method.toLowerCase();
|
|
143
|
+
(paths[path] ??= {})[method] = operation;
|
|
144
|
+
}
|
|
145
|
+
const components = { ...config.components };
|
|
146
|
+
if (config.securitySchemes) components.securitySchemes = {
|
|
147
|
+
...components.securitySchemes,
|
|
148
|
+
...config.securitySchemes
|
|
149
|
+
};
|
|
150
|
+
if (Object.keys(schemas).length) components.schemas = {
|
|
151
|
+
...components.schemas,
|
|
152
|
+
...schemas
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
openapi: options.openapi ?? "3.1.1",
|
|
156
|
+
info: config.info,
|
|
157
|
+
...config.jsonSchemaDialect ? { jsonSchemaDialect: config.jsonSchemaDialect } : {},
|
|
158
|
+
...config.servers ? { servers: config.servers } : {},
|
|
159
|
+
paths,
|
|
160
|
+
...config.webhooks ? { webhooks: config.webhooks } : {},
|
|
161
|
+
...Object.keys(components).length ? { components } : {},
|
|
162
|
+
...config.security ? { security: config.security } : {},
|
|
163
|
+
...config.tags ? { tags: config.tags } : {},
|
|
164
|
+
...config.externalDocs ? { externalDocs: config.externalDocs } : {},
|
|
165
|
+
...config.extensions
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
export { generate };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { s as ParameterOverride } from "../metadata-CT05Tw6j.js";
|
|
2
|
+
import { n as ValidationContext, t as Validated } from "../runtime-CtmUGPnb.js";
|
|
3
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
4
|
+
import { DeriveMiddleware } from "@rhythmjs/rhythm/types";
|
|
5
|
+
//#region src/header/header.d.ts
|
|
6
|
+
export interface ApiHeaderOptions {
|
|
7
|
+
overrides?: Record<string, ParameterOverride>;
|
|
8
|
+
}
|
|
9
|
+
export declare function apiHeader<TSchema extends StandardSchemaV1>(schema: TSchema, options?: ApiHeaderOptions): DeriveMiddleware<ValidationContext, Validated<"header", TSchema>>;
|
|
10
|
+
//#endregion
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { a as schemaMiddleware, r as collectHeaders } from "../runtime-CCU9lE2d.js";
|
|
2
|
+
//#region src/header/header.ts
|
|
3
|
+
function apiHeader(schema, options = {}) {
|
|
4
|
+
return schemaMiddleware("header", schema, { parameters: [{
|
|
5
|
+
in: "header",
|
|
6
|
+
schema,
|
|
7
|
+
...options.overrides ? { overrides: options.overrides } : {}
|
|
8
|
+
}] }, (ctx) => ({
|
|
9
|
+
ok: true,
|
|
10
|
+
value: collectHeaders(ctx.request)
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
export { apiHeader };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as OperationFragment, c as RequestBodySpec, d as docOnly, f as fragmentOf, i as OperationFields, l as ResponseSpec, n as MediaTypeSpec, o as ParameterGroupSpec, p as withFragment, r as OPENAPI_METADATA, s as ParameterOverride, t as HeaderSpec, u as SchemaLike } from "../metadata-CT05Tw6j.js";
|
|
2
|
+
export { HeaderSpec, MediaTypeSpec, OPENAPI_METADATA, OperationFields, OperationFragment, ParameterGroupSpec, ParameterOverride, RequestBodySpec, ResponseSpec, SchemaLike, docOnly, fragmentOf, withFragment };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/metadata/metadata.ts
|
|
2
|
+
const OPENAPI_METADATA = Symbol.for("rhythmjs.openapi");
|
|
3
|
+
function withFragment(fn, fragment) {
|
|
4
|
+
Object.defineProperty(fn, OPENAPI_METADATA, { value: fragment });
|
|
5
|
+
return fn;
|
|
6
|
+
}
|
|
7
|
+
function fragmentOf(fn) {
|
|
8
|
+
if (typeof fn !== "function") return void 0;
|
|
9
|
+
return fn[OPENAPI_METADATA];
|
|
10
|
+
}
|
|
11
|
+
function docOnly(fragment) {
|
|
12
|
+
return withFragment(async (_ctx, next) => {
|
|
13
|
+
await next();
|
|
14
|
+
}, fragment);
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { OPENAPI_METADATA, docOnly, fragmentOf, withFragment };
|