Skip to content

Commit

Permalink
feat: ArkType validator middleware (#325)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
MonsterDeveloper authored Jan 4, 2024
1 parent 353d91e commit b84c6c8
Show file tree
Hide file tree
Showing 12 changed files with 382 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/tidy-swans-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/arktype-validator': major
---

Create Arktype validator middleware
25 changes: 25 additions & 0 deletions .github/workflows/ci-arktype-validator.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions packages/arktype-validator/README.md
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions packages/arktype-validator/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
160 changes: 160 additions & 0 deletions packages/arktype-validator/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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!')
})
})
61 changes: 61 additions & 0 deletions packages/arktype-validator/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
})
10 changes: 10 additions & 0 deletions packages/arktype-validator/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
},
"include": [
"src/**/*.ts"
],
}
8 changes: 8 additions & 0 deletions packages/arktype-validator/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: true,
},
})
2 changes: 1 addition & 1 deletion packages/auth-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,4 @@
"engines": {
"node": ">=18.4.0"
}
}
}
2 changes: 1 addition & 1 deletion packages/firebase-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,4 @@
"typescript": "^4.7.4",
"vitest": "^0.34.6"
}
}
}
Loading

0 comments on commit b84c6c8

Please sign in to comment.