-
Notifications
You must be signed in to change notification settings - Fork 164
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add Effect Schema validator (#589)
* First commit * Add basic Effect Schema validator * chore(effect-validator): Change name of middleware to `effect-validator` and fix devDependencies * chores(effect-validator): Update yarn.lock * Remove bun lock file * chores(effect-validator): Add github workflow * chores: Update yarn.lock * refactor the code and correct settings * remove unnecessary files --------- Co-authored-by: Yusuke Wada <[email protected]>
- Loading branch information
Showing
9 changed files
with
694 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,5 @@ | ||
--- | ||
'@hono/schema-validator': major | ||
--- | ||
|
||
Add new basic Effect Schema validator middleware |
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,25 @@ | ||
name: ci-effect-validator | ||
on: | ||
push: | ||
branches: [main] | ||
paths: | ||
- 'packages/effect-validator/**' | ||
pull_request: | ||
branches: ['*'] | ||
paths: | ||
- 'packages/effect-validator/**' | ||
|
||
jobs: | ||
ci: | ||
runs-on: ubuntu-latest | ||
defaults: | ||
run: | ||
working-directory: ./packages/effect-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 |
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,51 @@ | ||
# Effect Schema Validator Middleware for Hono | ||
|
||
This package provides a validator middleware using [Effect Schema](https://github.com/Effect-TS/effect/blob/main/packages/schema/README.md) for [Hono](https://honojs.dev) applications. With this middleware, you can define schemas using Effect Schema and validate incoming data in your Hono routes. | ||
|
||
## Why Effect Schema? | ||
|
||
Effect Schema offers several advantages over other validation libraries: | ||
|
||
1. Bidirectional transformations: Effect Schema can both decode and encode data. | ||
2. Integration with Effect: It inherits benefits from the Effect ecosystem, such as dependency tracking in transformations. | ||
3. Highly customizable: Users can attach meta-information through annotations. | ||
4. Functional programming style: Uses combinators and transformations for schema definition. | ||
|
||
## Usage | ||
|
||
```ts | ||
import { Hono } from 'hono' | ||
import { Schema as S } from '@effect/schema' | ||
import { effectValidator } from '@hono/effect-validator' | ||
|
||
const app = new Hono() | ||
|
||
const User = S.Struct({ | ||
name: S.String, | ||
age: S.Number, | ||
}) | ||
|
||
app.post('/user', effectValidator('json', User), (c) => { | ||
const user = c.req.valid('json') | ||
|
||
return c.json({ | ||
success: true, | ||
message: `${user.name} is ${user.age}`, | ||
}) | ||
}) | ||
``` | ||
|
||
## API | ||
|
||
### `effectValidator(target, schema)` | ||
|
||
- `target`: The target of validation ('json', 'form', 'query', etc.) | ||
- `schema`: An Effect Schema schema | ||
|
||
## Author | ||
|
||
Günther Brunner <https://github.com/gunta> | ||
|
||
## 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,52 @@ | ||
{ | ||
"name": "@hono/effect-validator", | ||
"version": "0.0.0", | ||
"description": "Validator middleware using Effect Schema", | ||
"type": "module", | ||
"main": "dist/cjs/index.js", | ||
"module": "dist/esm/index.js", | ||
"types": "dist/esm/index.d.ts", | ||
"exports": { | ||
".": { | ||
"import": { | ||
"types": "./dist/index.d.ts", | ||
"default": "./dist/index.js" | ||
}, | ||
"require": { | ||
"types": "./dist/index.d.cts", | ||
"default": "./dist/index.cjs" | ||
} | ||
} | ||
}, | ||
"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" | ||
}, | ||
"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": { | ||
"@effect/schema": ">=0.68.18", | ||
"hono": ">=4.4.13" | ||
}, | ||
"devDependencies": { | ||
"@effect/schema": "^0.68.21", | ||
"effect": "^3.4.8", | ||
"hono": "^4.4.13", | ||
"tsup": "^8.1.0", | ||
"typescript": "^5.5.3", | ||
"vitest": "^2.0.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,54 @@ | ||
import * as S from '@effect/schema/Schema' | ||
import { Either } from 'effect' | ||
import type { Env, Input, MiddlewareHandler, ValidationTargets } from 'hono' | ||
import type { Simplify } from 'hono/utils/types' | ||
import { validator } from 'hono/validator' | ||
|
||
type RemoveReadonly<T> = { -readonly [P in keyof T]: RemoveReadonly<T[P]> } | ||
|
||
type HasUndefined<T> = undefined extends T ? true : false | ||
|
||
export const effectValidator = < | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
T extends S.Schema.Variance<any, any, any>, | ||
Target extends keyof ValidationTargets, | ||
E extends Env, | ||
P extends string, | ||
In = Simplify<RemoveReadonly<S.Schema.Type<T>>>, | ||
Out = Simplify<RemoveReadonly<S.Schema.Type<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]: Out } | ||
}, | ||
V extends I = I | ||
>( | ||
target: Target, | ||
schema: T | ||
): MiddlewareHandler<E, P, V> => | ||
// @ts-expect-error not typed well | ||
validator(target, async (value, c) => { | ||
// @ts-expect-error not typed well | ||
const result = S.decodeUnknownEither(schema)(value) | ||
|
||
return Either.match(result, { | ||
onLeft: (error) => c.json({ success: false, error: JSON.parse(JSON.stringify(error)) }, 400), | ||
onRight: (data) => { | ||
c.req.addValidatedData(target, data as object) | ||
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,152 @@ | ||
import { Schema as S } from '@effect/schema' | ||
import { Hono } from 'hono' | ||
import type { StatusCode } from 'hono/utils/http-status' | ||
import type { Equal, Expect } from 'hono/utils/types' | ||
import { effectValidator } 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 = S.Struct({ | ||
name: S.String, | ||
age: S.Number, | ||
}) | ||
|
||
const querySchema = S.Union( | ||
S.Struct({ | ||
name: S.optional(S.String), | ||
}), | ||
S.Undefined | ||
) | ||
|
||
const route = app.post( | ||
'/author', | ||
effectValidator('json', jsonSchema), | ||
effectValidator('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 | string[] | undefined | ||
} | ||
} | ||
output: { | ||
success: boolean | ||
message: string | ||
queryName: string | undefined | ||
} | ||
outputFormat: 'json' | ||
status: StatusCode | ||
} | ||
} | ||
} | ||
|
||
// 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', | ||
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('coerce', () => { | ||
const app = new Hono() | ||
|
||
const querySchema = S.Struct({ | ||
page: S.NumberFromString, | ||
}) | ||
|
||
const route = app.get('/page', effectValidator('query', querySchema), (c) => { | ||
const { page } = c.req.valid('query') | ||
return c.json({ page }) | ||
}) | ||
|
||
type Actual = ExtractSchema<typeof route> | ||
type Expected = { | ||
'/page': { | ||
$get: { | ||
input: { | ||
query: { | ||
page: string | string[] | ||
} | ||
} | ||
output: { | ||
page: number | ||
} | ||
outputFormat: 'json' | ||
status: StatusCode | ||
} | ||
} | ||
} | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
type verify = Expect<Equal<Expected, Actual>> | ||
|
||
it('Should return 200 response', async () => { | ||
const res = await app.request('/page?page=123') | ||
expect(res).not.toBeNull() | ||
expect(res.status).toBe(200) | ||
expect(await res.json()).toEqual({ | ||
page: 123, | ||
}) | ||
}) | ||
}) |
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,10 @@ | ||
{ | ||
"extends": "../../tsconfig.json", | ||
"compilerOptions": { | ||
"rootDir": "./src", | ||
"exactOptionalPropertyTypes": true | ||
}, | ||
"include": [ | ||
"src/**/*.ts" | ||
], | ||
} |
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 @@ | ||
/// <reference types="vitest" /> | ||
import { defineConfig } from 'vitest/config' | ||
|
||
export default defineConfig({ | ||
test: { | ||
globals: true, | ||
}, | ||
}) |
Oops, something went wrong.