import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, NotImplementedError, Route, RouteMethod, ServerError, type ServerErrorResponse, UnauthorizedError, ZodValidationError, } from "@spec/relay"; import { treeifyError } from "zod"; import { logger } from "~libraries/logger/mod.ts"; import { getRequestContext } from "./context.ts"; import { req } from "./request.ts"; const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; export class Api { readonly #index = new Map(); /** * Route maps funneling registered routes to the specific methods supported by * the relay instance. */ readonly routes: Routes = { POST: [], GET: [], PUT: [], PATCH: [], DELETE: [], }; /** * List of paths in the '${method} ${path}' format allowing us to quickly throw * errors if a duplicate route path is being added. */ readonly #paths = new Set(); /** * Instantiate a new Api instance. * * @param routes - Initial list of routes to register with the api. */ constructor(routes: Route[] = []) { this.register(routes); } /** * Register relays with the API instance allowing for decoupled registration * of server side handling of relay contracts. * * @param routes - Relays to register with the instance. */ register(routes: Route[]): this { const methods: (keyof typeof this.routes)[] = []; for (const route of routes) { const path = `${route.method} ${route.path}`; if (this.#paths.has(path)) { throw new Error(`Router > Path ${path} already exists`); } this.#paths.add(path); this.routes[route.method].push(route); methods.push(route.method); this.#index.set(`${route.method} ${route.path}`, route); } for (const method of methods) { this.routes[method].sort(byStaticPriority); } return this; } /** * Executes request and returns a `Response` instance. * * @param request - REST request to pass to a route handler. */ async fetch(request: Request): Promise { const url = new URL(request.url); // ### Route // Locate a route matching the incoming request method and path. const resolved = this.#getResolvedRoute(request.method, url.pathname); if (resolved === undefined) { return toResponse( new NotFoundError(`Invalid routing path provided for ${request.url}`, { method: request.method, url: request.url, }), request, ); } // ### Handle // Execute request and return a response. const response = await this.#getRouteResponse(resolved, request).catch((error) => this.#getErrorResponse(error, resolved.route, request), ); return response; } /** * Attempt to resolve a route based on the given method and pathname. * * @param method - HTTP method. * @param url - HTTP request url. */ #getResolvedRoute(method: string, url: string): ResolvedRoute | undefined { assertMethod(method); for (const route of this.routes[method]) { if (route.match(url) === true) { return { route, params: route.getParsedParams(url) }; } } } /** * Resolve the request on the given route and return a `Response` instance. * * @param resolved - Route and paramter details resolved for the request. * @param request - Request instance to resolve. */ async #getRouteResponse({ route, params }: ResolvedRoute, request: Request): Promise { const url = new URL(request.url); // ### Args // Arguments is passed to every route handler and provides a suite of functionality // and request data. const args: any[] = []; // ### Input // Generate route input which contains a map fo params, query, and/or body. If // none of these are present then the input is not added to the final argument // context of the handler. const input: { params?: object; query?: object; body?: unknown; } = { params: undefined, query: undefined, body: undefined, }; // ### Access // Check the access requirements of the route and run any additional checks // if nessesary before proceeding with further request handling. // 1. All routes needs access assignment, else we consider it an internal error. // 2. If access requires a session we throw Unauthorized if the request is not authenticated. // 3. If access is an array of access resources, we check that each resources can be // accessed by the request. if (route.state.access === undefined) { return toResponse( new InternalServerError(`Route '${route.method} ${route.path}' is missing access assignment.`), request, ); } if (route.state.access === "authenticated" && req.isAuthenticated === false) { return toResponse(new UnauthorizedError(), request); } if (Array.isArray(route.state.access)) { for (const hasAccess of route.state.access) { if (hasAccess() === false) { return toResponse(new ForbiddenError(), request); } } } // ### Params // If the route has params we want to coerce the values to the expected types. if (route.state.params !== undefined) { const result = await route.state.params.safeParseAsync(params); if (result.success === false) { return toResponse(new ZodValidationError("Invalid request params", treeifyError(result.error)), request); } input.params = result.data; } // ### Query // If the route has a query schema we need to validate and parse the query. if (route.state.query !== undefined) { const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {}); if (result.success === false) { return toResponse(new ZodValidationError("Invalid request query", treeifyError(result.error)), request); } input.query = result.data; } // ### Body // If the route has a body schema we need to validate and parse the body. if (route.state.body !== undefined) { const body = await this.#getRequestBody(request); const result = await route.state.body.safeParseAsync(body); if (result.success === false) { return toResponse(new ZodValidationError("Invalid request body", treeifyError(result.error)), request); } input.body = result.data; } if (input.params !== undefined || input.query !== undefined || input.body !== undefined) { args.push(input); } // ### Context // Request context pass to every route as the last argument. args.push(getRequestContext(request)); // ### Handler // Execute the route handler and apply the result. if (route.state.handle === undefined) { return toResponse(new NotImplementedError(`Path '${route.method} ${route.path}' is not implemented.`), request); } return toResponse(await route.state.handle(...args), request); } #getErrorResponse(error: unknown, route: Route, request: Request): Response { if (route?.state.hooks?.onError !== undefined) { return route.state.hooks.onError(error); } if (error instanceof ServerError) { return toResponse(error, request); } logger.error(error); if (error instanceof Error) { return toResponse(new InternalServerError(error.message), request); } return toResponse(new InternalServerError(), request); } /** * Resolves request body and returns it. * * @param request - Request to resolve body from. * @param files - Files to populate if present. */ async #getRequestBody(request: Request): Promise> { let body: Record = {}; const type = request.headers.get("content-type"); if (!type || request.method === "GET") { return body; } if (type.includes("json")) { body = await request.json(); } if (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data")) { try { const formData = await request.formData(); for (const [name, value] of Array.from(formData.entries())) { body[name] = value; } } catch (error) { logger.error(error); throw new BadRequestError(`Malformed FormData`, { error }); } } return body; } } /* |-------------------------------------------------------------------------------- | Helpers |-------------------------------------------------------------------------------- */ /** * Assert that the given method string is a valid routing method. * * @param candidate - Method candidate. */ function assertMethod(candidate: string): asserts candidate is RouteMethod { if (!SUPPORTED_MEHODS.includes(candidate)) { throw new Error(`Router > Unsupported method '${candidate}'`); } } /** * Sorting method for routes to ensure that static properties takes precedence * for when a route is matched against incoming requests. * * @param a - Route A * @param b - Route B */ function byStaticPriority(a: Route, b: Route) { const aSegments = a.path.split("/"); const bSegments = b.path.split("/"); const maxLength = Math.max(aSegments.length, bSegments.length); for (let i = 0; i < maxLength; i++) { const aSegment = aSegments[i] || ""; const bSegment = bSegments[i] || ""; const isADynamic = aSegment.startsWith(":"); const isBDynamic = bSegment.startsWith(":"); if (isADynamic !== isBDynamic) { return isADynamic ? 1 : -1; } if (isADynamic === false && aSegment !== bSegment) { return aSegment.localeCompare(bSegment); } } return a.path.localeCompare(b.path); } /** * Resolve and return query object from the provided search parameters, or undefined * if the search parameters does not have any entries. * * @param searchParams - Search params to create a query object from. */ function toQuery(searchParams: URLSearchParams): object | undefined { if (searchParams.size === 0) { return undefined; } const result: Record = {}; for (const [key, value] of searchParams.entries()) { result[key] = value; } return result; } /** * Takes a server side request result and returns a fetch Response. * * @param result - Result to send back as a Response. * @param request - Request instance. */ export function toResponse(result: unknown, request: Request): Response { const method = request.method; if (result instanceof Response) { if (method === "HEAD") { return new Response(null, { status: result.status, statusText: result.statusText, headers: new Headers(result.headers), }); } return result; } if (result instanceof ServerError) { const body = JSON.stringify({ error: { status: result.status, message: result.message, data: result.data, }, } satisfies ServerErrorResponse); return new Response(method === "HEAD" ? null : body, { statusText: result.message || "Internal Server Error", status: result.status || 500, headers: { "content-type": "application/json", }, }); } const body = JSON.stringify({ data: result ?? null, }); return new Response(method === "HEAD" ? null : body, { status: 200, headers: { "content-type": "application/json", }, }); } /* |-------------------------------------------------------------------------------- | Types |-------------------------------------------------------------------------------- */ type Routes = { POST: Route[]; GET: Route[]; PUT: Route[]; PATCH: Route[]; DELETE: Route[]; }; type ResolvedRoute = { route: Route; params: any; };