commit 1d2e89fecaccf3f9e787444123840022cfa8002e Author: Max P. Date: Wed May 7 10:53:56 2025 +0200 First commit Signed-off-by: Max P. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac86692 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +coverage/ +logs/ +.locale/ +cache/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1df5f7c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "editor.tabSize": 4, + "editor.insertSpaces": true, + "deno.enable": true, + "deno.testing.args": [ + "--allow-all", + "--unstable-kv" + ], + "editor.formatOnSave": true, + "editor.defaultFormatter": "denoland.vscode-deno", + "editor.detectIndentation": false, + "editor.indentSize": "tabSize", +} \ No newline at end of file diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..8fca440 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,28 @@ +{ + "tasks": { + // "start": "deno run --allow-net --allow-env --unstable-kv --allow-read --allow-write --env-file src/main.ts -- --verbose", + // "watch": "deno run --watch --allow-net --allow-env --unstable-kv --allow-read --allow-write --env-file src/main.ts -- --verbose", + "test": "deno test --allow-net --allow-env --unstable-kv --allow-read --allow-write --coverage **/__tests__/*.test.ts" + }, + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "strict": true + }, + "fmt": { + "useTabs": false, + "lineWidth": 80, + "indentWidth": 4, + "semiColons": true, + "singleQuote": true, + "proseWrap": "preserve", + "include": [ + "src/", + "main.ts" + ] + }, + "importMap": "./import_map.json" +} \ No newline at end of file diff --git a/src/HttpKernel.ts b/src/HttpKernel.ts new file mode 100644 index 0000000..fe66dbc --- /dev/null +++ b/src/HttpKernel.ts @@ -0,0 +1,121 @@ +import { + IContext, + IHandler, + IHttpKernel, + IInternalRoute, + IMiddleware, + IRouteBuilder, + IRouteBuilderFactory, + IRouteDefinition, + ResponseDecorator, +} from './Interfaces/mod.ts'; +import { RouteBuilder } from './RouteBuilder.ts'; + +/** + * The central HTTP kernel responsible for managing route definitions, + * executing middleware chains, and dispatching HTTP requests to their handlers. + * + * This class supports a fluent API for route registration and allows the injection + * of custom response decorators and route builder factories for maximum flexibility and testability. + */ +export class HttpKernel implements IHttpKernel { + /** + * The list of internally registered routes, each with method, matcher, middleware, and handler. + */ + private routes: IInternalRoute[] = []; + + /** + * Creates a new instance of the `HttpKernel`. + * + * @param decorateResponse - An optional response decorator function that is applied to all responses + * after the middleware/handler pipeline. Defaults to identity (no modification). + * @param routeBuilderFactory - Optional factory for creating route builders. Defaults to using `RouteBuilder`. + */ + public constructor( + private readonly decorateResponse: ResponseDecorator = (res) => res, + private readonly routeBuilderFactory: IRouteBuilderFactory = + RouteBuilder, + ) {} + + /** + * @inheritdoc + */ + public route(definition: IRouteDefinition): IRouteBuilder { + return new this.routeBuilderFactory( + this.registerRoute.bind(this), + definition, + ); + } + + /** + * @inheritdoc + */ + public handle = async (request: Request): Promise => { + const url = new URL(request.url); + const method = request.method.toUpperCase(); + + for (const route of this.routes) { + if (route.method !== method) continue; + const match = route.matcher(url, request); + if (match) { + const ctx: IContext = { + req: request, + params: match.params, + state: {}, + }; + return await this.executePipeline( + ctx, + route.middlewares, + route.handler, + ); + } + } + + return new Response('Not Found', { status: 404 }); + }; + + /** + * Registers a finalized route by pushing it into the internal route list. + * + * This method is typically called by the route builder after `.handle()` is invoked. + * + * @param route - The fully constructed route including matcher, middlewares, and handler. + */ + private registerRoute(route: IInternalRoute): void { + this.routes.push(route); + } + + /** + * Executes the middleware pipeline and final handler for a given request context. + * + * 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. + * + * The final response is passed through the `decorateResponse` function before being returned. + * + * @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. + */ + private async executePipeline( + ctx: IContext, + 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 | IHandler = 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)(ctx); + }; + return this.decorateResponse(await dispatch(0)); + } +} diff --git a/src/Interfaces/IContext.ts b/src/Interfaces/IContext.ts new file mode 100644 index 0000000..ae874ba --- /dev/null +++ b/src/Interfaces/IContext.ts @@ -0,0 +1,31 @@ +/** + * Represents the per-request context passed through the middleware pipeline and to the final handler. + * + * This context object encapsulates the original HTTP request, + * the path parameters extracted from the matched route, + * and a mutable state object for sharing information across middlewares and handlers. + */ +export interface IContext { + /** + * The original HTTP request object as received by Deno. + * Contains all standard fields like headers, method, body, etc. + */ + req: Request; + + /** + * Route parameters parsed from the URL path, based on route definitions + * that include dynamic segments (e.g., `/users/:id` → `{ id: "123" }`). + * + * These parameters are considered read-only and are set by the router. + */ + params: Record; + + /** + * A shared, mutable object used to pass arbitrary data between middlewares and handlers. + * + * Use this field to attach validated user info, auth state, logging context, etc. + * + * Each key should be well-named to avoid collisions across layers. + */ + state: Record; +} diff --git a/src/Interfaces/IHandler.ts b/src/Interfaces/IHandler.ts new file mode 100644 index 0000000..4231fa0 --- /dev/null +++ b/src/Interfaces/IHandler.ts @@ -0,0 +1,16 @@ +import { IContext } from './IContext.ts'; + +/** + * Represents a final request handler responsible for generating a response. + * + * The handler is the last step in the middleware pipeline and must return + * a valid HTTP `Response`. It has access to all data injected into the + * request context, including path parameters and any state added by middleware. + */ +export interface IHandler { + /** + * @param ctx - The complete request context, including parameters and middleware state. + * @returns A promise resolving to an HTTP `Response`. + */ + (ctx: IContext): Promise; +} diff --git a/src/Interfaces/IHttpKernel.ts b/src/Interfaces/IHttpKernel.ts new file mode 100644 index 0000000..4bc3b77 --- /dev/null +++ b/src/Interfaces/IHttpKernel.ts @@ -0,0 +1,33 @@ +import { IRouteBuilder } from './IRouteBuilder.ts'; +import { IRouteDefinition } from './IRouteDefinition.ts'; + +/** + * Defines the core interface for the HTTP kernel, responsible for route registration, + * middleware orchestration, and request dispatching. + */ +export interface IHttpKernel { + /** + * Registers a new route with a static path pattern or a dynamic matcher. + * + * This method accepts both conventional route definitions (with path templates) + * and advanced matcher-based routes for flexible URL structures. + * + * Returns a route builder that allows chaining middleware and assigning a handler. + * + * @param definition - A static or dynamic route definition, including the HTTP method + * and either a path pattern or custom matcher function. + * @returns A builder interface to attach middleware and define the handler. + */ + route(definition: IRouteDefinition): IRouteBuilder; + + /** + * Handles an incoming HTTP request by matching it against registered routes, + * executing any associated middleware in order, and invoking the final route handler. + * + * This method serves as the main entry point to integrate with `Deno.serve`. + * + * @param request - The incoming HTTP request object. + * @returns A promise resolving to the final HTTP response. + */ + handle(request: Request): Promise; +} diff --git a/src/Interfaces/IInternalRoute.ts b/src/Interfaces/IInternalRoute.ts new file mode 100644 index 0000000..b8bc2ba --- /dev/null +++ b/src/Interfaces/IInternalRoute.ts @@ -0,0 +1,42 @@ +import { IHandler } from './IHandler.ts'; +import { IMiddleware } from './IMiddleware.ts'; + +/** + * Represents an internally registered route within the HttpKernel. + * + * Contains all data required to match an incoming request and dispatch it + * through the associated middleware chain and final handler. + */ +export interface IInternalRoute { + /** + * The HTTP method (e.g. 'GET', 'POST') that this route responds to. + * The method should always be in uppercase. + */ + method: string; + + /** + * A matcher function used to determine whether this route matches a given request. + * + * If the matcher returns `null`, the route does not apply to the request. + * If it returns a params object, the route is considered matched and the extracted + * parameters are passed into the request context. + * + * @param url - The parsed URL object from the incoming request. + * @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 }; + + /** + * An ordered list of middleware functions to be executed before the handler. + */ + middlewares: IMiddleware[]; + + /** + * The final handler that generates the HTTP response after all middleware has run. + */ + handler: IHandler; +} diff --git a/src/Interfaces/IMiddleware.ts b/src/Interfaces/IMiddleware.ts new file mode 100644 index 0000000..c8ee818 --- /dev/null +++ b/src/Interfaces/IMiddleware.ts @@ -0,0 +1,20 @@ +import { IContext } from './IContext.ts'; + +/** + * Represents a middleware function in the HTTP request pipeline. + * + * Middleware can perform tasks such as logging, authentication, validation, + * or response transformation. It receives the current request context and + * a `next()` function to delegate control to the next middleware or final handler. + * + * To stop the request pipeline, a middleware can return a `Response` directly + * without calling `next()`. + */ +export interface IMiddleware { + /** + * @param ctx - The request context, containing the request, path parameters, and shared state. + * @param next - A function that continues the middleware pipeline. Returns the final `Response`. + * @returns A promise resolving to an HTTP `Response`. + */ + (ctx: IContext, next: () => Promise): Promise; +} diff --git a/src/Interfaces/IRouteBuilder.ts b/src/Interfaces/IRouteBuilder.ts new file mode 100644 index 0000000..c1917d0 --- /dev/null +++ b/src/Interfaces/IRouteBuilder.ts @@ -0,0 +1,35 @@ +import { IHandler } from './IHandler.ts'; +import { IInternalRoute } from './IInternalRoute.ts'; +import { IMiddleware } from './IMiddleware.ts'; +import { IRouteDefinition } from './IRouteDefinition.ts'; + +export interface IRouteBuilderFactory { + new ( + registerRoute: (route: IInternalRoute) => void, + def: IRouteDefinition, + mws?: IMiddleware[], + ): IRouteBuilder; +} + +/** + * Provides a fluent API to build a single route configuration by chaining + * middleware and setting the final request handler. + */ +export interface IRouteBuilder { + /** + * Adds a middleware to the current route. + * Middleware will be executed in the order of registration. + * + * @param mw - A middleware function. + * @returns The route builder for further chaining. + */ + middleware(mw: IMiddleware): IRouteBuilder; + + /** + * Sets the final request handler for the route. + * Calling this finalizes the route and registers it in the kernel. + * + * @param handler - The function to execute when this route is matched. + */ + handle(handler: IHandler): void; +} diff --git a/src/Interfaces/IRouteDefinition.ts b/src/Interfaces/IRouteDefinition.ts new file mode 100644 index 0000000..39dee12 --- /dev/null +++ b/src/Interfaces/IRouteDefinition.ts @@ -0,0 +1,46 @@ +import { IRouteMatcher } from './IRouteMatcher.ts'; + +/** + * Defines a static route using a path pattern with optional parameters. + * + * Suitable for conventional routes like "/users/:id", which can be parsed + * into named parameters using a path-matching library. + */ +export interface IStaticRouteDefinition { + /** + * The HTTP method this route should match (e.g. "GET", "POST"). + */ + method: string; + + /** + * A static path pattern for the route, which may include named parameters + * (e.g. "/caches/:id"). Internally, this can be converted to a regex matcher. + */ + path: string; +} + +/** + * Defines a dynamic route using a custom matcher function instead of a static path. + * + * Useful for complex URL structures that cannot easily be expressed using a static pattern, + * such as routes with variable prefixes or conditional segment logic. + */ +export interface IDynamicRouteDefinition { + /** + * The HTTP method this route should match (e.g. "GET", "POST"). + */ + method: string; + + /** + * A custom matcher function that receives the parsed URL and raw request. + * If the function returns `null`, the route does not match. + * If the function returns a params object, the route is considered matched. + */ + matcher: IRouteMatcher; +} + +/** + * A route definition can either be a conventional static route with a path pattern, + * or a dynamic route with a custom matcher function for advanced matching logic. + */ +export type IRouteDefinition = IStaticRouteDefinition | IDynamicRouteDefinition; diff --git a/src/Interfaces/IRouteMatcher.ts b/src/Interfaces/IRouteMatcher.ts new file mode 100644 index 0000000..88d8ec2 --- /dev/null +++ b/src/Interfaces/IRouteMatcher.ts @@ -0,0 +1,34 @@ +import { IRouteDefinition } from './IRouteDefinition.ts'; + +/** + * Defines a route matcher function that evaluates whether a route applies to a given request. + * + * If the route matches, the matcher returns an object containing extracted route parameters. + * Otherwise, it returns `null`. + */ +export interface IRouteMatcher { + /** + * Evaluates whether the given URL and request match a defined route. + * + * @param url - The full URL of the incoming request. + * @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 }; +} + +/** + * Represents a factory for creating route matcher functions from route definitions. + * + * This allows the matcher logic to be injected or replaced (e.g. for testing, + * pattern libraries, or advanced routing scenarios). + */ +export interface IRouteMatcherFactory { + /** + * Creates a matcher function based on a given route definition. + * + * @param def - The route definition (static or dynamic). + * @returns A matcher function that checks if a request matches and extracts parameters. + */ + (def: IRouteDefinition): IRouteMatcher; +} diff --git a/src/Interfaces/ResponseDecorator.ts b/src/Interfaces/ResponseDecorator.ts new file mode 100644 index 0000000..c0cd438 --- /dev/null +++ b/src/Interfaces/ResponseDecorator.ts @@ -0,0 +1,25 @@ +/** + * A function that modifies or enriches an outgoing HTTP response before it is returned to the client. + * + * This decorator can be used to inject headers (e.g., CORS, security), apply global transformations, + * or wrap responses for logging, analytics, or debugging purposes. + * + * It is called exactly once at the end of the middleware/handler pipeline, + * allowing central response customization without interfering with business logic. + * + * @param res - The original `Response` object produced by the route handler or middleware chain. + * @returns A modified or wrapped `Response` object to be sent back to the client. + * + * @example + * ```ts + * const addCors: ResponseDecorator = (res) => { + * const headers = new Headers(res.headers); + * headers.set("Access-Control-Allow-Origin", "*"); + * return new Response(res.body, { + * status: res.status, + * headers, + * }); + * }; + * ``` + */ +export type ResponseDecorator = (res: Response) => Response; diff --git a/src/Interfaces/mod.ts b/src/Interfaces/mod.ts new file mode 100644 index 0000000..517ee2a --- /dev/null +++ b/src/Interfaces/mod.ts @@ -0,0 +1,13 @@ +export type { IContext } from './IContext.ts'; +export type { IMiddleware } from './IMiddleware.ts'; +export type { IHandler } from './IHandler.ts'; +export type { IHttpKernel } from './IHttpKernel.ts'; +export type { IRouteBuilder, IRouteBuilderFactory } from './IRouteBuilder.ts'; +export type { + IDynamicRouteDefinition, + IRouteDefinition, + IStaticRouteDefinition, +} from './IRouteDefinition.ts'; +export type { IInternalRoute } from './IInternalRoute.ts'; +export type { IRouteMatcher } from './IRouteMatcher.ts'; +export type { ResponseDecorator } from './ResponseDecorator.ts'; diff --git a/src/RouteBuilder.ts b/src/RouteBuilder.ts new file mode 100644 index 0000000..8be19c1 --- /dev/null +++ b/src/RouteBuilder.ts @@ -0,0 +1,66 @@ +import { IRouteMatcherFactory } from './Interfaces/IRouteMatcher.ts'; +import { + IHandler, + IInternalRoute, + IMiddleware, + IRouteBuilder, + IRouteDefinition, +} from './Interfaces/mod.ts'; +import { createRouteMatcher } from './Utils.ts'; + +/** + * Provides a fluent builder interface for defining a single route, + * including HTTP method, path or matcher, middleware chain and final handler. + * + * This builder is stateless and immutable; each chained call returns a new instance. + */ +export class RouteBuilder implements IRouteBuilder { + /** + * Constructs a new instance of the route builder. + * + * @param registerRoute - A delegate used to register the finalized route definition. + * @param def - The route definition (static path or dynamic matcher). + * @param mws - The list of middleware functions collected so far (default: empty). + */ + constructor( + private readonly registerRoute: (route: IInternalRoute) => void, + private readonly def: IRouteDefinition, + private readonly mws: IMiddleware[] = [], + private readonly matcherFactory: IRouteMatcherFactory = + createRouteMatcher, + ) {} + + /** + * Adds a middleware function to the current route definition. + * + * Middleware is executed in the order it is added. + * Returns a new builder instance with the additional middleware appended. + * + * @param mw - A middleware function to be executed before the handler. + * @returns A new `RouteBuilder` instance for continued chaining. + */ + middleware(mw: IMiddleware): IRouteBuilder { + return new RouteBuilder(this.registerRoute, this.def, [ + ...this.mws, + mw, + ]); + } + + /** + * Finalizes the route by assigning the handler and registering the route. + * + * Internally constructs a matcher function from the route definition + * and passes all route data to the registration delegate. + * + * @param handler - The final request handler for this route. + */ + handle(handler: IHandler): void { + const matcher = this.matcherFactory(this.def); + this.registerRoute({ + method: this.def.method.toUpperCase(), + matcher, + middlewares: this.mws, + handler, + }); + } +} diff --git a/src/Utils.ts b/src/Utils.ts new file mode 100644 index 0000000..73b5d94 --- /dev/null +++ b/src/Utils.ts @@ -0,0 +1,61 @@ +import { IRouteDefinition, IRouteMatcher } from './Interfaces/mod.ts'; + +/** + * Creates a matcher function from a given route definition. + * + * 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" } } + * ``` + */ +export function createRouteMatcher( + def: IRouteDefinition, +): IRouteMatcher { + if ('matcher' in 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 }; + }; + } +} diff --git a/src/__tests__/HttpKernel.test.ts b/src/__tests__/HttpKernel.test.ts new file mode 100644 index 0000000..75cf67c --- /dev/null +++ b/src/__tests__/HttpKernel.test.ts @@ -0,0 +1,161 @@ +import { + assertEquals, + assertRejects, +} from 'https://deno.land/std@0.204.0/assert/mod.ts'; +import { HttpKernel } from '../HttpKernel.ts'; +import { IRouteDefinition } from '../Interfaces/mod.ts'; + +Deno.test('HttpKernel: matches static route and executes handler', async () => { + const kernel = new HttpKernel(); + + const def: IRouteDefinition = { method: 'GET', path: '/hello' }; + let called = false; + + kernel.route(def).handle(() => { + called = true; + return Promise.resolve(new Response('OK', { status: 200 })); + }); + + const res = await kernel.handle( + new Request('http://localhost/hello', { method: 'GET' }), + ); + assertEquals(res.status, 200); + assertEquals(await res.text(), 'OK'); + assertEquals(called, true); +}); + +Deno.test('HttpKernel: supports dynamic matcher', async () => { + const kernel = new HttpKernel(); + const def: IRouteDefinition = { + method: 'GET', + matcher: (url) => url.pathname === '/dyn' ? { params: {} } : null, + }; + + kernel.route(def).handle(() => + Promise.resolve(new Response('Dyn', { status: 200 })) + ); + + const res = await kernel.handle(new Request('http://localhost/dyn')); + assertEquals(res.status, 200); + assertEquals(await res.text(), 'Dyn'); +}); + +Deno.test('HttpKernel: calls middleware in order and passes to handler', async () => { + const kernel = new HttpKernel(); + const calls: string[] = []; + + kernel.route({ method: 'GET', path: '/test' }) + .middleware(async (ctx, next) => { + calls.push('mw1'); + return await next(); + }) + .middleware(async (ctx, next) => { + calls.push('mw2'); + return await next(); + }) + .handle(() => { + calls.push('handler'); + return Promise.resolve(new Response('done')); + }); + + const res = await kernel.handle( + new Request('http://localhost/test', { method: 'GET' }), + ); + assertEquals(await res.text(), 'done'); + assertEquals(calls, ['mw1', 'mw2', 'handler']); +}); + +Deno.test('HttpKernel: middleware short-circuits pipeline', async () => { + const kernel = new HttpKernel(); + const calls: string[] = []; + + kernel.route({ method: 'GET', path: '/stop' }) + .middleware(() => { + calls.push('mw1'); + return Promise.resolve(new Response('blocked', { status: 403 })); + }) + .middleware(() => { + calls.push('mw2'); + return Promise.resolve(new Response('should-not-call')); + }) + .handle(() => { + calls.push('handler'); + return Promise.resolve(new Response('ok')); + }); + + const res = await kernel.handle( + new Request('http://localhost/stop', { method: 'GET' }), + ); + assertEquals(res.status, 403); + assertEquals(await res.text(), 'blocked'); + assertEquals(calls, ['mw1']); +}); + +Deno.test('HttpKernel: 404 for unmatched route', async () => { + const kernel = new HttpKernel(); + const res = await kernel.handle(new Request('http://localhost/nothing')); + assertEquals(res.status, 404); +}); + +Deno.test('HttpKernel: skips route with wrong method', async () => { + const kernel = new HttpKernel(); + + kernel.route({ method: 'POST', path: '/only-post' }) + .handle(() => Promise.resolve(new Response('nope'))); + + const res = await kernel.handle( + new Request('http://localhost/only-post', { method: 'GET' }), + ); + assertEquals(res.status, 404); +}); + +Deno.test('HttpKernel: throws on next() called twice', async () => { + const kernel = new HttpKernel(); + + kernel.route({ method: 'GET', path: '/bad' }) + .middleware(async (ctx, next) => { + await next(); + await next(); // ❌ + return new Response('should never reach'); + }) + .handle(() => Promise.resolve(new Response('OK'))); + + await assertRejects( + () => kernel.handle(new Request('http://localhost/bad')), + Error, + 'next() called multiple times', + ); +}); + +Deno.test('HttpKernel: handler throws → error propagates', async () => { + const kernel = new HttpKernel(); + + kernel.route({ method: 'GET', path: '/throw' }) + .handle(() => { + throw new Error('fail!'); + }); + + await assertRejects( + () => kernel.handle(new Request('http://localhost/throw')), + Error, + 'fail!', + ); +}); + +Deno.test('HttpKernel: returns 500 if no handler or middleware defined', async () => { + const kernel = new HttpKernel(); + + // Force-manual Registrierung mit `handler: undefined` + // Umgehen des Builders zur Simulation dieses Edge-Cases + kernel['routes'].push({ + method: 'GET', + matcher: (url) => url.pathname === '/fail' ? { params: {} } : null, + middlewares: [], + // @ts-expect-error absichtlich ungültiger Handler + handler: undefined, + }); + + const res = await kernel.handle(new Request('http://localhost/fail')); + assertEquals(res.status, 500); + assertEquals(await res.text(), 'Internal error'); +}); diff --git a/src/__tests__/RouteBuilder.test.ts b/src/__tests__/RouteBuilder.test.ts new file mode 100644 index 0000000..83891ab --- /dev/null +++ b/src/__tests__/RouteBuilder.test.ts @@ -0,0 +1,111 @@ +import { + assert, + assertEquals, + assertNotEquals, + assertThrows, +} from 'https://deno.land/std@0.204.0/assert/mod.ts'; +import { + IHandler, + IInternalRoute, + IMiddleware, + IRouteDefinition, +} from '../Interfaces/mod.ts'; +import { RouteBuilder } from '../mod.ts'; + +// Dummy objects +const dummyHandler: IHandler = async () => new Response('ok'); +const dummyMiddleware: IMiddleware = async (_, next) => await next(); +const dummyDef: IRouteDefinition = { method: 'get', path: '/hello' }; +const dummyMatcher = () => ({ params: {} }); + +Deno.test('middleware: single middleware is registered correctly', () => { + let registered: IInternalRoute | null = null as IInternalRoute | null; + + const builder = new RouteBuilder((r) => registered = r, dummyDef) + .middleware(dummyMiddleware); + + builder.handle(dummyHandler); + + assert(registered); + assertEquals(registered?.middlewares.length, 1); + assertEquals(registered?.middlewares[0], dummyMiddleware); +}); + +Deno.test('middleware: middleware is chained immutably', () => { + const builder1 = new RouteBuilder(() => {}, dummyDef); + const builder2 = builder1.middleware(dummyMiddleware); + + assertNotEquals(builder1, builder2); +}); + +Deno.test('middleware: preserves order of middleware', () => { + const mw1: IMiddleware = async (_, next) => await next(); + const mw2: IMiddleware = async (_, next) => await next(); + + let result: IInternalRoute | null = null as IInternalRoute | null; + + const builder = new RouteBuilder((r) => result = r, dummyDef) + .middleware(mw1) + .middleware(mw2); + + builder.handle(dummyHandler); + + assert(result); + assertEquals(result!.middlewares, [mw1, mw2]); +}); + +Deno.test('handle: uppercases method', () => { + let result: IInternalRoute | null = null as IInternalRoute | null; + + new RouteBuilder((r) => result = r, { method: 'post', path: '/x' }) + .handle(dummyHandler); + + assertEquals(result?.method, 'POST'); +}); + +Deno.test('handle: works with no middleware', async () => { + let route: IInternalRoute | null = null as IInternalRoute | null; + + const builder = new RouteBuilder((r) => route = r, dummyDef); + builder.handle(dummyHandler); + + assert(route); + assertEquals(route?.middlewares.length, 0); + + const request = new Request('http://localhost'); + + const res1 = await route?.handler({ req: request, params: {}, state: {} }); + const res2 = await dummyHandler({ req: request, params: {}, state: {} }); + + assertEquals(res1?.status, res2?.status); + assertEquals(await res1?.text(), await res2?.text()); +}); + +Deno.test('handle: uses custom matcher factory', () => { + let called = false; + + const factory = (def: IRouteDefinition) => { + called = true; + return dummyMatcher; + }; + + let route: IInternalRoute | null = null as IInternalRoute | null; + + new RouteBuilder((r) => route = r, dummyDef, [], factory).handle( + dummyHandler, + ); + + assert(called); + assert(route); + assertEquals(route!.matcher, dummyMatcher); +}); + +Deno.test('handle: throws if matcher factory throws', () => { + const faultyFactory = () => { + throw new Error('matcher fail'); + }; + + const builder = new RouteBuilder(() => {}, dummyDef, [], faultyFactory); + + assertThrows(() => builder.handle(dummyHandler), Error, 'matcher fail'); +}); diff --git a/src/__tests__/Utils.test.ts b/src/__tests__/Utils.test.ts new file mode 100644 index 0000000..8e1fb8a --- /dev/null +++ b/src/__tests__/Utils.test.ts @@ -0,0 +1,54 @@ +import { + assert, + assertEquals, + assertStrictEquals, +} from 'https://deno.land/std/assert/mod.ts'; +import { IRouteDefinition } from '../Interfaces/mod.ts'; +import { createRouteMatcher } from '../mod.ts'; + +// Dummy request +const dummyRequest = new Request('http://localhost'); + +Deno.test('createRouteMatcher: static route matches and extracts params', () => { + const def: IRouteDefinition = { method: 'GET', path: '/users/:id' }; + const matcher = createRouteMatcher(def); + + const result = matcher(new URL('http://localhost/users/42'), dummyRequest); + + assert(result); + assertEquals(result.params, { id: '42' }); +}); + +Deno.test('createRouteMatcher: static route with multiple params', () => { + const def: IRouteDefinition = { method: 'GET', path: '/repo/:owner/:name' }; + const matcher = createRouteMatcher(def); + + const result = matcher( + new URL('http://localhost/repo/max/wiki'), + dummyRequest, + ); + + assert(result); + assertEquals(result.params, { owner: 'max', name: 'wiki' }); +}); + +Deno.test('createRouteMatcher: static route does not match wrong path', () => { + const def: IRouteDefinition = { method: 'GET', path: '/users/:id' }; + const matcher = createRouteMatcher(def); + + const result = matcher(new URL('http://localhost/posts/42'), dummyRequest); + + assertStrictEquals(result, null); +}); + +Deno.test('createRouteMatcher: uses custom matcher if provided', () => { + const def: IRouteDefinition = { + method: 'GET', + matcher: (url) => url.pathname === '/ping' ? { params: {} } : null, + }; + const matcher = createRouteMatcher(def); + + const result = matcher(new URL('http://localhost/ping'), dummyRequest); + assert(result); + assertEquals(result.params, {}); +}); diff --git a/src/mod.ts b/src/mod.ts new file mode 100644 index 0000000..1ba8006 --- /dev/null +++ b/src/mod.ts @@ -0,0 +1,4 @@ +// deno-coverage-ignore-file +export { HttpKernel } from './HttpKernel.ts'; +export { RouteBuilder } from './RouteBuilder.ts'; +export { createRouteMatcher } from './Utils.ts';