designcise/bitframe-fastroute

View on GitHub
src/RouteCollection.php

Summary

Maintainability
B
6 hrs
Test Coverage
A
100%
<?php

/**
 * BitFrame Framework (https://www.bitframephp.com)
 *
 * @author    Daniyal Hamid
 * @copyright Copyright (c) 2017-2022 Daniyal Hamid (https://designcise.com)
 * @license   https://bitframephp.com/about/license MIT License
 */

declare(strict_types=1);

namespace BitFrame\FastRoute;

use BitFrame\FastRoute\Exception\{
    MethodNotAllowedException,
    RouteNotFoundException,
    BadRouteException
};

use function strlen;
use function str_contains;
use function trim;
use function rtrim;
use function is_string;
use function substr;
use function implode;
use function array_map;
use function array_chunk;
use function count;
use function round;
use function ceil;
use function max;
use function sprintf;
use function str_repeat;
use function preg_split;
use function preg_quote;
use function preg_match;
use function preg_match_all;

use const PREG_OFFSET_CAPTURE;
use const PREG_SET_ORDER;

/**
 * Stores parsed routes.
 */
class RouteCollection
{
    private const PLACEHOLDER_REGEX = <<<REGEX
\{
    \s* ([a-zA-Z_][a-zA-Z0-9_-]*) \s*
    (?:
        : \s* ([^{}]*(?:\{(?-1)\}[^{}]*)*)
    )?
\}
REGEX;

    private array $staticRoutes = [];

    private array $variableRoutes = [];

    private array $allowedMethods = [];

    /**
     * Adds a route to the data generator.
     *
     * @param array $methods
     * @param string $routePath
     * @param mixed $handler
     */
    public function add(array $methods, string $routePath, mixed $handler): void
    {
        $routePathTokens = $this->parsePath($routePath);

        foreach ($methods as $method) {
            foreach ($routePathTokens as $path) {
                if ($this->isStaticRoute($path)) {
                    $this->addStaticRoute($method, $path[0], $handler);
                    continue;
                }

                $this->addVariableRoute($method, $path, $handler);
            }
        }
    }

    /**
     * Parses a route path string into multiple route token arrays.
     *
     * @param string $route Route string to parse
     *
     * @return array[] Array of route data arrays
     */
    public function parsePath(string $route): array
    {
        $noClosingOptionals = rtrim($route, ']');
        $numOptionals = strlen($route) - strlen($noClosingOptionals);

        // split on `[` while skipping placeholders
        $pattern = '~' . self::PLACEHOLDER_REGEX . '(*SKIP)(*F) | \[~x';
        $segments = preg_split($pattern, $noClosingOptionals);

        if ($numOptionals !== count($segments) - 1) {
            // if there are any `]` in the middle of the route, throw a more specific error message
            if (
                preg_match('~' . self::PLACEHOLDER_REGEX . '(*SKIP)(*F) | \]~x', $noClosingOptionals)
            ) {
                throw new BadRouteException(
                    'Optional segments (i.e. parts enclosed within `[]`) can only occur at the end of a route'
                );
            }

            throw new BadRouteException("Number of opening '[' and closing ']' do not match");
        }

        $currentRoute = '';
        $routeTokens = [];

        foreach ($segments as $key => $segment) {
            if ($segment === '' && $key !== 0) {
                throw new BadRouteException('Optional segments (i.e. parts enclosed within `[]`) cannot be empty');
            }

            $currentRoute .= $segment;
            $routeTokens[] = self::parseRouteTokens($currentRoute);
        }

        return $routeTokens;
    }

    /**
     * @param string $method
     * @param string $uri
     *
     * @return array
     *
     * @throws MethodNotAllowedException
     * @throws RouteNotFoundException
     */
    public function getRouteData(string $method, string $uri): array
    {
        if (isset($this->staticRoutes[$method][$uri])) {
            return [$this->staticRoutes[$method][$uri], []];
        }

        $routeData = self::getVariableRouteData($this->variableRoutes[$method] ?? [], $method, $uri);
        if (! empty($routeData)) {
            return $routeData;
        }

        // for `HEAD` requests, attempt fallback to `GET`
        if ($method === 'HEAD') {
            return $this->getRouteData('GET', $uri);
        }

        // if nothing else matches, try fallback routes
        if (isset($this->staticRoutes['*'][$uri])) {
            return [$this->staticRoutes['*'][$uri], []];
        }

        $routeData = self::getVariableRouteData($this->variableRoutes['*'] ?? [], '*', $uri);
        if (! empty($routeData)) {
            return $routeData;
        }

        if (count($this->getAllowedMethods($uri))) {
            throw new MethodNotAllowedException($method);
        }

        throw new RouteNotFoundException($uri);
    }

    public function getAllowedMethods(string $uri): array
    {
        if (isset($this->allowedMethods[$uri])) {
            return $this->allowedMethods[$uri];
        }

        $this->allowedMethods[$uri] = [];

        foreach ($this->variableRoutes as $method => $routeData) {
            $result = self::getVariableRouteData($routeData, $method, $uri);
            if (! empty($result)) {
                $this->allowedMethods[$uri][] = $method;
            }
        }

        return $this->allowedMethods[$uri];
    }

    private function isStaticRoute(array $path): bool
    {
        return count($path) === 1 && is_string($path[0]);
    }

    /**
     * Add a static route (i.e. route with no variables).
     *
     * @param string $method
     * @param string $path
     * @param mixed $handler
     *
     * @throws BadRouteException
     */
    private function addStaticRoute(string $method, string $path, mixed $handler): void
    {
        if (isset($this->staticRoutes[$method][$path])) {
            throw new BadRouteException(sprintf(
                'Cannot register two routes matching "%s" for method "%s"',
                $path,
                $method
            ));
        }

        $variableRoutes = $this->variableRoutes[$method] ?? [];

        foreach ($variableRoutes as $route) {
            if (preg_match('~^' . $route['regex'] . '$~', $path)) {
                throw new BadRouteException(sprintf(
                    'Static route "%s" is shadowed by previously defined variable route "%s" for method "%s"',
                    $path,
                    $route['regex'],
                    $method
                ));
            }
        }

        $this->staticRoutes[$method][$path] = $handler;
        $this->allowedMethods[$path] = [...$this->allowedMethods[$path] ?? [], $method];
    }

    /**
     * Add route that consists of variables.
     *
     * @param string $method
     * @param array $pathData
     * @param mixed $handler
     *
     * @throws BadRouteException
     */
    private function addVariableRoute(string $method, array $pathData, mixed $handler): void
    {
        [$regex, $vars] = self::buildRegexForRoute($pathData);

        if (isset($this->variableRoutes[$method][$regex])) {
            throw new BadRouteException(sprintf(
                'Cannot register two routes matching "%s" for method "%s"',
                $regex,
                $method
            ));
        }

        $this->variableRoutes[$method][$regex] = [
            'method' => $method,
            'handler' => $handler,
            'regex' => $regex,
            'vars' => $vars
        ];
    }

    private static function regexHasCapturingGroups(string $regex): bool
    {
        // needs to have at least a ( to contain a capturing group
        return str_contains($regex, '(')
            // semi-accurate detection for capturing groups
            && preg_match(
                '~
                    (?:
                        \(\?\(
                    | \[ [^\]\\\\]* (?: \\\\ . [^\]\\\\]* )* \]
                    | \\\\ .
                    ) (*SKIP)(*FAIL) |
                    \(
                    (?!
                        \? (?! <(?![!=]) | P< | \' )
                    | \*
                    )
                ~x',
                $regex
            );
    }

    /**
     * @param array $routeData
     *
     * @return array
     *
     * @throws BadRouteException
     */
    private static function buildRegexForRoute(array $routeData): array
    {
        $regex = '';
        $vars = [];

        foreach ($routeData as $pathSegment) {
            if (is_string($pathSegment)) {
                $regex .= preg_quote($pathSegment, '~');
                continue;
            }

            [$varName, $regexPart] = $pathSegment;

            if (isset($vars[$varName])) {
                throw new BadRouteException(sprintf(
                    'Cannot use the same placeholder "%s" twice',
                    $varName
                ));
            }

            if (self::regexHasCapturingGroups($regexPart)) {
                throw new BadRouteException(sprintf(
                    'Regex "%s" for parameter "%s" contains a capturing group',
                    $regexPart,
                    $varName
                ));
            }

            $vars[$varName] = $varName;
            $regex .= '(' . $regexPart . ')';
        }

        return [$regex, $vars];
    }

    /**
     * Parse a route string that does not contain optional segments.
     *
     * @param string $routePattern A route pattern with no optional segments.
     *
     * @return array
     */
    private static function parseRouteTokens(string $routePattern): array
    {
        $pattern = '~' . self::PLACEHOLDER_REGEX . '~x';
        // check if any placeholders (i.e. `{}`) exist
        if (! preg_match_all($pattern, $routePattern, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
            return [$routePattern];
        }

        $index = 0;
        $routeTokens = [];

        // placeholders exist?
        foreach ($matches as $match) {
            $offset = $match[0][1];

            if ($offset > $index) {
                $routeTokens[] = substr($routePattern, $index, $offset - $index);
            }

            $routeTokens[] = [
                // path
                $match[1][0],
                // pattern
                isset($match[2][0]) ? trim($match[2][0]) : '[^/]+',
            ];

            $index = $offset + strlen($match[0][0]);
        }

        if ($index !== strlen($routePattern)) {
            $routeTokens[] = substr($routePattern, $index);
        }

        return $routeTokens;
    }

    private static function processRouteChunks(array $routeMap): array
    {
        $routeMapCollection = [];
        $regexes = [];
        $numGroups = 0;

        foreach ($routeMap as $regex => $route) {
            $numVariables = count($route['vars']);
            $numGroups = max($numGroups, $numVariables);

            $regexes[] = $regex . str_repeat('()', $numGroups - $numVariables);
            $routeMapCollection[$numGroups + 1] = [
                'handler' => $route['handler'],
                'vars' => $route['vars']
            ];

            ++$numGroups;
        }

        return [
            'regex' => '~^(?|' . implode('|', $regexes) . ')$~',
            'routeMap' => $routeMapCollection
        ];
    }

    private static function generateVariableRouteData(
        array $routeData,
        string $method,
    ): array {
        if (empty($routeData)) {
            return [];
        }

        $approxChunkSize = 10;
        $count = count($routeData);
        $numParts = max(1, round($count / $approxChunkSize));
        $chunkSize = (int) ceil($count / $numParts);
        $chunks = array_chunk($routeData, $chunkSize, true);

        $data[$method] = array_map([RouteCollection::class, 'processRouteChunks'], $chunks);

        return $data;
    }

    private static function getVariableRouteData(
        array $routeData,
        string $method,
        string $uri,
    ): array {
        $generatedRouteData = self::generateVariableRouteData($routeData, $method);
        $routeMethodData = $generatedRouteData[$method] ?? [];

        foreach ($routeMethodData as $data) {
            if (! preg_match($data['regex'], $uri, $matches)) {
                continue;
            }

            $routeMap = $data['routeMap'][count($matches)];

            $vars = [];
            $i = 0;

            foreach ($routeMap['vars'] as $varName) {
                $vars[$varName] = $matches[++$i];
            }

            return [$routeMap['handler'], $vars];
        }

        return [];
    }
}