feat(typebox-validator): Add TypeBox Validator middleware (#55)

* feat(typebox-validator): Add TypeBox Validator middleware

* fix(typebox-validator): Updates based on code review comments
pull/56/head
Curtis Larson 2023-02-24 04:22:02 -05:00 committed by GitHub
parent 84c7d4d02b
commit 2a3245ad06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 369 additions and 2 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/typebox-validator': patch
---
Add TypeBox validator middleware

View File

@ -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

View File

@ -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",

View File

@ -0,0 +1 @@
# @hono/typebox-validator

View File

@ -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

View File

@ -0,0 +1 @@
module.exports = require('../../jest.config.js')

View File

@ -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"
}
}

View File

@ -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)
})
}

View File

@ -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)
})
})

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

@ -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}`,

View File

@ -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"