Skip to content

Commit

Permalink
feat(zod-openapi): add support for defaultHook in initializer (#170)
Browse files Browse the repository at this point in the history
* feat: add support for defaultHook in initializer

* update README
  • Loading branch information
msutkowski authored Sep 26, 2023
1 parent a9123dd commit 9c45dbc
Show file tree
Hide file tree
Showing 4 changed files with 217 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .changeset/witty-dolls-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/zod-openapi': minor
---

Add defaultHook as an option for OpenAPIHono
53 changes: 52 additions & 1 deletion packages/zod-openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
```

Expand Down
11 changes: 8 additions & 3 deletions packages/zod-openapi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = <
Expand All @@ -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)

Expand Down
153 changes: 152 additions & 1 deletion packages/zod-openapi/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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',
})
})
})
})

0 comments on commit 9c45dbc

Please sign in to comment.