121
src/HttpKernel.ts
Normal file
121
src/HttpKernel.ts
Normal file
@@ -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<Response> => {
|
||||
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<Response> {
|
||||
let i = -1;
|
||||
const dispatch = async (index: number): Promise<Response> => {
|
||||
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));
|
||||
}
|
||||
}
|
31
src/Interfaces/IContext.ts
Normal file
31
src/Interfaces/IContext.ts
Normal file
@@ -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<string, string>;
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
}
|
16
src/Interfaces/IHandler.ts
Normal file
16
src/Interfaces/IHandler.ts
Normal file
@@ -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<Response>;
|
||||
}
|
33
src/Interfaces/IHttpKernel.ts
Normal file
33
src/Interfaces/IHttpKernel.ts
Normal file
@@ -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<Response>;
|
||||
}
|
42
src/Interfaces/IInternalRoute.ts
Normal file
42
src/Interfaces/IInternalRoute.ts
Normal file
@@ -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<string, string> };
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
20
src/Interfaces/IMiddleware.ts
Normal file
20
src/Interfaces/IMiddleware.ts
Normal file
@@ -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<Response>): Promise<Response>;
|
||||
}
|
35
src/Interfaces/IRouteBuilder.ts
Normal file
35
src/Interfaces/IRouteBuilder.ts
Normal file
@@ -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;
|
||||
}
|
46
src/Interfaces/IRouteDefinition.ts
Normal file
46
src/Interfaces/IRouteDefinition.ts
Normal file
@@ -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;
|
34
src/Interfaces/IRouteMatcher.ts
Normal file
34
src/Interfaces/IRouteMatcher.ts
Normal file
@@ -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<string, string> };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
25
src/Interfaces/ResponseDecorator.ts
Normal file
25
src/Interfaces/ResponseDecorator.ts
Normal file
@@ -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;
|
13
src/Interfaces/mod.ts
Normal file
13
src/Interfaces/mod.ts
Normal file
@@ -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';
|
66
src/RouteBuilder.ts
Normal file
66
src/RouteBuilder.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
61
src/Utils.ts
Normal file
61
src/Utils.ts
Normal file
@@ -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<string, string> }` 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<string, string> = {};
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
params[keys[i]] = decodeURIComponent(match[i + 1]);
|
||||
}
|
||||
return { params };
|
||||
};
|
||||
}
|
||||
}
|
161
src/__tests__/HttpKernel.test.ts
Normal file
161
src/__tests__/HttpKernel.test.ts
Normal file
@@ -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');
|
||||
});
|
111
src/__tests__/RouteBuilder.test.ts
Normal file
111
src/__tests__/RouteBuilder.test.ts
Normal file
@@ -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');
|
||||
});
|
54
src/__tests__/Utils.test.ts
Normal file
54
src/__tests__/Utils.test.ts
Normal file
@@ -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, {});
|
||||
});
|
4
src/mod.ts
Normal file
4
src/mod.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// deno-coverage-ignore-file
|
||||
export { HttpKernel } from './HttpKernel.ts';
|
||||
export { RouteBuilder } from './RouteBuilder.ts';
|
||||
export { createRouteMatcher } from './Utils.ts';
|
Reference in New Issue
Block a user