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
pull/675/head
uttk 2024-07-31 22:55:02 +09:00 committed by GitHub
parent e2ede3bdfd
commit d4a69131e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1534 additions and 4 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/conform-validator': major
---
Create Conform validator middleware

View File

@ -0,0 +1,25 @@
name: ci-conform-validator
on:
push:
branches: [main]
paths:
- 'packages/conform-validator/**'
pull_request:
branches: ['*']
paths:
- 'packages/conform-validator/**'
jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/conform-validator
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install --frozen-lockfile
- run: yarn build
- run: yarn test

View File

@ -34,6 +34,7 @@
"build:node-ws": "yarn workspace @hono/node-ws build",
"build:react-compat": "yarn workspace @hono/react-compat build",
"build:effect-validator": "yarn workspace @hono/effect-validator build",
"build:conform-validator": "yarn workspace @hono/conform-validator build",
"build": "run-p 'build:*'",
"lint": "eslint 'packages/**/*.{ts,tsx}'",
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",

View File

@ -0,0 +1,112 @@
# Conform validator middleware for Hono
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).
## Usage
Zod:
```ts
import { z } from 'zod'
import { parseWithZod } from '@conform-to/zod'
import { conformValidator } from '@hono/conform-validator'
import { HTTPException } from 'hono/http-exception'
const schema = z.object({
name: z.string(),
age: z.string(),
})
app.post(
'/author',
conformValidator((formData) => parseWithZod(formData, { schema })),
(c) => {
const submission = c.req.valid('form')
const data = submission.value
return c.json({ success: true, message: `${data.name} is ${data.age}` })
}
)
```
Yup:
```ts
import { object, string } from 'yup'
import { parseWithYup } from '@conform-to/yup'
import { conformValidator } from '@hono/conform-validator'
import { HTTPException } from 'hono/http-exception'
const schema = object({
name: string(),
age: string(),
})
app.post(
'/author',
conformValidator((formData) => parseWithYup(formData, { schema })),
(c) => {
const submission = c.req.valid('form')
const data = submission.value
return c.json({ success: true, message: `${data.name} is ${data.age}` })
}
)
```
Valibot:
```ts
import { object, string } from 'valibot'
import { parseWithValibot } from 'conform-to-valibot'
import { conformValidator } from '@hono/conform-validator'
import { HTTPException } from 'hono/http-exception'
const schema = object({
name: string(),
age: string(),
})
app.post(
'/author',
conformValidator((formData) => parseWithYup(formData, { schema })),
(c) => {
const submission = c.req.valid('form')
const data = submission.value
return c.json({ success: true, message: `${data.name} is ${data.age}` })
}
)
```
## Custom Hook Option
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.
```ts
app.post(
'/author',
conformValidator(
(formData) => parseWithYup(formData, { schema })
(submission, c) => {
if(submission.status !== 'success') {
return c.json({ success: false, message: 'Bad Request' }, 400)
}
}
),
(c) => {
const submission = c.req.valid('form')
const data = submission.value
return c.json({ success: true, message: `${data.name} is ${data.age}` })
}
)
```
> [!NOTE]
> 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).
## Author
uttk <https://github.com/uttk>
## License
MIT

View File

@ -0,0 +1,56 @@
{
"name": "@hono/conform-validator",
"version": "0.0.0",
"description": "Validator middleware using Conform",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"test": "vitest --run",
"build": "tsup ./src/index.ts --format esm,cjs --dts",
"prerelease": "yarn build && yarn test",
"release": "yarn publish"
},
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"license": "MIT",
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/honojs/middleware.git"
},
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"@conform-to/dom": ">=1.1.5",
"hono": ">=4.5.1"
},
"devDependencies": {
"@conform-to/dom": "^1.1.5",
"@conform-to/yup": "^1.1.5",
"@conform-to/zod": "^1.1.5",
"conform-to-valibot": "^1.10.0",
"hono": "^4.5.1",
"tsup": "^8.2.3",
"valibot": "^0.36.0",
"vitest": "^2.0.4",
"yup": "^1.4.0",
"zod": "^3.23.8"
}
}

View File

@ -0,0 +1,59 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Context, Env, Input as HonoInput, MiddlewareHandler, ValidationTargets } from 'hono'
import type { Submission } from '@conform-to/dom'
import { getFormDataFromContext } from './utils'
type FormTargetValue = ValidationTargets['form']['string']
type GetInput<T extends ParseFn> = T extends (_: any) => infer S
? Awaited<S> extends Submission<any, any, infer V>
? V
: never
: never
type GetSuccessSubmission<S> = S extends { status: 'success' } ? S : never
type ParseFn = (formData: FormData) => Submission<unknown> | Promise<Submission<unknown>>
type Hook<F extends ParseFn, E extends Env, P extends string> = (
submission: Awaited<ReturnType<F>>,
c: Context<E, P>
) => Response | Promise<Response> | void | Promise<Response | void>
export const conformValidator = <
F extends ParseFn,
E extends Env,
P extends string,
In = GetInput<F>,
Out = Awaited<ReturnType<F>>,
I extends HonoInput = {
in: {
form: { [K in keyof In]: FormTargetValue }
}
out: { form: GetSuccessSubmission<Out> }
}
>(
parse: F,
hook?: Hook<F, E, P>
): MiddlewareHandler<E, P, I> => {
return async (c, next) => {
const formData = await getFormDataFromContext(c)
const submission = await parse(formData)
if (hook) {
const hookResult = hook(submission as any, c)
if (hookResult instanceof Response || hookResult instanceof Promise) {
return hookResult
}
}
if (submission.status !== 'success') {
return c.json(submission.reply(), 400)
}
c.req.addValidatedData('form', submission)
await next()
}
}

View File

@ -0,0 +1,25 @@
import type { Context } from 'hono'
import { bufferToFormData } from 'hono/utils/buffer'
// ref: https://github.com/honojs/hono/blob/a63bcfd6fba66297d8234c21aed8a42ac00711fe/src/validator/validator.ts#L27-L28
const multipartRegex = /^multipart\/form-data(; boundary=[A-Za-z0-9'()+_,\-./:=?]+)?$/
const urlencodedRegex = /^application\/x-www-form-urlencoded$/
export const getFormDataFromContext = async (ctx: Context): Promise<FormData> => {
const contentType = ctx.req.header('Content-Type')
if (!contentType || !(multipartRegex.test(contentType) || urlencodedRegex.test(contentType))) {
return new FormData()
}
const cache = ctx.req.bodyCache.formData
if (cache) {
return cache
}
const arrayBuffer = await ctx.req.arrayBuffer()
const formData = await bufferToFormData(arrayBuffer, contentType)
ctx.req.bodyCache.formData = formData
return formData
}

View File

@ -0,0 +1,35 @@
import { Hono } from 'hono'
import { z } from 'zod'
import { parseWithZod } from '@conform-to/zod'
import { conformValidator } from '../src'
describe('Validate common processing', () => {
const app = new Hono()
const schema = z.object({ name: z.string() })
const route = app.post(
'/author',
conformValidator((formData) => parseWithZod(formData, { schema })),
(c) => {
const submission = c.req.valid('form')
const value = submission.value
return c.json({ success: true, message: `my name is ${value.name}` })
}
)
describe('When the request body is empty', () => {
it('Should return 400 response', async () => {
const res = await route.request('/author', { method: 'POST' })
expect(res.status).toBe(400)
})
})
describe('When the request body is not FormData', () => {
it('Should return 400 response', async () => {
const res = await route.request('/author', {
method: 'POST',
body: JSON.stringify({ name: 'Space Cat!' }),
})
expect(res.status).toBe(400)
})
})
})

View File

@ -0,0 +1,62 @@
import * as z from 'zod'
import { Hono } from 'hono'
import { hc } from 'hono/client'
import { parseWithZod } from '@conform-to/zod'
import { conformValidator } from '../src'
import { vi } from 'vitest'
describe('Validate the hook option processing', () => {
const app = new Hono()
const schema = z.object({ name: z.string() })
const hookMockFn = vi.fn((submission, c) => {
if (submission.status !== 'success') {
return c.json({ success: false, message: 'Bad Request' }, 400)
}
})
const handlerMockFn = vi.fn((c) => {
const submission = c.req.valid('form')
const value = submission.value
return c.json({ success: true, message: `name is ${value.name}` })
})
const route = app.post(
'/author',
conformValidator((formData) => parseWithZod(formData, { schema }), hookMockFn),
handlerMockFn
)
const client = hc<typeof route>('http://localhost', {
fetch: (req, init) => {
return app.request(req, init)
},
})
afterEach(() => {
hookMockFn.mockClear()
handlerMockFn.mockClear()
})
it('Should called hook function', async () => {
await client.author.$post({ form: { name: 'Space Cat' } })
expect(hookMockFn).toHaveBeenCalledTimes(1)
})
describe('When the hook return Response', () => {
it('Should return response that the hook returned', async () => {
const req = new Request('http://localhost/author', { body: new FormData(), method: 'POST' })
const res = (await app.request(req)).clone()
const hookRes = hookMockFn.mock.results[0].value.clone()
expect(hookMockFn).toHaveReturnedWith(expect.any(Response))
expect(res.status).toBe(hookRes.status)
expect(await res.json()).toStrictEqual(await hookRes.json())
})
})
describe('When the hook not return Response', () => {
it('Should return response that the handler function returned', async () => {
const res = (await client.author.$post({ form: { name: 'Space Cat' } })).clone()
const handlerRes = handlerMockFn.mock.results[0].value.clone()
expect(hookMockFn).not.toHaveReturnedWith(expect.any(Response))
expect(res.status).toBe(handlerRes.status)
expect(await res.json()).toStrictEqual(await handlerRes.json())
})
})
})

View File

@ -0,0 +1,111 @@
import type { ExtractSchema, ParsedFormValue } from 'hono/types'
import type { Equal, Expect } from 'hono/utils/types'
import type { StatusCode } from 'hono/utils/http-status'
import * as v from 'valibot'
import { Hono } from 'hono'
import { hc } from 'hono/client'
import { parseWithValibot } from 'conform-to-valibot'
import { conformValidator } from '../src'
describe('Validate requests using a Valibot schema', () => {
const app = new Hono()
const schema = v.object({
name: v.string(),
age: v.pipe(
v.string(),
v.transform((v) => Number(v)),
v.integer()
),
nickname: v.optional(v.string()),
})
const route = app.post(
'/author',
conformValidator((formData) => parseWithValibot(formData, { schema })),
(c) => {
const submission = c.req.valid('form')
const value = submission.value
return c.json({
success: true,
message: `${value.name} is ${value.age}, nickname is ${
value?.nickname || 'nothing yet :3'
}`,
})
}
)
it('check the route object types', () => {
type Actual = ExtractSchema<typeof route>
type Expected = {
'/author': {
$post: {
input: {
form: {
name: ParsedFormValue | ParsedFormValue[]
age: ParsedFormValue | ParsedFormValue[]
nickname?: ParsedFormValue | ParsedFormValue[] | undefined
}
}
output: {
success: boolean
message: string
}
outputFormat: 'json'
status: StatusCode
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type verify = Expect<Equal<Expected, Actual>>
})
it('Should return 200 response', async () => {
const client = hc<typeof route>('http://localhost', {
fetch: (req, init) => {
return app.request(req, init)
},
})
const res = await client.author.$post({
form: {
name: 'Space Cat',
age: '20',
nickname: 'meow',
},
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({
success: true,
message: 'Space Cat is 20, nickname is meow',
})
})
it('Should return 400 response', async () => {
const formData = new FormData()
const req = new Request('http://localhost/author', {
body: formData,
method: 'POST',
})
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(400)
const json = await res.json()
expect(json).toMatchObject({
status: 'error',
error: {
name: ['Invalid type: Expected string but received undefined'],
age: ['Invalid type: Expected string but received undefined'],
},
})
})
})

View File

@ -0,0 +1,107 @@
import type { ExtractSchema, ParsedFormValue } from 'hono/types'
import type { Equal, Expect } from 'hono/utils/types'
import type { StatusCode } from 'hono/utils/http-status'
import * as y from 'yup'
import { Hono } from 'hono'
import { hc } from 'hono/client'
import { parseWithYup } from '@conform-to/yup'
import { conformValidator } from '../src'
describe('Validate requests using a Yup schema', () => {
const app = new Hono()
const schema = y.object({
name: y.string().required(),
age: y.number().required(),
nickname: y.string().optional(),
})
const route = app.post(
'/author',
conformValidator((formData) => parseWithYup(formData, { schema })),
(c) => {
const submission = c.req.valid('form')
const value = submission.value
return c.json({
success: true,
message: `${value.name} is ${value.age}, nickname is ${
value?.nickname || 'nothing yet :3'
}`,
})
}
)
it('check the route object types', () => {
type Actual = ExtractSchema<typeof route>
type Expected = {
'/author': {
$post: {
input: {
form: {
name: ParsedFormValue | ParsedFormValue[]
age: ParsedFormValue | ParsedFormValue[]
nickname?: ParsedFormValue | ParsedFormValue[] | undefined
}
}
output: {
success: boolean
message: string
}
outputFormat: 'json'
status: StatusCode
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type verify = Expect<Equal<Expected, Actual>>
})
it('Should return 200 response', async () => {
const client = hc<typeof route>('http://localhost', {
fetch: (req, init) => {
return app.request(req, init)
},
})
const res = await client.author.$post({
form: {
name: 'Space Cat',
age: '20',
nickname: 'meow',
},
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({
success: true,
message: 'Space Cat is 20, nickname is meow',
})
})
it('Should return 400 response', async () => {
const formData = new FormData()
const req = new Request('http://localhost/author', {
body: formData,
method: 'POST',
})
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(400)
const json = await res.json()
expect(json).toMatchObject({
status: 'error',
error: {
name: ['name is a required field'],
age: ['age is a required field'],
},
})
})
})

View File

@ -0,0 +1,107 @@
import type { ExtractSchema, ParsedFormValue } from 'hono/types'
import type { Equal, Expect } from 'hono/utils/types'
import type { StatusCode } from 'hono/utils/http-status'
import * as z from 'zod'
import { Hono } from 'hono'
import { hc } from 'hono/client'
import { parseWithZod } from '@conform-to/zod'
import { conformValidator } from '../src'
describe('Validate requests using a Zod schema', () => {
const app = new Hono()
const schema = z.object({
name: z.string(),
age: z.string().transform((str) => Number(str)),
nickname: z.string().optional(),
})
const route = app.post(
'/author',
conformValidator((formData) => parseWithZod(formData, { schema })),
(c) => {
const submission = c.req.valid('form')
const value = submission.value
return c.json({
success: true,
message: `${value.name} is ${value.age}, nickname is ${
value?.nickname || 'nothing yet :3'
}`,
})
}
)
it('check the route object types', () => {
type Actual = ExtractSchema<typeof route>
type Expected = {
'/author': {
$post: {
input: {
form: {
name: ParsedFormValue | ParsedFormValue[]
age: ParsedFormValue | ParsedFormValue[]
nickname?: ParsedFormValue | ParsedFormValue[] | undefined
}
}
output: {
success: boolean
message: string
}
outputFormat: 'json'
status: StatusCode
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type verify = Expect<Equal<Expected, Actual>>
})
it('Should return 200 response', async () => {
const client = hc<typeof route>('http://localhost', {
fetch: (req, init) => {
return app.request(req, init)
},
})
const res = await client.author.$post({
form: {
name: 'Space Cat',
age: '20',
nickname: 'meow',
},
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({
success: true,
message: 'Space Cat is 20, nickname is meow',
})
})
it('Should return 400 response', async () => {
const formData = new FormData()
const req = new Request('http://localhost/author', {
body: formData,
method: 'POST',
})
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(400)
const json = await res.json()
expect(json).toMatchObject({
status: 'error',
error: {
name: ['Required'],
age: ['Required'],
},
})
})
})

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"declaration": false,
"outDir": "./dist/cjs"
}
}

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"declaration": true,
"outDir": "./dist/esm"
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
},
"include": [
"src/**/*.ts"
],
}

View File

@ -0,0 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
},
})

800
yarn.lock

File diff suppressed because it is too large Load Diff