Skip to content

Commit

Permalink
fix(zod-openapi): don't validate the body if content-type is mismatch…
Browse files Browse the repository at this point in the history
…ed (#686)

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

* changeset
  • Loading branch information
yusukebe authored Aug 10, 2024
1 parent 5facd8c commit a6ec008
Show file tree
Hide file tree
Showing 7 changed files with 127 additions and 24 deletions.
5 changes: 5 additions & 0 deletions .changeset/orange-ducks-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/zod-openapi': patch
---

fix: don't validate the body if content-type is mismatched
Binary file modified packages/zod-openapi/.yarn/install-state.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/zod-openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 38 additions & 12 deletions packages/zod-openapi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ type RequestTypes = {
}

type IsJson<T> = 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> = T extends string
? T extends
Expand Down Expand Up @@ -259,6 +259,17 @@ export type OpenAPIObjectConfigure<E extends Env, P extends string> =
| 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<
E extends Env = Env,
S extends Schema = {},
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/zod-openapi/test/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ describe('coerce', () => {
type Actual = ExtractSchema<typeof routes>['/api/users/:id']['$get']['input']
type Expected = {
param: {
id: string | undefined
id: string
}
}
type verify = Expect<Equal<Expected, Actual>>
Expand Down
82 changes: 77 additions & 5 deletions packages/zod-openapi/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
})
})

Expand Down
10 changes: 5 additions & 5 deletions packages/zod-openapi/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down

0 comments on commit a6ec008

Please sign in to comment.