feat(zod-openapi): use z.input to infer input types of the request (#286)

* fix(zod-openapi): vite typecheck for type testing

* feat(zod-openapi): use z.input to infer types for inputs of the input
pull/289/head
Phil Chen 2023-12-05 17:15:06 +08:00 committed by GitHub
parent 7cd3fd40c8
commit 8178ba094f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 277 additions and 56 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/zod-openapi': patch
---
use z.input to infer input types of the request

View File

@ -9,7 +9,7 @@
"dist" "dist"
], ],
"scripts": { "scripts": {
"test": "vitest run", "test": "vitest run && vitest typecheck --run --passWithNoTests",
"build": "tsup ./src/index.ts --format esm,cjs --dts", "build": "tsup ./src/index.ts --format esm,cjs --dts",
"publint": "publint", "publint": "publint",
"release": "yarn build && yarn test && yarn publint && yarn publish" "release": "yarn build && yarn test && yarn publint && yarn publish"

View File

@ -65,7 +65,7 @@ type InputTypeBase<
> = R['request'] extends RequestTypes > = R['request'] extends RequestTypes
? RequestPart<R, Part> extends AnyZodObject ? RequestPart<R, Part> extends AnyZodObject
? { ? {
in: { [K in Type]: z.infer<RequestPart<R, Part>> } in: { [K in Type]: z.input<RequestPart<R, Part>> }
out: { [K in Type]: z.output<RequestPart<R, Part>> } out: { [K in Type]: z.output<RequestPart<R, Part>> }
} }
: {} : {}
@ -79,7 +79,7 @@ type InputTypeJson<R extends RouteConfig> = R['request'] extends RequestTypes
: R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] extends ZodSchema<any> : R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] extends ZodSchema<any>
? { ? {
in: { in: {
json: z.infer< json: z.input<
R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
> >
} }
@ -102,7 +102,7 @@ type InputTypeForm<R extends RouteConfig> = R['request'] extends RequestTypes
: R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] extends ZodSchema<any> : R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] extends ZodSchema<any>
? { ? {
in: { in: {
form: z.infer< form: z.input<
R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
> >
} }

View File

@ -1,6 +1,6 @@
/* eslint-disable node/no-extraneous-import */ /* eslint-disable node/no-extraneous-import */
import { describe, it, expect, expectTypeOf } from 'vitest' import { describe, it, expect, expectTypeOf } from 'vitest'
import { createRoute, z } from '../src' import { createRoute, z } from '../src/index'
describe('createRoute', () => { describe('createRoute', () => {
it.each([ it.each([

View File

@ -0,0 +1,166 @@
import type { Hono, Env, ToSchema } from 'hono'
import { describe, it, expectTypeOf, assertType } from 'vitest'
import { OpenAPIHono, createRoute, z } from '../src/index'
describe('Types', () => {
const RequestSchema = z.object({
id: z.number().openapi({}),
title: z.string().openapi({}),
})
const PostSchema = z
.object({
id: z.number().openapi({}),
message: z.string().openapi({}),
})
.openapi('Post')
const route = createRoute({
method: 'post',
path: '/posts',
request: {
body: {
content: {
'application/json': {
schema: RequestSchema,
},
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: PostSchema,
},
},
description: 'Post a post',
},
},
})
const app = new OpenAPIHono()
const appRoutes = app.openapi(route, (c) => {
const data = c.req.valid('json')
assertType<number>(data.id)
return c.jsonT({
id: data.id,
message: 'Success',
})
})
it('Should return correct types', () => {
type H = Hono<
Env,
ToSchema<
'post',
'/posts',
{
json: {
title: string
id: number
}
},
{
id: number
message: string
}
>,
'/'
>
expectTypeOf(appRoutes).toMatchTypeOf<H>()
})
})
describe('Input types', () => {
const ParamsSchema = z.object({
id: z.string().transform(Number).openapi({
param: {
name: 'id',
in: 'path',
},
example: 123,
}),
})
const QuerySchema = z.object({
age: z.string().transform(Number).openapi({
param: {
name: 'age',
in: 'query',
},
example: 42
}),
})
const BodySchema = z.object({
sex: z.enum(['male', 'female']).openapi({})
}).openapi('User')
const UserSchema = z
.object({
id: z.number().openapi({
example: 123,
}),
name: z.string().openapi({
example: 'John Doe',
}),
age: z.number().openapi({
example: 42,
}),
sex: z.enum(['male', 'female']).openapi({
example: 'male',
})
})
.openapi('User')
const route = createRoute({
method: 'patch',
path: '/users/{id}',
request: {
params: ParamsSchema,
query: QuerySchema,
body: {
content: {
'application/json': {
schema: BodySchema,
},
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: 'Update a user',
},
},
})
it('Should return correct types', () => {
const app = new OpenAPIHono()
app.openapi(route, (c) => {
const { id } = c.req.valid('param')
assertType<number>(id)
const { age } = c.req.valid('query')
assertType<number>(age)
const { sex } = c.req.valid('json')
assertType<'male' | 'female'>(sex)
return c.jsonT({
id,
age,
sex,
name: 'Ultra-man',
})
})
})
})

View File

@ -1,10 +1,8 @@
/* eslint-disable node/no-extraneous-import */
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { RouteConfig } from '@asteasolutions/zod-to-openapi' import type { RouteConfig } from '@asteasolutions/zod-to-openapi'
import type { Hono, Env, ToSchema, Context } from 'hono' import type { Hono, Env, ToSchema, Context } 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' import { OpenAPIHono, createRoute, z } from '../src/index'
describe('Constructor', () => { describe('Constructor', () => {
it('Should not require init object', () => { it('Should not require init object', () => {
@ -20,7 +18,7 @@ describe('Constructor', () => {
it('Should accept a defaultHook', () => { it('Should accept a defaultHook', () => {
type FakeEnv = { Variables: { fake: string }; Bindings: { other: number } } type FakeEnv = { Variables: { fake: string }; Bindings: { other: number } }
const app = new OpenAPIHono<FakeEnv>({ const app = new OpenAPIHono<FakeEnv>({
defaultHook: (result, c) => { defaultHook: (_result, c) => {
// Make sure we're passing context types through // Make sure we're passing context types through
expectTypeOf(c).toMatchTypeOf<Context<FakeEnv, any, any>>() expectTypeOf(c).toMatchTypeOf<Context<FakeEnv, any, any>>()
}, },
@ -301,12 +299,10 @@ describe('Header', () => {
const app = new OpenAPIHono() const app = new OpenAPIHono()
const controller = (c) => { app.openapi(route, (c) => {
const headerData = c.req.valid('header') const headerData = c.req.valid('header')
return c.jsonT(headerData) return c.jsonT(headerData)
} })
app.openapi(route, controller)
it('Should return 200 response with correct contents', async () => { it('Should return 200 response with correct contents', async () => {
const res = await app.request('/pong', { const res = await app.request('/pong', {
@ -554,27 +550,58 @@ describe('Form', () => {
}) })
}) })
describe('Types', () => { describe('Input types', () => {
const RequestSchema = z.object({ const ParamsSchema = z.object({
id: z.number().openapi({}), id: z.string().transform(Number).openapi({
title: z.string().openapi({}), param: {
name: 'id',
in: 'path',
},
example: 123,
}),
}) })
const PostSchema = z const QuerySchema = z.object({
.object({ age: z.string().transform(Number).openapi({
id: z.number().openapi({}), param: {
message: z.string().openapi({}), name: 'age',
in: 'query',
},
example: 42
}),
}) })
.openapi('Post')
const BodySchema = z.object({
sex: z.enum(['male', 'female']).openapi({})
}).openapi('User')
const UserSchema = z
.object({
id: z.number().openapi({
example: 123,
}),
name: z.string().openapi({
example: 'John Doe',
}),
age: z.number().openapi({
example: 42,
}),
sex: z.enum(['male', 'female']).openapi({
example: 'male',
})
})
.openapi('User')
const route = createRoute({ const route = createRoute({
method: 'post', method: 'patch',
path: '/posts', path: '/users/{id}',
request: { request: {
params: ParamsSchema,
query: QuerySchema,
body: { body: {
content: { content: {
'application/json': { 'application/json': {
schema: RequestSchema, schema: BodySchema,
}, },
}, },
}, },
@ -583,44 +610,44 @@ describe('Types', () => {
200: { 200: {
content: { content: {
'application/json': { 'application/json': {
schema: PostSchema, schema: UserSchema,
}, },
}, },
description: 'Post a post', description: 'Update a user',
}, },
}, },
}) })
const app = new OpenAPIHono() const app = new OpenAPIHono()
const appRoutes = app.openapi(route, (c) => { app.openapi(route, (c) => {
const data = c.req.valid('json') const { id } = c.req.valid('param')
const { age } = c.req.valid('query')
const { sex } = c.req.valid('json')
return c.jsonT({ return c.jsonT({
id: data.id, id,
message: 'Success', age,
sex,
name: 'Ultra-man',
}) })
}) })
it('Should return correct types', () => { it('Should return 200 response with correct typed contents', async () => {
type H = Hono< const res = await app.request('/users/123?age=42', {
Env, method: 'PATCH',
ToSchema< body: JSON.stringify({ sex: 'male' }),
'post', headers: {
'/posts', 'Content-Type': 'application/json',
{
json: {
title: string
id: number
}
}, },
{ })
id: number expect(res.status).toBe(200)
message: string expect(await res.json()).toEqual({
} id: 123,
>, age: 42,
'/' sex: 'male',
> name: 'Ultra-man'
expectTypeOf(appRoutes).toMatchTypeOf<H> })
}) })
// @ts-expect-error it should throw an error if the types are wrong // @ts-expect-error it should throw an error if the types are wrong
@ -1018,7 +1045,7 @@ describe('Path normalization', () => {
}) })
} }
const handler = (c) => c.body(null, 204) const handler = (c: Context) => c.body(null, 204)
describe('Duplicate slashes in the root path', () => { describe('Duplicate slashes in the root path', () => {
const app = createRootApp() const app = createRootApp()
@ -1157,7 +1184,7 @@ describe('Context can be accessible in the doc route', () => {
})) }))
it('Should return with the title set as specified in env', async () => { it('Should return with the title set as specified in env', async () => {
const res = await app.request('/doc', {}, { TITLE: 'My API' }) const res = await app.request('/doc', undefined, { TITLE: 'My API' })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
openapi: '3.0.0', openapi: '3.0.0',

View File

@ -1,11 +1,10 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"skipLibCheck": false,
"rootDir": "./src", "rootDir": "./src",
}, },
"include": [ "include": [
"src/**/*.ts" "src/",
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules",

View File

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": [
"vitest/globals",
],
},
"include": [
"src/",
"test/"
],
}

View File

@ -0,0 +1,12 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
export default defineConfig({
test: {
globals: true,
typecheck: {
tsconfig: './tsconfig.vitest.json',
},
},
})