Skip to content

Commit

Permalink
feat(typia-validator): support typia http module (#888)
Browse files Browse the repository at this point in the history
* feature(typia-validator): support typia http module

* feature(typia-validator): add change-set & update README
  • Loading branch information
miyaji255 authored Dec 15, 2024
1 parent f58f47e commit c63470e
Show file tree
Hide file tree
Showing 10 changed files with 860 additions and 22 deletions.
35 changes: 35 additions & 0 deletions .changeset/support-http-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@hono/typia-validator': minor
---

Enables handling of `number`, `boolean`, and `bigint` types in query parameters and headers.

```diff
- import { typiaValidator } from '@hono/typia-validator';
+ import { typiaValidator } from '@hono/typia-validator/http';
import { Hono } from 'hono';
import typia, { type tags } from 'typia';

interface Schema {
- pages: `${number}`[];
+ pages: (number & tags.Type<'uint32'>)[];
}

const app = new Hono()
.get(
'/books',
typiaValidator(
- typia.createValidate<Schema>(),
+ typia.http.createValidateQuery<Schema>(),
async (result, c) => {
if (!result.success)
return c.text('Invalid query parameters', 400);
- return { pages: result.data.pages.map(Number) };
}
),
async c => {
const { pages } = c.req.valid('query'); // { pages: number[] }
//...
}
)
```
1 change: 1 addition & 0 deletions packages/typia-validator/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/test-generated
80 changes: 70 additions & 10 deletions packages/typia-validator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,30 @@ The validator middleware using [Typia](https://typia.io/docs/) for [Hono](https:

## Usage

You can use [Basic Validation](#basic-validation) and [HTTP Module Validation](#http-module-validation) with Typia Validator.

### Basic Validation

Use only the standard validator in typia.

```ts
import typia, { tags } from 'typia'
import { typiaValidator } from '@hono/typia-validator'

interface Author {
name: string
age: number & tags.Type<'uint32'> & tags.Minimum<20> & tags.ExclusiveMaximum<100>
}
name: string
age: number & tags.Type<'uint32'> & tags.Minimum<20> & tags.ExclusiveMaximum<100>
}

const validate = typia.createValidate<Author>()
const validate = typia.createValidate<Author>()

const route = app.post('/author', typiaValidator('json', validate), (c) => {
const data = c.req.valid('json')
return c.json({
success: true,
message: `${data.name} is ${data.age}`,
})
const route = app.post('/author', typiaValidator('json', validate), (c) => {
const data = c.req.valid('json')
return c.json({
success: true,
message: `${data.name} is ${data.age}`,
})
})
```

Hook:
Expand All @@ -38,6 +44,60 @@ app.post(
)
```

### HTTP Module Validation

[Typia's HTTP module](https://typia.io/docs/misc/#http-module) allows you to validate query and header parameters with automatic type parsing.

- **Supported Parsers:** The HTTP module currently supports "query" and "header" validations.
- **Parsing Differences:** The parsing mechanism differs slightly from Hono's native parsers. Ensure that your type definitions comply with Typia's HTTP module restrictions.

```typescript
import { Hono } from 'hono'
import typia from 'typia'
import { typiaValidator } from '@hono/typia-validator/http'

interface Author {
name: string
age: number & tags.Type<'uint32'> & tags.Minimum<20> & tags.ExclusiveMaximum<100>
}

interface IQuery {
limit?: number
enforce: boolean
values?: string[]
atomic: string | null
indexes: number[]
}
interface IHeaders {
'x-category': 'x' | 'y' | 'z'
'x-memo'?: string
'x-name'?: string
'x-values': number[]
'x-flags': boolean[]
'x-descriptions': string[]
}

const app = new Hono()

const validate = typia.createValidate<Author>()
const validateQuery = typia.http.createValidateQuery<IQuery>()
const validateHeaders = typia.http.createValidateHeaders<IHeaders>()

app.get('/items',
typiaValidator('json', validate),
typiaValidator('query', validateQuery),
typiaValidator('header', validateHeaders),
(c) => {
const query = c.req.valid('query')
const headers = c.req.valid('header')
return c.json({
success: true,
query,
headers,
})
}
)
```
## Author

Patryk Dwórznik <https://github.com/dworznik>
Expand Down
21 changes: 18 additions & 3 deletions packages/typia-validator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,25 @@
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"exports": {
".": {
"default": "./dist/cjs/index.js",
"require": "./dist/cjs/index.js",
"import": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts"
},
"./http": {
"default": "./dist/cjs/http.js",
"require": "./dist/cjs/http.js",
"import": "./dist/esm/http.js",
"types": "./dist/esm/http.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"generate-test": "rimraf test-generated && typia generate --input test --output test-generated --project tsconfig.json",
"generate-test": "rimraf test-generated && typia generate --input test --output test-generated --project tsconfig.json && node scripts/add-ts-ignore.cjs",
"test": "npm run generate-test && jest",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json",
Expand All @@ -29,12 +43,13 @@
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"hono": ">=3.9.0",
"typia": "^6.1.0"
"typia": "^7.0.0"
},
"devDependencies": {
"hono": "^3.11.7",
"jest": "^29.7.0",
"rimraf": "^5.0.5",
"typia": "^5.0.4"
"typescript": "^5.4.0",
"typia": "^7.3.0"
}
}
27 changes: 27 additions & 0 deletions packages/typia-validator/scripts/add-ts-ignore.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// @ts-check
const fs = require('node:fs')
const path = require('node:path')

// https://github.com/samchon/typia/issues/1432
// typia generated files have some type errors

const generatedFiles = fs
.readdirSync(path.resolve(__dirname, '../test-generated'))
.map((file) => path.resolve(__dirname, '../test-generated', file))

for (const file of generatedFiles) {
const content = fs.readFileSync(file, 'utf8')
const lines = content.split('\n')
const distLines = []
for (const line of lines) {
if (
line.includes('._httpHeaderReadNumber(') ||
line.includes('._httpHeaderReadBigint(') ||
line.includes('._httpHeaderReadBoolean(')
)
distLines.push(`// @ts-ignore`)
distLines.push(line)
}

fs.writeFileSync(file, distLines.join('\n'))
}
181 changes: 181 additions & 0 deletions packages/typia-validator/src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import type { Context, MiddlewareHandler, Env, ValidationTargets, TypedResponse } from 'hono'
import { validator } from 'hono/validator'
import type { IReadableURLSearchParams, IValidation } from 'typia'

interface IFailure<T> {
success: false
errors: IValidation.IError[]
data: T
}

type BaseType<T> = T extends string
? string
: T extends number
? number
: T extends boolean
? boolean
: T extends symbol
? symbol
: T extends bigint
? bigint
: T
type Parsed<T> = T extends Record<string | number, any>
? {
[K in keyof T]-?: T[K] extends (infer U)[]
? (BaseType<U> | null | undefined)[] | undefined
: BaseType<T[K]> | null | undefined
}
: BaseType<T>

export type QueryValidation<O extends Record<string | number, any> = any> = (
input: string | URLSearchParams
) => IValidation<O>
export type QueryOutputType<T> = T extends QueryValidation<infer O> ? O : never
type QueryStringify<T> = T extends Record<string | number, any>
? {
// Suppress to split union types
[K in keyof T]: [T[K]] extends [bigint | number | boolean]
? `${T[K]}`
: T[K] extends (infer U)[]
? [U] extends [bigint | number | boolean]
? `${U}`[]
: T[K]
: T[K]
}
: T
export type HeaderValidation<O extends Record<string | number, any> = any> = (
input: Record<string, string | string[] | undefined>
) => IValidation<O>
export type HeaderOutputType<T> = T extends HeaderValidation<infer O> ? O : never
type HeaderStringify<T> = T extends Record<string | number, any>
? {
// Suppress to split union types
[K in keyof T]: [T[K]] extends [bigint | number | boolean]
? `${T[K]}`
: T[K] extends (infer U)[]
? [U] extends [bigint | number | boolean]
? `${U}`
: U
: T[K]
}
: T

export type HttpHook<T, E extends Env, P extends string, O = {}> = (
result: IValidation.ISuccess<T> | IFailure<Parsed<T>>,
c: Context<E, P>
) => Response | Promise<Response> | void | Promise<Response | void> | TypedResponse<O>
export type Hook<T, E extends Env, P extends string, O = {}> = (
result: IValidation.ISuccess<T> | IFailure<T>,
c: Context<E, P>
) => Response | Promise<Response> | void | Promise<Response | void> | TypedResponse<O>

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Validation<O = any> = (input: unknown) => IValidation<O>
export type OutputType<T> = T extends Validation<infer O> ? O : never

interface TypiaValidator {
<
T extends QueryValidation,
O extends QueryOutputType<T>,
E extends Env,
P extends string,
V extends { in: { query: QueryStringify<O> }; out: { query: O } } = {
in: { query: QueryStringify<O> }
out: { query: O }
}
>(
target: 'query',
validate: T,
hook?: HttpHook<O, E, P>
): MiddlewareHandler<E, P, V>

<
T extends HeaderValidation,
O extends HeaderOutputType<T>,
E extends Env,
P extends string,
V extends { in: { header: HeaderStringify<O> }; out: { header: O } } = {
in: { header: HeaderStringify<O> }
out: { header: O }
}
>(
target: 'header',
validate: T,
hook?: HttpHook<O, E, P>
): MiddlewareHandler<E, P, V>

<
T extends Validation,
O extends OutputType<T>,
Target extends Exclude<keyof ValidationTargets, 'query' | 'queries' | 'header'>,
E extends Env,
P extends string,
V extends {
in: { [K in Target]: O }
out: { [K in Target]: O }
} = {
in: { [K in Target]: O }
out: { [K in Target]: O }
}
>(
target: Target,
validate: T,
hook?: Hook<O, E, P>
): MiddlewareHandler<E, P, V>
}

export const typiaValidator: TypiaValidator = (
target: keyof ValidationTargets,
validate: (input: any) => IValidation<any>,
hook?: Hook<any, any, any>
): MiddlewareHandler => {
if (target === 'query' || target === 'header')
return async (c, next) => {
let value: any
if (target === 'query') {
const queries = c.req.queries()
value = {
get: (key) => queries[key]?.[0] ?? null,
getAll: (key) => queries[key] ?? [],
} satisfies IReadableURLSearchParams
} else {
value = Object.create(null)
for (const [key, headerValue] of c.req.raw.headers) value[key.toLowerCase()] = headerValue
if (c.req.raw.headers.has('Set-Cookie'))
value['Set-Cookie'] = c.req.raw.headers.getSetCookie()
}
const result = validate(value)

if (hook) {
const res = await hook(result as never, c)
if (res instanceof Response) return res
}
if (!result.success) {
return c.json({ success: false, error: result.errors }, 400)
}
c.req.addValidatedData(target, result.data)

await next()
}

return validator(target, async (value, c) => {
const result = validate(value)

if (hook) {
const hookResult = await hook({ ...result, data: value }, c)
if (hookResult) {
if (hookResult instanceof Response || hookResult instanceof Promise) {
return hookResult
}
if ('response' in hookResult) {
return hookResult.response
}
}
}

if (!result.success) {
return c.json({ success: false, error: result.errors }, 400)
}
return result.data
})
}
Loading

0 comments on commit c63470e

Please sign in to comment.