Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Effect Schema validator #589

Merged
merged 10 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/poor-clouds-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/schema-validator': major
gunta marked this conversation as resolved.
Show resolved Hide resolved
---

Add new basic Effect Schema validator middleware
25 changes: 25 additions & 0 deletions .github/workflows/ci-effect-validator.yml
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
51 changes: 51 additions & 0 deletions packages/effect-validator/README.md
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
52 changes: 52 additions & 0 deletions packages/effect-validator/package.json
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"
}
}
54 changes: 54 additions & 0 deletions packages/effect-validator/src/index.ts
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
},
})
})
152 changes: 152 additions & 0 deletions packages/effect-validator/test/index.test.ts
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,
})
})
})
10 changes: 10 additions & 0 deletions packages/effect-validator/tsconfig.json
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"
],
}
8 changes: 8 additions & 0 deletions packages/effect-validator/vitest.config.ts
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,
},
})
Loading