[zod-openapi] Merge subapps' definitions into main app (#153)

* feat(zod-openapi): support `init` object

* feat(zod-openapi): support `v3.1` spec output

* feat(zod-openapi): Merge subapps' definitions
pull/157/head
Mike Stop Continues 2023-09-12 00:25:41 +01:00 committed by GitHub
parent ec3beefd63
commit 430088e175
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 201 additions and 41 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/zod-openapi': minor
---
Merge subapps' spec definitions into main app

View File

@ -0,0 +1,5 @@
---
'@hono/zod-openapi': minor
---
Support v3.1 spec output

View File

@ -0,0 +1,5 @@
---
'@hono/zod-openapi': minor
---
OpenAPIHono constructor supports init object

View File

@ -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<typeof appRoutes>('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

View File

@ -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 string> = T extends `${infer _}/{${infer Param}}$
type HandlerResponse<O> = TypedResponse<O> | Promise<TypedResponse<O>>
type HonoInit = ConstructorParameters<typeof Hono>[0];
export class OpenAPIHono<
E extends Env = Env,
S extends Schema = {},
@ -152,8 +156,8 @@ export class OpenAPIHono<
> extends Hono<E, S, BasePath> {
openAPIRegistry: OpenAPIRegistry
constructor() {
super()
constructor(init?: HonoInit) {
super(init)
this.openAPIRegistry = new OpenAPIRegistry()
}
@ -170,7 +174,7 @@ export class OpenAPIHono<
route: R,
handler: Handler<E, P, I, HandlerResponse<OutputType<R>>>,
hook?: Hook<I, E, P, OutputType<R>>
): Hono<E, ToSchema<R['method'], P, I['in'], OutputType<R>>, BasePath> => {
): OpenAPIHono<E, ToSchema<R['method'], P, I['in'], OutputType<R>>, 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<SubEnv, SubSchema, SubBasePath>
): OpenAPIHono<E, MergeSchemaPath<SubSchema, MergePath<BasePath, SubPath>> & S, BasePath>
route<SubPath extends string>(path: SubPath): Hono<E, RemoveBlankRecord<S>, BasePath>
route<
SubPath extends string,
SubEnv extends Env,
SubSchema extends Schema,
SubBasePath extends string
>(
path: SubPath,
app?: Hono<SubEnv, SubSchema, SubBasePath>
): OpenAPIHono<E, MergeSchemaPath<SubSchema, MergePath<BasePath, SubPath>> & 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 = <P extends string, R extends Omit<RouteConfig, 'path'> & { path: P }>(

View File

@ -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(),