Skip to content

Commit d4a6913

Browse files
authored
feat: Conform Validator Middleware (#666)
* feat: add `@hono/conform-validator` to packages * docs: Add the conform-validtor middleware usage to README.md * docs: fix README * refactor: Fix tests to use HTTPException * fix: update devDependencies in conform-validator * chore: add github workflows for conform-validator * feat: add changesets * fix: Init conform-validator version to 0.0.0 for changesets * feat: Add a hook option to `conformValidator()` * feat: Fixed the conformValidator to return an error response when a validation error occurs * fix: Fixed node version used in CI from 18.x to 20.x * fix: Fix to use tsup in build command * chore: delete `.skip` from `it` in test files. * chore: fix title in test files. * fix: Fixed to return 400 response when the request body is not FormData * chore: fixed to change patch to major in changeset. * chore: Removed unused libraries
1 parent e2ede3b commit d4a6913

17 files changed

+1534
-4
lines changed

.changeset/pretty-eels-prove.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hono/conform-validator': major
3+
---
4+
5+
Create Conform validator middleware
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: ci-conform-validator
2+
on:
3+
push:
4+
branches: [main]
5+
paths:
6+
- 'packages/conform-validator/**'
7+
pull_request:
8+
branches: ['*']
9+
paths:
10+
- 'packages/conform-validator/**'
11+
12+
jobs:
13+
ci:
14+
runs-on: ubuntu-latest
15+
defaults:
16+
run:
17+
working-directory: ./packages/conform-validator
18+
steps:
19+
- uses: actions/checkout@v4
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: 20.x
23+
- run: yarn install --frozen-lockfile
24+
- run: yarn build
25+
- run: yarn test

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"build:node-ws": "yarn workspace @hono/node-ws build",
3535
"build:react-compat": "yarn workspace @hono/react-compat build",
3636
"build:effect-validator": "yarn workspace @hono/effect-validator build",
37+
"build:conform-validator": "yarn workspace @hono/conform-validator build",
3738
"build": "run-p 'build:*'",
3839
"lint": "eslint 'packages/**/*.{ts,tsx}'",
3940
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",

packages/conform-validator/README.md

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Conform validator middleware for Hono
2+
3+
The validator middleware using [conform](https://conform.guide) for [Hono](https://honojs.dev) applications. This middleware allows you to validate submitted FormValue and making better use of [Hono RPC](https://hono.dev/docs/guides/rpc).
4+
5+
## Usage
6+
7+
Zod:
8+
9+
```ts
10+
import { z } from 'zod'
11+
import { parseWithZod } from '@conform-to/zod'
12+
import { conformValidator } from '@hono/conform-validator'
13+
import { HTTPException } from 'hono/http-exception'
14+
15+
const schema = z.object({
16+
name: z.string(),
17+
age: z.string(),
18+
})
19+
20+
app.post(
21+
'/author',
22+
conformValidator((formData) => parseWithZod(formData, { schema })),
23+
(c) => {
24+
const submission = c.req.valid('form')
25+
const data = submission.value
26+
27+
return c.json({ success: true, message: `${data.name} is ${data.age}` })
28+
}
29+
)
30+
```
31+
32+
Yup:
33+
34+
```ts
35+
import { object, string } from 'yup'
36+
import { parseWithYup } from '@conform-to/yup'
37+
import { conformValidator } from '@hono/conform-validator'
38+
import { HTTPException } from 'hono/http-exception'
39+
40+
const schema = object({
41+
name: string(),
42+
age: string(),
43+
})
44+
45+
app.post(
46+
'/author',
47+
conformValidator((formData) => parseWithYup(formData, { schema })),
48+
(c) => {
49+
const submission = c.req.valid('form')
50+
const data = submission.value
51+
return c.json({ success: true, message: `${data.name} is ${data.age}` })
52+
}
53+
)
54+
```
55+
56+
Valibot:
57+
58+
```ts
59+
import { object, string } from 'valibot'
60+
import { parseWithValibot } from 'conform-to-valibot'
61+
import { conformValidator } from '@hono/conform-validator'
62+
import { HTTPException } from 'hono/http-exception'
63+
64+
const schema = object({
65+
name: string(),
66+
age: string(),
67+
})
68+
69+
app.post(
70+
'/author',
71+
conformValidator((formData) => parseWithYup(formData, { schema })),
72+
(c) => {
73+
const submission = c.req.valid('form')
74+
const data = submission.value
75+
return c.json({ success: true, message: `${data.name} is ${data.age}` })
76+
}
77+
)
78+
```
79+
80+
## Custom Hook Option
81+
82+
By default, `conformValidator()` returns a [`SubmissionResult`](https://github.com/edmundhung/conform/blob/6b98c077d757edd4846321678dfb6de283c177b1/packages/conform-dom/submission.ts#L40-L47) when a validation error occurs. If you wish to change this behavior, or if you wish to perform common processing, you can modify the response by passing a function as the second argument.
83+
84+
```ts
85+
app.post(
86+
'/author',
87+
conformValidator(
88+
(formData) => parseWithYup(formData, { schema })
89+
(submission, c) => {
90+
if(submission.status !== 'success') {
91+
return c.json({ success: false, message: 'Bad Request' }, 400)
92+
}
93+
}
94+
),
95+
(c) => {
96+
const submission = c.req.valid('form')
97+
const data = submission.value
98+
return c.json({ success: true, message: `${data.name} is ${data.age}` })
99+
}
100+
)
101+
```
102+
103+
> [!NOTE]
104+
> if a response is returned by the Hook function, subsequent middleware or handler functions will not be executed. [see more](https://hono.dev/docs/concepts/middleware).
105+
106+
## Author
107+
108+
uttk <https://github.com/uttk>
109+
110+
## License
111+
112+
MIT
+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@hono/conform-validator",
3+
"version": "0.0.0",
4+
"description": "Validator middleware using Conform",
5+
"type": "module",
6+
"main": "dist/index.cjs",
7+
"module": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"files": [
10+
"dist"
11+
],
12+
"scripts": {
13+
"test": "vitest --run",
14+
"build": "tsup ./src/index.ts --format esm,cjs --dts",
15+
"prerelease": "yarn build && yarn test",
16+
"release": "yarn publish"
17+
},
18+
"exports": {
19+
".": {
20+
"import": {
21+
"types": "./dist/index.d.ts",
22+
"default": "./dist/index.js"
23+
},
24+
"require": {
25+
"types": "./dist/index.d.cts",
26+
"default": "./dist/index.cjs"
27+
}
28+
}
29+
},
30+
"license": "MIT",
31+
"publishConfig": {
32+
"registry": "https://registry.npmjs.org",
33+
"access": "public"
34+
},
35+
"repository": {
36+
"type": "git",
37+
"url": "https://github.com/honojs/middleware.git"
38+
},
39+
"homepage": "https://github.com/honojs/middleware",
40+
"peerDependencies": {
41+
"@conform-to/dom": ">=1.1.5",
42+
"hono": ">=4.5.1"
43+
},
44+
"devDependencies": {
45+
"@conform-to/dom": "^1.1.5",
46+
"@conform-to/yup": "^1.1.5",
47+
"@conform-to/zod": "^1.1.5",
48+
"conform-to-valibot": "^1.10.0",
49+
"hono": "^4.5.1",
50+
"tsup": "^8.2.3",
51+
"valibot": "^0.36.0",
52+
"vitest": "^2.0.4",
53+
"yup": "^1.4.0",
54+
"zod": "^3.23.8"
55+
}
56+
}
+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
3+
import type { Context, Env, Input as HonoInput, MiddlewareHandler, ValidationTargets } from 'hono'
4+
import type { Submission } from '@conform-to/dom'
5+
import { getFormDataFromContext } from './utils'
6+
7+
type FormTargetValue = ValidationTargets['form']['string']
8+
9+
type GetInput<T extends ParseFn> = T extends (_: any) => infer S
10+
? Awaited<S> extends Submission<any, any, infer V>
11+
? V
12+
: never
13+
: never
14+
15+
type GetSuccessSubmission<S> = S extends { status: 'success' } ? S : never
16+
17+
type ParseFn = (formData: FormData) => Submission<unknown> | Promise<Submission<unknown>>
18+
19+
type Hook<F extends ParseFn, E extends Env, P extends string> = (
20+
submission: Awaited<ReturnType<F>>,
21+
c: Context<E, P>
22+
) => Response | Promise<Response> | void | Promise<Response | void>
23+
24+
export const conformValidator = <
25+
F extends ParseFn,
26+
E extends Env,
27+
P extends string,
28+
In = GetInput<F>,
29+
Out = Awaited<ReturnType<F>>,
30+
I extends HonoInput = {
31+
in: {
32+
form: { [K in keyof In]: FormTargetValue }
33+
}
34+
out: { form: GetSuccessSubmission<Out> }
35+
}
36+
>(
37+
parse: F,
38+
hook?: Hook<F, E, P>
39+
): MiddlewareHandler<E, P, I> => {
40+
return async (c, next) => {
41+
const formData = await getFormDataFromContext(c)
42+
const submission = await parse(formData)
43+
44+
if (hook) {
45+
const hookResult = hook(submission as any, c)
46+
if (hookResult instanceof Response || hookResult instanceof Promise) {
47+
return hookResult
48+
}
49+
}
50+
51+
if (submission.status !== 'success') {
52+
return c.json(submission.reply(), 400)
53+
}
54+
55+
c.req.addValidatedData('form', submission)
56+
57+
await next()
58+
}
59+
}
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Context } from 'hono'
2+
import { bufferToFormData } from 'hono/utils/buffer'
3+
4+
// ref: https://github.com/honojs/hono/blob/a63bcfd6fba66297d8234c21aed8a42ac00711fe/src/validator/validator.ts#L27-L28
5+
const multipartRegex = /^multipart\/form-data(; boundary=[A-Za-z0-9'()+_,\-./:=?]+)?$/
6+
const urlencodedRegex = /^application\/x-www-form-urlencoded$/
7+
8+
export const getFormDataFromContext = async (ctx: Context): Promise<FormData> => {
9+
const contentType = ctx.req.header('Content-Type')
10+
if (!contentType || !(multipartRegex.test(contentType) || urlencodedRegex.test(contentType))) {
11+
return new FormData()
12+
}
13+
14+
const cache = ctx.req.bodyCache.formData
15+
if (cache) {
16+
return cache
17+
}
18+
19+
const arrayBuffer = await ctx.req.arrayBuffer()
20+
const formData = await bufferToFormData(arrayBuffer, contentType)
21+
22+
ctx.req.bodyCache.formData = formData
23+
24+
return formData
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Hono } from 'hono'
2+
import { z } from 'zod'
3+
import { parseWithZod } from '@conform-to/zod'
4+
import { conformValidator } from '../src'
5+
6+
describe('Validate common processing', () => {
7+
const app = new Hono()
8+
const schema = z.object({ name: z.string() })
9+
const route = app.post(
10+
'/author',
11+
conformValidator((formData) => parseWithZod(formData, { schema })),
12+
(c) => {
13+
const submission = c.req.valid('form')
14+
const value = submission.value
15+
return c.json({ success: true, message: `my name is ${value.name}` })
16+
}
17+
)
18+
19+
describe('When the request body is empty', () => {
20+
it('Should return 400 response', async () => {
21+
const res = await route.request('/author', { method: 'POST' })
22+
expect(res.status).toBe(400)
23+
})
24+
})
25+
26+
describe('When the request body is not FormData', () => {
27+
it('Should return 400 response', async () => {
28+
const res = await route.request('/author', {
29+
method: 'POST',
30+
body: JSON.stringify({ name: 'Space Cat!' }),
31+
})
32+
expect(res.status).toBe(400)
33+
})
34+
})
35+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import * as z from 'zod'
2+
import { Hono } from 'hono'
3+
import { hc } from 'hono/client'
4+
import { parseWithZod } from '@conform-to/zod'
5+
import { conformValidator } from '../src'
6+
import { vi } from 'vitest'
7+
8+
describe('Validate the hook option processing', () => {
9+
const app = new Hono()
10+
const schema = z.object({ name: z.string() })
11+
const hookMockFn = vi.fn((submission, c) => {
12+
if (submission.status !== 'success') {
13+
return c.json({ success: false, message: 'Bad Request' }, 400)
14+
}
15+
})
16+
const handlerMockFn = vi.fn((c) => {
17+
const submission = c.req.valid('form')
18+
const value = submission.value
19+
return c.json({ success: true, message: `name is ${value.name}` })
20+
})
21+
const route = app.post(
22+
'/author',
23+
conformValidator((formData) => parseWithZod(formData, { schema }), hookMockFn),
24+
handlerMockFn
25+
)
26+
const client = hc<typeof route>('http://localhost', {
27+
fetch: (req, init) => {
28+
return app.request(req, init)
29+
},
30+
})
31+
32+
afterEach(() => {
33+
hookMockFn.mockClear()
34+
handlerMockFn.mockClear()
35+
})
36+
37+
it('Should called hook function', async () => {
38+
await client.author.$post({ form: { name: 'Space Cat' } })
39+
expect(hookMockFn).toHaveBeenCalledTimes(1)
40+
})
41+
42+
describe('When the hook return Response', () => {
43+
it('Should return response that the hook returned', async () => {
44+
const req = new Request('http://localhost/author', { body: new FormData(), method: 'POST' })
45+
const res = (await app.request(req)).clone()
46+
const hookRes = hookMockFn.mock.results[0].value.clone()
47+
expect(hookMockFn).toHaveReturnedWith(expect.any(Response))
48+
expect(res.status).toBe(hookRes.status)
49+
expect(await res.json()).toStrictEqual(await hookRes.json())
50+
})
51+
})
52+
53+
describe('When the hook not return Response', () => {
54+
it('Should return response that the handler function returned', async () => {
55+
const res = (await client.author.$post({ form: { name: 'Space Cat' } })).clone()
56+
const handlerRes = handlerMockFn.mock.results[0].value.clone()
57+
expect(hookMockFn).not.toHaveReturnedWith(expect.any(Response))
58+
expect(res.status).toBe(handlerRes.status)
59+
expect(await res.json()).toStrictEqual(await handlerRes.json())
60+
})
61+
})
62+
})

0 commit comments

Comments
 (0)