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 * changesetpull/688/head
parent
5facd8cd25
commit
a6ec008fbd
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/zod-openapi': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: don't validate the body if content-type is mismatched
|
Binary file not shown.
|
@ -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",
|
||||||
|
|
|
@ -54,12 +54,12 @@ type RequestTypes = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type IsJson<T> = T extends string
|
type IsJson<T> = T extends string
|
||||||
? T extends `application/${infer Start}json${infer _End}`
|
? T extends `application/${infer Start}json${infer _End}`
|
||||||
? Start extends '' | `${string}+` | `vnd.${string}+`
|
? Start extends '' | `${string}+` | `vnd.${string}+`
|
||||||
? 'json'
|
? 'json'
|
||||||
: never
|
: never
|
||||||
: never
|
|
||||||
: never
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
type IsForm<T> = T extends string
|
type IsForm<T> = T extends string
|
||||||
? T extends
|
? T extends
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.req.addValidatedData('json', {})
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
validators.push(mw)
|
||||||
}
|
}
|
||||||
if (
|
if (isFormContentType(mediaType)) {
|
||||||
mediaType.startsWith('multipart/form-data') ||
|
|
||||||
mediaType.startsWith('application/x-www-form-urlencoded')
|
|
||||||
) {
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>>
|
||||||
|
|
|
@ -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'
|
||||||
|
@ -450,7 +450,7 @@ describe('JSON', () => {
|
||||||
const app = new OpenAPIHono()
|
const app = new OpenAPIHono()
|
||||||
|
|
||||||
app.openapi(route, (c) => {
|
app.openapi(route, (c) => {
|
||||||
const {id, title} = c.req.valid('json')
|
const { id, title } = c.req.valid('json')
|
||||||
return c.json({
|
return c.json({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
@ -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', () => {
|
||||||
|
@ -519,7 +527,7 @@ describe('JSON', () => {
|
||||||
const app = new OpenAPIHono()
|
const app = new OpenAPIHono()
|
||||||
|
|
||||||
app.openapi(route, (c) => {
|
app.openapi(route, (c) => {
|
||||||
const {id, title} = c.req.valid('json')
|
const { id, title } = c.req.valid('json')
|
||||||
return c.json({
|
return c.json({
|
||||||
id,
|
id,
|
||||||
title,
|
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', {
|
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue