feat(zod-openapi): add support for defaultHook in initializer (#170)
* feat: add support for defaultHook in initializer * update READMEpull/177/head
parent
a9123dd9e3
commit
9c45dbc41d
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hono/zod-openapi': minor
|
||||
---
|
||||
|
||||
Add defaultHook as an option for OpenAPIHono
|
|
@ -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
|
||||
|
||||
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 { 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)
|
||||
```
|
||||
|
||||
|
|
|
@ -151,7 +151,10 @@ 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 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<
|
||||
R extends RouteConfig,
|
||||
|
@ -183,10 +186,12 @@ export class OpenAPIHono<
|
|||
BasePath extends string = '/'
|
||||
> extends Hono<E, S, BasePath> {
|
||||
openAPIRegistry: OpenAPIRegistry
|
||||
defaultHook?: OpenAPIHonoOptions<E>['defaultHook']
|
||||
|
||||
constructor(init?: HonoInit) {
|
||||
constructor(init?: HonoInit<E>) {
|
||||
super(init)
|
||||
this.openAPIRegistry = new OpenAPIRegistry()
|
||||
this.defaultHook = init?.defaultHook
|
||||
}
|
||||
|
||||
openapi = <
|
||||
|
@ -201,7 +206,7 @@ export class OpenAPIHono<
|
|||
>(
|
||||
route: 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> => {
|
||||
this.openAPIRegistry.registerPath(route)
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable node/no-extraneous-import */
|
||||
/* 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 { describe, it, expect, expectTypeOf } from 'vitest'
|
||||
import { OpenAPIHono, createRoute, z } from '../src'
|
||||
|
@ -15,6 +16,17 @@ describe('Constructor', () => {
|
|||
const app = new OpenAPIHono({ 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', () => {
|
||||
|
@ -757,4 +769,143 @@ describe('With hc', () => {
|
|||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue