fix(zod-openapi): don't validate the body if content-type is mismatched (#686)

* fix(zod-openapi): don't validate the body if content-type is mismatched

* changeset
pull/688/head
Yusuke Wada 2024-08-10 16:01:19 +09:00 committed by GitHub
parent 5facd8cd25
commit a6ec008fbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 127 additions and 24 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/zod-openapi': patch
---
fix: don't validate the body if content-type is mismatched

View File

@ -42,7 +42,7 @@
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20240117.0", "@cloudflare/workers-types": "^4.20240117.0",
"hono": "^4.3.6", "hono": "^4.5.4",
"jest": "^29.7.0", "jest": "^29.7.0",
"tsup": "^8.0.1", "tsup": "^8.0.1",
"typescript": "^5.4.4", "typescript": "^5.4.4",

View File

@ -259,6 +259,17 @@ export type OpenAPIObjectConfigure<E extends Env, P extends string> =
| OpenAPIObjectConfig | OpenAPIObjectConfig
| ((context: Context<E, P>) => OpenAPIObjectConfig) | ((context: Context<E, P>) => 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< export class OpenAPIHono<
E extends Env = Env, E extends Env = Env,
S extends Schema = {}, S extends Schema = {},
@ -390,16 +401,31 @@ export class OpenAPIHono<
if (!(schema instanceof ZodType)) { if (!(schema instanceof ZodType)) {
continue continue
} }
if (/^application\/([a-z-\.]+\+)?json/.test(mediaType)) { if (isJSONContentType(mediaType)) {
const validator = zValidator('json', schema, hook as any) 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)
} }
if ( }
mediaType.startsWith('multipart/form-data') || c.req.addValidatedData('json', {})
mediaType.startsWith('application/x-www-form-urlencoded') await next()
) { }
validators.push(mw)
}
if (isFormContentType(mediaType)) {
const validator = zValidator('form', schema, hook as any) 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)
} }
} }
} }

View File

@ -228,7 +228,7 @@ describe('coerce', () => {
type Actual = ExtractSchema<typeof routes>['/api/users/:id']['$get']['input'] type Actual = ExtractSchema<typeof routes>['/api/users/:id']['$get']['input']
type Expected = { type Expected = {
param: { param: {
id: string | undefined id: string
} }
} }
type verify = Expect<Equal<Expected, Actual>> type verify = Expect<Equal<Expected, Actual>>

View File

@ -2,7 +2,7 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'
import type { Context, TypedResponse } from 'hono' import type { Context, TypedResponse } from 'hono'
import { bearerAuth } from 'hono/bearer-auth' import { bearerAuth } from 'hono/bearer-auth'
import { hc } from 'hono/client' 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 type { RouteConfigToTypedResponse } from '../src/index'
import { OpenAPIHono, createRoute, z } from '../src/index' import { OpenAPIHono, createRoute, z } from '../src/index'
import type { Equal, Expect } from 'hono/utils/types' import type { Equal, Expect } from 'hono/utils/types'
@ -489,6 +489,14 @@ describe('JSON', () => {
const res = await app.request(req) const res = await app.request(req)
expect(res.status).toBe(400) 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', () => { describe('Content-Type application/vnd.api+json', () => {
@ -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', { const req = new Request('http://localhost/posts', {
method: 'POST', method: 'POST',
}) })
const res = await app.request(req) 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()
}) })
}) })

View File

@ -594,7 +594,7 @@ __metadata:
"@asteasolutions/zod-to-openapi": "npm:^7.1.0" "@asteasolutions/zod-to-openapi": "npm:^7.1.0"
"@cloudflare/workers-types": "npm:^4.20240117.0" "@cloudflare/workers-types": "npm:^4.20240117.0"
"@hono/zod-validator": "npm:0.2.2" "@hono/zod-validator": "npm:0.2.2"
hono: "npm:^4.3.6" hono: "npm:^4.5.4"
jest: "npm:^29.7.0" jest: "npm:^29.7.0"
tsup: "npm:^8.0.1" tsup: "npm:^8.0.1"
typescript: "npm:^5.4.4" typescript: "npm:^5.4.4"
@ -2379,10 +2379,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"hono@npm:^4.3.6": "hono@npm:^4.5.4":
version: 4.4.6 version: 4.5.4
resolution: "hono@npm:4.4.6" resolution: "hono@npm:4.5.4"
checksum: 065318f3fe021320b59f3daddacf7d74bfc3303de55f415a999b6967a9f09714e136528bc86cc880a45633cd85dec5428e41e902b5e3a3809f3cd17204302668 checksum: 980accb9567fe5ba4533c1378d175a95a0eef0a1fa8a03ceb1f9296d88d4ee8ff0e1447f5e69eb56150a8b6cc66f4a663ec9d1c1135f005f44f61353b58e276f
languageName: node languageName: node
linkType: hard linkType: hard