fix(zod-openapi): use `z.output` for types after validation (#164)

* fix(zod-openapi): use `z.output` for types after validation

* changeset
pull/165/head
Yusuke Wada 2023-09-20 06:15:33 +09:00 committed by GitHub
parent 55373edb67
commit 62a97fda6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 61 additions and 39 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/zod-openapi': patch
---
fix(zod-openapi): use `z.output` for types after validation

View File

@ -31,7 +31,7 @@
"zod": "3.*" "zod": "3.*"
}, },
"devDependencies": { "devDependencies": {
"hono": "^3.5.8", "hono": "^3.6.3",
"zod": "^3.22.1" "zod": "^3.22.1"
}, },
"dependencies": { "dependencies": {

View File

@ -6,7 +6,11 @@ import type {
ZodContentObject, ZodContentObject,
ZodRequestBody, ZodRequestBody,
} from '@asteasolutions/zod-to-openapi' } from '@asteasolutions/zod-to-openapi'
import { OpenApiGeneratorV3, OpenApiGeneratorV31, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi' import {
OpenApiGeneratorV3,
OpenApiGeneratorV31,
OpenAPIRegistry,
} from '@asteasolutions/zod-to-openapi'
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi' import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'
import type { OpenAPIObjectConfig } from '@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator' import type { OpenAPIObjectConfig } from '@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator'
import { zValidator } from '@hono/zod-validator' import { zValidator } from '@hono/zod-validator'
@ -60,7 +64,7 @@ type InputTypeBase<
? RequestPart<R, Part> extends AnyZodObject ? RequestPart<R, Part> extends AnyZodObject
? { ? {
in: { [K in Type]: z.input<RequestPart<R, Part>> } in: { [K in Type]: z.input<RequestPart<R, Part>> }
out: { [K in Type]: z.input<RequestPart<R, Part>> } out: { [K in Type]: z.output<RequestPart<R, Part>> }
} }
: {} : {}
: {} : {}
@ -78,7 +82,7 @@ type InputTypeJson<R extends RouteConfig> = R['request'] extends RequestTypes
> >
} }
out: { out: {
json: z.input< json: z.output<
R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
> >
} }
@ -101,7 +105,7 @@ type InputTypeForm<R extends RouteConfig> = R['request'] extends RequestTypes
> >
} }
out: { out: {
form: z.input< form: z.output<
R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
> >
} }
@ -147,7 +151,7 @@ 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]; type HonoInit = ConstructorParameters<typeof Hono>[0]
export class OpenAPIHono< export class OpenAPIHono<
E extends Env = Env, E extends Env = Env,
@ -281,33 +285,26 @@ export class OpenAPIHono<
app.openAPIRegistry.definitions.forEach((def) => { app.openAPIRegistry.definitions.forEach((def) => {
switch (def.type) { switch (def.type) {
case 'component': case 'component':
return this.openAPIRegistry.registerComponent( return this.openAPIRegistry.registerComponent(def.componentType, def.name, def.component)
def.componentType,
def.name,
def.component
)
case 'route': case 'route':
return this.openAPIRegistry.registerPath({ return this.openAPIRegistry.registerPath({
...def.route, ...def.route,
path: `${path}${def.route.path}` path: `${path}${def.route.path}`,
}) })
case 'webhook': case 'webhook':
return this.openAPIRegistry.registerWebhook({ return this.openAPIRegistry.registerWebhook({
...def.webhook, ...def.webhook,
path: `${path}${def.webhook.path}` path: `${path}${def.webhook.path}`,
}) })
case 'schema': case 'schema':
return this.openAPIRegistry.register( return this.openAPIRegistry.register(def.schema._def.openapi._internal.refId, def.schema)
def.schema._def.openapi._internal.refId,
def.schema
)
case 'parameter': case 'parameter':
return this.openAPIRegistry.registerParameter( return this.openAPIRegistry.registerParameter(
def.schema._def.openapi._internal.refId, def.schema._def.openapi._internal.refId,
def.schema def.schema
) )
@ -323,7 +320,9 @@ export class OpenAPIHono<
} }
} }
type RoutingPath<P extends string> = P extends `${infer Head}/{${infer Param}}${infer Tail}` ? `${Head}/:${Param}${RoutingPath<Tail>}` : P type RoutingPath<P extends string> = P extends `${infer Head}/{${infer Param}}${infer Tail}`
? `${Head}/:${Param}${RoutingPath<Tail>}`
: P
export const createRoute = <P extends string, R extends Omit<RouteConfig, 'path'> & { path: P }>( export const createRoute = <P extends string, R extends Omit<RouteConfig, 'path'> & { path: P }>(
routeConfig: R routeConfig: R
@ -332,7 +331,7 @@ export const createRoute = <P extends string, R extends Omit<RouteConfig, 'path'
...routeConfig, ...routeConfig,
getRoutingPath(): RoutingPath<R['path']> { getRoutingPath(): RoutingPath<R['path']> {
return routeConfig.path.replaceAll(/\/{(.+?)}/g, '/:$1') as RoutingPath<P> return routeConfig.path.replaceAll(/\/{(.+?)}/g, '/:$1') as RoutingPath<P>
} },
} }
} }

View File

@ -11,8 +11,7 @@ describe('Constructor', () => {
it('Should accept init object', () => { it('Should accept init object', () => {
const getPath = () => '' const getPath = () => ''
const app = new OpenAPIHono({getPath}) const app = new OpenAPIHono({ getPath })
expect(app.getPath).toBe(getPath) expect(app.getPath).toBe(getPath)
}) })
}) })
@ -21,20 +20,31 @@ describe('Basic - params', () => {
const ParamsSchema = z.object({ const ParamsSchema = z.object({
id: z id: z
.string() .string()
.min(3) .transform((val, ctx) => {
const parsed = parseInt(val)
if (isNaN(parsed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Not a number',
})
return z.NEVER
}
return parsed
})
.openapi({ .openapi({
param: { param: {
name: 'id', name: 'id',
in: 'path', in: 'path',
}, },
example: '1212121', example: 123,
type: 'integer',
}), }),
}) })
const UserSchema = z const UserSchema = z
.object({ .object({
id: z.string().openapi({ id: z.number().openapi({
example: '123', example: 123,
}), }),
name: z.string().openapi({ name: z.string().openapi({
example: 'John Doe', example: 'John Doe',
@ -116,14 +126,14 @@ describe('Basic - params', () => {
const res = await app.request('/users/123') const res = await app.request('/users/123')
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
id: '123', id: 123,
age: 20, age: 20,
name: 'Ultra-man', name: 'Ultra-man',
}) })
}) })
it('Should return 400 response with correct contents', async () => { it('Should return 400 response with correct contents', async () => {
const res = await app.request('/users/1') const res = await app.request('/users/abc')
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(await res.json()).toEqual({ ok: false }) expect(await res.json()).toEqual({ ok: false })
}) })
@ -139,7 +149,7 @@ describe('Basic - params', () => {
User: { User: {
type: 'object', type: 'object',
properties: { properties: {
id: { type: 'string', example: '123' }, id: { type: 'number', example: 123 },
name: { type: 'string', example: 'John Doe' }, name: { type: 'string', example: 'John Doe' },
age: { type: 'number', example: 42 }, age: { type: 'number', example: 42 },
}, },
@ -158,7 +168,7 @@ describe('Basic - params', () => {
get: { get: {
parameters: [ parameters: [
{ {
schema: { type: 'string', minLength: 3, example: '1212121' }, schema: { type: 'integer', example: 123 },
required: true, required: true,
name: 'id', name: 'id',
in: 'path', in: 'path',
@ -626,23 +636,26 @@ describe('Routers', () => {
}) })
it('Should include definitions from nested routers', () => { it('Should include definitions from nested routers', () => {
const router = new OpenAPIHono().openapi(route, (ctx) => { const router = new OpenAPIHono().openapi(route, (ctx) => {
return ctx.jsonT({id: 123}) return ctx.jsonT({ id: 123 })
}) })
router.openAPIRegistry.register('Id', z.number()) router.openAPIRegistry.register('Id', z.number())
router.openAPIRegistry.registerParameter('Key', z.number().openapi({ router.openAPIRegistry.registerParameter(
param: {in: 'path'} 'Key',
})) z.number().openapi({
param: { in: 'path' },
})
)
router.openAPIRegistry.registerWebhook({ router.openAPIRegistry.registerWebhook({
method: 'post', method: 'post',
path: '/postback', path: '/postback',
responses: { responses: {
200: { 200: {
description: 'Receives a post back' description: 'Receives a post back',
} },
} },
}) })
const app = new OpenAPIHono().route('/api', router) const app = new OpenAPIHono().route('/api', router)
@ -653,7 +666,7 @@ describe('Routers', () => {
version: '1.0.0', version: '1.0.0',
}, },
}) })
expect(json.components?.schemas).toHaveProperty('Id') expect(json.components?.schemas).toHaveProperty('Id')
expect(json.components?.schemas).toHaveProperty('Post') expect(json.components?.schemas).toHaveProperty('Post')
expect(json.components?.parameters).toHaveProperty('Key') expect(json.components?.parameters).toHaveProperty('Key')

View File

@ -5864,6 +5864,11 @@ hono@^3.5.8:
resolved "https://registry.yarnpkg.com/hono/-/hono-3.5.8.tgz#9bbc412f5a54183cf2a81a36a9b9ea56da10f785" resolved "https://registry.yarnpkg.com/hono/-/hono-3.5.8.tgz#9bbc412f5a54183cf2a81a36a9b9ea56da10f785"
integrity sha512-ZipTmGfHm43q5QOEBGog2wyejyNUcicjPt0BLDQ8yz9xij/y9RYXRpR1YPxMpQqeyNM7isvpsIAe9Ems51Wq0Q== integrity sha512-ZipTmGfHm43q5QOEBGog2wyejyNUcicjPt0BLDQ8yz9xij/y9RYXRpR1YPxMpQqeyNM7isvpsIAe9Ems51Wq0Q==
hono@^3.6.3:
version "3.6.3"
resolved "https://registry.yarnpkg.com/hono/-/hono-3.6.3.tgz#0dab94a9e49dadc0f99bf8b8ffc70b223f53ab9f"
integrity sha512-8WszeHGzUm45qJy2JcCXkEFXMsAysciGGQs+fbpdUYPO2bRMbjJznZE3LX8tCXBqR4f/3e6225B3YOX6pQZWvA==
hosted-git-info@^2.1.4: hosted-git-info@^2.1.4:
version "2.8.9" version "2.8.9"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"