sformisano/jetski-router

View on GitHub
src/RouteStore.php

Summary

Maintainability
B
4 hrs
Test Coverage
<?php
/**
 * Stores routes data and provids methods to retrieve them
 * 
 * @package    JetRouter
 * @subpackage Router
 */

namespace JetRouter;

class RouteStore
{

  /**
   * The namespace regex (standard alphanumeric plus the "_", "-", "." and "/" characters).
   * it also does not allow consecutive "/" characters
   */
  const NAMESPACE_REGEX = '~(?!.*(\/)\1)^([a-zA-Z0-9_\-\.\/])+$~';

  const CHUNK_SIZE = 10;

  /**
   * The status code returned when a route is not found.
   */
  const NOT_FOUND = 0;


  /*** STATIC METHODS ***/

  /**
   * Determines if the route path passed as argument is a static route path.
   *
   * @param  string  $routePath  The route path
   *
   * @return boolean  True if the route passed as argument is a static route path, False otherwise.
   */
  public static function isStaticRoutePath($routePath)
  {
    return strpos($routePath, '{') === false && strpos($routePath, '}') === false;
  }


  /*** PROPERTIES ***/

  private $namespace = '';
  private $staticRoutes = [];
  private $dynamicRoutesData = [];
  private $dynamicRoutes = [];


  /*** PUBLIC METHODS ***/

  /**
   * Parses the namespace and then sets it as object property
   *
   * @param  string  $namespace  The router namespace
   */
  public function __construct($namespace)
  {
    $this->namespace = $this->parseNamespace($namespace);
  }

  /**
   * Adds a route to this object.
   * 
   * Makes sure the route name is unique, then prepends the router namespace
   * to the route path and finally it calls the appropriate add route method
   * depending on route type (static/dynamic).
   *
   * @param  string    $httpMethod  The route's http method
   * @param  string    $routePath   The route's path
   * @param  string    $routeName   The route's name
   * @param  callback  $handler     The handler
   */
  public function addRoute($httpMethod, $routePath, $routeName, $handler)
  {
    $this->validateRouteNameUniqueness($routeName);
    $routePath = $this->preparePath($routePath);

    $add = self::isStaticRoutePath($routePath) ? 'addStaticRoute' : 'addDynamicRoute';

    $this->$add($httpMethod, $routePath, $routeName, $handler);
  }

  /**
   * Looks for a route by request http method and request path.
   * 
   * Tries to establish if it should look for the route at all. If it should, it
   * looks for a static route first, then for a dynamic one if no static route was 
   * found. If a static or dynamic route is found, that route is returned, otherwise
   * the RouteStore::NOT_FOUND status code is returned.
   *
   * @param  string  $requestHttpMethod  The request http method
   * @param  string  $requestPath        The request path
   *
   * @return integer|array  Returns the RouteStore::NOT_FOUND status code (int) if not found, The route array if a route is found
   */
  public function findRouteByRequestMethodAndPath($requestHttpMethod, $requestPath)
  {
    $requestPath = trim($requestPath, ' /');
    
    if( ! $this->shouldLookForRoute($requestPath) ){
      return self::NOT_FOUND;
    }

    $route = $this->findStaticRoute($requestHttpMethod, $requestPath);

    if( ! $route ){
      $route = $this->findDynamicRoute($requestHttpMethod, $requestPath);

      if( ! $route ){
        $route = self::NOT_FOUND;
      }
    }

    return $route;
  }

  /**
   * Looks for a static route by name.
   * 
   * Alias method for getRouteByName method specifying static routes as the
   * routes to be searched.
   *
   * @param  string  $routeName  The route name
   *
   * @return integer|array  Returns the RouteStore::NOT_FOUND status code (int) if not found, The route array if a route is found
   */
  public function getStaticRouteByName($routeName)
  {
    return $this->getRouteByName(
      $this->staticRoutes,
      $routeName
    );
  }

  /**
   * Looks for a dynamc route by name.
   * 
   * Alias method for getRouteByName method specifying dynamc routes as the
   * routes to be searched.
   *
   * @param  string  $routeName  The route name
   *
   * @return integer|array  Returns the RouteStore::NOT_FOUND status code (int) if not found, The route array if a route is found
   */
  public function getDynamicRouteByName($routeName, $args = [])
  {
    return $this->getRouteByName(
      $this->dynamicRoutesData,
      $routeName,
      $args
    );
  }


  /*** PRIVATE METHODS ***/

  /**
   * Parses the router namespace.
   * 
   * Makes sure that if the namespace is not empty it matches the regex 
   * specified in the NAMESPACE_REGEX constant of this class. Trims whitespace
   * and leading/trailing forward slashes. 
   *
   * @param  string  $namespace  The namespace
   *
   * @throws Exception\InvalidNamespaceException  If the namespace is not empty and does not match the namespace regex
   *
   * @return  string  The parsed router namespace
   */
  private function parseNamespace($namespace)
  {
    if( 
      ! empty($namespace) && 
      ! preg_match(self::NAMESPACE_REGEX, $namespace)
    ){
      throw new Exception\InvalidNamespaceException(
        "'$namespace' is not a valid namespace",
        1
      );
    }

    return trim($namespace, ' /');
  }

  /**
   * Makes sure the route name passed as argument is not used for another route already.
   * 
   * Looks for static and dynamic routes already using the route name passed as 
   * argument, throws an Exception if it finds one. Returns not found status code otherwise.
   *
   * @param  $routeName  The route's name
   *
   * @throws Exception\InvalidRouteException  If a route with the route name passed as argument is found
   *
   * @return integer|void  Returns the RouteStore::NOT_FOUND status code (int) if no route is found, void otherwise
   */
  private function validateRouteNameUniqueness($routeName)
  {
    $route = $this->getStaticRouteByName($routeName);

    if($route === self::NOT_FOUND){      
      $route = $this->getDynamicRouteByName($routeName);

      if($route === self::NOT_FOUND){
        return self::NOT_FOUND;
      }
    }

    throw new Exception\InvalidRouteException(
      "A route named '$routeName' already exists."
    );
  }

  /**
   * Adds a static route to this object.
   * 
   * Parses the route data passed to this method as arguments through the
   * StaticRouteParser class, then makes sure no conflicting route already exists
   * and finally it adds the route data to the staticRoutes property of this object.
   *
   * @param  string    $httpMethod  The route's http method
   * @param  string    $routePath   The route's path
   * @param  string    $routeName   The route's name
   * @param  callback  $handler     The route's handler
   *
   * @throws Exception\InvalidRouteException  If a route with the same http method and path already exists
   */
  private function addStaticRoute($httpMethod, $routePath, $routeName, $handler)
  {
    $route = new StaticRouteParser($httpMethod, $routePath, $routeName, $handler);
    $httpMethod = $route->getHttpMethod();
    $routePath = $route->getPath();
    $routeName = $route->getName();
    $handler = $route->getHandler();

    if ( isset( $this->staticRoutes[$routePath][$httpMethod]) ){
      throw new Exception\InvalidRouteException(
        "Cannot register two routes matching '$routePath' for method '$httpMethod'"
      );
    }

    $this->staticRoutes[$routePath][$httpMethod] = [
      'routeName' => $routeName,
      'routeHandler' => $handler,
      'routeArgs' => []
    ];
  }

  /**
   * Adds a dynamic route.
   *
   * @param      <type>                           $httpMethod  The http method
   * @param      <type>                           $routePath   The route path
   * @param      <type>                           $routeName   The route name
   * @param      <type>                           $handler     The handler
   *
   * @throws     Exception\InvalidRouteException  (description)
   */
  private function addDynamicRoute($httpMethod, $routePath, $routeName, $handler)
  {
    $route = new DynamicRouteParser($httpMethod, $routePath, $routeName, $handler);

    $regex = $route->getRegex();
    $paramsNames = $route->getParamsNames();
    $segments = $route->getSegments();

    if( isset( $this->dynamicRoutesData[$regex][$httpMethod] ) ){
       throw new Exception\InvalidRouteException("Cannot register two routes matching '$regex' for method '$httpMethod'");
    }

    $this->dynamicRoutesData[$regex][$httpMethod] = [
      'routeName' => $routeName,
      'routeHandler' => $handler,
      'routeArgs' => $paramsNames,
      'routeSegments' => $segments
    ];
  }

  /**
   * Parses the dynamic route data and generates the dynamic routes.
   * 
   * Explanation of the group position based, non chunked dispatching 
   * implementation adopted by this router:
   * 
   * The router splits the dynamic routes data array in chunks whose size is 
   * defined in the CHUNK_SIZE constant of this class. It then iterates
   * over the chunks to merge all the regexes in the chunk into a single
   * regex containing the regex expressions in a single OR group.
   * 
   * The PCRE regex "?|" non-capturing group type is used to avoid ending
   * up with a massive amount of unneeded capturing groups (bad performance).
   * However, doing this also means losing separate group numbers which would 
   * have allowed us to know which of the regexes in the OR group matched.
   * 
   * This is solved by adding dummy groups to each individual route, making the
   * matches size of each regex unique.
   * 
   * This unique number is also then used to map each of the regexes in the 
   * OR group to its route data (handlers) in the routeMap array, i.e. the 
   * unique number is used as index in the routeMap array.
   * 
   * This regexes OR merge and the subsequent procedure outline above 
   * is repeated for each chunk of routes.
   * 
   * @return  boolean  Returns False if the dynamic route data array is empty
   */
  private function generateDynamicRoutes()
  {
    $dynamicRoutesN = count($this->dynamicRoutesData);

    if( ! $dynamicRoutesN ){
      return false;
    }

    $partsN = max(1, round( $dynamicRoutesN / self::CHUNK_SIZE ) );
    $chunkSize = ceil($dynamicRoutesN / $partsN);
    $chunks = array_chunk( $this->dynamicRoutesData, $chunkSize, true);
    $dynamicRoutes = [];

    foreach($chunks as $chunk){
      $routeMap = [];
      $regexes = [];
      $groupsN = 0;

      foreach($chunk as $regex => $routes){
        $firstRoute = reset($routes);
        $variablesN = count($firstRoute['routeArgs']);
        $groupsN = max($groupsN, $variablesN);

        $regexes[] = $regex . str_repeat('()', $groupsN - $variablesN);

        foreach ($routes as $httpMethod => $route) {
          $routeMap[$groupsN + 1][$httpMethod] = $route;
        }

        $groupsN++;
      }

      $regex = '~^(?|' . implode('|', $regexes) . ')$~';

      $dynamicRoutes[] = [ 'routeRegex' => $regex, 'routeMap' => $routeMap ];
    }

    $this->dynamicRoutes = $dynamicRoutes;
  }

  /**
   * Looks for a static route by http method and request path
   *
   * @param  string  $httpMethod   The http method
   * @param  string  $requestPath  The request path
   *
   * @return array|boolean  Returns the route array if the route is found, False otherwise
   */
  private function findStaticRoute($httpMethod, $requestPath)
  {
    if( ! isset($this->staticRoutes[$requestPath][$httpMethod]) ){
      return false;
    }

    return $this->staticRoutes[$requestPath][$httpMethod];
  }

  /**
   * Looks for a dynamic route by http method and request path.
   * 
   * Generates the dynamic routes chunks from the dynamic routes data. This is
   * done only at "find" time to avoid doing this work if a static route matched
   * the request we are trying to route.
   * 
   * If both route regex match and http method handler are found, arguments are
   * extracted from the route path, added to the route array, and the route array
   * is then returned.
   *
   * @param  string  $httpMethod   The http method
   * @param  string  $requestPath  The request path
   *
   * @return array|boolean  Returns the route array if the route is found, False otherwise
   */
  private function findDynamicRoute($httpMethod, $requestPath)
  {
    $this->generateDynamicRoutes();

    foreach($this->dynamicRoutes as $data){
      if ( ! preg_match( $data['routeRegex'], $requestPath, $matches ) ){
        continue;
      }

      $matchesN = count($matches);

      while( ! isset( $data['routeMap'][$matchesN++] ) );

      $routes = $data['routeMap'][$matchesN - 1];

      foreach(array_keys($routes[$httpMethod]['routeArgs']) as $i => $varName){
        if( ! isset($matches[$i + 1]) || $matches[$i + 1] === '' ){
          unset($routes[$httpMethod]['routeArgs'][$i]);
          continue;
        }

        $routes[$httpMethod]['routeArgs'][$varName] = $matches[$i + 1];
      }

      return $routes[$httpMethod];
    }

    return false;
  }

  /**
   * Looks for a route by name.
   * 
   * Loops over the routes passed as argument and if one with the same name is
   * found, it does some parsing and it returnes the route data array.
   *
   * @param  array   $routes     The routes array
   * @param  string  $routeName  The name of the route to find
   * @param  array   $args       The arguments to pass to the route data array (dynamic routes only)
   *
   * @return  array|integer   The route data array if a route is found, RouteStore::NOT_FOUND status code (int) otherwise
   */
  private function getRouteByName($routes, $routeName, $args = null)
  {
    foreach($routes as $routePath => $httpHandlers){
      foreach($httpHandlers as $httpHandler){
        if( $httpHandler['routeName'] === $routeName ){
          $route = [ 
            'routeName' => $routeName, 
            'routePath' => $routePath,
            'routeArgs' => $args
          ];

          // only defined in dynamic routes
          if( isset($httpHandler['routeSegments']) ){
            $routeSegments = $httpHandler['routeSegments'];
            $route['routeSegments'] = $routeSegments;
          }

          return $route;
        }
      }
    }

    return self::NOT_FOUND;
  }

  /**
   * Determines if the RouteStore object needs to look for a route matching 
   * the request path passed as argument.
   *
   * @param  string  $requestPath  The request path
   *
   * @return boolean  The boolean result
   */
  private function shouldLookForRoute($requestPath)
  {
    return ! $this->namespace || 0 === strpos($requestPath, $this->namespace);
  }

  /**
   * Returns the route path passed as argument with the router namespace prepended to it
   *
   * @param  string  $routePath  The route path
   *
   * @return string  The route path with the prepended router namespace
   */
  private function preparePath($routePath)
  {
    $routePath = trim($routePath, ' /');

    if( ! $this->namespace ){
      return $routePath;
    }
    
    return $this->namespace . '/' . $routePath;
  }
  
}