@voltro/plugin-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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +4596 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.js +237 -0
- package/package.json +44 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ProcedureDescriptor } from '@voltro/protocol';
|
|
2
|
+
import { RestRouteDescriptor } from '@voltro/protocol/rest';
|
|
3
|
+
import { VoltroPlugin } from '@voltro/protocol';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the full OpenAPI 3.1 document from a list of REST descriptors and,
|
|
7
|
+
* optionally, the rpc procedure descriptors.
|
|
8
|
+
*
|
|
9
|
+
* Overloads keep the original `(routes, info)` shape working while adding the
|
|
10
|
+
* `(routes, info, sources)` form that folds in rpc procedures.
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateOpenApiSpec(routes: ReadonlyArray<RestRouteDescriptor<any, any>>, info?: OpenApiInfo): OpenApiDocument;
|
|
13
|
+
|
|
14
|
+
export declare function generateOpenApiSpec(routes: ReadonlyArray<RestRouteDescriptor<any, any>>, info: OpenApiInfo, sources: OpenApiSources): OpenApiDocument;
|
|
15
|
+
|
|
16
|
+
/** A minimal OpenAPI 3.1 document — the shape `generateOpenApiSpec` returns.
|
|
17
|
+
* Deliberately narrow: it names the top-level keys a consumer reads (so the
|
|
18
|
+
* public `.d.ts` documents the contract) while leaving the per-operation
|
|
19
|
+
* `paths` / `components.schemas` values open — those are full OpenAPI objects
|
|
20
|
+
* the JSON-Schema converter assembles, not worth re-typing here. */
|
|
21
|
+
export declare interface OpenApiDocument {
|
|
22
|
+
readonly openapi: '3.1.0';
|
|
23
|
+
readonly info: {
|
|
24
|
+
readonly title: string;
|
|
25
|
+
readonly version: string;
|
|
26
|
+
readonly description?: string;
|
|
27
|
+
};
|
|
28
|
+
readonly paths: Record<string, Record<string, unknown>>;
|
|
29
|
+
readonly components: {
|
|
30
|
+
readonly schemas: Record<string, unknown>;
|
|
31
|
+
readonly securitySchemes?: Record<string, unknown>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export declare interface OpenApiInfo {
|
|
36
|
+
readonly title?: string;
|
|
37
|
+
readonly version?: string;
|
|
38
|
+
readonly description?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export declare const openapiPlugin: (options: OpenApiPluginOptions) => VoltroPlugin;
|
|
42
|
+
|
|
43
|
+
export declare interface OpenApiPluginOptions {
|
|
44
|
+
/** The REST descriptors to document (the same array you pass to `restRoutes`).
|
|
45
|
+
* Optional — a spec of ONLY rpc procedures is valid (pass `procedures`). */
|
|
46
|
+
readonly routes?: ReadonlyArray<RestRouteDescriptor<any, any>>;
|
|
47
|
+
/**
|
|
48
|
+
* rpc procedure descriptors to project into the spec — the queries /
|
|
49
|
+
* mutations / actions / streams your app defines. Each becomes a
|
|
50
|
+
* `POST /rpc/<name>` operation. Opt-in: omit to keep the spec REST-only.
|
|
51
|
+
* (Mixing rpc + REST in one spec is fine — the rpc ops are tagged `rpc` and
|
|
52
|
+
* flagged `x-voltro-rpc` so consumers can tell them apart.)
|
|
53
|
+
*/
|
|
54
|
+
readonly procedures?: ReadonlyArray<ProcedureDescriptor>;
|
|
55
|
+
/** Spec metadata. */
|
|
56
|
+
readonly info?: OpenApiInfo;
|
|
57
|
+
/** Path to serve the JSON spec. Default `/openapi.json`. */
|
|
58
|
+
readonly specPath?: string;
|
|
59
|
+
/** Path to serve the Swagger-UI page. Default `/docs`. `false` disables it. */
|
|
60
|
+
readonly docsPath?: string | false;
|
|
61
|
+
/**
|
|
62
|
+
* Require `Authorization: Bearer <token>` on BOTH the spec and the docs
|
|
63
|
+
* route. Default `OPENAPI_DOCS_TOKEN` env; unset → open (the spec/docs are
|
|
64
|
+
* public — gate at the network layer, or set this for a public deployment).
|
|
65
|
+
*/
|
|
66
|
+
readonly token?: string;
|
|
67
|
+
readonly name?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Extra sources to fold into the spec beyond the REST `routes`. Today: the
|
|
71
|
+
* rpc procedure descriptors (queries / mutations / actions / streams). */
|
|
72
|
+
export declare interface OpenApiSources {
|
|
73
|
+
/** rpc procedure descriptors to project into the spec. Each becomes a POST
|
|
74
|
+
* operation under `/rpc/<name>` (see `rpc.ts`). Opt-in — omit to keep the
|
|
75
|
+
* spec REST-only. */
|
|
76
|
+
readonly procedures?: ReadonlyArray<ProcedureDescriptor>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export declare const swaggerUiHtml: (specUrl: string, title?: string) => string;
|
|
80
|
+
|
|
81
|
+
export { }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Effect as e, JSONSchema as t } from "effect";
|
|
2
|
+
import { definePlugin as n } from "@voltro/protocol";
|
|
3
|
+
import { pluginEnv as r } from "@voltro/env";
|
|
4
|
+
//#region src/rpc.ts
|
|
5
|
+
var i = "/rpc", a = "rpc", o = (e) => e.ast._tag === "NeverKeyword", s = (e, t) => `${e} ${t}`, c = (e) => `Voltro rpc ${e}. This \`/rpc/<name>\` path is a documentation projection of the procedure's input/output schemas — live calls go through ${e === "query" || e === "stream" ? "the batched `POST /rpc` endpoint (first snapshot / element) or the WebSocket rpc channel (reactive/streaming)" : "the batched `POST /rpc` endpoint"}, NOT a REST request to this path. The request body below is the rpc payload.`, l = (e, t) => {
|
|
6
|
+
let { kind: n, name: r } = e, i = {
|
|
7
|
+
required: !0,
|
|
8
|
+
content: { "application/json": { schema: f(e.input, t) } }
|
|
9
|
+
}, l = (() => {
|
|
10
|
+
if (n === "stream") return {
|
|
11
|
+
description: "Stream of elements. The rpc transport pushes each element as it is produced (WebSocket) or drains to the first element over `POST /rpc`. The schema below is ONE element, not the whole response.",
|
|
12
|
+
content: { "application/x-ndjson": { schema: f(e.element, t) } }
|
|
13
|
+
};
|
|
14
|
+
let r = f(e.output, t);
|
|
15
|
+
return {
|
|
16
|
+
description: n === "query" ? "OK — the query snapshot (the first/initial snapshot over `POST /rpc`; the WebSocket channel then pushes reactive updates)." : "OK",
|
|
17
|
+
content: { "application/json": { schema: r } }
|
|
18
|
+
};
|
|
19
|
+
})(), u = o(e.error) ? {} : { 422: {
|
|
20
|
+
description: "A typed error from the procedure — one of its declared error variants (the rpc failure channel).",
|
|
21
|
+
content: { "application/json": { schema: f(e.error, t) } }
|
|
22
|
+
} };
|
|
23
|
+
return {
|
|
24
|
+
tags: [a],
|
|
25
|
+
summary: s(n, r),
|
|
26
|
+
description: c(n),
|
|
27
|
+
"x-voltro-rpc": {
|
|
28
|
+
kind: n,
|
|
29
|
+
procedure: r
|
|
30
|
+
},
|
|
31
|
+
requestBody: i,
|
|
32
|
+
responses: {
|
|
33
|
+
200: l,
|
|
34
|
+
...u,
|
|
35
|
+
500: _("Internal Server Error — an untyped defect.")
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}, u = (e, t) => {
|
|
39
|
+
let n = {};
|
|
40
|
+
for (let r of e) {
|
|
41
|
+
let e = `${i}/${r.name}`;
|
|
42
|
+
n[e] = { post: l(r, t) };
|
|
43
|
+
}
|
|
44
|
+
return n;
|
|
45
|
+
}, d = (e) => e.replace(/:([A-Za-z0-9_]+)/g, "{$1}"), f = (e, n) => {
|
|
46
|
+
let { $schema: r, $defs: i, ...a } = t.make(e);
|
|
47
|
+
if (i && typeof i == "object") for (let [e, t] of Object.entries(i)) n[e] = t;
|
|
48
|
+
return a;
|
|
49
|
+
}, p = (e) => {
|
|
50
|
+
if (Array.isArray(e)) return e.map(p);
|
|
51
|
+
if (e && typeof e == "object") {
|
|
52
|
+
let t = {};
|
|
53
|
+
for (let [n, r] of Object.entries(e)) t[n] = n === "$ref" && typeof r == "string" && r.startsWith("#/$defs/") ? `#/components/schemas/${r.slice(8)}` : p(r);
|
|
54
|
+
return t;
|
|
55
|
+
}
|
|
56
|
+
return e;
|
|
57
|
+
}, m = (e, t) => {
|
|
58
|
+
if (!e) return {
|
|
59
|
+
parameters: [],
|
|
60
|
+
requestBody: void 0
|
|
61
|
+
};
|
|
62
|
+
let n = f(e, t), r = n.properties ?? {}, i = new Set(n.required ?? []), a = [];
|
|
63
|
+
for (let [e, t] of [["query", "query"], ["params", "path"]]) {
|
|
64
|
+
let n = r[e];
|
|
65
|
+
if (!n || typeof n != "object") continue;
|
|
66
|
+
let i = n.properties ?? {}, o = new Set(n.required ?? []);
|
|
67
|
+
for (let [e, n] of Object.entries(i)) a.push({
|
|
68
|
+
name: e,
|
|
69
|
+
in: t,
|
|
70
|
+
required: t === "path" || o.has(e),
|
|
71
|
+
schema: n
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
let o = r.body;
|
|
75
|
+
return {
|
|
76
|
+
parameters: a,
|
|
77
|
+
requestBody: o && typeof o == "object" ? {
|
|
78
|
+
required: i.has("body"),
|
|
79
|
+
content: { "application/json": { schema: o } }
|
|
80
|
+
} : void 0
|
|
81
|
+
};
|
|
82
|
+
}, h = "VoltroHttpError", g = {
|
|
83
|
+
type: "object",
|
|
84
|
+
description: "The serve pipeline's error envelope.",
|
|
85
|
+
properties: {
|
|
86
|
+
error: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "Human-readable error message."
|
|
89
|
+
},
|
|
90
|
+
detail: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "Decode failure detail (400 only)."
|
|
93
|
+
},
|
|
94
|
+
replacement: {
|
|
95
|
+
type: "string",
|
|
96
|
+
description: "Replacement endpoint hint (410 only)."
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
required: ["error"]
|
|
100
|
+
}, _ = (e) => ({
|
|
101
|
+
description: e,
|
|
102
|
+
content: { "application/json": { schema: { $ref: `#/components/schemas/${h}` } } }
|
|
103
|
+
}), v = (e) => ({
|
|
104
|
+
...e.input === void 0 ? {} : { 400: _("Invalid request — the input failed schema decode.") },
|
|
105
|
+
...e.guards !== void 0 && e.guards.length > 0 ? {
|
|
106
|
+
401: _("Unauthorized — a guard rejected the unauthenticated request."),
|
|
107
|
+
403: _("Forbidden — a guard rejected the request (e.g. a required scope is missing).")
|
|
108
|
+
} : {},
|
|
109
|
+
...e.sunset === void 0 ? {} : { 410: _(`Gone — the route is past its sunset date (${e.sunset}).`) },
|
|
110
|
+
500: _("Internal Server Error.")
|
|
111
|
+
});
|
|
112
|
+
function y(e, t = {}, n = {}) {
|
|
113
|
+
let r = {}, i = {}, a = e.some((e) => e.guards !== void 0 && e.guards.length > 0);
|
|
114
|
+
for (let t of e) {
|
|
115
|
+
let { parameters: e, requestBody: n } = m(t.input, i), a = t.guards !== void 0 && t.guards.length > 0;
|
|
116
|
+
if (n !== void 0 && t.example?.request !== void 0) {
|
|
117
|
+
let e = n.content;
|
|
118
|
+
e["application/json"] = {
|
|
119
|
+
...e["application/json"],
|
|
120
|
+
example: t.example.request
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
let o = {
|
|
124
|
+
...t.summary ? { summary: t.summary } : {},
|
|
125
|
+
...t.description ? { description: t.description } : {},
|
|
126
|
+
...t.deprecated === void 0 ? {} : { deprecated: !0 },
|
|
127
|
+
...t.sunset === void 0 ? {} : { "x-sunset": t.sunset },
|
|
128
|
+
...e.length > 0 ? { parameters: e } : {},
|
|
129
|
+
...n ? { requestBody: n } : {},
|
|
130
|
+
...a ? { security: [{ bearerAuth: [] }] } : {},
|
|
131
|
+
responses: {
|
|
132
|
+
200: {
|
|
133
|
+
description: "OK",
|
|
134
|
+
content: { "application/json": {
|
|
135
|
+
schema: f(t.output, i),
|
|
136
|
+
...t.example?.response === void 0 ? {} : { example: t.example.response }
|
|
137
|
+
} }
|
|
138
|
+
},
|
|
139
|
+
...v(t)
|
|
140
|
+
}
|
|
141
|
+
}, s = d(t.path), c = r[s] ?? {};
|
|
142
|
+
c[t.method.toLowerCase()] = o, r[s] = c;
|
|
143
|
+
}
|
|
144
|
+
let o = n.procedures ?? [];
|
|
145
|
+
if (o.length > 0) {
|
|
146
|
+
let e = u(o, i);
|
|
147
|
+
for (let [t, n] of Object.entries(e)) r[t] = {
|
|
148
|
+
...n,
|
|
149
|
+
...r[t] ?? {}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
openapi: "3.1.0",
|
|
154
|
+
info: {
|
|
155
|
+
title: t.title ?? "API",
|
|
156
|
+
version: t.version ?? "1.0.0",
|
|
157
|
+
...t.description ? { description: t.description } : {}
|
|
158
|
+
},
|
|
159
|
+
paths: p(r),
|
|
160
|
+
components: {
|
|
161
|
+
schemas: {
|
|
162
|
+
...p(i),
|
|
163
|
+
[h]: g
|
|
164
|
+
},
|
|
165
|
+
...a ? { securitySchemes: { bearerAuth: {
|
|
166
|
+
type: "http",
|
|
167
|
+
scheme: "bearer"
|
|
168
|
+
} } } : {}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/swaggerUi.ts
|
|
174
|
+
var b = "5.17.14", x = "sha384-wxLW6kwyHktdDGr6Pv1zgm/VGJh99lfUbzSn6HNHBENZlCN7W602k9VkGdxuFvPn", S = "sha384-wmyclcVGX/WhUkdkATwhaK1X1JtiNrr2EoYJ+diV3vj4v6OC5yCeSu+yW13SYJep", C = `https://unpkg.com/swagger-ui-dist@${b}/swagger-ui.css`, w = `https://unpkg.com/swagger-ui-dist@${b}/swagger-ui-bundle.js`, T = (e, t = "API docs") => `<!doctype html>
|
|
175
|
+
<html lang="en">
|
|
176
|
+
<head>
|
|
177
|
+
<meta charset="utf-8" />
|
|
178
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
179
|
+
<title>${t}</title>
|
|
180
|
+
<link rel="stylesheet" href="${C}" integrity="${x}" crossorigin="anonymous" />
|
|
181
|
+
<style>body { margin: 0 }</style>
|
|
182
|
+
</head>
|
|
183
|
+
<body>
|
|
184
|
+
<div id="swagger-ui"></div>
|
|
185
|
+
<script src="${w}" integrity="${S}" crossorigin="anonymous"><\/script>
|
|
186
|
+
<script>
|
|
187
|
+
window.ui = SwaggerUIBundle({ url: ${JSON.stringify(e)}, dom_id: '#swagger-ui', deepLinking: true });
|
|
188
|
+
<\/script>
|
|
189
|
+
</body>
|
|
190
|
+
</html>`, E = r([{
|
|
191
|
+
name: "OPENAPI_DOCS_TOKEN",
|
|
192
|
+
required: !1,
|
|
193
|
+
secret: !0,
|
|
194
|
+
description: "Bearer token required on GET /openapi.json + GET /docs; unset leaves them public."
|
|
195
|
+
}]), D = (e, t) => {
|
|
196
|
+
if (e) return (t.headers.authorization ?? t.headers.Authorization) === `Bearer ${e}` ? void 0 : {
|
|
197
|
+
status: 401,
|
|
198
|
+
body: "unauthorized",
|
|
199
|
+
contentType: "text/plain"
|
|
200
|
+
};
|
|
201
|
+
}, O = (t) => {
|
|
202
|
+
let r = t.specPath ?? "/openapi.json", i = t.docsPath ?? "/docs", a = E.read("OPENAPI_DOCS_TOKEN", t.token), o = t.name ? `@voltro/plugin-openapi#${t.name}` : "@voltro/plugin-openapi", s = t.routes ?? [], c = t.procedures ?? [], l = JSON.stringify(y(s, t.info ?? {}, { procedures: c }), null, 2), u = [{
|
|
203
|
+
method: "GET",
|
|
204
|
+
path: r,
|
|
205
|
+
handle: async (e) => D(a, e) ?? {
|
|
206
|
+
status: 200,
|
|
207
|
+
body: l,
|
|
208
|
+
contentType: "application/json"
|
|
209
|
+
}
|
|
210
|
+
}];
|
|
211
|
+
return i !== !1 && u.push({
|
|
212
|
+
method: "GET",
|
|
213
|
+
path: i,
|
|
214
|
+
handle: async (e) => D(a, e) ?? {
|
|
215
|
+
status: 200,
|
|
216
|
+
body: T(r, t.info?.title ?? "API docs"),
|
|
217
|
+
contentType: "text/html; charset=utf-8"
|
|
218
|
+
}
|
|
219
|
+
}), n({
|
|
220
|
+
name: o,
|
|
221
|
+
description: "OpenAPI 3.1 spec + Swagger-UI docs from defineRestRoute descriptors + rpc procedures.",
|
|
222
|
+
permissions: [],
|
|
223
|
+
declaredEnv: E.declared,
|
|
224
|
+
httpRoutes: u,
|
|
225
|
+
onActivate: (t) => e.sync(() => {
|
|
226
|
+
t.logger.info("openapi docs active", {
|
|
227
|
+
specPath: r,
|
|
228
|
+
docsPath: i,
|
|
229
|
+
routes: s.length,
|
|
230
|
+
procedures: c.length,
|
|
231
|
+
tokenGated: !!a
|
|
232
|
+
});
|
|
233
|
+
})
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
//#endregion
|
|
237
|
+
export { y as generateOpenApiSpec, O as openapiPlugin, T as swaggerUiHtml };
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voltro/plugin-openapi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate an OpenAPI 3.1 spec from defineRestRoute descriptors and serve it + a Swagger-UI docs page. Zero hand-maintained API docs — the REST descriptors ARE the source of truth.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"voltro",
|
|
7
|
+
"typescript",
|
|
8
|
+
"framework"
|
|
9
|
+
],
|
|
10
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
11
|
+
"homepage": "https://voltro.dev",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"email": "support@voltro.dev"
|
|
14
|
+
},
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Voltro UG",
|
|
17
|
+
"url": "https://voltro.dev"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=24.0.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@voltro/env": "0.1.0",
|
|
36
|
+
"@voltro/protocol": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"effect": "^3.21.4"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|