hono-openapi 0.4.8 → 0.5.0-rc.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 CHANGED
@@ -1,270 +1,21 @@
1
1
  # 📜 Hono OpenAPI
2
2
 
3
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/rhinobase/hono-openapi)
3
4
  [![npm version](https://img.shields.io/npm/v/hono-openapi.svg)](https://npmjs.org/package/hono-openapi "View this project on NPM")
4
5
  [![npm downloads](https://img.shields.io/npm/dm/hono-openapi)](https://www.npmjs.com/package/hono-openapi)
5
- [![license](https://img.shields.io/npm/l/hono-openapi)](LICENSE)
6
6
 
7
7
  This can automatically generate the OpenAPI specification for the Hono API using your validation schema, which can be used to generate client libraries, documentation, and more.
8
8
 
9
- Supported Validation Libraries:
9
+ This lib supports all the validation libs which are [Standard Schema](https://standardschema.dev/) compliant.
10
10
 
11
- - [x] [Zod](https://zod.dev/)
12
- - [x] [Valibot](https://valibot.dev/)
13
- - [x] [ArkType](https://arktype.io/)
14
- - [x] [TypeBox](https://github.com/sinclairzx81/typebox)
15
- - [x] [Effect](https://effect.website/docs/schema/introduction/)
11
+ For documentation visit [honohub.dev](https://honohub.dev).
16
12
 
17
13
  > [!Note]
18
14
  > This package is still in development and your feedback is highly appreciated. If you have any suggestions or issues, please let us know by creating an issue on GitHub.
19
15
 
20
- ## Usage
21
-
22
- ### Installation
23
-
24
- You can install the package using favorite package manager.
25
-
26
- #### For Zod
27
-
28
- ```bash
29
- pnpm add hono-openapi @hono/zod-validator zod zod-openapi
30
- ```
31
-
32
- #### For Valibot
33
-
34
- ```bash
35
- pnpm add hono-openapi @hono/valibot-validator valibot @valibot/to-json-schema
36
- ```
37
-
38
- #### For ArkType
39
-
40
- ```bash
41
- pnpm add hono-openapi @hono/arktype-validator arktype
42
- ```
43
-
44
- #### For TypeBox
45
-
46
- ```bash
47
- pnpm add hono-openapi @hono/typebox-validator @sinclair/typebox
48
- ```
49
-
50
- #### For Effect
51
-
52
- ```bash
53
- pnpm add hono-openapi @hono/effect-validator effect
54
- ```
55
-
56
- > [!IMPORTANT]
57
- >
58
- > Requires `effect@^3.10.0`.
59
- > Also, use the `Schema` class from the `effect` package, as `@effect/schema` is not supported.
60
-
61
- ### Basic Usage
62
-
63
- #### Setting up your application
64
-
65
- First, define your schemas, here is an example using Zod:
66
-
67
- ```ts
68
- import z from "zod";
69
-
70
- // For extending the Zod schema with OpenAPI properties
71
- import "zod-openapi/extend";
72
-
73
- const querySchema = z
74
- .object({
75
- name: z.string().optional().openapi({ example: "Steven" }),
76
- })
77
- .openapi({ ref: "Query" });
78
-
79
- const responseSchema = z.string().openapi({ example: "Hello Steven!" });
80
- ```
81
-
82
- Extending the Zod schema with OpenAPI properties is optional, but it will help you generate the OpenAPI specification. You can learn more about it here - [https://github.com/samchungy/zod-openapi](https://github.com/samchungy/zod-openapi).
83
-
84
- > [!Tip]
85
- > The `querySchema` schema will be registered as "#/components/schemas/Query" refs in the OpenAPI document. If you want to register the schema as referenced components, use .openapi() method.
86
-
87
- Next, create your route -
88
-
89
- ```ts
90
- import { Hono } from "hono";
91
- import { describeRoute } from "hono-openapi";
92
- import { resolver, validator as zValidator } from "hono-openapi/zod";
93
-
94
- const app = new Hono();
95
-
96
- app.get(
97
- "/",
98
- describeRoute({
99
- description: "Say hello to the user",
100
- responses: {
101
- 200: {
102
- description: "Successful greeting response",
103
- content: {
104
- "text/plain": {
105
- schema: resolver(responseSchema),
106
- },
107
- },
108
- },
109
- },
110
- }),
111
- zValidator("query", querySchema),
112
- (c) => {
113
- const query = c.req.valid("query");
114
- return c.text(`Hello ${query?.name ?? "Hono"}!`);
115
- }
116
- );
117
- ```
118
-
119
- You might be wondering why are we importing `validator` from `hono-openapi/zod` instead of `@hono/zod-validator` and as `zValidator`? This is because `hono-openapi` provides a wrapper around the `@hono/zod-validator` to make it easier to use. The idea is if you are already using `@hono/zod-validator` to validate your schemas, you can easily switch to `hono-openapi` without changing much of your code.
120
-
121
- Finally, generate the OpenAPI specification -
122
-
123
- ```ts
124
- app.get(
125
- "/openapi",
126
- openAPISpecs(app, {
127
- documentation: {
128
- info: {
129
- title: "Hono",
130
- version: "1.0.0",
131
- description: "API for greeting users",
132
- },
133
- servers: [
134
- {
135
- url: "http://localhost:3000",
136
- description: "Local server",
137
- },
138
- ],
139
- },
140
- })
141
- );
142
- ```
143
-
144
- Now, you can access the OpenAPI specification by visiting `http://localhost:3000/openapi`, and you can use this specification to generate client libraries, documentation, and more. Some tools that I used to generate documentation are -
145
-
146
- - [Swagger UI](https://github.com/honojs/middleware/tree/main/packages/swagger-ui)
147
- - [Scalar](https://www.npmjs.com/package/@scalar/hono-api-reference)
148
-
149
- ##### Scalar Example
150
-
151
- ```ts
152
- app.get(
153
- "/docs",
154
- Scalar({
155
- theme: "saturn",
156
- url: "/openapi",
157
- })
158
- );
159
- ```
160
-
161
- And that's it! You have successfully generated the OpenAPI specification for your Hono API.
162
-
163
- ### Advanced Usage
164
-
165
- #### Adding Security Definitions
166
-
167
- You can add security definitions to your OpenAPI specification by using the `security` property in the `openAPISpecs` function.
168
-
169
- ```ts
170
- app.get(
171
- "/openapi",
172
- openAPISpecs(appRouter, {
173
- documentation: {
174
- info: {
175
- title: "Rhinobase Cloud",
176
- version: "1.0.0",
177
- description: "API Documentation",
178
- },
179
- components: {
180
- securitySchemes: {
181
- bearerAuth: {
182
- type: "http",
183
- scheme: "bearer",
184
- bearerFormat: "JWT",
185
- },
186
- },
187
- },
188
- security: [
189
- {
190
- bearerAuth: [],
191
- },
192
- ],
193
- servers: [
194
- {
195
- url: "http://localhost:3004",
196
- description: "Local server",
197
- },
198
- ],
199
- },
200
- })
201
- );
202
- ```
203
-
204
- #### Conditionaly Hiding Routes
205
-
206
- You can conditionally hide routes from the OpenAPI specification by using the `hide` property in the `describeRoute` function.
207
-
208
- ```ts
209
- app.get(
210
- "/",
211
- describeRoute({
212
- // ...
213
- hide: process.env.NODE_ENV === "production",
214
- }),
215
- (c) => {
216
- return c.text("Private Route");
217
- }
218
- );
219
- ```
220
-
221
- #### Validating Responses
222
-
223
- > [!Warning]
224
- > Experimental
225
-
226
- You can validate the responses using the `validateResponse` property in the `describeRoute` function. This will validate the response against the schema and return an error if the response is invalid.
227
-
228
- ```ts
229
- app.get(
230
- "/",
231
- describeRoute({
232
- // ...
233
- validateResponse: true,
234
- }),
235
- (c) => {
236
- return c.json({ message: "This response will be validated" });
237
- }
238
- );
239
- ```
240
-
241
- #### Persisting OpenAPI Spec to a file
242
-
243
- You can save the spec to a file for cache or any other external use.
244
-
245
- ```ts
246
- import fs from 'node:fs';
247
- import { openAPISpecs, generateSpecs } from 'hono-openapi';
248
-
249
- const options = {/* ... */};
250
- const app = new Hono()
251
- .get(
252
- "/openapi",
253
- openAPISpecs(app, options),
254
- );
255
-
256
- generateSpecs(app, options)
257
- .then(spec => {
258
- const pathToSpec = "openapi.json"
259
- fs.writeFileSync(pathToSpec, JSON.stringify(spec, null, 2));
260
- })
261
- ```
262
-
263
16
  ## Contributing
264
17
 
265
- We would love to have more contributors involved!
266
-
267
- To get started, please read our [Contributing Guide](https://github.com/rhinobase/hono-openapi/blob/main/CONTRIBUTING.md).
18
+ Visit our [contributing docs](https://github.com/rhinobase/hono-openapi/blob/main/CONTRIBUTING.md).
268
19
 
269
20
  ## Credits
270
21
 
package/dist/index.cjs ADDED
@@ -0,0 +1,410 @@
1
+ 'use strict';
2
+
3
+ var standardValidator = require('@hono/standard-validator');
4
+ var standardJson = require('@standard-community/standard-json');
5
+ var standardOpenapi = require('@standard-community/standard-openapi');
6
+
7
+ const uniqueSymbol = Symbol("openapi");
8
+ const ALLOWED_METHODS = [
9
+ "GET",
10
+ "PUT",
11
+ "POST",
12
+ "DELETE",
13
+ "OPTIONS",
14
+ "HEAD",
15
+ "PATCH",
16
+ "TRACE"
17
+ ];
18
+ const toOpenAPIPath = (path) => path.split("/").map((x) => {
19
+ let tmp = x;
20
+ if (tmp.startsWith(":")) {
21
+ const match = tmp.match(/^:([^{?]+)(?:{(.+)})?(\?)?$/);
22
+ if (match) {
23
+ const paramName = match[1];
24
+ tmp = `{${paramName}}`;
25
+ } else {
26
+ tmp = tmp.slice(1, tmp.length);
27
+ if (tmp.endsWith("?")) tmp = tmp.slice(0, -1);
28
+ tmp = `{${tmp}}`;
29
+ }
30
+ }
31
+ return tmp;
32
+ }).join("/");
33
+ const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
34
+ const generateOperationIdCache = /* @__PURE__ */ new Map();
35
+ const generateOperationId = (route) => {
36
+ const operationIdKey = `${route.method}:${route.path}`;
37
+ if (generateOperationIdCache.has(operationIdKey)) {
38
+ return generateOperationIdCache.get(operationIdKey);
39
+ }
40
+ let operationId = route.method;
41
+ if (route.path === "/") return `${operationId}Index`;
42
+ for (const segment of route.path.split("/")) {
43
+ if (segment.charCodeAt(0) === 123) {
44
+ operationId += `By${capitalize(segment.slice(1, -1))}`;
45
+ } else {
46
+ operationId += capitalize(segment);
47
+ }
48
+ }
49
+ generateOperationIdCache.set(operationIdKey, operationId);
50
+ return operationId;
51
+ };
52
+ const paramKey = (param) => "$ref" in param ? param.$ref : `${param.in} ${param.name}`;
53
+ function mergeParameters(...params) {
54
+ const _params = params.flatMap((x) => x ?? []);
55
+ const merged = _params.reduce((acc, param) => {
56
+ acc.set(paramKey(param), param);
57
+ return acc;
58
+ }, /* @__PURE__ */ new Map());
59
+ return Array.from(merged.values());
60
+ }
61
+ function getProperty(obj, key, defaultValue) {
62
+ if (obj != null && key in obj) {
63
+ return obj[key];
64
+ }
65
+ return defaultValue;
66
+ }
67
+ const specsByPathContext = /* @__PURE__ */ new Map();
68
+ function getPathContext(path) {
69
+ const keys = Array.from(specsByPathContext.keys());
70
+ const context = [];
71
+ for (const key of keys) {
72
+ if (path.match(key)) {
73
+ const data = specsByPathContext.get(key);
74
+ if (!data) continue;
75
+ context.push(data);
76
+ }
77
+ }
78
+ return context;
79
+ }
80
+ function mergeSpecs(...specs) {
81
+ return specs.reduce(
82
+ (prev, spec) => {
83
+ if (!spec) return prev;
84
+ return {
85
+ ...prev,
86
+ ...spec,
87
+ tags: Array.from(
88
+ /* @__PURE__ */ new Set([
89
+ ...getProperty(prev, "tags") ?? [],
90
+ ...getProperty(spec, "tags") ?? []
91
+ ])
92
+ ),
93
+ parameters: mergeParameters(
94
+ getProperty(prev, "parameters"),
95
+ getProperty(spec, "parameters")
96
+ ),
97
+ responses: {
98
+ ...getProperty(prev, "responses", {}),
99
+ ...getProperty(spec, "responses", {})
100
+ }
101
+ };
102
+ },
103
+ {}
104
+ );
105
+ }
106
+ function registerSchemaPath({
107
+ route,
108
+ specs,
109
+ paths
110
+ }) {
111
+ const path = toOpenAPIPath(route.path);
112
+ const method = route.method.toLowerCase();
113
+ if (method === "all") {
114
+ if (!specs) return;
115
+ if (specsByPathContext.has(path)) {
116
+ const prev = specsByPathContext.get(path) ?? {};
117
+ specsByPathContext.set(path, mergeSpecs(prev, specs));
118
+ } else {
119
+ specsByPathContext.set(path, specs);
120
+ }
121
+ } else {
122
+ const pathContext = getPathContext(path);
123
+ paths[path] = {
124
+ ...paths[path] ? paths[path] : {},
125
+ [method]: {
126
+ operationId: generateOperationId(route),
127
+ ...mergeSpecs(...pathContext, paths[path]?.[method], specs)
128
+ }
129
+ };
130
+ }
131
+ }
132
+ function removeExcludedPaths(paths, ctx) {
133
+ const { exclude, excludeStaticFile } = ctx.options;
134
+ const newPaths = {};
135
+ const _exclude = Array.isArray(exclude) ? exclude : [exclude];
136
+ for (const [key, value] of Object.entries(paths)) {
137
+ const isPathExcluded = !_exclude.some((x) => {
138
+ if (typeof x === "string") return key === x;
139
+ return x.test(key);
140
+ });
141
+ const isStaticFileExcluded = excludeStaticFile ? !key.includes(".") || key.includes("{") : true;
142
+ if (isPathExcluded && !(key.includes("*") && !key.includes("{")) && isStaticFileExcluded && value != null) {
143
+ for (const method of Object.keys(value)) {
144
+ const schema = value[method];
145
+ if (key.includes("{")) {
146
+ if (!schema.parameters) schema.parameters = [];
147
+ const pathParameters = key.split("/").filter(
148
+ (x) => x.startsWith("{") && !schema.parameters.find(
149
+ (params) => params.in === "path" && params.name === x.slice(1, x.length - 1)
150
+ )
151
+ );
152
+ for (const param of pathParameters) {
153
+ const paramName = param.slice(1, param.length - 1);
154
+ const index = schema.parameters.findIndex(
155
+ (x) => x.in === "param" && x.name === paramName
156
+ );
157
+ if (index !== -1) schema.parameters[index].in = "path";
158
+ else {
159
+ schema.parameters.push({
160
+ schema: { type: "string" },
161
+ in: "path",
162
+ name: paramName,
163
+ required: true
164
+ });
165
+ }
166
+ }
167
+ }
168
+ if (!schema.responses) {
169
+ schema.responses = {
170
+ 200: {}
171
+ };
172
+ }
173
+ }
174
+ newPaths[key] = value;
175
+ }
176
+ }
177
+ return newPaths;
178
+ }
179
+
180
+ const DEFAULT_OPTIONS = {
181
+ documentation: {},
182
+ excludeStaticFile: true,
183
+ exclude: [],
184
+ excludeMethods: ["OPTIONS"],
185
+ excludeTags: []
186
+ };
187
+ async function generateSpecs(hono, options = DEFAULT_OPTIONS, c) {
188
+ const ctx = {
189
+ components: {},
190
+ // @ts-expect-error
191
+ options: {
192
+ ...DEFAULT_OPTIONS,
193
+ ...options
194
+ }
195
+ };
196
+ const _documentation = ctx.options.documentation ?? {};
197
+ const schema = await generatePaths(hono, ctx);
198
+ for (const path in schema) {
199
+ for (const method in schema[path]) {
200
+ const isHidden = getHiddenValue({
201
+ valueOrFunc: schema[path][method]?.hide,
202
+ method,
203
+ path,
204
+ c
205
+ });
206
+ if (isHidden) {
207
+ delete schema[path][method];
208
+ }
209
+ }
210
+ }
211
+ return {
212
+ openapi: "3.1.0",
213
+ ..._documentation,
214
+ tags: _documentation.tags?.filter(
215
+ (tag) => !ctx.options.excludeTags?.includes(tag?.name)
216
+ ),
217
+ info: {
218
+ title: "Hono Documentation",
219
+ description: "Development documentation",
220
+ version: "0.0.0",
221
+ ..._documentation.info
222
+ },
223
+ paths: {
224
+ ...removeExcludedPaths(schema, ctx),
225
+ ..._documentation.paths
226
+ },
227
+ components: {
228
+ ..._documentation.components,
229
+ schemas: {
230
+ ...ctx.components.schemas,
231
+ ..._documentation.components?.schemas
232
+ }
233
+ }
234
+ };
235
+ }
236
+ async function generatePaths(hono, ctx) {
237
+ const paths = {};
238
+ for (const route of hono.routes) {
239
+ if (!(uniqueSymbol in route.handler)) {
240
+ if (ctx.options.includeEmptyPaths) {
241
+ registerSchemaPath({
242
+ route,
243
+ paths
244
+ });
245
+ }
246
+ continue;
247
+ }
248
+ const routeMethod = route.method;
249
+ if (routeMethod !== "ALL") {
250
+ if (ctx.options.excludeMethods?.includes(routeMethod)) {
251
+ continue;
252
+ }
253
+ if (!ALLOWED_METHODS.includes(routeMethod)) {
254
+ continue;
255
+ }
256
+ }
257
+ const middlewareHandler = route.handler[uniqueSymbol];
258
+ const defaultOptionsForThisMethod = ctx.options.defaultOptions?.[routeMethod];
259
+ const { schema: routeSpecs, components = {} } = await getSpec(
260
+ middlewareHandler,
261
+ defaultOptionsForThisMethod
262
+ );
263
+ ctx.components = {
264
+ ...ctx.components,
265
+ ...components,
266
+ schemas: {
267
+ ...ctx.components.schemas,
268
+ ...components.schemas
269
+ }
270
+ };
271
+ registerSchemaPath({
272
+ route,
273
+ specs: routeSpecs,
274
+ paths
275
+ });
276
+ }
277
+ return paths;
278
+ }
279
+ function getHiddenValue(options) {
280
+ const { valueOrFunc, c, method, path } = options;
281
+ if (valueOrFunc != null) {
282
+ if (typeof valueOrFunc === "boolean") {
283
+ return valueOrFunc;
284
+ } else if (typeof valueOrFunc === "function") {
285
+ if (c) {
286
+ return valueOrFunc(c);
287
+ } else {
288
+ console.warn(
289
+ `'c' is not defined, cannot evaluate hide function for ${method} ${path}`
290
+ );
291
+ }
292
+ }
293
+ }
294
+ return false;
295
+ }
296
+ async function getSpec(middlewareHandler, defaultOptions) {
297
+ if ("spec" in middlewareHandler) {
298
+ let components = {};
299
+ const tmp = {
300
+ ...defaultOptions,
301
+ ...middlewareHandler.spec,
302
+ responses: {
303
+ ...defaultOptions?.responses,
304
+ ...middlewareHandler.spec.responses
305
+ }
306
+ };
307
+ if (tmp.responses) {
308
+ for (const key of Object.keys(tmp.responses)) {
309
+ const response = tmp.responses[key];
310
+ if (!response || !("content" in response)) continue;
311
+ for (const contentKey of Object.keys(response.content ?? {})) {
312
+ const raw = response.content?.[contentKey];
313
+ if (!raw) continue;
314
+ if (raw.schema && "toOpenAPISchema" in raw.schema) {
315
+ const result2 = await raw.schema.toOpenAPISchema(defaultOptions);
316
+ raw.schema = result2.schema;
317
+ if (result2.components) {
318
+ components = {
319
+ ...components,
320
+ ...result2.components
321
+ };
322
+ }
323
+ }
324
+ }
325
+ }
326
+ }
327
+ return { schema: tmp, components };
328
+ }
329
+ const result = await middlewareHandler.toOpenAPISchema();
330
+ const docs = {};
331
+ if (middlewareHandler.target === "form" || middlewareHandler.target === "json") {
332
+ const media = middlewareHandler.target === "json" ? "application/json" : "multipart/form-data";
333
+ if (!docs.requestBody || !("content" in docs.requestBody) || !docs.requestBody.content) {
334
+ docs.requestBody = {
335
+ content: {
336
+ [media]: {
337
+ schema: result.schema
338
+ }
339
+ }
340
+ };
341
+ } else {
342
+ docs.requestBody.content[media] = {
343
+ schema: result.schema
344
+ };
345
+ }
346
+ } else {
347
+ const parameters = [];
348
+ if ("$ref" in result.schema) {
349
+ parameters.push({
350
+ in: middlewareHandler.target,
351
+ // @ts-expect-error
352
+ name: result.schema.$ref,
353
+ // @ts-expect-error
354
+ schema: result.schema
355
+ });
356
+ } else {
357
+ for (const [key, value] of Object.entries(
358
+ result.schema.properties ?? {}
359
+ )) {
360
+ parameters.push({
361
+ in: middlewareHandler.target,
362
+ name: key,
363
+ // @ts-expect-error
364
+ schema: value,
365
+ required: result.schema.required?.includes(key)
366
+ });
367
+ }
368
+ }
369
+ docs.parameters = parameters;
370
+ }
371
+ return { schema: docs, components: result.components };
372
+ }
373
+
374
+ function resolver(schema) {
375
+ return {
376
+ vendor: schema["~standard"].vendor,
377
+ validate: schema["~standard"].validate,
378
+ toJSONSchema: (options) => standardJson.toJsonSchema(schema, options),
379
+ toOpenAPISchema: (options) => standardOpenapi.toOpenAPISchema(schema, options)
380
+ };
381
+ }
382
+ function validator(target, schema, hook, options) {
383
+ const middleware = standardValidator.sValidator(target, schema, hook);
384
+ return Object.assign(middleware, {
385
+ [uniqueSymbol]: {
386
+ target,
387
+ ...resolver(schema),
388
+ options
389
+ }
390
+ });
391
+ }
392
+ function describeRoute(spec) {
393
+ const middleware = async (_c, next) => {
394
+ await next();
395
+ };
396
+ return Object.assign(middleware, {
397
+ [uniqueSymbol]: {
398
+ spec
399
+ }
400
+ });
401
+ }
402
+
403
+ exports.ALLOWED_METHODS = ALLOWED_METHODS;
404
+ exports.describeRoute = describeRoute;
405
+ exports.generateSpecs = generateSpecs;
406
+ exports.registerSchemaPath = registerSchemaPath;
407
+ exports.removeExcludedPaths = removeExcludedPaths;
408
+ exports.resolver = resolver;
409
+ exports.uniqueSymbol = uniqueSymbol;
410
+ exports.validator = validator;