diff --git a/.changeset/new-deer-attack.md b/.changeset/new-deer-attack.md new file mode 100644 index 00000000..05a39de4 --- /dev/null +++ b/.changeset/new-deer-attack.md @@ -0,0 +1,5 @@ +--- +'@hono/zod-validator': minor +--- + +feat: support Zod v4 diff --git a/.changeset/shiny-words-matter.md b/.changeset/shiny-words-matter.md new file mode 100644 index 00000000..d2a5599d --- /dev/null +++ b/.changeset/shiny-words-matter.md @@ -0,0 +1,5 @@ +--- +'@hono/zod-openapi': patch +--- + +fix: ignore the type error from Zod Validator diff --git a/package.json b/package.json index 4327afbf..db065154 100644 --- a/package.json +++ b/package.json @@ -45,4 +45,4 @@ "vitest": "^3.0.8" }, "packageManager": "yarn@4.0.2" -} \ No newline at end of file +} diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index 4ed40042..217687ac 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -501,7 +501,9 @@ export class OpenAPIHono< continue } if (isJSONContentType(mediaType)) { - const validator = zValidator('json', schema, hook as any) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore we can ignore the type error since Zod Validator's types are not used + const validator = zValidator('json', schema, hook) if (route.request?.body?.required) { validators.push(validator) } else { diff --git a/packages/zod-validator/README.md b/packages/zod-validator/README.md index ddd7a782..b93327f2 100644 --- a/packages/zod-validator/README.md +++ b/packages/zod-validator/README.md @@ -2,8 +2,7 @@ [![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=zod-validator)](https://codecov.io/github/honojs/middleware) -The validator middleware using [Zod](https://zod.dev) for [Hono](https://honojs.dev) applications. -You can write a schema with Zod and validate the incoming values. +The validator middleware using [Zod](https://zod.dev) for [Hono](https://honojs.dev) applications. You can write a schema with Zod and validate the incoming values. ## Usage diff --git a/packages/zod-validator/package.json b/packages/zod-validator/package.json index 9accce8e..edcb880f 100644 --- a/packages/zod-validator/package.json +++ b/packages/zod-validator/package.json @@ -49,6 +49,6 @@ "tsup": "^8.4.0", "typescript": "^5.8.2", "vitest": "^3.0.8", - "zod": "^3.22.4" + "zod": "~3.25.6" } } diff --git a/packages/zod-validator/src/index.ts b/packages/zod-validator/src/index.ts index 80d3b38c..143a2be4 100644 --- a/packages/zod-validator/src/index.ts +++ b/packages/zod-validator/src/index.ts @@ -1,7 +1,18 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import type { Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono' import { validator } from 'hono/validator' -import { ZodObject } from 'zod' -import type { SafeParseReturnType, ZodError, ZodSchema, z } from 'zod' +import type * as v3 from 'zod' +import type { ZodSafeParseResult as v4ZodSafeParseResult } from 'zod/v4' +import type * as v4 from 'zod/v4/core' + +type ZodSchema = any extends v4.$ZodType ? v3.ZodType : v3.ZodType | v4.$ZodType +type ZodError = T extends v4.$ZodType ? v4.$ZodError : v3.ZodError +type ZodSafeParseResult = T3 extends v4.$ZodType + ? v4ZodSafeParseResult + : v3.SafeParseReturnType +type zInput = T extends v3.ZodType ? v3.input : T extends v4.$ZodType ? v4.input : never +type zOutput = T extends v3.ZodType ? v3.output : T extends v4.$ZodType ? v4.output : never +type zInfer = T extends v3.ZodType ? v3.infer : T extends v4.$ZodType ? v4.infer : never export type Hook< T, @@ -9,8 +20,9 @@ export type Hook< P extends string, Target extends keyof ValidationTargets = keyof ValidationTargets, O = {}, + Schema extends ZodSchema = any, > = ( - result: ({ success: true; data: T } | { success: false; error: ZodError; data: T }) & { + result: ({ success: true; data: T } | { success: false; error: ZodError; data: T }) & { target: Target }, c: Context @@ -23,8 +35,8 @@ export const zValidator = < Target extends keyof ValidationTargets, E extends Env, P extends string, - In = z.input, - Out = z.output, + In = zInput, + Out = zOutput, I extends Input = { in: HasUndefined extends true ? { @@ -40,16 +52,16 @@ export const zValidator = < out: { [K in Target]: Out } }, V extends I = I, + InferredValue = zInfer, >( target: Target, schema: T, - hook?: Hook, E, P, Target>, + hook?: Hook, options?: { validationFunction: ( schema: T, value: ValidationTargets[Target] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => SafeParseReturnType | Promise> + ) => ZodSafeParseResult | Promise> } ): MiddlewareHandler => // @ts-expect-error not typed well @@ -58,8 +70,9 @@ export const zValidator = < // in case where our `target` === `header`, Hono parses all of the headers into lowercase. // this might not match the Zod schema, so we want to make sure that we account for that when parsing the schema. - if (target === 'header' && schema instanceof ZodObject) { + if ((target === 'header' && '_def' in schema) || (target === 'header' && '_zod' in schema)) { // create an object that maps lowercase schema keys to lowercase + // @ts-expect-error the schema is a Zod Schema const schemaKeys = Object.keys(schema.shape) const caseInsensitiveKeymap = Object.fromEntries( schemaKeys.map((key) => [key.toLowerCase(), key]) @@ -73,7 +86,8 @@ export const zValidator = < const result = options && options.validationFunction ? await options.validationFunction(schema, validatorValue) - : await schema.safeParseAsync(validatorValue) + : // @ts-expect-error z4.$ZodType has safeParseAsync + await schema.safeParseAsync(validatorValue) if (hook) { const hookResult = await hook({ data: validatorValue, ...result, target }, c) @@ -92,5 +106,5 @@ export const zValidator = < return c.json(result, 400) } - return result.data as z.infer + return result.data as zInfer }) diff --git a/packages/zod-validator/src/index.test.ts b/packages/zod-validator/src/v3.test.ts similarity index 98% rename from packages/zod-validator/src/index.test.ts rename to packages/zod-validator/src/v3.test.ts index 665d6899..6131c03b 100644 --- a/packages/zod-validator/src/index.test.ts +++ b/packages/zod-validator/src/v3.test.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono' import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { Equal, Expect } from 'hono/utils/types' import { vi } from 'vitest' -import { z } from 'zod' +import { z } from 'zod/v3' import { zValidator } from '.' // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -163,6 +163,8 @@ describe('With Hook', () => { '/post', zValidator('json', schema, (result, c) => { if (!result.success) { + type verify = Expect> + type verify2 = Expect> return c.text(`${result.data.id} is invalid!`, 400) } }), diff --git a/packages/zod-validator/src/v4.test.ts b/packages/zod-validator/src/v4.test.ts new file mode 100644 index 00000000..884e6410 --- /dev/null +++ b/packages/zod-validator/src/v4.test.ts @@ -0,0 +1,466 @@ +import { Hono } from 'hono' +import type { ContentfulStatusCode } from 'hono/utils/http-status' +import type { Equal, Expect } from 'hono/utils/types' +import { vi } from 'vitest' +import type z4 from 'zod/v4' +import { z } from 'zod/v4' +import { zValidator } from '.' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type ExtractSchema = T extends Hono ? S : never + +describe('Basic', () => { + const app = new Hono() + + const jsonSchema = z.object({ + name: z.string(), + age: z.number(), + }) + + const querySchema = z + .object({ + name: z.string().optional(), + }) + .optional() + + const route = app.post( + '/author', + zValidator('json', jsonSchema), + zValidator('query', querySchema), + (c) => { + const data = c.req.valid('json') + const query = c.req.valid('query') + + return c.json({ + success: true, + message: `${data.name} is ${data.age}`, + queryName: query?.name, + }) + } + ) + + type Actual = ExtractSchema + type Expected = { + '/author': { + $post: { + input: { + json: { + name: string + age: number + } + } & { + query?: + | { + name?: string | undefined + } + | undefined + } + output: { + success: boolean + message: string + queryName: string | undefined + } + outputFormat: 'json' + status: ContentfulStatusCode + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type verify = Expect> + + it('Should return 200 response', async () => { + const req = new Request('http://localhost/author?name=Metallo', { + body: JSON.stringify({ + name: 'Superman', + age: 20, + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + success: true, + message: 'Superman is 20', + queryName: 'Metallo', + }) + }) + + it('Should return 400 response', async () => { + const req = new Request('http://localhost/author', { + body: JSON.stringify({ + name: 'Superman', + age: '20', + }), + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + }) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(400) + const data = (await res.json()) as { success: boolean } + expect(data['success']).toBe(false) + }) +}) + +describe('coerce', () => { + const app = new Hono() + + const querySchema = z.object({ + page: z.coerce.number(), + }) + + const route = app.get('/page', zValidator('query', querySchema), (c) => { + const { page } = c.req.valid('query') + return c.json({ page }) + }) + + type Actual = ExtractSchema + type Expected = { + '/page': { + $get: { + input: { + query: { + page: string | string[] + } + } + output: { + page: number + } + outputFormat: 'json' + status: ContentfulStatusCode + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type verify = Expect> + + it('Should return 200 response', async () => { + const res = await app.request('/page?page=123') + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + page: 123, + }) + }) +}) + +describe('With Hook', () => { + const app = new Hono() + + const schema = z.object({ + id: z.number(), + title: z.string(), + }) + + app.post( + '/post', + zValidator('json', schema, (result, c) => { + if (!result.success) { + type verify = Expect> + type verify2 = Expect> + return c.text(`${result.data.id} is invalid!`, 400) + } + }), + (c) => { + const data = c.req.valid('json') + return c.text(`${data.id} is valid!`) + } + ) + + it('Should return 200 response', async () => { + const req = new Request('http://localhost/post', { + body: JSON.stringify({ + id: 123, + title: 'Hello', + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.text()).toBe('123 is valid!') + }) + + it('Should return 400 response', async () => { + const req = new Request('http://localhost/post', { + body: JSON.stringify({ + id: '123', + title: 'Hello', + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(400) + expect(await res.text()).toBe('123 is invalid!') + }) +}) + +describe('With Async Hook', () => { + const app = new Hono() + + const schema = z.object({ + id: z.number(), + title: z.string(), + }) + + app.post( + '/post', + zValidator('json', schema, async (result, c) => { + if (!result.success) { + return c.text(`${result.data.id} is invalid!`, 400) + } + }), + (c) => { + const data = c.req.valid('json') + return c.text(`${data.id} is valid!`) + } + ) + + it('Should return 200 response', async () => { + const req = new Request('http://localhost/post', { + body: JSON.stringify({ + id: 123, + title: 'Hello', + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.text()).toBe('123 is valid!') + }) + + it('Should return 400 response', async () => { + const req = new Request('http://localhost/post', { + body: JSON.stringify({ + id: '123', + title: 'Hello', + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(400) + expect(await res.text()).toBe('123 is invalid!') + }) +}) + +describe('With target', () => { + it('should call hook for correctly validated target', async () => { + const app = new Hono() + + const schema = z.object({ + id: z.string(), + }) + + const jsonHook = vi.fn() + const paramHook = vi.fn() + const queryHook = vi.fn() + app.post( + '/:id/post', + zValidator('json', schema, jsonHook), + zValidator('param', schema, paramHook), + zValidator('query', schema, queryHook), + (c) => { + return c.text('ok') + } + ) + + const req = new Request('http://localhost/1/post?id=2', { + body: JSON.stringify({ + id: '3', + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.text()).toBe('ok') + expect(paramHook).toHaveBeenCalledWith( + { data: { id: '1' }, success: true, target: 'param' }, + expect.anything() + ) + expect(queryHook).toHaveBeenCalledWith( + { data: { id: '2' }, success: true, target: 'query' }, + expect.anything() + ) + expect(jsonHook).toHaveBeenCalledWith( + { data: { id: '3' }, success: true, target: 'json' }, + expect.anything() + ) + }) +}) + +describe('Only Types', () => { + it('Should return correct enum types for query', () => { + const app = new Hono() + + const querySchema = z.object({ + order: z.enum(['asc', 'desc']), + }) + + const route = app.get('/', zValidator('query', querySchema), (c) => { + const data = c.req.valid('query') + return c.json(data) + }) + + type Actual = ExtractSchema + type Expected = { + '/': { + $get: { + input: { + query: { + order: 'asc' | 'desc' + } + } + output: { + order: 'asc' | 'desc' + } + outputFormat: 'json' + status: ContentfulStatusCode + } + } + } + type verify = Expect> + }) +}) + +describe('Case-Insensitive Headers', () => { + it('Should ignore the case for headers in the Zod schema and return 200', () => { + const app = new Hono() + const headerSchema = z.object({ + 'Content-Type': z.string(), + ApiKey: z.string(), + onlylowercase: z.string(), + ONLYUPPERCASE: z.string(), + }) + + const route = app.get('/', zValidator('header', headerSchema), (c) => { + const headers = c.req.valid('header') + return c.json(headers) + }) + + type Actual = ExtractSchema + type Expected = { + '/': { + $get: { + input: { + header: z.infer + } + output: z.infer + outputFormat: 'json' + status: ContentfulStatusCode + } + } + } + type verify = Expect> + }) +}) + +describe('With options + validationFunction', () => { + const app = new Hono() + const jsonSchema = z.object({ + name: z.string(), + age: z.number(), + }) + + const route = app + .post('/', zValidator('json', jsonSchema), (c) => { + const data = c.req.valid('json') + return c.json({ + success: true, + data, + }) + }) + .post( + '/extended', + zValidator('json', jsonSchema, undefined, { + validationFunction: async (schema, value) => { + const result = schema.safeParse(value) + return await schema.passthrough().safeParseAsync(value) + }, + }), + (c) => { + const data = c.req.valid('json') + return c.json({ + success: true, + data, + }) + } + ) + + it('Should be ok due to passthrough schema', async () => { + const req = new Request('http://localhost/extended', { + body: JSON.stringify({ + name: 'Superman', + age: 20, + length: 170, + weight: 55, + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + success: true, + data: { + name: 'Superman', + age: 20, + length: 170, + weight: 55, + }, + }) + }) + it('Should be ok due to required schema', async () => { + const req = new Request('http://localhost', { + body: JSON.stringify({ + name: 'Superman', + age: 20, + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + const res = await app.request(req) + + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + success: true, + data: { + name: 'Superman', + age: 20, + }, + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 30bd88f5..5b083eee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2402,7 +2402,7 @@ __metadata: tsup: "npm:^8.4.0" typescript: "npm:^5.8.2" vitest: "npm:^3.0.8" - zod: "npm:^3.22.4" + zod: "npm:~3.25.6" peerDependencies: hono: ">=3.9.0" zod: ^3.19.1 @@ -15153,7 +15153,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.20.2, zod@npm:^3.22.1, zod@npm:^3.22.3, zod@npm:^3.22.4": +"zod@npm:^3.20.2, zod@npm:^3.22.1, zod@npm:^3.22.3": version: 3.24.2 resolution: "zod@npm:3.24.2" checksum: c638c7220150847f13ad90635b3e7d0321b36cce36f3fc6050ed960689594c949c326dfe2c6fa87c14b126ee5d370ccdebd6efb304f41ef5557a4aaca2824565 @@ -15174,6 +15174,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:~3.25.6": + version: 3.25.6 + resolution: "zod@npm:3.25.6" + checksum: b7be69c76baa317e55496d9b4aab0090534bc2fd5ec65f8099ffb9fb460b4885dff60b1bed62cdd23e8a710e2cc82798c99f69283031b2bd8cb3cdbb9ce08399 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0": version: 2.0.4 resolution: "zwitch@npm:2.0.4"