diff --git a/.changeset/mighty-clouds-double.md b/.changeset/mighty-clouds-double.md new file mode 100644 index 00000000..5fb710b1 --- /dev/null +++ b/.changeset/mighty-clouds-double.md @@ -0,0 +1,5 @@ +--- +'@hono/typia-validator': patch +--- + +Add Typia validator diff --git a/.github/workflows/ci-typia-validator.yml b/.github/workflows/ci-typia-validator.yml new file mode 100644 index 00000000..89b2e361 --- /dev/null +++ b/.github/workflows/ci-typia-validator.yml @@ -0,0 +1,25 @@ +name: ci-typia-validator +on: + push: + branches: [main] + paths: + - 'packages/typia-validator/**' + pull_request: + branches: ['*'] + paths: + - 'packages/typia-validator/**' + +jobs: + ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./packages/typia-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 bcda8010..d209c801 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "build:medley-router": "yarn workspace @hono/medley-router build", "build:valibot-validator": "yarn workspace @hono/valibot-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:*" }, "license": "MIT", @@ -53,4 +54,4 @@ "typescript": "^4.7.4", "vitest": "^0.34.2" } -} \ No newline at end of file +} diff --git a/packages/typia-validator/README.md b/packages/typia-validator/README.md new file mode 100644 index 00000000..5975d8e4 --- /dev/null +++ b/packages/typia-validator/README.md @@ -0,0 +1,47 @@ +# Typia validator middleware for Hono + +The validator middleware using [Typia](https://typia.io/docs/) for [Hono](https://honojs.dev) applications. + +## Usage + +```ts +import typia, { tags } from 'typia' +import { typiaValidator } from '@hono/typia-validator' + +interface Author { + name: string + age: number & tags.Type<'uint32'> & tags.Minimum<20> & tags.ExclusiveMaximum<100> + } + + const validate = typia.createValidate() + + const route = app.post('/author', typiaValidator('json', validate), (c) => { + const data = c.req.valid('json') + return c.jsonT({ + success: true, + message: `${data.name} is ${data.age}`, + }) + }) +``` + +Hook: + +```ts +app.post( + '/post', + typiaValidator('json', validate, (result, c) => { + if (!result.success) { + return c.text('Invalid!', 400) + } + }) + //... +) +``` + +## Author + +Patryk Dwórznik + +## License + +MIT diff --git a/packages/typia-validator/jest.config.js b/packages/typia-validator/jest.config.js new file mode 100644 index 00000000..69ed93bc --- /dev/null +++ b/packages/typia-validator/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + testMatch: ['**/test-generated/**/*.+(ts|tsx|js)'], + testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.history/'], + transform: { + '^.+\\.(ts|tsx)$': 'ts-jest', + }, + testEnvironment: 'miniflare', + } + \ No newline at end of file diff --git a/packages/typia-validator/package.json b/packages/typia-validator/package.json new file mode 100644 index 00000000..66a79c52 --- /dev/null +++ b/packages/typia-validator/package.json @@ -0,0 +1,39 @@ +{ + "name": "@hono/typia-validator", + "version": "0.0.1", + "description": "Validator middleware using Typia", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "generate-test": "rimraf test-generated && typia generate --input test --output test-generated --project tsconfig.json", + "test": "npm run generate-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.*", + "typia": "^5.0.4" + }, + "devDependencies": { + "hono": "^3.5.8", + "typia": "^5.0.4" + } +} diff --git a/packages/typia-validator/src/index.ts b/packages/typia-validator/src/index.ts new file mode 100644 index 00000000..b1aac5f0 --- /dev/null +++ b/packages/typia-validator/src/index.ts @@ -0,0 +1,50 @@ +import type { Context, MiddlewareHandler, Env, ValidationTargets, TypedResponse } from 'hono' +import { validator } from 'hono/validator' +import { IValidation, Primitive } from 'typia' + +export type Hook = ( + result: IValidation.ISuccess | { success: false; errors: IValidation.IError[]; data: T }, + c: Context +) => Response | Promise | void | Promise | TypedResponse + +export type Validation = (input: unknown) => IValidation +export type OutputType = T extends Validation ? O : never + +export const typiaValidator = < + T extends Validation, + O extends OutputType, + Target extends keyof ValidationTargets, + E extends Env, + P extends string, + V extends { + in: { [K in Target]: O } + out: { [K in Target]: O } + } = { + in: { [K in Target]: O } + out: { [K in Target]: O } + } +>( + target: Target, + validate: T, + hook?: Hook +): MiddlewareHandler => + validator(target, (value, c) => { + const result = validate(value) + + if (hook) { + const hookResult = hook({ ...result, data: value }, c) + if (hookResult) { + if (hookResult instanceof Response || hookResult instanceof Promise) { + return hookResult + } + if ('response' in hookResult) { + return hookResult.response + } + } + } + + if (!result.success) { + return c.json({ success: false, error: result.errors }, 400) + } + return result.data + }) diff --git a/packages/typia-validator/test/index.test.ts b/packages/typia-validator/test/index.test.ts new file mode 100644 index 00000000..8958c33c --- /dev/null +++ b/packages/typia-validator/test/index.test.ts @@ -0,0 +1,133 @@ +import { Hono } from 'hono' +import type { Equal, Expect } from 'hono/utils/types' +import typia, { tags } from 'typia' +import { typiaValidator } from '../src' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type ExtractSchema = T extends Hono ? S : never + +describe('Basic', () => { + const app = new Hono() + + interface Author { + name: string + age: number & tags.Type<'uint32'> & tags.Minimum<20> & tags.ExclusiveMaximum<100> + } + + const validate = typia.createValidate() + + const route = app.post('/author', typiaValidator('json', validate), (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: Author + } + 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: 30, + }), + 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 30', + }) + }) + + it('Should return 400 response', async () => { + const req = new Request('http://localhost/author', { + body: JSON.stringify({ + name: 'Superman', + age: 18, + }), + 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() + + interface Item { + id: number & tags.ExclusiveMaximum<9999> + title: string + } + + const validate = typia.createValidate() + + app.post( + '/post', + typiaValidator('json', validate, (result, c) => { + if (!result.success) { + return c.text(`${result.data.id} is invalid!`, 400) + } + const data = result.data + return Promise.resolve(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) + expect(await res.text()).toBe('123 is invalid!') + }) +}) diff --git a/packages/typia-validator/tsconfig.cjs.json b/packages/typia-validator/tsconfig.cjs.json new file mode 100644 index 00000000..b8bf50ee --- /dev/null +++ b/packages/typia-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/typia-validator/tsconfig.esm.json b/packages/typia-validator/tsconfig.esm.json new file mode 100644 index 00000000..8130f1a5 --- /dev/null +++ b/packages/typia-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/typia-validator/tsconfig.json b/packages/typia-validator/tsconfig.json new file mode 100644 index 00000000..b9b633f1 --- /dev/null +++ b/packages/typia-validator/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + }, + "include": [ + "src/**/*.ts" + ], + "plugins": [ + { + "transform": "typia/lib/transform" + } + ], +} diff --git a/yarn.lock b/yarn.lock index 85a6e10a..3949f173 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2579,6 +2579,11 @@ array-includes@^3.1.6: get-intrinsic "^1.1.3" is-string "^1.0.7" +array-timsort@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-timsort/-/array-timsort-1.0.3.tgz#3c9e4199e54fb2b9c3fe5976396a21614ef0d926" + integrity sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ== + array-union@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" @@ -3510,6 +3515,17 @@ comment-json@^3.0.2: has-own-prop "^2.0.0" repeat-string "^1.6.1" +comment-json@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/comment-json/-/comment-json-4.2.3.tgz#50b487ebbf43abe44431f575ebda07d30d015365" + integrity sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw== + dependencies: + array-timsort "^1.0.3" + core-util-is "^1.0.3" + esprima "^4.0.1" + has-own-prop "^2.0.0" + repeat-string "^1.6.1" + compress-commons@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-4.1.1.tgz#df2a09a7ed17447642bad10a85cc9a19e5c42a7d" @@ -3617,7 +3633,7 @@ core-util-is@1.0.2: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== -core-util-is@^1.0.2, core-util-is@~1.0.0: +core-util-is@^1.0.2, core-util-is@^1.0.3, core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== @@ -4120,6 +4136,11 @@ dotenv@^8.1.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g== +drange@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/drange/-/drange-1.1.1.tgz#b2aecec2aab82fcef11dbbd7b9e32b83f8f6c0b8" + integrity sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA== + duplexer3@^0.1.4: version "0.1.5" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.5.tgz#0b5e4d7bad5de8901ea4440624c8e1d20099217e" @@ -6137,6 +6158,27 @@ inquirer@^8.2.0: through "^2.3.6" wrap-ansi "^7.0.0" +inquirer@^8.2.5: + version "8.2.6" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.6.tgz#733b74888195d8d400a67ac332011b5fae5ea562" + integrity sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.1" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.21" + mute-stream "0.0.8" + ora "^5.4.1" + run-async "^2.4.0" + rxjs "^7.5.5" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + wrap-ansi "^6.0.1" + install-artifact-from-github@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/install-artifact-from-github/-/install-artifact-from-github-1.3.3.tgz#57d89bacfa0f47d7307fe41b6247cda9f9a8079c" @@ -9988,6 +10030,14 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +randexp@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.5.3.tgz#f31c2de3148b30bdeb84b7c3f59b0ebb9fec3738" + integrity sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w== + dependencies: + drange "^1.0.2" + ret "^0.2.0" + range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -10327,6 +10377,11 @@ restore-cursor@^3.1.0: onetime "^5.1.0" signal-exit "^3.0.2" +ret@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.2.2.tgz#b6861782a1f4762dce43402a71eb7a283f44573c" + integrity sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ== + retry-request@^5.0.0: version "5.0.2" resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-5.0.2.tgz#143d85f90c755af407fcc46b7166a4ba520e44da" @@ -11617,6 +11672,16 @@ typescript@^4.7.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typia@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/typia/-/typia-5.0.4.tgz#71fac044326a5cde5dc51c2bff5acc9978fc64b1" + integrity sha512-/Go9GK+l67votbhp77rh2CO4LC4fv9WQ9jzJSQsHRXP6EWJF8LjOZVHwuLRdVRZMygzdAihDD7KlCAZGgCkIkw== + dependencies: + commander "^10.0.0" + comment-json "^4.2.3" + inquirer "^8.2.5" + randexp "^0.5.3" + uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" @@ -12225,7 +12290,7 @@ wrap-ansi@^3.0.1: string-width "^2.1.1" strip-ansi "^4.0.0" -wrap-ansi@^6.2.0: +wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==