import { ConfigurationManager } from "@nivinjoseph/n-config"; import { given } from "@nivinjoseph/n-defensive"; import { ApplicationException } from "@nivinjoseph/n-exception"; import { Container, type Scope } from "@nivinjoseph/n-ject"; import { Profiler, Templator } from "@nivinjoseph/n-util"; import Koa from "koa"; import { Router as KoaRouter, type RouterContext } from "@koa/router"; import { ControllerRegistration } from "./controller-registration.js"; import { Controller } from "./controller.js"; import { HttpException } from "./exceptions/http-exception.js"; import { HttpRedirectException } from "./exceptions/http-redirect-exception.js"; import { HttpMethods } from "./http-method.js"; import { RouteInfo } from "./route-info.js"; import type { AuthorizationHandler } from "./security/authorization-handler.js"; import type { CallContext } from "./services/call-context/call-context.js"; export class Router { private readonly _koa: Koa; private readonly _container: Container; private readonly _authorizationHandlerKey: string; private readonly _callContextKey: string; private readonly _koaRouter: KoaRouter; private readonly _controllers = new Array(); public constructor(koa: Koa, container: Container, authorizationHandlerKey: string, callContextKey: string) { given(koa, "koa").ensureHasValue(); given(container, "container").ensureHasValue(); given(authorizationHandlerKey, "authorizationHandlerKey").ensureHasValue().ensure(t => !t.isEmptyOrWhiteSpace()); given(callContextKey, "callContextKey").ensureHasValue().ensure(t => !t.isEmptyOrWhiteSpace()); this._koa = koa; this._container = container; this._authorizationHandlerKey = authorizationHandlerKey; this._callContextKey = callContextKey; this._koaRouter = new KoaRouter(); } // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type public registerControllers(...controllers: Array): void { for (const controller of controllers) { if (this._controllers.some(t => t.controller === controller)) throw new ApplicationException("Duplicate registration detected for Controller '{0}'." .format((controller as Object).getTypeName())); const registration = new ControllerRegistration(controller); this._controllers.push(registration); this._container.registerScoped(registration.name, registration.controller); } } public configureRouting(viewResolutionRoot?: string): void { given(viewResolutionRoot as string, "viewResolutionRoot").ensureIsString(); let catchAllRegistration: ControllerRegistration | null = null; for (const registration of this._controllers) { registration.complete(viewResolutionRoot); if (registration.route.isCatchAll) { if (catchAllRegistration != null) throw new ApplicationException("Multiple catch all registrations detected"); catchAllRegistration = registration; continue; } switch (registration.method) { case HttpMethods.Get: this._configureGet(registration); break; case HttpMethods.Post: this._configurePost(registration); break; case HttpMethods.Put: this._configurePut(registration); break; case HttpMethods.Delete: this._configureDelete(registration); break; } } this._koa.use(this._koaRouter.routes()); this._koa.use(this._koaRouter.allowedMethods()); if (catchAllRegistration != null) { this._koa.use(async (ctx, _next) => { await this._handleRequest(ctx as unknown as RouterContext, catchAllRegistration, false); }); } } private _configureGet(registration: ControllerRegistration): void { this._koaRouter.get(registration.route.koaRoute, async (ctx) => { await this._handleRequest(ctx, registration, false); }); } private _configurePost(registration: ControllerRegistration): void { this._koaRouter.post(registration.route.koaRoute, async (ctx) => { await this._handleRequest(ctx, registration, true); }); } private _configurePut(registration: ControllerRegistration): void { this._koaRouter.put(registration.route.koaRoute, async (ctx) => { await this._handleRequest(ctx, registration, true); }); } private _configureDelete(registration: ControllerRegistration): void { this._koaRouter.del(registration.route.koaRoute, async (ctx) => { await this._handleRequest(ctx, registration, true); }); } private async _handleRequest(ctx: RouterContext, registration: ControllerRegistration, processBody: boolean): Promise { const profiler = ctx.state.profiler; profiler?.trace("Request handling started"); const scope = ctx.state.scope as Scope; const callContext = scope.resolve(this._callContextKey); profiler?.trace("Request callContext resolved"); if (registration.authorizeClaims) { if (!callContext.isAuthenticated) throw new HttpException(401); const authorizationHandler = scope.resolve(this._authorizationHandlerKey); const authorized = await authorizationHandler.authorize(callContext.identity!, registration.authorizeClaims); profiler?.trace("Request authorized"); if (!authorized) throw new HttpException(403); } const args = this._createRouteArgs(registration.route, ctx); if (processBody) args.push(ctx.request.body); profiler?.trace("Request args created"); const controllerInstance = scope.resolve(registration.name); (controllerInstance).__ctx = ctx; profiler?.trace("Request controller created"); let result: any; try { result = await controllerInstance.execute(...args); } catch (error) { if (!(error instanceof HttpRedirectException)) throw error; ctx.redirect(error.url); return; } finally { profiler?.trace("Request controller executed"); } if (registration.hasView) { let vm = result; if (typeof vm !== "object") vm = { value: result }; let view = (await registration.retrieveView())!; const viewLayout = await registration.retrieveViewLayout(); if (viewLayout !== null) view = viewLayout.replaceAll("${view}", view); let html = new Templator(view).renderHtml(vm); const config = Object.assign({ env: ConfigurationManager.getConfig("env") }, vm.config || {}); html = html.replace("", ` `); result = html; profiler?.trace("Request view rendered"); } ctx.body = result; profiler?.trace("Request handling ended"); } private _createRouteArgs(route: RouteInfo, ctx: RouterContext): Array { const queryParams = ctx.query; const pathParams = ctx.params; const model: { [index: string]: any; } = {}; for (const key in queryParams) { const rawValue = queryParams[key]; // Single-valued route params do not accept repeated query keys (e.g. ?id=1&id=2). if (Array.isArray(rawValue)) throw new HttpException(400, `Query parameter '${key}' was provided multiple times.`); const routeParam = route.findRouteParam(key); if (routeParam) { const parsed = routeParam.parseParam(rawValue ?? null); model[routeParam.paramKey] = parsed; (queryParams as Record)[key] = parsed; } else { if (rawValue == null || rawValue.isEmptyOrWhiteSpace() || rawValue.trim().toLowerCase() === "null") (queryParams as Record)[key] = null; } } for (const key in pathParams) { const routeParam = route.findRouteParam(key); if (!routeParam) throw new HttpException(404); const parsed = routeParam.parseParam(pathParams[key]); model[routeParam.paramKey] = parsed; pathParams[key] = parsed; } const result = []; for (const routeParam of route.params) { let value = model[routeParam.paramKey]; if (value === undefined || value === null) { if (!routeParam.isOptional) throw new HttpException(404); value = null; } result.push(value); } return result; } }