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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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/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 standar schema support
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
46 changes: 46 additions & 0 deletions packages/standard-validator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 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

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

const zod = 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)
}
})
//...
)
```

## Author

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

## License

MIT
3 changes: 3 additions & 0 deletions packages/standard-validator/package.cjs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
53 changes: 53 additions & 0 deletions packages/standard-validator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"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": "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-beta.4",
"@standard-schema/utils": "0.3.0",
"hono": ">=3.9.0"
},
"devDependencies": {
"@standard-schema/spec": "1.0.0-beta.4",
"@standard-schema/utils": "0.3.0",
"arktype": "^2.0.0-rc.26",
"hono": "^4.0.10",
"publint": "^0.2.7",
"tsup": "^8.1.0",
"typescript": "^5.3.3",
"valibot": "^1.0.0-beta.9",
"vitest": "^1.4.0",
"zod": "^3.24.0"
}
}
100 changes: 100 additions & 0 deletions packages/standard-validator/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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) => {
let validatorValue = value

// in case where our `target` === `header`, Hono parses all of the headers into lowercase.
// this might not match the Zod schema, so we want to make sure that we account for that when parsing the schema.
if (target === 'header' && isStandardSchemaValidator(schema) && schema['~standard'].types) {
// create an object that maps lowercase schema keys to lowercase
const schemaKeys = Object.keys(schema['~standard'].types)
const caseInsensitiveKeymap = Object.fromEntries(
schemaKeys.map((key) => [key.toLowerCase(), key])
)

validatorValue = Object.fromEntries(
Object.entries(value).map(([key, value]) => [caseInsensitiveKeymap[key] || key, value])
)
}

const result = await schema['~standard'].validate(validatorValue)

if (hook) {
const hookResult = await hook(
!!result.issues
? { data: validatorValue, error: result.issues, success: false, target }
: { data: validatorValue, 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: validatorValue, error: result.issues, success: false }, 400)
}

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

export type { Hook }
export { sValidator }
44 changes: 44 additions & 0 deletions packages/standard-validator/test/__schemas__/arktype.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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'",
})

const headerSchema = type({
'Content-Type': 'string',
ApiKey: 'string',
onlylowercase: 'string',
ONLYUPPERCASE: 'string',
})

export {
idJSONSchema,
personJSONSchema,
postJSONSchema,
queryNameSchema,
queryPaginationSchema,
querySortSchema,
headerSchema,
}
46 changes: 46 additions & 0 deletions packages/standard-validator/test/__schemas__/valibot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { object, string, number, optional, enum_, pipe, unknown, transform, union, const, 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']),
})

const headerSchema = object({
'Content-Type': string(),
ApiKey: string(),
onlylowercase: string(),
ONLYUPPERCASE: string(),
})

export {
idJSONSchema,
personJSONSchema,
postJSONSchema,
queryNameSchema,
queryPaginationSchema,
querySortSchema,
headerSchema,
}
46 changes: 46 additions & 0 deletions packages/standard-validator/test/__schemas__/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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']),
})

const headerSchema = z.object({
'Content-Type': z.string(),
ApiKey: z.string(),
onlylowercase: z.string(),
ONLYUPPERCASE: z.string(),
})

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