UNPKG

burger-api

Version:

<p align="center"> <img src="https://github.com/user-attachments/assets/0d9b376e-1d89-479a-aa7f-e7ee3c6b2342" alt="BurgerAPI logo"/> </p>

230 lines (229 loc) 9.9 kB
// Import stuff from node import { readdir } from 'fs/promises'; import * as path from 'path'; // Import utils import { cleanPrefix, normalizePath, ROUTE_CONSTANTS, HTTP_METHODS, } from '../utils/index.js'; /** * ApiRouter class for handling file-based routing. * Loads routes from a directory structure and matches requests to the appropriate route handlers. * Supports dynamic segments (e.g., [id]) and HTTP method handlers. * Routes are sorted to prioritize static routes over dynamic ones to prevent overlapping route issues. */ export class ApiRouter { routesDir; prefix; /** The root of the trie where all routes will begin*/ root = { children: new Map() }; /** * Constructor for the ApiRouter class. * @param routesDir The directory path where route modules are located. * @param prefix Optional prefix to prepend to all routes (e.g., "api" becomes "/api/..."). * @throws {Error} If the routes directory path is not provided. */ constructor(routesDir, prefix = '') { this.routesDir = routesDir; this.prefix = prefix; if (!routesDir) { throw new Error('Routes directory path is required'); } } /** * Getter for the routes trie. * @returns The root of the trie. */ get routes() { return this.root; } /** * Loads all routes from the routes directory into the trie structure. * @returns {Promise<void>} A promise that resolves when all routes are loaded. * @throws {Error} If the routes directory is not found or if an error occurs during route loading. */ async loadRoutes() { try { await this.scanDirectory(this.routesDir); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to load routes: ${errorMessage}`); } } /** * Inserts a route definition into the trie structure. * @param routeDef The route definition to insert, containing path, handlers, middleware, schema, and OpenAPI metadata. * @throws {Error} If the route definition is invalid or causes a conflict in the trie. */ insertRoute(routeDef) { let node = this.root; const segments = routeDef.path.split('/').filter(Boolean); for (const segment of segments) { if (segment.startsWith(':')) { if (!node.paramChild) { node.paramChild = { children: new Map() }; } node = node.paramChild; node.paramName = segment.slice(1); } else { if (!node.children.has(segment)) { node.children.set(segment, { children: new Map() }); } node = node.children.get(segment); } } node.route = routeDef; } /** * Recursively scans a directory for route modules (route.ts files) and loads them into the trie. * @param dir The current directory path to scan. * @param basePath The base path used to construct route paths from the directory structure. * @returns {Promise<void>} A promise that resolves when the directory scan is complete. * @throws {Error} If multiple dynamic folders are found at the same level or if directory access fails. */ async scanDirectory(dir, basePath = '') { // Track if a dynamic folder has been found at this directory level let dynamicFolderFound = false; try { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dir, entry.name); const relativePath = path.join(basePath, entry.name); if (entry.isDirectory()) { // Handle dynamic directories (e.g., [id]) if (entry.name.startsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_START) && entry.name.endsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_END)) { if (dynamicFolderFound) { throw new Error(`Multiple dynamic route folders found in the same directory: '${entry.name}' conflicts with another dynamic folder in '${dir}'.`); } dynamicFolderFound = true; } await this.scanDirectory(entryPath, relativePath); } else if (entry.isFile() && entry.name === 'route.ts') { await this.loadRouteModule(entryPath, relativePath); } } } catch (error) { if (error instanceof Error) { throw error; // Re-throw specific errors we created } throw new Error(`Failed to scan directory '${dir}': ${String(error)}`); } } /** * Loads a route module from a file and inserts it into the trie. * @param entryPath The full file system path to the route module (route.ts). * @param relativePath The relative path used to construct the route path. * @returns {Promise<void>} A promise that resolves when the module is loaded and inserted. * @throws {Error} If the route module fails to load or is invalid. */ async loadRouteModule(entryPath, relativePath) { try { // Convert the file path to a route path const routePath = this.convertFilePathToRoute(relativePath); console.info('Loading route:', routePath); // Get the module path like "D:\\Projects\\src\\api\\users\\route.ts" const modulePath = path.resolve(entryPath); // Import the module const routeModule = await import(modulePath); // Variable to store the handlers for each method const handlers = {}; // Loop through all the HTTP methods and add the handler to the handlers object for (const method of HTTP_METHODS) { if (typeof routeModule[method] === 'function') { handlers[method] = routeModule[method]; } } const routeDef = { path: routePath, handlers, middleware: routeModule.middleware, schema: routeModule.schema, openapi: routeModule.openapi, }; this.insertRoute(routeDef); } catch (error) { throw new Error(`Failed to load route module '${entryPath}': ${String(error)}`); } } /** * Converts a file path to a route path by processing dynamic segments and applying the prefix. * @param filePath The file path to convert (e.g., "users/[id]/route.ts"). * @returns {string} The converted route path (e.g., "/users/:id"). */ convertFilePathToRoute(filePath) { // Remove the "route.ts" suffix if (filePath.endsWith('route.ts')) { filePath = filePath.slice(0, -'route.ts'.length); } // Split path into segments const segments = filePath.split(path.sep); const resultSegments = []; for (let segment of segments) { if (!segment) continue; // Skip empty segments // Skip grouping segments (e.g., (group)) if (segment.startsWith(ROUTE_CONSTANTS.GROUPING_FOLDER_START) && segment.endsWith(ROUTE_CONSTANTS.GROUPING_FOLDER_END)) continue; // Convert dynamic segments from [param] to :param if (segment.startsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_START) && segment.endsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_END)) { const param = segment.slice(1, -1); resultSegments.push(ROUTE_CONSTANTS.DYNAMIC_SEGMENT_PREFIX + param); } else { resultSegments.push(segment); } } // Construct the route with leading slash let route = '/' + resultSegments.join('/'); // Apply prefix if provided if (this.prefix) { const cleanPrefixStr = cleanPrefix(this.prefix); route = '/' + cleanPrefixStr + route; } // Remove trailing slash unless it's the root if (route !== '/' && route.endsWith('/')) { route = route.slice(0, -1); } return route; } /** * Resolves a request to a route definition and its parameters. * @param pathname The normalized request pathname. * @param method The uppercase HTTP method. * @returns {{ route?: RouteDefinition; params: Record<string, string> }} An object containing the matched route (if any) and extracted parameters. * @throws {Error} If the request URL is malformed. */ resolve(pathname, method) { try { const segments = pathname.split('/').filter(Boolean); let node = this.root; const params = {}; for (const segment of segments) { if (node.children.has(segment)) { node = node.children.get(segment); } else if (node.paramChild) { node = node.paramChild; params[node.paramName] = segment; } else { return { params: {} }; } } if (node.route && node.route.handlers[method]) { return { route: node.route, params }; } return { params: {} }; } catch (error) { // Log the error for better debugging console.error(`Error resolving API route for path "${pathname}":`, error); return { params: {} }; } } }