feat(zod-openapi): add support for defaultHook in initializer (#170)

* feat: add support for defaultHook in initializer

* update README
pull/177/head
Matt Sutkowski 2023-09-26 01:09:02 -07:00 committed by GitHub
parent a9123dd9e3
commit 9c45dbc41d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 217 additions and 5 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/zod-openapi': minor
---
Add defaultHook as an option for OpenAPIHono

View File

@ -174,6 +174,57 @@ app.openapi(
) )
``` ```
### A DRY approach to handling validation errors
In the case that you have a common error formatter, you can initialize the `OpenAPIHono` instance with a `defaultHook`.
```ts
const app = new OpenAPIHono({
defaultHook: (result, c) => {
if (!result.success) {
return c.jsonT(
{
ok: false,
errors: formatZodErrors(result),
source: 'custom_error_handler',
},
422
)
}
},
})
```
You can still override the `defaultHook` by providing the hook at the call site when appropriate.
```ts
// uses the defaultHook
app.openapi(createPostRoute, (c) => {
const { title } = c.req.valid('json')
return c.jsonT({ title })
})
// override the defaultHook by passing in a hook
app.openapi(
createBookRoute,
(c) => {
const { title } = c.req.valid('json')
return c.jsonT({ title })
},
(result, c) => {
if (!result.success) {
return c.jsonT(
{
ok: false,
source: 'routeHook' as const,
},
400
)
}
}
)
```
### OpenAPI v3.1 ### OpenAPI v3.1
You can generate OpenAPI v3.1 spec using the following methods: You can generate OpenAPI v3.1 spec using the following methods:
@ -211,7 +262,7 @@ You can configure middleware for each endpoint from a route created by `createRo
import { prettyJSON } from 'hono/pretty-json' import { prettyJSON } from 'hono/pretty-json'
import { cache } from 'honoc/cache' import { cache } from 'honoc/cache'
app.use(route.getRoutingPath(), prettyJSON(), cache({ cacheName: "my-cache" })) app.use(route.getRoutingPath(), prettyJSON(), cache({ cacheName: 'my-cache' }))
app.openapi(route, handler) app.openapi(route, handler)
``` ```

View File

@ -151,7 +151,10 @@ 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 type OpenAPIHonoOptions<E extends Env> = {
defaultHook?: Hook<any, E, any, any>
}
type HonoInit<E extends Env> = ConstructorParameters<typeof Hono>[0] & OpenAPIHonoOptions<E>
export type RouteHandler< export type RouteHandler<
R extends RouteConfig, R extends RouteConfig,
@ -183,10 +186,12 @@ export class OpenAPIHono<
BasePath extends string = '/' BasePath extends string = '/'
> extends Hono<E, S, BasePath> { > extends Hono<E, S, BasePath> {
openAPIRegistry: OpenAPIRegistry openAPIRegistry: OpenAPIRegistry
defaultHook?: OpenAPIHonoOptions<E>['defaultHook']
constructor(init?: HonoInit) { constructor(init?: HonoInit<E>) {
super(init) super(init)
this.openAPIRegistry = new OpenAPIRegistry() this.openAPIRegistry = new OpenAPIRegistry()
this.defaultHook = init?.defaultHook
} }
openapi = < openapi = <
@ -201,7 +206,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>> | undefined = this.defaultHook
): OpenAPIHono<E, S & ToSchema<R['method'], P, I['in'], OutputType<R>>, BasePath> => { ): OpenAPIHono<E, S & ToSchema<R['method'], P, I['in'], OutputType<R>>, BasePath> => {
this.openAPIRegistry.registerPath(route) this.openAPIRegistry.registerPath(route)

View File

@ -1,6 +1,7 @@
/* eslint-disable node/no-extraneous-import */ /* eslint-disable node/no-extraneous-import */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import type { Hono, Env, ToSchema } from 'hono' import type { RouteConfig } from '@asteasolutions/zod-to-openapi'
import type { Hono, Env, ToSchema, Context } from 'hono'
import { hc } from 'hono/client' import { hc } from 'hono/client'
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'
@ -15,6 +16,17 @@ describe('Constructor', () => {
const app = new OpenAPIHono({ getPath }) const app = new OpenAPIHono({ getPath })
expect(app.getPath).toBe(getPath) expect(app.getPath).toBe(getPath)
}) })
it('Should accept a defaultHook', () => {
type FakeEnv = { Variables: { fake: string }; Bindings: { other: number } }
const app = new OpenAPIHono<FakeEnv>({
defaultHook: (result, c) => {
// Make sure we're passing context types through
expectTypeOf(c).toMatchTypeOf<Context<FakeEnv, any, any>>()
},
})
expect(app.defaultHook).toBeDefined()
})
}) })
describe('Basic - params', () => { describe('Basic - params', () => {
@ -757,4 +769,143 @@ describe('With hc', () => {
expect(client.books.$url().pathname).toBe('/books') expect(client.books.$url().pathname).toBe('/books')
}) })
}) })
describe('defaultHook', () => {
const app = new OpenAPIHono({
defaultHook: (result, c) => {
if (!result.success) {
const res = c.jsonT(
{
ok: false,
source: 'defaultHook',
},
400
)
return res
}
},
})
const TitleSchema = z.object({
title: z.string().openapi({}),
})
function errorResponse() {
return {
400: {
content: {
'application/json': {
schema: z.object({
ok: z.boolean().openapi({}),
source: z.enum(['routeHook', 'defaultHook']).openapi({}),
}),
},
},
description: 'A validation error',
},
} satisfies RouteConfig['responses']
}
const createPostRoute = createRoute({
method: 'post',
path: '/posts',
operationId: 'createPost',
request: {
body: {
content: {
'application/json': {
schema: TitleSchema,
},
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: TitleSchema,
},
},
description: 'A post',
},
...errorResponse(),
},
})
const createBookRoute = createRoute({
method: 'post',
path: '/books',
operationId: 'createBook',
request: {
body: {
content: {
'application/json': {
schema: TitleSchema,
},
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: TitleSchema,
},
},
description: 'A book',
},
...errorResponse(),
},
})
// use the defaultHook
app.openapi(createPostRoute, (c) => {
const { title } = c.req.valid('json')
return c.jsonT({ title })
})
// use a routeHook
app.openapi(
createBookRoute,
(c) => {
const { title } = c.req.valid('json')
return c.jsonT({ title })
},
(result, c) => {
if (!result.success) {
const res = c.jsonT(
{
ok: false,
source: 'routeHook' as const,
},
400
)
return res
}
}
)
it('uses the defaultHook', async () => {
const res = await app.request('/posts', {
method: 'POST',
body: JSON.stringify({ bad: 'property' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({
ok: false,
source: 'defaultHook',
})
})
it('it uses the route hook instead of the defaultHook', async () => {
const res = await app.request('/books', {
method: 'POST',
body: JSON.stringify({ bad: 'property' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({
ok: false,
source: 'routeHook',
})
})
})
}) })