Skip to content

Commit f77d7ba

Browse files
authored
feat(standard-validator): Add standard schema validation (#887)
* feat(standard-validator): Add standard schema validation * feat(standard-validator): add changeset * feat(standard-validator): reintroduce type tests * feat(standard-validator): simplif tests * build(standard-validator): add gitlab pipeline * chore(standard-validator): remove redundant files * feat(standard-validator): cleanup tests, adjust comments * fix(standard-validator): adjust versions, fix doc * build: fix lockfile * feat(standard-validator): drop headers lower-casing, update readme * check types in test dir and add `tsc` to the test command
1 parent 4e4e40c commit f77d7ba

File tree

15 files changed

+810
-2
lines changed

15 files changed

+810
-2
lines changed

.changeset/modern-bugs-search.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hono/standard-validator': minor
3+
---
4+
5+
Initial implementation for Standard Schema support
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: ci-standard-validator
2+
on:
3+
push:
4+
branches: [main]
5+
paths:
6+
- 'packages/standard-validator/**'
7+
pull_request:
8+
branches: ['*']
9+
paths:
10+
- 'packages/standard-validator/**'
11+
12+
jobs:
13+
ci:
14+
runs-on: ubuntu-latest
15+
defaults:
16+
run:
17+
working-directory: ./packages/standard-validator
18+
steps:
19+
- uses: actions/checkout@v4
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: 20.x
23+
- run: yarn install --frozen-lockfile
24+
- run: yarn build
25+
- run: yarn test

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"build:ajv-validator": "yarn workspace @hono/ajv-validator build",
4242
"build:tsyringe": "yarn workspace @hono/tsyringe build",
4343
"build:cloudflare-access": "yarn workspace @hono/cloudflare-access build",
44+
"build:standard-validator": "yarn workspace @hono/standard-validator build",
4445
"build": "run-p 'build:*'",
4546
"lint": "eslint 'packages/**/*.{ts,tsx}'",
4647
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",

packages/node-ws/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,4 @@
4848
"engines": {
4949
"node": ">=18.14.1"
5050
}
51-
}
51+
}

packages/standard-validator/README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Standard Schema validator middleware for Hono
2+
3+
The validator middleware using [Standard Schema Spec](https://github.com/standard-schema/standard-schema) for [Hono](https://honojs.dev) applications.
4+
You can write a schema with any validation library supporting Standard Schema and validate the incoming values.
5+
6+
## Usage
7+
8+
9+
### Basic:
10+
```ts
11+
import { z } from 'zod'
12+
import { sValidator } from '@hono/standard-validator'
13+
14+
const schema = z.object({
15+
name: z.string(),
16+
age: z.number(),
17+
});
18+
19+
app.post('/author', sValidator('json', schema), (c) => {
20+
const data = c.req.valid('json')
21+
return c.json({
22+
success: true,
23+
message: `${data.name} is ${data.age}`,
24+
})
25+
})
26+
```
27+
28+
### Hook:
29+
```ts
30+
app.post(
31+
'/post',
32+
sValidator('json', schema, (result, c) => {
33+
if (!result.success) {
34+
return c.text('Invalid!', 400)
35+
}
36+
})
37+
//...
38+
)
39+
```
40+
41+
### Headers:
42+
Headers are internally transformed to lower-case in Hono. Hence, you will have to make them lower-cased in validation object.
43+
```ts
44+
import { object, string } from 'valibot'
45+
import { sValidator } from '@hono/standard-validator'
46+
47+
const schema = object({
48+
'content-type': string(),
49+
'user-agent': string()
50+
});
51+
52+
app.post('/author', sValidator('header', schema), (c) => {
53+
const headers = c.req.valid('header')
54+
// do something with headers
55+
})
56+
```
57+
58+
59+
## Author
60+
61+
Rokas Muningis <https://github.com/muningis>
62+
63+
## License
64+
65+
MIT
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "@hono/standard-validator",
3+
"version": "0.0.0",
4+
"description": "Validator middleware using Standard Schema",
5+
"type": "module",
6+
"main": "dist/index.cjs",
7+
"module": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.js",
13+
"require": "./dist/index.cjs"
14+
}
15+
},
16+
"files": [
17+
"dist"
18+
],
19+
"scripts": {
20+
"test": "tsc --noEmit && vitest --run",
21+
"build": "tsup ./src/index.ts --format esm,cjs --dts",
22+
"publint": "publint",
23+
"prerelease": "yarn build && yarn test",
24+
"release": "yarn publish"
25+
},
26+
"license": "MIT",
27+
"publishConfig": {
28+
"registry": "https://registry.npmjs.org",
29+
"access": "public"
30+
},
31+
"repository": {
32+
"type": "git",
33+
"url": "https://github.com/honojs/middleware.git"
34+
},
35+
"homepage": "https://github.com/honojs/middleware",
36+
"peerDependencies": {
37+
"@standard-schema/spec": "1.0.0",
38+
"hono": ">=3.9.0"
39+
},
40+
"devDependencies": {
41+
"@standard-schema/spec": "1.0.0",
42+
"arktype": "^2.0.0-rc.26",
43+
"hono": "^4.0.10",
44+
"publint": "^0.2.7",
45+
"tsup": "^8.1.0",
46+
"typescript": "^5.7.3",
47+
"valibot": "^1.0.0-beta.9",
48+
"vitest": "^1.4.0",
49+
"zod": "^3.24.0"
50+
}
51+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono'
2+
import { validator } from 'hono/validator'
3+
import type { StandardSchemaV1 } from '@standard-schema/spec'
4+
5+
type HasUndefined<T> = undefined extends T ? true : false
6+
type TOrPromiseOfT<T> = T | Promise<T>
7+
8+
type Hook<
9+
T,
10+
E extends Env,
11+
P extends string,
12+
Target extends keyof ValidationTargets = keyof ValidationTargets,
13+
O = {}
14+
> = (
15+
result: (
16+
| { success: boolean; data: T }
17+
| { success: boolean; error: ReadonlyArray<StandardSchemaV1.Issue>; data: T }
18+
) & {
19+
target: Target
20+
},
21+
c: Context<E, P>
22+
) => TOrPromiseOfT<Response | void | TypedResponse<O>>
23+
24+
const isStandardSchemaValidator = (validator: unknown): validator is StandardSchemaV1 =>
25+
!!validator && typeof validator === 'object' && '~standard' in validator
26+
27+
const sValidator = <
28+
Schema extends StandardSchemaV1,
29+
Target extends keyof ValidationTargets,
30+
E extends Env,
31+
P extends string,
32+
In = StandardSchemaV1.InferInput<Schema>,
33+
Out = StandardSchemaV1.InferOutput<Schema>,
34+
I extends Input = {
35+
in: HasUndefined<In> extends true
36+
? {
37+
[K in Target]?: In extends ValidationTargets[K]
38+
? In
39+
: { [K2 in keyof In]?: ValidationTargets[K][K2] }
40+
}
41+
: {
42+
[K in Target]: In extends ValidationTargets[K]
43+
? In
44+
: { [K2 in keyof In]: ValidationTargets[K][K2] }
45+
}
46+
out: { [K in Target]: Out }
47+
},
48+
V extends I = I
49+
>(
50+
target: Target,
51+
schema: Schema,
52+
hook?: Hook<StandardSchemaV1.InferOutput<Schema>, E, P, Target>
53+
): MiddlewareHandler<E, P, V> =>
54+
// @ts-expect-error not typed well
55+
validator(target, async (value, c) => {
56+
const result = await schema['~standard'].validate(value)
57+
58+
if (hook) {
59+
const hookResult = await hook(
60+
!!result.issues
61+
? { data: value, error: result.issues, success: false, target }
62+
: { data: value, success: true, target },
63+
c
64+
)
65+
if (hookResult) {
66+
if (hookResult instanceof Response) {
67+
return hookResult
68+
}
69+
70+
if ('response' in hookResult) {
71+
return hookResult.response
72+
}
73+
}
74+
}
75+
76+
if (result.issues) {
77+
return c.json({ data: value, error: result.issues, success: false }, 400)
78+
}
79+
80+
return result.value as StandardSchemaV1.InferOutput<Schema>
81+
})
82+
83+
export type { Hook }
84+
export { sValidator }
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { type } from 'arktype'
2+
3+
const personJSONSchema = type({
4+
name: 'string',
5+
age: 'number',
6+
})
7+
8+
const postJSONSchema = type({
9+
id: 'number',
10+
title: 'string',
11+
})
12+
13+
const idJSONSchema = type({
14+
id: 'string',
15+
})
16+
17+
const queryNameSchema = type({
18+
'name?': 'string',
19+
})
20+
21+
const queryPaginationSchema = type({
22+
page: type('unknown').pipe((p) => Number(p)),
23+
})
24+
25+
const querySortSchema = type({
26+
order: "'asc'|'desc'",
27+
})
28+
29+
export {
30+
idJSONSchema,
31+
personJSONSchema,
32+
postJSONSchema,
33+
queryNameSchema,
34+
queryPaginationSchema,
35+
querySortSchema,
36+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { object, string, number, optional, pipe, unknown, transform, picklist } from 'valibot'
2+
3+
const personJSONSchema = object({
4+
name: string(),
5+
age: number(),
6+
})
7+
8+
const postJSONSchema = object({
9+
id: number(),
10+
title: string(),
11+
})
12+
13+
const idJSONSchema = object({
14+
id: string(),
15+
})
16+
17+
const queryNameSchema = optional(
18+
object({
19+
name: optional(string()),
20+
})
21+
)
22+
23+
const queryPaginationSchema = object({
24+
page: pipe(unknown(), transform(Number)),
25+
})
26+
27+
const querySortSchema = object({
28+
order: picklist(['asc', 'desc']),
29+
})
30+
31+
export {
32+
idJSONSchema,
33+
personJSONSchema,
34+
postJSONSchema,
35+
queryNameSchema,
36+
queryPaginationSchema,
37+
querySortSchema,
38+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { z } from 'zod'
2+
3+
const personJSONSchema = z.object({
4+
name: z.string(),
5+
age: z.number(),
6+
})
7+
8+
const postJSONSchema = z.object({
9+
id: z.number(),
10+
title: z.string(),
11+
})
12+
13+
const idJSONSchema = z.object({
14+
id: z.string(),
15+
})
16+
17+
const queryNameSchema = z
18+
.object({
19+
name: z.string().optional(),
20+
})
21+
.optional()
22+
23+
const queryPaginationSchema = z.object({
24+
page: z.coerce.number(),
25+
})
26+
27+
const querySortSchema = z.object({
28+
order: z.enum(['asc', 'desc']),
29+
})
30+
31+
export {
32+
idJSONSchema,
33+
personJSONSchema,
34+
postJSONSchema,
35+
queryNameSchema,
36+
queryPaginationSchema,
37+
querySortSchema,
38+
}

0 commit comments

Comments
 (0)