Skip to content

Commit

Permalink
feat: add schema validator middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
sebastianwessel committed Jan 30, 2024
1 parent e8b494b commit 48ab612
Show file tree
Hide file tree
Showing 11 changed files with 461 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .changeset/cyan-penguins-bathe.md
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.
5 changes: 5 additions & 0 deletions packages/schema-validator/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @hono/schema-validator

## 1.0.0

### Major Changes
56 changes: 56 additions & 0 deletions packages/schema-validator/README.md
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
1 change: 1 addition & 0 deletions packages/schema-validator/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../jest.config.js')
42 changes: 42 additions & 0 deletions packages/schema-validator/package.json
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"
}
}
91 changes: 91 additions & 0 deletions packages/schema-validator/src/index.ts
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
})
163 changes: 163 additions & 0 deletions packages/schema-validator/test/index.test.ts
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!')
})
})
8 changes: 8 additions & 0 deletions packages/schema-validator/tsconfig.cjs.json
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"
}
}
8 changes: 8 additions & 0 deletions packages/schema-validator/tsconfig.esm.json
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"
}
}
Loading

0 comments on commit 48ab612

Please sign in to comment.