Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(typia-validator): support typia http module #888

Merged
merged 2 commits into from
Dec 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading