diff --git a/.changeset/late-shoes-drive.md b/.changeset/late-shoes-drive.md new file mode 100644 index 00000000..86998641 --- /dev/null +++ b/.changeset/late-shoes-drive.md @@ -0,0 +1,5 @@ +--- +'@hono/typebox-validator': patch +--- + +Add TypeBox validator middleware diff --git a/.github/workflows/ci-typebox-validator.yml b/.github/workflows/ci-typebox-validator.yml new file mode 100644 index 00000000..0a802c4a --- /dev/null +++ b/.github/workflows/ci-typebox-validator.yml @@ -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 diff --git a/package.json b/package.json index 9bc0cfb6..4d1dcfa7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/typebox-validator/CHANGELOG.md b/packages/typebox-validator/CHANGELOG.md new file mode 100644 index 00000000..cead40b8 --- /dev/null +++ b/packages/typebox-validator/CHANGELOG.md @@ -0,0 +1 @@ +# @hono/typebox-validator diff --git a/packages/typebox-validator/README.md b/packages/typebox-validator/README.md new file mode 100644 index 00000000..3a513fc4 --- /dev/null +++ b/packages/typebox-validator/README.md @@ -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 + +## License + +MIT diff --git a/packages/typebox-validator/jest.config.js b/packages/typebox-validator/jest.config.js new file mode 100644 index 00000000..f697d831 --- /dev/null +++ b/packages/typebox-validator/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../jest.config.js') diff --git a/packages/typebox-validator/package.json b/packages/typebox-validator/package.json new file mode 100644 index 00000000..dd892a8d --- /dev/null +++ b/packages/typebox-validator/package.json @@ -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" + } +} diff --git a/packages/typebox-validator/src/index.ts b/packages/typebox-validator/src/index.ts new file mode 100644 index 00000000..fc7fa6c5 --- /dev/null +++ b/packages/typebox-validator/src/index.ts @@ -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 = ( + result: { success: true; data: T } | { success: false; errors: ValueError[] }, + c: Context +) => Response | Promise | 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> +): MiddlewareHandler }> { + // 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) { + return hookResult + } + } + return data + } + return c.json({ success: false, errors: [...compiled.Errors(data)] }, 400) + }) +} diff --git a/packages/typebox-validator/test/index.test.ts b/packages/typebox-validator/test/index.test.ts new file mode 100644 index 00000000..9ad3721d --- /dev/null +++ b/packages/typebox-validator/test/index.test.ts @@ -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 extends Hono ? 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 + 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> + + 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) + }) +}) diff --git a/packages/typebox-validator/tsconfig.cjs.json b/packages/typebox-validator/tsconfig.cjs.json new file mode 100644 index 00000000..b8bf50ee --- /dev/null +++ b/packages/typebox-validator/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "declaration": false, + "outDir": "./dist/cjs" + } +} \ No newline at end of file diff --git a/packages/typebox-validator/tsconfig.esm.json b/packages/typebox-validator/tsconfig.esm.json new file mode 100644 index 00000000..8130f1a5 --- /dev/null +++ b/packages/typebox-validator/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "declaration": true, + "outDir": "./dist/esm" + } +} \ No newline at end of file diff --git a/packages/typebox-validator/tsconfig.json b/packages/typebox-validator/tsconfig.json new file mode 100644 index 00000000..6c1a3990 --- /dev/null +++ b/packages/typebox-validator/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + }, + "include": [ + "src/**/*.ts" + ], +} \ No newline at end of file diff --git a/packages/zod-validator/README.md b/packages/zod-validator/README.md index f0fde26d..34357e13 100644 --- a/packages/zod-validator/README.md +++ b/packages/zod-validator/README.md @@ -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}`, diff --git a/yarn.lock b/yarn.lock index 794c2c74..65c77104 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"