diff --git a/.github/workflows/ci-unknwnutil-validator.yml b/.github/workflows/ci-unknwnutil-validator.yml new file mode 100644 index 00000000..42ee0359 --- /dev/null +++ b/.github/workflows/ci-unknwnutil-validator.yml @@ -0,0 +1,25 @@ +name: ci-unknownutil-validator +on: + push: + branches: [main] + paths: + - 'packages/unknownutil-validator/**' + pull_request: + branches: ['*'] + paths: + - 'packages/unknownutil-validator/**' + +jobs: + ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./packages/unknownutil-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 d209c801..1d61365d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build:typebox-validator": "yarn workspace @hono/typebox-validator build", "build:medley-router": "yarn workspace @hono/medley-router build", "build:valibot-validator": "yarn workspace @hono/valibot-validator build", + "build:unknownutil-validator": "yarn workspace @hono/unknownutil-validator build", "build:zod-openapi": "yarn build:zod-validator && yarn workspace @hono/zod-openapi build", "build:typia-validator": "yarn workspace @hono/typia-validator build", "build": "run-p build:*" diff --git a/packages/unknownutil-validator/CHANGELOG.md b/packages/unknownutil-validator/CHANGELOG.md new file mode 100644 index 00000000..24f63faa --- /dev/null +++ b/packages/unknownutil-validator/CHANGELOG.md @@ -0,0 +1,2 @@ +# @hono/unknownutil-validator + diff --git a/packages/unknownutil-validator/README.md b/packages/unknownutil-validator/README.md new file mode 100644 index 00000000..2b073208 --- /dev/null +++ b/packages/unknownutil-validator/README.md @@ -0,0 +1,46 @@ +# Unknownutil validator middleware for Hono + +The type validator middleware using [Unknownutil](https://github.com/lambdalisue/deno-unknownutil) for [Hono](https://honojs.dev) applications. +You can write a schema with unknownutil and check the type of the incoming values. + +## Usage + +```ts +import { is } from 'unknownutil' +import { uValidator } from '@hono/unknownutil-validator' + +const schema = is.ObjectOf({ + name: is.String, + age: is.Number, +}) + +app.post('/author', uValidator('json', schema), (c) => { + const data = c.req.valid('json') + return c.json({ + success: true, + message: `${data.name} is ${data.age}`, + }) +}) +``` + +Hook: + +```ts +app.post( + '/post', + uValidator('json', schema, (result, c) => { + if (result.error) { + return c.text('Invalid!', 400) + } + }) + //... +) +``` + +## Author + +Ryotaro "Justin" Kimura + +## License + +MIT diff --git a/packages/unknownutil-validator/jest.config.js b/packages/unknownutil-validator/jest.config.js new file mode 100644 index 00000000..f697d831 --- /dev/null +++ b/packages/unknownutil-validator/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../jest.config.js') diff --git a/packages/unknownutil-validator/package.json b/packages/unknownutil-validator/package.json new file mode 100644 index 00000000..d3f47609 --- /dev/null +++ b/packages/unknownutil-validator/package.json @@ -0,0 +1,37 @@ +{ + "name": "@hono/unknownutil-validator", + "version": "0.1.0", + "description": "Validator middleware using unknonwnutil", + "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", + "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.*", + "unknownutil": "^3.9.0" + }, + "devDependencies": { + "hono": "^3.1.0", + "unknownutil": "^3.9.0" + } +} diff --git a/packages/unknownutil-validator/src/index.ts b/packages/unknownutil-validator/src/index.ts new file mode 100644 index 00000000..fbbdd202 --- /dev/null +++ b/packages/unknownutil-validator/src/index.ts @@ -0,0 +1,60 @@ +import type { Context, MiddlewareHandler, Env, ValidationTargets, TypedResponse } from 'hono' +import { validator } from 'hono/validator' +import { AssertError, ensure, type Predicate, type PredicateType } from 'unknownutil' + +export type Hook = ( + result: { data: T; error: undefined } | { data: undefined; error: AssertError }, + c: Context +) => Response | Promise | void | Promise | TypedResponse + +export const uValidator = < + T, + S extends Predicate, + Target extends keyof ValidationTargets, + E extends Env, + P extends string, + V extends { + in: { [K in Target]: PredicateType } + out: { [K in Target]: PredicateType } + } = { + in: { [K in Target]: PredicateType } + out: { [K in Target]: PredicateType } + } +>( + target: Target, + schema: S, + hook?: Hook, E, P> +): MiddlewareHandler => + validator(target, (value, c) => { + let resultUnion: [AssertError, undefined] | [undefined, PredicateType] + + try { + resultUnion = [undefined, ensure(value, schema) as PredicateType] + } catch (error: unknown) { + if (error instanceof AssertError) { + resultUnion = [error, undefined] + } else { + throw new Error(undefined, { cause: error }) + } + } + + const [error, data] = resultUnion + + if (hook) { + const hookResult = hook(error ? { data: undefined, error } : { data, error: undefined }, c) + if (hookResult) { + if (hookResult instanceof Response || hookResult instanceof Promise) { + return hookResult + } + if ('response' in hookResult) { + return hookResult.response + } + } + } + + if (error) { + return c.json({ success: false, error: error.message }, 400) + } + + return value + }) diff --git a/packages/unknownutil-validator/test/index.test.ts b/packages/unknownutil-validator/test/index.test.ts new file mode 100644 index 00000000..bf480294 --- /dev/null +++ b/packages/unknownutil-validator/test/index.test.ts @@ -0,0 +1,131 @@ +import { Hono } from 'hono' +import type { Equal, Expect } from 'hono/utils/types' +import { is } from 'unknownutil' +import { uValidator } 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 = is.ObjectOf({ + name: is.String, + age: is.Number, + }) + + const route = app.post('/author', uValidator('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: true + 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 = is.ObjectOf({ + id: is.Number, + title: is.String, + }) + + app.post( + '/post', + uValidator('json', schema, (result, c) => { + if (result.error) { + 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/unknownutil-validator/tsconfig.cjs.json b/packages/unknownutil-validator/tsconfig.cjs.json new file mode 100644 index 00000000..b8bf50ee --- /dev/null +++ b/packages/unknownutil-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/unknownutil-validator/tsconfig.esm.json b/packages/unknownutil-validator/tsconfig.esm.json new file mode 100644 index 00000000..8130f1a5 --- /dev/null +++ b/packages/unknownutil-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/unknownutil-validator/tsconfig.json b/packages/unknownutil-validator/tsconfig.json new file mode 100644 index 00000000..6c1a3990 --- /dev/null +++ b/packages/unknownutil-validator/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + }, + "include": [ + "src/**/*.ts" + ], +} \ No newline at end of file