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
|
### 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue