Skip to content

Commit

Permalink
feat: Add Effect Schema validator (#589)
Browse files Browse the repository at this point in the history
* 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
gunta and yusukebe authored Jul 11, 2024
1 parent d722b19 commit 95eb48c
Show file tree
Hide file tree
Showing 9 changed files with 694 additions and 0 deletions.
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
---

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

0 comments on commit 95eb48c

Please sign in to comment.