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

@ -47,4 +47,4 @@
"engines": {
"node": ">=18.14.1"
}
}
}

View File

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

View File

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

View File

@ -27,7 +27,8 @@ import type {
TypedResponse,
} from 'hono'
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 type { AnyZodObject, ZodSchema, ZodError } 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 InputTypeCookie<R extends RouteConfig> = InputTypeBase<R, 'cookies', 'cookie'>
type OutputType<R extends RouteConfig> = R['responses'] extends Record<infer _, infer C>
? C extends ResponseConfig
? C['content'] extends ZodContentObject
? IsJson<keyof C['content']> extends never
? {}
: C['content'][keyof C['content']] extends Record<'schema', ZodSchema>
? z.infer<C['content'][keyof C['content']]['schema']>
: {}
: {}
: {}
: {}
type ExtractContent<T> = T extends {
[K in keyof T]: infer A
}
? A extends Record<'schema', ZodSchema>
? z.infer<A['schema']>
: never
: 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:
| {
success: true
@ -155,25 +160,12 @@ export type Hook<T, E extends Env, P extends string, O> = (
error: ZodError
},
c: Context<E, P>
) =>
| TypedResponse<O>
| Promise<TypedResponse<T>>
| Response
| Promise<Response>
| void
| Promise<void>
) => R
type ConvertPathType<T extends string> = T extends `${infer Start}/{${infer Param}}${infer Rest}`
? `${Start}/:${Param}${ConvertPathType<Rest>}`
: 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> = {
defaultHook?: Hook<any, E, any, any>
}
@ -196,15 +188,15 @@ export type RouteHandler<
// If response type is defined, only TypedResponse is allowed.
R extends {
responses: {
[statusCode: string]: {
[statusCode: number]: {
content: {
[mediaType: string]: ZodMediaTypeObject
}
}
}
}
? HandlerTypedResponse<OutputType<R>>
: HandlerAllResponse<OutputType<R>>
? RouteConfigToTypedResponse<R>
: RouteConfigToTypedResponse<R> | Response | Promise<Response>
>
export type RouteHook<
@ -217,7 +209,12 @@ export type RouteHook<
InputTypeForm<R> &
InputTypeJson<R>,
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> =
| OpenAPIObjectConfig
@ -237,6 +234,37 @@ export class OpenAPIHono<
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 = <
R extends RouteConfig,
I extends Input = InputTypeParam<R> &
@ -255,20 +283,27 @@ export class OpenAPIHono<
// If response type is defined, only TypedResponse is allowed.
R extends {
responses: {
[statusCode: string]: {
[statusCode: number]: {
content: {
[mediaType: string]: ZodMediaTypeObject
}
}
}
}
? HandlerTypedResponse<OutputType<R>>
: HandlerAllResponse<OutputType<R>>
? RouteConfigToTypedResponse<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<
E,
S & ToSchema<R['method'], MergePath<BasePath, P>, I['in'], OutputType<R>>,
S & ToSchema<R['method'], MergePath<BasePath, P>, I, RouteConfigToTypedResponse<R>>,
BasePath
> => {
this.openAPIRegistry.registerPath(route)
@ -357,7 +392,7 @@ export class OpenAPIHono<
try {
const document = this.getOpenAPIDocument(config)
return c.json(document)
} catch (e) {
} catch (e: any) {
return c.json(e, 500)
}
}) as any
@ -372,7 +407,7 @@ export class OpenAPIHono<
try {
const document = this.getOpenAPI31Document(config)
return c.json(document)
} catch (e) {
} catch (e: any) {
return c.json(e, 500)
}
}) as any

View File

@ -1,8 +1,9 @@
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 { 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', () => {
it('Should not require init object', () => {
@ -104,11 +105,14 @@ describe('Basic - params', () => {
route,
(c) => {
const { id } = c.req.valid('param')
return c.json({
id,
age: 20,
name: 'Ultra-man',
})
return c.json(
{
id,
age: 20,
name: 'Ultra-man',
},
200 // You should specify the status code even if it's 200.
)
},
(result, c) => {
if (!result.success) {
@ -663,9 +667,24 @@ describe('Input types', () => {
app.openapi(route, (c) => {
return c.json({
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', () => {
@ -976,7 +995,7 @@ describe('With hc', () => {
// use the defaultHook
app.openapi(createPostRoute, (c) => {
const { title } = c.req.valid('json')
return c.json({ title })
return c.json({ title }, 200)
})
// use a routeHook
@ -984,7 +1003,7 @@ describe('With hc', () => {
createBookRoute,
(c) => {
const { title } = c.req.valid('json')
return c.json({ title })
return c.json({ title }, 200)
},
(result, c) => {
if (!result.success) {
@ -1389,3 +1408,84 @@ describe('Middleware', () => {
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"
"@cloudflare/workers-types": "npm:^4.20240117.0"
"@hono/zod-validator": "npm:0.2.1"
hono: "npm:^4.2.2"
hono: "npm:^4.3.6"
jest: "npm:^29.7.0"
openapi3-ts: "npm:^4.1.2"
tsup: "npm:^8.0.1"
@ -602,7 +602,7 @@ __metadata:
vitest: "npm:^1.4.0"
zod: "npm:^3.22.1"
peerDependencies:
hono: ">=3.11.3"
hono: ">=4.3.6"
zod: 3.*
languageName: unknown
linkType: soft
@ -2379,10 +2379,10 @@ __metadata:
languageName: node
linkType: hard
"hono@npm:^4.2.2":
version: 4.2.2
resolution: "hono@npm:4.2.2"
checksum: 4f423ff808c105341d2802192abe9070c5f6fc4a45da51842a5f5f2111b4c333ef4aea2720a585e16996944cc0cedf68657c57df1d150f1382f21fc18e814b42
"hono@npm:^4.3.6":
version: 4.3.6
resolution: "hono@npm:4.3.6"
checksum: 2e27eb1e90b392a5884af573179d29e3f717f5e803c2b90f1383488f42bc986810e8e714d5bb1205935fda1d3e9944b3262aed88e852ea44d0e13d799474fa5b
languageName: node
linkType: hard

View File

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