From b84c6c8eb3734c1e42e2a9931adaa3e138af253b Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 4 Jan 2024 07:05:17 +0300 Subject: [PATCH] feat: ArkType validator middleware (#325) * feat: Create arktype-validator * feat: Add changeset * chore(arktype-validator): Fix formatting * chore(index.test.ts): Replace `jsonT` with `json` * feat(ci): Create arktype validator CI * feat(package.json): Add arktype validator build script * fix(ci): Fix lock file --- .changeset/tidy-swans-smile.md | 5 + .github/workflows/ci-arktype-validator.yml | 25 +++ package.json | 1 + packages/arktype-validator/README.md | 46 ++++++ packages/arktype-validator/package.json | 43 +++++ packages/arktype-validator/src/index.test.ts | 160 +++++++++++++++++++ packages/arktype-validator/src/index.ts | 61 +++++++ packages/arktype-validator/tsconfig.json | 10 ++ packages/arktype-validator/vitest.config.ts | 8 + packages/auth-js/package.json | 2 +- packages/firebase-auth/package.json | 2 +- yarn.lock | 21 +++ 12 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 .changeset/tidy-swans-smile.md create mode 100644 .github/workflows/ci-arktype-validator.yml create mode 100644 packages/arktype-validator/README.md create mode 100644 packages/arktype-validator/package.json create mode 100644 packages/arktype-validator/src/index.test.ts create mode 100644 packages/arktype-validator/src/index.ts create mode 100644 packages/arktype-validator/tsconfig.json create mode 100644 packages/arktype-validator/vitest.config.ts diff --git a/.changeset/tidy-swans-smile.md b/.changeset/tidy-swans-smile.md new file mode 100644 index 000000000..dc9c160e2 --- /dev/null +++ b/.changeset/tidy-swans-smile.md @@ -0,0 +1,5 @@ +--- +'@hono/arktype-validator': major +--- + +Create Arktype validator middleware diff --git a/.github/workflows/ci-arktype-validator.yml b/.github/workflows/ci-arktype-validator.yml new file mode 100644 index 000000000..2fd8b9569 --- /dev/null +++ b/.github/workflows/ci-arktype-validator.yml @@ -0,0 +1,25 @@ +name: ci-arktype-validator +on: + push: + branches: [main] + paths: + - 'packages/arktype-validator/**' + pull_request: + branches: ['*'] + paths: + - 'packages/arktype-validator/**' + +jobs: + ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./packages/arktype-validator + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: 18.x + - run: yarn install --frozen-lockfile + - run: yarn build + - run: yarn test diff --git a/package.json b/package.json index fcb0c9b9f..5c68bec99 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "build:hello": "yarn workspace @hono/hello build", "build:zod-validator": "yarn workspace @hono/zod-validator build", + "build:arktype-validator": "yarn workspace @hono/arktype-validator build", "build:qwik-city": "yarn workspace @hono/qwik-city build", "build:graphql-server": "yarn workspace @hono/graphql-server build", "build:sentry": "yarn workspace @hono/sentry build", diff --git a/packages/arktype-validator/README.md b/packages/arktype-validator/README.md new file mode 100644 index 000000000..0537cc747 --- /dev/null +++ b/packages/arktype-validator/README.md @@ -0,0 +1,46 @@ +# ArkType validator middleware for Hono + +The validator middleware using [ArkType](https://arktype.io/) for [Hono](https://honojs.dev) applications. +You can write a schema with ArkType and validate the incoming values. + +## Usage + +```ts +import { type } from 'arktype' +import { arktypeValidator } from '@hono/arktype-validator' + +const schema = type({ + name: 'string', + age: 'number' +}) + +app.post('/author', arktypeValidator('json', schema), (c) => { + const data = c.req.valid('json') + return c.json({ + success: true, + message: `${data.name} is ${data.age}`, + }) +}) +``` + +### With hook: + +```ts +app.post( + '/post', + arktypeValidator('json', schema, (result, c) => { + if (!result.success) { + return c.text('Invalid!', 400) + } + }) + //... +) +``` + +## Author + +Andrei Bobkov + +## License + +MIT diff --git a/packages/arktype-validator/package.json b/packages/arktype-validator/package.json new file mode 100644 index 000000000..ec38a1782 --- /dev/null +++ b/packages/arktype-validator/package.json @@ -0,0 +1,43 @@ +{ + "name": "@hono/arktype-validator", + "version": "0.1.0", + "description": "ArkType validator middleware", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "test": "vitest --run", + "build": "tsup ./src/index.ts --format esm,cjs --dts", + "release": "yarn build && yarn test && yarn publish" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "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": { + "arktype": "^1.0.28-alpha", + "hono": "*" + }, + "devDependencies": { + "arktype": "^1.0.28-alpha", + "hono": "^3.11.7", + "tsup": "^8.0.1", + "vitest": "^1.0.4" + } +} diff --git a/packages/arktype-validator/src/index.test.ts b/packages/arktype-validator/src/index.test.ts new file mode 100644 index 000000000..119347361 --- /dev/null +++ b/packages/arktype-validator/src/index.test.ts @@ -0,0 +1,160 @@ +import { type } from 'arktype' +import { Hono } from 'hono' +import type { Equal, Expect } from 'hono/utils/types' +import { arktypeValidator } from '.' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type ExtractSchema = T extends Hono ? S : never + +describe('Basic', () => { + const app = new Hono() + + const jsonSchema = type({ + name: 'string', + age: 'number', + }) + + const querySchema = type({ + 'name?': 'string', + }) + + const route = app.post( + '/author', + arktypeValidator('json', jsonSchema), + arktypeValidator('query', querySchema), + (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 + type Expected = { + '/author': { + $post: { + input: { + json: { + name: string + age: number + } + } & { + query: { + name?: 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({ + 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', + }) + 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('With Hook', () => { + const app = new Hono() + + const schema = type({ + id: 'number', + title: 'string', + }) + + app.post( + '/post', + arktypeValidator('json', schema, (result, c) => { + if (!result.success) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return c.text(`${(result.data as any).id} is invalid!`, 400) + } + const data = result.data + return c.text(`${data.id} is valid!`) + }), + (c) => { + const data = c.req.valid('json') + return c.json({ + success: true, + message: `${data.id} is ${data.title}`, + }) + } + ) + + 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!') + }) +}) diff --git a/packages/arktype-validator/src/index.ts b/packages/arktype-validator/src/index.ts new file mode 100644 index 000000000..4518f10bc --- /dev/null +++ b/packages/arktype-validator/src/index.ts @@ -0,0 +1,61 @@ +import type { Type, Problems } from 'arktype' +import type { Context, MiddlewareHandler, Env, ValidationTargets, TypedResponse } from 'hono' +import { validator } from 'hono/validator' + +export type Hook = ( + result: { success: false; data: unknown; problems: Problems } | { success: true; data: T }, + c: Context +) => Response | Promise | void | Promise | TypedResponse + +type HasUndefined = undefined extends T ? true : false + +export const arktypeValidator = < + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends Type, + Target extends keyof ValidationTargets, + E extends Env, + P extends string, + I = T['inferIn'], + O = T['infer'], + V extends { + in: HasUndefined extends true ? { [K in Target]?: I } : { [K in Target]: I } + out: { [K in Target]: O } + } = { + in: HasUndefined extends true ? { [K in Target]?: I } : { [K in Target]: I } + out: { [K in Target]: O } + } +>( + target: Target, + schema: T, + hook?: Hook +): MiddlewareHandler => + validator(target, (value, c) => { + const { data, problems } = schema(value) + + if (hook) { + const hookResult = hook( + problems ? { success: false, data: value, problems } : { success: true, data }, + c + ) + if (hookResult) { + if (hookResult instanceof Response || hookResult instanceof Promise) { + return hookResult + } + if ('response' in hookResult) { + return hookResult.response + } + } + } + + if (problems) { + return c.json( + { + success: false, + problems: problems.map((problem) => ({ ...problem, message: problem.toString() })), + }, + 400 + ) + } + + return data + }) diff --git a/packages/arktype-validator/tsconfig.json b/packages/arktype-validator/tsconfig.json new file mode 100644 index 000000000..acfcd8430 --- /dev/null +++ b/packages/arktype-validator/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + }, + "include": [ + "src/**/*.ts" + ], +} \ No newline at end of file diff --git a/packages/arktype-validator/vitest.config.ts b/packages/arktype-validator/vitest.config.ts new file mode 100644 index 000000000..17b54e485 --- /dev/null +++ b/packages/arktype-validator/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, +}) diff --git a/packages/auth-js/package.json b/packages/auth-js/package.json index f7903f294..5486b0efd 100644 --- a/packages/auth-js/package.json +++ b/packages/auth-js/package.json @@ -69,4 +69,4 @@ "engines": { "node": ">=18.4.0" } -} \ No newline at end of file +} diff --git a/packages/firebase-auth/package.json b/packages/firebase-auth/package.json index e7679176e..01a007279 100644 --- a/packages/firebase-auth/package.json +++ b/packages/firebase-auth/package.json @@ -57,4 +57,4 @@ "typescript": "^4.7.4", "vitest": "^0.34.6" } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 754c45a80..4fc2021c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1346,6 +1346,20 @@ __metadata: languageName: node linkType: hard +"@hono/arktype-validator@workspace:packages/arktype-validator": + version: 0.0.0-use.local + resolution: "@hono/arktype-validator@workspace:packages/arktype-validator" + dependencies: + arktype: "npm:^1.0.28-alpha" + hono: "npm:^3.11.7" + tsup: "npm:^8.0.1" + vitest: "npm:^1.0.4" + peerDependencies: + arktype: ^1.0.28-alpha + hono: "*" + languageName: unknown + linkType: soft + "@hono/auth-js@workspace:packages/auth-js": version: 0.0.0-use.local resolution: "@hono/auth-js@workspace:packages/auth-js" @@ -4401,6 +4415,13 @@ __metadata: languageName: node linkType: hard +"arktype@npm:^1.0.28-alpha": + version: 1.0.28-alpha + resolution: "arktype@npm:1.0.28-alpha" + checksum: cf5a7a6303dcd42d54f10f83119c90ac382a0b75074716c4b6fcca6e6326ea070b6a8b1ae7a4da576e93e80b312d0d01368937e695074ddd3a53a0dc30fb98b2 + 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"