Skip to content

Commit

Permalink
feat: class validator middleware for Hono (#788)
Browse files Browse the repository at this point in the history
* setup middleware package

* add implementation middleware

* add documentation + updae tsconfig for decorator handling

* add tests

* fix format class-validator middleware

* Add Readme

* Update changelog & changeset

* update changelog 2

* update changelog 2

* fix working directory ci

* rm jest dependencies

change to tsup for build

fix ci name

* revert changes not related to class-validator

* remove the changeset since Changesets will add a changeset automatically

* package description
  • Loading branch information
pr0m3th3usEx authored Nov 8, 2024
1 parent 28ca0f3 commit a5c20b3
Show file tree
Hide file tree
Showing 11 changed files with 1,212 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/spotty-donuts-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/class-validator': major
---

First release
25 changes: 25 additions & 0 deletions .github/workflows/ci-class-validator.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: ci-class-validator
on:
push:
branches: [main]
paths:
- 'packages/class-validator/**'
pull_request:
branches: ['*']
paths:
- 'packages/class-validator/**'

jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/class-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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"scripts": {
"build:hello": "yarn workspace @hono/hello build",
"build:zod-validator": "yarn workspace @hono/zod-validator build",
"build:class-validator": "yarn workspace @hono/class-validator build",
"build:arktype-validator": "yarn workspace @hono/arktype-validator build",
"build:qwik-city": "yarn workspace @hono/qwik-city build",
"build:graphql-server": "yarn workspace @hono/graphql-server build",
Expand Down
56 changes: 56 additions & 0 deletions packages/class-validator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Class-validator middleware for Hono

The validator middleware using [class-validator](https://github.com/typestack/class-validator) for [Hono](https://github.com/honojs/hono) applications.

## Usage

```ts
import { classValidator } from '@hono/class-validator'
import { IsInt, IsString } from 'class-validator'

class CreateUserDto {
@IsString()
name!: string;

@IsInt()
age!: number;
}


const route = app.post('/user', classValidator('json', CreateUserDto), (c) => {
const user = c.req.valid('json')
return c.json({ success: true, message: `${user.name} is ${user.age}` })
})
```

With hook:

```ts
import { classValidator } from '@hono/class-validator'
import { IsInt, IsString } from 'class-validator'

class CreateUserDto {
@IsString()
name!: string;

@IsInt()
age!: number;
}

app.post(
'/user', classValidator('json', CreateUserDto, (result, c) => {
if (!result.success) {
return c.text('Invalid!', 400)
}
})
//...
)
```

## Author

**Pr0m3ht3us** - https://github.com/pr0m3th3usex

## License

MIT
47 changes: 47 additions & 0 deletions packages/class-validator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@hono/class-validator",
"packageManager": "[email protected]",
"description": "Validator middleware using class-validator",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"test": "vitest --run",
"build": "rimraf dist && tsup ./src/index.ts --format esm,cjs --dts",
"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"
},
"devDependencies": {
"hono": "^4.0.10",
"rimraf": "^5.0.5",
"tsup": "^8.3.5",
"typescript": "^5.3.3",
"vitest": "^1.4.0"
},
"dependencies": {
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2"
}
}
158 changes: 158 additions & 0 deletions packages/class-validator/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import 'reflect-metadata'
import { validator } from 'hono/validator'
import { ClassConstructor, ClassTransformOptions, plainToClass } from 'class-transformer'
import { Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono'
import { ValidationError, validate } from 'class-validator'

/**
* Hono middleware that validates incoming data using class-validator(https://github.com/typestack/class-validator).
*
* ---
*
* No Hook
*
* ```ts
* import { classValidator } from '@hono/class-validator'
* import { IsInt, IsString } from 'class-validator'
*
* class CreateUserDto {
* @IsString()
* name!: string;
*
* @IsInt()
* age!: number;
* }
*
*
* const route = app.post('/user', classValidator('json', CreateUserDto), (c) => {
* const user = c.req.valid('json')
* return c.json({ success: true, message: `${user.name} is ${user.age}` })
* })
* ```
*
* ---
* Hook
*
* ```ts
* import { classValidator } from '@hono/class-validator'
* import { IsInt, IsString } from 'class-validator'
*
* class CreateUserDto {
* @IsString()
* name!: string;
*
* @IsInt()
* age!: number;
* }
*
* app.post(
* '/user',
* classValidator('json', CreateUserDto, (result, c) => {
* if (!result.success) {
* return c.text('Invalid!', 400)
* }
* })
* //...
* )
* ```
*/

type Hook<
T,
E extends Env,
P extends string,
Target extends keyof ValidationTargets = keyof ValidationTargets,
O = object
> = (
result: ({ success: true } | { success: false; errors: ValidationError[] }) & {
data: T
target: Target
},
c: Context<E, P>
) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>

type HasUndefined<T> = undefined extends T ? true : false

type HasClassConstructor<T> = ClassConstructor<any> extends T ? true : false

export type StaticObject<T extends ClassConstructor<any>> = {
[K in keyof InstanceType<T>]: HasClassConstructor<InstanceType<T>[K]> extends true
? StaticObject<InstanceType<T>[K]>
: InstanceType<T>[K]
}

const parseAndValidate = async <T extends ClassConstructor<any>>(
dto: T,
obj: object,
options: ClassTransformOptions
): Promise<
{ success: false; errors: ValidationError[] } | { success: true; output: InstanceType<T> }
> => {
// tranform the literal object to class object
const objInstance = plainToClass(dto, obj, options)
// validating and check the errors, throw the errors if exist

const errors = await validate(objInstance)
// errors is an array of validation errors
if (errors.length > 0) {
return {
success: false,
errors,
}
}

return { success: true, output: objInstance as InstanceType<T> }
}

export const classValidator = <
T extends ClassConstructor<any>,
Output extends InstanceType<T> = InstanceType<T>,
Target extends keyof ValidationTargets = keyof ValidationTargets,
E extends Env = Env,
P extends string = string,
In = StaticObject<T>,
I extends Input = {
in: HasUndefined<In> extends true
? {
[K in Target]?: K extends 'json'
? In
: HasUndefined<keyof ValidationTargets[K]> extends true
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
: { [K2 in keyof In]: ValidationTargets[K][K2] }
}
: {
[K in Target]: K extends 'json'
? In
: HasUndefined<keyof ValidationTargets[K]> extends true
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
: { [K2 in keyof In]: ValidationTargets[K][K2] }
}
out: { [K in Target]: Output }
},
V extends I = I
>(
target: Target,
dataType: T,
hook?: Hook<Output, E, P, Target>,
options: ClassTransformOptions = { enableImplicitConversion: false }
): MiddlewareHandler<E, P, V> =>
// @ts-expect-error not typed well
validator(target, async (data, c) => {
const result = await parseAndValidate(dataType, data, options)

if (hook) {
const hookResult = hook({ ...result, data, target }, c)
if (hookResult instanceof Response || hookResult instanceof Promise) {
if ('response' in hookResult) {
return hookResult.response
}
return hookResult
}
}

if (!result.success) {
return c.json({ errors: result.errors }, 400)
}

return result.output
})
Loading

0 comments on commit a5c20b3

Please sign in to comment.