feat(zod-openapi): support "status code" (#519)
* feat(zod-openapi): support "status code" * add changeset and tweak * fixed lock filepull/520/head
parent
fb81f4ea4a
commit
b03484ba05
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hono/zod-openapi': minor
|
||||
---
|
||||
|
||||
feat(zod-openapi): support "status code"
|
|
@ -47,4 +47,4 @@
|
|||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
|
@ -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',
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>>
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
18
yarn.lock
18
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue