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 inputpull/289/head
parent
7cd3fd40c8
commit
8178ba094f
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hono/zod-openapi': patch
|
||||
---
|
||||
|
||||
use z.input to infer input types of the request
|
|
@ -9,7 +9,7 @@
|
|||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test": "vitest run && vitest typecheck --run --passWithNoTests",
|
||||
"build": "tsup ./src/index.ts --format esm,cjs --dts",
|
||||
"publint": "publint",
|
||||
"release": "yarn build && yarn test && yarn publint && yarn publish"
|
||||
|
|
|
@ -65,7 +65,7 @@ type InputTypeBase<
|
|||
> = R['request'] extends RequestTypes
|
||||
? 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>> }
|
||||
}
|
||||
: {}
|
||||
|
@ -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>
|
||||
? {
|
||||
in: {
|
||||
json: z.infer<
|
||||
json: z.input<
|
||||
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>
|
||||
? {
|
||||
in: {
|
||||
form: z.infer<
|
||||
form: z.input<
|
||||
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
|
||||
>
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable node/no-extraneous-import */
|
||||
import { describe, it, expect, expectTypeOf } from 'vitest'
|
||||
import { createRoute, z } from '../src'
|
||||
import { createRoute, z } from '../src/index'
|
||||
|
||||
describe('createRoute', () => {
|
||||
it.each([
|
||||
|
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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 { Hono, Env, ToSchema, Context } from 'hono'
|
||||
import { hc } from 'hono/client'
|
||||
import { describe, it, expect, expectTypeOf } from 'vitest'
|
||||
import { OpenAPIHono, createRoute, z } from '../src'
|
||||
import { OpenAPIHono, createRoute, z } from '../src/index'
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('Should not require init object', () => {
|
||||
|
@ -20,7 +18,7 @@ describe('Constructor', () => {
|
|||
it('Should accept a defaultHook', () => {
|
||||
type FakeEnv = { Variables: { fake: string }; Bindings: { other: number } }
|
||||
const app = new OpenAPIHono<FakeEnv>({
|
||||
defaultHook: (result, c) => {
|
||||
defaultHook: (_result, c) => {
|
||||
// Make sure we're passing context types through
|
||||
expectTypeOf(c).toMatchTypeOf<Context<FakeEnv, any, any>>()
|
||||
},
|
||||
|
@ -301,12 +299,10 @@ describe('Header', () => {
|
|||
|
||||
const app = new OpenAPIHono()
|
||||
|
||||
const controller = (c) => {
|
||||
app.openapi(route, (c) => {
|
||||
const headerData = c.req.valid('header')
|
||||
return c.jsonT(headerData)
|
||||
}
|
||||
|
||||
app.openapi(route, controller)
|
||||
})
|
||||
|
||||
it('Should return 200 response with correct contents', async () => {
|
||||
const res = await app.request('/pong', {
|
||||
|
@ -554,27 +550,58 @@ describe('Form', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Types', () => {
|
||||
const RequestSchema = z.object({
|
||||
id: z.number().openapi({}),
|
||||
title: z.string().openapi({}),
|
||||
describe('Input types', () => {
|
||||
const ParamsSchema = z.object({
|
||||
id: z.string().transform(Number).openapi({
|
||||
param: {
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
},
|
||||
example: 123,
|
||||
}),
|
||||
})
|
||||
|
||||
const PostSchema = z
|
||||
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({}),
|
||||
message: z.string().openapi({}),
|
||||
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('Post')
|
||||
.openapi('User')
|
||||
|
||||
const route = createRoute({
|
||||
method: 'post',
|
||||
path: '/posts',
|
||||
method: 'patch',
|
||||
path: '/users/{id}',
|
||||
request: {
|
||||
params: ParamsSchema,
|
||||
query: QuerySchema,
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: RequestSchema,
|
||||
schema: BodySchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -583,44 +610,44 @@ describe('Types', () => {
|
|||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: PostSchema,
|
||||
schema: UserSchema,
|
||||
},
|
||||
},
|
||||
description: 'Post a post',
|
||||
description: 'Update a user',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const app = new OpenAPIHono()
|
||||
|
||||
const appRoutes = app.openapi(route, (c) => {
|
||||
const data = c.req.valid('json')
|
||||
app.openapi(route, (c) => {
|
||||
const { id } = c.req.valid('param')
|
||||
const { age } = c.req.valid('query')
|
||||
const { sex } = c.req.valid('json')
|
||||
|
||||
return c.jsonT({
|
||||
id: data.id,
|
||||
message: 'Success',
|
||||
id,
|
||||
age,
|
||||
sex,
|
||||
name: 'Ultra-man',
|
||||
})
|
||||
})
|
||||
|
||||
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>
|
||||
it('Should return 200 response with correct typed contents', async () => {
|
||||
const res = await app.request('/users/123?age=42', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sex: 'male' }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
id: 123,
|
||||
age: 42,
|
||||
sex: 'male',
|
||||
name: 'Ultra-man'
|
||||
})
|
||||
})
|
||||
|
||||
// @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', () => {
|
||||
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 () => {
|
||||
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(await res.json()).toEqual({
|
||||
openapi: '3.0.0',
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"skipLibCheck": false,
|
||||
"rootDir": "./src",
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
"src/",
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
],
|
||||
},
|
||||
"include": [
|
||||
"src/",
|
||||
"test/"
|
||||
],
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference types="vitest" />
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
typecheck: {
|
||||
tsconfig: './tsconfig.vitest.json',
|
||||
},
|
||||
},
|
||||
})
|
Loading…
Reference in New Issue