diff --git a/.changeset/orange-ducks-itch.md b/.changeset/orange-ducks-itch.md new file mode 100644 index 00000000..ac0fd492 --- /dev/null +++ b/.changeset/orange-ducks-itch.md @@ -0,0 +1,5 @@ +--- +'@hono/zod-openapi': patch +--- + +fix: don't validate the body if content-type is mismatched diff --git a/packages/zod-openapi/.yarn/install-state.gz b/packages/zod-openapi/.yarn/install-state.gz index 915e0732..9f30ae64 100644 Binary files a/packages/zod-openapi/.yarn/install-state.gz and b/packages/zod-openapi/.yarn/install-state.gz differ diff --git a/packages/zod-openapi/package.json b/packages/zod-openapi/package.json index 9a4c677f..99fc7c59 100644 --- a/packages/zod-openapi/package.json +++ b/packages/zod-openapi/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20240117.0", - "hono": "^4.3.6", + "hono": "^4.5.4", "jest": "^29.7.0", "tsup": "^8.0.1", "typescript": "^5.4.4", diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index 81050ca5..cc47f15c 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -54,12 +54,12 @@ type RequestTypes = { } type IsJson = T extends string - ? T extends `application/${infer Start}json${infer _End}` - ? Start extends '' | `${string}+` | `vnd.${string}+` - ? 'json' - : never - : never + ? T extends `application/${infer Start}json${infer _End}` + ? Start extends '' | `${string}+` | `vnd.${string}+` + ? 'json' + : never : never + : never type IsForm = T extends string ? T extends @@ -259,6 +259,17 @@ export type OpenAPIObjectConfigure = | OpenAPIObjectConfig | ((context: Context) => OpenAPIObjectConfig) +const isJSONContentType = (contentType: string) => { + return /^application\/([a-z-\.]+\+)?json/.test(contentType) +} + +const isFormContentType = (contentType: string) => { + return ( + contentType.startsWith('multipart/form-data') || + contentType.startsWith('application/x-www-form-urlencoded') + ) +} + export class OpenAPIHono< E extends Env = Env, S extends Schema = {}, @@ -390,16 +401,31 @@ export class OpenAPIHono< if (!(schema instanceof ZodType)) { continue } - if (/^application\/([a-z-\.]+\+)?json/.test(mediaType)) { + if (isJSONContentType(mediaType)) { const validator = zValidator('json', schema, hook as any) - validators.push(validator as any) + const mw: MiddlewareHandler = async (c, next) => { + if (c.req.header('content-type')) { + if (isJSONContentType(c.req.header('content-type')!)) { + return await validator(c, next) + } + } + c.req.addValidatedData('json', {}) + await next() + } + validators.push(mw) } - if ( - mediaType.startsWith('multipart/form-data') || - mediaType.startsWith('application/x-www-form-urlencoded') - ) { + if (isFormContentType(mediaType)) { const validator = zValidator('form', schema, hook as any) - validators.push(validator as any) + const mw: MiddlewareHandler = async (c, next) => { + if (c.req.header('content-type')) { + if (isFormContentType(c.req.header('content-type')!)) { + return await validator(c, next) + } + } + c.req.addValidatedData('form', {}) + await next() + } + validators.push(mw) } } } diff --git a/packages/zod-openapi/test/index.test-d.ts b/packages/zod-openapi/test/index.test-d.ts index 22f5d77b..b9f2df26 100644 --- a/packages/zod-openapi/test/index.test-d.ts +++ b/packages/zod-openapi/test/index.test-d.ts @@ -228,7 +228,7 @@ describe('coerce', () => { type Actual = ExtractSchema['/api/users/:id']['$get']['input'] type Expected = { param: { - id: string | undefined + id: string } } type verify = Expect> diff --git a/packages/zod-openapi/test/index.test.ts b/packages/zod-openapi/test/index.test.ts index f7945d2b..15fed599 100644 --- a/packages/zod-openapi/test/index.test.ts +++ b/packages/zod-openapi/test/index.test.ts @@ -2,7 +2,7 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi' import type { Context, TypedResponse } from 'hono' import { bearerAuth } from 'hono/bearer-auth' import { hc } from 'hono/client' -import { describe, expect, expectTypeOf, it } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import type { RouteConfigToTypedResponse } from '../src/index' import { OpenAPIHono, createRoute, z } from '../src/index' import type { Equal, Expect } from 'hono/utils/types' @@ -450,7 +450,7 @@ describe('JSON', () => { const app = new OpenAPIHono() app.openapi(route, (c) => { - const {id, title} = c.req.valid('json') + const { id, title } = c.req.valid('json') return c.json({ id, title, @@ -489,6 +489,14 @@ describe('JSON', () => { const res = await app.request(req) expect(res.status).toBe(400) }) + + it('Should return 200 response without a content-type', async () => { + const req = new Request('http://localhost/posts', { + method: 'POST', + }) + const res = await app.request(req) + expect(res.status).toBe(200) + }) }) describe('Content-Type application/vnd.api+json', () => { @@ -519,7 +527,7 @@ describe('JSON', () => { const app = new OpenAPIHono() app.openapi(route, (c) => { - const {id, title} = c.req.valid('json') + const { id, title } = c.req.valid('json') return c.json({ id, title, @@ -641,12 +649,76 @@ describe('Form', () => { }) }) - it('Should return 400 response with correct contents', async () => { + it('Should return 200 response without a content-type', async () => { const req = new Request('http://localhost/posts', { method: 'POST', }) const res = await app.request(req) - expect(res.status).toBe(400) + expect(res.status).toBe(200) + }) +}) + +describe('JSON and Form', () => { + const functionInForm = vi.fn() + const functionInJSON = vi.fn() + const route = createRoute({ + method: 'post', + path: '/hello', + request: { + body: { + content: { + 'application/x-www-form-urlencoded': { + schema: z.custom(() => { + functionInForm() + return true + }), + }, + 'application/json': { + schema: z.custom(() => { + functionInJSON() + return true + }), + }, + }, + }, + }, + responses: { + 200: { + description: 'response', + }, + }, + }) + + const app = new OpenAPIHono() + + app.openapi(route, (c) => { + return c.json(0) + }) + + test('functionInJSON should not be called when the body is Form', async () => { + const form = new FormData() + form.append('foo', 'foo') + await app.request('/hello', { + method: 'POST', + body: form, + }) + expect(functionInForm).toHaveBeenCalled() + expect(functionInJSON).not.toHaveBeenCalled() + functionInForm.mockReset() + functionInJSON.mockReset() + }) + test('functionInForm should not be called when the body is JSON', async () => { + await app.request('/hello', { + method: 'POST', + body: JSON.stringify({ foo: 'foo' }), + headers: { + 'content-type': 'application/json', + }, + }) + expect(functionInForm).not.toHaveBeenCalled() + expect(functionInJSON).toHaveBeenCalled() + functionInForm.mockReset() + functionInJSON.mockReset() }) }) diff --git a/packages/zod-openapi/yarn.lock b/packages/zod-openapi/yarn.lock index 5b43ad25..e2d8b121 100644 --- a/packages/zod-openapi/yarn.lock +++ b/packages/zod-openapi/yarn.lock @@ -594,7 +594,7 @@ __metadata: "@asteasolutions/zod-to-openapi": "npm:^7.1.0" "@cloudflare/workers-types": "npm:^4.20240117.0" "@hono/zod-validator": "npm:0.2.2" - hono: "npm:^4.3.6" + hono: "npm:^4.5.4" jest: "npm:^29.7.0" tsup: "npm:^8.0.1" typescript: "npm:^5.4.4" @@ -2379,10 +2379,10 @@ __metadata: languageName: node linkType: hard -"hono@npm:^4.3.6": - version: 4.4.6 - resolution: "hono@npm:4.4.6" - checksum: 065318f3fe021320b59f3daddacf7d74bfc3303de55f415a999b6967a9f09714e136528bc86cc880a45633cd85dec5428e41e902b5e3a3809f3cd17204302668 +"hono@npm:^4.5.4": + version: 4.5.4 + resolution: "hono@npm:4.5.4" + checksum: 980accb9567fe5ba4533c1378d175a95a0eef0a1fa8a03ceb1f9296d88d4ee8ff0e1447f5e69eb56150a8b6cc66f4a663ec9d1c1135f005f44f61353b58e276f languageName: node linkType: hard