Skip to content

Commit 48ab612

Browse files
feat: add schema validator middleware
1 parent e8b494b commit 48ab612

File tree

11 files changed

+461
-0
lines changed

11 files changed

+461
-0
lines changed

.changeset/cyan-penguins-bathe.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hono/schema-validator': major
3+
---
4+
5+
Add @hono/schema-validator middleware.
6+
This middleware leverages [TypeSchema](https://typeschema.com), offering an abstraction layer that facilitates interaction with a variety of validation libraries through a unified interface. Consequently, there is no immediate requirement to develop a dedicated middleware for each validation library. This not only reduces maintenance efforts but also extends support to validation libraries that may currently lack compatibility.
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# @hono/schema-validator
2+
3+
## 1.0.0
4+
5+
### Major Changes

packages/schema-validator/README.md

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Universal validator middleware for Hono
2+
3+
The validator middleware using [TypeSchema](https://typeschema.com) for [Hono](https://honojs.dev) applications.
4+
You can write a schema with various schema libraries and validate the incoming values.
5+
6+
The preferred validation library must be additionally installed.
7+
The list of supported validation libraries can be found at [TypeSchema](https://typeschema.com/#coverage).
8+
9+
## Usage
10+
11+
```ts
12+
import { z } from 'zod'
13+
import { schemaValidator, type ValidationError } from '@hono/schema-validator'
14+
15+
const schema = z.object({
16+
name: z.string(),
17+
age: z.number(),
18+
})
19+
20+
app.post('/author', schemaValidator('json', schema), (c) => {
21+
const data = c.req.valid('json')
22+
return c.json({
23+
success: true,
24+
message: `${data.name} is ${data.age}`,
25+
})
26+
})
27+
28+
app.onError(async (err, c) => {
29+
if (err instanceof ValidationError) {
30+
return c.json(err, err.status)
31+
}
32+
return c.text('Internal Server Error', 500)
33+
})
34+
```
35+
36+
Hook:
37+
38+
```ts
39+
app.post(
40+
'/post',
41+
schemaValidator('json', schema, (result, c) => {
42+
if (!result.success) {
43+
return c.text('Invalid!', 400)
44+
}
45+
})
46+
//...
47+
)
48+
```
49+
50+
## Author
51+
52+
Sebastian Wessel <https://github.com/sebastianwessel>
53+
54+
## License
55+
56+
MIT
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('../../jest.config.js')
+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "@hono/schema-validator",
3+
"version": "0.0.0",
4+
"description": "Validator middleware for multiple schema validation libraries based on schema.com",
5+
"main": "dist/cjs/index.js",
6+
"module": "dist/esm/index.js",
7+
"types": "dist/esm/index.d.ts",
8+
"files": [
9+
"dist"
10+
],
11+
"scripts": {
12+
"test": "jest",
13+
"build:cjs": "tsc -p tsconfig.cjs.json",
14+
"build:esm": "tsc -p tsconfig.esm.json",
15+
"build": "rimraf dist && yarn build:cjs && yarn build:esm",
16+
"prerelease": "yarn build && yarn test",
17+
"release": "yarn publish"
18+
},
19+
"license": "MIT",
20+
"publishConfig": {
21+
"registry": "https://registry.npmjs.org",
22+
"access": "public"
23+
},
24+
"repository": {
25+
"type": "git",
26+
"url": "https://github.com/honojs/middleware.git"
27+
},
28+
"homepage": "https://github.com/honojs/middleware",
29+
"peerDependencies": {
30+
"hono": ">=3.9.0"
31+
},
32+
"dependencies": {
33+
"@decs/typeschema": "^0.12.2"
34+
},
35+
"devDependencies": {
36+
"hono": "^3.11.7",
37+
"jest": "^29.7.0",
38+
"rimraf": "^5.0.5",
39+
"typescript": "^5.3.3",
40+
"zod": "3.19.1"
41+
}
42+
}
+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Infer, InferIn, Schema, ValidationIssue } from '@decs/typeschema'
2+
import { validate } from '@decs/typeschema'
3+
import type { Context, Env, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono'
4+
import { HTTPException } from 'hono/http-exception'
5+
import { validator } from 'hono/validator'
6+
7+
export type Hook<T, E extends Env, P extends string, O = {}> = (
8+
result:
9+
| { success: true; data: T; inputData: unknown }
10+
| { success: false; issues: Array<ValidationIssue>; inputData: unknown },
11+
c: Context<E, P>,
12+
) => Response | Promise<Response> | void | Promise<Response | void> | TypedResponse<O>
13+
14+
type HasUndefined<T> = undefined extends T ? true : false
15+
16+
17+
type HTTPExceptionOptions = {
18+
res?: Response;
19+
message?: string;
20+
data?:unknown
21+
};
22+
23+
export class ValidationError extends HTTPException {
24+
25+
private data:unknown
26+
constructor(
27+
options?: HTTPExceptionOptions,
28+
) {
29+
/* Calling the constructor of the parent class (Error) and passing the message. */
30+
super(400,options)
31+
this.data=options?.data
32+
Error.captureStackTrace(this, this.constructor)
33+
34+
Object.setPrototypeOf(this, ValidationError.prototype)
35+
this.name = this.constructor.name
36+
}
37+
38+
getData(){
39+
return this.data
40+
}
41+
42+
toJSON(){
43+
return {
44+
status: this.status,
45+
message: this.message,
46+
data: this.data
47+
}
48+
}
49+
}
50+
51+
export const schemaValidator = <
52+
T extends Schema,
53+
Target extends keyof ValidationTargets,
54+
E extends Env,
55+
P extends string,
56+
I = InferIn<T>,
57+
O = Infer<T>,
58+
V extends {
59+
in: HasUndefined<I> extends true ? { [K in Target]?: I } : { [K in Target]: I }
60+
out: { [K in Target]: O }
61+
} = {
62+
in: HasUndefined<I> extends true ? { [K in Target]?: I } : { [K in Target]: I }
63+
out: { [K in Target]: O }
64+
},
65+
>(
66+
target: Target,
67+
schema: T,
68+
hook?: Hook<Infer<T>, E, P>,
69+
): MiddlewareHandler<E, P, V> =>
70+
validator(target, async (value, c) => {
71+
const result = await validate(schema, value)
72+
73+
if (hook) {
74+
const hookResult = hook({ inputData: value, ...result }, c)
75+
if (hookResult) {
76+
if (hookResult instanceof Response || hookResult instanceof Promise) {
77+
return hookResult
78+
}
79+
if ('response' in hookResult) {
80+
return hookResult.response
81+
}
82+
}
83+
}
84+
85+
if (!result.success) {
86+
throw new ValidationError({ message: 'Custom error message',data:{issues:result.issues,target} })
87+
}
88+
89+
const data = result.data as Infer<T>
90+
return data
91+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { Hono } from 'hono'
2+
import type { Equal, Expect } from 'hono/utils/types'
3+
import { z } from 'zod'
4+
import { schemaValidator } from '../src'
5+
6+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
7+
type ExtractSchema<T> = T extends Hono<infer _, infer S> ? S : never
8+
9+
describe('Basic', () => {
10+
const app = new Hono()
11+
12+
const jsonSchema = z.object({
13+
name: z.string(),
14+
age: z.number(),
15+
})
16+
17+
const querySchema = z
18+
.object({
19+
name: z.string().optional(),
20+
})
21+
.optional()
22+
23+
const route = app.post(
24+
'/author',
25+
schemaValidator('json', jsonSchema),
26+
schemaValidator('query', querySchema),
27+
(c) => {
28+
const data = c.req.valid('json')
29+
const query = c.req.valid('query')
30+
31+
return c.json({
32+
success: true,
33+
message: `${data.name} is ${data.age}`,
34+
queryName: query?.name,
35+
})
36+
}
37+
)
38+
39+
type Actual = ExtractSchema<typeof route>
40+
type Expected = {
41+
'/author': {
42+
$post: {
43+
input: {
44+
json: {
45+
name: string
46+
age: number
47+
}
48+
} & {
49+
query?:
50+
| {
51+
name?: string | undefined
52+
}
53+
| undefined
54+
}
55+
output: {
56+
success: boolean
57+
message: string
58+
queryName: string | undefined
59+
}
60+
}
61+
}
62+
}
63+
64+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
65+
type verify = Expect<Equal<Expected, Actual>>
66+
67+
it('Should return 200 response', async () => {
68+
const req = new Request('http://localhost/author?name=Metallo', {
69+
body: JSON.stringify({
70+
name: 'Superman',
71+
age: 20,
72+
}),
73+
method: 'POST',
74+
headers: {
75+
'Content-Type': 'application/json',
76+
},
77+
})
78+
const res = await app.request(req)
79+
expect(res).not.toBeNull()
80+
expect(res.status).toBe(200)
81+
expect(await res.json()).toEqual({
82+
success: true,
83+
message: 'Superman is 20',
84+
queryName: 'Metallo',
85+
})
86+
})
87+
88+
it('Should return 400 response', async () => {
89+
const req = new Request('http://localhost/author', {
90+
body: JSON.stringify({
91+
name: 'Superman',
92+
age: '20',
93+
}),
94+
method: 'POST',
95+
})
96+
const res = await app.request(req)
97+
expect(res).not.toBeNull()
98+
expect(res.status).toBe(400)
99+
const data:{success:boolean} = await res.json()
100+
expect(data['success']).toBe(false)
101+
})
102+
})
103+
104+
describe('With Hook', () => {
105+
const app = new Hono()
106+
107+
const schema = z.object({
108+
id: z.number(),
109+
title: z.string(),
110+
})
111+
112+
app.post(
113+
'/post',
114+
schemaValidator('json', schema, (result, c) => {
115+
if (!result.success) {
116+
return c.text(`${(result.inputData as {id:string}).id} is invalid!`, 400)
117+
}
118+
const data = result.data
119+
return c.text(`${data.id} is valid!`)
120+
}),
121+
(c) => {
122+
const data = c.req.valid('json')
123+
return c.json({
124+
success: true,
125+
message: `${data.id} is ${data.title}`,
126+
})
127+
}
128+
)
129+
130+
it('Should return 200 response', async () => {
131+
const req = new Request('http://localhost/post', {
132+
body: JSON.stringify({
133+
id: 123,
134+
title: 'Hello',
135+
}),
136+
method: 'POST',
137+
headers: {
138+
'Content-Type': 'application/json',
139+
},
140+
})
141+
const res = await app.request(req)
142+
expect(res).not.toBeNull()
143+
expect(res.status).toBe(200)
144+
expect(await res.text()).toBe('123 is valid!')
145+
})
146+
147+
it('Should return 400 response', async () => {
148+
const req = new Request('http://localhost/post', {
149+
body: JSON.stringify({
150+
id: '123',
151+
title: 'Hello',
152+
}),
153+
method: 'POST',
154+
headers: {
155+
'Content-Type': 'application/json',
156+
},
157+
})
158+
const res = await app.request(req)
159+
expect(res).not.toBeNull()
160+
expect(res.status).toBe(400)
161+
expect(await res.text()).toBe('123 is invalid!')
162+
})
163+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"module": "CommonJS",
5+
"declaration": false,
6+
"outDir": "./dist/cjs"
7+
}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"module": "ESNext",
5+
"declaration": true,
6+
"outDir": "./dist/esm"
7+
}
8+
}

0 commit comments

Comments
 (0)