From 8627010094eae1122d703b293f5629acf1b3632f Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Sun, 23 Oct 2022 08:10:00 +0900 Subject: [PATCH] refactor(types): refactor and add tests for checking Types (#615) * refactor(types): refactor and add tests for checking Types * remove unused * uncomment * use `Handler` in validator middleware * remove unused * create `src/validator` dir and move some files into it * add the case that the context is in `validator` * rename `D` to `S` --- deno_dist/compose.ts | 14 +- deno_dist/context.ts | 47 +++---- deno_dist/hono.ts | 122 ++++++++++-------- .../middleware/serve-static/serve-static.ts | 7 +- deno_dist/middleware/validator/middleware.ts | 92 +++---------- deno_dist/request.ts | 11 +- deno_dist/utils/types.ts | 5 + deno_dist/{middleware => }/validator/rule.ts | 0 .../{middleware => }/validator/sanitizer.ts | 0 deno_dist/validator/schema.ts | 54 ++++++++ .../{middleware => }/validator/validator.ts | 8 +- src/compose.ts | 14 +- src/context.ts | 47 +++---- src/hono.test.ts | 66 +++++++++- src/hono.ts | 122 ++++++++++-------- src/middleware/serve-static/serve-static.ts | 7 +- src/middleware/validator/middleware.test.ts | 116 ++++++++++++++++- src/middleware/validator/middleware.ts | 92 +++---------- src/request.ts | 11 +- src/utils/types.ts | 5 + src/{middleware => }/validator/rule.test.ts | 0 src/{middleware => }/validator/rule.ts | 0 .../validator/sanitizer.test.ts | 0 src/{middleware => }/validator/sanitizer.ts | 0 src/validator/schema.ts | 54 ++++++++ .../validator/validator.test.ts | 2 +- src/{middleware => }/validator/validator.ts | 8 +- tsconfig.build.cjs.json | 2 + tsconfig.build.json | 2 + tsconfig.json | 4 +- 30 files changed, 564 insertions(+), 348 deletions(-) create mode 100644 deno_dist/utils/types.ts rename deno_dist/{middleware => }/validator/rule.ts (100%) rename deno_dist/{middleware => }/validator/sanitizer.ts (100%) create mode 100644 deno_dist/validator/schema.ts rename deno_dist/{middleware => }/validator/validator.ts (98%) create mode 100644 src/utils/types.ts rename src/{middleware => }/validator/rule.test.ts (100%) rename src/{middleware => }/validator/rule.ts (100%) rename src/{middleware => }/validator/sanitizer.test.ts (100%) rename src/{middleware => }/validator/sanitizer.ts (100%) create mode 100644 src/validator/schema.ts rename src/{middleware => }/validator/validator.test.ts (99%) rename src/{middleware => }/validator/validator.ts (98%) diff --git a/deno_dist/compose.ts b/deno_dist/compose.ts index 3ac2c32c..aec2f014 100644 --- a/deno_dist/compose.ts +++ b/deno_dist/compose.ts @@ -1,16 +1,22 @@ import { HonoContext } from './context.ts' import type { Environment, NotFoundHandler, ErrorHandler } from './hono.ts' +import type { Schema } from './validator/schema.ts' interface ComposeContext { finalized: boolean - res: any + res: unknown } // Based on the code in the MIT licensed `koa-compose` package. -export const compose = = Environment>( +export const compose = < + C extends ComposeContext, + P extends string = string, + E extends Partial = Environment, + D extends Partial = Schema +>( middleware: Function[], - onNotFound?: NotFoundHandler, - onError?: ErrorHandler + onNotFound?: NotFoundHandler, + onError?: ErrorHandler ) => { const middlewareLength = middleware.length return (context: C, next?: Function) => { diff --git a/deno_dist/context.ts b/deno_dist/context.ts index 3c95f282..41d38337 100644 --- a/deno_dist/context.ts +++ b/deno_dist/context.ts @@ -1,24 +1,19 @@ -import type { - Environment, - NotFoundHandler, - ContextVariableMap, - Bindings, - ValidatedData, -} from './hono.ts' +import type { Environment, NotFoundHandler, ContextVariableMap, Bindings } from './hono.ts' import type { CookieOptions } from './utils/cookie.ts' import { serialize } from './utils/cookie.ts' import type { StatusCode } from './utils/http-status.ts' +import type { Schema, SchemaToProp } from './validator/schema.ts' type HeaderField = [string, string] type Headers = Record export type Data = string | ArrayBuffer | ReadableStream export interface Context< - RequestParamKeyType extends string = string, - E extends Partial = any, - D extends ValidatedData = ValidatedData + P extends string = string, + E extends Partial = Environment, + S extends Partial = Schema > { - req: Request + req: Request> env: E['Bindings'] event: FetchEvent executionCtx: ExecutionContext @@ -32,7 +27,7 @@ export interface Context< set: { (key: Key, value: ContextVariableMap[Key]): void (key: Key, value: E['Variables'][Key]): void - (key: string, value: any): void + (key: string, value: unknown): void } get: { (key: Key): ContextVariableMap[Key] @@ -51,12 +46,12 @@ export interface Context< } export class HonoContext< - RequestParamKeyType extends string = string, + P extends string = string, E extends Partial = Environment, - D extends ValidatedData = ValidatedData -> implements Context + S extends Partial = Schema +> implements Context { - req: Request + req: Request> env: E['Bindings'] finalized: boolean error: Error | undefined = undefined @@ -65,19 +60,19 @@ export class HonoContext< private _executionCtx: FetchEvent | ExecutionContext | undefined private _pretty: boolean = false private _prettySpace: number = 2 - private _map: Record | undefined + private _map: Record | undefined private _headers: Record | undefined private _res: Response | undefined - private notFoundHandler: NotFoundHandler + private notFoundHandler: NotFoundHandler constructor( - req: Request, - env: E['Bindings'] | undefined = undefined, + req: Request

, + env: E['Bindings'] = {}, executionCtx: FetchEvent | ExecutionContext | undefined = undefined, - notFoundHandler: NotFoundHandler = () => new Response() + notFoundHandler: NotFoundHandler = () => new Response() ) { this._executionCtx = executionCtx - this.req = req as Request + this.req = req as Request> this.env = env || ({} as Bindings) this.notFoundHandler = notFoundHandler @@ -142,15 +137,15 @@ export class HonoContext< set(key: Key, value: ContextVariableMap[Key]): void set(key: Key, value: E['Variables'][Key]): void - set(key: string, value: any): void - set(key: string, value: any): void { + set(key: string, value: unknown): void + set(key: string, value: unknown): void { this._map ||= {} this._map[key] = value } get(key: Key): ContextVariableMap[Key] get(key: Key): E['Variables'][Key] - get(key: string): T + get(key: string): T get(key: string) { if (!this._map) { return undefined @@ -233,6 +228,6 @@ export class HonoContext< } notFound(): Response | Promise { - return this.notFoundHandler(this as any) + return this.notFoundHandler(this) } } diff --git a/deno_dist/hono.ts b/deno_dist/hono.ts index ec59003e..470efaa9 100644 --- a/deno_dist/hono.ts +++ b/deno_dist/hono.ts @@ -10,6 +10,7 @@ import { SmartRouter } from './router/smart-router/index.ts' import { StaticRouter } from './router/static-router/index.ts' import { TrieRouter } from './router/trie-router/index.ts' import { getPathFromURL, mergePath } from './utils/url.ts' +import type { Schema } from './validator/schema.ts' export interface ContextVariableMap {} @@ -20,30 +21,29 @@ export type Environment = { Variables: Variables } -export type ValidatedData = Record // For validated data - export type Handler< - RequestParamKeyType extends string = string, + P extends string = string, E extends Partial = Environment, - D extends ValidatedData = ValidatedData -> = ( - c: Context, - next: Next -) => Response | Promise | Promise | Promise + S extends Partial = Schema +> = (c: Context, next: Next) => Response | Promise -export type MiddlewareHandler = = Environment>( - c: Context, - next: Next -) => Promise | Promise +export type MiddlewareHandler< + P extends string = string, + E extends Partial = Environment, + S extends Partial = Schema +> = (c: Context, next: Next) => Promise -export type NotFoundHandler = Environment> = ( - c: Context -) => Response | Promise +export type NotFoundHandler< + P extends string = string, + E extends Partial = Environment, + S extends Partial = Schema +> = (c: Context) => Response | Promise -export type ErrorHandler = Environment> = ( - err: Error, - c: Context -) => Response +export type ErrorHandler< + P extends string = string, + E extends Partial = Environment, + S extends Partial = Schema +> = (err: Error, c: Context) => Response export type Next = () => Promise @@ -60,52 +60,65 @@ type ParamKeys = Path extends `${infer Component}/${infer Rest}` ? ParamKey | ParamKeys : ParamKey -interface HandlerInterface, U = Hono> { +interface HandlerInterface< + P extends string, + E extends Partial, + S extends Partial, + U = Hono +> { // app.get(handler...) - ( + ( ...handlers: Handler extends never ? string : ParamKeys, E, Data>[] ): U - (...handlers: Handler[]): U + (...handlers: Handler[]): U + // app.get('/', handler, handler...) - ( + = Schema>( path: Path, ...handlers: Handler extends never ? string : ParamKeys, E, Data>[] ): U - (path: string, ...handlers: Handler[]): U + (path: Path, ...handlers: Handler[]): U + (path: string, ...handlers: Handler[]): U } type Methods = typeof METHODS[number] | typeof METHOD_NAME_ALL_LOWERCASE function defineDynamicClass(): { - new = Environment, T extends string = string, U = Hono>(): { - [K in Methods]: HandlerInterface + new < + E extends Partial = Environment, + P extends string = string, + S extends Partial = Schema, + U = Hono + >(): { + [K in Methods]: HandlerInterface } } { - return class {} as any + return class {} as never } interface Route< + P extends string = string, E extends Partial = Environment, - D extends ValidatedData = ValidatedData + S extends Partial = Schema > { path: string method: string - handler: Handler + handler: Handler } export class Hono< E extends Partial = Environment, P extends string = '/', - D extends ValidatedData = ValidatedData -> extends defineDynamicClass()> { - readonly router: Router> = new SmartRouter({ + S extends Partial = Schema +> extends defineDynamicClass()> { + readonly router: Router> = new SmartRouter({ routers: [new StaticRouter(), new RegExpRouter(), new TrieRouter()], }) readonly strict: boolean = true // strict routing - default is true private _tempPath: string = '' private path: string = '/' - routes: Route[] = [] + routes: Route[] = [] constructor(init: Partial> = {}) { super() @@ -114,18 +127,18 @@ export class Hono< const allMethods = [...METHODS, METHOD_NAME_ALL_LOWERCASE] allMethods.map((method) => { - this[method] = ( - args1: Path | Handler, E, D>, - ...args: [Handler, E, D>] + this[method] = ( + args1: Path | Handler, Env, Data>, + ...args: [Handler, Env, Data>] ): this => { if (typeof args1 === 'string') { this.path = args1 } else { - this.addRoute(method, this.path, args1) + this.addRoute(method, this.path, args1 as unknown as Handler) } args.map((handler) => { if (typeof handler !== 'string') { - this.addRoute(method, this.path, handler) + this.addRoute(method, this.path, handler as unknown as Handler) } }) return this @@ -135,17 +148,17 @@ export class Hono< Object.assign(this, init) } - private notFoundHandler: NotFoundHandler = (c: Context) => { + private notFoundHandler: NotFoundHandler = (c: Context) => { return c.text('404 Not Found', 404) } - private errorHandler: ErrorHandler = (err: Error, c: Context) => { + private errorHandler: ErrorHandler = (err: Error, c: Context) => { console.trace(err.message) const message = 'Internal Server Error' return c.text(message, 500) } - route(path: string, app?: Hono): Hono { + route(path: string, app?: Hono) { this._tempPath = path if (app) { app.routes.map((r) => { @@ -153,18 +166,17 @@ export class Hono< }) this._tempPath = '' } - return this } - use( + use = Schema>( ...middleware: Handler[] - ): Hono - use( + ): Hono + use = Schema>( arg1: string, ...middleware: Handler[] - ): Hono - use(arg1: string | Handler, ...handlers: Handler[]): Hono { + ): Hono + use(arg1: string | Handler, ...handlers: Handler[]) { if (typeof arg1 === 'string') { this.path = arg1 } else { @@ -176,23 +188,23 @@ export class Hono< return this } - onError(handler: ErrorHandler): Hono { + onError(handler: ErrorHandler) { this.errorHandler = handler return this } - notFound(handler: NotFoundHandler): Hono { + notFound(handler: NotFoundHandler) { this.notFoundHandler = handler return this } - private addRoute(method: string, path: string, handler: Handler): void { + private addRoute(method: string, path: string, handler: Handler): void { method = method.toUpperCase() if (this._tempPath) { path = mergePath(this._tempPath, path) } this.router.add(method, path, handler) - const r: Route = { path: path, method: method, handler: handler } + const r: Route = { path: path, method: method, handler: handler } this.routes.push(r) } @@ -200,7 +212,7 @@ export class Hono< return this.router.match(method, path) } - private handleError(err: unknown, c: Context) { + private handleError(err: unknown, c: Context) { if (err instanceof Error) { return this.errorHandler(err, c) } @@ -218,12 +230,12 @@ export class Hono< const result = this.matchRoute(method, path) request.paramData = result?.params - const c = new HonoContext(request, env, eventOrExecutionCtx, this.notFoundHandler) + const c = new HonoContext(request, env, eventOrExecutionCtx, this.notFoundHandler) // Do not `compose` if it has only one handler if (result && result.handlers.length === 1) { const handler = result.handlers[0] - let res: ReturnType + let res: ReturnType> try { res = handler(c, async () => {}) @@ -249,7 +261,7 @@ export class Hono< } const handlers = result ? result.handlers : [this.notFoundHandler] - const composed = compose, E>( + const composed = compose, P, E, S>( handlers, this.notFoundHandler, this.errorHandler diff --git a/deno_dist/middleware/serve-static/serve-static.ts b/deno_dist/middleware/serve-static/serve-static.ts index 51cdcaa1..451921cf 100644 --- a/deno_dist/middleware/serve-static/serve-static.ts +++ b/deno_dist/middleware/serve-static/serve-static.ts @@ -1,5 +1,4 @@ -import type { Context } from '../../context.ts' -import type { Next } from '../../hono.ts' +import type { MiddlewareHandler } from '../../hono.ts' import { getContentFromKVAsset } from '../../utils/cloudflare.ts' import { getFilePath } from '../../utils/filepath.ts' import { getMimeType } from '../../utils/mime.ts' @@ -14,8 +13,8 @@ export type ServeStaticOptions = { const DEFAULT_DOCUMENT = 'index.html' // This middleware is available only on Cloudflare Workers. -export const serveStatic = (options: ServeStaticOptions = { root: '' }) => { - return async (c: Context, next: Next) => { +export const serveStatic = (options: ServeStaticOptions = { root: '' }): MiddlewareHandler => { + return async (c, next) => { // Do nothing if Response is already set if (c.finalized) { await next() diff --git a/deno_dist/middleware/validator/middleware.ts b/deno_dist/middleware/validator/middleware.ts index a89aa9b9..88cb8eb2 100644 --- a/deno_dist/middleware/validator/middleware.ts +++ b/deno_dist/middleware/validator/middleware.ts @@ -1,62 +1,10 @@ import type { Context } from '../../context.ts' -import type { Environment, Next, ValidatedData } from '../../hono.ts' +import type { Environment, MiddlewareHandler } from '../../hono.ts' import { getStatusText } from '../../utils/http-status.ts' import { mergeObjects } from '../../utils/object.ts' -import { VBase, Validator, VObjectBase } from './validator.ts' -import type { - VString, - VNumber, - VBoolean, - VObject, - VNumberArray, - VStringArray, - VBooleanArray, - ValidateResult, - VArray, -} from './validator.ts' - -export type Schema = { - [key: string]: - | VString - | VNumber - | VBoolean - | VStringArray - | VNumberArray - | VBooleanArray - | Schema - | VObject - | VArray -} - -type SchemaToProp = { - [K in keyof T]: T[K] extends VNumberArray - ? number[] - : T[K] extends VBooleanArray - ? boolean[] - : T[K] extends VStringArray - ? string[] - : T[K] extends VNumber - ? number - : T[K] extends VBoolean - ? boolean - : T[K] extends VString - ? string - : T[K] extends VObjectBase - ? T[K]['container'] extends VNumber - ? number - : T[K]['container'] extends VString - ? string - : T[K]['container'] extends VBoolean - ? boolean - : T[K] extends VArray - ? SchemaToProp> - : T[K] extends VObject - ? SchemaToProp - : T[K] extends Schema - ? SchemaToProp - : never - : SchemaToProp -} +import type { Schema } from '../../validator/schema.ts' +import type { ValidateResult } from '../../validator/validator.ts' +import { Validator, VBase, VObjectBase } from '../../validator/validator.ts' type ResultSet = { hasError: boolean @@ -64,27 +12,27 @@ type ResultSet = { results: ValidateResult[] } -type Done> = ( +type Done

= Environment> = ( resultSet: ResultSet, - context: Context + c: Context ) => Response | void -type ValidationFunction> = ( - v: Validator, - c: Context -) => T +type ValidationFunction< + P extends string, + E extends Partial = Environment, + S extends Schema = Schema +> = (v: Validator, c: Context) => S -type MiddlewareHandler< - Data extends ValidatedData = ValidatedData, - Env extends Partial = Environment -> = (c: Context, next: Next) => Promise | Promise - -export const validatorMiddleware = >( - validationFunction: ValidationFunction, - options?: { done?: Done } +export const validatorMiddleware = < + P extends string, + E extends Partial = Environment, + S extends Schema = Schema +>( + validationFunction: ValidationFunction, + options?: { done?: Done } ) => { const v = new Validator() - const handler: MiddlewareHandler, Env> = async (c, next) => { + const handler: MiddlewareHandler = async (c, next) => { const resultSet: ResultSet = { hasError: false, messages: [], @@ -98,7 +46,7 @@ export const validatorMiddleware = (): Promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any jsonData?: any - json(): Promise + json(): Promise> data: Data valid: { - (key: string | string[], value: any): Data + (key: string | string[], value: unknown): Data (): Data } } @@ -132,9 +133,9 @@ export function extendRequestPrototype() { return body } as InstanceType['parseBody'] - Request.prototype.json = async function (this: Request): Promise { + Request.prototype.json = async function (this: Request) { // Cache the JSON body - let jsonData: JSONData + let jsonData: Partial if (!this.jsonData) { jsonData = JSON.parse(await this.text()) this.jsonData = jsonData @@ -144,7 +145,7 @@ export function extendRequestPrototype() { return jsonData } as InstanceType['jsonData'] - Request.prototype.valid = function (this: Request, keys?: string | string[], value?: any) { + Request.prototype.valid = function (this: Request, keys?: string | string[], value?: unknown) { if (!this.data) { this.data = {} } diff --git a/deno_dist/utils/types.ts b/deno_dist/utils/types.ts new file mode 100644 index 00000000..a61550ba --- /dev/null +++ b/deno_dist/utils/types.ts @@ -0,0 +1,5 @@ +export type Expect = T +export type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false +export type NotEqual = true extends Equal ? false : true diff --git a/deno_dist/middleware/validator/rule.ts b/deno_dist/validator/rule.ts similarity index 100% rename from deno_dist/middleware/validator/rule.ts rename to deno_dist/validator/rule.ts diff --git a/deno_dist/middleware/validator/sanitizer.ts b/deno_dist/validator/sanitizer.ts similarity index 100% rename from deno_dist/middleware/validator/sanitizer.ts rename to deno_dist/validator/sanitizer.ts diff --git a/deno_dist/validator/schema.ts b/deno_dist/validator/schema.ts new file mode 100644 index 00000000..3fc024ac --- /dev/null +++ b/deno_dist/validator/schema.ts @@ -0,0 +1,54 @@ +import type { + VString, + VNumber, + VBoolean, + VObject, + VNumberArray, + VStringArray, + VBooleanArray, + VArray, + VObjectBase, +} from './validator.ts' + +export type Schema = { + [key: string]: + | VString + | VNumber + | VBoolean + | VStringArray + | VNumberArray + | VBooleanArray + | Schema + | VObject + | VArray +} + +export type SchemaToProp = { + [K in keyof T]: T[K] extends VNumberArray + ? number[] + : T[K] extends VBooleanArray + ? boolean[] + : T[K] extends VStringArray + ? string[] + : T[K] extends VNumber + ? number + : T[K] extends VBoolean + ? boolean + : T[K] extends VString + ? string + : T[K] extends VObjectBase + ? T[K]['container'] extends VNumber + ? number + : T[K]['container'] extends VString + ? string + : T[K]['container'] extends VBoolean + ? boolean + : T[K] extends VArray + ? SchemaToProp> + : T[K] extends VObject + ? SchemaToProp + : T[K] extends Schema + ? SchemaToProp + : never + : SchemaToProp +} diff --git a/deno_dist/middleware/validator/validator.ts b/deno_dist/validator/validator.ts similarity index 98% rename from deno_dist/middleware/validator/validator.ts rename to deno_dist/validator/validator.ts index 87b69bfa..4268b96d 100644 --- a/deno_dist/middleware/validator/validator.ts +++ b/deno_dist/validator/validator.ts @@ -1,8 +1,8 @@ -import { JSONPathCopy } from '../../utils/json.ts' -import type { JSONObject, JSONPrimitive, JSONArray } from '../../utils/json.ts' -import type { Schema } from './middleware.ts' +import { JSONPathCopy } from './../utils/json.ts' +import type { JSONObject, JSONPrimitive, JSONArray } from './../utils/json.ts' import { rule } from './rule.ts' import { sanitizer } from './sanitizer.ts' +import type { Schema } from './schema.ts' type Target = 'query' | 'header' | 'body' | 'json' type Type = JSONPrimitive | JSONObject | JSONArray | File @@ -209,7 +209,7 @@ export abstract class VBase { return this } - validate = async (req: Request): Promise => { + validate = async (req: R): Promise => { let value: Type = undefined let jsonData: JSONObject | undefined = undefined diff --git a/src/compose.ts b/src/compose.ts index cc037c4a..55b876b5 100644 --- a/src/compose.ts +++ b/src/compose.ts @@ -1,16 +1,22 @@ import { HonoContext } from './context' import type { Environment, NotFoundHandler, ErrorHandler } from './hono' +import type { Schema } from './validator/schema' interface ComposeContext { finalized: boolean - res: any + res: unknown } // Based on the code in the MIT licensed `koa-compose` package. -export const compose = = Environment>( +export const compose = < + C extends ComposeContext, + P extends string = string, + E extends Partial = Environment, + D extends Partial = Schema +>( middleware: Function[], - onNotFound?: NotFoundHandler, - onError?: ErrorHandler + onNotFound?: NotFoundHandler, + onError?: ErrorHandler ) => { const middlewareLength = middleware.length return (context: C, next?: Function) => { diff --git a/src/context.ts b/src/context.ts index 37a9cd46..bff4d2f0 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,24 +1,19 @@ -import type { - Environment, - NotFoundHandler, - ContextVariableMap, - Bindings, - ValidatedData, -} from './hono' +import type { Environment, NotFoundHandler, ContextVariableMap, Bindings } from './hono' import type { CookieOptions } from './utils/cookie' import { serialize } from './utils/cookie' import type { StatusCode } from './utils/http-status' +import type { Schema, SchemaToProp } from './validator/schema' type HeaderField = [string, string] type Headers = Record export type Data = string | ArrayBuffer | ReadableStream export interface Context< - RequestParamKeyType extends string = string, - E extends Partial = any, - D extends ValidatedData = ValidatedData + P extends string = string, + E extends Partial = Environment, + S extends Partial = Schema > { - req: Request + req: Request> env: E['Bindings'] event: FetchEvent executionCtx: ExecutionContext @@ -32,7 +27,7 @@ export interface Context< set: { (key: Key, value: ContextVariableMap[Key]): void (key: Key, value: E['Variables'][Key]): void - (key: string, value: any): void + (key: string, value: unknown): void } get: { (key: Key): ContextVariableMap[Key] @@ -51,12 +46,12 @@ export interface Context< } export class HonoContext< - RequestParamKeyType extends string = string, + P extends string = string, E extends Partial = Environment, - D extends ValidatedData = ValidatedData -> implements Context + S extends Partial = Schema +> implements Context { - req: Request + req: Request> env: E['Bindings'] finalized: boolean error: Error | undefined = undefined @@ -65,19 +60,19 @@ export class HonoContext< private _executionCtx: FetchEvent | ExecutionContext | undefined private _pretty: boolean = false private _prettySpace: number = 2 - private _map: Record | undefined + private _map: Record | undefined private _headers: Record | undefined private _res: Response | undefined - private notFoundHandler: NotFoundHandler + private notFoundHandler: NotFoundHandler constructor( - req: Request, - env: E['Bindings'] | undefined = undefined, + req: Request

, + env: E['Bindings'] = {}, executionCtx: FetchEvent | ExecutionContext | undefined = undefined, - notFoundHandler: NotFoundHandler = () => new Response() + notFoundHandler: NotFoundHandler = () => new Response() ) { this._executionCtx = executionCtx - this.req = req as Request + this.req = req as Request> this.env = env || ({} as Bindings) this.notFoundHandler = notFoundHandler @@ -142,15 +137,15 @@ export class HonoContext< set(key: Key, value: ContextVariableMap[Key]): void set(key: Key, value: E['Variables'][Key]): void - set(key: string, value: any): void - set(key: string, value: any): void { + set(key: string, value: unknown): void + set(key: string, value: unknown): void { this._map ||= {} this._map[key] = value } get(key: Key): ContextVariableMap[Key] get(key: Key): E['Variables'][Key] - get(key: string): T + get(key: string): T get(key: string) { if (!this._map) { return undefined @@ -233,6 +228,6 @@ export class HonoContext< } notFound(): Response | Promise { - return this.notFoundHandler(this as any) + return this.notFoundHandler(this) } } diff --git a/src/hono.test.ts b/src/hono.test.ts index 0bf0615a..a439ba73 100644 --- a/src/hono.test.ts +++ b/src/hono.test.ts @@ -1,8 +1,10 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import type { Context } from './context' -import type { Next } from './hono' +import type { Handler, Next } from './hono' import { Hono } from './hono' import { logger } from './middleware/logger' import { poweredBy } from './middleware/powered-by' +import type { Expect, Equal } from './utils/types' describe('GET Request', () => { const app = new Hono() @@ -1137,3 +1139,65 @@ describe('Count of logger called', () => { expect(log).toMatch(/404/) }) }) + +describe('Context set/get variables', () => { + type Variables = { + id: number + title: string + } + + const app = new Hono<{ Variables: Variables }>() + + it('Should set and get variables with correct types', async () => { + app.use('*', async (c, next) => { + c.set('id', 123) + c.set('title', 'Hello') + await next() + }) + app.get('/', (c) => { + const id = c.get('id') + const title = c.get('title') + type verifyID = Expect> + type verifyTitle = Expect> + return c.text(`${id} is ${title}`) + }) + const res = await app.request('http://localhost/') + expect(res.status).toBe(200) + expect(await res.text()).toBe('123 is Hello') + }) +}) + +describe('Context binding variables', () => { + type Bindings = { + USER_ID: number + USER_NAME: string + } + + const app = new Hono<{ Bindings: Bindings }>() + + it('Should get binding variables with correct types', async () => { + app.get('/', (c) => { + type verifyID = Expect> + type verifyName = Expect> + return c.text('These are verified') + }) + const res = await app.request('http://localhost/') + expect(res.status).toBe(200) + }) +}) + +describe('Handler as variables', () => { + const app = new Hono() + + it('Should be typed correctly', async () => { + const handler: Handler = (c) => { + const id = c.req.param('id') + return c.text(`Post id is ${id}`) + } + app.get('/posts/:id', handler) + + const res = await app.request('http://localhost/posts/123') + expect(res.status).toBe(200) + expect(await res.text()).toBe('Post id is 123') + }) +}) diff --git a/src/hono.ts b/src/hono.ts index ec3541d2..64a0cba7 100644 --- a/src/hono.ts +++ b/src/hono.ts @@ -10,6 +10,7 @@ import { SmartRouter } from './router/smart-router' import { StaticRouter } from './router/static-router' import { TrieRouter } from './router/trie-router' import { getPathFromURL, mergePath } from './utils/url' +import type { Schema } from './validator/schema' export interface ContextVariableMap {} @@ -20,30 +21,29 @@ export type Environment = { Variables: Variables } -export type ValidatedData = Record // For validated data - export type Handler< - RequestParamKeyType extends string = string, + P extends string = string, E extends Partial = Environment, - D extends ValidatedData = ValidatedData -> = ( - c: Context, - next: Next -) => Response | Promise | Promise | Promise + S extends Partial = Schema +> = (c: Context, next: Next) => Response | Promise -export type MiddlewareHandler = = Environment>( - c: Context, - next: Next -) => Promise | Promise +export type MiddlewareHandler< + P extends string = string, + E extends Partial = Environment, + S extends Partial = Schema +> = (c: Context, next: Next) => Promise -export type NotFoundHandler = Environment> = ( - c: Context -) => Response | Promise +export type NotFoundHandler< + P extends string = string, + E extends Partial = Environment, + S extends Partial = Schema +> = (c: Context) => Response | Promise -export type ErrorHandler = Environment> = ( - err: Error, - c: Context -) => Response +export type ErrorHandler< + P extends string = string, + E extends Partial = Environment, + S extends Partial = Schema +> = (err: Error, c: Context) => Response export type Next = () => Promise @@ -60,52 +60,65 @@ type ParamKeys = Path extends `${infer Component}/${infer Rest}` ? ParamKey | ParamKeys : ParamKey -interface HandlerInterface, U = Hono> { +interface HandlerInterface< + P extends string, + E extends Partial, + S extends Partial, + U = Hono +> { // app.get(handler...) - ( + ( ...handlers: Handler extends never ? string : ParamKeys, E, Data>[] ): U - (...handlers: Handler[]): U + (...handlers: Handler[]): U + // app.get('/', handler, handler...) - ( + = Schema>( path: Path, ...handlers: Handler extends never ? string : ParamKeys, E, Data>[] ): U - (path: string, ...handlers: Handler[]): U + (path: Path, ...handlers: Handler[]): U + (path: string, ...handlers: Handler[]): U } type Methods = typeof METHODS[number] | typeof METHOD_NAME_ALL_LOWERCASE function defineDynamicClass(): { - new = Environment, T extends string = string, U = Hono>(): { - [K in Methods]: HandlerInterface + new < + E extends Partial = Environment, + P extends string = string, + S extends Partial = Schema, + U = Hono + >(): { + [K in Methods]: HandlerInterface } } { - return class {} as any + return class {} as never } interface Route< + P extends string = string, E extends Partial = Environment, - D extends ValidatedData = ValidatedData + S extends Partial = Schema > { path: string method: string - handler: Handler + handler: Handler } export class Hono< E extends Partial = Environment, P extends string = '/', - D extends ValidatedData = ValidatedData -> extends defineDynamicClass()> { - readonly router: Router> = new SmartRouter({ + S extends Partial = Schema +> extends defineDynamicClass()> { + readonly router: Router> = new SmartRouter({ routers: [new StaticRouter(), new RegExpRouter(), new TrieRouter()], }) readonly strict: boolean = true // strict routing - default is true private _tempPath: string = '' private path: string = '/' - routes: Route[] = [] + routes: Route[] = [] constructor(init: Partial> = {}) { super() @@ -114,18 +127,18 @@ export class Hono< const allMethods = [...METHODS, METHOD_NAME_ALL_LOWERCASE] allMethods.map((method) => { - this[method] = ( - args1: Path | Handler, E, D>, - ...args: [Handler, E, D>] + this[method] = ( + args1: Path | Handler, Env, Data>, + ...args: [Handler, Env, Data>] ): this => { if (typeof args1 === 'string') { this.path = args1 } else { - this.addRoute(method, this.path, args1) + this.addRoute(method, this.path, args1 as unknown as Handler) } args.map((handler) => { if (typeof handler !== 'string') { - this.addRoute(method, this.path, handler) + this.addRoute(method, this.path, handler as unknown as Handler) } }) return this @@ -135,17 +148,17 @@ export class Hono< Object.assign(this, init) } - private notFoundHandler: NotFoundHandler = (c: Context) => { + private notFoundHandler: NotFoundHandler = (c: Context) => { return c.text('404 Not Found', 404) } - private errorHandler: ErrorHandler = (err: Error, c: Context) => { + private errorHandler: ErrorHandler = (err: Error, c: Context) => { console.trace(err.message) const message = 'Internal Server Error' return c.text(message, 500) } - route(path: string, app?: Hono): Hono { + route(path: string, app?: Hono) { this._tempPath = path if (app) { app.routes.map((r) => { @@ -153,18 +166,17 @@ export class Hono< }) this._tempPath = '' } - return this } - use( + use = Schema>( ...middleware: Handler[] - ): Hono - use( + ): Hono + use = Schema>( arg1: string, ...middleware: Handler[] - ): Hono - use(arg1: string | Handler, ...handlers: Handler[]): Hono { + ): Hono + use(arg1: string | Handler, ...handlers: Handler[]) { if (typeof arg1 === 'string') { this.path = arg1 } else { @@ -176,23 +188,23 @@ export class Hono< return this } - onError(handler: ErrorHandler): Hono { + onError(handler: ErrorHandler) { this.errorHandler = handler return this } - notFound(handler: NotFoundHandler): Hono { + notFound(handler: NotFoundHandler) { this.notFoundHandler = handler return this } - private addRoute(method: string, path: string, handler: Handler): void { + private addRoute(method: string, path: string, handler: Handler): void { method = method.toUpperCase() if (this._tempPath) { path = mergePath(this._tempPath, path) } this.router.add(method, path, handler) - const r: Route = { path: path, method: method, handler: handler } + const r: Route = { path: path, method: method, handler: handler } this.routes.push(r) } @@ -200,7 +212,7 @@ export class Hono< return this.router.match(method, path) } - private handleError(err: unknown, c: Context) { + private handleError(err: unknown, c: Context) { if (err instanceof Error) { return this.errorHandler(err, c) } @@ -218,12 +230,12 @@ export class Hono< const result = this.matchRoute(method, path) request.paramData = result?.params - const c = new HonoContext(request, env, eventOrExecutionCtx, this.notFoundHandler) + const c = new HonoContext(request, env, eventOrExecutionCtx, this.notFoundHandler) // Do not `compose` if it has only one handler if (result && result.handlers.length === 1) { const handler = result.handlers[0] - let res: ReturnType + let res: ReturnType> try { res = handler(c, async () => {}) @@ -249,7 +261,7 @@ export class Hono< } const handlers = result ? result.handlers : [this.notFoundHandler] - const composed = compose, E>( + const composed = compose, P, E, S>( handlers, this.notFoundHandler, this.errorHandler diff --git a/src/middleware/serve-static/serve-static.ts b/src/middleware/serve-static/serve-static.ts index 8ef59053..421da36e 100644 --- a/src/middleware/serve-static/serve-static.ts +++ b/src/middleware/serve-static/serve-static.ts @@ -1,5 +1,4 @@ -import type { Context } from '../../context' -import type { Next } from '../../hono' +import type { MiddlewareHandler } from '../../hono' import { getContentFromKVAsset } from '../../utils/cloudflare' import { getFilePath } from '../../utils/filepath' import { getMimeType } from '../../utils/mime' @@ -14,8 +13,8 @@ export type ServeStaticOptions = { const DEFAULT_DOCUMENT = 'index.html' // This middleware is available only on Cloudflare Workers. -export const serveStatic = (options: ServeStaticOptions = { root: '' }) => { - return async (c: Context, next: Next) => { +export const serveStatic = (options: ServeStaticOptions = { root: '' }): MiddlewareHandler => { + return async (c, next) => { // Do nothing if Response is already set if (c.finalized) { await next() diff --git a/src/middleware/validator/middleware.test.ts b/src/middleware/validator/middleware.test.ts index e089e453..92bc09c8 100644 --- a/src/middleware/validator/middleware.test.ts +++ b/src/middleware/validator/middleware.test.ts @@ -1,5 +1,8 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { Hono } from '../../hono' import { getStatusText } from '../../utils/http-status' +import type { Expect, Equal } from '../../utils/types' +import type { Schema } from '../../validator/schema' import { validator } from './index' describe('Basic - query', () => { @@ -102,15 +105,19 @@ describe('Basic - header & custom message', () => { }) }) -describe('Basic - JSON', () => { +describe('Basic - JSON with type check', () => { const app = new Hono() // JSON app.post( '/json', validator((v) => ({ + id: v.json('post.author.id').asNumber(), name: v.json('post.author.name').isAlpha(), })), (c) => { + const { id, name } = c.req.valid() + type verifyID = Expect> + type verifyName = Expect> return c.text('Valid') } ) @@ -119,6 +126,7 @@ describe('Basic - JSON', () => { const json = { post: { author: { + id: 123, name: 'abcdef', }, }, @@ -579,7 +587,7 @@ describe('Structured data', () => { }) }) -describe('Array values', () => { +describe('Array values with type check', () => { const app = new Hono() app.post( '/post', @@ -591,8 +599,13 @@ describe('Array values', () => { }, })), (c) => { - const res = c.req.valid() - return c.json({ tag1: res.post.tags[0] }) + const { post } = c.req.valid() + + type verifyTitle = Expect> + type verifyTags = Expect> + type verifyIDs = Expect> + + return c.json({ tag1: post.tags[0] }) } ) @@ -730,3 +743,98 @@ describe('Special case', () => { ) }) }) + +describe('Type check in special case', () => { + it('Should return 200 response with correct types', async () => { + const app = new Hono() + app.post( + '/posts/:id', + validator((v) => ({ + title: v.body('title').isRequired(), + })), + (c) => { + const res = c.req.valid() + const id = c.req.param('id') + type verifyTitle = Expect> + type verifyId = Expect> + return c.text(`${id} is ${res.title}`) + } + ) + const body = new FormData() + body.append('title', 'Hello') + const req = new Request('http://localhost/posts/123', { + method: 'POST', + body: body, + }) + const res = await app.request(req) + expect(res.status).toBe(200) + expect(await res.text()).toBe('123 is Hello') + }) + + it('Should return 200 response with correct types - validator and named parameter', async () => { + const app = new Hono() + + const vm = validator((v) => ({ + title: v.body('title').isRequired(), + })) + + app.post('/posts', vm, (c) => { + const { title } = c.req.valid() + type verify = Expect> + return c.text(title) + }) + + app.post('/posts/:id', vm, (c) => { + const id = c.req.param('id') + const { title } = c.req.valid() + type verify = Expect> + return c.text(`${id} is ${title}`) + }) + + const body = new FormData() + body.append('title', 'Hello') + + let res = await app.request('http://localhost/posts', { + method: 'POST', + body: body, + }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('Hello') + res = await app.request('http://localhost/posts/123?title=foo', { + method: 'POST', + body: body, + }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('123 is Hello') + }) + + it('Should return 200 response with correct types - Context in validator function', async () => { + type Env = { Bindings: { FOO: 'abc' } } + + const app = new Hono() + + app.get( + '/search', + validator( + (v) => ({ + foo: v.query('foo'), + }), + { + done: (_, c) => { + type verifyBindings = Expect> + }, + } + ), + (c) => { + const { foo } = c.req.valid() + type verifyBindings = Expect> + type verify = Expect> + return c.text(foo) + } + ) + + const res = await app.request('http://localhost/search?foo=bar') + expect(res.status).toBe(200) + expect(await res.text()).toBe('bar') + }) +}) diff --git a/src/middleware/validator/middleware.ts b/src/middleware/validator/middleware.ts index 621b2682..3c53044d 100644 --- a/src/middleware/validator/middleware.ts +++ b/src/middleware/validator/middleware.ts @@ -1,62 +1,10 @@ import type { Context } from '../../context' -import type { Environment, Next, ValidatedData } from '../../hono' +import type { Environment, MiddlewareHandler } from '../../hono' import { getStatusText } from '../../utils/http-status' import { mergeObjects } from '../../utils/object' -import { VBase, Validator, VObjectBase } from './validator' -import type { - VString, - VNumber, - VBoolean, - VObject, - VNumberArray, - VStringArray, - VBooleanArray, - ValidateResult, - VArray, -} from './validator' - -export type Schema = { - [key: string]: - | VString - | VNumber - | VBoolean - | VStringArray - | VNumberArray - | VBooleanArray - | Schema - | VObject - | VArray -} - -type SchemaToProp = { - [K in keyof T]: T[K] extends VNumberArray - ? number[] - : T[K] extends VBooleanArray - ? boolean[] - : T[K] extends VStringArray - ? string[] - : T[K] extends VNumber - ? number - : T[K] extends VBoolean - ? boolean - : T[K] extends VString - ? string - : T[K] extends VObjectBase - ? T[K]['container'] extends VNumber - ? number - : T[K]['container'] extends VString - ? string - : T[K]['container'] extends VBoolean - ? boolean - : T[K] extends VArray - ? SchemaToProp> - : T[K] extends VObject - ? SchemaToProp - : T[K] extends Schema - ? SchemaToProp - : never - : SchemaToProp -} +import type { Schema } from '../../validator/schema' +import type { ValidateResult } from '../../validator/validator' +import { Validator, VBase, VObjectBase } from '../../validator/validator' type ResultSet = { hasError: boolean @@ -64,27 +12,27 @@ type ResultSet = { results: ValidateResult[] } -type Done> = ( +type Done

= Environment> = ( resultSet: ResultSet, - context: Context + c: Context ) => Response | void -type ValidationFunction> = ( - v: Validator, - c: Context -) => T +type ValidationFunction< + P extends string, + E extends Partial = Environment, + S extends Schema = Schema +> = (v: Validator, c: Context) => S -type MiddlewareHandler< - Data extends ValidatedData = ValidatedData, - Env extends Partial = Environment -> = (c: Context, next: Next) => Promise | Promise - -export const validatorMiddleware = >( - validationFunction: ValidationFunction, - options?: { done?: Done } +export const validatorMiddleware = < + P extends string, + E extends Partial = Environment, + S extends Schema = Schema +>( + validationFunction: ValidationFunction, + options?: { done?: Done } ) => { const v = new Validator() - const handler: MiddlewareHandler, Env> = async (c, next) => { + const handler: MiddlewareHandler = async (c, next) => { const resultSet: ResultSet = { hasError: false, messages: [], @@ -98,7 +46,7 @@ export const validatorMiddleware = (): Promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any jsonData?: any - json(): Promise + json(): Promise> data: Data valid: { - (key: string | string[], value: any): Data + (key: string | string[], value: unknown): Data (): Data } } @@ -132,9 +133,9 @@ export function extendRequestPrototype() { return body } as InstanceType['parseBody'] - Request.prototype.json = async function (this: Request): Promise { + Request.prototype.json = async function (this: Request) { // Cache the JSON body - let jsonData: JSONData + let jsonData: Partial if (!this.jsonData) { jsonData = JSON.parse(await this.text()) this.jsonData = jsonData @@ -144,7 +145,7 @@ export function extendRequestPrototype() { return jsonData } as InstanceType['jsonData'] - Request.prototype.valid = function (this: Request, keys?: string | string[], value?: any) { + Request.prototype.valid = function (this: Request, keys?: string | string[], value?: unknown) { if (!this.data) { this.data = {} } diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 00000000..a61550ba --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,5 @@ +export type Expect = T +export type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false +export type NotEqual = true extends Equal ? false : true diff --git a/src/middleware/validator/rule.test.ts b/src/validator/rule.test.ts similarity index 100% rename from src/middleware/validator/rule.test.ts rename to src/validator/rule.test.ts diff --git a/src/middleware/validator/rule.ts b/src/validator/rule.ts similarity index 100% rename from src/middleware/validator/rule.ts rename to src/validator/rule.ts diff --git a/src/middleware/validator/sanitizer.test.ts b/src/validator/sanitizer.test.ts similarity index 100% rename from src/middleware/validator/sanitizer.test.ts rename to src/validator/sanitizer.test.ts diff --git a/src/middleware/validator/sanitizer.ts b/src/validator/sanitizer.ts similarity index 100% rename from src/middleware/validator/sanitizer.ts rename to src/validator/sanitizer.ts diff --git a/src/validator/schema.ts b/src/validator/schema.ts new file mode 100644 index 00000000..dbf66f05 --- /dev/null +++ b/src/validator/schema.ts @@ -0,0 +1,54 @@ +import type { + VString, + VNumber, + VBoolean, + VObject, + VNumberArray, + VStringArray, + VBooleanArray, + VArray, + VObjectBase, +} from './validator' + +export type Schema = { + [key: string]: + | VString + | VNumber + | VBoolean + | VStringArray + | VNumberArray + | VBooleanArray + | Schema + | VObject + | VArray +} + +export type SchemaToProp = { + [K in keyof T]: T[K] extends VNumberArray + ? number[] + : T[K] extends VBooleanArray + ? boolean[] + : T[K] extends VStringArray + ? string[] + : T[K] extends VNumber + ? number + : T[K] extends VBoolean + ? boolean + : T[K] extends VString + ? string + : T[K] extends VObjectBase + ? T[K]['container'] extends VNumber + ? number + : T[K]['container'] extends VString + ? string + : T[K]['container'] extends VBoolean + ? boolean + : T[K] extends VArray + ? SchemaToProp> + : T[K] extends VObject + ? SchemaToProp + : T[K] extends Schema + ? SchemaToProp + : never + : SchemaToProp +} diff --git a/src/middleware/validator/validator.test.ts b/src/validator/validator.test.ts similarity index 99% rename from src/middleware/validator/validator.test.ts rename to src/validator/validator.test.ts index 273e2c5f..552f0ace 100644 --- a/src/middleware/validator/validator.test.ts +++ b/src/validator/validator.test.ts @@ -1,4 +1,4 @@ -import { extendRequestPrototype } from '../../request' +import { extendRequestPrototype } from '../request' import { Validator } from './validator' extendRequestPrototype() diff --git a/src/middleware/validator/validator.ts b/src/validator/validator.ts similarity index 98% rename from src/middleware/validator/validator.ts rename to src/validator/validator.ts index 22d26965..3ab31fc1 100644 --- a/src/middleware/validator/validator.ts +++ b/src/validator/validator.ts @@ -1,8 +1,8 @@ -import { JSONPathCopy } from '../../utils/json' -import type { JSONObject, JSONPrimitive, JSONArray } from '../../utils/json' -import type { Schema } from './middleware' +import { JSONPathCopy } from './../utils/json' +import type { JSONObject, JSONPrimitive, JSONArray } from './../utils/json' import { rule } from './rule' import { sanitizer } from './sanitizer' +import type { Schema } from './schema' type Target = 'query' | 'header' | 'body' | 'json' type Type = JSONPrimitive | JSONObject | JSONArray | File @@ -209,7 +209,7 @@ export abstract class VBase { return this } - validate = async (req: Request): Promise => { + validate = async (req: R): Promise => { let value: Type = undefined let jsonData: JSONObject | undefined = undefined diff --git a/tsconfig.build.cjs.json b/tsconfig.build.cjs.json index 0596a506..12bcc9f2 100644 --- a/tsconfig.build.cjs.json +++ b/tsconfig.build.cjs.json @@ -5,6 +5,8 @@ "declaration": false, "rootDir": "./src/", "outDir": "./dist/cjs/", + "noUnusedLocals": true, + "noUnusedParameters": true, }, "include": [ "src/**/*.ts" diff --git a/tsconfig.build.json b/tsconfig.build.json index 40ac3f4c..42eeebfa 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -4,6 +4,8 @@ "module": "ES2020", "rootDir": "./src/", "outDir": "./dist/", + "noUnusedLocals": true, + "noUnusedParameters": true, }, "include": [ "src/**/*.ts", diff --git a/tsconfig.json b/tsconfig.json index 9be69405..6940f1b9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,8 +10,8 @@ "skipLibCheck": true, "strictPropertyInitialization": true, "strictNullChecks": true, - "noUnusedLocals": true, - "noUnusedParameters": true, + "noUnusedLocals": false, + "noUnusedParameters": false, "types": [ "jest", "node",