-
Notifications
You must be signed in to change notification settings - Fork 200
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add schema validator middleware
- Loading branch information
1 parent
e8b494b
commit 48ab612
Showing
11 changed files
with
461 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
--- | ||
'@hono/schema-validator': major | ||
--- | ||
|
||
Add @hono/schema-validator middleware. | ||
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
# @hono/schema-validator | ||
|
||
## 1.0.0 | ||
|
||
### Major Changes |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
# Universal validator middleware for Hono | ||
|
||
The validator middleware using [TypeSchema](https://typeschema.com) for [Hono](https://honojs.dev) applications. | ||
You can write a schema with various schema libraries and validate the incoming values. | ||
|
||
The preferred validation library must be additionally installed. | ||
The list of supported validation libraries can be found at [TypeSchema](https://typeschema.com/#coverage). | ||
|
||
## Usage | ||
|
||
```ts | ||
import { z } from 'zod' | ||
import { schemaValidator, type ValidationError } from '@hono/schema-validator' | ||
|
||
const schema = z.object({ | ||
name: z.string(), | ||
age: z.number(), | ||
}) | ||
|
||
app.post('/author', schemaValidator('json', schema), (c) => { | ||
const data = c.req.valid('json') | ||
return c.json({ | ||
success: true, | ||
message: `${data.name} is ${data.age}`, | ||
}) | ||
}) | ||
|
||
app.onError(async (err, c) => { | ||
if (err instanceof ValidationError) { | ||
return c.json(err, err.status) | ||
} | ||
return c.text('Internal Server Error', 500) | ||
}) | ||
``` | ||
|
||
Hook: | ||
|
||
```ts | ||
app.post( | ||
'/post', | ||
schemaValidator('json', schema, (result, c) => { | ||
if (!result.success) { | ||
return c.text('Invalid!', 400) | ||
} | ||
}) | ||
//... | ||
) | ||
``` | ||
|
||
## Author | ||
|
||
Sebastian Wessel <https://github.com/sebastianwessel> | ||
|
||
## License | ||
|
||
MIT |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module.exports = require('../../jest.config.js') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
{ | ||
"name": "@hono/schema-validator", | ||
"version": "0.0.0", | ||
"description": "Validator middleware for multiple schema validation libraries based on schema.com", | ||
"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.9.0" | ||
}, | ||
"dependencies": { | ||
"@decs/typeschema": "^0.12.2" | ||
}, | ||
"devDependencies": { | ||
"hono": "^3.11.7", | ||
"jest": "^29.7.0", | ||
"rimraf": "^5.0.5", | ||
"typescript": "^5.3.3", | ||
"zod": "3.19.1" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import type { Infer, InferIn, Schema, ValidationIssue } from '@decs/typeschema' | ||
import { validate } from '@decs/typeschema' | ||
import type { Context, Env, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono' | ||
import { HTTPException } from 'hono/http-exception' | ||
import { validator } from 'hono/validator' | ||
|
||
export type Hook<T, E extends Env, P extends string, O = {}> = ( | ||
result: | ||
| { success: true; data: T; inputData: unknown } | ||
| { success: false; issues: Array<ValidationIssue>; inputData: unknown }, | ||
c: Context<E, P>, | ||
) => Response | Promise<Response> | void | Promise<Response | void> | TypedResponse<O> | ||
|
||
type HasUndefined<T> = undefined extends T ? true : false | ||
|
||
|
||
type HTTPExceptionOptions = { | ||
res?: Response; | ||
message?: string; | ||
data?:unknown | ||
}; | ||
|
||
export class ValidationError extends HTTPException { | ||
|
||
private data:unknown | ||
constructor( | ||
options?: HTTPExceptionOptions, | ||
) { | ||
/* Calling the constructor of the parent class (Error) and passing the message. */ | ||
super(400,options) | ||
this.data=options?.data | ||
Error.captureStackTrace(this, this.constructor) | ||
|
||
Object.setPrototypeOf(this, ValidationError.prototype) | ||
this.name = this.constructor.name | ||
} | ||
|
||
getData(){ | ||
return this.data | ||
} | ||
|
||
toJSON(){ | ||
return { | ||
status: this.status, | ||
message: this.message, | ||
data: this.data | ||
} | ||
} | ||
} | ||
|
||
export const schemaValidator = < | ||
T extends Schema, | ||
Target extends keyof ValidationTargets, | ||
E extends Env, | ||
P extends string, | ||
I = InferIn<T>, | ||
O = Infer<T>, | ||
V extends { | ||
in: HasUndefined<I> extends true ? { [K in Target]?: I } : { [K in Target]: I } | ||
out: { [K in Target]: O } | ||
} = { | ||
in: HasUndefined<I> extends true ? { [K in Target]?: I } : { [K in Target]: I } | ||
out: { [K in Target]: O } | ||
}, | ||
>( | ||
target: Target, | ||
schema: T, | ||
hook?: Hook<Infer<T>, E, P>, | ||
): MiddlewareHandler<E, P, V> => | ||
validator(target, async (value, c) => { | ||
const result = await validate(schema, value) | ||
|
||
if (hook) { | ||
const hookResult = hook({ inputData: value, ...result }, c) | ||
if (hookResult) { | ||
if (hookResult instanceof Response || hookResult instanceof Promise) { | ||
return hookResult | ||
} | ||
if ('response' in hookResult) { | ||
return hookResult.response | ||
} | ||
} | ||
} | ||
|
||
if (!result.success) { | ||
throw new ValidationError({ message: 'Custom error message',data:{issues:result.issues,target} }) | ||
} | ||
|
||
const data = result.data as Infer<T> | ||
return data | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
import { Hono } from 'hono' | ||
import type { Equal, Expect } from 'hono/utils/types' | ||
import { z } from 'zod' | ||
import { schemaValidator } from '../src' | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
type ExtractSchema<T> = T extends Hono<infer _, infer S> ? S : never | ||
|
||
describe('Basic', () => { | ||
const app = new Hono() | ||
|
||
const jsonSchema = z.object({ | ||
name: z.string(), | ||
age: z.number(), | ||
}) | ||
|
||
const querySchema = z | ||
.object({ | ||
name: z.string().optional(), | ||
}) | ||
.optional() | ||
|
||
const route = app.post( | ||
'/author', | ||
schemaValidator('json', jsonSchema), | ||
schemaValidator('query', querySchema), | ||
(c) => { | ||
const data = c.req.valid('json') | ||
const query = c.req.valid('query') | ||
|
||
return c.json({ | ||
success: true, | ||
message: `${data.name} is ${data.age}`, | ||
queryName: query?.name, | ||
}) | ||
} | ||
) | ||
|
||
type Actual = ExtractSchema<typeof route> | ||
type Expected = { | ||
'/author': { | ||
$post: { | ||
input: { | ||
json: { | ||
name: string | ||
age: number | ||
} | ||
} & { | ||
query?: | ||
| { | ||
name?: string | undefined | ||
} | ||
| undefined | ||
} | ||
output: { | ||
success: boolean | ||
message: string | ||
queryName: string | undefined | ||
} | ||
} | ||
} | ||
} | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
type verify = Expect<Equal<Expected, Actual>> | ||
|
||
it('Should return 200 response', async () => { | ||
const req = new Request('http://localhost/author?name=Metallo', { | ||
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', | ||
queryName: 'Metallo', | ||
}) | ||
}) | ||
|
||
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:{success:boolean} = await res.json() | ||
expect(data['success']).toBe(false) | ||
}) | ||
}) | ||
|
||
describe('With Hook', () => { | ||
const app = new Hono() | ||
|
||
const schema = z.object({ | ||
id: z.number(), | ||
title: z.string(), | ||
}) | ||
|
||
app.post( | ||
'/post', | ||
schemaValidator('json', schema, (result, c) => { | ||
if (!result.success) { | ||
return c.text(`${(result.inputData as {id:string}).id} is 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', | ||
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) | ||
expect(await res.text()).toBe('123 is invalid!') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{ | ||
"extends": "./tsconfig.json", | ||
"compilerOptions": { | ||
"module": "CommonJS", | ||
"declaration": false, | ||
"outDir": "./dist/cjs" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{ | ||
"extends": "./tsconfig.json", | ||
"compilerOptions": { | ||
"module": "ESNext", | ||
"declaration": true, | ||
"outDir": "./dist/esm" | ||
} | ||
} |
Oops, something went wrong.