Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 4 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-berries-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/zod-openapi': minor
---

Merge subapps' spec definitions into main app
5 changes: 5 additions & 0 deletions .changeset/red-chefs-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/zod-openapi': minor
---

Support v3.1 spec output
5 changes: 5 additions & 0 deletions .changeset/spotty-ads-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/zod-openapi': minor
---

OpenAPIHono constructor supports init object
48 changes: 11 additions & 37 deletions packages/zod-openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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.

```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)
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.

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

Expand Down
94 changes: 90 additions & 4 deletions packages/zod-openapi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -145,15 +147,17 @@ 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 = {},
BasePath extends string = '/'
> extends Hono<E, S, BasePath> {
openAPIRegistry: OpenAPIRegistry

constructor() {
super()
constructor(init?: HonoInit) {
super(init)
this.openAPIRegistry = new OpenAPIRegistry()
}

Expand All @@ -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[] = []
Expand Down Expand Up @@ -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 }>(
Expand Down
85 changes: 85 additions & 0 deletions packages/zod-openapi/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
Loading