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

feat(standard-validator): Add standard schema validation #887

Merged
merged 12 commits into from
Feb 6, 2025
Merged
5 changes: 5 additions & 0 deletions .changeset/modern-bugs-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/standard-validator': minor
---

Initial implementation for Standard Schema support
25 changes: 25 additions & 0 deletions .github/workflows/ci-standard-validator.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: ci-standard-validator
on:
push:
branches: [main]
paths:
- 'packages/standard-validator/**'
pull_request:
branches: ['*']
paths:
- 'packages/standard-validator/**'

jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/standard-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 @@ -41,6 +41,7 @@
"build:ajv-validator": "yarn workspace @hono/ajv-validator build",
"build:tsyringe": "yarn workspace @hono/tsyringe build",
"build:cloudflare-access": "yarn workspace @hono/cloudflare-access build",
"build:standard-validator": "yarn workspace @hono/standard-validator build",
"build": "run-p 'build:*'",
"lint": "eslint 'packages/**/*.{ts,tsx}'",
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",
Expand Down
2 changes: 1 addition & 1 deletion packages/node-ws/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@
"engines": {
"node": ">=18.14.1"
}
}
}
65 changes: 65 additions & 0 deletions packages/standard-validator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Standard Schema validator middleware for Hono

The validator middleware using [Standard Schema Spec](https://github.com/standard-schema/standard-schema) for [Hono](https://honojs.dev) applications.
You can write a schema with any validation library supporting Standard Schema and validate the incoming values.

## Usage


### Basic:
```ts
import { z } from 'zod'
import { sValidator } from '@hono/standard-validator'

const schema = z.object({
name: z.string(),
age: z.number(),
});

app.post('/author', sValidator('json', schema), (c) => {
const data = c.req.valid('json')
return c.json({
success: true,
message: `${data.name} is ${data.age}`,
})
})
```

### Hook:
```ts
app.post(
'/post',
sValidator('json', schema, (result, c) => {
if (!result.success) {
return c.text('Invalid!', 400)
}
})
//...
)
```

### Headers:
Headers are internally transformed to lower-case in Hono. Hence, you will have to make them lower-cased in validation object.
```ts
import { object, string } from 'valibot'
import { sValidator } from '@hono/standard-validator'

const schema = object({
'content-type': string(),
'user-agent': string()
});

app.post('/author', sValidator('header', schema), (c) => {
const headers = c.req.valid('header')
// do something with headers
})
```


## Author

Rokas Muningis <https://github.com/muningis>

## License

MIT
51 changes: 51 additions & 0 deletions packages/standard-validator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@hono/standard-validator",
"version": "0.0.0",
"description": "Validator middleware using Standard Schema",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"test": "tsc --noEmit && vitest --run",
"build": "tsup ./src/index.ts --format esm,cjs --dts",
"publint": "publint",
"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": {
"@standard-schema/spec": "1.0.0",
"hono": ">=3.9.0"
},
"devDependencies": {
"@standard-schema/spec": "1.0.0",
"arktype": "^2.0.0-rc.26",
"hono": "^4.0.10",
"publint": "^0.2.7",
"tsup": "^8.1.0",
"typescript": "^5.7.3",
"valibot": "^1.0.0-beta.9",
"vitest": "^1.4.0",
"zod": "^3.24.0"
}
}
84 changes: 84 additions & 0 deletions packages/standard-validator/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono'
import { validator } from 'hono/validator'
import type { StandardSchemaV1 } from '@standard-schema/spec'

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

type Hook<
T,
E extends Env,
P extends string,
Target extends keyof ValidationTargets = keyof ValidationTargets,
O = {}
> = (
result: (
| { success: boolean; data: T }
| { success: boolean; error: ReadonlyArray<StandardSchemaV1.Issue>; data: T }
) & {
target: Target
},
c: Context<E, P>
) => TOrPromiseOfT<Response | void | TypedResponse<O>>

const isStandardSchemaValidator = (validator: unknown): validator is StandardSchemaV1 =>
!!validator && typeof validator === 'object' && '~standard' in validator

const sValidator = <
Schema extends StandardSchemaV1,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
In = StandardSchemaV1.InferInput<Schema>,
Out = StandardSchemaV1.InferOutput<Schema>,
I extends Input = {
in: HasUndefined<In> extends true
? {
[K in Target]?: In extends ValidationTargets[K]
? In
: { [K2 in keyof In]?: ValidationTargets[K][K2] }
}
: {
[K in Target]: In extends ValidationTargets[K]
? In
: { [K2 in keyof In]: ValidationTargets[K][K2] }
}
out: { [K in Target]: Out }
},
V extends I = I
>(
target: Target,
schema: Schema,
hook?: Hook<StandardSchemaV1.InferOutput<Schema>, E, P, Target>
Copy link
Contributor

@fabian-hiller fabian-hiller Feb 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be Hook<Schema, E, P, Target> instead? At least the Valibot validator middleware implementation is using the schema object and not its output. But maybe this is a bug in the Valibot validator middleware.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valibot and Zod has different Hook implementation, this one is based on Zod.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yusukebe should this be uniformed?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yusukebe should this be uniformed?

It's better to unify the implementation. Following Zod Validator is good. The current implementation in this PR is okay, but we should change the implementation of the Valibot Validator.

): MiddlewareHandler<E, P, V> =>
// @ts-expect-error not typed well
validator(target, async (value, c) => {
const result = await schema['~standard'].validate(value)

if (hook) {
const hookResult = await hook(
!!result.issues
? { data: value, error: result.issues, success: false, target }
: { data: value, success: true, target },
c
)
if (hookResult) {
if (hookResult instanceof Response) {
return hookResult
}

if ('response' in hookResult) {
return hookResult.response
}
}
}

if (result.issues) {
return c.json({ data: value, error: result.issues, success: false }, 400)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably not a problem in this implementation, but the Valibot validator middleware uses output instead of data for the output value. Could it be that the Valibot implementation should be updated?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm. Does valibot transform values if it fails to validate? Personally, I think, ideally, we should return whatever was passed, before transformations and defaults, in case of failed validation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, Valibot always provides an output that can contain transformations when using safeParse or safeParseAsync. I have no opinion on the implementation. I just noticed it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working with original data IMO has slightly more better handling if there’s something you need to do what’s not available/possible with schema and/or want to use it for observability. Would be nice to hear what @yusukebe has to say on this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's okay with @muningis's implementation for this Standard Validator. It's helpful to see the original data. Regarding the Validabot Validator, I think both updating and not are okay.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would probably be good/nice to update Valibot Validator, but that would be ugly breaking change :/

}

return result.value as StandardSchemaV1.InferOutput<Schema>
})

export type { Hook }
export { sValidator }
36 changes: 36 additions & 0 deletions packages/standard-validator/test/__schemas__/arktype.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type } from 'arktype'

const personJSONSchema = type({
name: 'string',
age: 'number',
})

const postJSONSchema = type({
id: 'number',
title: 'string',
})

const idJSONSchema = type({
id: 'string',
})

const queryNameSchema = type({
'name?': 'string',
})

const queryPaginationSchema = type({
page: type('unknown').pipe((p) => Number(p)),
})

const querySortSchema = type({
order: "'asc'|'desc'",
})

export {
idJSONSchema,
personJSONSchema,
postJSONSchema,
queryNameSchema,
queryPaginationSchema,
querySortSchema,
}
38 changes: 38 additions & 0 deletions packages/standard-validator/test/__schemas__/valibot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { object, string, number, optional, pipe, unknown, transform, picklist } from 'valibot'

const personJSONSchema = object({
name: string(),
age: number(),
})

const postJSONSchema = object({
id: number(),
title: string(),
})

const idJSONSchema = object({
id: string(),
})

const queryNameSchema = optional(
object({
name: optional(string()),
})
)

const queryPaginationSchema = object({
page: pipe(unknown(), transform(Number)),
})

const querySortSchema = object({
order: picklist(['asc', 'desc']),
})

export {
idJSONSchema,
personJSONSchema,
postJSONSchema,
queryNameSchema,
queryPaginationSchema,
querySortSchema,
}
38 changes: 38 additions & 0 deletions packages/standard-validator/test/__schemas__/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from 'zod'

const personJSONSchema = z.object({
name: z.string(),
age: z.number(),
})

const postJSONSchema = z.object({
id: z.number(),
title: z.string(),
})

const idJSONSchema = z.object({
id: z.string(),
})

const queryNameSchema = z
.object({
name: z.string().optional(),
})
.optional()

const queryPaginationSchema = z.object({
page: z.coerce.number(),
})

const querySortSchema = z.object({
order: z.enum(['asc', 'desc']),
})

export {
idJSONSchema,
personJSONSchema,
postJSONSchema,
queryNameSchema,
queryPaginationSchema,
querySortSchema,
}
Loading