feat(typebox-validator): Add TypeBox Validator middleware (#55)
* feat(typebox-validator): Add TypeBox Validator middleware * fix(typebox-validator): Updates based on code review commentspull/56/head
parent
84c7d4d02b
commit
2a3245ad06
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hono/typebox-validator': patch
|
||||
---
|
||||
|
||||
Add TypeBox validator middleware
|
|
@ -0,0 +1,25 @@
|
|||
name: ci-typebox-validator
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/typebox-validator/**'
|
||||
pull_request:
|
||||
branches: ['*']
|
||||
paths:
|
||||
- 'packages/typebox-validator/**'
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./packages/typebox-validator
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn build
|
||||
- run: yarn test
|
|
@ -13,6 +13,7 @@
|
|||
"build:sentry": "yarn workspace @hono/sentry build",
|
||||
"build:firebase-auth": "yarn workspace @hono/firebase-auth build",
|
||||
"build:trpc-server": "yarn workspace @hono/trpc-server build",
|
||||
"build:typebox-validator": "yarn workspace @hono/typebox-validator build",
|
||||
"build": "run-p build:*"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
# @hono/typebox-validator
|
|
@ -0,0 +1,53 @@
|
|||
# TypeBox validator middleware for Hono
|
||||
|
||||
Validator middleware using [TypeBox](https://github.com/sinclairzx81/typebox) for [Hono](https://honojs.dev) applications.
|
||||
Define your schema with TypeBox and validate incoming requests.
|
||||
|
||||
## Usage
|
||||
|
||||
No Hook:
|
||||
|
||||
```ts
|
||||
import { tbValidator } from '@hono/typebox-validator'
|
||||
import { Type as T } from '@sinclair/typebox'
|
||||
|
||||
const schema = T.Object({
|
||||
name: T.String(),
|
||||
age: T.Number(),
|
||||
})
|
||||
|
||||
const route = app.post('/user', tbValidator('json', schema), (c) => {
|
||||
const user = c.req.valid('json')
|
||||
return c.json({ success: true, message: `${user.name} is ${user.age}` })
|
||||
})
|
||||
```
|
||||
|
||||
Hook:
|
||||
|
||||
```ts
|
||||
import { tbValidator } from '@hono/typebox-validator'
|
||||
import { Type as T } from '@sinclair/typebox'
|
||||
|
||||
const schema = T.Object({
|
||||
name: T.String(),
|
||||
age: T.Number(),
|
||||
})
|
||||
|
||||
app.post(
|
||||
'/user',
|
||||
tbValidator('json', schema, (result, c) => {
|
||||
if (!result.success) {
|
||||
return c.text('Invalid!', 400)
|
||||
}
|
||||
})
|
||||
//...
|
||||
)
|
||||
```
|
||||
|
||||
## Author
|
||||
|
||||
Curtis Larson <https://github.com/curtislarson>
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
|
@ -0,0 +1 @@
|
|||
module.exports = require('../../jest.config.js')
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "@hono/typebox-validator",
|
||||
"version": "0.0.0",
|
||||
"description": "Validator middleware using TypeBox",
|
||||
"main": "dist/cjs/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build:cjs": "tsc -p tsconfig.cjs.json",
|
||||
"build:esm": "tsc -p tsconfig.esm.json",
|
||||
"build": "rimraf dist && yarn build:cjs && yarn build:esm",
|
||||
"prerelease": "yarn build && yarn test",
|
||||
"release": "yarn publish"
|
||||
},
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
"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": {
|
||||
"hono": "3.*",
|
||||
"@sinclair/typebox": "^0.25.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"hono": "^3.0.0",
|
||||
"@sinclair/typebox": "^0.25.24"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import type { TSchema, Static } from '@sinclair/typebox'
|
||||
import { TypeCompiler, type ValueError } from '@sinclair/typebox/compiler'
|
||||
import type { Context, Env, MiddlewareHandler } from 'hono'
|
||||
import { validator } from 'hono/validator'
|
||||
|
||||
type ValidationTargets = 'json' | 'form' | 'query' | 'queries'
|
||||
type Hook<T> = (
|
||||
result: { success: true; data: T } | { success: false; errors: ValueError[] },
|
||||
c: Context
|
||||
) => Response | Promise<Response> | void
|
||||
|
||||
/**
|
||||
* Hono middleware that validates incoming data via a [TypeBox](https://github.com/sinclairzx81/typebox) schema.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* No Hook
|
||||
*
|
||||
* ```ts
|
||||
* import { tbValidator } from '@hono/typebox-validator'
|
||||
* import { Type as T } from '@sinclair/typebox'
|
||||
*
|
||||
* const schema = T.Object({
|
||||
* name: T.String(),
|
||||
* age: T.Number(),
|
||||
* })
|
||||
*
|
||||
* const route = app.post('/user', tbValidator('json', schema), (c) => {
|
||||
* const user = c.req.valid('json')
|
||||
* return c.json({ success: true, message: `${user.name} is ${user.age}` })
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
* Hook
|
||||
*
|
||||
* ```ts
|
||||
* import { tbValidator } from '@hono/typebox-validator'
|
||||
* import { Type as T } from '@sinclair/typebox'
|
||||
*
|
||||
* const schema = T.Object({
|
||||
* name: T.String(),
|
||||
* age: T.Number(),
|
||||
* })
|
||||
*
|
||||
* app.post(
|
||||
* '/user',
|
||||
* tbValidator('json', schema, (result, c) => {
|
||||
* if (!result.success) {
|
||||
* return c.text('Invalid!', 400)
|
||||
* }
|
||||
* })
|
||||
* //...
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function tbValidator<
|
||||
T extends TSchema,
|
||||
Target extends ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string
|
||||
>(
|
||||
target: Target,
|
||||
schema: T,
|
||||
hook?: Hook<Static<T>>
|
||||
): MiddlewareHandler<E, P, { [K in Target]: Static<T> }> {
|
||||
// Compile the provided schema once rather than per validation. This could be optimized further using a shared schema
|
||||
// compilation pool similar to the Fastify implementation.
|
||||
const compiled = TypeCompiler.Compile(schema)
|
||||
return validator(target, (data, c) => {
|
||||
if (compiled.Check(data)) {
|
||||
if (hook) {
|
||||
const hookResult = hook({ success: true, data }, c)
|
||||
if (hookResult instanceof Response || hookResult instanceof Promise<Response>) {
|
||||
return hookResult
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
return c.json({ success: false, errors: [...compiled.Errors(data)] }, 400)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
import { Type as T } from '@sinclair/typebox'
|
||||
import { Hono } from 'hono'
|
||||
import type { Equal, Expect } from 'hono/utils/types'
|
||||
import { tbValidator } from '../src'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type ExtractSchema<T> = T extends Hono<infer _, infer S> ? S : never
|
||||
|
||||
describe('Basic', () => {
|
||||
const app = new Hono()
|
||||
|
||||
const schema = T.Object({
|
||||
name: T.String(),
|
||||
age: T.Number(),
|
||||
})
|
||||
|
||||
const route = app.post('/author', tbValidator('json', schema), (c) => {
|
||||
const data = c.req.valid('json')
|
||||
return c.jsonT({
|
||||
success: true,
|
||||
message: `${data.name} is ${data.age}`,
|
||||
})
|
||||
})
|
||||
|
||||
type Actual = ExtractSchema<typeof route>
|
||||
type Expected = {
|
||||
'/author': {
|
||||
$post: {
|
||||
input: {
|
||||
json: {
|
||||
name: string
|
||||
age: number
|
||||
}
|
||||
}
|
||||
output: {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type verify = Expect<Equal<Expected, Actual>>
|
||||
|
||||
it('Should return 200 response', async () => {
|
||||
const req = new Request('http://localhost/author', {
|
||||
body: JSON.stringify({
|
||||
name: 'Superman',
|
||||
age: 20,
|
||||
}),
|
||||
method: 'POST',
|
||||
})
|
||||
const res = await app.request(req)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
success: true,
|
||||
message: 'Superman is 20',
|
||||
})
|
||||
})
|
||||
|
||||
it('Should return 400 response', async () => {
|
||||
const req = new Request('http://localhost/author', {
|
||||
body: JSON.stringify({
|
||||
name: 'Superman',
|
||||
age: '20',
|
||||
}),
|
||||
method: 'POST',
|
||||
})
|
||||
const res = await app.request(req)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(400)
|
||||
const data = (await res.json()) as { success: boolean }
|
||||
expect(data['success']).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('With Hook', () => {
|
||||
const app = new Hono()
|
||||
|
||||
const schema = T.Object({
|
||||
id: T.Number(),
|
||||
title: T.String(),
|
||||
})
|
||||
|
||||
app.post(
|
||||
'/post',
|
||||
tbValidator('json', schema, (result, c) => {
|
||||
if (!result.success) {
|
||||
return c.text('Invalid!', 400)
|
||||
}
|
||||
const data = result.data
|
||||
return c.text(`${data.id} is valid!`)
|
||||
}),
|
||||
(c) => {
|
||||
const data = c.req.valid('json')
|
||||
return c.json({
|
||||
success: true,
|
||||
message: `${data.id} is ${data.title}`,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('Should return 200 response', async () => {
|
||||
const req = new Request('http://localhost/post', {
|
||||
body: JSON.stringify({
|
||||
id: 123,
|
||||
title: 'Hello',
|
||||
}),
|
||||
method: 'POST',
|
||||
})
|
||||
const res = await app.request(req)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('123 is valid!')
|
||||
})
|
||||
|
||||
it('Should return 400 response', async () => {
|
||||
const req = new Request('http://localhost/post', {
|
||||
body: JSON.stringify({
|
||||
id: '123',
|
||||
title: 'Hello',
|
||||
}),
|
||||
method: 'POST',
|
||||
})
|
||||
const res = await app.request(req)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
|
@ -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"
|
||||
],
|
||||
}
|
|
@ -7,7 +7,7 @@ You can write a schema with Zod and validate the incoming values.
|
|||
|
||||
```ts
|
||||
import { z } from 'zod'
|
||||
import { zValidator } from '../src'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
|
@ -15,7 +15,7 @@ const schema = z.object({
|
|||
})
|
||||
|
||||
app.post('/author', zValidator('json', schema), (c) => {
|
||||
const data = c.req.valid()
|
||||
const data = c.req.valid('json')
|
||||
return c.json({
|
||||
success: true,
|
||||
message: `${data.name} is ${data.age}`,
|
||||
|
|
|
@ -1475,6 +1475,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f"
|
||||
integrity sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==
|
||||
|
||||
"@sinclair/typebox@^0.25.24":
|
||||
version "0.25.24"
|
||||
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718"
|
||||
integrity sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==
|
||||
|
||||
"@sindresorhus/is@^0.14.0":
|
||||
version "0.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
|
||||
|
|
Loading…
Reference in New Issue