feat(zod-openapi): support "status code" (#519)

* feat(zod-openapi): support "status code"

* add changeset and tweak

* fixed lock file
pull/520/head
Yusuke Wada 2024-05-15 10:06:11 +09:00 committed by GitHub
parent fb81f4ea4a
commit b03484ba05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 238 additions and 93 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/zod-openapi': minor
---
feat(zod-openapi): support "status code"

View File

@ -51,8 +51,7 @@ const UserSchema = z
.openapi('User') .openapi('User')
``` ```
> [!TIP] > [!TIP] > `UserSchema` schema will be registered as `"#/components/schemas/User"` refs in the OpenAPI document.
> `UserSchema` schema will be registered as `"#/components/schemas/User"` refs in the OpenAPI document.
> If you want to register the schema as referenced components, use `.openapi()` method. > If you want to register the schema as referenced components, use `.openapi()` method.
Next, create a route: Next, create a route:
@ -88,11 +87,14 @@ const app = new OpenAPIHono()
app.openapi(route, (c) => { app.openapi(route, (c) => {
const { id } = c.req.valid('param') const { id } = c.req.valid('param')
return c.json({ return c.json(
id, {
age: 20, id,
name: 'Ultra-man', age: 20,
}) name: 'Ultra-man',
},
200 // You should specify the status code even if it is 200.
)
}) })
// The OpenAPI documentation will be available at /doc // The OpenAPI documentation will be available at /doc
@ -157,11 +159,14 @@ app.openapi(
route, route,
(c) => { (c) => {
const { id } = c.req.valid('param') const { id } = c.req.valid('param')
return c.json({ return c.json(
id, {
age: 20, id,
name: 'Ultra-man', age: 20,
}) name: 'Ultra-man',
},
200
)
}, },
// Hook // Hook
(result, c) => { (result, c) => {
@ -213,7 +218,7 @@ app.openapi(
createBookRoute, createBookRoute,
(c) => { (c) => {
const { title } = c.req.valid('json') const { title } = c.req.valid('json')
return c.json({ title }) return c.json({ title }, 200)
}, },
(result, c) => { (result, c) => {
if (!result.success) { if (!result.success) {
@ -234,8 +239,8 @@ app.openapi(
You can generate OpenAPI v3.1 spec using the following methods: You can generate OpenAPI v3.1 spec using the following methods:
```ts ```ts
app.doc31('/docs', {openapi: '3.1.0'}) // new endpoint app.doc31('/docs', { openapi: '3.1.0' }) // new endpoint
app.getOpenAPI31Document({openapi: '3.1.0'}) // raw json app.getOpenAPI31Document({ openapi: '3.1.0' }) // raw json
``` ```
### The Registry ### The Registry
@ -279,10 +284,7 @@ const route = createRoute({
request: { request: {
params: ParamsSchema, params: ParamsSchema,
}, },
middleware: [ middleware: [prettyJSON(), cache({ cacheName: 'my-cache' })],
prettyJSON(),
cache({ cacheName: 'my-cache' })
],
responses: { responses: {
200: { 200: {
content: { content: {
@ -305,10 +307,13 @@ import { hc } from 'hono/client'
const appRoutes = app.openapi(route, (c) => { const appRoutes = app.openapi(route, (c) => {
const data = c.req.valid('json') const data = c.req.valid('json')
return c.json({ return c.json(
id: data.id, {
message: 'Success', id: data.id,
}) message: 'Success',
},
200
)
}) })
const client = hc<typeof appRoutes>('http://localhost:8787/') const client = hc<typeof appRoutes>('http://localhost:8787/')
@ -337,9 +342,9 @@ eg. Bearer Auth
Register the security scheme: Register the security scheme:
```ts ```ts
app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { app.openAPIRegistry.registerComponent('securitySchemes', 'Bearer', {
type: "http", type: 'http',
scheme: "bearer", scheme: 'bearer',
}) })
``` ```
@ -361,7 +366,7 @@ const route = createRoute({
You can access the context in `app.doc` as follows: You can access the context in `app.doc` as follows:
```ts ```ts
app.doc('/doc', c => ({ app.doc('/doc', (c) => ({
openapi: '3.0.0', openapi: '3.0.0',
info: { info: {
version: '1.0.0', version: '1.0.0',

View File

@ -37,12 +37,12 @@
}, },
"homepage": "https://github.com/honojs/middleware", "homepage": "https://github.com/honojs/middleware",
"peerDependencies": { "peerDependencies": {
"hono": ">=3.11.3", "hono": ">=4.3.6",
"zod": "3.*" "zod": "3.*"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20240117.0", "@cloudflare/workers-types": "^4.20240117.0",
"hono": "^4.2.2", "hono": "^4.3.6",
"jest": "^29.7.0", "jest": "^29.7.0",
"openapi3-ts": "^4.1.2", "openapi3-ts": "^4.1.2",
"tsup": "^8.0.1", "tsup": "^8.0.1",

View File

@ -27,7 +27,8 @@ import type {
TypedResponse, TypedResponse,
} from 'hono' } from 'hono'
import type { MergePath, MergeSchemaPath } from 'hono/types' import type { MergePath, MergeSchemaPath } from 'hono/types'
import type { RemoveBlankRecord } from 'hono/utils/types' import { StatusCode } from 'hono/utils/http-status'
import type { Prettify, RemoveBlankRecord } from 'hono/utils/types'
import { mergePath } from 'hono/utils/url' import { mergePath } from 'hono/utils/url'
import type { AnyZodObject, ZodSchema, ZodError } from 'zod' import type { AnyZodObject, ZodSchema, ZodError } from 'zod'
import { z, ZodType } from 'zod' import { z, ZodType } from 'zod'
@ -132,19 +133,23 @@ type InputTypeQuery<R extends RouteConfig> = InputTypeBase<R, 'query', 'query'>
type InputTypeHeader<R extends RouteConfig> = InputTypeBase<R, 'headers', 'header'> type InputTypeHeader<R extends RouteConfig> = InputTypeBase<R, 'headers', 'header'>
type InputTypeCookie<R extends RouteConfig> = InputTypeBase<R, 'cookies', 'cookie'> type InputTypeCookie<R extends RouteConfig> = InputTypeBase<R, 'cookies', 'cookie'>
type OutputType<R extends RouteConfig> = R['responses'] extends Record<infer _, infer C> type ExtractContent<T> = T extends {
? C extends ResponseConfig [K in keyof T]: infer A
? C['content'] extends ZodContentObject }
? IsJson<keyof C['content']> extends never ? A extends Record<'schema', ZodSchema>
? {} ? z.infer<A['schema']>
: C['content'][keyof C['content']] extends Record<'schema', ZodSchema> : never
? z.infer<C['content'][keyof C['content']]['schema']> : never
: {}
: {}
: {}
: {}
export type Hook<T, E extends Env, P extends string, O> = ( export type RouteConfigToTypedResponse<R extends RouteConfig> = {
[Status in keyof R['responses'] & StatusCode]: IsJson<
keyof R['responses'][Status]['content']
> extends never
? TypedResponse<{}, Status, string>
: TypedResponse<ExtractContent<R['responses'][Status]['content']>, Status, 'json'>
}[keyof R['responses'] & StatusCode]
export type Hook<T, E extends Env, P extends string, R> = (
result: result:
| { | {
success: true success: true
@ -155,25 +160,12 @@ export type Hook<T, E extends Env, P extends string, O> = (
error: ZodError error: ZodError
}, },
c: Context<E, P> c: Context<E, P>
) => ) => R
| TypedResponse<O>
| Promise<TypedResponse<T>>
| Response
| Promise<Response>
| void
| Promise<void>
type ConvertPathType<T extends string> = T extends `${infer Start}/{${infer Param}}${infer Rest}` type ConvertPathType<T extends string> = T extends `${infer Start}/{${infer Param}}${infer Rest}`
? `${Start}/:${Param}${ConvertPathType<Rest>}` ? `${Start}/:${Param}${ConvertPathType<Rest>}`
: T : T
type HandlerTypedResponse<O> = TypedResponse<O> | Promise<TypedResponse<O>>
type HandlerAllResponse<O> =
| Response
| Promise<Response>
| TypedResponse<O>
| Promise<TypedResponse<O>>
export type OpenAPIHonoOptions<E extends Env> = { export type OpenAPIHonoOptions<E extends Env> = {
defaultHook?: Hook<any, E, any, any> defaultHook?: Hook<any, E, any, any>
} }
@ -196,15 +188,15 @@ export type RouteHandler<
// If response type is defined, only TypedResponse is allowed. // If response type is defined, only TypedResponse is allowed.
R extends { R extends {
responses: { responses: {
[statusCode: string]: { [statusCode: number]: {
content: { content: {
[mediaType: string]: ZodMediaTypeObject [mediaType: string]: ZodMediaTypeObject
} }
} }
} }
} }
? HandlerTypedResponse<OutputType<R>> ? RouteConfigToTypedResponse<R>
: HandlerAllResponse<OutputType<R>> : RouteConfigToTypedResponse<R> | Response | Promise<Response>
> >
export type RouteHook< export type RouteHook<
@ -217,7 +209,12 @@ export type RouteHook<
InputTypeForm<R> & InputTypeForm<R> &
InputTypeJson<R>, InputTypeJson<R>,
P extends string = ConvertPathType<R['path']> P extends string = ConvertPathType<R['path']>
> = Hook<I, E, P, OutputType<R>> > = Hook<
I,
E,
P,
RouteConfigToTypedResponse<R> | Response | Promise<Response> | void | Promise<void>
>
export type OpenAPIObjectConfigure<E extends Env, P extends string> = export type OpenAPIObjectConfigure<E extends Env, P extends string> =
| OpenAPIObjectConfig | OpenAPIObjectConfig
@ -237,6 +234,37 @@ export class OpenAPIHono<
this.defaultHook = init?.defaultHook this.defaultHook = init?.defaultHook
} }
/**
*
* @param {RouteConfig} route - The route definition which you create with `createRoute()`.
* @param {Handler} handler - The handler. If you want to return a JSON object, you should specify the status code with `c.json()`.
* @param {Hook} hook - Optional. The hook method defines what it should do after validation.
* @example
* app.openapi(
* route,
* (c) => {
* // ...
* return c.json(
* {
* age: 20,
* name: 'Young man',
* },
* 200 // You should specify the status code even if it's 200.
* )
* },
* (result, c) => {
* if (!result.success) {
* return c.json(
* {
* code: 400,
* message: 'Custom Message',
* },
* 400
* )
* }
* }
*)
*/
openapi = < openapi = <
R extends RouteConfig, R extends RouteConfig,
I extends Input = InputTypeParam<R> & I extends Input = InputTypeParam<R> &
@ -255,20 +283,27 @@ export class OpenAPIHono<
// If response type is defined, only TypedResponse is allowed. // If response type is defined, only TypedResponse is allowed.
R extends { R extends {
responses: { responses: {
[statusCode: string]: { [statusCode: number]: {
content: { content: {
[mediaType: string]: ZodMediaTypeObject [mediaType: string]: ZodMediaTypeObject
} }
} }
} }
} }
? HandlerTypedResponse<OutputType<R>> ? RouteConfigToTypedResponse<R>
: HandlerAllResponse<OutputType<R>> : RouteConfigToTypedResponse<R> | Response | Promise<Response>
>, >,
hook: Hook<I, E, P, OutputType<R>> | undefined = this.defaultHook hook:
| Hook<
I,
E,
P,
RouteConfigToTypedResponse<R> | Response | Promise<Response> | void | Promise<void>
>
| undefined = this.defaultHook
): OpenAPIHono< ): OpenAPIHono<
E, E,
S & ToSchema<R['method'], MergePath<BasePath, P>, I['in'], OutputType<R>>, S & ToSchema<R['method'], MergePath<BasePath, P>, I, RouteConfigToTypedResponse<R>>,
BasePath BasePath
> => { > => {
this.openAPIRegistry.registerPath(route) this.openAPIRegistry.registerPath(route)
@ -357,7 +392,7 @@ export class OpenAPIHono<
try { try {
const document = this.getOpenAPIDocument(config) const document = this.getOpenAPIDocument(config)
return c.json(document) return c.json(document)
} catch (e) { } catch (e: any) {
return c.json(e, 500) return c.json(e, 500)
} }
}) as any }) as any
@ -372,7 +407,7 @@ export class OpenAPIHono<
try { try {
const document = this.getOpenAPI31Document(config) const document = this.getOpenAPI31Document(config)
return c.json(document) return c.json(document)
} catch (e) { } catch (e: any) {
return c.json(e, 500) return c.json(e, 500)
} }
}) as any }) as any

View File

@ -1,8 +1,9 @@
import type { RouteConfig } from '@asteasolutions/zod-to-openapi' import type { RouteConfig } from '@asteasolutions/zod-to-openapi'
import type { Context } from 'hono' import type { Context, TypedResponse } 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/index' import { OpenAPIHono, createRoute, z, RouteConfigToTypedResponse } from '../src/index'
import { Expect, Equal } from 'hono/utils/types'
describe('Constructor', () => { describe('Constructor', () => {
it('Should not require init object', () => { it('Should not require init object', () => {
@ -104,11 +105,14 @@ describe('Basic - params', () => {
route, route,
(c) => { (c) => {
const { id } = c.req.valid('param') const { id } = c.req.valid('param')
return c.json({ return c.json(
id, {
age: 20, id,
name: 'Ultra-man', age: 20,
}) name: 'Ultra-man',
},
200 // You should specify the status code even if it's 200.
)
}, },
(result, c) => { (result, c) => {
if (!result.success) { if (!result.success) {
@ -663,9 +667,24 @@ describe('Input types', () => {
app.openapi(route, (c) => { app.openapi(route, (c) => {
return c.json({ return c.json({
id: '123', // should be number id: '123', // should be number
message: 'Success', age: 42,
sex: 'male' as const,
name: 'Success',
}) })
}) })
// @ts-expect-error it should throw an error if the status code is wrong
app.openapi(route, (c) => {
return c.json(
{
id: 123,
age: 42,
sex: 'male' as const,
name: 'Success',
},
404
)
})
}) })
describe('Routers', () => { describe('Routers', () => {
@ -976,7 +995,7 @@ describe('With hc', () => {
// use the defaultHook // use the defaultHook
app.openapi(createPostRoute, (c) => { app.openapi(createPostRoute, (c) => {
const { title } = c.req.valid('json') const { title } = c.req.valid('json')
return c.json({ title }) return c.json({ title }, 200)
}) })
// use a routeHook // use a routeHook
@ -984,7 +1003,7 @@ describe('With hc', () => {
createBookRoute, createBookRoute,
(c) => { (c) => {
const { title } = c.req.valid('json') const { title } = c.req.valid('json')
return c.json({ title }) return c.json({ title }, 200)
}, },
(result, c) => { (result, c) => {
if (!result.success) { if (!result.success) {
@ -1389,3 +1408,84 @@ describe('Middleware', () => {
expect(res.headers.get('x-foo')).toBe('bar') expect(res.headers.get('x-foo')).toBe('bar')
}) })
}) })
describe('RouteConfigToTypedResponse', () => {
const ParamsSchema = z.object({
id: z
.string()
.min(4)
.openapi({
param: {
name: 'id',
in: 'path',
},
example: '12345',
}),
})
const UserSchema = z
.object({
name: z.string().openapi({
example: 'John Doe',
}),
age: z.number().openapi({
example: 42,
}),
})
.openapi('User')
const ErrorSchema = z
.object({
ok: z.boolean().openapi({
example: false,
}),
})
.openapi('Error')
it('Should return types correctly', () => {
const route = {
method: 'post' as any,
path: '/users/{id}',
request: {
params: ParamsSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: 'Get the user',
},
400: {
content: {
'application/json': {
schema: ErrorSchema,
},
},
description: 'Error!',
},
},
}
type Actual = RouteConfigToTypedResponse<typeof route>
type Expected =
| TypedResponse<
{
name: string
age: number
},
200,
'json'
>
| TypedResponse<
{
ok: boolean
},
400,
'json'
>
type verify = Expect<Equal<Expected, Actual>>
})
})

View File

@ -594,7 +594,7 @@ __metadata:
"@asteasolutions/zod-to-openapi": "npm:^7.0.0" "@asteasolutions/zod-to-openapi": "npm:^7.0.0"
"@cloudflare/workers-types": "npm:^4.20240117.0" "@cloudflare/workers-types": "npm:^4.20240117.0"
"@hono/zod-validator": "npm:0.2.1" "@hono/zod-validator": "npm:0.2.1"
hono: "npm:^4.2.2" hono: "npm:^4.3.6"
jest: "npm:^29.7.0" jest: "npm:^29.7.0"
openapi3-ts: "npm:^4.1.2" openapi3-ts: "npm:^4.1.2"
tsup: "npm:^8.0.1" tsup: "npm:^8.0.1"
@ -602,7 +602,7 @@ __metadata:
vitest: "npm:^1.4.0" vitest: "npm:^1.4.0"
zod: "npm:^3.22.1" zod: "npm:^3.22.1"
peerDependencies: peerDependencies:
hono: ">=3.11.3" hono: ">=4.3.6"
zod: 3.* zod: 3.*
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -2379,10 +2379,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"hono@npm:^4.2.2": "hono@npm:^4.3.6":
version: 4.2.2 version: 4.3.6
resolution: "hono@npm:4.2.2" resolution: "hono@npm:4.3.6"
checksum: 4f423ff808c105341d2802192abe9070c5f6fc4a45da51842a5f5f2111b4c333ef4aea2720a585e16996944cc0cedf68657c57df1d150f1382f21fc18e814b42 checksum: 2e27eb1e90b392a5884af573179d29e3f717f5e803c2b90f1383488f42bc986810e8e714d5bb1205935fda1d3e9944b3262aed88e852ea44d0e13d799474fa5b
languageName: node languageName: node
linkType: hard linkType: hard

View File

@ -2153,7 +2153,7 @@ __metadata:
"@asteasolutions/zod-to-openapi": "npm:^7.0.0" "@asteasolutions/zod-to-openapi": "npm:^7.0.0"
"@cloudflare/workers-types": "npm:^4.20240117.0" "@cloudflare/workers-types": "npm:^4.20240117.0"
"@hono/zod-validator": "npm:0.2.1" "@hono/zod-validator": "npm:0.2.1"
hono: "npm:^4.2.2" hono: "npm:^4.3.6"
jest: "npm:^29.7.0" jest: "npm:^29.7.0"
openapi3-ts: "npm:^4.1.2" openapi3-ts: "npm:^4.1.2"
tsup: "npm:^8.0.1" tsup: "npm:^8.0.1"
@ -2161,7 +2161,7 @@ __metadata:
vitest: "npm:^1.4.0" vitest: "npm:^1.4.0"
zod: "npm:^3.22.1" zod: "npm:^3.22.1"
peerDependencies: peerDependencies:
hono: ">=3.11.3" hono: ">=4.3.6"
zod: 3.* zod: 3.*
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -9605,13 +9605,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"hono@npm:^4.2.2":
version: 4.2.2
resolution: "hono@npm:4.2.2"
checksum: 4f423ff808c105341d2802192abe9070c5f6fc4a45da51842a5f5f2111b4c333ef4aea2720a585e16996944cc0cedf68657c57df1d150f1382f21fc18e814b42
languageName: node
linkType: hard
"hono@npm:^4.2.3": "hono@npm:^4.2.3":
version: 4.2.3 version: 4.2.3
resolution: "hono@npm:4.2.3" resolution: "hono@npm:4.2.3"
@ -9640,6 +9633,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"hono@npm:^4.3.6":
version: 4.3.6
resolution: "hono@npm:4.3.6"
checksum: 2e27eb1e90b392a5884af573179d29e3f717f5e803c2b90f1383488f42bc986810e8e714d5bb1205935fda1d3e9944b3262aed88e852ea44d0e13d799474fa5b
languageName: node
linkType: hard
"hosted-git-info@npm:^2.1.4": "hosted-git-info@npm:^2.1.4":
version: 2.8.9 version: 2.8.9
resolution: "hosted-git-info@npm:2.8.9" resolution: "hosted-git-info@npm:2.8.9"