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 filepull/339/head
parent
353d91e6c6
commit
b84c6c8eb3
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/arktype-validator': major
|
||||||
|
---
|
||||||
|
|
||||||
|
Create Arktype validator middleware
|
|
@ -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
|
|
@ -10,6 +10,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:hello": "yarn workspace @hono/hello build",
|
"build:hello": "yarn workspace @hono/hello build",
|
||||||
"build:zod-validator": "yarn workspace @hono/zod-validator 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:qwik-city": "yarn workspace @hono/qwik-city build",
|
||||||
"build:graphql-server": "yarn workspace @hono/graphql-server build",
|
"build:graphql-server": "yarn workspace @hono/graphql-server build",
|
||||||
"build:sentry": "yarn workspace @hono/sentry build",
|
"build:sentry": "yarn workspace @hono/sentry build",
|
||||||
|
|
|
@ -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 <https://github.com/MonsterDeveloper>
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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> = T extends Hono<infer _, infer S> ? 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<typeof route>
|
||||||
|
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<Equal<Expected, Actual>>
|
||||||
|
|
||||||
|
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!')
|
||||||
|
})
|
||||||
|
})
|
|
@ -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<T, E extends Env, P extends string, O = {}> = (
|
||||||
|
result: { success: false; data: unknown; problems: Problems } | { success: true; data: T },
|
||||||
|
c: Context<E, P>
|
||||||
|
) => Response | Promise<Response> | void | Promise<Response | void> | TypedResponse<O>
|
||||||
|
|
||||||
|
type HasUndefined<T> = undefined extends T ? true : false
|
||||||
|
|
||||||
|
export const arktypeValidator = <
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
T extends Type<any>,
|
||||||
|
Target extends keyof ValidationTargets,
|
||||||
|
E extends Env,
|
||||||
|
P extends string,
|
||||||
|
I = T['inferIn'],
|
||||||
|
O = T['infer'],
|
||||||
|
V extends {
|
||||||
|
in: HasUndefined<I> extends true ? { [K in Target]?: I } : { [K in Target]: I }
|
||||||
|
out: { [K in Target]: O }
|
||||||
|
} = {
|
||||||
|
in: HasUndefined<I> extends true ? { [K in Target]?: I } : { [K in Target]: I }
|
||||||
|
out: { [K in Target]: O }
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
target: Target,
|
||||||
|
schema: T,
|
||||||
|
hook?: Hook<T['infer'], E, P>
|
||||||
|
): MiddlewareHandler<E, P, V> =>
|
||||||
|
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
|
||||||
|
})
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
})
|
21
yarn.lock
21
yarn.lock
|
@ -1346,6 +1346,20 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@hono/auth-js@workspace:packages/auth-js":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@hono/auth-js@workspace:packages/auth-js"
|
resolution: "@hono/auth-js@workspace:packages/auth-js"
|
||||||
|
@ -4401,6 +4415,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"array-buffer-byte-length@npm:^1.0.0":
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
resolution: "array-buffer-byte-length@npm:1.0.0"
|
resolution: "array-buffer-byte-length@npm:1.0.0"
|
||||||
|
|
Loading…
Reference in New Issue