diff --git a/.changeset/quiet-berries-accept.md b/.changeset/quiet-berries-accept.md new file mode 100644 index 00000000..157e6799 --- /dev/null +++ b/.changeset/quiet-berries-accept.md @@ -0,0 +1,5 @@ +--- +'@hono/zod-openapi': minor +--- + +Merge subapps' spec definitions into main app diff --git a/.changeset/red-chefs-repeat.md b/.changeset/red-chefs-repeat.md new file mode 100644 index 00000000..50f3737a --- /dev/null +++ b/.changeset/red-chefs-repeat.md @@ -0,0 +1,5 @@ +--- +'@hono/zod-openapi': minor +--- + +Support v3.1 spec output diff --git a/.changeset/spotty-ads-act.md b/.changeset/spotty-ads-act.md new file mode 100644 index 00000000..0b900c06 --- /dev/null +++ b/.changeset/spotty-ads-act.md @@ -0,0 +1,5 @@ +--- +'@hono/zod-openapi': minor +--- + +OpenAPIHono constructor supports init object diff --git a/packages/zod-openapi/README.md b/packages/zod-openapi/README.md index 0b8d0fdd..4df2be10 100644 --- a/packages/zod-openapi/README.md +++ b/packages/zod-openapi/README.md @@ -174,6 +174,15 @@ app.openapi( ) ``` +### OpenAPI v3.1 + +You can generate OpenAPI v3.1 spec using the following methods: + +```ts +app.doc31('/docs', {openapi: '3.1.0'}) // new endpoint +app.getOpenAPI31Document(, {openapi: '3.1.0'}) // raw json +``` + ### The Registry You can access the [`OpenAPIRegistry`](https://github.com/asteasolutions/zod-to-openapi#the-registry) object via `app.openAPIRegistry`: @@ -214,44 +223,9 @@ const client = hc('http://localhost:8787/') ## Limitations -An instance of Zod OpenAPI Hono cannot be used as a "subApp" in conjunction with `rootApp.route('/api', subApp)`. -Use `app.mount('/api', subApp.fetch)` instead. +Be careful when combining `OpenAPIHono` instances with plain `Hono` instances. `OpenAPIHono` will merge the definitions of direct subapps, but plain `Hono` knows nothing about the OpenAPI spec additions. Similarly `OpenAPIHono` will not "dig" for instances deep inside a branch of plain `Hono` instances. -```ts -const api = OpenAPIHono() - -// ... - -// Set the `/api` as a base path in the document. -api.get('/doc', (c) => { - const url = new URL(c.req.url) - url.pathname = '/api' - url.search = '' - - return c.json( - // `api.getOpenAPIDocument()` will return a JSON object of the docs. - api.getOpenAPIDocument({ - openapi: '3.0.0', - info: { - version: '1.0.0', - title: 'My API', - }, - servers: [ - { - url: `${url.toString()}`, - }, - ], - }) - ) -}) - -const app = new Hono() - -// Mount the Open API app to `/api` in the main app. -app.mount('/api', api.fetch) - -export default app -``` +If you're migrating from plain `Hono` to `OpenAPIHono`, we recommend porting your top-level app, then working your way down the router tree. ## References diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index 03de18cd..7b49c512 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -6,7 +6,7 @@ import type { ZodContentObject, ZodRequestBody, } from '@asteasolutions/zod-to-openapi' -import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi' +import { OpenApiGeneratorV3, OpenApiGeneratorV31, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi' import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi' import type { OpenAPIObjectConfig } from '@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator' import { zValidator } from '@hono/zod-validator' @@ -21,6 +21,8 @@ import type { ToSchema, TypedResponse, } from 'hono' +import type { MergePath, MergeSchemaPath } from 'hono/dist/types/types' +import type { RemoveBlankRecord } from 'hono/utils/types' import type { AnyZodObject, ZodSchema, ZodError } from 'zod' import { z, ZodType } from 'zod' @@ -145,6 +147,8 @@ type ConvertPathType = T extends `${infer _}/{${infer Param}}$ type HandlerResponse = TypedResponse | Promise> +type HonoInit = ConstructorParameters[0]; + export class OpenAPIHono< E extends Env = Env, S extends Schema = {}, @@ -152,8 +156,8 @@ export class OpenAPIHono< > extends Hono { openAPIRegistry: OpenAPIRegistry - constructor() { - super() + constructor(init?: HonoInit) { + super(init) this.openAPIRegistry = new OpenAPIRegistry() } @@ -170,7 +174,7 @@ export class OpenAPIHono< route: R, handler: Handler>>, hook?: Hook> - ): Hono>, BasePath> => { + ): OpenAPIHono>, BasePath> => { this.openAPIRegistry.registerPath(route) const validators: MiddlewareHandler[] = [] @@ -229,12 +233,94 @@ export class OpenAPIHono< return document } + getOpenAPI31Document = (config: OpenAPIObjectConfig) => { + const generator = new OpenApiGeneratorV31(this.openAPIRegistry.definitions) + const document = generator.generateDocument(config) + return document + } + doc = (path: string, config: OpenAPIObjectConfig) => { this.get(path, (c) => { const document = this.getOpenAPIDocument(config) return c.json(document) }) } + + doc31 = (path: string, config: OpenAPIObjectConfig) => { + this.get(path, (c) => { + const document = this.getOpenAPI31Document(config) + return c.json(document) + }) + } + + route< + SubPath extends string, + SubEnv extends Env, + SubSchema extends Schema, + SubBasePath extends string + >( + path: SubPath, + app: Hono + ): OpenAPIHono> & S, BasePath> + route(path: SubPath): Hono, BasePath> + route< + SubPath extends string, + SubEnv extends Env, + SubSchema extends Schema, + SubBasePath extends string + >( + path: SubPath, + app?: Hono + ): OpenAPIHono> & S, BasePath> { + super.route(path, app as any) + + if (!(app instanceof OpenAPIHono)) { + return this as any + } + + app.openAPIRegistry.definitions.forEach((def) => { + switch (def.type) { + case 'component': + return this.openAPIRegistry.registerComponent( + def.componentType, + def.name, + def.component + ) + + case 'route': + return this.openAPIRegistry.registerPath({ + ...def.route, + path: `${path}${def.route.path}` + }) + + case 'webhook': + return this.openAPIRegistry.registerWebhook({ + ...def.webhook, + path: `${path}${def.webhook.path}` + }) + + case 'schema': + return this.openAPIRegistry.register( + def.schema._def.openapi._internal.refId, + def.schema + ) + + case 'parameter': + return this.openAPIRegistry.registerParameter( + def.schema._def.openapi._internal.refId, + def.schema + ) + + default: { + const errorIfNotExhaustive: never = def + throw new Error(`Unknown registry type: ${errorIfNotExhaustive}`) + } + } + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this as any + } } export const createRoute =

& { path: P }>( diff --git a/packages/zod-openapi/test/index.test.ts b/packages/zod-openapi/test/index.test.ts index 7783af59..f3ed9ef0 100644 --- a/packages/zod-openapi/test/index.test.ts +++ b/packages/zod-openapi/test/index.test.ts @@ -4,6 +4,19 @@ import type { Hono, Env, ToSchema } from 'hono' import { describe, it, expect, expectTypeOf } from 'vitest' import { OpenAPIHono, createRoute, z } from '../src' +describe('Constructor', () => { + it('Should not require init object', () => { + expect(() => new OpenAPIHono()).not.toThrow() + }) + + it('Should accept init object', () => { + const getPath = () => '' + const app = new OpenAPIHono({getPath}) + + expect(app.getPath).toBe(getPath) + }) +}) + describe('Basic - params', () => { const ParamsSchema = z.object({ id: z @@ -577,6 +590,78 @@ describe('Types', () => { }) }) +describe('Routers', () => { + const RequestSchema = z.object({ + id: z.number().openapi({}), + }) + + const PostSchema = z + .object({ + id: z.number().openapi({}), + }) + .openapi('Post') + + const route = createRoute({ + method: 'post', + path: '/posts', + request: { + body: { + content: { + 'application/json': { + schema: RequestSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: PostSchema, + }, + }, + description: 'Post a post', + }, + }, + }) + it('Should include definitions from nested routers', () => { + const router = new OpenAPIHono().openapi(route, (ctx) => { + return ctx.jsonT({id: 123}) + }) + + router.openAPIRegistry.register('Id', z.number()) + + router.openAPIRegistry.registerParameter('Key', z.number().openapi({ + param: {in: 'path'} + })) + + router.openAPIRegistry.registerWebhook({ + method: 'post', + path: '/postback', + responses: { + 200: { + description: 'Receives a post back' + } + } + }) + + const app = new OpenAPIHono().route('/api', router) + const json = app.getOpenAPI31Document({ + openapi: '3.1.0', + info: { + title: 'My API', + version: '1.0.0', + }, + }) + + expect(json.components?.schemas).toHaveProperty('Id') + expect(json.components?.schemas).toHaveProperty('Post') + expect(json.components?.parameters).toHaveProperty('Key') + expect(json.paths).toHaveProperty('/api/posts') + expect(json.webhooks).toHaveProperty('/api/postback') + }) +}) + describe('Multi params', () => { const ParamsSchema = z.object({ id: z.string(),