[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' definitionspull/157/head
parent
ec3beefd63
commit
430088e175
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/zod-openapi': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Merge subapps' spec definitions into main app
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/zod-openapi': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Support v3.1 spec output
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/zod-openapi': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
OpenAPIHono constructor supports init object
|
|
@ -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
|
### The Registry
|
||||||
|
|
||||||
You can access the [`OpenAPIRegistry`](https://github.com/asteasolutions/zod-to-openapi#the-registry) object via `app.openAPIRegistry`:
|
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
|
## Limitations
|
||||||
|
|
||||||
An instance of Zod OpenAPI Hono cannot be used as a "subApp" in conjunction with `rootApp.route('/api', subApp)`.
|
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.
|
||||||
Use `app.mount('/api', subApp.fetch)` instead.
|
|
||||||
|
|
||||||
```ts
|
If you're migrating from plain `Hono` to `OpenAPIHono`, we recommend porting your top-level app, then working your way down the router tree.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import type {
|
||||||
ZodContentObject,
|
ZodContentObject,
|
||||||
ZodRequestBody,
|
ZodRequestBody,
|
||||||
} from '@asteasolutions/zod-to-openapi'
|
} 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 { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'
|
||||||
import type { OpenAPIObjectConfig } from '@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator'
|
import type { OpenAPIObjectConfig } from '@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator'
|
||||||
import { zValidator } from '@hono/zod-validator'
|
import { zValidator } from '@hono/zod-validator'
|
||||||
|
@ -21,6 +21,8 @@ import type {
|
||||||
ToSchema,
|
ToSchema,
|
||||||
TypedResponse,
|
TypedResponse,
|
||||||
} from 'hono'
|
} 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 type { AnyZodObject, ZodSchema, ZodError } from 'zod'
|
||||||
import { z, ZodType } 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 HandlerResponse<O> = TypedResponse<O> | Promise<TypedResponse<O>>
|
||||||
|
|
||||||
|
type HonoInit = ConstructorParameters<typeof Hono>[0];
|
||||||
|
|
||||||
export class OpenAPIHono<
|
export class OpenAPIHono<
|
||||||
E extends Env = Env,
|
E extends Env = Env,
|
||||||
S extends Schema = {},
|
S extends Schema = {},
|
||||||
|
@ -152,8 +156,8 @@ export class OpenAPIHono<
|
||||||
> extends Hono<E, S, BasePath> {
|
> extends Hono<E, S, BasePath> {
|
||||||
openAPIRegistry: OpenAPIRegistry
|
openAPIRegistry: OpenAPIRegistry
|
||||||
|
|
||||||
constructor() {
|
constructor(init?: HonoInit) {
|
||||||
super()
|
super(init)
|
||||||
this.openAPIRegistry = new OpenAPIRegistry()
|
this.openAPIRegistry = new OpenAPIRegistry()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,7 +174,7 @@ export class OpenAPIHono<
|
||||||
route: R,
|
route: R,
|
||||||
handler: Handler<E, P, I, HandlerResponse<OutputType<R>>>,
|
handler: Handler<E, P, I, HandlerResponse<OutputType<R>>>,
|
||||||
hook?: Hook<I, E, P, 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)
|
this.openAPIRegistry.registerPath(route)
|
||||||
|
|
||||||
const validators: MiddlewareHandler[] = []
|
const validators: MiddlewareHandler[] = []
|
||||||
|
@ -229,12 +233,94 @@ export class OpenAPIHono<
|
||||||
return document
|
return document
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOpenAPI31Document = (config: OpenAPIObjectConfig) => {
|
||||||
|
const generator = new OpenApiGeneratorV31(this.openAPIRegistry.definitions)
|
||||||
|
const document = generator.generateDocument(config)
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
|
||||||
doc = (path: string, config: OpenAPIObjectConfig) => {
|
doc = (path: string, config: OpenAPIObjectConfig) => {
|
||||||
this.get(path, (c) => {
|
this.get(path, (c) => {
|
||||||
const document = this.getOpenAPIDocument(config)
|
const document = this.getOpenAPIDocument(config)
|
||||||
return c.json(document)
|
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 }>(
|
export const createRoute = <P extends string, R extends Omit<RouteConfig, 'path'> & { path: P }>(
|
||||||
|
|
|
@ -4,6 +4,19 @@ import type { Hono, Env, ToSchema } from 'hono'
|
||||||
import { describe, it, expect, expectTypeOf } from 'vitest'
|
import { describe, it, expect, expectTypeOf } from 'vitest'
|
||||||
import { OpenAPIHono, createRoute, z } from '../src'
|
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', () => {
|
describe('Basic - params', () => {
|
||||||
const ParamsSchema = z.object({
|
const ParamsSchema = z.object({
|
||||||
id: z
|
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', () => {
|
describe('Multi params', () => {
|
||||||
const ParamsSchema = z.object({
|
const ParamsSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|
Loading…
Reference in New Issue