diff --git a/.changeset/two-poets-vanish.md b/.changeset/two-poets-vanish.md new file mode 100644 index 000000000..796fe4c7d --- /dev/null +++ b/.changeset/two-poets-vanish.md @@ -0,0 +1,5 @@ +--- +'@hono/ajv-validator': patch +--- + +Add Ajv validator middleware diff --git a/.github/workflows/ci-ajv-validator.yml b/.github/workflows/ci-ajv-validator.yml new file mode 100644 index 000000000..46ef330e8 --- /dev/null +++ b/.github/workflows/ci-ajv-validator.yml @@ -0,0 +1,25 @@ +name: ci-ajv-validator +on: + push: + branches: [main] + paths: + - 'packages/ajv-validator/**' + pull_request: + branches: ['*'] + paths: + - 'packages/ajv-validator/**' + +jobs: + ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./packages/ajv-validator + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + - run: yarn install --frozen-lockfile + - run: yarn build + - run: yarn test diff --git a/package.json b/package.json index f57a51c23..989faadfe 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "build:effect-validator": "yarn workspace @hono/effect-validator build", "build:conform-validator": "yarn workspace @hono/conform-validator build", "build:casbin": "yarn workspace @hono/casbin build", + "build:ajv-validator": "yarn workspace @hono/ajv-validator build", "build": "run-p 'build:*'", "lint": "eslint 'packages/**/*.{ts,tsx}'", "lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'", diff --git a/packages/ajv-validator/README.md b/packages/ajv-validator/README.md new file mode 100644 index 000000000..cd02581f5 --- /dev/null +++ b/packages/ajv-validator/README.md @@ -0,0 +1,63 @@ +# Ajv validator middleware for Hono + +Validator middleware using [Ajv](https://github.com/ajv-validator/ajv) for [Hono](https://honojs.dev) applications. +Define your schema with Ajv and validate incoming requests. + +## Usage + +No Hook: + +```ts +import { type JSONSchemaType } from 'ajv'; +import { ajvValidator } from '@hono/ajv-validator'; + +const schema: JSONSchemaType<{ name: string; age: number }> = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + additionalProperties: false, +} as const; + +const route = app.post('/user', ajvValidator('json', schema), (c) => { + const user = c.req.valid('json'); + return c.json({ success: true, message: `${user.name} is ${user.age}` }); +}); +``` + +Hook: + +```ts +import { type JSONSchemaType } from 'ajv'; +import { ajvValidator } from '@hono/ajv-validator'; + +const schema: JSONSchemaType<{ name: string; age: number }> = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + additionalProperties: false, +}; + +app.post( + '/user', + ajvValidator('json', schema, (result, c) => { + if (!result.success) { + return c.text('Invalid!', 400); + } + }) + //... +); +``` + +## Author + +Illia Khvost + +## License + +MIT diff --git a/packages/ajv-validator/package.json b/packages/ajv-validator/package.json new file mode 100644 index 000000000..c2ca4be9a --- /dev/null +++ b/packages/ajv-validator/package.json @@ -0,0 +1,49 @@ +{ + "name": "@hono/ajv-validator", + "version": "0.0.0", + "description": "Validator middleware using Ajv", + "type": "module", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "test": "vitest --run", + "build": "tsup ./src/index.ts --format esm,cjs --dts", + "publint": "publint", + "release": "yarn build && yarn test && yarn publint && yarn publish" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "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": { + "ajv": ">=8.12.0", + "hono": ">=3.9.0" + }, + "devDependencies": { + "ajv": ">=8.12.0", + "hono": "^4.4.12", + "tsup": "^8.1.0", + "vitest": "^1.6.0" + } +} diff --git a/packages/ajv-validator/src/index.ts b/packages/ajv-validator/src/index.ts new file mode 100644 index 000000000..0e8cc8e97 --- /dev/null +++ b/packages/ajv-validator/src/index.ts @@ -0,0 +1,106 @@ +import type { Context, Env, MiddlewareHandler, ValidationTargets } from 'hono'; +import { validator } from 'hono/validator'; +import { Ajv, type JSONSchemaType, type ErrorObject } from 'ajv'; + +type Hook = ( + result: { success: true; data: T } | { success: false; errors: ErrorObject[] }, + c: Context +) => Response | Promise | void; + +/** + * Hono middleware that validates incoming data via an Ajv JSON schema. + * + * --- + * + * No Hook + * + * ```ts + * import { type JSONSchemaType } from 'ajv'; + * import { ajvValidator } from '@hono/ajv-validator'; + * + * const schema: JSONSchemaType<{ name: string; age: number }> = { + * type: 'object', + * properties: { + * name: { type: 'string' }, + * age: { type: 'number' }, + * }, + * required: ['name', 'age'], + * additionalProperties: false, + * }; + * + * const route = app.post('/user', ajvValidator('json', schema), (c) => { + * const user = c.req.valid('json'); + * return c.json({ success: true, message: `${user.name} is ${user.age}` }); + * }); + * ``` + * + * --- + * Hook + * + * ```ts + * import { type JSONSchemaType } from 'ajv'; + * import { ajvValidator } from '@hono/ajv-validator'; + * + * const schema: JSONSchemaType<{ name: string; age: number }> = { + * type: 'object', + * properties: { + * name: { type: 'string' }, + * age: { type: 'number' }, + * }, + * required: ['name', 'age'], + * additionalProperties: false, + * }; + * + * app.post( + * '/user', + * ajvValidator('json', schema, (result, c) => { + * if (!result.success) { + * return c.text('Invalid!', 400); + * } + * }) + * //... + * ); + * ``` + */ +export function ajvValidator< + T, + Target extends keyof ValidationTargets, + E extends Env = Env, + P extends string = string +>( + target: Target, + schema: JSONSchemaType, + hook?: Hook +): MiddlewareHandler< + E, + P, + { + in: { [K in Target]: T }; + out: { [K in Target]: T }; + } +> { + const ajv = new Ajv(); + const validate = ajv.compile(schema); + + return validator(target, (data, c) => { + const valid = validate(data); + if (valid) { + if (hook) { + const hookResult = hook({ success: true, data: data as T }, c); + if (hookResult instanceof Response || hookResult instanceof Promise) { + return hookResult; + } + } + return data as T; + } + + const errors = validate.errors || []; + if (hook) { + const hookResult = hook({ success: false, errors }, c); + if (hookResult instanceof Response || hookResult instanceof Promise) { + return hookResult; + } + } + return c.json({ success: false, errors }, 400); + }); +} diff --git a/packages/ajv-validator/test/index.test.ts b/packages/ajv-validator/test/index.test.ts new file mode 100644 index 000000000..2cc71ffc2 --- /dev/null +++ b/packages/ajv-validator/test/index.test.ts @@ -0,0 +1,200 @@ +import { Hono } from 'hono'; +import type { Equal, Expect } from 'hono/utils/types'; +import { ajvValidator } from '../src'; +import { JSONSchemaType, type ErrorObject } from 'ajv'; + +type ExtractSchema = T extends Hono ? S : never; + +describe('Basic', () => { + const app = new Hono(); + + const schema: JSONSchemaType<{ name: string; age: number }> = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + additionalProperties: false, + }; + + const route = app.post('/author', ajvValidator('json', schema), (c) => { + const data = c.req.valid('json'); + return c.json({ + 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; + }; + }; + }; + }; + + 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', + headers: { + 'Content-Type': 'application/json', + }, + }); + 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', + headers: { + 'Content-Type': 'application/json', + }, + }); + 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: JSONSchemaType<{ id: number; title: string }> = { + type: 'object', + properties: { + id: { type: 'number' }, + title: { type: 'string' }, + }, + required: ['id', 'title'], + additionalProperties: false, + }; + + app + .post( + '/post', + ajvValidator('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}`, + }); + } + ) + .post( + '/errorTest', + ajvValidator('json', schema, (result, c) => { + return c.json(result, 400); + }), + (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', + headers: { + 'Content-Type': 'application/json', + }, + }); + 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', + headers: { + 'Content-Type': 'application/json', + }, + }); + const res = await app.request(req); + expect(res).not.toBeNull(); + expect(res.status).toBe(400); + }); + + it('Should return 400 response and error array', async () => { + const req = new Request('http://localhost/errorTest', { + body: JSON.stringify({ + id: 123, + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + const res = await app.request(req); + expect(res).not.toBeNull(); + expect(res.status).toBe(400); + + const { errors, success } = (await res.json()) as { + success: boolean; + errors: ErrorObject[]; + }; + expect(success).toBe(false); + expect(Array.isArray(errors)).toBe(true); + expect( + errors.map((e: ErrorObject) => ({ + keyword: e.keyword, + instancePath: e.instancePath, + message: e.message, + })) + ).toEqual([ + { + keyword: 'required', + instancePath: '', + message: "must have required property 'title'", + }, + ]); + }); +}); diff --git a/packages/ajv-validator/tsconfig.json b/packages/ajv-validator/tsconfig.json new file mode 100644 index 000000000..acfcd8430 --- /dev/null +++ b/packages/ajv-validator/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + }, + "include": [ + "src/**/*.ts" + ], +} \ No newline at end of file diff --git a/packages/ajv-validator/vitest.config.ts b/packages/ajv-validator/vitest.config.ts new file mode 100644 index 000000000..17b54e485 --- /dev/null +++ b/packages/ajv-validator/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, +}) diff --git a/yarn.lock b/yarn.lock index da7c9ee4e..8b66811c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2407,6 +2407,20 @@ __metadata: languageName: node linkType: hard +"@hono/ajv-validator@workspace:packages/ajv-validator": + version: 0.0.0-use.local + resolution: "@hono/ajv-validator@workspace:packages/ajv-validator" + dependencies: + ajv: "npm:>=8.12.0" + hono: "npm:^4.4.12" + tsup: "npm:^8.1.0" + vitest: "npm:^1.6.0" + peerDependencies: + ajv: ">=8.12.0" + hono: ">=3.9.0" + languageName: unknown + linkType: soft + "@hono/arktype-validator@workspace:packages/arktype-validator": version: 0.0.0-use.local resolution: "@hono/arktype-validator@workspace:packages/arktype-validator" @@ -6119,6 +6133,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:>=8.12.0": + version: 8.17.1 + resolution: "ajv@npm:8.17.1" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: ec3ba10a573c6b60f94639ffc53526275917a2df6810e4ab5a6b959d87459f9ef3f00d5e7865b82677cb7d21590355b34da14d1d0b9c32d75f95a187e76fff35 + languageName: node + linkType: hard + "ajv@npm:^6.12.4, ajv@npm:^6.12.6": version: 6.12.6 resolution: "ajv@npm:6.12.6" @@ -10191,6 +10217,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.0.3 + resolution: "fast-uri@npm:3.0.3" + checksum: 4b2c5ce681a062425eae4f15cdc8fc151fd310b2f69b1f96680677820a8b49c3cd6e80661a406e19d50f0c40a3f8bffdd458791baf66f4a879d80be28e10a320 + languageName: node + linkType: hard + "fast-url-parser@npm:^1.1.3": version: 1.1.3 resolution: "fast-url-parser@npm:1.1.3"