divineniiquaye/flight-routing

View on GitHub
src/Traits/PrototypeTrait.php

Summary

Maintainability
F
3 days
Test Coverage
A
100%
<?php declare(strict_types=1);

/*
 * This file is part of Flight Routing.
 *
 * PHP version 8.0 and above required
 *
 * @author    Divine Niiquaye Ibok <divineibok@gmail.com>
 * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/)
 * @license   https://opensource.org/licenses/BSD-3-Clause License
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Flight\Routing\Traits;

use Flight\Routing\Exceptions\{InvalidControllerException, UriHandlerException};
use Flight\Routing\Handlers\ResourceHandler;

/**
 * A trait providing route method prototyping.
 *
 * @author Divine Niiquaye Ibok <divineibok@gmail.com>
 */
trait PrototypeTrait
{
    protected int $defaultIndex = -1;
    protected bool $asRoute = false, $sorted = false;

    /** @var array<string,mixed> */
    protected array $prototypes = [];

    /** @var array<int,array<string,mixed>> */
    protected array $routes = [];

    /** @var array<int,self> */
    protected array $groups = [];

    /**
     * Set route's data by calling supported route method in collection.
     *
     * @param array<string,mixed>|true $routeData An array is a list of route method bindings
     *                                            Else if true, route bindings can be prototyped
     *                                            to all registered routes
     *
     * @return $this
     *
     * @throws \InvalidArgumentException if route not defined before calling this method
     */
    public function prototype(array|bool $routeData): self
    {
        if (true === $routeData) {
            $this->asRoute = false;

            return $this;
        }

        foreach (\array_filter($routeData) as $routeMethod => $arguments) {
            $this->{$routeMethod}(...(!\is_array($arguments) || (\count($arguments) > 1 && !\array_is_list($arguments)) ? [$arguments] : $arguments));
        }

        return $this;
    }

    /**
     * Ending of group chaining stack. (use with caution!).
     *
     * RISK: This method can break the collection, call this method
     * only on the last route of a group stack which the $return parameter
     * of the group method is set true.
     *
     * @return $this
     */
    public function end(): self
    {
        return $this->parent ?? $this;
    }

    /**
     * Set the route's path.
     *
     * @return $this
     *
     * @throws \InvalidArgumentException if you is not set
     */
    public function path(string $pattern): self
    {
        if (!$this->asRoute) {
            throw new \InvalidArgumentException('Cannot use the "path()" method if route not defined.');
        }

        if (1 === \preg_match(static::RCA_PATTERN, $pattern, $matches, \PREG_UNMATCHED_AS_NULL)) {
            isset($matches[1]) && $this->routes[$this->defaultIndex]['schemes'][$matches[1]] = true;

            if (isset($matches[2])) {
                if ('/' !== ($matches[3][0] ?? '')) {
                    throw new UriHandlerException(\sprintf('The route pattern "%s" is invalid as route path must be present in pattern.', $pattern));
                }
                $this->routes[$this->defaultIndex]['hosts'][$matches[2]] = true;
            }

            if (isset($matches[5])) {
                $handler = $matches[4] ?? $this->routes[$this->defaultIndex]['handler'] ?? null;
                $this->routes[$this->defaultIndex]['handler'] = !empty($handler) ? [$handler, $matches[5]] : $matches[5];
            }

            \preg_match(static::PRIORITY_REGEX, $pattern = $matches[3], $m, \PREG_UNMATCHED_AS_NULL);
            $this->routes[$this->defaultIndex]['prefix'] = $m[1] ?? null;
        }

        $this->routes[$this->defaultIndex]['path'] = $pattern;

        return $this;
    }

    /**
     * Set the route's unique name identifier,.
     *
     * @return $this
     *
     * @throws \InvalidArgumentException if you is not set
     */
    public function bind(string $routeName): self
    {
        if (!$this->asRoute) {
            throw new \InvalidArgumentException('Cannot use the "bind()" method if route not defined.');
        }
        $this->routes[$this->defaultIndex]['name'] = $routeName;

        return $this;
    }

    /**
     * Set the route's handler.
     *
     * @param mixed $to PHP class, object or callable that returns the response when matched
     *
     * @return $this
     *
     * @throws \InvalidArgumentException if you is not set
     */
    public function run(mixed $to): self
    {
        if (!$this->asRoute) {
            throw new \InvalidArgumentException('Cannot use the "run()" method if route not defined.');
        }

        if (!empty($namespace = $this->routes[$this->defaultIndex]['namespace'] ?? null)) {
            unset($this->routes[$this->defaultIndex]['namespace']);
        }
        $this->routes[$this->defaultIndex]['handler'] = $this->resolveHandler($to, $namespace);

        return $this;
    }

    /**
     * Set the route(s) default value for it's placeholder or required argument.
     *
     * @return $this
     */
    public function default(string $variable, mixed $default): self
    {
        if ($this->asRoute) {
            $this->routes[$this->defaultIndex]['defaults'][$variable] = $default;
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
            $this->prototypes['defaults'][$variable] = $default;
        } else {
            foreach ($this->routes as &$route) {
                $route['defaults'][$variable] = $default;
            }
            $this->resolveGroup(__FUNCTION__, [$variable, $default]);
        }

        return $this;
    }

    /**
     * Set the routes(s) default value for it's placeholder or required argument.
     *
     * @param array<string,mixed> $values
     *
     * @return $this
     */
    public function defaults(array $values): self
    {
        foreach ($values as $variable => $default) {
            $this->default($variable, $default);
        }

        return $this;
    }

    /**
     * Set the route(s) placeholder requirement.
     *
     * @param array<int,string>|string $regexp The regexp to apply
     *
     * @return $this
     */
    public function placeholder(string $variable, string|array $regexp): self
    {
        if ($this->asRoute) {
            $this->routes[$this->defaultIndex]['placeholders'][$variable] = $regexp;
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
            $this->prototypes['placeholders'][$variable] = $regexp;
        } else {
            foreach ($this->routes as &$route) {
                $route['placeholders'][$variable] = $regexp;
            }

            $this->resolveGroup(__FUNCTION__, [$variable, $regexp]);
        }

        return $this;
    }

    /**
     * Set the route(s) placeholder requirements.
     *
     * @param array<string,array<int,string>|string> $placeholders The regexps to apply
     *
     * @return $this
     */
    public function placeholders(array $placeholders): self
    {
        foreach ($placeholders as $placeholder => $value) {
            $this->placeholder($placeholder, $value);
        }

        return $this;
    }

    /**
     * Set the named parameter supplied to route(s) handler's constructor/factory.
     *
     * @return $this
     */
    public function argument(string $parameter, mixed $value): self
    {
        $resolver = fn ($value) => \is_numeric($value) ? (int) $value : (\is_string($value) ? \rawurldecode($value) : $value);

        if ($this->asRoute) {
            $this->routes[$this->defaultIndex]['arguments'][$parameter] = $resolver($value);
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
            $this->prototypes['arguments'][$parameter] = $resolver($value);
        } else {
            foreach ($this->routes as &$route) {
                $route['arguments'][$parameter] = $resolver($value);
            }
            $this->resolveGroup(__FUNCTION__, [$parameter, $value]);
        }

        return $this;
    }

    /**
     * Set the named parameters supplied to route(s) handler's constructor/factory.
     *
     * @param array<string,mixed> $parameters The route handler parameters
     *
     * @return $this
     */
    public function arguments(array $parameters): self
    {
        foreach ($parameters as $parameter => $value) {
            $this->argument($parameter, $value);
        }

        return $this;
    }

    /**
     * Set the missing namespace for route(s) handler(s).
     *
     * @return $this
     *
     * @throws InvalidControllerException if namespace does not ends with a \
     */
    public function namespace(string $namespace): self
    {
        if ('\\' !== $namespace[-1]) {
            throw new InvalidControllerException(\sprintf('Cannot set a route\'s handler namespace "%s" without an ending "\\".', $namespace));
        }

        if ($this->asRoute) {
            $handler = &$this->routes[$this->defaultIndex]['handler'] ?? null;

            if (!empty($handler)) {
                $handler = $this->resolveHandler($handler, $namespace);
            } else {
                $this->routes[$this->defaultIndex][__FUNCTION__] = $namespace;
            }
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
            $this->prototypes[__FUNCTION__][] = $namespace;
        } else {
            foreach ($this->routes as &$route) {
                $route['handler'] = $this->resolveHandler($route['handler'] ?? null, $namespace);
            }
            $this->resolveGroup(__FUNCTION__, [$namespace]);
        }

        return $this;
    }

    /**
     * Set the route(s) HTTP request method(s).
     *
     * @return $this
     */
    public function method(string ...$methods): self
    {
        if ($this->asRoute) {
            foreach ($methods as $method) {
                $this->routes[$this->defaultIndex]['methods'][\strtoupper($method)] = true;
            }

            return $this;
        }

        $routeMethods = \array_fill_keys(\array_map('strtoupper', $methods), true);

        if (-1 === $this->defaultIndex && empty($this->groups)) {
            $this->prototypes['methods'] = \array_merge($this->prototypes['methods'] ?? [], $routeMethods);
        } else {
            foreach ($this->routes as &$route) {
                $route['methods'] += $routeMethods;
            }
            $this->resolveGroup(__FUNCTION__, $methods);
        }

        return $this;
    }

    /**
     * Set route(s) HTTP host scheme(s).
     *
     * @return $this
     */
    public function scheme(string ...$schemes): self
    {
        if ($this->asRoute) {
            foreach ($schemes as $scheme) {
                $this->routes[$this->defaultIndex]['schemes'][$scheme] = true;
            }

            return $this;
        }
        $routeSchemes = \array_fill_keys($schemes, true);

        if (-1 === $this->defaultIndex && empty($this->groups)) {
            $this->prototypes['schemes'] = \array_merge($this->prototypes['schemes'] ?? [], $routeSchemes);
        } else {
            foreach ($this->routes as &$route) {
                $route['schemes'] = \array_merge($route['schemes'] ?? [], $routeSchemes);
            }
            $this->resolveGroup(__FUNCTION__, $schemes);
        }

        return $this;
    }

    /**
     * Set the route(s) HTTP host name(s).
     *
     * @return $this
     */
    public function domain(string ...$domains): self
    {
        $resolver = static function (array &$route, array $domains): void {
            foreach ($domains as $domain) {
                if (1 === \preg_match('/^(?:([a-z]+)\:\/{2})?([^\/]+)?$/u', $domain, $m, \PREG_UNMATCHED_AS_NULL)) {
                    if (isset($m[1])) {
                        $route['schemes'][$m[1]] = true;
                    }

                    if (isset($m[2])) {
                        $route['hosts'][$m[2]] = true;
                    }
                }
            }
        };

        if ($this->asRoute) {
            $resolver($this->routes[$this->defaultIndex], $domains);
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
            $this->prototypes[__FUNCTION__] = \array_merge($this->prototypes[__FUNCTION__] ?? [], $domains);
        } else {
            foreach ($this->routes as &$route) {
                $resolver($route, $domains);
            }
            $this->resolveGroup(__FUNCTION__, $domains);
        }

        return $this;
    }

    /**
     * Set prefix path which should be prepended to route(s) path.
     *
     * @return $this
     */
    public function prefix(string $path): self
    {
        $resolver = fn (string $a, string $b): string => $a.(($a[-1] ?? '') === $b[0] ? \substr($b, 1) : $b);

        if ($this->asRoute) {
            \preg_match(
                static::PRIORITY_REGEX,
                $this->routes[$this->defaultIndex]['path'] = $resolver(
                    $path,
                    $this->routes[$this->defaultIndex]['path'] ?? '',
                ),
                $m,
                \PREG_UNMATCHED_AS_NULL
            );
            $this->routes[$this->defaultIndex]['prefix'] = $m[1] ?? null;
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
            $this->prototypes[__FUNCTION__][] = $path;
        } else {
            foreach ($this->routes as &$route) {
                \preg_match(static::PRIORITY_REGEX, $route['path'] = $resolver($path, $route['path']), $m);
                $route['prefix'] = $m[1] ?? null;
            }

            $this->resolveGroup(__FUNCTION__, [$path]);
        }

        return $this;
    }

    /**
     * Set a set of named grouped middleware(s) to route(s).
     *
     * @return $this
     */
    public function piped(string ...$to): self
    {
        if ($this->asRoute) {
            foreach ($to as $middleware) {
                $this->routes[$this->defaultIndex]['middlewares'][$middleware] = true;
            }

            return $this;
        }
        $routeMiddlewares = \array_fill_keys($to, true);

        if (-1 === $this->defaultIndex && empty($this->groups)) {
            $this->prototypes['middlewares'] = \array_merge($this->prototypes['middlewares'] ?? [], $routeMiddlewares);
        } else {
            foreach ($this->routes as &$route) {
                $route['middlewares'] = \array_merge($route['middlewares'] ?? [], $routeMiddlewares);
            }
            $this->resolveGroup(__FUNCTION__, $to);
        }

        return $this;
    }

    /**
     * Set a custom key and value to route(s).
     *
     * @return $this
     */
    public function set(string $key, mixed $value): self
    {
        if (\in_array($key, [
            'name',
            'handler',
            'arguments',
            'namespace',
            'middlewares',
            'methods',
            'placeholders',
            'prefix',
            'hosts',
            'schemes',
            'defaults',
        ], true)) {
            throw new \InvalidArgumentException(\sprintf('Cannot replace the default "%s" route binding.', $key));
        }

        if ($this->asRoute) {
            $this->routes[$this->defaultIndex][$key] = $value;
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
            $this->prototypes[$key] = !\is_array($value) ? $value : \array_merge($this->prototypes[$key] ?? [], $value);
        } else {
            foreach ($this->routes as &$route) {
                $route[$key] = \is_array($value) ? \array_merge($route[$key] ?? [], $value) : $value;
            }
            $this->resolveGroup(__FUNCTION__, [$key, $value]);
        }

        return $this;
    }

    protected function resolveHandler(mixed $handler, string $namespace = null): mixed
    {
        if (empty($namespace)) {
            return $handler;
        }

        if (\is_string($handler)) {
            if ('\\' === $handler[0] || \str_starts_with($handler, $namespace)) {
                return $handler;
            }
            $handler = $namespace.$handler;
        } elseif (\is_array($handler)) {
            if (2 !== \count($handler, \COUNT_RECURSIVE)) {
                throw new InvalidControllerException('Cannot use a non callable like array as route handler.');
            }

            if (\is_string($handler[0]) && !\str_starts_with($handler[0], $namespace)) {
                $handler[0] = $this->resolveHandler($handler[0], $namespace);
            }
        } elseif ($handler instanceof ResourceHandler) {
            $handler = $handler->namespace($namespace);
        }

        return $handler;
    }

    /**
     * @param array<int,mixed> $arguments
     */
    protected function resolveGroup(string $method, array $arguments): void
    {
        foreach ($this->groups as $group) {
            $asRoute = $group->asRoute;
            $group->asRoute = false;
            $group->{$method}(...$arguments);
            $group->asRoute = $asRoute;
        }
    }
}