Router.js

/**
 *  lambda-lambda-lambda/router
 *  AWS Lambda@Edge serverless application router.
 *
 *  Copyright 2021-2023, Marc S. Brooks (https://mbrooks.info)
 *  Licensed under the MIT license:
 *  http://www.opensource.org/licenses/mit-license.php
 */

'use strict';

const fs   = require('fs');
const path = require('path');

// Local modules.
const Request  = require('./router/Request');
const Response = require('./router/Response');
const Route    = require('./router/Route');
const Stack    = require('./router/Stack');

const {
  isPromise,
  isValidFunc,
  isValidPath,
  isValidRoute,
  moduleParent,
  setFuncName
} = require('./router/Utils');

// Global variables.
global.APP_ROOT = process.env.LAMBDA_TASK_ROOT || `${process.cwd()}/src`;

/**
 * Provides HTTP request/response handling.
 */
class Router {

  /**
   * @param {CloudFrontRequest} request
   *   CloudFront request object.
   *
   * @param {CloudFrontResponse|undefined} response
   *   CloudFront response object (optional).
   *
   * @example
   * exports.handler = (event, context, callback) => {
   *   const {request, response} = event.Records[0].cf;
   *
   *   const router = new Router(request, response);
   *
   *   callback(null, router.response());
   * };
   *
   *   ..
   *
   * exports.handler = async (event) => {
   *   const {request, response} = event.Records[0].cf;
   *
   *   const router = new Router(request, response);
   *
   *   return await router.response();
   * };
   */
  constructor(request, response) {
    this.req   = new Request (request);
    this.res   = new Response(response);
    this.stack = new Stack();

    this.prefix = '';
  }

  /**
   * Return CloudFront response object.
   *
   * @return {CloudFrontResponse|Promise<CloudFrontResponse>}
   */
  response() {
    loadRoutes(this);

    const result = this.stack.exec(this.req, this.res);

    if (isPromise(result)) {
      return result.then(() => this.res.data());
    }

    return this.res.data();
  }

  /**
   * Handle the Route/Middleware request (add to stack).
   *
   * @param {String} path
   *   Request URI.
   *
   * @param {Function} func
   *   Route/Middleware function.
   */
  handle(path, func) {
    let uri = `${this.prefix}${path}`;

    if (isValidRoute(this.req.uri(), uri, func)) {
      if (!func.name) {
        setFuncName(func, 'route::undefined');
      }

      this.stack.add(func);
    }
  }

  /**
   * Load the Route (e.g. Middleware) handler.
   *
   * @param {Function|String} arg
   *   Route/Middleware or Request URI.
   *
   * @param {Function} func
   *   Route/Middleware function (optional).
   *
   * @example
   * // Include function as middleware.
   * const Middleware = require('./path/to/middleware');
   *
   * router.use(Middleware);
   *
   *   ..
   *
   *
   * // Run function for every request.
   * router.use(function(req, res, next) {
   *   if (req.method() === 'POST') {
   *     res.status(405).send();
   *   } else {
   *     next();
   *   }
   * });
   *
   *   ..
   *
   * // Run function on URI path only.
   * router.use('/api/test', function(req, res) {
   *   res.setHeader('Content-Type', 'text/html');
   *   res.status(200).send('Hello World');
   * });
   */
  use(arg, func) {

    // Route middleware handler.
    if (isValidPath(arg) && isValidFunc(func)) {
      setFuncName(func, `middleware:${arg}`);

      this.handle(arg, func);
    } else

    // General middleware.
    if (isValidFunc(arg)) {
      if (arg.length === 3 && !arg.name) {
        setFuncName(arg, 'middleware');
      }

      this.stack.add(arg);
    }
  }

  /**
   * Set URI path prefix.
   *
   * @param {String} value
   *   Request URI.
   *
   * @example
   * router.setPrefix('/api');
   */
  setPrefix(value) {
    if (isValidPath(value) && value !== '/') {
      this.prefix = value;
    }
  }

  /**
   * Set router fallback (default route).
   *
   * @param {Function} route
   *   Route function.
   *
   * @example
   * router.default(function(req, res) {
   *   res.status(404).send();
   * });
   */
  default(route) {
    if (isValidFunc(route)) {
      const func = (req, res, next) => {
        route(req, res, next);
      };

      setFuncName(func, 'fallback');

      this.use(func);
    }
  }

  /**
   * Handle HTTP GET requests.
   *
   * @param {String} path
   *   Request URI.
   *
   * @param {Function} route
   *   Route function.
   *
   * @example
   * router.get('/api/test', function(req, res) {
   *   res.setHeader('Content-Type', 'text/html');
   *   res.status(200).send('Hello World');
   * });
   */
  get(path, route) {
    if (this.req.method() === 'GET' && isValidFunc(route)) {
      this.handle(path, route);
    }
  }

  /**
   * Handle HTTP POST requests.
   *
   * @param {String} path
   *   Request URI.
   *
   * @param {Function} route
   *   Route function.
   *
   * @example
   * router.post('/api/test', function(req, res) {
   *   res.status(201).send();
   * });
   */
  post(path, route) {
    if (this.req.method() === 'POST' && isValidFunc(route)) {
      this.handle(path, route);
    }
  }

  /**
   * Handle HTTP PUT requests.
   *
   * @param {String} path
   *   Request URI.
   *
   * @param {Function} route
   *   Route function.
   *
   * @example
   * router.put('/api/test', function(req, res) {
   *   res.status(201).send();
   * });
   */
  put(path, route) {
    if (this.req.method() === 'PUT' && isValidFunc(route)) {
      this.handle(path, route);
    }
  }

  /**
   * Handle HTTP PATCH requests.
   *
   * @param {String} path
   *   Request URI.
   *
   * @param {Function} route
   *   Route function.
   *
   * @example
   * router.patch('/api/test', function(req, res) {
   *   res.status(204).send();
   * });
   */
  patch(path, route) {
    if (this.req.method() === 'PATCH' && isValidFunc(route)) {
      this.handle(path, route);
    }
  }

  /**
   * Handle HTTP DELETE requests.
   *
   * @param {String} path
   *   Request URI.
   *
   * @param {Function} route
   *   Route function.
   *
   * @example
   * router.delete('/api/test', function(req, res) {
   *   res.status(200).send();
   * });
   */
  delete(path, route) {
    if (this.req.method() === 'DELETE' && isValidFunc(route)) {
      this.handle(path, route);
    }
  }
};

/**
 * Load routes from a pre-configured directory.
 *
 * @param {Router} router
 *   Router instance.
 */
function loadRoutes(router) {
  const routeDir = moduleParent() + '/routes';

  const files = getRoutes(routeDir);

  files.forEach(file => {
    file = path.relative(routeDir, file);
    const {dir, name} = path.parse(file);

    const filePath = [dir, name].join('/');
    const route = require(`${routeDir}/${filePath}`);

    route.path = (
      filePath[0] === '/' ? filePath : `/${filePath}`
    ).toLowerCase();

    Route(router, route);
  });
}

/**
 * Return list of route files for a given directory.
 *
 * @param {String} dir
 *   Files directory.
 *
 * @param {Array} files
 *   List of files (optional).
 *
 * @return {Array<String>}
 */
function getRoutes(dir, files = []) {
  fs.readdirSync(dir).forEach(function(file) {
    const filePath = path.join(dir, file);

    if (fs.lstatSync(filePath).isDirectory()) {

      // Perform recursive traversal.
      getRoutes(filePath, files);

    } else /* istanbul ignore next */ if (path.extname(filePath) === '.js') {
      files.push(filePath);
    }
  });

  return files;
}

module.exports = Router;