feat(ajv-validator): Add Ajv validator middleware (#794)
parent
b0320d91f0
commit
c9f63deb95
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/ajv-validator': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Ajv validator middleware
|
|
@ -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
|
|
@ -38,6 +38,7 @@
|
||||||
"build:effect-validator": "yarn workspace @hono/effect-validator build",
|
"build:effect-validator": "yarn workspace @hono/effect-validator build",
|
||||||
"build:conform-validator": "yarn workspace @hono/conform-validator build",
|
"build:conform-validator": "yarn workspace @hono/conform-validator build",
|
||||||
"build:casbin": "yarn workspace @hono/casbin build",
|
"build:casbin": "yarn workspace @hono/casbin build",
|
||||||
|
"build:ajv-validator": "yarn workspace @hono/ajv-validator build",
|
||||||
"build": "run-p 'build:*'",
|
"build": "run-p 'build:*'",
|
||||||
"lint": "eslint 'packages/**/*.{ts,tsx}'",
|
"lint": "eslint 'packages/**/*.{ts,tsx}'",
|
||||||
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",
|
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",
|
||||||
|
|
|
@ -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 <https://github.com/ikhvost>
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<T, E extends Env, P extends string> = (
|
||||||
|
result: { success: true; data: T } | { success: false; errors: ErrorObject[] },
|
||||||
|
c: Context<E, P>
|
||||||
|
) => Response | Promise<Response> | 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<T>,
|
||||||
|
hook?: Hook<T, E, P>
|
||||||
|
): 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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -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> = T extends Hono<infer _, infer S> ? 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<typeof route>;
|
||||||
|
type Expected = {
|
||||||
|
'/author': {
|
||||||
|
$post: {
|
||||||
|
input: {
|
||||||
|
json: {
|
||||||
|
name: string;
|
||||||
|
age: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
output: {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
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'",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
})
|
33
yarn.lock
33
yarn.lock
|
@ -2407,6 +2407,20 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@hono/arktype-validator@workspace:packages/arktype-validator":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@hono/arktype-validator@workspace:packages/arktype-validator"
|
resolution: "@hono/arktype-validator@workspace:packages/arktype-validator"
|
||||||
|
@ -6119,6 +6133,18 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"ajv@npm:^6.12.4, ajv@npm:^6.12.6":
|
||||||
version: 6.12.6
|
version: 6.12.6
|
||||||
resolution: "ajv@npm:6.12.6"
|
resolution: "ajv@npm:6.12.6"
|
||||||
|
@ -10191,6 +10217,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"fast-url-parser@npm:^1.1.3":
|
||||||
version: 1.1.3
|
version: 1.1.3
|
||||||
resolution: "fast-url-parser@npm:1.1.3"
|
resolution: "fast-url-parser@npm:1.1.3"
|
||||||
|
|
Loading…
Reference in New Issue