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 librariespull/675/head
parent
e2ede3bdfd
commit
d4a69131e1
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/conform-validator': major
|
||||||
|
---
|
||||||
|
|
||||||
|
Create Conform validator middleware
|
|
@ -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
|
|
@ -34,6 +34,7 @@
|
||||||
"build:node-ws": "yarn workspace @hono/node-ws build",
|
"build:node-ws": "yarn workspace @hono/node-ws build",
|
||||||
"build:react-compat": "yarn workspace @hono/react-compat build",
|
"build:react-compat": "yarn workspace @hono/react-compat build",
|
||||||
"build:effect-validator": "yarn workspace @hono/effect-validator build",
|
"build:effect-validator": "yarn workspace @hono/effect-validator build",
|
||||||
|
"build:conform-validator": "yarn workspace @hono/conform-validator build",
|
||||||
"build": "run-p 'build:*'",
|
"build": "run-p 'build:*'",
|
||||||
"lint": "eslint 'packages/**/*.{ts,tsx}'",
|
"lint": "eslint 'packages/**/*.{ts,tsx}'",
|
||||||
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",
|
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",
|
||||||
|
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"declaration": false,
|
||||||
|
"outDir": "./dist/cjs"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "./dist/esm"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
})
|
Loading…
Reference in New Issue