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
pull/961/head
Rokas Muningis 2025-02-06 14:38:36 +02:00 committed by GitHub
parent 4e4e40cdbf
commit f77d7ba2e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 810 additions and 2 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/standard-validator': minor
---
Initial implementation for Standard Schema support

View File

@ -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

View File

@ -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}'",

View File

@ -48,4 +48,4 @@
"engines": {
"node": ">=18.14.1"
}
}
}

View File

@ -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

View File

@ -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"
}
}

View File

@ -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>
): 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)
}
return result.value as StandardSchemaV1.InferOutput<Schema>
})
export type { Hook }
export { sValidator }

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -0,0 +1,356 @@
import { Hono } from 'hono'
import type { Equal, Expect, UnionToIntersection } from 'hono/utils/types'
import { sValidator } from '../src'
import { vi } from 'vitest'
import * as valibotSchemas from './__schemas__/valibot'
import * as zodSchemas from './__schemas__/zod'
import * as arktypeSchemas from './__schemas__/arktype'
type ExtractSchema<T> = T extends Hono<infer _, infer S> ? S : never
type MergeDiscriminatedUnion<U> = UnionToIntersection<U> extends infer O
? { [K in keyof O]: O[K] }
: never
const libs = ['valibot', 'zod', 'arktype'] as const
const schemasByLibrary = {
valibot: valibotSchemas,
zod: zodSchemas,
arktype: arktypeSchemas,
}
describe('Standard Schema Validation', () => {
libs.forEach((lib) => {
const schemas = schemasByLibrary[lib]
describe(`Using ${lib} schemas for validation`, () => {
describe('Basic', () => {
const app = new Hono()
const route = app.post(
'/author',
sValidator('json', schemas.personJSONSchema),
sValidator('query', schemas.queryNameSchema),
(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 verifyOutput = Expect<
Equal<
{
success: boolean
message: string
queryName: string | undefined
},
MergeDiscriminatedUnion<Actual['/author']['$post']['output']>
>
>
type verifyJSONInput = Expect<
Equal<
{
name: string
age: number
},
MergeDiscriminatedUnion<Actual['/author']['$post']['input']['json']>
>
>
type verifyQueryInput = Expect<
Equal<
| {
name?: string | undefined
}
| {
name?: string | undefined
}
| {
name?: string | undefined
}
| undefined,
Actual['/author']['$post']['input']['query']
>
>
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 schema = schemas.queryPaginationSchema
const route = app.get('/page', sValidator('query', schema), (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[]
}
| {
page: string | string[]
}
| {
page: string | string[]
}
}
output: {
page: number
}
}
}
}
type verifyInput = Expect<
Equal<
{ page: string | string[] },
MergeDiscriminatedUnion<Actual['/page']['$get']['input']['query']>
>
>
type verifyOutput = Expect<
Equal<
{
page: number
},
MergeDiscriminatedUnion<Actual['/page']['$get']['output']>
>
>
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,
})
})
})
describe('With Hook', () => {
const app = new Hono()
const schema = schemas.postJSONSchema
app.post(
'/post',
sValidator('json', schema, (result, c) => {
if (!result.success) {
return c.text(`${result.data.id} is invalid!`, 400)
}
}),
(c) => {
const data = c.req.valid('json')
return c.text(`${data.id} is valid!`)
}
)
it('Should return 200 response', async () => {
const req = new Request('http://localhost/post', {
body: JSON.stringify({
id: 123,
title: 'Hello',
}),
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.text()).toBe('123 is valid!')
})
it('Should return 400 response', async () => {
const req = new Request('http://localhost/post', {
body: JSON.stringify({
id: '123',
title: 'Hello',
}),
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(400)
expect(await res.text()).toBe('123 is invalid!')
})
})
describe('With Async Hook', () => {
const app = new Hono()
const schema = schemas.postJSONSchema
app.post(
'/post',
sValidator('json', schema, async (result, c) => {
if (!result.success) {
return c.text(`${result.data.id} is invalid!`, 400)
}
}),
(c) => {
const data = c.req.valid('json')
return c.text(`${data.id} is valid!`)
}
)
it('Should return 200 response', async () => {
const req = new Request('http://localhost/post', {
body: JSON.stringify({
id: 123,
title: 'Hello',
}),
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.text()).toBe('123 is valid!')
})
it('Should return 400 response', async () => {
const req = new Request('http://localhost/post', {
body: JSON.stringify({
id: '123',
title: 'Hello',
}),
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(400)
expect(await res.text()).toBe('123 is invalid!')
})
})
describe('With target', () => {
it('should call hook for correctly validated target', async () => {
const app = new Hono()
const schema = schemas.idJSONSchema
const jsonHook = vi.fn()
const paramHook = vi.fn()
const queryHook = vi.fn()
app.post(
'/:id/post',
sValidator('json', schema, jsonHook),
sValidator('param', schema, paramHook),
sValidator('query', schema, queryHook),
(c) => {
return c.text('ok')
}
)
const req = new Request('http://localhost/1/post?id=2', {
body: JSON.stringify({
id: '3',
}),
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.text()).toBe('ok')
expect(paramHook).toHaveBeenCalledWith(
{ data: { id: '1' }, success: true, target: 'param' },
expect.anything()
)
expect(queryHook).toHaveBeenCalledWith(
{ data: { id: '2' }, success: true, target: 'query' },
expect.anything()
)
expect(jsonHook).toHaveBeenCalledWith(
{ data: { id: '3' }, success: true, target: 'json' },
expect.anything()
)
})
})
describe('Only Types', () => {
it('Should return correct enum types for query', () => {
const app = new Hono()
const schema = schemas.querySortSchema
const route = app.get('/', sValidator('query', schema), (c) => {
const data = c.req.valid('query')
return c.json(data)
})
type Actual = ExtractSchema<typeof route>
type verifyInput = Expect<
Equal<
{ order: 'asc' | 'desc' },
MergeDiscriminatedUnion<Actual['/']['$get']['input']['query']>
>
>
type verifyOutput = Expect<
Equal<{ order: 'asc' | 'desc' }, MergeDiscriminatedUnion<Actual['/']['$get']['output']>>
>
})
})
})
})
})

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./",
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
],
}

View File

@ -0,0 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
},
})

View File

@ -39,4 +39,4 @@
"jest": "^29.7.0",
"rimraf": "^5.0.5"
}
}
}

View File

@ -44,6 +44,22 @@ __metadata:
languageName: node
linkType: hard
"@ark/schema@npm:0.26.0":
version: 0.26.0
resolution: "@ark/schema@npm:0.26.0"
dependencies:
"@ark/util": "npm:0.26.0"
checksum: e038b73bd0d1a7556d5d7ab70382ddcc1ba35cafdddf81ea961a676d10bd2d642514c08b7a7cd9f4d99fb8e5ec3ae4e2ea8d3bbdf1d8b19d94149379f1c739f3
languageName: node
linkType: hard
"@ark/util@npm:0.26.0":
version: 0.26.0
resolution: "@ark/util@npm:0.26.0"
checksum: 60c54dca4556b1ccb6f4a3dc1e28beb09219ee3f01124916550c993f0508ac2142c9c967992ee16a71334e849c214234e6c3e4c2271dc104a893068a9cc33afc
languageName: node
linkType: hard
"@arktype/schema@npm:0.1.4-cjs":
version: 0.1.4-cjs
resolution: "@arktype/schema@npm:0.1.4-cjs"
@ -2916,6 +2932,25 @@ __metadata:
languageName: unknown
linkType: soft
"@hono/standard-validator@workspace:packages/standard-validator":
version: 0.0.0-use.local
resolution: "@hono/standard-validator@workspace:packages/standard-validator"
dependencies:
"@standard-schema/spec": "npm:1.0.0"
arktype: "npm:^2.0.0-rc.26"
hono: "npm:^4.0.10"
publint: "npm:^0.2.7"
tsup: "npm:^8.1.0"
typescript: "npm:^5.7.3"
valibot: "npm:^1.0.0-beta.9"
vitest: "npm:^1.4.0"
zod: "npm:^3.24.0"
peerDependencies:
"@standard-schema/spec": 1.0.0
hono: ">=3.9.0"
languageName: unknown
linkType: soft
"@hono/swagger-editor@workspace:packages/swagger-editor":
version: 0.0.0-use.local
resolution: "@hono/swagger-editor@workspace:packages/swagger-editor"
@ -4846,6 +4881,13 @@ __metadata:
languageName: node
linkType: hard
"@standard-schema/spec@npm:1.0.0":
version: 1.0.0
resolution: "@standard-schema/spec@npm:1.0.0"
checksum: a1ab9a8bdc09b5b47aa8365d0e0ec40cc2df6437be02853696a0e377321653b0d3ac6f079a8c67d5ddbe9821025584b1fb71d9cc041a6666a96f1fadf2ece15f
languageName: node
linkType: hard
"@szmarczak/http-timer@npm:^1.1.2":
version: 1.1.2
resolution: "@szmarczak/http-timer@npm:1.1.2"
@ -6522,6 +6564,16 @@ __metadata:
languageName: node
linkType: hard
"arktype@npm:^2.0.0-rc.26":
version: 2.0.0-rc.26
resolution: "arktype@npm:2.0.0-rc.26"
dependencies:
"@ark/schema": "npm:0.26.0"
"@ark/util": "npm:0.26.0"
checksum: 190c4a82baec546bca704b26bffa73209a756ea4a0a2ade080a6d640a807e7eb1990a69e6507112f103151a37592cca84591569c90e607fb2865bd6ea2532b9a
languageName: node
linkType: hard
"array-buffer-byte-length@npm:^1.0.0":
version: 1.0.0
resolution: "array-buffer-byte-length@npm:1.0.0"
@ -20770,6 +20822,16 @@ __metadata:
languageName: node
linkType: hard
"typescript@npm:^5.7.3":
version: 5.7.3
resolution: "typescript@npm:5.7.3"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: b7580d716cf1824736cc6e628ab4cd8b51877408ba2be0869d2866da35ef8366dd6ae9eb9d0851470a39be17cbd61df1126f9e211d8799d764ea7431d5435afa
languageName: node
linkType: hard
"typescript@patch:typescript@npm%3A^4.7.4#optional!builtin<compat/typescript>":
version: 4.9.5
resolution: "typescript@patch:typescript@npm%3A4.9.5#optional!builtin<compat/typescript>::version=4.9.5&hash=289587"
@ -20820,6 +20882,16 @@ __metadata:
languageName: node
linkType: hard
"typescript@patch:typescript@npm%3A^5.7.3#optional!builtin<compat/typescript>":
version: 5.7.3
resolution: "typescript@patch:typescript@npm%3A5.7.3#optional!builtin<compat/typescript>::version=5.7.3&hash=e012d7"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: 3b56d6afa03d9f6172d0b9cdb10e6b1efc9abc1608efd7a3d2f38773d5d8cfb9bbc68dfb72f0a7de5e8db04fc847f4e4baeddcd5ad9c9feda072234f0d788896
languageName: node
linkType: hard
"typia@npm:^7.3.0":
version: 7.3.0
resolution: "typia@npm:7.3.0"
@ -21246,6 +21318,18 @@ __metadata:
languageName: node
linkType: hard
"valibot@npm:^1.0.0-beta.9":
version: 1.0.0-beta.9
resolution: "valibot@npm:1.0.0-beta.9"
peerDependencies:
typescript: ">=5"
peerDependenciesMeta:
typescript:
optional: true
checksum: ecd20ec024f5f05985002b385f624d9218c839a54c23f3dbf3e193161207c049859d99069b257334756b3a07e2734e93456061600dd1101aec121828df3ab286
languageName: node
linkType: hard
"valid-url@npm:^1":
version: 1.0.9
resolution: "valid-url@npm:1.0.9"
@ -22571,6 +22655,13 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.24.0":
version: 3.24.1
resolution: "zod@npm:3.24.1"
checksum: 0223d21dbaa15d8928fe0da3b54696391d8e3e1e2d0283a1a070b5980a1dbba945ce631c2d1eccc088fdbad0f2dfa40155590bf83732d3ac4fcca2cc9237591b
languageName: node
linkType: hard
"zwitch@npm:^2.0.0":
version: 2.0.4
resolution: "zwitch@npm:2.0.4"