peta-docs 0.3.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/README.md +350 -0
- package/dist/hono/index.d.mts +102 -0
- package/dist/hono/index.mjs +435 -0
- package/dist/index.d.mts +30 -0
- package/dist/index.mjs +38 -0
- package/dist/scanner-CU4MsJ2G.d.mts +155 -0
- package/dist/spec-Bobwv7gP.mjs +281 -0
- package/package.json +53 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
//#region src/spec/schema.ts
|
|
2
|
+
function isArkType(value) {
|
|
3
|
+
return typeof value === "function" && "toJsonSchema" in value;
|
|
4
|
+
}
|
|
5
|
+
function toOpenAPISchema(schema) {
|
|
6
|
+
if (schema === null || schema === void 0) return {};
|
|
7
|
+
if (isArkType(schema)) try {
|
|
8
|
+
return schema.toJsonSchema();
|
|
9
|
+
} catch {
|
|
10
|
+
try {
|
|
11
|
+
const inner = schema.in;
|
|
12
|
+
if (inner?.toJsonSchema) return inner.toJsonSchema();
|
|
13
|
+
} catch {}
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
if (typeof schema === "function") {
|
|
17
|
+
const isDev = typeof process !== "undefined" && process.env.NODE_ENV === "development";
|
|
18
|
+
const msg = "[peta-docs] A non-ArkType function was passed where a schema is expected. OpenAPI spec generation only supports ArkType types and plain JSON Schema objects. Either use an ArkType type, or pre-convert your schema to a JSON Schema object.";
|
|
19
|
+
console.warn(msg);
|
|
20
|
+
if (isDev) throw new TypeError(msg);
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
if (typeof schema === "object" && !Array.isArray(schema)) return schema;
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
function mapContentSchemas(content, convert) {
|
|
27
|
+
const result = {};
|
|
28
|
+
for (const [mediaType, { schema }] of Object.entries(content)) result[mediaType] = { schema: convert(schema) };
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
const STATUS_DESCRIPTIONS = {
|
|
32
|
+
"200": "OK",
|
|
33
|
+
"201": "Created",
|
|
34
|
+
"202": "Accepted",
|
|
35
|
+
"204": "No Content",
|
|
36
|
+
"301": "Moved Permanently",
|
|
37
|
+
"304": "Not Modified",
|
|
38
|
+
"400": "Bad Request",
|
|
39
|
+
"401": "Unauthorized",
|
|
40
|
+
"403": "Forbidden",
|
|
41
|
+
"404": "Not Found",
|
|
42
|
+
"405": "Method Not Allowed",
|
|
43
|
+
"409": "Conflict",
|
|
44
|
+
"422": "Unprocessable Entity",
|
|
45
|
+
"429": "Too Many Requests",
|
|
46
|
+
"500": "Internal Server Error",
|
|
47
|
+
"502": "Bad Gateway",
|
|
48
|
+
"503": "Service Unavailable"
|
|
49
|
+
};
|
|
50
|
+
function normalizeResponse(status, value) {
|
|
51
|
+
if (typeof value === "string") return { description: value };
|
|
52
|
+
if (isArkType(value)) return {
|
|
53
|
+
description: STATUS_DESCRIPTIONS[status] ?? status,
|
|
54
|
+
content: { "application/json": { schema: value } }
|
|
55
|
+
};
|
|
56
|
+
const obj = value;
|
|
57
|
+
return {
|
|
58
|
+
description: obj.description ?? STATUS_DESCRIPTIONS[status] ?? status,
|
|
59
|
+
...obj.content ? { content: obj.content } : {}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function normalizeRequestBody(value) {
|
|
63
|
+
if (value === null || value === void 0) return void 0;
|
|
64
|
+
if (isArkType(value)) return {
|
|
65
|
+
required: true,
|
|
66
|
+
content: { "application/json": { schema: value } }
|
|
67
|
+
};
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/spec/builder.ts
|
|
72
|
+
function honoPathToOpenAPI(path) {
|
|
73
|
+
return path.replace(/:(\w+)/g, "{$1}");
|
|
74
|
+
}
|
|
75
|
+
function parsePathParams(path) {
|
|
76
|
+
const params = [];
|
|
77
|
+
const regex = /{(\w+)}/g;
|
|
78
|
+
let match = regex.exec(path);
|
|
79
|
+
while (match !== null) {
|
|
80
|
+
params.push(match[1]);
|
|
81
|
+
match = regex.exec(path);
|
|
82
|
+
}
|
|
83
|
+
return params;
|
|
84
|
+
}
|
|
85
|
+
function extractProperties(schema) {
|
|
86
|
+
const props = schema.properties;
|
|
87
|
+
if (!props) return [];
|
|
88
|
+
const requiredSet = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
89
|
+
return Object.entries(props).map(([name, propSchema]) => ({
|
|
90
|
+
name,
|
|
91
|
+
schema: propSchema,
|
|
92
|
+
required: requiredSet.has(name)
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
function autoOperationId(method, path) {
|
|
96
|
+
const segments = path.replace(/{(\w+)}/g, (_m, name) => `By${name[0].toUpperCase() + name.slice(1)}`).replace(/:(\w+)/g, (_m, name) => `By${name[0].toUpperCase() + name.slice(1)}`).replace(/\/+/g, "/").replace(/^\//, "").split("/").filter(Boolean);
|
|
97
|
+
return method.toLowerCase() + segments.map((s) => s[0].toUpperCase() + s.slice(1)).join("");
|
|
98
|
+
}
|
|
99
|
+
function autoTags(path, basePath) {
|
|
100
|
+
const segment = (basePath && path.startsWith(basePath) ? path.slice(basePath.length) : path).replace(/^\//, "").split("/").filter(Boolean)[0];
|
|
101
|
+
if (!segment || segment.startsWith(":") || segment.startsWith("{")) return [];
|
|
102
|
+
return [segment];
|
|
103
|
+
}
|
|
104
|
+
const METHOD_ORDER = {
|
|
105
|
+
get: 0,
|
|
106
|
+
post: 1,
|
|
107
|
+
put: 2,
|
|
108
|
+
delete: 3,
|
|
109
|
+
patch: 4
|
|
110
|
+
};
|
|
111
|
+
function buildOpenAPISpec(routes, info, options) {
|
|
112
|
+
const basePath = options?.basePath ?? "/api";
|
|
113
|
+
const s = (schema) => toOpenAPISchema(schema);
|
|
114
|
+
const paths = {};
|
|
115
|
+
const tagged = routes.map((r) => ({
|
|
116
|
+
...r,
|
|
117
|
+
tag: (r.config.tags ?? autoTags(r.path, basePath))[0] ?? "__untagged"
|
|
118
|
+
})).sort((a, b) => {
|
|
119
|
+
if (a.tag !== b.tag) return a.tag < b.tag ? -1 : 1;
|
|
120
|
+
if (a.path !== b.path) return a.path < b.path ? -1 : 1;
|
|
121
|
+
return (METHOD_ORDER[a.method.toLowerCase()] ?? 99) - (METHOD_ORDER[b.method.toLowerCase()] ?? 99);
|
|
122
|
+
});
|
|
123
|
+
for (const { path: rawPath, method, config } of tagged) {
|
|
124
|
+
const path = honoPathToOpenAPI(rawPath);
|
|
125
|
+
paths[path] ??= {};
|
|
126
|
+
const pathItem = paths[path];
|
|
127
|
+
const parameters = [];
|
|
128
|
+
const pathParams = parsePathParams(path);
|
|
129
|
+
if (pathParams.length > 0 && config.params) {
|
|
130
|
+
const paramDefs = extractProperties(s(config.params));
|
|
131
|
+
for (const param of pathParams) {
|
|
132
|
+
const def = paramDefs.find((p) => p.name === param);
|
|
133
|
+
parameters.push({
|
|
134
|
+
name: param,
|
|
135
|
+
in: "path",
|
|
136
|
+
required: true,
|
|
137
|
+
schema: def?.schema ?? { type: "string" }
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (config.query) {
|
|
142
|
+
const querySchema = s(config.query);
|
|
143
|
+
for (const param of extractProperties(querySchema)) parameters.push({
|
|
144
|
+
name: param.name,
|
|
145
|
+
in: "query",
|
|
146
|
+
required: param.required,
|
|
147
|
+
schema: param.schema
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (config.pagination) parameters.push({
|
|
151
|
+
name: "page",
|
|
152
|
+
in: "query",
|
|
153
|
+
required: false,
|
|
154
|
+
schema: {
|
|
155
|
+
type: "integer",
|
|
156
|
+
minimum: 1,
|
|
157
|
+
default: 1,
|
|
158
|
+
description: "Page number"
|
|
159
|
+
}
|
|
160
|
+
}, {
|
|
161
|
+
name: "limit",
|
|
162
|
+
in: "query",
|
|
163
|
+
required: false,
|
|
164
|
+
schema: {
|
|
165
|
+
type: "integer",
|
|
166
|
+
minimum: 1,
|
|
167
|
+
maximum: config.pagination.maxLimit,
|
|
168
|
+
default: config.pagination.defaultLimit,
|
|
169
|
+
description: "Items per page"
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
if (config.filters) for (const filter of config.filters) for (const op of filter.operators) {
|
|
173
|
+
const paramName = op === "eq" ? filter.name : `${filter.name}__${op}`;
|
|
174
|
+
const schemaObj = s(filter.schema);
|
|
175
|
+
if (op === "in") schemaObj["x-operator"] = "in";
|
|
176
|
+
parameters.push({
|
|
177
|
+
name: paramName,
|
|
178
|
+
in: "query",
|
|
179
|
+
required: false,
|
|
180
|
+
schema: schemaObj
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (config.sort) {
|
|
184
|
+
const enumValues = config.sort.flatMap((f) => [f, `-${f}`]);
|
|
185
|
+
parameters.push({
|
|
186
|
+
name: "sort",
|
|
187
|
+
in: "query",
|
|
188
|
+
required: false,
|
|
189
|
+
schema: {
|
|
190
|
+
type: "string",
|
|
191
|
+
enum: enumValues,
|
|
192
|
+
description: "Comma-separated sort fields. Prefix with - for descending."
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (config.include) parameters.push({
|
|
197
|
+
name: "include",
|
|
198
|
+
in: "query",
|
|
199
|
+
required: false,
|
|
200
|
+
schema: {
|
|
201
|
+
type: "string",
|
|
202
|
+
enum: config.include,
|
|
203
|
+
description: "Comma-separated related resources to sideload."
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
if (config.fieldsets) for (const resource of config.fieldsets) parameters.push({
|
|
207
|
+
name: `fields[${resource}]`,
|
|
208
|
+
in: "query",
|
|
209
|
+
required: false,
|
|
210
|
+
schema: {
|
|
211
|
+
type: "string",
|
|
212
|
+
description: `Fields to return for ${resource}.`
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
if (config.headers) {
|
|
216
|
+
const headersSchema = s(config.headers);
|
|
217
|
+
for (const param of extractProperties(headersSchema)) parameters.push({
|
|
218
|
+
name: param.name,
|
|
219
|
+
in: "header",
|
|
220
|
+
required: param.required,
|
|
221
|
+
schema: param.schema
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const operation = { operationId: config.operationId ?? autoOperationId(method, path) };
|
|
225
|
+
if (config.summary) operation.summary = config.summary;
|
|
226
|
+
if (config.description) operation.description = config.description;
|
|
227
|
+
if (config.tags) operation.tags = config.tags;
|
|
228
|
+
else operation.tags = autoTags(path, basePath);
|
|
229
|
+
if (config.security) operation.security = [{ ...Object.fromEntries(config.security.map((s) => [s, []])) }];
|
|
230
|
+
if (parameters.length > 0) operation.parameters = parameters;
|
|
231
|
+
if (config.requestBody) {
|
|
232
|
+
const normalized = normalizeRequestBody(config.requestBody);
|
|
233
|
+
if (normalized) operation.requestBody = {
|
|
234
|
+
description: normalized.description,
|
|
235
|
+
required: normalized.required,
|
|
236
|
+
content: mapContentSchemas(normalized.content, s)
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const responses = {};
|
|
240
|
+
for (const [status, rawResponse] of Object.entries(config.responses)) {
|
|
241
|
+
const normalized = normalizeResponse(status, rawResponse);
|
|
242
|
+
const resp = { description: normalized.description };
|
|
243
|
+
if (normalized.content) resp.content = mapContentSchemas(normalized.content, s);
|
|
244
|
+
responses[status] = resp;
|
|
245
|
+
}
|
|
246
|
+
operation.responses = responses;
|
|
247
|
+
const methodLower = method.toLowerCase();
|
|
248
|
+
const key = [
|
|
249
|
+
"get",
|
|
250
|
+
"post",
|
|
251
|
+
"put",
|
|
252
|
+
"delete",
|
|
253
|
+
"patch"
|
|
254
|
+
].includes(methodLower) ? methodLower : void 0;
|
|
255
|
+
if (key) pathItem[key] = operation;
|
|
256
|
+
}
|
|
257
|
+
const spec = {
|
|
258
|
+
openapi: "3.1.0",
|
|
259
|
+
info,
|
|
260
|
+
paths
|
|
261
|
+
};
|
|
262
|
+
if (options?.components) spec.components = options.components;
|
|
263
|
+
return spec;
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/spec.ts
|
|
267
|
+
let _defaultScanner = null;
|
|
268
|
+
/**
|
|
269
|
+
* Register a default scanner for `getOpenAPISpec`.
|
|
270
|
+
* Called automatically by framework adapter modules (e.g., `peta-docs/hono`).
|
|
271
|
+
*/
|
|
272
|
+
function setDefaultScanner(scanner) {
|
|
273
|
+
_defaultScanner = scanner;
|
|
274
|
+
}
|
|
275
|
+
function getOpenAPISpec(app, info, scanner, options) {
|
|
276
|
+
const active = scanner ?? _defaultScanner;
|
|
277
|
+
if (!active) throw new Error("No RouteScanner provided. Pass one explicitly or import from 'peta-docs/hono' which registers a default scanner.");
|
|
278
|
+
return buildOpenAPISpec(active.scan(app), info, options);
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
export { toOpenAPISchema as i, setDefaultScanner as n, buildOpenAPISpec as r, getOpenAPISpec as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "peta-docs",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "OpenAPI + Scalar docs for Hono (and more), powered by Standard Schema",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/zfadhli/peta-stack.git"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"main": "./dist/index.mjs",
|
|
17
|
+
"module": "./dist/index.mjs",
|
|
18
|
+
"types": "./dist/index.d.mts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.mts",
|
|
22
|
+
"import": "./dist/index.mjs",
|
|
23
|
+
"default": "./dist/index.mjs"
|
|
24
|
+
},
|
|
25
|
+
"./hono": {
|
|
26
|
+
"types": "./dist/hono/index.d.mts",
|
|
27
|
+
"import": "./dist/hono/index.mjs",
|
|
28
|
+
"default": "./dist/hono/index.mjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"hono": "^4"
|
|
33
|
+
},
|
|
34
|
+
"optionalDependencies": {
|
|
35
|
+
"arktype": "^2"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"typescript": "^6.0.3"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsdown",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "bun test",
|
|
44
|
+
"lint": "biome check src/ test/ examples/",
|
|
45
|
+
"format": "biome check --write src/ test/ examples/",
|
|
46
|
+
"prepublishOnly": "bun run build"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@biomejs/biome": "^2.5.0",
|
|
50
|
+
"@types/bun": "^1.3.14",
|
|
51
|
+
"tsdown": "^0.22.1"
|
|
52
|
+
}
|
|
53
|
+
}
|