From 45cff3fcb34f9bf91f0315410529039179dc3992 Mon Sep 17 00:00:00 2001 From: Rokas Muningis <28229273+muningis@users.noreply.github.com> Date: Fri, 13 Dec 2024 22:26:32 +0200 Subject: [PATCH 1/4] feat(standard-validator): Add standard schema validation --- package.json | 1 + packages/standard-validator/README.md | 46 +++ packages/standard-validator/package.cjs.json | 3 + packages/standard-validator/package.json | 53 ++++ packages/standard-validator/src/index.ts | 100 +++++++ .../test/__schemas__/arktype.ts | 44 +++ .../test/__schemas__/valibot.ts | 50 ++++ .../test/__schemas__/zod.ts | 46 +++ .../standard-validator/test/index.test.ts | 279 ++++++++++++++++++ packages/standard-validator/tsconfig.cjs.json | 8 + packages/standard-validator/tsconfig.esm.json | 8 + packages/standard-validator/tsconfig.json | 9 + packages/standard-validator/vitest.config.ts | 8 + yarn.lock | 80 +++++ 14 files changed, 735 insertions(+) create mode 100644 packages/standard-validator/README.md create mode 100644 packages/standard-validator/package.cjs.json create mode 100644 packages/standard-validator/package.json create mode 100644 packages/standard-validator/src/index.ts create mode 100644 packages/standard-validator/test/__schemas__/arktype.ts create mode 100644 packages/standard-validator/test/__schemas__/valibot.ts create mode 100644 packages/standard-validator/test/__schemas__/zod.ts create mode 100644 packages/standard-validator/test/index.test.ts create mode 100644 packages/standard-validator/tsconfig.cjs.json create mode 100644 packages/standard-validator/tsconfig.esm.json create mode 100644 packages/standard-validator/tsconfig.json create mode 100644 packages/standard-validator/vitest.config.ts diff --git a/package.json b/package.json index a3e75b17b..1bca3f15c 100644 --- a/package.json +++ b/package.json @@ -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}'", diff --git a/packages/standard-validator/README.md b/packages/standard-validator/README.md new file mode 100644 index 000000000..0f0709e7d --- /dev/null +++ b/packages/standard-validator/README.md @@ -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 + +## License + +MIT diff --git a/packages/standard-validator/package.cjs.json b/packages/standard-validator/package.cjs.json new file mode 100644 index 000000000..c9a442261 --- /dev/null +++ b/packages/standard-validator/package.cjs.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} \ No newline at end of file diff --git a/packages/standard-validator/package.json b/packages/standard-validator/package.json new file mode 100644 index 000000000..170774ec8 --- /dev/null +++ b/packages/standard-validator/package.json @@ -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" + } +} diff --git a/packages/standard-validator/src/index.ts b/packages/standard-validator/src/index.ts new file mode 100644 index 000000000..2d72e89cf --- /dev/null +++ b/packages/standard-validator/src/index.ts @@ -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 = undefined extends T ? true : false +type TOrPromiseOfT = T | Promise + +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; data: T } + ) & { + target: Target + }, + c: Context +) => TOrPromiseOfT> + +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, + Out = StandardSchemaV1.InferOutput, + I extends Input = { + in: HasUndefined 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, E, P, Target> +): MiddlewareHandler => + // @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 + }) + +export type { Hook } +export { sValidator } diff --git a/packages/standard-validator/test/__schemas__/arktype.ts b/packages/standard-validator/test/__schemas__/arktype.ts new file mode 100644 index 000000000..4e8b619da --- /dev/null +++ b/packages/standard-validator/test/__schemas__/arktype.ts @@ -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|undefined', +}) + +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, +} diff --git a/packages/standard-validator/test/__schemas__/valibot.ts b/packages/standard-validator/test/__schemas__/valibot.ts new file mode 100644 index 000000000..c850eedf7 --- /dev/null +++ b/packages/standard-validator/test/__schemas__/valibot.ts @@ -0,0 +1,50 @@ +import { object, string, number, optional, enum_, pipe, unknown, transform } 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)), +}) + +enum Sort { + ASC = 'asc', + DESC = 'desc', +} +const querySortSchema = object({ + order: enum_(Sort), +}) + +const headerSchema = object({ + 'Content-Type': string(), + ApiKey: string(), + onlylowercase: string(), + ONLYUPPERCASE: string(), +}) + +export { + idJSONSchema, + personJSONSchema, + postJSONSchema, + queryNameSchema, + queryPaginationSchema, + querySortSchema, + headerSchema, +} diff --git a/packages/standard-validator/test/__schemas__/zod.ts b/packages/standard-validator/test/__schemas__/zod.ts new file mode 100644 index 000000000..9ee50c019 --- /dev/null +++ b/packages/standard-validator/test/__schemas__/zod.ts @@ -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, +} diff --git a/packages/standard-validator/test/index.test.ts b/packages/standard-validator/test/index.test.ts new file mode 100644 index 000000000..a6b47a888 --- /dev/null +++ b/packages/standard-validator/test/index.test.ts @@ -0,0 +1,279 @@ +import { Hono } from 'hono' +import type { Equal, Expect } 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' +import { StandardSchemaV1 } from '@standard-schema/spec' + +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, + }) + } + ) + + 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 }) + }) + + 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) + }) + }) + }) + + describe('Case-Insensitive Headers', () => { + it('Should ignore the case for headers in the Zod schema and return 200', () => { + const app = new Hono() + const schema = schemas.headerSchema + + const route = app.get('/', sValidator('header', schema), (c) => { + const headers = c.req.valid('header') + return c.json(headers) + }) + }) + }) + }) + }) +}) diff --git a/packages/standard-validator/tsconfig.cjs.json b/packages/standard-validator/tsconfig.cjs.json new file mode 100644 index 000000000..b8bf50ee9 --- /dev/null +++ b/packages/standard-validator/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "declaration": false, + "outDir": "./dist/cjs" + } +} \ No newline at end of file diff --git a/packages/standard-validator/tsconfig.esm.json b/packages/standard-validator/tsconfig.esm.json new file mode 100644 index 000000000..8130f1a53 --- /dev/null +++ b/packages/standard-validator/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "declaration": true, + "outDir": "./dist/esm" + } +} \ No newline at end of file diff --git a/packages/standard-validator/tsconfig.json b/packages/standard-validator/tsconfig.json new file mode 100644 index 000000000..6c1a39902 --- /dev/null +++ b/packages/standard-validator/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + }, + "include": [ + "src/**/*.ts" + ], +} \ No newline at end of file diff --git a/packages/standard-validator/vitest.config.ts b/packages/standard-validator/vitest.config.ts new file mode 100644 index 000000000..17b54e485 --- /dev/null +++ b/packages/standard-validator/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, +}) diff --git a/yarn.lock b/yarn.lock index a5d5d156e..e207a356e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -2821,6 +2837,27 @@ __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-beta.4" + "@standard-schema/utils": "npm:0.3.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.3.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-beta.4 + "@standard-schema/utils": 0.3.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" @@ -4707,6 +4744,20 @@ __metadata: languageName: node linkType: hard +"@standard-schema/spec@npm:1.0.0-beta.4": + version: 1.0.0-beta.4 + resolution: "@standard-schema/spec@npm:1.0.0-beta.4" + checksum: f001a08d798fe2eb19e4982fc7707549be2b5d0a61c6f144ace9c85e71eee23a2774d84f83f3b2dea02cb670cbe5cd91c151c0bebbc15d0b7fbcf6b29911e334 + languageName: node + linkType: hard + +"@standard-schema/utils@npm:0.3.0": + version: 0.3.0 + resolution: "@standard-schema/utils@npm:0.3.0" + checksum: 6eb74cd13e52d5fc74054df51e37d947ef53f3ab9e02c085665dcca3c38c60ece8d735cebbdf18fbb13c775fbcb9becb3f53109b0e092a63f0f7389ce0993fd0 + languageName: node + linkType: hard + "@szmarczak/http-timer@npm:^1.1.2": version: 1.1.2 resolution: "@szmarczak/http-timer@npm:1.1.2" @@ -6410,6 +6461,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" @@ -20537,6 +20598,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" @@ -21856,6 +21929,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" From 4b94c5bea5194cb89b39ef6a297df4a8424511f8 Mon Sep 17 00:00:00 2001 From: Rokas Muningis <28229273+muningis@users.noreply.github.com> Date: Fri, 13 Dec 2024 22:31:39 +0200 Subject: [PATCH 2/4] feat(standard-validator): add changeset --- .changeset/modern-bugs-search.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/modern-bugs-search.md diff --git a/.changeset/modern-bugs-search.md b/.changeset/modern-bugs-search.md new file mode 100644 index 000000000..bdcffcf20 --- /dev/null +++ b/.changeset/modern-bugs-search.md @@ -0,0 +1,5 @@ +--- +'@hono/standard-validator': minor +--- + +Initial implementation for standar schema support From 4c397bbc3c2179a465f8346f1141404b1afcb105 Mon Sep 17 00:00:00 2001 From: Rokas Muningis <28229273+muningis@users.noreply.github.com> Date: Fri, 13 Dec 2024 23:59:58 +0200 Subject: [PATCH 3/4] feat(standard-validator): reintroduce type tests --- .../test/__schemas__/valibot.ts | 8 +- .../standard-validator/test/index.test.ts | 118 +++++++++++++++++- 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/packages/standard-validator/test/__schemas__/valibot.ts b/packages/standard-validator/test/__schemas__/valibot.ts index c850eedf7..58d83fa58 100644 --- a/packages/standard-validator/test/__schemas__/valibot.ts +++ b/packages/standard-validator/test/__schemas__/valibot.ts @@ -1,4 +1,4 @@ -import { object, string, number, optional, enum_, pipe, unknown, transform } from 'valibot' +import { object, string, number, optional, enum_, pipe, unknown, transform, union, const, picklist } from 'valibot' const personJSONSchema = object({ name: string(), @@ -24,12 +24,8 @@ const queryPaginationSchema = object({ page: pipe(unknown(), transform(Number)), }) -enum Sort { - ASC = 'asc', - DESC = 'desc', -} const querySortSchema = object({ - order: enum_(Sort), + order: picklist(['asc', 'desc']), }) const headerSchema = object({ diff --git a/packages/standard-validator/test/index.test.ts b/packages/standard-validator/test/index.test.ts index a6b47a888..5d6a33b56 100644 --- a/packages/standard-validator/test/index.test.ts +++ b/packages/standard-validator/test/index.test.ts @@ -6,8 +6,11 @@ import { vi } from 'vitest' import * as valibotSchemas from './__schemas__/valibot' import * as zodSchemas from './__schemas__/zod' import * as arktypeSchemas from './__schemas__/arktype' + import { StandardSchemaV1 } from '@standard-schema/spec' +type ExtractSchema = T extends Hono ? S : never + const libs = ['valibot', 'zod', 'arktype'] as const const schemasByLibrary = { valibot: valibotSchemas, @@ -37,6 +40,49 @@ describe('Standard Schema Validation', () => { } ) + type Actual = ExtractSchema + type Expected = { + '/author': { + $post: { + input: { + json: + | { + name: string + age: number + } + | { + name: string + age: number + } + | { + name: string + age: number + } + } & { + query?: + | { + name?: string | undefined + } + | { + name?: string | undefined + } + | { + name?: string | string[] | undefined // arktype returns string[] + } + | undefined + } + output: { + success: boolean + message: string + queryName: string | undefined + } + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type verify = Expect> + it('Should return 200 response', async () => { const req = new Request('http://localhost/author?name=Metallo', { body: JSON.stringify({ @@ -86,6 +132,32 @@ describe('Standard Schema Validation', () => { return c.json({ page }) }) + type Actual = ExtractSchema + type Expected = { + '/page': { + $get: { + input: { + query: + | { + page: string | string[] + } + | { + page: string | string[] + } + | { + page: string | string[] + } + } + output: { + page: number + } + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type verify = Expect> + it('Should return 200 response', async () => { const res = await app.request('/page?page=123') expect(res).not.toBeNull() @@ -260,11 +332,42 @@ describe('Standard Schema Validation', () => { const data = c.req.valid('query') return c.json(data) }) + + type Actual = ExtractSchema + type Expected = { + '/': { + $get: { + input: { + query: + | { + order: 'asc' | 'desc' + } + | { + order: 'asc' | 'desc' + } + | { + order: 'asc' | 'desc' + } + } + output: + | { + order: 'asc' | 'desc' + } + | { + order: 'asc' | 'desc' + } + | { + order: 'asc' | 'desc' + } + } + } + } + type verify = Expect> }) }) describe('Case-Insensitive Headers', () => { - it('Should ignore the case for headers in the Zod schema and return 200', () => { + it('Should ignore the case for headers in schema and return 200', () => { const app = new Hono() const schema = schemas.headerSchema @@ -272,6 +375,19 @@ describe('Standard Schema Validation', () => { const headers = c.req.valid('header') return c.json(headers) }) + + type Actual = ExtractSchema + type Expected = { + '/': { + $get: { + input: { + header: StandardSchemaV1.InferInput + } + output: StandardSchemaV1.InferOutput + } + } + } + type verify = Expect> }) }) }) From e9c47b7c6b4c93c45a755eaaf2590f33eb6cdbfa Mon Sep 17 00:00:00 2001 From: Rokas Muningis <28229273+muningis@users.noreply.github.com> Date: Sat, 14 Dec 2024 00:31:53 +0200 Subject: [PATCH 4/4] feat(standard-validator): simplif tests --- .../test/__schemas__/arktype.ts | 2 +- .../standard-validator/test/index.test.ts | 165 +++++++++--------- 2 files changed, 82 insertions(+), 85 deletions(-) diff --git a/packages/standard-validator/test/__schemas__/arktype.ts b/packages/standard-validator/test/__schemas__/arktype.ts index 4e8b619da..955d1e002 100644 --- a/packages/standard-validator/test/__schemas__/arktype.ts +++ b/packages/standard-validator/test/__schemas__/arktype.ts @@ -15,7 +15,7 @@ const idJSONSchema = type({ }) const queryNameSchema = type({ - name: 'string|undefined', + 'name?': 'string', }) const queryPaginationSchema = type({ diff --git a/packages/standard-validator/test/index.test.ts b/packages/standard-validator/test/index.test.ts index 5d6a33b56..91a6cf70e 100644 --- a/packages/standard-validator/test/index.test.ts +++ b/packages/standard-validator/test/index.test.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono' -import type { Equal, Expect } from 'hono/utils/types' +import type { Equal, Expect, UnionToIntersection } from 'hono/utils/types' import { sValidator } from '../src' import { vi } from 'vitest' @@ -7,9 +7,10 @@ import * as valibotSchemas from './__schemas__/valibot' import * as zodSchemas from './__schemas__/zod' import * as arktypeSchemas from './__schemas__/arktype' -import { StandardSchemaV1 } from '@standard-schema/spec' - type ExtractSchema = T extends Hono ? S : never +type MergeDiscriminatedUnion = UnionToIntersection extends infer O + ? { [K in keyof O]: O[K] } + : never const libs = ['valibot', 'zod', 'arktype'] as const const schemasByLibrary = { @@ -41,47 +42,40 @@ describe('Standard Schema Validation', () => { ) type Actual = ExtractSchema - type Expected = { - '/author': { - $post: { - input: { - json: - | { - name: string - age: number - } - | { - name: string - age: number - } - | { - name: string - age: number - } - } & { - query?: - | { - name?: string | undefined - } - | { - name?: string | undefined - } - | { - name?: string | string[] | undefined // arktype returns string[] - } - | undefined + type verifyOutput = Expect< + Equal< + { + success: boolean + message: string + queryName: string | undefined + }, + MergeDiscriminatedUnion + > + > + type verifyJSONInput = Expect< + Equal< + { + name: string + age: number + }, + MergeDiscriminatedUnion + > + > + type verifyQueryInput = Expect< + Equal< + | { + name?: string | undefined } - output: { - success: boolean - message: string - queryName: string | undefined + | { + name?: string | undefined } - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - type verify = Expect> + | { + name?: string | undefined + } + | undefined, + Actual['/author']['$post']['input']['query'] + > + > it('Should return 200 response', async () => { const req = new Request('http://localhost/author?name=Metallo', { @@ -155,8 +149,20 @@ describe('Standard Schema Validation', () => { } } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - type verify = Expect> + type verifyInput = Expect< + Equal< + { page: string | string[] }, + MergeDiscriminatedUnion + > + > + type verifyOutput = Expect< + Equal< + { + page: number + }, + MergeDiscriminatedUnion + > + > it('Should return 200 response', async () => { const res = await app.request('/page?page=123') @@ -334,35 +340,15 @@ describe('Standard Schema Validation', () => { }) type Actual = ExtractSchema - type Expected = { - '/': { - $get: { - input: { - query: - | { - order: 'asc' | 'desc' - } - | { - order: 'asc' | 'desc' - } - | { - order: 'asc' | 'desc' - } - } - output: - | { - order: 'asc' | 'desc' - } - | { - order: 'asc' | 'desc' - } - | { - order: 'asc' | 'desc' - } - } - } - } - type verify = Expect> + type verifyInput = Expect< + Equal< + { order: 'asc' | 'desc' }, + MergeDiscriminatedUnion + > + > + type verifyOutput = Expect< + Equal<{ order: 'asc' | 'desc' }, MergeDiscriminatedUnion> + > }) }) @@ -377,17 +363,28 @@ describe('Standard Schema Validation', () => { }) type Actual = ExtractSchema - type Expected = { - '/': { - $get: { - input: { - header: StandardSchemaV1.InferInput - } - output: StandardSchemaV1.InferOutput - } - } - } - type verify = Expect> + type verifyInput = Expect< + Equal< + { + 'Content-Type': string + ApiKey: string + onlylowercase: string + ONLYUPPERCASE: string + }, + MergeDiscriminatedUnion + > + > + type verifyOutput = Expect< + Equal< + { + 'Content-Type': string + ApiKey: string + onlylowercase: string + ONLYUPPERCASE: string + }, + MergeDiscriminatedUnion + > + > }) }) })