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"
|
"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"
|
||||||
|
|
|
@ -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']
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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([
|
||||||
|
|
|
@ -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 { 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',
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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