From b7410b44dd8720e46ee2871aa1727ce5039ebad4 Mon Sep 17 00:00:00 2001 From: "Max P." Date: Wed, 7 May 2025 16:38:53 +0200 Subject: [PATCH] refactor(core): enhance HttpKernel pipeline and matcher system with full context and error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: `parseQuery` utility removed; `IRouteMatcher` now includes query parsing; `RouteBuilder.middleware` and `handle` are now strictly typed per builder instance. - Add `isHandler` and `isMiddleware` runtime type guards for validation in `HttpKernel`. - Introduce `createEmptyContext` for constructing default context objects. - Support custom HTTP error handlers (`404`, `500`) via `IHttpKernelConfig.httpErrorHandlers`. - Default error handlers return meaningful HTTP status text (e.g., "Not Found"). - Replace legacy `parseQuery` logic with integrated query extraction via `createRouteMatcher`. - Strongly type `RouteBuilder.middleware()` and `.handle()` methods without generic overrides. - Simplify `HttpKernel.handle()` and `executePipeline()` through precise control flow and validation. - Remove deprecated `registerRoute.ts` and `HttpKernelConfig.ts` in favor of colocated type exports. - Add tests for integrated query parsing in `createRouteMatcher`. - Improve error handling tests: middleware/handler validation, double `next()` call, thrown exceptions. - Replace `assertRejects` with plain response code checks (via updated error handling). - Removed `parseQuery.ts` and all related tests — query parsing is now built into route matching. - `IRouteMatcher` signature changed to return `{ params, query }` instead of only `params`. - `HttpKernelConfig` now uses `DeepPartial` and includes `httpErrorHandlers`. - `RouteBuilder`'s generics are simplified for better DX and improved type safety. This refactor improves clarity, test coverage, and runtime safety of the request lifecycle while reducing boilerplate and eliminating duplicated query handling logic. Signed-off-by: Max P. --- src/Errors/mod.ts | 2 + src/HttpKernel.ts | 135 +++++++++---- src/Interfaces/IHandler.ts | 27 +++ src/Interfaces/IHttpErrorHandlers.ts | 40 ++++ src/Interfaces/IHttpKernel.ts | 2 +- ...tpKernelConfig.ts => IHttpKernelConfig.ts} | 2 + src/Interfaces/IInternalRoute.ts | 7 +- src/Interfaces/IMiddleware.ts | 18 ++ src/Interfaces/IRouteBuilder.ts | 10 +- src/Interfaces/IRouteDefinition.ts | 46 ++++- src/Interfaces/IRouteMatch.ts | 6 + src/Interfaces/IRouteMatcher.ts | 4 +- .../__tests__/routeDefinitionGuards.test.ts | 43 ++++ src/Interfaces/mod.ts | 10 +- src/RouteBuilder.ts | 18 +- src/Types/DeepPartial.ts | 4 + src/Types/HttpErrorHandler.ts | 28 +++ src/Types/HttpStatusCode.ts | 189 ++++++++++++++++++ src/Types/Params.ts | 2 +- .../{registerRoute.ts => RegisterRoute.ts} | 0 src/Types/__tests__/HttpMethod.test.ts | 40 ++++ src/Types/__tests__/HttpStatusCode.test.ts | 35 ++++ src/Types/mod.ts | 34 +++- .../__tests__/createEmptyContext.test.ts | 28 +++ .../__tests__/createRouteMatcher.test.ts | 64 ++++++ src/Utils/__tests__/normalizeError.test.ts | 35 ++++ src/Utils/__tests__/parseQuery.test.ts | 49 ----- src/Utils/createEmptyContext.ts | 30 +++ src/Utils/createRouteMatcher.ts | 93 ++++----- src/Utils/mod.ts | 3 +- src/Utils/normalizeError.ts | 32 +++ src/Utils/parseQuery.ts | 30 --- src/__tests__/HttpKernel.test.ts | 69 ++++--- 33 files changed, 915 insertions(+), 220 deletions(-) create mode 100644 src/Interfaces/IHttpErrorHandlers.ts rename src/Interfaces/{HttpKernelConfig.ts => IHttpKernelConfig.ts} (74%) create mode 100644 src/Interfaces/IRouteMatch.ts create mode 100644 src/Interfaces/__tests__/routeDefinitionGuards.test.ts create mode 100644 src/Types/DeepPartial.ts create mode 100644 src/Types/HttpErrorHandler.ts create mode 100644 src/Types/HttpStatusCode.ts rename src/Types/{registerRoute.ts => RegisterRoute.ts} (100%) create mode 100644 src/Types/__tests__/HttpMethod.test.ts create mode 100644 src/Types/__tests__/HttpStatusCode.test.ts create mode 100644 src/Utils/__tests__/createEmptyContext.test.ts create mode 100644 src/Utils/__tests__/normalizeError.test.ts delete mode 100644 src/Utils/__tests__/parseQuery.test.ts create mode 100644 src/Utils/createEmptyContext.ts create mode 100644 src/Utils/normalizeError.ts delete mode 100644 src/Utils/parseQuery.ts diff --git a/src/Errors/mod.ts b/src/Errors/mod.ts index 8fac76f..50e5ba8 100644 --- a/src/Errors/mod.ts +++ b/src/Errors/mod.ts @@ -1 +1,3 @@ +// deno-coverage-ignore-file + export { InvalidHttpMethodError } from './InvalidHttpMethodError.ts'; diff --git a/src/HttpKernel.ts b/src/HttpKernel.ts index 8925aff..20eb5e2 100644 --- a/src/HttpKernel.ts +++ b/src/HttpKernel.ts @@ -7,9 +7,17 @@ import { IMiddleware, IRouteBuilder, IRouteDefinition, + isHandler, + isMiddleware, } from './Interfaces/mod.ts'; +import { + DeepPartial, + HTTP_404_NOT_FOUND, + HTTP_500_INTERNAL_SERVER_ERROR, + HttpStatusTextMap, +} from './Types/mod.ts'; import { RouteBuilder } from './RouteBuilder.ts'; -import { parseQuery } from './Utils/mod.ts'; +import { createEmptyContext, normalizeError } from './Utils/mod.ts'; /** * The central HTTP kernel responsible for managing route definitions, @@ -34,11 +42,25 @@ export class HttpKernel * @param routeBuilderFactory - Optional factory for creating route builders. Defaults to using `RouteBuilder`. */ public constructor( - config?: Partial>, + config?: DeepPartial>, ) { this.cfg = { decorateResponse: (res) => res, routeBuilderFactory: RouteBuilder, + httpErrorHandlers: { + [HTTP_404_NOT_FOUND]: () => + new Response(HttpStatusTextMap[HTTP_404_NOT_FOUND], { + status: HTTP_404_NOT_FOUND, + }), + [HTTP_500_INTERNAL_SERVER_ERROR]: () => + new Response( + HttpStatusTextMap[HTTP_500_INTERNAL_SERVER_ERROR], + { + status: HTTP_500_INTERNAL_SERVER_ERROR, + }, + ), + ...(config?.httpErrorHandlers ?? {}), + }, ...config, } as IHttpKernelConfig; @@ -51,16 +73,16 @@ export class HttpKernel */ public route<_TContext extends IContext = TContext>( definition: IRouteDefinition, - ): IRouteBuilder { + ): IRouteBuilder<_TContext> { return new this.cfg.routeBuilderFactory( this.registerRoute, definition, - ); + ) as IRouteBuilder<_TContext>; } /** * @inheritdoc - */ public async handle<_TContext extends IContext = TContext>( + */ public async handle( request: Request, ): Promise { const url = new URL(request.url); @@ -70,21 +92,23 @@ export class HttpKernel if (route.method !== method) continue; const match = route.matcher(url, request); if (match) { - const ctx: _TContext = { + const ctx: TContext = { req: request, params: match.params, - query: parseQuery(url.searchParams), + query: match.query, state: {}, - } as _TContext; - return await this.executePipeline<_TContext>( + } as TContext; + return await this.executePipeline( ctx, - route.middlewares as unknown as IMiddleware<_TContext>[], - route.handler as unknown as IHandler<_TContext>, + route.middlewares, + route.handler, ); } } - return new Response('Not Found', { status: 404 }); + return this.cfg.httpErrorHandlers[HTTP_404_NOT_FOUND]( + createEmptyContext(request), + ); } /** @@ -101,38 +125,71 @@ export class HttpKernel } /** - * Executes the middleware pipeline and final handler for a given request context. + * Executes the complete request pipeline: middleware chain, final handler, and optional response decoration. * - * This function recursively invokes middleware in the order they were registered, - * ending with the route's final handler. If a middleware returns a response directly - * without calling `next()`, the pipeline is short-circuited. + * Middleware functions are invoked sequentially in the order of registration. Each middleware + * receives a `next()` callback to advance to the next stage. If a middleware returns a `Response` + * directly, the pipeline short-circuits. * - * The final response is passed through the `decorateResponse` function before being returned. + * After the final handler produces a response, it is passed through the configured response decorator, + * which may modify it (e.g., adding headers or logging metadata). * - * @param ctx - The request context containing the request, parameters, and shared state. - * @param middleware - The ordered list of middleware to apply before the handler. - * @param handler - The final request handler to invoke at the end of the pipeline. - * @returns The final HTTP response after middleware and decoration. + * Internal error handling ensures: + * - That `next()` is not called multiple times. + * - That all middleware and handlers are properly typed. + * - That thrown exceptions are routed to the 500-error handler. + * + * @param ctx - The current request context, including request data and shared state. + * @param middleware - An ordered list of middleware functions to invoke. + * @param handler - The terminal request handler to produce the response. + * @returns The final decorated `Response` object. */ - private async executePipeline<_TContext extends IContext = TContext>( - ctx: _TContext, - middleware: IMiddleware<_TContext>[], - handler: IHandler<_TContext>, + private async executePipeline( + ctx: TContext, + middleware: IMiddleware[], + handler: IHandler, ): Promise { - let i = -1; - const dispatch = async (index: number): Promise => { - if (index <= i) throw new Error('next() called multiple times'); - i = index; - const fn: IMiddleware<_TContext> | IHandler<_TContext> = - index < middleware.length ? middleware[index] : handler; - if (!fn) return new Response('Internal error', { status: 500 }); - return index < middleware.length - ? await fn(ctx, () => dispatch(index + 1)) - : await (fn as IHandler<_TContext>)(ctx); + const handleInternalError = (ctx: TContext, err?: unknown) => + this.cfg.httpErrorHandlers[HTTP_500_INTERNAL_SERVER_ERROR]( + ctx, + normalizeError(err), + ); + + let lastIndex = -1; + + const dispatch = async (currentIndex: number): Promise => { + // Prevent middleware from invoking next() multiple times + if (currentIndex <= lastIndex) { + throw new Error('Middleware called `next()` multiple times'); + } + lastIndex = currentIndex; + + const isWithinMiddleware = currentIndex < middleware.length; + const fn = isWithinMiddleware ? middleware[currentIndex] : handler; + + if (isWithinMiddleware) { + if (!isMiddleware(fn)) { + throw new Error( + 'Expected middleware function, but received invalid value', + ); + } + return await fn(ctx, () => dispatch(currentIndex + 1)); + } + + if (!isHandler(fn)) { + throw new Error( + 'Expected request handler, but received invalid value', + ); + } + + return await fn(ctx); }; - return this.cfg.decorateResponse( - await dispatch(0), - ctx as unknown as TContext, - ); + + try { + const response = await dispatch(0); + return this.cfg.decorateResponse(response, ctx); + } catch (e) { + return handleInternalError(ctx, e); + } } } diff --git a/src/Interfaces/IHandler.ts b/src/Interfaces/IHandler.ts index d9e255d..80b9a53 100644 --- a/src/Interfaces/IHandler.ts +++ b/src/Interfaces/IHandler.ts @@ -21,3 +21,30 @@ export interface IHandler { */ (ctx: TContext): Promise; } + +/** + * Type guard to determine whether a given value is a valid `IHandler` function. + * + * This function checks whether the input is a function and whether it returns + * a `Promise` when called. Due to TypeScript's structural typing and + * the lack of runtime type information, only minimal runtime validation is possible. + * + * @param value - The value to test. + * @returns `true` if the value is a function that appears to conform to `IHandler`. + * + * @example + * ```ts + * const candidate = async (ctx: IContext) => new Response("ok"); + * if (isHandler(candidate)) { + * // candidate is now typed as IHandler + * } + * ``` + */ +export function isHandler( + value: unknown, +): value is IHandler { + return ( + typeof value === 'function' && + value.length === 1 // ctx + ); +} diff --git a/src/Interfaces/IHttpErrorHandlers.ts b/src/Interfaces/IHttpErrorHandlers.ts new file mode 100644 index 0000000..fb1a9a4 --- /dev/null +++ b/src/Interfaces/IHttpErrorHandlers.ts @@ -0,0 +1,40 @@ +import { IContext } from '../Interfaces/mod.ts'; +import { HttpErrorHandler, validHttpErrorCodes } from '../Types/mod.ts'; + +/** + * A mapping of HTTP status codes to their corresponding error handlers. + * + * This interface defines required handlers for common critical status codes (404 and 500) + * and allows optional handlers for all other known error codes defined in `validHttpErrorCodes`. + * + * This hybrid approach ensures predictable handling for key failure cases, + * while remaining flexible for less common codes. + * + * @template TContext - The context type used in all error handlers. + * + * @example + * ```ts + * const errorHandlers: IHttpErrorHandlers = { + * 404: (ctx) => new Response("Not Found", { status: 404 }), + * 500: (ctx, err) => { + * console.error(err); + * return new Response("Internal Server Error", { status: 500 }); + * }, + * 429: (ctx) => new Response("Too Many Requests", { status: 429 }), + * }; + * ``` + */ +export interface IHttpErrorHandlers + extends + Partial< + Record< + Exclude, + HttpErrorHandler + > + > { + /** Required error handler for HTTP 404 (Not Found). */ + 404: HttpErrorHandler; + + /** Required error handler for HTTP 500 (Internal Server Error). */ + 500: HttpErrorHandler; +} diff --git a/src/Interfaces/IHttpKernel.ts b/src/Interfaces/IHttpKernel.ts index fb26fdd..2a6e09b 100644 --- a/src/Interfaces/IHttpKernel.ts +++ b/src/Interfaces/IHttpKernel.ts @@ -44,7 +44,7 @@ export interface IHttpKernel { * @param request - The incoming HTTP request to dispatch. * @returns A promise resolving to the final HTTP response. */ - handle<_TContext extends IContext = TContext>( + handle( request: Request, ): Promise; } diff --git a/src/Interfaces/HttpKernelConfig.ts b/src/Interfaces/IHttpKernelConfig.ts similarity index 74% rename from src/Interfaces/HttpKernelConfig.ts rename to src/Interfaces/IHttpKernelConfig.ts index 6f9fe19..cd2cdc3 100644 --- a/src/Interfaces/HttpKernelConfig.ts +++ b/src/Interfaces/IHttpKernelConfig.ts @@ -1,8 +1,10 @@ import { ResponseDecorator } from '../Types/mod.ts'; import { IContext } from './IContext.ts'; +import { IHttpErrorHandlers } from './IHttpErrorHandlers.ts'; import { IRouteBuilderFactory } from './IRouteBuilder.ts'; export interface IHttpKernelConfig { decorateResponse: ResponseDecorator; routeBuilderFactory: IRouteBuilderFactory; + httpErrorHandlers: IHttpErrorHandlers; } diff --git a/src/Interfaces/IInternalRoute.ts b/src/Interfaces/IInternalRoute.ts index 1c91acf..c2a8804 100644 --- a/src/Interfaces/IInternalRoute.ts +++ b/src/Interfaces/IInternalRoute.ts @@ -1,7 +1,7 @@ import { HttpMethod } from '../Types/mod.ts'; import { IHandler } from './IHandler.ts'; import { IMiddleware } from './IMiddleware.ts'; -import { IContext } from './mod.ts'; +import { IContext, IRouteMatcher } from './mod.ts'; /** * Represents an internally registered route within the HttpKernel. @@ -27,10 +27,7 @@ export interface IInternalRoute { * @param req - The original Request object. * @returns An object with extracted path parameters, or `null` if not matched. */ - matcher: ( - url: URL, - req: Request, - ) => null | { params: Record }; + matcher: IRouteMatcher; /** * An ordered list of middleware functions to be executed before the handler. diff --git a/src/Interfaces/IMiddleware.ts b/src/Interfaces/IMiddleware.ts index c91bbbe..b7b38a4 100644 --- a/src/Interfaces/IMiddleware.ts +++ b/src/Interfaces/IMiddleware.ts @@ -23,3 +23,21 @@ export interface IMiddleware { */ (ctx: TContext, next: () => Promise): Promise; } + +/** + * Type guard to verify whether a given value is a valid `IMiddleware` function. + * + * This guard checks whether the input is a function that accepts exactly two arguments. + * Note: This is a structural check and cannot fully guarantee the semantics of a middleware. + * + * @param value - The value to test. + * @returns `true` if the value is structurally a valid middleware function. + */ +export function isMiddleware( + value: unknown, +): value is IMiddleware { + return ( + typeof value === 'function' && + value.length === 2 // ctx, next + ); +} diff --git a/src/Interfaces/IRouteBuilder.ts b/src/Interfaces/IRouteBuilder.ts index 874dd41..9d2e0a4 100644 --- a/src/Interfaces/IRouteBuilder.ts +++ b/src/Interfaces/IRouteBuilder.ts @@ -24,9 +24,9 @@ export interface IRouteBuilder { * @param mw - A middleware function. * @returns The route builder for further chaining. */ - middleware<_TContext extends IContext = TContext>( - mw: IMiddleware<_TContext>, - ): IRouteBuilder<_TContext>; + middleware( + mw: IMiddleware, + ): IRouteBuilder; /** * Sets the final request handler for the route. @@ -34,7 +34,7 @@ export interface IRouteBuilder { * * @param handler - The function to execute when this route is matched. */ - handle<_TContext extends IContext = TContext>( - handler: IHandler<_TContext>, + handle( + handler: IHandler, ): void; } diff --git a/src/Interfaces/IRouteDefinition.ts b/src/Interfaces/IRouteDefinition.ts index 955d204..43146cc 100644 --- a/src/Interfaces/IRouteDefinition.ts +++ b/src/Interfaces/IRouteDefinition.ts @@ -1,4 +1,4 @@ -import { HttpMethod } from '../Types/mod.ts'; +import { HttpMethod, isHttpMethod } from '../Types/mod.ts'; import { IRouteMatcher } from './IRouteMatcher.ts'; /** @@ -45,3 +45,47 @@ export interface IDynamicRouteDefinition { * or a dynamic route with a custom matcher function for advanced matching logic. */ export type IRouteDefinition = IStaticRouteDefinition | IDynamicRouteDefinition; + +/** + * Type guard to check whether a route definition is a valid static route definition. + * + * Ensures that the object: + * - has a `method` property of type `HttpMethod` + * - has a `path` property of type `string` + * - does NOT have a `matcher` function (to avoid ambiguous mixed types) + */ +export function isStaticRouteDefinition( + def: IRouteDefinition, +): def is IStaticRouteDefinition { + return ( + def && + typeof def === 'object' && + 'method' in def && + isHttpMethod(def.method) && + 'path' in def && + typeof (def as { path?: unknown }).path === 'string' && + !('matcher' in def) + ); +} + +/** + * Type guard to check whether a route definition is a valid dynamic route definition. + * + * Ensures that the object: + * - has a `method` property of type `HttpMethod` + * - has a `matcher` property of type `function` + * - does NOT have a `path` property (to avoid ambiguous mixed types) + */ +export function isDynamicRouteDefinition( + def: IRouteDefinition, +): def is IDynamicRouteDefinition { + return ( + def && + typeof def === 'object' && + 'method' in def && + isHttpMethod(def.method) && + 'matcher' in def && + typeof (def as { matcher?: unknown }).matcher === 'function' && + !('path' in def) + ); +} diff --git a/src/Interfaces/IRouteMatch.ts b/src/Interfaces/IRouteMatch.ts new file mode 100644 index 0000000..06e1d7c --- /dev/null +++ b/src/Interfaces/IRouteMatch.ts @@ -0,0 +1,6 @@ +import { Params, Query } from '../Types/mod.ts'; + +export interface IRouteMatch { + params?: Params; + query?: Query; +} diff --git a/src/Interfaces/IRouteMatcher.ts b/src/Interfaces/IRouteMatcher.ts index 88d8ec2..1cbd3a3 100644 --- a/src/Interfaces/IRouteMatcher.ts +++ b/src/Interfaces/IRouteMatcher.ts @@ -1,4 +1,6 @@ +import { Params } from '../Types/mod.ts'; import { IRouteDefinition } from './IRouteDefinition.ts'; +import { IRouteMatch } from './IRouteMatch.ts'; /** * Defines a route matcher function that evaluates whether a route applies to a given request. @@ -14,7 +16,7 @@ export interface IRouteMatcher { * @param req - The raw Request object (may be used for context or headers). * @returns An object containing path parameters if matched, or `null` if not matched. */ - (url: URL, req: Request): null | { params: Record }; + (url: URL, req: Request): null | IRouteMatch; } /** diff --git a/src/Interfaces/__tests__/routeDefinitionGuards.test.ts b/src/Interfaces/__tests__/routeDefinitionGuards.test.ts new file mode 100644 index 0000000..c00b7c6 --- /dev/null +++ b/src/Interfaces/__tests__/routeDefinitionGuards.test.ts @@ -0,0 +1,43 @@ +import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts'; +import { + IRouteDefinition, + isDynamicRouteDefinition, + isStaticRouteDefinition, +} from '../IRouteDefinition.ts'; + +Deno.test('isStaticRouteDefinition returns true for static route', () => { + const staticDef: IRouteDefinition = { + method: 'GET', + path: '/users/:id', + }; + + assertEquals(isStaticRouteDefinition(staticDef), true); + assertEquals(isDynamicRouteDefinition(staticDef), false); +}); + +Deno.test('isDynamicRouteDefinition returns true for dynamic route', () => { + const dynamicDef: IRouteDefinition = { + method: 'POST', + matcher: (_url, _req) => ({ params: {} }), + }; + + assertEquals(isDynamicRouteDefinition(dynamicDef), true); + assertEquals(isStaticRouteDefinition(dynamicDef), false); +}); + +Deno.test('isStaticRouteDefinition returns false for invalid object', () => { + const invalidDef = { + method: 'GET', + } as unknown as IRouteDefinition; + + assertEquals(isStaticRouteDefinition(invalidDef), false); +}); + +Deno.test('isDynamicRouteDefinition returns false for object with no matcher', () => { + const def = { + method: 'DELETE', + path: '/something', + }; + + assertEquals(isDynamicRouteDefinition(def as IRouteDefinition), false); +}); diff --git a/src/Interfaces/mod.ts b/src/Interfaces/mod.ts index b0083f1..c1d0e58 100644 --- a/src/Interfaces/mod.ts +++ b/src/Interfaces/mod.ts @@ -1,15 +1,23 @@ // deno-coverage-ignore-file -export type { IHttpKernelConfig } from './HttpKernelConfig.ts'; export type { IContext } from './IContext.ts'; +export { isHandler } from './IHandler.ts'; export type { IHandler } from './IHandler.ts'; +export type { IHttpErrorHandlers } from './IHttpErrorHandlers.ts'; export type { IHttpKernel } from './IHttpKernel.ts'; +export type { IHttpKernelConfig } from './IHttpKernelConfig.ts'; export type { IInternalRoute } from './IInternalRoute.ts'; +export { isMiddleware } from './IMiddleware.ts'; export type { IMiddleware } from './IMiddleware.ts'; export type { IRouteBuilder, IRouteBuilderFactory } from './IRouteBuilder.ts'; +export { + isDynamicRouteDefinition, + isStaticRouteDefinition, +} from './IRouteDefinition.ts'; export type { IDynamicRouteDefinition, IRouteDefinition, IStaticRouteDefinition, } from './IRouteDefinition.ts'; +export type { IRouteMatch } from './IRouteMatch.ts'; export type { IRouteMatcher, IRouteMatcherFactory } from './IRouteMatcher.ts'; diff --git a/src/RouteBuilder.ts b/src/RouteBuilder.ts index 7764c85..872e488 100644 --- a/src/RouteBuilder.ts +++ b/src/RouteBuilder.ts @@ -41,13 +41,13 @@ export class RouteBuilder * @param mw - A middleware function to be executed before the handler. * @returns A new `RouteBuilder` instance for continued chaining. */ - middleware<_TContext extends IContext = TContext>( - mw: IMiddleware<_TContext>, - ): IRouteBuilder<_TContext> { - return new RouteBuilder<_TContext>( - this.registerRoute as unknown as RegisterRoute<_TContext>, + middleware( + mw: IMiddleware, + ): IRouteBuilder { + return new RouteBuilder( + this.registerRoute, this.def, - [...this.mws as unknown as IMiddleware<_TContext>[], mw], + [...this.mws, mw], ); } @@ -59,15 +59,15 @@ export class RouteBuilder * * @param handler - The final request handler for this route. */ - handle<_TContext extends IContext = TContext>( - handler: IHandler<_TContext>, + handle( + handler: IHandler, ): void { const matcher = this.matcherFactory(this.def); this.registerRoute({ method: this.def.method, matcher, middlewares: this.mws, - handler: handler as unknown as IHandler, + handler: handler, }); } } diff --git a/src/Types/DeepPartial.ts b/src/Types/DeepPartial.ts new file mode 100644 index 0000000..07c5132 --- /dev/null +++ b/src/Types/DeepPartial.ts @@ -0,0 +1,4 @@ +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial + : T[P]; +}; diff --git a/src/Types/HttpErrorHandler.ts b/src/Types/HttpErrorHandler.ts new file mode 100644 index 0000000..5a7667f --- /dev/null +++ b/src/Types/HttpErrorHandler.ts @@ -0,0 +1,28 @@ +import { IContext } from '../Interfaces/mod.ts'; + +/** + * Defines a handler function for errors that occur during the execution + * of middleware or route handlers within the HTTP kernel. + * + * This function receives both the request context and the thrown error, + * and is responsible for producing an appropriate HTTP `Response`. + * + * Typical use cases include: + * - Mapping known error types to specific HTTP status codes. + * - Generating structured error responses (e.g. JSON error payloads). + * - Logging errors centrally with request metadata. + * + * The handler may return the response synchronously or asynchronously. + * + * @template TContext - The specific request context type, allowing typed access to route parameters, + * query parameters, and per-request state when formatting error responses. + * + * @param context - The active request context at the time the error occurred. + * @param error - The exception or error that was thrown during request processing. + * + * @returns A `Response` object or a `Promise` resolving to one, to be sent to the client. + */ +export type HttpErrorHandler = ( + context?: Partial, + error?: Error, +) => Promise | Response; diff --git a/src/Types/HttpStatusCode.ts b/src/Types/HttpStatusCode.ts new file mode 100644 index 0000000..2230d6f --- /dev/null +++ b/src/Types/HttpStatusCode.ts @@ -0,0 +1,189 @@ +// Informational responses +/** Indicates that the request was received and the client can continue. */ +export const HTTP_100_CONTINUE = 100; +/** The server is switching protocols as requested by the client. */ +export const HTTP_101_SWITCHING_PROTOCOLS = 101; +/** The server has received and is processing the request, but no response is available yet. */ +export const HTTP_102_PROCESSING = 102; + +// Successful responses +/** The request has succeeded. */ +export const HTTP_200_OK = 200; +/** The request has succeeded and a new resource has been created as a result. */ +export const HTTP_201_CREATED = 201; +/** The request has been accepted for processing, but the processing is not complete. */ +export const HTTP_202_ACCEPTED = 202; +/** The server has successfully fulfilled the request and there is no content to send. */ +export const HTTP_204_NO_CONTENT = 204; + +// Redirection messages +/** The resource has been moved permanently to a new URI. */ +export const HTTP_301_MOVED_PERMANENTLY = 301; +/** The resource resides temporarily under a different URI. */ +export const HTTP_302_FOUND = 302; +/** Indicates that the resource has not been modified since the last request. */ +export const HTTP_304_NOT_MODIFIED = 304; + +// Client error responses +/** The server could not understand the request due to invalid syntax. */ +export const HTTP_400_BAD_REQUEST = 400; +/** The request requires user authentication. */ +export const HTTP_401_UNAUTHORIZED = 401; +/** The server understood the request but refuses to authorize it. */ +export const HTTP_403_FORBIDDEN = 403; +/** The server cannot find the requested resource. */ +export const HTTP_404_NOT_FOUND = 404; +/** The request method is known by the server but is not supported by the target resource. */ +export const HTTP_405_METHOD_NOT_ALLOWED = 405; +/** The request could not be completed due to a conflict with the current state of the resource. */ +export const HTTP_409_CONFLICT = 409; +/** The server understands the content type but was unable to process the contained instructions. */ +export const HTTP_422_UNPROCESSABLE_ENTITY = 422; +/** The user has sent too many requests in a given amount of time. */ +export const HTTP_429_TOO_MANY_REQUESTS = 429; + +// Server error responses +/** The server encountered an unexpected condition that prevented it from fulfilling the request. */ +export const HTTP_500_INTERNAL_SERVER_ERROR = 500; +/** The server does not support the functionality required to fulfill the request. */ +export const HTTP_501_NOT_IMPLEMENTED = 501; +/** The server, while acting as a gateway or proxy, received an invalid response from the upstream server. */ +export const HTTP_502_BAD_GATEWAY = 502; +/** The server is not ready to handle the request, often due to maintenance or overload. */ +export const HTTP_503_SERVICE_UNAVAILABLE = 503; +/** The server is acting as a gateway and cannot get a response in time. */ +export const HTTP_504_GATEWAY_TIMEOUT = 504; + +/** + * A constant list of supported HTTP status codes used by this application. + * + * These constants are grouped by category and used to construct the union type `HttpStatusCode`. + */ +export const validHttpStatusCodes = [ + // Informational + HTTP_100_CONTINUE, + HTTP_101_SWITCHING_PROTOCOLS, + HTTP_102_PROCESSING, + + // Successful + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_202_ACCEPTED, + HTTP_204_NO_CONTENT, + + // Redirection + HTTP_301_MOVED_PERMANENTLY, + HTTP_302_FOUND, + HTTP_304_NOT_MODIFIED, + + // Client Errors + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_405_METHOD_NOT_ALLOWED, + HTTP_409_CONFLICT, + HTTP_422_UNPROCESSABLE_ENTITY, + HTTP_429_TOO_MANY_REQUESTS, + + // Server Errors + HTTP_500_INTERNAL_SERVER_ERROR, + HTTP_501_NOT_IMPLEMENTED, + HTTP_502_BAD_GATEWAY, + HTTP_503_SERVICE_UNAVAILABLE, + HTTP_504_GATEWAY_TIMEOUT, +] as const; + +/** + * A constant list of HTTP error codes that are commonly used in the application. + */ +export const validHttpErrorCodes = [ + // Client Errors + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_405_METHOD_NOT_ALLOWED, + HTTP_409_CONFLICT, + HTTP_422_UNPROCESSABLE_ENTITY, + HTTP_429_TOO_MANY_REQUESTS, + + // Server Errors + HTTP_500_INTERNAL_SERVER_ERROR, + HTTP_501_NOT_IMPLEMENTED, + HTTP_502_BAD_GATEWAY, + HTTP_503_SERVICE_UNAVAILABLE, + HTTP_504_GATEWAY_TIMEOUT, +] as const; + +/** + * Maps each supported HTTP status code to its standard status message. + * + * Useful for logging, diagnostics, or building custom error responses. + */ +export const HttpStatusTextMap: Record< + typeof validHttpStatusCodes[number], + string +> = { + [HTTP_100_CONTINUE]: 'Continue', + [HTTP_101_SWITCHING_PROTOCOLS]: 'Switching Protocols', + [HTTP_102_PROCESSING]: 'Processing', + + [HTTP_200_OK]: 'OK', + [HTTP_201_CREATED]: 'Created', + [HTTP_202_ACCEPTED]: 'Accepted', + [HTTP_204_NO_CONTENT]: 'No Content', + + [HTTP_301_MOVED_PERMANENTLY]: 'Moved Permanently', + [HTTP_302_FOUND]: 'Found', + [HTTP_304_NOT_MODIFIED]: 'Not Modified', + + [HTTP_400_BAD_REQUEST]: 'Bad Request', + [HTTP_401_UNAUTHORIZED]: 'Unauthorized', + [HTTP_403_FORBIDDEN]: 'Forbidden', + [HTTP_404_NOT_FOUND]: 'Not Found', + [HTTP_405_METHOD_NOT_ALLOWED]: 'Method Not Allowed', + [HTTP_409_CONFLICT]: 'Conflict', + [HTTP_422_UNPROCESSABLE_ENTITY]: 'Unprocessable Entity', + [HTTP_429_TOO_MANY_REQUESTS]: 'Too Many Requests', + + [HTTP_500_INTERNAL_SERVER_ERROR]: 'Internal Server Error', + [HTTP_501_NOT_IMPLEMENTED]: 'Not Implemented', + [HTTP_502_BAD_GATEWAY]: 'Bad Gateway', + [HTTP_503_SERVICE_UNAVAILABLE]: 'Service Unavailable', + [HTTP_504_GATEWAY_TIMEOUT]: 'Gateway Timeout', +}; + +/** + * A union type representing commonly used HTTP status codes. + * + * This type ensures consistency between runtime and type-level status code handling. + * + * Example: + * ```ts + * const status: HttpStatusCode = 404; // ✅ valid + * const status: HttpStatusCode = 418; // ❌ Type error (unless added to list) + * ``` + */ +export type HttpStatusCode = typeof validHttpStatusCodes[number]; + +/** + * Type guard to check whether a given value is a valid HTTP status code. + * + * This is useful for validating numeric values received from external input, + * ensuring they conform to known HTTP semantics. + * + * Example: + * ```ts + * if (isHttpStatusCode(value)) { + * // value is now typed as HttpStatusCode + * } + * ``` + * + * @param value - The numeric value to check. + * @returns `true` if the value is a recognized HTTP status code, otherwise `false`. + */ +export function isHttpStatusCode(value: unknown): value is HttpStatusCode { + return typeof value === 'number' && + validHttpStatusCodes.includes(value as HttpStatusCode); +} diff --git a/src/Types/Params.ts b/src/Types/Params.ts index 045e056..300c211 100644 --- a/src/Types/Params.ts +++ b/src/Types/Params.ts @@ -7,4 +7,4 @@ * All values are strings and should be considered read-only, as they are * extracted by the router and should not be modified by application code. */ -export type Params = Record; +export type Params = Record; diff --git a/src/Types/registerRoute.ts b/src/Types/RegisterRoute.ts similarity index 100% rename from src/Types/registerRoute.ts rename to src/Types/RegisterRoute.ts diff --git a/src/Types/__tests__/HttpMethod.test.ts b/src/Types/__tests__/HttpMethod.test.ts new file mode 100644 index 0000000..9559d79 --- /dev/null +++ b/src/Types/__tests__/HttpMethod.test.ts @@ -0,0 +1,40 @@ +import { assertEquals } from 'https://deno.land/std/assert/mod.ts'; +import { isHttpMethod, validHttpMethods } from '../HttpMethod.ts'; + +Deno.test('isHttpMethod: returns true for all valid methods', () => { + for (const method of validHttpMethods) { + const result = isHttpMethod(method); + assertEquals(result, true, `Expected "${method}" to be valid`); + } +}); + +Deno.test('isHttpMethod: returns false for lowercase or unknown strings', () => { + const invalid = [ + 'get', + 'post', + 'FETCH', + 'TRACE', + 'CONNECT', + 'INVALID', + '', + ' ', + ]; + + for (const method of invalid) { + const result = isHttpMethod(method); + assertEquals(result, false, `Expected "${method}" to be invalid`); + } +}); + +Deno.test('isHttpMethod: returns false for non-string inputs', () => { + const invalidInputs = [null, undefined, 123, {}, [], true, Symbol('GET')]; + + for (const input of invalidInputs) { + const result = isHttpMethod(input); + assertEquals( + result, + false, + `Expected non-string input to be invalid: ${String(input)}`, + ); + } +}); diff --git a/src/Types/__tests__/HttpStatusCode.test.ts b/src/Types/__tests__/HttpStatusCode.test.ts new file mode 100644 index 0000000..3042ff1 --- /dev/null +++ b/src/Types/__tests__/HttpStatusCode.test.ts @@ -0,0 +1,35 @@ +// src/Types/__tests__/HttpStatusCode.test.ts +import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts'; +import { isHttpStatusCode, validHttpStatusCodes } from '../HttpStatusCode.ts'; + +Deno.test('isHttpStatusCode: returns true for all valid status codes', () => { + for (const code of validHttpStatusCodes) { + assertEquals( + isHttpStatusCode(code), + true, + `Expected ${code} to be valid`, + ); + } +}); + +Deno.test('isHttpStatusCode: returns false for invalid status codes', () => { + const invalidInputs = [99, 600, 1234, -1, 0, 999]; + for (const val of invalidInputs) { + assertEquals( + isHttpStatusCode(val), + false, + `Expected ${val} to be invalid`, + ); + } +}); + +Deno.test('isHttpStatusCode: returns false for non-numeric values', () => { + const invalid = ['200', null, undefined, {}, [], true]; + for (const val of invalid) { + assertEquals( + isHttpStatusCode(val), + false, + `Expected ${val} to be invalid`, + ); + } +}); diff --git a/src/Types/mod.ts b/src/Types/mod.ts index 2a05cc6..b86f3e9 100644 --- a/src/Types/mod.ts +++ b/src/Types/mod.ts @@ -1,9 +1,41 @@ // deno-coverage-ignore-file +export type { DeepPartial } from './DeepPartial.ts'; +export type { HttpErrorHandler } from './HttpErrorHandler.ts'; export { isHttpMethod, validHttpMethods } from './HttpMethod.ts'; export type { HttpMethod } from './HttpMethod.ts'; +export { + HTTP_100_CONTINUE, + HTTP_101_SWITCHING_PROTOCOLS, + HTTP_102_PROCESSING, + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_202_ACCEPTED, + HTTP_204_NO_CONTENT, + HTTP_301_MOVED_PERMANENTLY, + HTTP_302_FOUND, + HTTP_304_NOT_MODIFIED, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_405_METHOD_NOT_ALLOWED, + HTTP_409_CONFLICT, + HTTP_422_UNPROCESSABLE_ENTITY, + HTTP_429_TOO_MANY_REQUESTS, + HTTP_500_INTERNAL_SERVER_ERROR, + HTTP_501_NOT_IMPLEMENTED, + HTTP_502_BAD_GATEWAY, + HTTP_503_SERVICE_UNAVAILABLE, + HTTP_504_GATEWAY_TIMEOUT, + HttpStatusTextMap, + isHttpStatusCode, + validHttpErrorCodes, + validHttpStatusCodes, +} from './HttpStatusCode.ts'; +export type { HttpStatusCode } from './HttpStatusCode.ts'; export type { Params } from './Params.ts'; export type { Query } from './Query.ts'; +export type { RegisterRoute } from './RegisterRoute.ts'; export type { ResponseDecorator } from './ResponseDecorator.ts'; export type { State } from './State.ts'; -export type { RegisterRoute } from './registerRoute.ts'; diff --git a/src/Utils/__tests__/createEmptyContext.test.ts b/src/Utils/__tests__/createEmptyContext.test.ts new file mode 100644 index 0000000..fe58fb2 --- /dev/null +++ b/src/Utils/__tests__/createEmptyContext.test.ts @@ -0,0 +1,28 @@ +import { assertEquals } from 'https://deno.land/std/assert/mod.ts'; +import { createEmptyContext } from '../createEmptyContext.ts'; +import { IContext } from '../../Interfaces/mod.ts'; + +Deno.test('createEmptyContext: returns default-initialized context', () => { + const request = new Request('http://localhost'); + const ctx = createEmptyContext(request); + + assertEquals(ctx.req, request); + assertEquals(ctx.params, {}); + assertEquals(ctx.query, {}); + assertEquals(ctx.state, {}); +}); + +Deno.test('createEmptyContext: preserves generic type compatibility', () => { + interface MyContext + extends + IContext<{ userId: string }, { id: string }, { verbose: string }> {} + + const req = new Request('http://localhost'); + const ctx = createEmptyContext(req); + + // All properties exist and are empty + assertEquals(ctx.params, {} as MyContext['params']); + assertEquals(ctx.query, {} as MyContext['query']); + assertEquals(ctx.state, {} as MyContext['state']); + assertEquals(ctx.req, req); +}); diff --git a/src/Utils/__tests__/createRouteMatcher.test.ts b/src/Utils/__tests__/createRouteMatcher.test.ts index 1d64e6e..13a1eb7 100644 --- a/src/Utils/__tests__/createRouteMatcher.test.ts +++ b/src/Utils/__tests__/createRouteMatcher.test.ts @@ -52,3 +52,67 @@ Deno.test('createRouteMatcher: uses custom matcher if provided', () => { assert(result); assertEquals(result.params, {}); }); + +Deno.test('createRouteMatcher: extracts single query param', () => { + const def: IRouteDefinition = { method: 'GET', path: '/search' }; + const matcher = createRouteMatcher(def); + + const url = new URL('http://localhost/search?q=deno'); + const result = matcher(url, dummyRequest); + + assert(result); + assertEquals(result.params, {}); // no path params + assertEquals(result.query, { q: 'deno' }); // single key → string +}); + +Deno.test('createRouteMatcher: duplicate query keys become array', () => { + const def: IRouteDefinition = { method: 'GET', path: '/tags' }; + const matcher = createRouteMatcher(def); + + const url = new URL('http://localhost/tags?tag=js&tag=ts&tag=deno'); + const result = matcher(url, dummyRequest); + + assert(result); + assertEquals(result.params, {}); + assertEquals(result.query, { tag: ['js', 'ts', 'deno'] }); // multi → string[] +}); + +Deno.test('createRouteMatcher: mix of single and duplicate keys', () => { + const def: IRouteDefinition = { method: 'GET', path: '/filter/:type' }; + const matcher = createRouteMatcher(def); + + const url = new URL('http://localhost/filter/repo?lang=ts&lang=js&page=2'); + const result = matcher(url, dummyRequest); + + assert(result); + assertEquals(result.params, { type: 'repo' }); + assertEquals(result.query, { + lang: ['ts', 'js'], // duplicated + page: '2', // single + }); +}); + +Deno.test('createRouteMatcher: no query parameters returns empty object', () => { + const def: IRouteDefinition = { method: 'GET', path: '/info' }; + const matcher = createRouteMatcher(def); + + const url = new URL('http://localhost/info'); + const result = matcher(url, dummyRequest); + + assert(result); + assertEquals(result.params, {}); + assertEquals(result.query, {}); // empty +}); + +Deno.test('createRouteMatcher: retains array order of duplicate keys', () => { + const def: IRouteDefinition = { method: 'GET', path: '/order' }; + const matcher = createRouteMatcher(def); + + const url = new URL( + 'http://localhost/order?item=first&item=second&item=third', + ); + const result = matcher(url, dummyRequest); + + assert(result); + assertEquals(result.query?.item, ['first', 'second', 'third']); +}); diff --git a/src/Utils/__tests__/normalizeError.test.ts b/src/Utils/__tests__/normalizeError.test.ts new file mode 100644 index 0000000..0dbb8d9 --- /dev/null +++ b/src/Utils/__tests__/normalizeError.test.ts @@ -0,0 +1,35 @@ +import { + assertEquals, + assertInstanceOf, +} from 'https://deno.land/std/assert/mod.ts'; +import { normalizeError } from '../normalizeError.ts'; + +Deno.test('normalizeError: preserves Error instances', () => { + const original = new Error('original'); + const result = normalizeError(original); + + assertInstanceOf(result, Error); + assertEquals(result, original); +}); + +Deno.test('normalizeError: converts string to Error', () => { + const result = normalizeError('something went wrong'); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'something went wrong'); +}); + +Deno.test('normalizeError: converts number to Error', () => { + const result = normalizeError(404); + + assertInstanceOf(result, Error); + assertEquals(result.message, '404'); +}); + +Deno.test('normalizeError: converts plain object to Error', () => { + const input = { error: true, msg: 'Invalid' }; + const result = normalizeError(input); + + assertInstanceOf(result, Error); + assertEquals(result.message, JSON.stringify(input)); +}); diff --git a/src/Utils/__tests__/parseQuery.test.ts b/src/Utils/__tests__/parseQuery.test.ts deleted file mode 100644 index 9c4924e..0000000 --- a/src/Utils/__tests__/parseQuery.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { assertEquals } from 'https://deno.land/std/assert/mod.ts'; -import { parseQuery } from '../parseQuery.ts'; - -Deno.test('parseQuery: single-value parameters are parsed as strings', () => { - const url = new URL('http://localhost?foo=bar&limit=10'); - const result = parseQuery(url.searchParams); - - assertEquals(result, { - foo: 'bar', - limit: '10', - }); -}); - -Deno.test('parseQuery: multi-value parameters are parsed as string arrays', () => { - const url = new URL('http://localhost?tag=ts&tag=deno&tag=web'); - const result = parseQuery(url.searchParams); - - assertEquals(result, { - tag: ['ts', 'deno', 'web'], - }); -}); - -Deno.test('parseQuery: mixed single and multi-value parameters', () => { - const url = new URL( - 'http://localhost?sort=asc&filter=active&filter=pending', - ); - const result = parseQuery(url.searchParams); - - assertEquals(result, { - sort: 'asc', - filter: ['active', 'pending'], - }); -}); - -Deno.test('parseQuery: empty query string returns empty object', () => { - const url = new URL('http://localhost'); - const result = parseQuery(url.searchParams); - - assertEquals(result, {}); -}); - -Deno.test('parseQuery: repeated single-value keys are grouped', () => { - const url = new URL('http://localhost?a=1&a=2&a=3'); - const result = parseQuery(url.searchParams); - - assertEquals(result, { - a: ['1', '2', '3'], - }); -}); diff --git a/src/Utils/createEmptyContext.ts b/src/Utils/createEmptyContext.ts new file mode 100644 index 0000000..e5d115a --- /dev/null +++ b/src/Utils/createEmptyContext.ts @@ -0,0 +1,30 @@ +import { IContext } from '../Interfaces/mod.ts'; +import { Params, Query, State } from '../Types/mod.ts'; + +/** + * Creates an empty request context suitable for fallback handlers (e.g., 404 or 500 errors). + * + * This function is primarily intended for cases where no route matched, but a context-compatible + * object is still needed to invoke a generic error handler. All context fields are initialized + * to their default empty values (`{}` for params, query, and state). + * + * @template TContext - The expected context type, typically extending `IContext`. + * @param req - The original HTTP request object from `Deno.serve()`. + * @returns A minimal context object compatible with `TContext`. + * + * @example + * ```ts + * const ctx = createEmptyContext(request); + * return httpErrorHandlers[404](ctx); + * ``` + */ +export function createEmptyContext( + req: Request, +): TContext { + return { + req, + params: {} as Params, + query: {} as Query, + state: {} as State, + } as TContext; +} diff --git a/src/Utils/createRouteMatcher.ts b/src/Utils/createRouteMatcher.ts index 3e696d8..90fc2a5 100644 --- a/src/Utils/createRouteMatcher.ts +++ b/src/Utils/createRouteMatcher.ts @@ -1,61 +1,52 @@ -import { IRouteDefinition, IRouteMatcher } from '../Interfaces/mod.ts'; +// createRouteMatcher.ts + +import { + IRouteDefinition, + IRouteMatch, + IRouteMatcher, + isDynamicRouteDefinition, +} from '../Interfaces/mod.ts'; +import { Params, Query } from '../Types/mod.ts'; /** - * Creates a matcher function from a given route definition. + * Transforms a route definition into a matcher using Deno's URLPattern API. * - * This utility supports both static path-based route definitions (e.g. `/users/:id`) - * and custom matcher functions for dynamic routing scenarios. - * - * ### Static Path Example - * For a definition like: - * ```ts - * { method: "GET", path: "/users/:id" } - * ``` - * the returned matcher function will: - * - match requests to `/users/123` - * - extract `{ id: "123" }` as `params` - * - * ### Dynamic Matcher Example - * If the `IRouteDefinition` includes a `matcher` function, it will be used as-is. - * - * @param def - The route definition to convert into a matcher function. - * Can be static (`path`) or dynamic (`matcher`). - * - * @returns A matcher function that receives a `URL` and `Request` and returns: - * - `{ params: Record }` if the route matches - * - `null` if the route does not match the request - * - * @example - * ```ts - * const matcher = createRouteMatcher({ method: "GET", path: "/repo/:owner/:name" }); - * const result = matcher(new URL("http://localhost/repo/foo/bar"), req); - * // result: { params: { owner: "foo", name: "bar" } } - * ``` + * @param def - Static path pattern or custom matcher. + * @returns IRouteMatcher that returns `{ params, query }` or `null`. */ export function createRouteMatcher( def: IRouteDefinition, ): IRouteMatcher { - if ('matcher' in def) { + // 1. Allow users to provide their own matcher + if (isDynamicRouteDefinition(def)) { return def.matcher; - } else { - const pattern = def.path; - const keys: string[] = []; - const regex = new RegExp( - '^' + - pattern.replace(/:[^\/]+/g, (m) => { - keys.push(m.substring(1)); - return '([^/]+)'; - }) + - '$', - ); - return (url: URL) => { - const match = url.pathname.match(regex); - if (!match) return null; - const params: Record = {}; - for (let i = 0; i < keys.length; i++) { - params[keys[i]] = decodeURIComponent(match[i + 1]); - } - return { params }; - }; } + + // 2. Build URLPattern; supports :id, *wildcards, regex groups, etc. + const pattern = new URLPattern({ pathname: def.path }); + + // 3. The actual matcher closure + return (url: URL): IRouteMatch | null => { + const result = pattern.exec(url); + + // 3a. Path did not match + if (!result) return null; + + // 3b. Extract route params + const params: Params = {}; + for (const [key, value] of Object.entries(result.pathname.groups)) { + params[key] = value; + } + + // 3c. Extract query parameters – keep duplicates as arrays + const query: Query = {}; + for (const key of url.searchParams.keys()) { + const values = url.searchParams.getAll(key); // → string[] + query[key] = values.length === 1 + ? values[0] // single → "foo" + : values; // multi → ["foo","bar"] + } + + return { params, query }; + }; } diff --git a/src/Utils/mod.ts b/src/Utils/mod.ts index e755516..2e21a1d 100644 --- a/src/Utils/mod.ts +++ b/src/Utils/mod.ts @@ -1,4 +1,5 @@ // deno-coverage-ignore-file +export { createEmptyContext } from './createEmptyContext.ts'; export { createRouteMatcher } from './createRouteMatcher.ts'; -export { parseQuery } from './parseQuery.ts'; +export { normalizeError } from './normalizeError.ts'; diff --git a/src/Utils/normalizeError.ts b/src/Utils/normalizeError.ts new file mode 100644 index 0000000..e058ee7 --- /dev/null +++ b/src/Utils/normalizeError.ts @@ -0,0 +1,32 @@ +/** + * Normalizes any thrown value to a proper `Error` instance. + * + * This is useful when handling unknown thrown values that may be: + * - strings (e.g. `throw "oops"`) + * - numbers (e.g. `throw 404`) + * - objects that are not instances of `Error` + * + * Ensures that downstream error handling logic always receives a consistent `Error` object. + * + * @param unknownError - Any value that might have been thrown. + * @returns A valid `Error` instance wrapping the original input. + * + * @example + * ```ts + * try { + * throw "something went wrong"; + * } catch (e) { + * const err = normalizeError(e); + * console.error(err.message); // "something went wrong" + * } + * ``` + */ +export function normalizeError(unknownError: unknown): Error { + return unknownError instanceof Error + ? unknownError + : new Error( + typeof unknownError === 'string' + ? unknownError + : JSON.stringify(unknownError), + ); +} diff --git a/src/Utils/parseQuery.ts b/src/Utils/parseQuery.ts deleted file mode 100644 index 5125f52..0000000 --- a/src/Utils/parseQuery.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Query } from '../Types/Query.ts'; - -/** - * Parses a `URLSearchParams` object into an `IQuery` structure - * that preserves both single and multi-value semantics. - * - * For each query parameter key, this function checks how often the key appears: - * - If the key occurs once, the value is stored as a string. - * - If the key occurs multiple times, the values are stored as a string array. - * - * This ensures compatibility with the `IQuery` type definition, - * which allows both `string` and `string[]` as value types. - * - * Example: - * - ?tag=deno&tag=ts → { tag: ["deno", "ts"] } - * - ?page=2 → { page: "2" } - * - * @param searchParams - The `URLSearchParams` instance from a parsed URL. - * @returns An object conforming to `IQuery`, with normalized parameter values. - */ -export function parseQuery(searchParams: URLSearchParams): Query { - const query: Query = {}; - - for (const key of new Set(searchParams.keys())) { - const values = searchParams.getAll(key); - query[key] = values.length > 1 ? values : values[0]; - } - - return query; -} diff --git a/src/__tests__/HttpKernel.test.ts b/src/__tests__/HttpKernel.test.ts index 75cf67c..214ce51 100644 --- a/src/__tests__/HttpKernel.test.ts +++ b/src/__tests__/HttpKernel.test.ts @@ -1,7 +1,4 @@ -import { - assertEquals, - assertRejects, -} from 'https://deno.land/std@0.204.0/assert/mod.ts'; +import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts'; import { HttpKernel } from '../HttpKernel.ts'; import { IRouteDefinition } from '../Interfaces/mod.ts'; @@ -11,7 +8,7 @@ Deno.test('HttpKernel: matches static route and executes handler', async () => { const def: IRouteDefinition = { method: 'GET', path: '/hello' }; let called = false; - kernel.route(def).handle(() => { + kernel.route(def).handle((_ctx) => { called = true; return Promise.resolve(new Response('OK', { status: 200 })); }); @@ -31,7 +28,7 @@ Deno.test('HttpKernel: supports dynamic matcher', async () => { matcher: (url) => url.pathname === '/dyn' ? { params: {} } : null, }; - kernel.route(def).handle(() => + kernel.route(def).handle((_ctx) => Promise.resolve(new Response('Dyn', { status: 200 })) ); @@ -45,15 +42,15 @@ Deno.test('HttpKernel: calls middleware in order and passes to handler', async ( const calls: string[] = []; kernel.route({ method: 'GET', path: '/test' }) - .middleware(async (ctx, next) => { + .middleware(async (_ctx, next) => { calls.push('mw1'); return await next(); }) - .middleware(async (ctx, next) => { + .middleware(async (_ctx, next) => { calls.push('mw2'); return await next(); }) - .handle(() => { + .handle((_ctx) => { calls.push('handler'); return Promise.resolve(new Response('done')); }); @@ -70,15 +67,15 @@ Deno.test('HttpKernel: middleware short-circuits pipeline', async () => { const calls: string[] = []; kernel.route({ method: 'GET', path: '/stop' }) - .middleware(() => { + .middleware((_ctx, _next) => { calls.push('mw1'); return Promise.resolve(new Response('blocked', { status: 403 })); }) - .middleware(() => { + .middleware((_ctx, _next) => { calls.push('mw2'); return Promise.resolve(new Response('should-not-call')); }) - .handle(() => { + .handle((_ctx) => { calls.push('handler'); return Promise.resolve(new Response('ok')); }); @@ -91,6 +88,32 @@ Deno.test('HttpKernel: middleware short-circuits pipeline', async () => { assertEquals(calls, ['mw1']); }); +Deno.test('HttpKernel: invalid middleware or handler signature triggers 500', async () => { + const kernel = new HttpKernel(); + + // Middleware with wrong signature (missing ctx, next) + kernel.route({ method: 'GET', path: '/bad-mw' }) + // @ts-expect-error invalid middleware + .middleware(() => new Response('invalid')) + .handle((_ctx) => Promise.resolve(new Response('ok'))); + + const res1 = await kernel.handle(new Request('http://localhost/bad-mw')); + assertEquals(res1.status, 500); + assertEquals(await res1.text(), 'Internal Server Error'); + + // Handler with wrong signature (no ctx) + kernel.route({ method: 'GET', path: '/bad-handler' }) + .middleware(async (_ctx, next) => await next()) + // @ts-expect-error invalid handler + .handle(() => new Response('invalid')); + + const res2 = await kernel.handle( + new Request('http://localhost/bad-handler'), + ); + assertEquals(res2.status, 500); + assertEquals(await res2.text(), 'Internal Server Error'); +}); + Deno.test('HttpKernel: 404 for unmatched route', async () => { const kernel = new HttpKernel(); const res = await kernel.handle(new Request('http://localhost/nothing')); @@ -113,18 +136,16 @@ Deno.test('HttpKernel: throws on next() called twice', async () => { const kernel = new HttpKernel(); kernel.route({ method: 'GET', path: '/bad' }) - .middleware(async (ctx, next) => { + .middleware(async (_ctx, next) => { await next(); await next(); // ❌ return new Response('should never reach'); }) - .handle(() => Promise.resolve(new Response('OK'))); + .handle((_ctx) => Promise.resolve(new Response('OK'))); - await assertRejects( - () => kernel.handle(new Request('http://localhost/bad')), - Error, - 'next() called multiple times', - ); + const res = await kernel.handle(new Request('http://localhost/bad')); + assertEquals(res.status, 500); + assertEquals(await res.text(), 'Internal Server Error'); }); Deno.test('HttpKernel: handler throws → error propagates', async () => { @@ -135,11 +156,9 @@ Deno.test('HttpKernel: handler throws → error propagates', async () => { throw new Error('fail!'); }); - await assertRejects( - () => kernel.handle(new Request('http://localhost/throw')), - Error, - 'fail!', - ); + const res = await kernel.handle(new Request('http://localhost/throw')); + assertEquals(res.status, 500); + assertEquals(await res.text(), 'Internal Server Error'); }); Deno.test('HttpKernel: returns 500 if no handler or middleware defined', async () => { @@ -157,5 +176,5 @@ Deno.test('HttpKernel: returns 500 if no handler or middleware defined', async ( const res = await kernel.handle(new Request('http://localhost/fail')); assertEquals(res.status, 500); - assertEquals(await res.text(), 'Internal error'); + assertEquals(await res.text(), 'Internal Server Error'); });