Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Conform Validator Middleware #666

Merged
merged 18 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pretty-eels-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/conform-validator': patch
yusukebe marked this conversation as resolved.
Show resolved Hide resolved
---

Create Conform validator middleware
25 changes: 25 additions & 0 deletions .github/workflows/ci-conform-validator.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}'",
Expand Down
112 changes: 112 additions & 0 deletions packages/conform-validator/README.md
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions packages/conform-validator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"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",
"rimraf": "^5.0.9",
yusukebe marked this conversation as resolved.
Show resolved Hide resolved
"tsup": "^8.2.3",
"valibot": "^0.36.0",
"vitest": "^2.0.4",
"yup": "^1.4.0",
"zod": "^3.23.8"
}
}
59 changes: 59 additions & 0 deletions packages/conform-validator/src/index.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
25 changes: 25 additions & 0 deletions packages/conform-validator/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions packages/conform-validator/test/common.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
62 changes: 62 additions & 0 deletions packages/conform-validator/test/hook.test.ts
Original file line number Diff line number Diff line change
@@ -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())
})
})
})
Loading