brokencube/circuit

View on GitHub
src/Router.php

Summary

Maintainability
A
1 hr
Test Coverage
<?php

namespace Circuit;

use Circuit\Interfaces\Middleware;
use Circuit\Interfaces\ParameterDereferencer;
use Circuit\Interfaces\ExceptionHandler;
use Circuit\Interfaces\Delegate;
use Circuit\Exception;
use FastRoute\Dispatcher;
use Psr\SimpleCache\CacheInterface as Psr16;
use Psr\Cache\CacheItemPoolInterface as Psr6;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Psr\Log\LoggerInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Container\ContainerInterface as Container;

/**
 * Circuit - FastRoute + Middleware
 *
 * An implementation of nikic\FastRoute, with added capability of pre and post route middleware,
 * modelled after PSR-15 but using HTTP Foundation Request/Response objects as well as support for
 * PSR 6 and PSR 16 based caching solutions.
 *
 * @author Nik Barham <nik@brokencube.co.uk>
 */
class Router implements Delegate, LoggerAwareInterface
{
    use LoggerAwareTrait;
    
    const CACHE_KEY = 'routes_v3';
    
    /** @var mixed[] Various options as defined by FastRoute */
    protected $options = [];
    
    /** @var mixed Router Collector - Concrete class defined by options. */
    protected $routeCollection;
    
    /** @var float Timer for debug logging purposes */
    protected $stopwatch;
    
    /** @var CacheWrapper PSR6/16 compatible cache item */
    protected $cache;

    /** @var bool Did we pull results from cache i.e. do we need to call the RouteCollector callback */
    protected $cached = false;

    /** @var ExceptionHandler[] List of exception handlers for particular HTTP codes */
    public $exceptionHandlers = [];
    
    /** @var ExceptionHandler|null Default Exception Handler */
    public $defaultExceptionHandler = null;

    /** @var mixed[] List of arguments passed to Controller constructor */
    protected $controllerArgs = [];

    /** @var Middleware[] List of registered middlewares on this router */
    protected $namedMiddleware = [];
    
    /** @var array List of middlewares to run before matching routes */
    protected $prerouteMiddleware = [null];
    
    /**
     * Create a new Router
     * See https://github.com/nikic/FastRoute for more details
     *
     * @param array $options Option overrides
     * @param Psr16|Psr6 $cache A PSR-6 or PSR-16 compatible Cache object
     */
    public function __construct(array $options = [], $cache = null, LoggerInterface $logger = null)
    {
        $this->options = $options + [
            'routeParser' => 'FastRoute\\RouteParser\\Std',
            'dataGenerator' => 'FastRoute\\DataGenerator\\GroupCountBased',
            'dispatcher' => 'FastRoute\\Dispatcher\\GroupCountBased',
            'routeCollector' => 'Circuit\\RouteCollector',
            'cacheTimeout' => 3600
        ];
        $this->logger = $logger;
        $this->cache = $cache ? new CacheWrapper($cache) : null;
        $this->routeCollection = $this->cache ? $this->cache->get(static::CACHE_KEY) : null;
        
        if ($this->routeCollection) {
            $this->cached = true;
        } else {
            $this->routeCollection = new $this->options['routeCollector'](
                new $this->options['routeParser'], new $this->options['dataGenerator']
            );
        }
    }
    
    /**
     * Define routes using a routerCollector
     * See https://github.com/nikic/FastRoute for more details
     *
     * @param callable $definitionCallback Callback that will define the routes
     * @return self
     */
    public function defineRoutes(callable $definitionCallback)
    {
        if (!$this->cached) {
            $definitionCallback($this->routeCollection);
            // Cache
            if ($this->cache) {
                $this->cache->set(static::CACHE_KEY, $this->routeCollection, $this->options['cacheTimeout']);
            }
        }
        
        $this->dispatcher = new $this->options['dispatcher']($this->routeCollection->getData());
        return $this;
    }
    
    /**
     * Execute a route
     *
     * @param Request $request Request object for current process
     */
    public function run(Request $request)
    {
        $this->stopwatch = microtime(true);
        $starttime = $request->server->get('REQUEST_TIME_FLOAT');
        
        $this->log("Router: ->run() called. Starting clock at REQUEST_TIME+%.2fms", microtime(true) - $starttime);
        try {
            $response = $this->process($request);
        } catch (\Throwable $e) {
            $this->log("Router: Exception");
            $response = $this->handleException($e, $request);
        }
        
        $this->log("Router: Preparing to send response");
        $response->prepare($request);
        $response->send();
        $this->log("Router: Response sent");
    }
    
    /**
     * Process a route
     * Will call pre-route middleware, then match route and execute that route (more middleware + controller)
     *
     * @param Request $request Request object for current process
     * @return Response Response to http request ready for dispatch
     */
    public function process(Request $request) : Response
    {
        // Try and run the next middleware
        $next = next($this->prerouteMiddleware);
        if ($next instanceof Middleware) {
            $this->log("Router: Calling Middleware: %s", get_class($next));
            $response = $next->process($request, $this);
            $this->log("Router: Leaving Middleware: %s", get_class($next));
            return $response;
        } elseif (is_string($next)) {
            $this->log("Router: Calling Middleware: %s", $next);
            $response = $this->getMiddleware($next)->process($request, $this);
            $this->log("Router: Leaving Middleware: %s", $next);
            return $response;
        } else {
            // Null byte poisoning protection
            list($uri) = explode('?', str_replace(chr(0), '', $request->server->get('REQUEST_URI')));
            $dispatch = $this->dispatcher->dispatch($request->server->get('REQUEST_METHOD'), $uri);
            switch ($dispatch[0]) {
                case Dispatcher::NOT_FOUND:
                    $this->log("Router: Route not matched");
                    throw new Exception\NotFound('Router: Route not matched');
                
                case Dispatcher::METHOD_NOT_ALLOWED:
                    $this->log("Router: Method not Allowed");
                    throw new Exception\MethodNotAllowed(
                        $dispatch[1],
                        'Router: Method not Allowed: ' . $request->getMethod()
                    );
                
                case Dispatcher::FOUND:
                    $dispatcher = unserialize($dispatch[1]);
                    $this->log(
                        "Router: Route matched: %s@%s",
                        $dispatcher->controllerClass,
                        $dispatcher->controllerMethod
                    );
                    return $dispatcher->startProcessing($this, $request, $uri, $dispatch[2]);
            }
        }
    }
    
    /**
     * Handle an exception during processing of route.
     *
     * @param Throwable $e The Exception / Error thrown.
     * @param Request $request The request that caused the exception.
     * @return Response The response to the exception (e.g. error page)
     */
    protected function handleException(\Throwable $e, Request $request) : Response
    {
        // Wrap non HTTP exception/errors
        if (!$e instanceof Exception\Exception) {
            $e = new Exception\UncaughtException($e);
        }
        
        // Throw to an appropriate handler
        $code = $e->getStatusCode();
        if ($this->exceptionHandlers[$code] instanceof ExceptionHandler) {
            return $this->exceptionHandlers[$code]->handle($e, $request);
        } elseif ($this->defaultExceptionHandler instanceof ExceptionHandler) {
            return $this->defaultExceptionHandler->handle($e, $request);
        } else {
            return (new \Circuit\ExceptionHandler\DefaultHandler)->handle($e, $request);
        }
    }
    
    /**
     * Set arguments that will be passed to the constructor for any controllers invoked
     *
     * @param Psr\Container\ContainerInterface $container Service container passed to controller constructor
     * @return self
     */
    public function setServiceContainer(Container $container)
    {
        $this->container = $container;
        return $this;
    }

    /**
     * Get arguments that will be passed to the constructor for any controllers invoked
     *
     * @return Psr\Container\ContainerInterface Service container passed to controller constructor
     */
    public function getServiceContainer()
    {
        return $this->container;
    }

    /**
     * Register a middleware against a name.
     * This allows middleware to be created on startup and then refered to in serialised/cached route table
     *
     * @param string $name Unique name for middleware instance
     * @param Middleware $middleware Middleware object
     * @return self
     */
    public function registerMiddleware($name, Middleware $middleware)
    {
        $this->namedMiddleware[$name] = $middleware;
        return $this;
    }
   
    /**
     * Retrieve a middleware by name.
     *
     * @param string $name Name of middleware set by ->registerMiddleware($name)
     * @throws UnexpectedValueException For unrecognised names
     * @return Middleware The referenced middleware instance
     */
    public function getMiddleware($name) : Middleware
    {
        if (!array_key_exists($name, $this->namedMiddleware)) {
            throw new \UnexpectedValueException("No middleware registered under name '{$name}'");
        }
        return $this->namedMiddleware[$name];
    }
    
    /**
     * Add a middleware that will be run before routes are matched
     *
     * @param mixed $middleware Middleware object or named middleware (via ->registerMiddleware($name))
     * @return self
     */
    public function addPrerouteMiddleware($middleware)
    {
        $this->prerouteMiddleware[] = $middleware;
        return $this;
    }

    /**
     * Register an exception handler for a particular HTTP code.
     *
     * @param string $code HTTP code handler is responsible for
     * @param ExceptionHandler $hander Handler
     * @return self
     */
    public function setExceptionHandler($code, ExceptionHandler $handler)
    {
        $this->exceptionHandlers[$code] = $handler;
        return $this;
    }

    /**
     * Register an exception handler for a particular HTTP code.
     *
     * @param string $code HTTP code handler is responsible for
     * @param ExceptionHandler $hander Handler
     * @return self
     */
    public function setDefaultExceptionHandler(ExceptionHandler $handler)
    {
        $this->defaultExceptionHandler = $handler;
        return $this;
    }
    
    /**
     * Log a debug message, and append time elapsed since ->run() was called
     *
     * @param string $message sprintf compatible string
     * @param mixed[] $args Data to pass to sprintf
     * @return self
     */
    public function log($message, ...$args)
    {
        if ($this->logger) {
            $args[] = $this->stopwatch ? microtime(true) - $this->stopwatch : 0;
            $this->logger->debug(sprintf($message . ' (%.2fs)', ...$args));
        }
        return $this;
    }
}