diff --git a/.changeset/nasty-gorillas-happen.md b/.changeset/nasty-gorillas-happen.md new file mode 100644 index 00000000..bf8d4ca9 --- /dev/null +++ b/.changeset/nasty-gorillas-happen.md @@ -0,0 +1,5 @@ +--- +'@hono/zod-openapi': patch +--- + +use z.input to infer input types of the request diff --git a/packages/zod-openapi/package.json b/packages/zod-openapi/package.json index 29ca12de..dce1f1f3 100644 --- a/packages/zod-openapi/package.json +++ b/packages/zod-openapi/package.json @@ -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" diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index cbbaa38a..01fcd22f 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -65,7 +65,7 @@ type InputTypeBase< > = R['request'] extends RequestTypes ? RequestPart extends AnyZodObject ? { - in: { [K in Type]: z.infer> } + in: { [K in Type]: z.input> } out: { [K in Type]: z.output> } } : {} @@ -79,7 +79,7 @@ type InputTypeJson = R['request'] extends RequestTypes : R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] extends ZodSchema ? { in: { - json: z.infer< + json: z.input< R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] > } @@ -102,7 +102,7 @@ type InputTypeForm = R['request'] extends RequestTypes : R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] extends ZodSchema ? { in: { - form: z.infer< + form: z.input< R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] > } diff --git a/packages/zod-openapi/test/createRoute.test.ts b/packages/zod-openapi/test/createRoute.test.ts index 14fe2790..52fc9cb0 100644 --- a/packages/zod-openapi/test/createRoute.test.ts +++ b/packages/zod-openapi/test/createRoute.test.ts @@ -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([ diff --git a/packages/zod-openapi/test/index.test-d.ts b/packages/zod-openapi/test/index.test-d.ts new file mode 100644 index 00000000..d8cd0071 --- /dev/null +++ b/packages/zod-openapi/test/index.test-d.ts @@ -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(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() + }) +}) + +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(id) + + const { age } = c.req.valid('query') + assertType(age) + + const { sex } = c.req.valid('json') + assertType<'male' | 'female'>(sex) + + return c.jsonT({ + id, + age, + sex, + name: 'Ultra-man', + }) + }) + }) +}) diff --git a/packages/zod-openapi/test/index.test.ts b/packages/zod-openapi/test/index.test.ts index 9817df97..56b34276 100644 --- a/packages/zod-openapi/test/index.test.ts +++ b/packages/zod-openapi/test/index.test.ts @@ -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({ - defaultHook: (result, c) => { + defaultHook: (_result, c) => { // Make sure we're passing context types through expectTypeOf(c).toMatchTypeOf>() }, @@ -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 + 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', diff --git a/packages/zod-openapi/tsconfig.json b/packages/zod-openapi/tsconfig.json index 8eaee1b9..28c65532 100644 --- a/packages/zod-openapi/tsconfig.json +++ b/packages/zod-openapi/tsconfig.json @@ -1,14 +1,13 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "skipLibCheck": false, "rootDir": "./src", }, "include": [ - "src/**/*.ts" + "src/", ], "exclude": [ "node_modules", "dist" ] -} \ No newline at end of file +} diff --git a/packages/zod-openapi/tsconfig.vitest.json b/packages/zod-openapi/tsconfig.vitest.json new file mode 100644 index 00000000..f48fdba3 --- /dev/null +++ b/packages/zod-openapi/tsconfig.vitest.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [ + "vitest/globals", + ], + }, + "include": [ + "src/", + "test/" + ], +} diff --git a/packages/zod-openapi/vitest.config.ts b/packages/zod-openapi/vitest.config.ts new file mode 100644 index 00000000..a83bce8c --- /dev/null +++ b/packages/zod-openapi/vitest.config.ts @@ -0,0 +1,12 @@ +/// + +import { defineConfig } from 'vite' + +export default defineConfig({ + test: { + globals: true, + typecheck: { + tsconfig: './tsconfig.vitest.json', + }, + }, +}) diff --git a/tsconfig.json b/tsconfig.json index ea416791..7aaed756 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,4 +16,4 @@ "@cloudflare/workers-types" ], } -} \ No newline at end of file +}