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'); });