diff --git a/.scrutinizer.yml b/.scrutinizer.yml index bec86a6c..d11e1bdc 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,7 +1,7 @@ build: environment: php: - version: 7.4 + version: 8.0 dependencies: before: - curl -sS https://getcomposer.org/installer | php -- --2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 194c1a52..12b663c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,3 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - ## [3.0.0] - TBD ### Changed @@ -21,6 +12,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated CI pipeline to test against PHP 7.4, 8.0, and 8.1 - CI now fails on PHP warnings and deprecations for stricter quality control - Added `declare(strict_types=1)` to all Exception classes for improved type safety + +- **BREAKING:** Refactored routing system internals: + - Routes are now represented as first-class objects (`Route`, `RouteCollection`, `MatchedRoute`) + - Introduced `RouteBuilder` as a fluent DSL interpreter and route composition engine + - Introduced `PatternCompiler` for centralized route pattern compilation + - Introduced `RouteDispatcher` for explicit controller / closure dispatching + - Route matching now produces a `MatchedRoute` object containing the route and extracted parameters + - Routing is executed once per request; matched route is stored on the `Request` + - Global route helper functions (`current_*`, `route_*`) now rely on `MatchedRoute` instead of legacy static route state + - Middleware ordering is strictly prepend-based (later middleware wraps earlier) + - Nested route groups are no longer supported + - Route name uniqueness is now enforced at build time + +- **BREAKING:** Controller resolution behavior changed: + - Routes no longer implicitly resolve controller class names via legacy `RouteController` logic + - Controllers are now instantiated directly based on the handler defined in the route + - Projects relying on legacy controller resolution or `RouteController` static state must update accordingly + - **BREAKING:** Refactored model architecture: - Introduced a base Model class for non-database models - Refactored QtModel into DbModel with persistence-only responsibilities @@ -42,6 +51,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rector as dev dependency for automated code refactoring - Additional PHP extensions required in CI: `bcmath`, `gd`, `zip` - PHPUnit strict testing flags: `--fail-on-warning`, `--fail-on-risky` + +- **Routing test coverage**: + - Comprehensive unit tests for route building, grouping, middleware ordering, caching, and naming + - Unit tests for route helpers and request–route integration + - Unit tests for dispatcher behavior with controller and closure routes + - **Cron Scheduler**: New CLI command `php qt cron:run` for running scheduled tasks - Task definition via PHP files in `cron/` directory - Cron expression parsing using `dragonmantank/cron-expression` library @@ -50,17 +65,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support for force mode and specific task execution - Automatic cleanup of stale locks (older than 24 hours) - Full documentation in `docs/cron-scheduler.md` + - **Opt-in Model Timestamps**: Introduced `HasTimestamps` trait for `DbModel`: - - Automatically sets `created_at` on insert + - Automatically sets `created_at` on insert - Automatically sets `updated_at` on insert and update - Supports custom timestamp column names via model constants (`CREATED_AT`, `UPDATED_AT`) - Supports datetime and unix timestamp formats via `TIMESTAMP_TYPE` ### Removed - Support for PHP 7.3 and earlier versions - ---- - -## [2.x.x] - Previous versions - -See Git history for changes in earlier versions. \ No newline at end of file +- Legacy routing static state and implicit controller resolution via `RouteController` diff --git a/src/App/Adapters/WebAppAdapter.php b/src/App/Adapters/WebAppAdapter.php index a3f44d62..6dab806a 100644 --- a/src/App/Adapters/WebAppAdapter.php +++ b/src/App/Adapters/WebAppAdapter.php @@ -16,7 +16,7 @@ use Quantum\Libraries\Database\Exceptions\DatabaseException; use Quantum\Libraries\Session\Exceptions\SessionException; -use Quantum\Router\Exceptions\RouteControllerException; +use Quantum\Middleware\Exceptions\MiddlewareException; use Quantum\Libraries\Csrf\Exceptions\CsrfException; use Quantum\Libraries\Lang\Exceptions\LangException; use Quantum\App\Exceptions\StopExecutionException; @@ -29,8 +29,12 @@ use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; use Quantum\App\Traits\WebAppTrait; +use Quantum\Router\RouteCollection; use Quantum\Router\RouteDispatcher; +use Quantum\Router\RouteBuilder; +use Quantum\Module\ModuleLoader; use DebugBar\DebugBarException; +use Quantum\Router\RouteFinder; use Quantum\Debugger\Debugger; use Quantum\Hook\HookManager; use Quantum\Http\Response; @@ -85,11 +89,11 @@ public function __construct() * @throws DiException * @throws HttpException * @throws LangException + * @throws ModuleException * @throws ReflectionException - * @throws RouteControllerException * @throws RouteException * @throws SessionException - * @throws ModuleException + * @throws MiddlewareException */ public function start(): ?int { @@ -103,17 +107,38 @@ public function start(): ?int $this->setupErrorHandler(); $this->initializeDebugger(); - $this->loadModules(); + $moduleLoader = ModuleLoader::getInstance(); + + $builder = new RouteBuilder(); + + $collection = $builder->build( + $moduleLoader->loadModulesRoutes(), + $moduleLoader->getModuleConfigs() + ); - $this->initializeRouter($this->request); + Di::set(RouteCollection::class, $collection); + + $routeFinder = new RouteFinder($collection); + + $matchedRoute = $routeFinder->find($this->request); + + if ($matchedRoute === null) { + page_not_found(); + stop(); + } + + $this->request->setMatchedRoute($matchedRoute); $this->loadLanguage(); info(HookManager::getInstance()->getRegistered(), ['tab' => Debugger::HOOKS]); - if (current_middlewares()) { - [$this->request, $this->response] = (new MiddlewareManager())->applyMiddlewares($this->request, $this->response); - } + $middlewareManager = new MiddlewareManager($matchedRoute); + + [$this->request, $this->response] = $middlewareManager->applyMiddlewares( + $this->request, + $this->response + ); $viewCache = $this->setupViewCache(); @@ -121,8 +146,8 @@ public function start(): ?int stop(); } - RouteDispatcher::handle($this->request); - + $dispatcher = new RouteDispatcher(); + $dispatcher->dispatch($matchedRoute, $this->request, $this->response); stop(); } catch (StopExecutionException $exception) { $this->handleCors($this->response); diff --git a/src/App/Traits/WebAppTrait.php b/src/App/Traits/WebAppTrait.php index 7dd9c036..2b98a877 100644 --- a/src/App/Traits/WebAppTrait.php +++ b/src/App/Traits/WebAppTrait.php @@ -14,27 +14,20 @@ namespace Quantum\App\Traits; -use Quantum\App\Exceptions\StopExecutionException; use Quantum\Environment\Exceptions\EnvException; use Quantum\Libraries\ResourceCache\ViewCache; -use Quantum\Module\Exceptions\ModuleException; use Quantum\Config\Exceptions\ConfigException; -use Quantum\Router\Exceptions\RouteException; use Quantum\Http\Exceptions\HttpException; use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; use Quantum\Environment\Environment; -use Quantum\Module\ModuleLoader; -use Quantum\Router\RouteBuilder; use DebugBar\DebugBarException; use Quantum\Environment\Server; use Quantum\Debugger\Debugger; use Quantum\Http\Response; -use Quantum\Router\Router; use Quantum\Http\Request; use Quantum\Loader\Setup; use ReflectionException; -use Quantum\Di\Di; /** * Trait WebAppTrait @@ -76,23 +69,6 @@ private function initializeDebugger() $debugger->initStore(); } - /** - * Load modules - * @throws ModuleException - */ - private function loadModules() - { - $moduleLoader = ModuleLoader::getInstance(); - - $modulesDependencies = $moduleLoader->loadModulesDependencies(); - Di::registerDependencies($modulesDependencies); - - $builder = new RouteBuilder(); - $allRoutes = $builder->build($moduleLoader->loadModulesRoutes(), $moduleLoader->getModuleConfigs()); - - Router::setRoutes($allRoutes); - } - /** * @return ViewCache * @throws ConfigException @@ -110,22 +86,6 @@ private function setupViewCache(): ViewCache return $viewCache; } - /** - * @param $request - * @throws BaseException - * @throws ConfigException - * @throws DebugBarException - * @throws DiException - * @throws ReflectionException - * @throws RouteException - * @throws StopExecutionException - */ - private function initializeRouter($request) - { - $router = new Router($request); - $router->findRoute(); - } - /** * @param Response $response * @throws ConfigException diff --git a/src/Console/Commands/OpenApiCommand.php b/src/Console/Commands/OpenApiCommand.php index 65348fc1..49bc0634 100644 --- a/src/Console/Commands/OpenApiCommand.php +++ b/src/Console/Commands/OpenApiCommand.php @@ -15,17 +15,16 @@ namespace Quantum\Console\Commands; use Quantum\Libraries\Storage\Factories\FileSystemFactory; -use Quantum\Config\Exceptions\ConfigException; use Quantum\Module\Exceptions\ModuleException; use Quantum\Router\Exceptions\RouteException; use Quantum\App\Exceptions\BaseException; use Quantum\Libraries\Storage\FileSystem; use Quantum\Di\Exceptions\DiException; +use Quantum\Router\RouteCollection; use Quantum\Module\ModuleLoader; use Quantum\Router\RouteBuilder; use Quantum\Console\QtCommand; -use Quantum\Router\Router; -use ReflectionException; +use Quantum\Di\Di; use OpenApi\Generator; /** @@ -38,7 +37,14 @@ class OpenApiCommand extends QtCommand * File System * @var FileSystem */ - protected $fs; + protected FileSystem $fs; + + public function __construct() + { + parent::__construct(); + + $this->fs = FileSystemFactory::get(); + } /** * Command name @@ -70,45 +76,39 @@ class OpenApiCommand extends QtCommand * Path to public debug bar resources * @var string */ - private $publicOpenApiFolderPath = 'public/assets/OpenApiUi'; + private string $publicOpenApiFolderPath = 'public/assets/OpenApiUi'; /** * Path to vendor debug bar resources * @var string */ - private $vendorOpenApiFolderPath = 'vendor/swagger-api/swagger-ui/dist'; + private string $vendorOpenApiFolderPath = 'vendor/swagger-api/swagger-ui/dist'; /** * Exclude File Names * @var array */ - private $excludeFileNames = ['index.html', 'swagger-initializer.js', 'favicon-16x16.png', 'favicon-32x32.png']; + private array $excludeFileNames = ['index.html', 'swagger-initializer.js', 'favicon-16x16.png', 'favicon-32x32.png']; /** * Executes the command and generate Open API specifications - * @throws ModuleException - * @throws RouteException - * @throws BaseException - */ - - /** * @throws BaseException * @throws ModuleException * @throws RouteException * @throws DiException - * @throws ConfigException - * @throws ReflectionException */ public function exec() { $moduleLoader = ModuleLoader::getInstance(); $builder = new RouteBuilder(); - $allRoutes = $builder->build($moduleLoader->loadModulesRoutes(), $moduleLoader->getModuleConfigs()); - Router::setRoutes($allRoutes); + $routeCollection = $builder->build( + $moduleLoader->loadModulesRoutes(), + $moduleLoader->getModuleConfigs() + ); - $this->fs = FileSystemFactory::get(); + Di::set(RouteCollection::class, $routeCollection); $module = $this->getArgument('module'); diff --git a/src/Console/Commands/RouteListCommand.php b/src/Console/Commands/RouteListCommand.php index d8297dd0..fa057e29 100644 --- a/src/Console/Commands/RouteListCommand.php +++ b/src/Console/Commands/RouteListCommand.php @@ -17,10 +17,13 @@ use Quantum\Module\Exceptions\ModuleException; use Quantum\Router\Exceptions\RouteException; use Symfony\Component\Console\Helper\Table; +use Quantum\Di\Exceptions\DiException; +use Quantum\Router\RouteCollection; use Quantum\Router\RouteBuilder; use Quantum\Module\ModuleLoader; use Quantum\Console\QtCommand; -use Quantum\Router\Router; +use Quantum\Router\Route; +use Quantum\Di\Di; /** * Class RouteListCommand @@ -50,6 +53,7 @@ class RouteListCommand extends QtCommand /** * Executes the command + * @throws DiException */ public function exec() { @@ -57,16 +61,25 @@ public function exec() $moduleLoader = ModuleLoader::getInstance(); $builder = new RouteBuilder(); - $allRoutes = $builder->build($moduleLoader->loadModulesRoutes(), $moduleLoader->getModuleConfigs()); + $routeCollection = $builder->build( + $moduleLoader->loadModulesRoutes(), + $moduleLoader->getModuleConfigs() + ); - Router::setRoutes($allRoutes); + Di::set(RouteCollection::class, $routeCollection); - $routes = Router::getRoutes(); + $routes = $routeCollection->all(); $module = $this->getOption('module'); if ($module) { - $routes = array_filter($routes, fn ($route) => strtolower($route['module']) === strtolower($module)); + $routes = array_filter( + $routes, + static function (Route $route) use ($module): bool { + $routeModule = $route->getModule(); + return $routeModule !== null && strtolower($routeModule) === strtolower($module); + } + ); if ($routes === []) { $this->error('The module is not found'); @@ -93,32 +106,36 @@ public function exec() /** * Composes a table row - * @param array $route + * @param Route $route * @param int $maxContentLength * @return array */ - private function composeTableRow(array $route, int $maxContentLength = 25): array + private function composeTableRow(Route $route, int $maxContentLength = 25): array { - $action = $route['action'] - . '\\' - . $route['controller'] - . '@' - . $route['action']; + $controller = $route->getController(); + $actionName = $route->getAction(); + + if ($controller !== null && $actionName !== null) { + $action = $controller . '@' . $actionName; + } else { + $action = 'Closure'; + } if (mb_strlen($action) > $maxContentLength) { $action = mb_substr($action, 0, $maxContentLength) . '...'; } - $middlewares = isset($route['middlewares']) - ? implode(',', $route['middlewares']) + $middlewares = $route->getMiddlewares(); + $middlewaresString = $middlewares !== [] + ? implode(',', $middlewares) : '-'; return [ - $route['module'] ?? '', - $route['method'] ?? '', - $route['route'] ?? '', + $route->getModule() ?? '', + implode('|', $route->getMethods()), + $route->getPattern(), $action, - $middlewares, + $middlewaresString, ]; } } diff --git a/src/Di/Di.php b/src/Di/Di.php index 38ed1868..7d5b632b 100644 --- a/src/Di/Di.php +++ b/src/Di/Di.php @@ -29,24 +29,27 @@ class Di { /** - * @var array + * @var array */ - private static $dependencies = []; + private static array $dependencies = []; /** - * @var array + * @var array */ - private static $container = []; + private static array $container = []; /** - * @var array + * @var array */ - private static $resolving = []; + private static array $resolving = []; /** * Register dependencies + * @param array $dependencies + * @return void + * @throws DiException */ - public static function registerDependencies(array $dependencies) + public static function registerDependencies(array $dependencies): void { foreach ($dependencies as $abstract => $concrete) { if (!self::isRegistered($abstract)) { @@ -59,9 +62,10 @@ public static function registerDependencies(array $dependencies) * Registers new dependency * @param string $concrete * @param string|null $abstract + * @return void * @throws DiException */ - public static function register(string $concrete, ?string $abstract = null) + public static function register(string $concrete, ?string $abstract = null): void { $key = $abstract ?? $concrete; @@ -92,12 +96,13 @@ public static function isRegistered(string $abstract): bool /** * Sets an instance into container - * @param string $abstract - * @param object $instance - * @return void + * @template T of object + * @param class-string $abstract + * @param T $instance + * @param bool $override * @throws DiException */ - public static function set(string $abstract, object $instance): void + public static function set(string $abstract, object $instance, bool $override = true): void { if (!class_exists($abstract) && !interface_exists($abstract)) { throw DiException::invalidAbstractDependency($abstract); @@ -111,6 +116,10 @@ public static function set(string $abstract, object $instance): void throw DiException::dependencyAlreadyRegistered($abstract); } + if (!$override && isset(self::$dependencies[$abstract])) { + throw DiException::dependencyAlreadyRegistered($abstract); + } + if (!isset(self::$dependencies[$abstract])) { self::$dependencies[$abstract] = get_class($instance); } @@ -120,11 +129,10 @@ public static function set(string $abstract, object $instance): void /** * Retrieves a shared instance of the given dependency. - * @param string $dependency - * @param array $args - * @return mixed - * @throws DiException - * @throws ReflectionException + * @template T of object + * @param class-string $dependency + * @return T + * @throws DiException|ReflectionException */ public static function get(string $dependency, array $args = []) { @@ -137,11 +145,10 @@ public static function get(string $dependency, array $args = []) /** * Creates new instance of the given dependency. - * @param string $dependency - * @param array $args - * @return mixed - * @throws DiException - * @throws ReflectionException + * @template T of object + * @param class-string $dependency + * @return T + * @throws DiException|ReflectionException */ public static function create(string $dependency, array $args = []) { @@ -153,10 +160,10 @@ public static function create(string $dependency, array $args = []) } /** - * Automatically resolves and injects parameters for a callable. + * Autowire callable parameters * @param callable $entry * @param array $args - * @return array + * @return list * @throws DiException * @throws ReflectionException */ @@ -189,14 +196,12 @@ public static function reset(): void * @param string $abstract * @param array $args * @param bool $singleton - * @return mixed - * @throws DiException - * @throws ReflectionException + * @return mixed|object + * @throws DiException|ReflectionException */ private static function resolve(string $abstract, array $args = [], bool $singleton = true) { self::checkCircularDependency($abstract); - self::$resolving[$abstract] = true; try { @@ -210,6 +215,7 @@ private static function resolve(string $abstract, array $args = [], bool $single } return self::instantiate($concrete, $args); + } finally { unset(self::$resolving[$abstract]); } @@ -220,27 +226,26 @@ private static function resolve(string $abstract, array $args = [], bool $single * @param string $concrete * @param array $args * @return mixed - * @throws DiException - * @throws ReflectionException + * @throws ReflectionException|DiException */ private static function instantiate(string $concrete, array $args = []) { $class = new ReflectionClass($concrete); - $constructor = $class->getConstructor(); - $params = $constructor ? self::resolveParameters($constructor->getParameters(), $args) : []; + $params = $constructor + ? self::resolveParameters($constructor->getParameters(), $args) + : []; return new $concrete(...$params); } /** - * Resolves all parameters + * Resolve parameter list * @param array $parameters * @param array $args * @return array * @throws DiException - * @throws ReflectionException */ private static function resolveParameters(array $parameters, array &$args = []): array { @@ -254,12 +259,11 @@ private static function resolveParameters(array $parameters, array &$args = []): } /** - * Resolves the parameter + * Resolve single parameter * @param ReflectionParameter $param * @param array $args - * @return array|mixed|null - * @throws DiException - * @throws ReflectionException + * @return array|mixed|object|null + * @throws DiException|ReflectionException */ private static function resolveParameter(ReflectionParameter $param, array &$args = []) { @@ -269,21 +273,29 @@ private static function resolveParameter(ReflectionParameter $param, array &$arg $type = $param->getType()->getName(); } - $concrete = self::$dependencies[$type] ?? $type; + // prefer registered dependency + if ($type !== null && isset(self::$dependencies[$type])) { + return self::get($type); + } - if ($concrete && self::instantiable($concrete)) { - return self::create($concrete); + // fallback instantiable class + if ($type !== null && self::instantiable($type)) { + return self::create($type); } + // array param receives remaining args if ($type === 'array') { return $args; } + // positional args fallback if ($args !== []) { return array_shift($args); } - return $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; + return $param->isDefaultValueAvailable() + ? $param->getDefaultValue() + : null; } /** @@ -293,7 +305,8 @@ private static function resolveParameter(ReflectionParameter $param, array &$arg */ protected static function instantiable(string $class): bool { - return class_exists($class) && (new ReflectionClass($class))->isInstantiable(); + return class_exists($class) + && (new ReflectionClass($class))->isInstantiable(); } /** diff --git a/src/Http/Request/HttpRequest.php b/src/Http/Request/HttpRequest.php index 828bebe5..625a1267 100644 --- a/src/Http/Request/HttpRequest.php +++ b/src/Http/Request/HttpRequest.php @@ -23,6 +23,7 @@ use Quantum\Http\Traits\Request\Params; use Quantum\Di\Exceptions\DiException; use Quantum\Http\Traits\Request\Query; +use Quantum\Http\Traits\Request\Route; use Quantum\Http\Traits\Request\Body; use Quantum\Http\Traits\Request\File; use Quantum\Http\Traits\Request\Url; @@ -36,6 +37,7 @@ */ abstract class HttpRequest { + use Route; use Header; use Body; use Url; @@ -62,19 +64,19 @@ abstract class HttpRequest /** * Request method - * @var string + * @var string|null */ - private static $__method = null; + private static ?string $__method = null; /** * @var Server */ - protected static $server; + protected static Server $server; /** * @var bool */ - private static $initialized = false; + private static bool $initialized = false; /** * Initializes the request static properties using the server instance. @@ -207,6 +209,7 @@ public static function getCsrfToken(): ?string * Gets the base url * @param bool $withModulePrefix * @return string + * @throws DiException|ReflectionException */ public static function getBaseUrl(bool $withModulePrefix = false): string { diff --git a/src/Http/Traits/Request/Route.php b/src/Http/Traits/Request/Route.php new file mode 100644 index 00000000..c1895deb --- /dev/null +++ b/src/Http/Traits/Request/Route.php @@ -0,0 +1,47 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Http\Traits\Request; + +use Quantum\Router\MatchedRoute; + +/** + * Trait Route + * @package Quantum\Http\Request + */ +trait Route +{ + /** + * @var MatchedRoute|null + */ + private static ?MatchedRoute $route = null; + + /** + * @param MatchedRoute|null $route + * @return void + */ + public static function setMatchedRoute(?MatchedRoute $route): void + { + self::$route = $route; + } + + /** + * @return MatchedRoute|null + */ + public static function getMatchedRoute(): ?MatchedRoute + { + return self::$route; + } + +} diff --git a/src/Middleware/MiddlewareManager.php b/src/Middleware/MiddlewareManager.php index 841efbd1..b2db8c71 100644 --- a/src/Middleware/MiddlewareManager.php +++ b/src/Middleware/MiddlewareManager.php @@ -15,6 +15,7 @@ namespace Quantum\Middleware; use Quantum\Middleware\Exceptions\MiddlewareException; +use Quantum\Router\MatchedRoute; use Quantum\Http\Response; use Quantum\Http\Request; @@ -28,21 +29,23 @@ class MiddlewareManager * Middlewares queue * @var array */ - private $middlewares = []; + private array $middlewares = []; /** * Current module - * @var string + * @var string|null */ - private $module; + private ?string $module; /** - * MiddlewareManager constructor. + * @param MatchedRoute $matchedRoute */ - public function __construct() + public function __construct(MatchedRoute $matchedRoute) { - $this->middlewares = current_middlewares(); - $this->module = current_module(); + $route = $matchedRoute->getRoute(); + + $this->middlewares = array_values($route->getMiddlewares()); + $this->module = $route->getModule(); } /** @@ -83,6 +86,12 @@ private function getMiddleware(Request $request, Response $response): QtMiddlewa throw MiddlewareException::middlewareNotFound($middlewareClass); } - return new $middlewareClass($request, $response); + $middleware = new $middlewareClass($request, $response); + + if (!$middleware instanceof QtMiddleware) { + throw MiddlewareException::notInstanceOf($middlewareClass, QtMiddleware::class); + } + + return $middleware; } } diff --git a/src/Module/ModuleLoader.php b/src/Module/ModuleLoader.php index 0ae81568..09e06744 100644 --- a/src/Module/ModuleLoader.php +++ b/src/Module/ModuleLoader.php @@ -23,6 +23,7 @@ use Quantum\Di\Exceptions\DiException; use ReflectionException; use Quantum\App\App; +use Quantum\Di\Di; use Closure; /** @@ -34,35 +35,36 @@ class ModuleLoader /** * @var array */ - private static $moduleDependencies = []; + private static array $moduleDependencies = []; /** * @var array */ - private static $moduleConfigs = []; + private static array $moduleConfigs = []; /** @var array */ - private static $moduleRouteClosures = []; + private static array $moduleRouteClosures = []; /** * @var FileSystem */ - private $fs; + private FileSystem $fs; /** * @var ModuleLoader|null */ - private static $instance = null; + private static ?ModuleLoader $instance = null; /** * @throws BaseException * @throws DiException * @throws ConfigException - * @throws ReflectionException + * @throws ReflectionException|ModuleException */ private function __construct() { $this->fs = FileSystemFactory::get(); + Di::registerDependencies($this->loadModulesDependencies()); } /** @@ -77,6 +79,11 @@ public static function getInstance(): ModuleLoader return self::$instance; } + /** + * @return array + * @throws ModuleException + * @throws RouteException + */ public function loadModulesRoutes(): array { if (empty(self::$moduleConfigs)) { @@ -96,6 +103,12 @@ public function loadModulesRoutes(): array return $modulesRoutes; } + /** + * @param string $module + * @return Closure + * @throws ModuleException + * @throws RouteException + */ private function getModuleRouteDefinitions(string $module): Closure { if (isset(self::$moduleRouteClosures[$module])) { diff --git a/src/Module/Templates/DefaultApi/src/Controllers/MainController.php.tpl b/src/Module/Templates/DefaultApi/src/Controllers/MainController.php.tpl index 4d191ab3..d911852a 100644 --- a/src/Module/Templates/DefaultApi/src/Controllers/MainController.php.tpl +++ b/src/Module/Templates/DefaultApi/src/Controllers/MainController.php.tpl @@ -9,19 +9,18 @@ * @author Arman Ag. * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) * @link http://quantum.softberg.org/ - * @since 2.9.8 + * @since 3.0.0 */ namespace {{MODULE_NAMESPACE}}\Controllers; -use Quantum\Router\RouteController; use Quantum\Http\Response; /** * Class MainController * @package Modules\Api */ -class MainController extends RouteController +class MainController { /** * Status error @@ -37,7 +36,7 @@ class MainController extends RouteController * CSRF verification * @var bool */ - public $csrfVerification = false; + public bool $csrfVerification = false; /** * Action - success response diff --git a/src/Module/Templates/DefaultApi/src/Controllers/OpenApi/OpenApiController.php.tpl b/src/Module/Templates/DefaultApi/src/Controllers/OpenApi/OpenApiController.php.tpl index 99aaf67d..d0ffe790 100644 --- a/src/Module/Templates/DefaultApi/src/Controllers/OpenApi/OpenApiController.php.tpl +++ b/src/Module/Templates/DefaultApi/src/Controllers/OpenApi/OpenApiController.php.tpl @@ -9,13 +9,11 @@ * @author Arman Ag. * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) * @link http://quantum.softberg.org/ - * @since 2.9.8 + * @since 3.0.0 */ namespace {{MODULE_NAMESPACE}}\Controllers\OpenApi; -use Quantum\Router\RouteController; - /** * Class ApiController * @package Modules\Api diff --git a/src/Module/Templates/DefaultApi/src/Controllers/OpenApi/OpenApiMainController.php.tpl b/src/Module/Templates/DefaultApi/src/Controllers/OpenApi/OpenApiMainController.php.tpl index d66b900f..19f57f9a 100644 --- a/src/Module/Templates/DefaultApi/src/Controllers/OpenApi/OpenApiMainController.php.tpl +++ b/src/Module/Templates/DefaultApi/src/Controllers/OpenApi/OpenApiMainController.php.tpl @@ -9,7 +9,7 @@ * @author Arman Ag. * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) * @link http://quantum.softberg.org/ - * @since 2.9.8 + * @since 3.0.0 */ namespace {{MODULE_NAMESPACE}}\Controllers\OpenApi; diff --git a/src/Module/Templates/DefaultWeb/src/Controllers/MainController.php.tpl b/src/Module/Templates/DefaultWeb/src/Controllers/MainController.php.tpl index ba0b3899..f53c9f5d 100644 --- a/src/Module/Templates/DefaultWeb/src/Controllers/MainController.php.tpl +++ b/src/Module/Templates/DefaultWeb/src/Controllers/MainController.php.tpl @@ -9,13 +9,12 @@ * @author Arman Ag. * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) * @link http://quantum.softberg.org/ - * @since 2.9.9 + * @since 3.0.0 */ namespace {{MODULE_NAMESPACE}}\Controllers; use Quantum\View\Factories\ViewFactory; -use Quantum\Router\RouteController; use Quantum\Libraries\Asset\Asset; use Quantum\Http\Response; @@ -23,7 +22,7 @@ use Quantum\Http\Response; * Class MainController * @package Modules\Web */ -class MainController extends RouteController +class MainController { /** diff --git a/src/Module/Templates/DemoApi/src/Controllers/AccountController.php.tpl b/src/Module/Templates/DemoApi/src/Controllers/AccountController.php.tpl index 26134c4b..17ec6bdd 100644 --- a/src/Module/Templates/DemoApi/src/Controllers/AccountController.php.tpl +++ b/src/Module/Templates/DemoApi/src/Controllers/AccountController.php.tpl @@ -36,13 +36,6 @@ class AccountController extends BaseController * Works before an action */ - /** - * @return void - * @throws ReflectionException - * @throws \Quantum\App\Exceptions\BaseException - * @throws \Quantum\Di\Exceptions\DiException - * @throws \Quantum\Service\Exceptions\ServiceException - */ public function __before() { $this->authService = service(AuthService::class); diff --git a/src/Module/Templates/DemoApi/src/Controllers/BaseController.php.tpl b/src/Module/Templates/DemoApi/src/Controllers/BaseController.php.tpl index 9cf06231..12503b12 100644 --- a/src/Module/Templates/DemoApi/src/Controllers/BaseController.php.tpl +++ b/src/Module/Templates/DemoApi/src/Controllers/BaseController.php.tpl @@ -9,18 +9,16 @@ * @author Arman Ag. * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) * @link http://quantum.softberg.org/ - * @since 2.9.8 + * @since 3.0.0 */ namespace {{MODULE_NAMESPACE}}\Controllers; -use Quantum\Router\RouteController; - /** * Class BaseController * @package Modules\{{MODULE_NAME}} */ -abstract class BaseController extends RouteController +abstract class BaseController { /** @@ -37,5 +35,5 @@ abstract class BaseController extends RouteController * CSRF verification * @var bool */ - public $csrfVerification = false; + public bool $csrfVerification = false; } \ No newline at end of file diff --git a/src/Module/Templates/DemoApi/src/Controllers/CommentController.php.tpl b/src/Module/Templates/DemoApi/src/Controllers/CommentController.php.tpl index 54beb1c8..8cc398e5 100644 --- a/src/Module/Templates/DemoApi/src/Controllers/CommentController.php.tpl +++ b/src/Module/Templates/DemoApi/src/Controllers/CommentController.php.tpl @@ -14,10 +14,7 @@ namespace {{MODULE_NAMESPACE}}\Controllers; -use Quantum\Service\Exceptions\ServiceException; -use Quantum\App\Exceptions\BaseException; use {{MODULE_NAMESPACE}}\Services\CommentService; -use Quantum\Di\Exceptions\DiException; use Quantum\Http\Response; use Quantum\Http\Request; @@ -33,9 +30,6 @@ class CommentController extends BaseController */ public CommentService $commentService; - /** - * Works before an action - */ public function __before() { $this->commentService = service(CommentService::class); diff --git a/src/Module/Templates/DemoApi/src/Controllers/PostController.php.tpl b/src/Module/Templates/DemoApi/src/Controllers/PostController.php.tpl index d8174ae3..cb91b129 100644 --- a/src/Module/Templates/DemoApi/src/Controllers/PostController.php.tpl +++ b/src/Module/Templates/DemoApi/src/Controllers/PostController.php.tpl @@ -45,9 +45,6 @@ class PostController extends BaseController */ public PostService $postService; - /** - * Works before an action - */ public function __before() { $this->postService = service(PostService::class); diff --git a/src/Module/Templates/DemoApi/src/Controllers/PostManagementController.php.tpl b/src/Module/Templates/DemoApi/src/Controllers/PostManagementController.php.tpl index c9e34333..ef9287b8 100644 --- a/src/Module/Templates/DemoApi/src/Controllers/PostManagementController.php.tpl +++ b/src/Module/Templates/DemoApi/src/Controllers/PostManagementController.php.tpl @@ -34,9 +34,6 @@ class PostManagementController extends BaseController */ public PostService $postService; - /** - * Works before an action - */ public function __before() { $this->postService = service(PostService::class); diff --git a/src/Module/Templates/DemoWeb/src/Controllers/AccountController.php.tpl b/src/Module/Templates/DemoWeb/src/Controllers/AccountController.php.tpl index 76e215c7..59541f72 100644 --- a/src/Module/Templates/DemoWeb/src/Controllers/AccountController.php.tpl +++ b/src/Module/Templates/DemoWeb/src/Controllers/AccountController.php.tpl @@ -36,9 +36,6 @@ class AccountController extends BaseController */ public AuthService $authService; - /** - * Works before an action - */ public function __before() { $this->authService = service(AuthService::class); diff --git a/src/Module/Templates/DemoWeb/src/Controllers/BaseController.php.tpl b/src/Module/Templates/DemoWeb/src/Controllers/BaseController.php.tpl index 25a9a8ee..f8f6deef 100644 --- a/src/Module/Templates/DemoWeb/src/Controllers/BaseController.php.tpl +++ b/src/Module/Templates/DemoWeb/src/Controllers/BaseController.php.tpl @@ -15,7 +15,6 @@ namespace {{MODULE_NAMESPACE}}\Controllers; use Quantum\View\Factories\ViewFactory; -use Quantum\Router\RouteController; use Quantum\Libraries\Asset\Asset; use Quantum\Http\Request; use Quantum\View\QtView; @@ -24,7 +23,7 @@ use Quantum\View\QtView; * Class BaseController * @package Modules\{{MODULE_NAME}} */ -abstract class BaseController extends RouteController +abstract class BaseController { /** @@ -32,9 +31,6 @@ abstract class BaseController extends RouteController */ protected QtView $view; - /** - * Works before an action - */ public function __before() { if (Request::isMethod('get')) { diff --git a/src/Module/Templates/DemoWeb/src/Controllers/CommentController.php.tpl b/src/Module/Templates/DemoWeb/src/Controllers/CommentController.php.tpl index 3522dc49..49aa6605 100644 --- a/src/Module/Templates/DemoWeb/src/Controllers/CommentController.php.tpl +++ b/src/Module/Templates/DemoWeb/src/Controllers/CommentController.php.tpl @@ -29,6 +29,13 @@ class CommentController extends BaseController */ public CommentService $commentService; + /** + * @return void + * @throws ReflectionException + * @throws \Quantum\App\Exceptions\BaseException + * @throws \Quantum\Di\Exceptions\DiException + * @throws \Quantum\Service\Exceptions\ServiceException + */ public function __before() { $this->commentService = service(CommentService::class); diff --git a/src/Module/Templates/Toolkit/src/Controllers/BaseController.php.tpl b/src/Module/Templates/Toolkit/src/Controllers/BaseController.php.tpl index 03a87853..15cd7267 100644 --- a/src/Module/Templates/Toolkit/src/Controllers/BaseController.php.tpl +++ b/src/Module/Templates/Toolkit/src/Controllers/BaseController.php.tpl @@ -15,7 +15,6 @@ namespace Modules\Toolkit\Controllers; use Quantum\View\Factories\ViewFactory; -use Quantum\Router\RouteController; use Quantum\Libraries\Asset\Asset; use Quantum\View\QtView; @@ -23,7 +22,7 @@ use Quantum\View\QtView; * Class BaseController * @package Modules\Toolkit */ -class BaseController extends RouteController +class BaseController { /** * Main layout diff --git a/src/Router/Exceptions/RouteControllerException.php b/src/Router/Exceptions/RouteControllerException.php deleted file mode 100644 index 7554de21..00000000 --- a/src/Router/Exceptions/RouteControllerException.php +++ /dev/null @@ -1,51 +0,0 @@ - - * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) - * @link http://quantum.softberg.org/ - * @since 3.0.0 - */ - -namespace Quantum\Router\Exceptions; - -use Quantum\Router\Enums\ExceptionMessages; -use Quantum\App\Exceptions\BaseException; - -/** - * ControllerException class - * @package Quantum\Router - */ -class RouteControllerException extends BaseException -{ - /** - * @param string|null $name - * @return RouteControllerException - */ - public static function controllerNotDefined(?string $name): self - { - return new self( - _message(ExceptionMessages::CONTROLLER_NOT_FOUND, [$name]), - E_ERROR - ); - } - - /** - * @param string $name - * @return RouteControllerException - */ - public static function actionNotDefined(string $name): self - { - return new self( - _message(ExceptionMessages::ACTION_NOT_DEFINED, [$name]), - E_ERROR - ); - } -} diff --git a/src/Router/Helpers/router.php b/src/Router/Helpers/router.php index c3b93a8d..d3c64aa1 100644 --- a/src/Router/Helpers/router.php +++ b/src/Router/Helpers/router.php @@ -12,160 +12,211 @@ * @since 3.0.0 */ +use Quantum\Di\Exceptions\DiException; use Quantum\Environment\Environment; -use Quantum\Router\RouteController; -use Quantum\Router\Router; +use Quantum\Router\RouteCollection; +use Quantum\Router\Route; +use Quantum\Http\Request; +use Quantum\Di\Di; /** - * Gets current middlewares + * Gets current route middlewares * @return array|null + * @throws DiException|ReflectionException */ function current_middlewares(): ?array { - return RouteController::getCurrentRoute()['middlewares'] ?? null; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); + + return $matchedRoute ? $matchedRoute->getRoute()->getMiddlewares() : null; } /** - * Gets current module + * Gets current route module * @return string|null + * @throws DiException|ReflectionException */ function current_module(): ?string { - return RouteController::getCurrentRoute()['module'] ?? null; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); + + return $matchedRoute ? $matchedRoute->getRoute()->getModule() : null; } /** - * Get current controller + * Gets current route controller * @return string|null + * @throws DiException|ReflectionException */ function current_controller(): ?string { - return RouteController::getCurrentRoute()['controller'] ?? null; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); + + return $matchedRoute ? $matchedRoute->getRoute()->getController() : null; } /** - * Gets current action + * Gets current route action * @return string|null + * @throws DiException|ReflectionException */ function current_action(): ?string { - return RouteController::getCurrentRoute()['action'] ?? null; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); + + return $matchedRoute ? $matchedRoute->getRoute()->getAction() : null; } /** - * Get current callback - * @return Closure $callback|null + * Gets current route callback + * @return Closure|null + * @throws DiException|ReflectionException */ function route_callback(): ?Closure { - return RouteController::getCurrentRoute()['callback'] ?? null; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); + + return $matchedRoute ? $matchedRoute->getRoute()->getClosure() : null; } /** - * Gets current route + * Gets current route DSL pattern * @return string|null + * @throws DiException|ReflectionException */ function current_route(): ?string { - return RouteController::getCurrentRoute()['route'] ?? null; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); + + return $matchedRoute ? $matchedRoute->getRoute()->getPattern() : null; } /** - * Gets current route parameters - * @return array + * Gets current route complied pattern + * @return string + * @throws DiException|ReflectionException */ -function route_params(): array +function route_pattern(): string { - return RouteController::getCurrentRoute()['params'] ?? []; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); + + return $matchedRoute ? $matchedRoute->getRoute()->getCompiledPattern() : ''; } /** - * Gets route parameter by name - * @param string $name - * @return mixed + * Gets current route parameters + * @return array + * @throws DiException|ReflectionException */ -function route_param(string $name) +function route_params(): array { - $params = RouteController::getCurrentRoute()['params']; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); - if ($params) { - foreach ($params as $param) { - if ($param['name'] == $name) { - return $param['value']; - } - } - } - - return null; + return $matchedRoute ? $matchedRoute->getParams() : []; } /** - * Gets current route pattern - * @return string + * Gets route parameter by name + * @param string $name + * @return mixed|null + * @throws DiException|ReflectionException */ -function route_pattern(): string +function route_param(string $name) { - return RouteController::getCurrentRoute()['pattern'] ?? ''; + $params = route_params(); + return $params[$name] ?? null; } /** * Gets current route method * @return string + * @throws DiException|ReflectionException */ function route_method(): string { - return RouteController::getCurrentRoute()['method'] ?? ''; + $request = Di::get(Request::class); + return $request->getMethod(); } /** * Gets the current route uri - * @return string + * @return string|null + * @throws DiException|ReflectionException */ -function route_uri(): string +function route_uri(): ?string { - return RouteController::getCurrentRoute()['uri'] ?? ''; + $request = Di::get(Request::class); + return $request->getUri(); } /** * Gets the current route cache settings * @return array|null + * @throws DiException|ReflectionException */ function route_cache_settings(): ?array { - return RouteController::getCurrentRoute()['cache_settings'] ?? null; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); + + return $matchedRoute ? $matchedRoute->getRoute()->getCache() : null; } /** * Gets the current route name * @return string|null + * @throws DiException|ReflectionException */ function route_name(): ?string { - return RouteController::getCurrentRoute()['name'] ?? null; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); + + return $matchedRoute ? $matchedRoute->getRoute()->getName() : null; } /** * Gets the current route name * @return string|null + * @throws DiException|ReflectionException */ function route_prefix(): ?string { - return RouteController::getCurrentRoute()['prefix'] ?? null; + $request = Di::get(Request::class); + $matchedRoute = $request->getMatchedRoute(); + + return $matchedRoute ? $matchedRoute->getRoute()->getPrefix() : null; } /** * Finds the route by name in given module scope * @param string $name * @param string $module - * @return array|null + * @return Route|null + * @throws DiException|ReflectionException */ -function find_route_by_name(string $name, string $module): ?array +function find_route_by_name(string $name, string $module): ?Route { - foreach (Router::getRoutes() as $route) { - if (isset($route['name']) && - strtolower($route['name']) === strtolower($name) && - strtolower($route['module']) === strtolower($module)) { + if (!Di::isRegistered(RouteCollection::class)) { + return null; + } + + $collection = Di::get(RouteCollection::class); + foreach ($collection->all() as $route) { + if ( + $route->getName() !== null && + strcasecmp($route->getName(), $name) === 0 && + strcasecmp((string) $route->getModule(), $module) === 0 + ) { return $route; } } @@ -178,14 +229,22 @@ function find_route_by_name(string $name, string $module): ?array * @param string $name * @param string $module * @return bool + * @throws DiException|ReflectionException */ function route_group_exists(string $name, string $module): bool { - foreach (Router::getRoutes() as $route) { - if (isset($route['group']) && - strtolower($route['group']) === strtolower($name) && - strtolower($route['module']) === strtolower($module)) { + if (!Di::isRegistered(RouteCollection::class)) { + return false; + } + + $collection = Di::get(RouteCollection::class); + foreach ($collection->all() as $route) { + if ( + $route->getGroup() !== null && + strcasecmp($route->getGroup(), $name) === 0 && + strcasecmp((string) $route->getModule(), $module) === 0 + ) { return true; } } diff --git a/src/Router/MatchedRoute.php b/src/Router/MatchedRoute.php new file mode 100644 index 00000000..b74a2eb8 --- /dev/null +++ b/src/Router/MatchedRoute.php @@ -0,0 +1,66 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Router; + +/** + * Class MatchedRoute + * @internal Resolves an incoming request to a matched route. + * @package Quantum\Router + */ +final class MatchedRoute +{ + /** + * @var Route + */ + private Route $route; + + /** + * @var array + */ + private array $params; + + /** + * @param Route $route + * @param array $params + */ + public function __construct(Route $route, array $params) + { + $this->route = $route; + $this->params = $params; + } + + /** + * Return the original route definition. + * @return Route + */ + public function getRoute(): Route + { + return $this->route; + } + + /** + * Runtime values extracted from URI + * e.g. ['id' => '42'] + */ + + /** + * Return parameters extracted from the URI at match time. + * @return array + */ + public function getParams(): array + { + return $this->params; + } +} diff --git a/src/Router/PatternCompiler.php b/src/Router/PatternCompiler.php new file mode 100644 index 00000000..7ed4341b --- /dev/null +++ b/src/Router/PatternCompiler.php @@ -0,0 +1,260 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Router; + +use Quantum\Router\Exceptions\RouteException; + +/** + * Class PatternCompiler + * @internal Compiles and matches route patterns against request URIs. + * @package Quantum\Router + */ +class PatternCompiler +{ + protected const VALID_PARAM_NAME_PATTERN = '/^[a-zA-Z]+$/'; + + protected const PARAM_TYPES = [ + ':alpha' => '[a-zA-Z]', + ':num' => '[0-9]', + ':any' => '[^\/]', + ]; + + /** + * @var array + */ + protected array $params = []; + + /** + * Check whether the given URI matches the route pattern and store extracted params. + * @param Route $route + * @param string $uri + * @return bool + * @throws RouteException + */ + public function match(Route $route, string $uri): bool + { + [$pattern, $segmentParams] = $this->compile($route); + + $requestUri = urldecode(parse_url($uri, PHP_URL_PATH) ?? ''); + + if (!preg_match('/^' . $this->escape($pattern) . '$/u', $requestUri, $matches)) { + $this->params = []; + return false; + } + + $this->params = $this->extractParams($segmentParams, $matches); + + return true; + } + + /** + * Compile route pattern into regex and param metadata. + * @param Route $route + * @return array + * @throws RouteException + */ + public function compile(Route $route): array + { + $segments = explode('/', trim($route->getPattern(), '/')); + + $pattern = '(\/)?'; + $params = []; + + $lastIndex = (int) array_key_last($segments); + + foreach ($segments as $index => $segment) { + $segmentParam = $this->getSegmentParam($segment, $index, $lastIndex); + + if ($segmentParam !== []) { + $this->checkParamName($params, $segmentParam['name']); + + $params[] = $segmentParam; + + $pattern = $this->normalizePattern( + $pattern, + $segmentParam, + $index, + $lastIndex + ); + } else { + if ($index === $lastIndex) { + $pattern .= $segment . '(\/)?'; + } else { + $pattern .= $segment . '(\/)'; + } + } + } + + return [$pattern, $params]; + } + + /** + * Return parameters extracted from the last successful match. + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + /** + * Build the final named parameter map from regex matches and param metadata. + * @param array $params + * @param array $matches + * @return array + */ + protected function extractParams(array $params, array $matches): array + { + $values = array_diff($matches, ['', '/']); + $result = []; + + foreach ($params as $param) { + if ($param['name'] !== null) { + $result[$param['name']] = $values[$param['name']] ?? null; + } + } + + return $result; + } + + /** + * Detect and build parameter definition for a single route segment if present. + * @param string $segment + * @param int $index + * @param int $lastIndex + * @return array + * @throws RouteException + */ + protected function getSegmentParam(string $segment, int $index, int $lastIndex): array + { + foreach (static::PARAM_TYPES as $type => $expr) { + if (preg_match('/\[(.*=)*(' . $type . ')(:([0-9]+))*\](\?)?/', $segment, $match)) { + return $this->getParamPattern($match, $expr, $index, $lastIndex); + } + } + + return []; + } + + /** + * Generate the regex pattern and name for a matched parameter segment. + * @param array $match + * @param string $expr + * @param int $index + * @param int $lastIndex + * @return array + * @throws RouteException + */ + protected function getParamPattern(array $match, string $expr, int $index, int $lastIndex): array + { + $name = $this->getParamName($match, $index); + + $pattern = '(?<' . $name . '>' . $expr; + + if (isset($match[4]) && is_numeric($match[4])) { + $pattern .= (isset($match[5]) && $match[5] === '?') + ? '{0,' . $match[4] . '})' + : '{' . $match[4] . '})'; + } else { + $pattern .= (isset($match[5]) && $match[5] === '?') + ? '*)' + : '+)'; + } + + if (isset($match[5]) && $match[5] === '?') { + $pattern = ($index === $lastIndex ? '(\/)?' . $pattern : $pattern . '(\/)?'); + } else { + $pattern = ($index === $lastIndex ? '(\/)' . $pattern : $pattern . '(\/)'); + } + + return [ + 'name' => $name, + 'pattern' => $pattern, + ]; + } + + /** + * Resolve and validate the parameter name from a segment match. + * @param array $match + * @param int $index + * @return string + * @throws RouteException + */ + protected function getParamName(array $match, int $index): string + { + $name = $match[1] ? rtrim($match[1], '=') : null; + + if ($name === null) { + return '_segment' . $index; + } + + if (!preg_match(static::VALID_PARAM_NAME_PATTERN, $name)) { + throw RouteException::paramNameNotValid(); + } + + return $name; + } + + /** + * Ensure the parameter name is unique within the route pattern. + * @param array $params + * @param string $name + * @return void + * @throws RouteException + */ + protected function checkParamName(array $params, string $name): void + { + foreach ($params as $param) { + if ($param['name'] === $name) { + throw RouteException::paramNameNotAvailable($name); + } + } + } + + /** + * Adjust the accumulated route regex before appending the last segment pattern. + * @param string $routePattern + * @param array $segmentParam + * @param int $index + * @param int $lastIndex + * @return string + */ + protected function normalizePattern( + string $routePattern, + array $segmentParam, + int $index, + int $lastIndex + ): string { + if ($index === $lastIndex) { + if (mb_substr($routePattern, -5) === '(\/)?') { + $routePattern = mb_substr($routePattern, 0, -5); + } elseif (mb_substr($routePattern, -4) === '(\/)') { + $routePattern = mb_substr($routePattern, 0, -4); + } + } + + return $routePattern . $segmentParam['pattern']; + } + + /** + * Escape forward slashes in a regex fragment safely. + * @param string $str + * @return string + */ + protected function escape(string $str): string + { + return str_replace('/', '\/', stripslashes($str)); + } +} diff --git a/src/Router/Route.php b/src/Router/Route.php index 999d0dcc..53b95548 100644 --- a/src/Router/Route.php +++ b/src/Router/Route.php @@ -14,305 +14,350 @@ namespace Quantum\Router; -use Quantum\Config\Exceptions\ConfigException; -use Quantum\Router\Exceptions\RouteException; -use Quantum\App\Exceptions\BaseException; -use Quantum\Di\Exceptions\DiException; -use ReflectionException; use Closure; /** - * Route Class + * Class Route + * @internal Framework routing descriptor. * @package Quantum\Router */ -class Route +final class Route { /** - * Current module name + * @var array + */ + protected array $methods; + + /** * @var string */ - private $moduleName; + protected string $pattern; /** - * Module options - * @var array + * @var string|null */ - private $moduleOptions; + protected ?string $controller; /** - * Identifies the group middleware - * @var bool + * @var string|null */ - private $isGroupMiddlewares; + protected ?string $action; /** - * Identifies the group - * @var boolean + * @var Closure|null */ - private $isGroup = false; + protected ?Closure $closure; /** - * Current group name * @var string|null */ - private $currentGroupName = null; + protected ?string $name = null; /** - * Current route - * @var array + * @var string|null */ - private $currentRoute = []; + protected ?string $module = null; /** - * Virtual routes * @var array */ - private $virtualRoutes = []; + protected array $middlewares = []; /** - * @param string $moduleName - * @param array $moduleOptions + * @var string|null */ - public function __construct(string $moduleName, array $moduleOptions) - { - $this->virtualRoutes['*'] = []; - $this->moduleName = $moduleName; - $this->moduleOptions = $moduleOptions; - } + protected ?string $group = null; /** - * @param string $route - * @param string $method - * @param ...$params - * @return $this + * @var string|null */ - public function add(string $route, string $method, ...$params): Route - { - $this->currentRoute = [ - 'route' => empty($this->moduleOptions['prefix']) ? $route : $this->moduleOptions['prefix'] . '/' . $route, - 'prefix' => $this->moduleOptions['prefix'] ?? '', - 'method' => $method, - 'module' => $this->moduleName, - ]; + protected ?string $prefix = null; - if (isset($this->moduleOptions['cacheable'])) { - $this->currentRoute['cache_settings']['shouldCache'] = (bool) $this->moduleOptions['cacheable']; - } + /** + * @var array|null + */ + protected ?array $cache = null; - if (is_callable($params[0])) { - $this->currentRoute['callback'] = $params[0]; - } else { - $this->currentRoute['controller'] = $params[0]; - $this->currentRoute['action'] = $params[1]; + /** + * @var string|null + */ + protected ?string $compiledPattern = null; + + /** + * @param array $methods + * @param string $pattern + * @param string|null $controller + * @param string|null $action + * @param Closure|null $closure + */ + public function __construct( + array $methods, + string $pattern, + ?string $controller, + ?string $action, + Closure $closure = null + ) { + if ($methods === []) { + throw new \InvalidArgumentException('Route must define at least one HTTP method.'); } - if ($this->currentGroupName) { - $this->currentRoute['group'] = $this->currentGroupName; - $this->virtualRoutes[$this->currentGroupName][] = $this->currentRoute; + $this->methods = array_map('strtoupper', $methods); + $this->pattern = $pattern; + + if ($closure !== null) { + if ($controller !== null || $action !== null) { + throw new \InvalidArgumentException( + 'Closure route cannot define controller or action.' + ); + } } else { - $this->isGroup = false; - $this->isGroupMiddlewares = false; - $this->virtualRoutes['*'][] = $this->currentRoute; + if ($controller === null || $action === null || $action === '') { + throw new \InvalidArgumentException( + 'Controller route must define non-empty controller and action.' + ); + } } - return $this; + $this->controller = $controller; + $this->action = $action; + $this->closure = $closure; + } /** - * @param string $route - * @param ...$params - * @return $this + * Check whether this route is handled by a closure. + * @return bool */ - public function get(string $route, ...$params): Route + public function isClosure(): bool { - return $this->add($route, 'GET', ...$params); + return $this->closure !== null; } /** - * @param string $route - * @param ...$params + * Configure response caching settings for this route. + * @param bool $enabled + * @param int|null $ttl * @return $this */ - public function post(string $route, ...$params): Route + public function cache(bool $enabled, ?int $ttl = null): self { - return $this->add($route, 'POST', ...$params); + $this->cache = [ + 'enabled' => $enabled, + 'ttl' => $ttl, + ]; + + return $this; } /** - * @param string $route - * @param ...$params - * @return $this + * Return caching configuration for this route. + * @return array|null */ - public function put(string $route, ...$params): Route + public function getCache(): ?array { - return $this->add($route, 'PUT', ...$params); + return $this->cache; } /** - * @param string $route - * @param ...$params + * Store compiled regex pattern for this route. + * @param string $pattern * @return $this */ - public function delete(string $route, ...$params): Route + public function setCompiledPattern(string $pattern): self { - return $this->add($route, 'DELETE', ...$params); + $this->compiledPattern = $pattern; + return $this; } /** - * Starts a named group of routes - * @param string $groupName - * @param Closure $callback - * @return Route + * Return compiled regex pattern if set. + * @return string|null */ - public function group(string $groupName, Closure $callback): Route + public function getCompiledPattern(): ?string { - $this->currentGroupName = $groupName; - - $this->isGroup = true; - $this->isGroupMiddlewares = false; - $callback($this); - $this->isGroupMiddlewares = true; - $this->currentGroupName = null; + return $this->compiledPattern; + } + /** + * Assign group name to this route. + * @param string|null $group + * @return $this + */ + public function group(?string $group): self + { + $this->group = $group; return $this; } /** - * Adds middlewares to routes and route groups - * @param array $middlewares - * @return Route + * Return group name. + * @return string|null */ - public function middlewares(array $middlewares = []): Route + public function getGroup(): ?string { - if (!$this->isGroup) { - $lastKey = array_key_last($this->virtualRoutes['*']); - $this->assignMiddlewaresToRoute($this->virtualRoutes['*'][$lastKey], $middlewares); - return $this; - } - $lastKeyOfFirstRound = array_key_last($this->virtualRoutes); + return $this->group; + } - if (!$this->isGroupMiddlewares) { - $lastKeyOfSecondRound = array_key_last($this->virtualRoutes[$lastKeyOfFirstRound]); - $this->assignMiddlewaresToRoute($this->virtualRoutes[$lastKeyOfFirstRound][$lastKeyOfSecondRound], $middlewares); - return $this; - } + /** + * Return URL prefix. + * @param string|null $prefix + * @return $this + */ + public function prefix(?string $prefix): self + { + $this->prefix = $prefix !== '' ? trim($prefix, '/') : null; + return $this; + } - foreach ($this->virtualRoutes[$lastKeyOfFirstRound] as &$route) { - $this->assignMiddlewaresToRoute($route, $middlewares); - } + /** + * @return string|null + */ + public function getPrefix(): ?string + { + return $this->prefix; + } + /** + * Assign unique route name. + * @param string $name + * @return $this + */ + public function name(string $name): self + { + $this->name = $name; return $this; } /** - * @param bool $shouldCache - * @param int|null $ttl + * Add middleware(s) with group-aware stacking order. + * @param array $middlewares * @return $this - * @throws ConfigException - * @throws DiException - * @throws ReflectionException - * @throws BaseException */ - public function cacheable(bool $shouldCache, ?int $ttl = null): Route + public function addMiddlewares(array $middlewares): self { - if (empty(session()->getId())) { - return $this; - } - - if (!$this->isGroup) { - $lastKey = array_key_last($this->virtualRoutes['*']); - - $this->virtualRoutes['*'][$lastKey]['cache_settings']['shouldCache'] = $shouldCache; + if (empty($this->middlewares)) { + $this->middlewares = $middlewares; + } else { + $middlewares = array_reverse($middlewares); - if ($shouldCache && $ttl) { - $this->virtualRoutes['*'][$lastKey]['cache_settings']['ttl'] = $ttl; + foreach ($middlewares as $middleware) { + array_unshift($this->middlewares, $middleware); } - - return $this; } - $lastKeyOfFirstRound = array_key_last($this->virtualRoutes); - foreach ($this->virtualRoutes[$lastKeyOfFirstRound] as &$route) { - $route['cache_settings']['shouldCache'] = $shouldCache; - - if ($shouldCache && $ttl) { - $route['cache_settings']['ttl'] = $ttl; - } - } + return $this; + } + /** + * Assign module name to this route. + * @param string|null $module + * @return $this + */ + public function module(?string $module): self + { + $this->module = $module; return $this; } /** - * Sets a unique name for a route - * @param string $name - * @return Route - * @throws RouteException + * Check whether HTTP method is allowed for this route. + * @param string $method + * @return bool */ - public function name(string $name): Route + public function allowsMethod(string $method): bool { - if ($this->currentRoute === []) { - throw RouteException::nameBeforeDefinition(); - } + return in_array(strtoupper($method), $this->methods, true); + } - if ($this->isGroupMiddlewares) { - throw RouteException::nameOnGroup(); - } + /** + * Return allowed HTTP methods. + * @return array + */ + public function getMethods(): array + { + return $this->methods; + } - foreach ($this->virtualRoutes as &$virtualRoute) { - foreach ($virtualRoute as &$route) { - if (isset($route['name']) && $route['name'] == $name) { - throw RouteException::nonUniqueName(); - } + /** + * Return route pattern string. + * @return string + */ + public function getPattern(): string + { + return $this->pattern; + } - if ($route['route'] == $this->currentRoute['route']) { - $route['name'] = $name; - } - } - } + /** + * Return route closure handler if defined. + * @return Closure|null + */ + public function getClosure(): ?Closure + { + return $this->closure; + } - return $this; + /** + * Return controller class name. + * @return string|null + */ + public function getController(): ?string + { + return $this->controller; } /** - * Gets the run-time routes - * @return array + * Return controller action name. + * @return string|null */ - public function getRuntimeRoutes(): array + public function getAction(): ?string { - $runtimeRoutes = []; - foreach ($this->virtualRoutes as $virtualRoute) { - foreach ($virtualRoute as $route) { - $runtimeRoutes[] = $route; - } - } - return $runtimeRoutes; + return $this->action; } /** - * Gets the virtual routes + * Return route name. + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Return middleware list. * @return array */ - public function getVirtualRoutes(): array + public function getMiddlewares(): array { - return $this->virtualRoutes; + return $this->middlewares; } /** - * Assigns middlewares to the route - * @param array $route - * @param array $middlewares + * Return middleware list. + * @return string|null */ - private function assignMiddlewaresToRoute(array &$route, array $middlewares) + public function getModule(): ?string { - if (!array_key_exists('middlewares', $route)) { - $route['middlewares'] = $middlewares; - } else { - $middlewares = array_reverse($middlewares); + return $this->module; + } - foreach ($middlewares as $middleware) { - array_unshift($route['middlewares'], $middleware); - } - } + /** + * Export route definition as array. + * @return array + */ + public function toArray(): array + { + return [ + 'methods' => $this->methods, + 'route' => $this->pattern, + 'controller' => $this->controller, + 'action' => $this->action, + 'middlewares' => $this->middlewares, + 'name' => $this->name, + 'module' => $this->module, + 'group' => $this->group, + 'prefix' => $this->prefix, + 'cache' => $this->cache, + ]; } } diff --git a/src/Router/RouteBuilder.php b/src/Router/RouteBuilder.php index 1a49c7d3..7bc732d0 100644 --- a/src/Router/RouteBuilder.php +++ b/src/Router/RouteBuilder.php @@ -14,35 +14,397 @@ namespace Quantum\Router; +use Quantum\Router\Exceptions\RouteException; +use InvalidArgumentException; +use LogicException; use Closure; /** * Class RouteBuilder + * @internal Fluent DSL interpreter and route composition engine. * @package Quantum\Router */ -class RouteBuilder +final class RouteBuilder { /** - * @param array $moduleClosures moduleName => closure(Route $collector): void - * @param array $moduleConfigs moduleName => config options - * @return array + * @var RouteCollection */ - public function build(array $moduleClosures, array $moduleConfigs = []): array + private RouteCollection $collection; + + /** + * @var PatternCompiler + */ + private PatternCompiler $patternCompiler; + + /** + * @var Route|null + */ + private ?Route $currentRoute = null; + + /** + * @var string|null + */ + private ?string $currentModule = null; + + /** + * @var string|null + */ + private ?string $currentPrefix = null; + + /** + * @var bool + */ + private bool $inGroup = false; + + /** + * @var string|null + */ + private ?string $currentGroupName = null; + + /** + * @var array + */ + private array $groupRoutes = []; + + /** + * @var array + */ + private array $groupMiddlewares = []; + + /** + * @var Route[]|null + */ + private ?array $lastGroupRoutes = null; + + public function __construct() { - $allRoutes = []; + $this->patternCompiler = new PatternCompiler(); + + $this->collection = new RouteCollection(); + } + + /** + * Execute DSL and return final RouteCollection + * @param array $moduleRouteClosures + * @param array $moduleConfigs + * @return RouteCollection + */ + public function build(array $moduleRouteClosures, array $moduleConfigs): RouteCollection + { + foreach ($moduleRouteClosures as $module => $closure) { + if (!$closure instanceof Closure) { + throw new InvalidArgumentException( + "Routes for module {$module} must return a Closure" + ); + } - foreach ($moduleClosures as $module => $closure) { $options = $moduleConfigs[$module] ?? []; - $routeCollector = new Route($module, $options); + $this->currentModule = $module; + $this->currentPrefix = trim((string) ($options['prefix'] ?? ''), '/'); + + $closure($this); + + $this->currentModule = null; + $this->currentPrefix = null; + $this->currentRoute = null; + } + + return $this->collection; + } + + /** + * Define a route with multiple HTTP methods. + * @param string $path + * @param string $methods + * @param $handler + * @param string|null $action + * @return self + * @throws RouteException + */ + public function add(string $path, string $methods, $handler, string $action = null): self + { + $methodList = array_map('trim', explode('|', $methods)); + + return $this->addRoute($methodList, $path, $handler, $action); + } + + /** + * Define a GET route. + * @param string $path + * @param $handler + * @param string|null $action + * @return self + * @throws RouteException + */ + public function get(string $path, $handler, string $action = null): self + { + return $this->addRoute(['GET'], $path, $handler, $action); + } + + /** + * Define a POST route. + * @param string $path + * @param $handler + * @param string|null $action + * @return self + * @throws RouteException + */ + public function post(string $path, $handler, string $action = null): self + { + return $this->addRoute(['POST'], $path, $handler, $action); + } + + /** + * Define a PUT route. + * @param string $path + * @param $handler + * @param string|null $action + * @return self + * @throws RouteException + */ + public function put(string $path, $handler, string $action = null): self + { + return $this->addRoute(['PUT'], $path, $handler, $action); + } + + /** + * Define a DELETE route. + * @param string $path + * @param $handler + * @param string|null $action + * @return self + * @throws RouteException + */ + public function delete(string $path, $handler, string $action = null): self + { + return $this->addRoute(['DELETE'], $path, $handler, $action); + } + + /** + * Group routes under a shared name and configuration. + * @param string $name + * @param callable $callback + * @return $this + */ + public function group(string $name, callable $callback): self + { + if ($this->inGroup) { + throw new LogicException('Nested route groups are not supported.'); + } + + $this->currentGroupName = $name; + $this->inGroup = true; + $this->groupRoutes = []; + $this->groupMiddlewares = []; + + $callback($this); + + /** @phpstan-ignore-next-line */ + foreach ($this->groupRoutes as $route) { + $route->addMiddlewares($this->groupMiddlewares); + } + + $this->lastGroupRoutes = $this->groupRoutes; + + $this->inGroup = false; + $this->groupRoutes = []; + $this->groupMiddlewares = []; + $this->currentGroupName = null; + $this->currentRoute = null; + + return $this; + } + + /** + * Apply middlewares to the current route or group. + * @param array $middlewares + * @return $this + */ + public function middlewares(array $middlewares): self + { + if ($this->currentRoute !== null) { + $this->currentRoute->addMiddlewares($middlewares); + return $this; + } + + if ($this->inGroup) { + $this->groupMiddlewares = array_merge( + $this->groupMiddlewares, + $middlewares + ); + + return $this; + } + + if ($this->lastGroupRoutes !== null) { + foreach ($this->lastGroupRoutes as $route) { + $route->addMiddlewares($middlewares); + } + + $this->lastGroupRoutes = null; + return $this; + } + + throw new LogicException( + 'middlewares() must be called inside a group or after a route definition.' + ); + } + + /** + * Assign a unique name to the current route. + * @param string $name + * @return $this + */ + public function name(string $name): self + { + if ($this->currentRoute === null) { + throw new LogicException('No route defined to name.'); + } + $currentModule = $this->currentModule; - $closure($routeCollector); + foreach ($this->collection->all() as $route) { + if ($route->getName() === null) { + continue; + } - foreach ($routeCollector->getRuntimeRoutes() as $runtimeRoute) { - $allRoutes[] = $runtimeRoute; + // Enforce uniqueness only within the same module context. + if ($route->getName() === $name && $route->getModule() === $currentModule) { + throw new LogicException("Route name '{$name}' must be unique within module."); } } - return $allRoutes; + $this->currentRoute->name($name); + return $this; } + + /** + * Enable or disable caching for the current route or group. + * @param bool $enabled + * @param int|null $ttl + * @return $this + */ + public function cacheable(bool $enabled, ?int $ttl = null): self + { + if ($this->inGroup) { + foreach ($this->groupRoutes as $route) { + $route->cache($enabled, $ttl); + } + + return $this; + } + + if ($this->lastGroupRoutes !== null) { + foreach ($this->lastGroupRoutes as $route) { + $route->cache($enabled, $ttl); + } + + $this->lastGroupRoutes = null; + return $this; + } + + if ($this->currentRoute !== null) { + $this->currentRoute->cache($enabled, $ttl); + return $this; + } + + throw new LogicException( + 'cacheable() must be called inside a group or after a route definition.' + ); + } + + /** + * Create and register a Route instance. + * @param array $methods + * @param string $path + * @param $handler + * @param string|null $action + * @return self + * @throws Exceptions\RouteException + */ + private function addRoute( + array $methods, + string $path, + $handler, + ?string $action + ): self { + if ($methods === []) { + throw new InvalidArgumentException('At least one HTTP method is required.'); + } + + $pattern = $this->resolvePath($path); + + if ($handler instanceof Closure) { + $route = new Route( + $methods, + $pattern, + null, + null, + $handler + ); + } else { + if (!is_string($handler) || $action === null) { + throw new InvalidArgumentException( + 'Controller routes require controller class and action name.' + ); + } + + if (strpos($handler, '\\') === false) { + if ($this->currentModule === null) { + throw new LogicException( + 'Cannot resolve controller without module context.' + ); + } + + $handler = + module_base_namespace() + . '\\' + . $this->currentModule + . '\\Controllers\\' + . $handler; + } + + $route = new Route( + $methods, + $pattern, + $handler, + $action, + null + ); + } + + [$compiled, $params] = $this->patternCompiler->compile($route); + + $route + ->module($this->currentModule) + ->prefix($this->currentPrefix) + ->group($this->currentGroupName) + ->setCompiledPattern($compiled); + + $this->collection->add($route); + $this->currentRoute = $route; + + if ($this->inGroup) { + $this->groupRoutes[] = $route; + } + + return $this; + } + + /** + * Resolve path using the current prefix. + * @param string $path + * @return string + */ + private function resolvePath(string $path): string + { + $path = '/' . ltrim($path, '/'); + + if ($this->currentPrefix === null || $this->currentPrefix === '') { + return $path; + } + + return '/' . trim($this->currentPrefix . $path, '/'); + } + } diff --git a/src/Router/RouteCollection.php b/src/Router/RouteCollection.php new file mode 100644 index 00000000..2d5ff3b6 --- /dev/null +++ b/src/Router/RouteCollection.php @@ -0,0 +1,56 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Router; + +/** + * Class RouteCollection + * @internal Internal collection of Route descriptors. + * @package Quantum\Router + */ +final class RouteCollection +{ + /** + * @var Route[] + */ + private array $routes = []; + + /** + * Add a route to the collection. + * @param Route $route + * @return void + */ + public function add(Route $route): void + { + $this->routes[] = $route; + } + + /** + * Return all routes in insertion order. + * @return Route[] + */ + public function all(): array + { + return $this->routes; + } + + /** + * Return total number of routes. + * @return int + */ + public function count(): int + { + return count($this->routes); + } +} diff --git a/src/Router/RouteController.php b/src/Router/RouteController.php deleted file mode 100644 index cfa7b6c6..00000000 --- a/src/Router/RouteController.php +++ /dev/null @@ -1,87 +0,0 @@ - - * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) - * @link http://quantum.softberg.org/ - * @since 3.0.0 - */ - -namespace Quantum\Router; - -use Quantum\Router\Exceptions\RouteControllerException; - -/** - * RouterController Class - * @package Quantum\Router - */ -abstract class RouteController -{ - /** - * List of routes - * @var array - */ - protected static $routes = []; - - /** - * Contains current route information - * @var array - */ - protected static $currentRoute = []; - - /** - * @var bool - */ - public $csrfVerification = true; - - /** - * Gets the current route - * @return array - */ - public static function getCurrentRoute(): ?array - { - return self::$currentRoute; - } - - /** - * @param array $route - */ - public static function setCurrentRoute(array $route) - { - self::$currentRoute = $route; - } - - /** - * Set Routes - * @param array $routes - */ - public static function setRoutes(array $routes) - { - static::$routes = $routes; - } - - /** - * Get Routes - * @return array - */ - public static function getRoutes(): array - { - return static::$routes; - } - - /** - * Handles the missing methods of the controller - * @param string $method - * @param array $arguments - * @throws RouteControllerException - */ - public function __call(string $method, array $arguments) - { - throw RouteControllerException::actionNotDefined($method); - } -} diff --git a/src/Router/RouteDispatcher.php b/src/Router/RouteDispatcher.php index f2469def..b31368ba 100644 --- a/src/Router/RouteDispatcher.php +++ b/src/Router/RouteDispatcher.php @@ -14,123 +14,129 @@ namespace Quantum\Router; -use Quantum\Router\Exceptions\RouteControllerException; use Quantum\Libraries\Csrf\Exceptions\CsrfException; use Quantum\Di\Exceptions\DiException; use Quantum\Libraries\Csrf\Csrf; +use Quantum\Http\Response; use Quantum\Http\Request; use ReflectionException; +use RuntimeException; +use LogicException; use Quantum\Di\Di; -class RouteDispatcher +/** + * Class RouteDispatcher + * @package Quantum\Router + */ +final class RouteDispatcher { /** - * Handles the incoming HTTP request. + * Dispatch a matched route. + * @param MatchedRoute $matched * @param Request $request + * @param Response $response * @return void - * @throws RouteControllerException - * @throws CsrfException * @throws DiException - * @throws ReflectionException + * @throws ReflectionException|CsrfException */ - public static function handle(Request $request): void + public function dispatch(MatchedRoute $matched, Request $request, Response $response): void { - $callback = route_callback(); + $route = $matched->getRoute(); + $params = $matched->getParams(); + + if ($route->isClosure()) { + $closure = $route->getClosure(); - if ($callback instanceof \Closure) { - self::callControllerMethod($callback); + if ($closure === null) { + throw new LogicException('Closure route missing closure.'); + } + + $this->invoke($closure, $params); return; } - $controller = self::resolveController(); - $action = self::resolveAction($controller); + $callable = $this->resolveControllerCallable($route); + [$controller] = $callable; + + $this->verifyCsrf($controller, $request); + + $this->callHook($controller, '__before', $params); - self::verifyCsrf($controller, $request); + $this->invoke($callable, $params); - self::callControllerHook($controller, '__before'); - self::callControllerMethod([$controller, $action]); - self::callControllerHook($controller, '__after'); + $this->callHook($controller, '__after', $params); } /** - * Loads and gets the current route's controller instance. - * @return RouteController - * @throws RouteControllerException + * Resolve controller callable from the route definition. + * @param Route $route + * @return array */ - private static function resolveController(): RouteController + private function resolveControllerCallable(Route $route): array { - $controllerClass = module_base_namespace() . '\\' . current_module() . '\\Controllers\\' . current_controller(); + $controllerClass = $route->getController(); + $action = $route->getAction(); - if (!class_exists($controllerClass)) { - throw RouteControllerException::controllerNotDefined($controllerClass); + if ($controllerClass === null || $action === null) { + throw new LogicException('Non-closure route must define controller and action.'); } - return new $controllerClass(); - } - - /** - * Retrieves the current route's action for the controller. - * @param RouteController $controller - * @return string - * @throws RouteControllerException - */ - private static function resolveAction(RouteController $controller): string - { - $action = current_action(); + $controller = new $controllerClass(); - if (!$action || !method_exists($controller, $action)) { - throw RouteControllerException::actionNotDefined($action); + if (!method_exists($controller, $action)) { + throw new RuntimeException("Action {$action} not found on controller {$controllerClass}"); } - return $action; + return [$controller, $action]; } /** - * Calls controller method + * Invoke a callable with parameters resolved via DI autowiring. * @param callable $callable + * @param array $params * @return void * @throws DiException * @throws ReflectionException */ - private static function callControllerMethod(callable $callable) + private function invoke(callable $callable, array $params): void { - call_user_func_array($callable, Di::autowire($callable, self::getRouteParams())); + call_user_func_array( + $callable, + Di::autowire($callable, $params) + ); } /** - * Calls controller lifecycle method if it exists. + * Invoke a controller lifecycle hook if it exists. * @param object $controller - * @param string $method + * @param string $hook + * @param array $params * @return void * @throws DiException * @throws ReflectionException */ - private static function callControllerHook(object $controller, string $method): void + private function callHook(object $controller, string $hook, array $params): void { - if (method_exists($controller, $method)) { - self::callControllerMethod([$controller, $method]); + if (method_exists($controller, $hook)) { + $this->invoke([$controller, $hook], $params); } } /** - * Retrieves the route parameters from the current route. - * @return array - */ - private static function getRouteParams(): array - { - return array_column(route_params(), 'value'); - } - - /** - * Verifies CSRF token if required - * @param RouteController $controller + * Verify CSRF token if controller requires it and request method applies. + * @param object|null $controller * @param Request $request * @return void * @throws CsrfException */ - private static function verifyCsrf(RouteController $controller, Request $request): void + private function verifyCsrf(?object $controller, Request $request): void { - if ($controller->csrfVerification && in_array($request->getMethod(), Csrf::METHODS, true)) { + if ( + $controller !== null && + property_exists($controller, 'csrfVerification') && + $controller->csrfVerification === true && + in_array($request->getMethod(), Csrf::METHODS, true) + ) { csrf()->checkToken($request); } } diff --git a/src/Router/RouteFinder.php b/src/Router/RouteFinder.php new file mode 100644 index 00000000..b15e95f6 --- /dev/null +++ b/src/Router/RouteFinder.php @@ -0,0 +1,72 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Router; + +use Quantum\Http\Request; + +/** + * Class RouteFinder + * @internal Resolves an incoming request to a matched route. + * @package Quantum\Router + */ +final class RouteFinder +{ + /** + * @var PatternCompiler + */ + private PatternCompiler $patternCompiler; + + /** + * @var RouteCollection + */ + private RouteCollection $routes; + + /** + * @param RouteCollection $routes + */ + public function __construct(RouteCollection $routes) + { + $this->patternCompiler = new PatternCompiler(); + $this->routes = $routes; + } + + /** + * Find the first route that matches request method and URI. + * @param Request $request + * @return MatchedRoute|null + * @throws Exceptions\RouteException + */ + public function find(Request $request): ?MatchedRoute + { + $method = $request->getMethod(); + $uri = $request->getUri(); + + foreach ($this->routes->all() as $route) { + if (!$route->allowsMethod($method)) { + continue; + } + + if (!$this->patternCompiler->match($route, $uri)) { + continue; + } + + $params = $this->patternCompiler->getParams(); + + return new MatchedRoute($route, $params); + } + + return null; + } +} diff --git a/src/Router/Router.php b/src/Router/Router.php deleted file mode 100644 index a626a11d..00000000 --- a/src/Router/Router.php +++ /dev/null @@ -1,404 +0,0 @@ - - * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) - * @link http://quantum.softberg.org/ - * @since 3.0.0 - */ - -namespace Quantum\Router; - -use Quantum\App\Exceptions\StopExecutionException; -use Quantum\Libraries\ResourceCache\ViewCache; -use Quantum\Config\Exceptions\ConfigException; -use Quantum\Router\Exceptions\RouteException; -use Quantum\App\Exceptions\BaseException; -use Quantum\Di\Exceptions\DiException; -use DebugBar\DebugBarException; -use Quantum\Debugger\Debugger; -use Quantum\Http\Request; -use ReflectionException; - -/** - * Class Router - * @package Quantum\Router - */ -class Router extends RouteController -{ - public const VALID_PARAM_NAME_PATTERN = '/^[a-zA-Z]+$/'; - - /** - * Parameter types - */ - public const PARAM_TYPES = [ - ':alpha' => '[a-zA-Z]', - ':num' => '[0-9]', - ':any' => '[^\/]', - ]; - - /** - * Request instance - * @var Request - */ - private $request; - - /** - * matched routes - * @var array - */ - private $matchedRoutes = []; - - /** - * Router constructor. - * @param Request $request - */ - public function __construct(Request $request) - { - $this->request = $request; - } - - /** - * Finds the current route - * @throws BaseException - * @throws ConfigException - * @throws DebugBarException - * @throws DiException - * @throws ReflectionException - * @throws RouteException - * @throws StopExecutionException - */ - public function findRoute() - { - $uri = $this->request->getUri(); - - if (!$uri) { - throw RouteException::routeNotFound(); - } - - $this->matchedRoutes = $this->findMatches($uri); - - if ($this->matchedRoutes === []) { - stop(function () { - page_not_found(); - }); - } - - if (count($this->matchedRoutes) > 1) { - $this->checkCollision(); - } - - $currentRoute = $this->currentRoute(); - - if (!$currentRoute) { - throw RouteException::incorrectMethod($this->request->getMethod()); - } - - $this->handleCaching($currentRoute); - - self::setCurrentRoute($currentRoute); - - info($this->collectDebugData($currentRoute), ['tab' => Debugger::ROUTES]); - } - - /** - * Resets the routes - */ - public function resetRoutes() - { - parent::$currentRoute = []; - $this->matchedRoutes = []; - } - - /** - * @param array $route - * @return void - */ - private function handleCaching(array $route): void - { - $viewCache = ViewCache::getInstance(); - - $defaultCaching = $viewCache->isEnabled(); - - $shouldCacheForRoute = $route['cache_settings']['shouldCache'] ?? $defaultCaching; - - $viewCache->enableCaching($shouldCacheForRoute); - - if ($shouldCacheForRoute && !empty($route['cache_settings']['ttl'])) { - $viewCache->setTtl($route['cache_settings']['ttl']); - } - } - - /** - * Gets the current route - * @return array|null - */ - private function currentRoute(): ?array - { - foreach ($this->matchedRoutes as $matchedRoute) { - if ($this->checkMethod($matchedRoute)) { - return $matchedRoute; - } - } - - return null; - } - - /** - * Collects debug data - * @param array $currentRoute - * @return array - */ - private function collectDebugData(array $currentRoute): array - { - $routeInfo = []; - - foreach ($currentRoute as $key => $value) { - $routeInfo[ucfirst($key)] = json_encode($value); - } - - return $routeInfo; - } - - /** - * Finds matches by pattern - * @param string $uri - * @return array - * @throws RouteException - */ - private function findMatches(string $uri): array - { - $requestUri = urldecode(parse_url($uri, PHP_URL_PATH)); - - $matches = []; - - foreach (self::$routes as $route) { - [$pattern, $params] = $this->handleRoutePattern($route); - - if (preg_match('/^' . $this->escape($pattern) . '$/u', $requestUri, $matchedParams)) { - $route['uri'] = $uri; - $route['params'] = $this->routeParams($params, $matchedParams); - $route['pattern'] = $pattern; - $matches[] = $route; - } - } - - return $matches; - } - - /** - * Handles the route pattern - * @param array $route - * @return array - * @throws RouteException - */ - private function handleRoutePattern(array $route): array - { - $routeSegments = explode('/', trim($route['route'], '/')); - - $routePattern = '(\/)?'; - $routeParams = []; - - $lastIndex = (int) array_key_last($routeSegments); - - foreach ($routeSegments as $index => $segment) { - $segmentParam = $this->getSegmentParam($segment, $index, $lastIndex); - - if ($segmentParam !== []) { - $this->checkParamName($routeParams, $segmentParam['name']); - - $routeParams[] = [ - 'route_pattern' => $segment, - 'pattern' => $segmentParam['pattern'], - 'name' => $segmentParam['name'], - ]; - - $routePattern = $this->normalizePattern($routePattern, $segmentParam, $index, $lastIndex); - } else { - $routePattern .= $segment . ($index !== $lastIndex ? '(\/)' : ''); - } - } - - return [ - $routePattern, - $routeParams, - ]; - } - - /** - * Normalize the pattern - * @param string $routePattern - * @param array $segmentParam - * @param int $index - * @param int $lastIndex - * @return string - */ - private function normalizePattern(string $routePattern, array $segmentParam, int $index, int $lastIndex): string - { - if ($index === $lastIndex) { - if (mb_substr($routePattern, -5) === '(\/)?') { - $routePattern = mb_substr($routePattern, 0, mb_strlen($routePattern) - 5); - } elseif (mb_substr($routePattern, -4) === '(\/)') { - $routePattern = mb_substr($routePattern, 0, mb_strlen($routePattern) - 4); - } - } - - return $routePattern . $segmentParam['pattern']; - } - - /** - * Gets the route parameters - * @param array $params - * @param array $arguments - * @return array - */ - private function routeParams(array $params, array $arguments): array - { - $arguments = array_diff($arguments, ['', '/']); - - foreach ($params as &$param) { - $param['value'] = $arguments[$param['name']] ?? null; - if (mb_substr($param['name'], 0, 1) === '_') { - $param['name'] = null; - } - } - - return $params; - } - - /** - * Checks the segment for parameter - * @param string $segment - * @param int $index - * @param int $lastIndex - * @return array - * @throws RouteException - */ - private function getSegmentParam(string $segment, int $index, int $lastIndex): array - { - foreach (self::PARAM_TYPES as $type => $expr) { - if (preg_match('/\[(.*=)*(' . $type . ')(:([0-9]+))*\](\?)?/', $segment, $match)) { - return $this->getParamPattern($match, $expr, $index, $lastIndex); - } - } - - return []; - } - - /** - * Checks the parameter name availability - * @param array $routeParams - * @param string $name - * @throws RouteException - */ - private function checkParamName(array $routeParams, string $name) - { - foreach ($routeParams as $param) { - if ($param['name'] == $name) { - throw RouteException::paramNameNotAvailable($name); - } - } - } - - /** - * Finds pattern for parameter - * @param array $match - * @param string $expr - * @param int $index - * @param int $lastIndex - * @return array - * @throws RouteException - */ - private function getParamPattern(array $match, string $expr, int $index, int $lastIndex): array - { - $name = $this->getParamName($match, $index); - - $pattern = '(?<' . $name . '>' . $expr; - - if (isset($match[4]) && is_numeric($match[4])) { - $pattern .= (isset($match[5]) && $match[5] == '?') ? '{0,' . $match[4] . '})' : '{' . $match[4] . '})'; - } else { - $pattern .= (isset($match[5]) && $match[5] == '?') ? '*)' : '+)'; - } - - if (isset($match[5]) && $match[5] == '?') { - $pattern = ($index === $lastIndex ? '(\/)?' . $pattern : $pattern . '(\/)?'); - } else { - $pattern = ($index === $lastIndex ? '(\/)' . $pattern : $pattern . '(\/)'); - } - - return [ - 'name' => $name, - 'pattern' => $pattern, - ]; - } - - /** - * Gets the parameter name - * @param array $match - * @param int $index - * @return string - * @throws RouteException - */ - private function getParamName(array $match, int $index): string - { - $name = $match[1] ? rtrim($match[1], '=') : null; - - if ($name === null) { - return '_segment' . $index; - } - - if (!preg_match(self::VALID_PARAM_NAME_PATTERN, $name)) { - throw RouteException::paramNameNotValid(); - } - - return $name; - } - - /** - * Checks the route collisions - * @throws RouteException - */ - private function checkCollision() - { - $length = count($this->matchedRoutes); - - for ($i = 0; $i < $length - 1; $i++) { - for ($j = $i + 1; $j < $length; $j++) { - if ($this->matchedRoutes[$i]['method'] == $this->matchedRoutes[$j]['method']) { - throw RouteException::repetitiveRouteSameMethod($this->matchedRoutes[$j]['method']); - } - if ($this->matchedRoutes[$i]['module'] != $this->matchedRoutes[$j]['module']) { - throw RouteException::repetitiveRouteDifferentModules(); - } - } - } - } - - /** - * Checks the request method against defined route method - * @param array $matchedRoute - * @return bool - */ - private function checkMethod(array $matchedRoute): bool - { - $allowedMethods = explode('|', $matchedRoute['method']); - - return in_array($this->request->getMethod(), $allowedMethods, true); - } - - /** - * Escapes the slashes - * @param string $str - * @return string - */ - private function escape(string $str): string - { - return str_replace('/', '\/', stripslashes($str)); - } -} diff --git a/tests/Unit/App/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php index d5667e3e..de7ea4cf 100644 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/WebAppAdapterTest.php @@ -7,7 +7,6 @@ use Quantum\Http\Request; use Quantum\App\App; use Quantum\Di\Di; -use Exception; class WebAppAdapterTest extends TestCase { @@ -23,6 +22,7 @@ public function setUp(): void public function tearDown(): void { config()->flush(); + Di::reset(); } public function testWebAppAdapterStartSuccessfully() @@ -40,8 +40,18 @@ public function testWebAppAdapterStartFails() $request = Di::get(Request::class); $request->create('POST', ''); - $this->expectException(Exception::class); + $result = $this->webAppAdapter->start(); + + $this->assertSame(0, $result); + } + + public function testWebAppAdapterHandlesPageNotFoundGracefully() + { + $request = Di::get(Request::class); + $request->create('GET', '/non-existing-uri'); + + $result = $this->webAppAdapter->start(); - $this->webAppAdapter->start(); + $this->assertSame(0, $result); } } diff --git a/tests/Unit/App/AppTest.php b/tests/Unit/App/AppTest.php index f1f82307..61bdc39f 100644 --- a/tests/Unit/App/AppTest.php +++ b/tests/Unit/App/AppTest.php @@ -21,6 +21,8 @@ public function setUp(): void { parent::setUp(); + Di::reset(); + App::setBaseDir(PROJECT_ROOT); } diff --git a/tests/Unit/AppTestCase.php b/tests/Unit/AppTestCase.php index cc6e15db..7d36dc78 100644 --- a/tests/Unit/AppTestCase.php +++ b/tests/Unit/AppTestCase.php @@ -5,9 +5,10 @@ use Quantum\Libraries\Storage\Factories\FileSystemFactory; use Quantum\App\Factories\AppFactory; use Quantum\Environment\Environment; -use Quantum\Router\RouteController; +use Quantum\Router\MatchedRoute; use PHPUnit\Framework\TestCase; use Quantum\Debugger\Debugger; +use Quantum\Router\Route; use Quantum\Http\Request; use Quantum\Loader\Setup; use ReflectionClass; @@ -31,6 +32,9 @@ public function setUp(): void public function tearDown(): void { + Request::setMatchedRoute(null); + Request::flush(); + AppFactory::destroy(App::WEB); config()->flush(); Debugger::getInstance()->resetStore(); @@ -75,12 +79,20 @@ protected function testRequest( $request->create($method, $uri, $body, $headers); - RouteController::setCurrentRoute([ - 'route' => 'test', - 'method' => $method, - 'controller' => 'TestController', - 'action' => 'testAction', - 'module' => 'Test', - ]); + $route = new Route( + [$method], + $uri, + 'TestController', + 'testAction', + null + ); + $route->module('Test'); + + $matchedRoute = new MatchedRoute($route, []); + Request::setMatchedRoute($matchedRoute); + + if (!Di::isRegistered(Request::class)) { + Di::set(Request::class, $request); + } } } diff --git a/tests/Unit/Di/DiTest.php b/tests/Unit/Di/DiTest.php index caff07a8..377f659f 100644 --- a/tests/Unit/Di/DiTest.php +++ b/tests/Unit/Di/DiTest.php @@ -4,11 +4,10 @@ use Quantum\Service\DummyServiceInterface; use Quantum\View\Factories\ViewFactory; - use Quantum\Router\RouteController; use Quantum\Http\Request; use Quantum\Http\Response; - class TestDiController extends RouteController + class TestDiController { public function index(Request $request, Response $response, ViewFactory $view) { @@ -171,7 +170,7 @@ public function testDiSetRejectsWhenAlreadyResolved(): void { Di::register(DummyService::class); - Di::get(DummyService::class); // resolve singleton first + Di::get(DummyService::class); $this->expectException(DiException::class); $this->expectExceptionMessage( @@ -181,6 +180,19 @@ public function testDiSetRejectsWhenAlreadyResolved(): void Di::set(DummyService::class, new DummyService()); } + public function testDiSetOverridesRegisteredButNotResolved(): void + { + Di::register(DummyService::class, DummyServiceInterface::class); + + $instance = new DummyService(); + + Di::set(DummyServiceInterface::class, $instance); + + $resolved = Di::get(DummyServiceInterface::class); + + $this->assertSame($instance, $resolved); + } + public function testDiGetCoreDependencies() { $this->assertInstanceOf(Loader::class, Di::get(Loader::class)); diff --git a/tests/Unit/Http/Helpers/HttpHelperTest.php b/tests/Unit/Http/Helpers/HttpHelperTest.php index 8a6798d9..069c0f4e 100644 --- a/tests/Unit/Http/Helpers/HttpHelperTest.php +++ b/tests/Unit/Http/Helpers/HttpHelperTest.php @@ -5,9 +5,12 @@ use Quantum\Libraries\Session\Factories\SessionFactory; use Quantum\App\Exceptions\StopExecutionException; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Router\Router; +use Quantum\Router\RouteCollection; +use Quantum\Router\RouteFinder; +use Quantum\Router\Route; use Quantum\Http\Response; use Quantum\Http\Request; +use Quantum\Di\Di; class HttpHelperTest extends AppTestCase { @@ -44,22 +47,35 @@ public function testBaseUrlWithModulePrefix() { config()->set('app.base_url', null); - $router = new Router($this->request); + $routeCollection = new RouteCollection(); - Router::setRoutes([ - [ - 'route' => 'signin', - 'method' => 'GET', - 'controller' => 'AdminController', - 'action' => 'signin', - 'module' => 'admin', - 'prefix' => 'admin', - ], - ]); + $route = new Route( + ['GET'], + '/signin', + 'AdminController', + 'signin', + null + ); + $route->module('admin')->prefix('admin'); + + $routeCollection->add($route); + + // Register route collection in DI + Di::set(RouteCollection::class, $routeCollection); + + // Create route finder and find the route + $router = new RouteFinder($routeCollection); $this->request->create('GET', 'https://testdomain.com/signin'); - $router->findRoute(); + // Register request in DI for route finding (only if not already registered) + if (!Di::isRegistered(Request::class)) { + Di::set(Request::class, $this->request); + } + + $matchedRoute = $router->find($this->request); + + Request::setMatchedRoute($matchedRoute); $baseUrl = base_url(true); diff --git a/tests/Unit/Http/Traits/Request/HttpRequestRouteTest.php b/tests/Unit/Http/Traits/Request/HttpRequestRouteTest.php new file mode 100644 index 00000000..37a38f2a --- /dev/null +++ b/tests/Unit/Http/Traits/Request/HttpRequestRouteTest.php @@ -0,0 +1,67 @@ +setMatchedRoute(null); + $diRequest->flush(); + } + + parent::tearDown(); + } + + public function testGetMatchedRouteReturnsNullByDefault() + { + $this->assertNull(Request::getMatchedRoute()); + } + + public function testSetAndGetMatchedRoute() + { + $route = new Route( + ['GET'], + '/test', + 'TestController', + 'tests', + null + ); + + $matched = new MatchedRoute($route, ['id' => 1]); + + Request::setMatchedRoute($matched); + + $this->assertSame($matched, Request::getMatchedRoute()); + } + + public function testSetMatchedRouteToNullResetsState() + { + $route = new Route( + ['GET'], + '/test', + 'TestController', + 'tests', + null + ); + + $matched = new MatchedRoute($route, []); + + Request::setMatchedRoute($matched); + $this->assertNotNull(Request::getMatchedRoute()); + + Request::setMatchedRoute(null); + $this->assertNull(Request::getMatchedRoute()); + } +} diff --git a/tests/Unit/Libraries/Lang/LangTest.php b/tests/Unit/Libraries/Lang/LangTest.php index f7f83eb8..cbf79edf 100644 --- a/tests/Unit/Libraries/Lang/LangTest.php +++ b/tests/Unit/Libraries/Lang/LangTest.php @@ -3,7 +3,10 @@ namespace Quantum\Tests\Unit\Libraries\Lang; use Quantum\Libraries\Lang\Translator; -use Quantum\Router\RouteController; +use Quantum\Router\MatchedRoute; +use Quantum\Router\Route; +use Quantum\Http\Request; +use Quantum\Di\Di; use Quantum\Tests\Unit\AppTestCase; use Quantum\Libraries\Lang\Lang; @@ -18,13 +21,23 @@ public function setUp(): void $translator = new Translator('en'); $this->lang = new Lang('en', true, $translator); - RouteController::setCurrentRoute([ - 'route' => 'api-signin', - 'method' => 'POST', - 'controller' => 'SomeController', - 'action' => 'signin', - 'module' => 'Test', - ]); + $route = new Route( + ['POST'], + '/api-signin', + 'SomeController', + 'signin', + null + ); + $route->module('Test'); + + $matchedRoute = new MatchedRoute($route, []); + Request::setMatchedRoute($matchedRoute); + + if (!Di::isRegistered(Request::class)) { + $request = new Request(); + $request->create('POST', '/api-signin'); + Di::set(Request::class, $request); + } } public function testLangGetSet() diff --git a/tests/Unit/Module/ModuleManagerTest.php b/tests/Unit/Module/ModuleManagerTest.php index a3cef9d6..5ebdbf4f 100644 --- a/tests/Unit/Module/ModuleManagerTest.php +++ b/tests/Unit/Module/ModuleManagerTest.php @@ -2,7 +2,6 @@ namespace Quantum\Tests\Unit\Module; -use Quantum\Router\RouteController; use Quantum\Tests\Unit\AppTestCase; use Quantum\Module\ModuleManager; use Quantum\Http\Response; @@ -30,7 +29,6 @@ public function testCreateModule() $moduleManager->addModuleConfig(); $modules = $this->fs->require($this->modulesConfigPath); - ; $this->assertEquals('Api', $moduleManager->getModuleName()); @@ -40,8 +38,6 @@ public function testCreateModule() $mainController = new \Quantum\Tests\_root\modules\Api\Controllers\MainController(); - $this->assertInstanceOf(RouteController::class, $mainController); - $response = new Response(); $mainController->index($response); diff --git a/tests/Unit/Renderer/Adapters/HtmlAdapterTest.php b/tests/Unit/Renderer/Adapters/HtmlAdapterTest.php index bcd68af2..0b00bb2d 100644 --- a/tests/Unit/Renderer/Adapters/HtmlAdapterTest.php +++ b/tests/Unit/Renderer/Adapters/HtmlAdapterTest.php @@ -4,7 +4,10 @@ use Quantum\Renderer\Adapters\HtmlAdapter; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Router\Router; +use Quantum\Router\MatchedRoute; +use Quantum\Router\Route; +use Quantum\Http\Request; +use Quantum\Di\Di; class HtmlAdapterTest extends AppTestCase { @@ -12,13 +15,20 @@ public function setUp(): void { parent::setUp(); - Router::setCurrentRoute([ - 'route' => 'test', - 'method' => 'GET', - 'controller' => 'SomeController', - 'action' => 'test', - 'module' => 'Test', - ]); + $route = new Route( + ['GET'], + '/test', + 'SomeController', + 'test', + null + ); + $route->module('Test'); + + $matchedRoute = new MatchedRoute($route, []); + + $request = Di::get(Request::class); + $request->create('GET', '/test'); + Request::setMatchedRoute($matchedRoute); } public function testHtmlAdapterRenderView(): void diff --git a/tests/Unit/Renderer/Adapters/TwigAdapterTest.php b/tests/Unit/Renderer/Adapters/TwigAdapterTest.php index 22a14773..344792ad 100644 --- a/tests/Unit/Renderer/Adapters/TwigAdapterTest.php +++ b/tests/Unit/Renderer/Adapters/TwigAdapterTest.php @@ -4,7 +4,10 @@ use Quantum\Renderer\Adapters\TwigAdapter; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Router\Router; +use Quantum\Router\MatchedRoute; +use Quantum\Router\Route; +use Quantum\Http\Request; +use Quantum\Di\Di; class TwigAdapterTest extends AppTestCase { @@ -12,13 +15,21 @@ public function setUp(): void { parent::setUp(); - Router::setCurrentRoute([ - 'route' => 'test', - 'method' => 'GET', - 'controller' => 'SomeController', - 'action' => 'test', - 'module' => 'Test', - ]); + $route = new Route( + ['GET'], + '/test', + 'SomeController', + 'test', + null + ); + $route->module('Test'); + + $matchedRoute = new MatchedRoute($route, []); + Request::setMatchedRoute($matchedRoute); + + $request = Di::get(Request::class); + $request->create('GET', '/test'); + Request::setMatchedRoute($matchedRoute); } public function testHtmlAdapterRenderView(): void diff --git a/tests/Unit/Renderer/RendererTest.php b/tests/Unit/Renderer/RendererTest.php index 87bc4c4b..3d5b77bb 100644 --- a/tests/Unit/Renderer/RendererTest.php +++ b/tests/Unit/Renderer/RendererTest.php @@ -6,9 +6,12 @@ use Quantum\Renderer\Exceptions\RendererException; use Quantum\Renderer\Adapters\TwigAdapter; use Quantum\Renderer\Adapters\HtmlAdapter; -use Quantum\Renderer\Renderer; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Router\Router; +use Quantum\Router\MatchedRoute; +use Quantum\Renderer\Renderer; +use Quantum\Router\Route; +use Quantum\Http\Request; +use Quantum\Di\Di; class RendererTest extends AppTestCase { @@ -16,13 +19,23 @@ public function setUp(): void { parent::setUp(); - Router::setCurrentRoute([ - 'route' => 'test', - 'method' => 'GET', - 'controller' => 'SomeController', - 'action' => 'test', - 'module' => 'Test', - ]); + $route = new Route( + ['GET'], + '/test', + 'SomeController', + 'test', + null + ); + $route->module('Test'); + + $matchedRoute = new MatchedRoute($route, []); + Request::setMatchedRoute($matchedRoute); + + if (!Di::isRegistered(Request::class)) { + $request = new Request(); + $request->create('GET', '/test'); + Di::set(Request::class, $request); + } } public function testRendererGetHtmlAdapter() diff --git a/tests/Unit/Router/Helpers/RouteHelpersTest.php b/tests/Unit/Router/Helpers/RouteHelpersTest.php new file mode 100644 index 00000000..6c3f8e1f --- /dev/null +++ b/tests/Unit/Router/Helpers/RouteHelpersTest.php @@ -0,0 +1,225 @@ +setMatchedRoute(null); + $diRequest->flush(); + } + + parent::tearDown(); + } + + public function testHelpersReturnDefaultsWhenNoRouteMatched() + { + $this->assertNull(current_middlewares()); + $this->assertNull(current_module()); + $this->assertNull(current_controller()); + $this->assertNull(current_action()); + $this->assertNull(route_callback()); + $this->assertNull(current_route()); + $this->assertSame('', route_pattern()); + $this->assertSame([], route_params()); + $this->assertNull(route_param('id')); + $this->assertNull(route_cache_settings()); + $this->assertNull(route_name()); + $this->assertNull(route_prefix()); + } + + public function testHelpersReturnValuesForMatchedControllerRoute() + { + $route = new Route( + ['GET'], + '[:alpha:2]?/post/[uuid=:any]', + 'PostController', + 'show', + null + ); + + $route + ->module('Web') + ->prefix('api') + ->group('content') + ->name('post.show') + ->addMiddlewares(['Auth', 'Editor']) + ->cache(true, 120) + ->setCompiledPattern('compiled-pattern'); + + $matched = new MatchedRoute( + $route, + ['uuid' => 'abc-123'] + ); + + Request::setMatchedRoute($matched); + + $this->assertSame(['Auth', 'Editor'], current_middlewares()); + $this->assertSame('Web', current_module()); + $this->assertSame('PostController', current_controller()); + $this->assertSame('show', current_action()); + $this->assertSame('[:alpha:2]?/post/[uuid=:any]', current_route()); + $this->assertSame('compiled-pattern', route_pattern()); + $this->assertSame(['uuid' => 'abc-123'], route_params()); + $this->assertSame('abc-123', route_param('uuid')); + $this->assertSame(['enabled' => true, 'ttl' => 120], route_cache_settings()); + $this->assertSame('post.show', route_name()); + $this->assertSame('api', route_prefix()); + } + + public function testRouteCallbackReturnsClosureForClosureRoute() + { + $closure = function () { + }; + + $route = new Route( + ['GET'], + 'home', + null, + null, + $closure + ); + + $matched = new MatchedRoute($route, []); + + Request::setMatchedRoute($matched); + + $this->assertSame($closure, route_callback()); + $this->assertNull(current_controller()); + $this->assertNull(current_action()); + } + + public function testFindRouteByNameReturnsRouteFromCollection() + { + $route = new Route( + ['GET'], + 'dashboard', + 'DashboardController', + 'index', + null + ); + + $route->name('Dashboard')->module('Admin'); + + $collection = new RouteCollection(); + $collection->add($route); + + Di::set(RouteCollection::class, $collection); + + $found = find_route_by_name('dashboard', 'admin'); + + $this->assertInstanceOf(Route::class, $found); + $this->assertSame('DashboardController', $found->getController()); + } + + public function testRouteGroupExistsDetectsGroupInModule() + { + $route = new Route( + ['GET'], + 'profile', + 'ProfileController', + 'show', + null + ); + + $route->group('auth')->module('Web'); + + $collection = new RouteCollection(); + $collection->add($route); + + Di::set(RouteCollection::class, $collection); + + $this->assertTrue(route_group_exists('auth', 'web')); + $this->assertFalse(route_group_exists('guest', 'web')); + $this->assertFalse(route_group_exists('auth', 'api')); + } + + public function testRouteMethodAndUri() + { + $request = new Request(); + $request->create('POST', 'http://example.com/api/test'); + + $this->assertSame('POST', route_method()); + + $this->assertSame('api/test', route_uri()); + } + + public function testFindRouteByNameDependsOnCollectionRegistration() + { + $this->assertNull( + find_route_by_name('dashboard', 'admin'), + 'Expected null when RouteCollection is not registered' + ); + + $collection = new RouteCollection(); + Di::set(RouteCollection::class, $collection); + + $this->assertNull( + find_route_by_name('dashboard', 'admin'), + 'Expected null when RouteCollection is empty' + ); + + $route = new Route( + ['GET'], + 'dashboard', + 'DashboardController', + 'index', + null + ); + + $route->name('dashboard')->module('admin'); + $collection->add($route); + + $found = find_route_by_name('dashboard', 'admin'); + + $this->assertInstanceOf(Route::class, $found); + + $this->assertSame('DashboardController', $found->getController()); + } + + public function testRouteGroupExistsDependsOnCollectionRegistration() + { + $this->assertFalse( + route_group_exists('auth', 'web'), + 'Expected false when RouteCollection is not registered' + ); + + $collection = new RouteCollection(); + Di::set(RouteCollection::class, $collection); + + $this->assertFalse( + route_group_exists('auth', 'web'), + 'Expected false when RouteCollection is empty' + ); + + $route = new Route( + ['GET'], + 'profile', + 'ProfileController', + 'show', + null + ); + + $route->group('auth')->module('web'); + $collection->add($route); + + $this->assertTrue( + route_group_exists('auth', 'web'), + 'Expected true when matching group exists in module' + ); + } + +} diff --git a/tests/Unit/Router/Helpers/RouterHelperTest.php b/tests/Unit/Router/Helpers/RouterHelperTest.php deleted file mode 100644 index 1d4269f0..00000000 --- a/tests/Unit/Router/Helpers/RouterHelperTest.php +++ /dev/null @@ -1,151 +0,0 @@ -request = new Request(); - - $this->router = new Router($this->request); - } - - public function testMvcHelpers() - { - Router::setRoutes([ - [ - 'route' => 'signin', - 'method' => 'POST', - 'controller' => 'SomeController', - 'action' => 'signin', - 'module' => 'Test', - 'middlewares' => ['guest', 'anonymous'], - 'prefix' => 'api', - ], - [ - 'route' => 'user/[id=:num]', - 'method' => 'GET', - 'controller' => 'SomeController', - 'action' => 'signout', - 'module' => 'Test', - 'middlewares' => ['user'], - 'name' => 'user', - 'prefix' => 'api', - ], - ]); - - $this->request->create('POST', 'http://testdomain.com/signin'); - - $this->router->findRoute(); - - $middlewares = current_middlewares(); - - $this->assertIsArray($middlewares); - - $this->assertEquals('guest', $middlewares[0]); - - $this->assertEquals('anonymous', $middlewares[1]); - - $this->assertEquals('Test', current_module()); - - $this->assertEquals('SomeController', current_controller()); - - $this->assertEquals('signin', current_action()); - - $this->assertEquals('signin', current_route()); - - $this->assertEmpty(route_params()); - - $this->request->create('GET', 'http://testdomain.com/user/12'); - - $this->router->resetRoutes(); - - $this->router->findRoute(); - - $this->assertNotEmpty(route_params()); - - $this->assertEquals(12, route_param('id')); - - $this->assertEquals('(\/)?user(\/)(?[0-9]+)', route_pattern()); - - $this->assertEquals('GET', route_method()); - - $this->assertEquals('user/12', route_uri()); - - $this->assertEquals('user', route_name()); - - $this->assertEquals('api', route_prefix()); - } - - public function testMvcRouteCallback() - { - Router::setRoutes([ - [ - 'route' => 'home', - 'method' => 'GET', - 'callback' => function (Response $response) { - }, - 'module' => 'Test', - ], - ]); - - $this->request->create('GET', '/home'); - - $this->router->findRoute(); - - $this->assertIsCallable(route_callback()); - } - - public function testMvcFindRouteByName() - { - $this->assertNull(find_route_by_name('user', 'Test')); - - Router::setRoutes([ - [ - 'route' => 'api-user/[id=:num]', - 'method' => 'GET', - 'controller' => 'SomeController', - 'action' => 'signout', - 'module' => 'Test', - 'middlewares' => ['user'], - 'name' => 'user', - ], - ]); - - $this->assertNotNull(find_route_by_name('user', 'Test')); - - $this->assertIsArray(find_route_by_name('user', 'Test')); - } - - public function testMvcCheckRouteGroupExists() - { - $this->assertFalse(route_group_exists('guest', 'Test')); - - Router::setRoutes([ - [ - 'route' => 'api-user/[id=:num]', - 'method' => 'GET', - 'controller' => 'SomeController', - 'action' => 'signout', - 'module' => 'Test', - 'middlewares' => ['user'], - 'group' => 'guest', - 'name' => 'user', - ], - ]); - - $this->assertTrue(route_group_exists('guest', 'Test')); - } - -} diff --git a/tests/Unit/Router/MatchedRouteTest.php b/tests/Unit/Router/MatchedRouteTest.php new file mode 100644 index 00000000..5b687aa7 --- /dev/null +++ b/tests/Unit/Router/MatchedRouteTest.php @@ -0,0 +1,59 @@ + '42']; + + $matched = new MatchedRoute($route, $params); + + $this->assertSame($route, $matched->getRoute()); + $this->assertSame($params, $matched->getParams()); + } + + public function testMatchedRouteSupportsEmptyParams() + { + $route = new Route(['GET'], 'users', 'Ctrl', 'act'); + + $matched = new MatchedRoute($route, []); + + $this->assertSame([], $matched->getParams()); + } + + public function testMatchedRouteParamsAreReturnedAsGiven() + { + $route = new Route(['GET'], 'x', 'Ctrl', 'act'); + + $params = [ + 'id' => '7', + 'slug' => 'hello', + ]; + + $matched = new MatchedRoute($route, $params); + + $this->assertArrayHasKey('id', $matched->getParams()); + + $this->assertArrayHasKey('slug', $matched->getParams()); + + $this->assertSame('7', $matched->getParams()['id']); + + $this->assertSame('hello', $matched->getParams()['slug']); + } + + public function testMatchedRouteRouteInstanceIsExactSameObject() + { + $route = new Route(['POST'], 'submit', 'Ctrl', 'act'); + + $matched = new MatchedRoute($route, []); + + $this->assertTrue($matched->getRoute() === $route); + } +} diff --git a/tests/Unit/Router/PatternCompilerTest.php b/tests/Unit/Router/PatternCompilerTest.php new file mode 100644 index 00000000..775fedab --- /dev/null +++ b/tests/Unit/Router/PatternCompilerTest.php @@ -0,0 +1,173 @@ +compiler = new PatternCompiler(); + } + + public function testPatternCompilerStaticRouteMatch() + { + $this->route = new Route(['GET'], 'users', 'Ctrl', 'act'); + + $this->assertTrue($this->compiler->match($this->route, '/users')); + + $this->assertSame([], $this->compiler->getParams()); + + $this->assertTrue($this->compiler->match($this->route, '/users/')); + + $this->assertFalse($this->compiler->match($this->route, '/users/1')); + } + + public function testPatternCompilerNumericParamMatch() + { + $this->route = new Route(['GET'], 'users/[id=:num]', 'Ctrl', 'act'); + + $this->assertTrue($this->compiler->match($this->route, '/users/42')); + + $this->assertSame(['id' => '42'], $this->compiler->getParams()); + + $this->assertFalse($this->compiler->match($this->route, '/users/abc')); + + $this->assertSame([], $this->compiler->getParams()); + } + + public function testPatternCompilerAlphaParamMatch() + { + $this->route = new Route(['GET'], 'tag/[name=:alpha]', 'Ctrl', 'act'); + + $this->assertTrue($this->compiler->match($this->route, '/tag/test')); + + $this->assertSame(['name' => 'test'], $this->compiler->getParams()); + + $this->assertFalse($this->compiler->match($this->route, '/tag/test1')); + } + + public function testPatternCompilerAnyParamMatch() + { + $this->route = new Route(['GET'], 'file/[path=:any]', 'Ctrl', 'act'); + + $this->assertTrue($this->compiler->match($this->route, '/file/a-b_c')); + + $this->assertSame(['path' => 'a-b_c'], $this->compiler->getParams()); + } + + public function testPatternCompilerOptionalParamAtEnd() + { + $this->route = new Route(['GET'], 'post/[id=:num]?', 'Ctrl', 'act'); + + $this->assertTrue($this->compiler->match($this->route, '/post')); + + $this->assertSame(['id' => null], $this->compiler->getParams()); + + $this->assertTrue($this->compiler->match($this->route, '/post/7')); + + $this->assertSame(['id' => '7'], $this->compiler->getParams()); + } + + public function testPatternCompilerLengthConstraint() + { + $this->route = new Route(['GET'], 'code/[id=:num:4]', 'Ctrl', 'act'); + + $this->assertTrue($this->compiler->match($this->route, '/code/1234')); + + $this->assertSame(['id' => '1234'], $this->compiler->getParams()); + + $this->assertFalse($this->compiler->match($this->route, '/code/123')); + } + + public function testPatternCompilerMultipleParams() + { + $this->route = new Route( + ['GET'], + 'user/[id=:num]/post/[slug=:alpha]', + 'Ctrl', + 'act' + ); + + $this->assertTrue($this->compiler->match($this->route, '/user/5/post/hello')); + + $this->assertSame(['id' => '5', 'slug' => 'hello'], $this->compiler->getParams()); + } + + public function testPatternCompilerOptionalLeadingParam() + { + $this->route = new Route( + ['GET'], + '[:alpha:2]?/about', + 'Ctrl', + 'act' + ); + + $this->assertTrue($this->compiler->match($this->route, '/about')); + + $this->assertTrue($this->compiler->match($this->route, '/en/about')); + } + + public function testPatternCompilerInvalidParamNameThrowsException() + { + $this->route = new Route(['GET'], '[id1=:num]', 'Ctrl', 'act'); + + $this->expectException(RouteException::class); + + $this->compiler->compile($this->route); + } + + public function testPatternCompilerDuplicateParamNameThrowsException() + { + $this->route = new Route( + ['GET'], + '[id=:num]/[id=:num]', + 'Ctrl', + 'act' + ); + + $this->expectException(RouteException::class); + + $this->compiler->compile($this->route); + } + + public function tesPatternCompilertUrlDecodedInput() + { + $this->route = new Route(['GET'], 'user/[id=:num]', 'Ctrl', 'act'); + + $this->assertTrue( + $this->compiler->match( + $this->route, + '/user/%34%32' + ) + ); + + $this->assertSame(['id' => '42'], $this->compiler->getParams()); + } + + public function testPatternCompilerRootPatternMatches() + { + $this->route = new Route(['GET'], '/', 'Ctrl', 'act'); + + $this->assertTrue($this->compiler->match($this->route, '/')); + } + + public function testPatternCompilerParamsResetAfterFailedMatch() + { + $this->route = new Route(['GET'], 'users/[id=:num]', 'Ctrl', 'act'); + + $this->compiler->match($this->route, '/users/42'); + $this->assertSame(['id' => '42'], $this->compiler->getParams()); + + $this->compiler->match($this->route, '/users/abc'); + $this->assertSame([], $this->compiler->getParams()); + } +} diff --git a/tests/Unit/Router/RouteBuilderTest.php b/tests/Unit/Router/RouteBuilderTest.php index 0c3554fe..b0d9720f 100644 --- a/tests/Unit/Router/RouteBuilderTest.php +++ b/tests/Unit/Router/RouteBuilderTest.php @@ -4,62 +4,435 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\Router\RouteBuilder; -use Quantum\Router\Route; +use InvalidArgumentException; +use LogicException; class RouteBuilderTest extends AppTestCase { - public function testRouteBuilderBuildReturnsFlattenedRoutesFromModuleClosures(): void + public function testRouteBuilderBuildReturnsRouteCollection() + { + $builder = new RouteBuilder(); + + $routes = $builder->build([ + 'Web' => function (RouteBuilder $route) { + $route->get('users', 'UserController', 'index'); + }, + ], []); + + $this->assertInstanceOf(\Quantum\Router\RouteCollection::class, $routes); + + $this->assertSame(1, $routes->count()); + } + + public function testRouteBuilderBuildCollectsRoutesFromMultipleModules() { $builder = new RouteBuilder(); $closures = [ - 'Api' => function (Route $route): void { + 'Api' => function (RouteBuilder $route) { $route->get('users', 'UsersController', 'index'); $route->post('login', 'AuthController', 'login'); }, - 'Web' => function (Route $route): void { + 'Web' => function (RouteBuilder $route) { $route->get('', 'HomeController', 'index'); }, ]; - $configs = [ - 'Api' => ['prefix' => 'api', 'enabled' => true], - 'Web' => ['prefix' => '', 'enabled' => true], + $moduleConfigs = [ + 'Api' => ['prefix' => 'api'], + 'Web' => ['prefix' => ''], ]; - $routes = $builder->build($closures, $configs); + $routes = $builder->build($closures, $moduleConfigs); + + $this->assertSame(3, $routes->count()); - $this->assertIsArray($routes); - $this->assertCount(3, $routes); + $all = $routes->all(); - $this->assertSame('GET', $routes[0]['method']); - $this->assertSame('api/users', $routes[0]['route']); - $this->assertSame('Api', $routes[0]['module']); + $this->assertSame('Api', $all[0]->getModule()); - $this->assertSame('POST', $routes[1]['method']); - $this->assertSame('api/login', $routes[1]['route']); - $this->assertSame('Api', $routes[1]['module']); + $this->assertSame('/api/users', $all[0]->getPattern()); - $this->assertSame('GET', $routes[2]['method']); - $this->assertSame('', $routes[2]['route']); - $this->assertSame('Web', $routes[2]['module']); + $this->assertTrue($all[0]->allowsMethod('GET')); + + $this->assertSame('Api', $all[1]->getModule()); + + $this->assertSame('/api/login', $all[1]->getPattern()); + + $this->assertTrue($all[1]->allowsMethod('POST')); + + $this->assertSame('Web', $all[2]->getModule()); + + $this->assertSame('/', $all[2]->getPattern()); + + $this->assertTrue($all[2]->allowsMethod('GET')); } - public function testRouteBuilderBuildUsesEmptyOptionsWhenModuleConfigNotProvided(): void + public function testRouteBuilderMissingModuleConfigFallsBackSafely() { $builder = new RouteBuilder(); - $closures = [ - 'Test' => function (Route $route): void { - $route->get('ping', 'PingController', 'index'); - }, - ]; + $routes = $builder->build( + [ + 'Test' => function (RouteBuilder $route) { + $route->get('ping', 'PingController', 'index'); + }, + ], + [] + ); + + $this->assertSame(1, $routes->count()); + + $route = $routes->all()[0]; + + $this->assertSame('Test', $route->getModule()); + + $this->assertSame('/ping', $route->getPattern()); + } + + public function testRouteBuilderPrefixIsAppliedToRoutes() + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Api' => function (RouteBuilder $route) { + $route->get('status', 'StatusController', 'index'); + }, + ], + [ + 'Api' => ['prefix' => 'v1'], + ] + ); + + $route = $routes->all()[0]; + + $this->assertSame('/v1/status', $route->getPattern()); + + $this->assertSame('Api', $route->getModule()); + } + + public function testRouteBuilderGroupAssignsGroupNameToRoutes() + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->group('auth', function (RouteBuilder $route) { + $route->get('dashboard', 'DashboardController', 'index'); + }); + }, + ], + [] + ); + + $route = $routes->all()[0]; + + $this->assertSame('auth', $route->getGroup()); + } + + public function testRouteBuilderNestedGroupsAreNotAllowed() + { + $this->expectException(LogicException::class); + + $builder = new RouteBuilder(); + + $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->group('one', function (RouteBuilder $route) { + $route->group('two', function () { + }); + }); + }, + ], + [] + ); + } + + public function testRouteBuilderGroupMiddlewaresAreAppliedToRoutes() + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->group('auth', function (RouteBuilder $route) { + $route->get('profile', 'ProfileController', 'show'); + })->middlewares(['Auth']); + }, + ], + [] + ); + + $route = $routes->all()[0]; + + $this->assertSame(['Auth'], $route->getMiddlewares()); + } + + public function testRouteBuilderRouteMiddlewaresPrependToGroupMiddlewares() + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->group('auth', function (RouteBuilder $route) { + $route + ->get('profile', 'ProfileController', 'show') + ->middlewares(['Editor']); + })->middlewares(['Auth']); + }, + ], + [] + ); + + $route = $routes->all()[0]; + + $this->assertSame( + ['Auth', 'Editor'], + $route->getMiddlewares() + ); + } + + public function testRouteBuilderMiddlewaresMustBeCalledAfterRouteOrInsideGroup() + { + $this->expectException(LogicException::class); + + $builder = new RouteBuilder(); + + $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->middlewares(['Invalid']); + }, + ], + [] + ); + } + + public function testRouteBuilderMiddlewareOrderAcrossModulesGroupsAndStandaloneRoutes() + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->group('auth', function (RouteBuilder $route) { + $route->get('profile', 'ProfileController', 'show')->middlewares(['ProfileMw']); + $route->get('settings', 'SettingsController', 'show')->middlewares(['SettingsMw']); + })->middlewares(['AuthMw']); + + $route->get('home', 'HomeController', 'index')->middlewares(['HomeMw']); + }, + + 'Api' => function (RouteBuilder $route) { + $route->group('api-auth', function (RouteBuilder $route) { + $route->get('users', 'UsersController', 'index')->middlewares(['UsersMw1', 'UsersMw2']); + $route->post('login', 'AuthController', 'login')->middlewares(['LoginMw']); + })->middlewares(['ApiAuthMw']); - $routes = $builder->build($closures, []); + $route->get('status', 'StatusController', 'index')->middlewares(['StatusMw']); + }, + ], + [] + ); - $this->assertCount(1, $routes); - $this->assertSame('GET', $routes[0]['method']); - $this->assertSame('ping', $routes[0]['route']); - $this->assertSame('Test', $routes[0]['module']); + $all = $routes->all(); + + $this->assertSame(['AuthMw', 'ProfileMw'], $all[0]->getMiddlewares()); + + $this->assertSame(['AuthMw', 'SettingsMw'], $all[1]->getMiddlewares()); + + $this->assertSame(['HomeMw'], $all[2]->getMiddlewares()); + + $this->assertSame(['ApiAuthMw', 'UsersMw1', 'UsersMw2'], $all[3]->getMiddlewares()); + + $this->assertSame(['ApiAuthMw', 'LoginMw'], $all[4]->getMiddlewares()); + + $this->assertSame(['StatusMw'], $all[5]->getMiddlewares()); + } + + public function testRouteBuilderRouteNameIsAssigned() + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->get('home', 'HomeController', 'index') + ->name('home'); + }, + ], + [] + ); + + $this->assertSame('home', $routes->all()[0]->getName()); + } + + public function testRouteBuilderRouteNamesMustBeUnique() + { + $this->expectException(LogicException::class); + + $builder = new RouteBuilder(); + + $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->get('a', 'AController', 'a')->name('dup'); + $route->get('b', 'BController', 'b')->name('dup'); + }, + ], + [] + ); + } + + public function testRouteBuilderRouteNamesCanRepeatAcrossModules() + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Api' => function (RouteBuilder $route) { + $route->get('posts', 'ApiPostController', 'index')->name('posts'); + }, + 'Web' => function (RouteBuilder $route) { + $route->get('posts', 'WebPostController', 'index')->name('posts'); + }, + ], + [ + 'Api' => ['prefix' => 'api'], + 'Web' => ['prefix' => ''], + ] + ); + + $all = $routes->all(); + + $this->assertSame('posts', $all[0]->getName()); + $this->assertSame('Api', $all[0]->getModule()); + + $this->assertSame('posts', $all[1]->getName()); + $this->assertSame('Web', $all[1]->getModule()); + } + + public function testRouteBuilderNameMustBeCalledAfterRouteDefinition() + { + $this->expectException(LogicException::class); + + $builder = new RouteBuilder(); + + $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->name('invalid'); + }, + ], + [] + ); + } + + public function testRouteBuilderCacheableAppliesToSingleRoute() + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->get('page', 'PageController', 'show') + ->cacheable(true, 60); + }, + ], + [] + ); + + $cache = $routes->all()[0]->getCache(); + + $this->assertSame(true, $cache['enabled']); + $this->assertSame(60, $cache['ttl']); + } + + public function testRouteBuilderCacheableAppliesToGroupRoutes() + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->group('cached', function (RouteBuilder $route) { + $route->get('a', 'AController', 'a'); + $route->get('b', 'BController', 'b'); + })->cacheable(true, 120); + }, + ], + [] + ); + + foreach ($routes->all() as $route) { + $cache = $route->getCache(); + $this->assertSame(true, $cache['enabled']); + $this->assertSame(120, $cache['ttl']); + } } + + public function testRouteBuilderAddRouteRequiresControllerAndAction() + { + $this->expectException(InvalidArgumentException::class); + + $builder = new RouteBuilder(); + + $builder->build( + [ + 'Web' => function (RouteBuilder $route) { + $route->get('broken', 'OnlyController'); + }, + ], + [] + ); + } + + public function testRouteBuilderResolvesShortControllerNameToFqcn() + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Test' => function (RouteBuilder $route) { + $route->get('tests', 'TestController', 'tests'); + }, + ], + [] + ); + + $route = $routes->all()[0]; + + $expected = 'Quantum\\Tests\\_root\\modules\\Test\\Controllers\\TestController'; + + $this->assertTrue(class_exists($expected), 'Expected test controller class to exist'); + + $this->assertSame($expected, $route->getController()); + } + + public function testRouteBuilderDoesNotModifyExplicitFqcnController() + { + $builder = new RouteBuilder(); + + $fqcn = 'Quantum\\Tests\\_root\\modules\\Test\\Controllers\\TestController'; + + $routes = $builder->build( + [ + 'Test' => function (RouteBuilder $route) use ($fqcn) { + $route->get('tests', $fqcn, 'tests'); + }, + ], + [] + ); + + $route = $routes->all()[0]; + + $this->assertTrue(class_exists($fqcn), 'Expected test controller class to exist'); + + $this->assertSame($fqcn, $route->getController()); + } + } diff --git a/tests/Unit/Router/RouteCollectionTest.php b/tests/Unit/Router/RouteCollectionTest.php new file mode 100644 index 00000000..8d40f862 --- /dev/null +++ b/tests/Unit/Router/RouteCollectionTest.php @@ -0,0 +1,73 @@ +assertSame([], $collection->all()); + + $this->assertSame(0, $collection->count()); + } + + public function testRouteCollectionAddIncreasesCount() + { + $collection = new RouteCollection(); + + $collection->add(new Route(['GET'], 'a', 'Ctrl', 'act')); + + $collection->add(new Route(['POST'], 'b', 'Ctrl', 'act')); + + $this->assertSame(2, $collection->count()); + } + + public function testRouteCollectionAllReturnsAddedRoutes() + { + $collection = new RouteCollection(); + + $r1 = new Route(['GET'], 'users', 'Ctrl', 'act'); + + $r2 = new Route(['GET'], 'posts', 'Ctrl', 'act'); + + $collection->add($r1); + $collection->add($r2); + + $all = $collection->all(); + + $this->assertSame([$r1, $r2], $all); + } + + public function testRouteCollectionInsertionOrderIsPreserved() + { + $collection = new RouteCollection(); + + $r1 = new Route(['GET'], 'first', 'Ctrl', 'act'); + + $r2 = new Route(['GET'], 'second', 'Ctrl', 'act'); + + $collection->add($r1); + $collection->add($r2); + + $all = $collection->all(); + + $this->assertSame($r1, $all[0]); + $this->assertSame($r2, $all[1]); + } + + public function testRouteCollectionAllReturnsSameInstances() + { + $collection = new RouteCollection(); + + $r = new Route(['GET'], 'x', 'C', 'a'); + $collection->add($r); + + $this->assertTrue($collection->all()[0] === $r); + } +} diff --git a/tests/Unit/Router/RouteControllerTest.php b/tests/Unit/Router/RouteControllerTest.php deleted file mode 100644 index dae4d5d9..00000000 --- a/tests/Unit/Router/RouteControllerTest.php +++ /dev/null @@ -1,42 +0,0 @@ -expectException(RouteControllerException::class); - - $this->expectExceptionMessage('Action `undefinedAction` not defined'); - - $controller = new SomeController(); - - $controller->undefinedAction(); - } - - } -} diff --git a/tests/Unit/Router/RouteDispatcherTest.php b/tests/Unit/Router/RouteDispatcherTest.php new file mode 100644 index 00000000..210e5545 --- /dev/null +++ b/tests/Unit/Router/RouteDispatcherTest.php @@ -0,0 +1,190 @@ + '123'] + ); + + $dispatcher = new RouteDispatcher(); + + $request = Mockery::mock(Request::class); + $response = Mockery::mock(Response::class); + + $dispatcher->dispatch($matched, $request, $response); + + $this->assertTrue($called); + $this->assertSame('123', $receivedParam); + } + + public function testDispatchThrowsWhenClosureRouteHasNoClosure() + { + $this->expectException(\LogicException::class); + + $route = new Route(['GET'], '/broken', null, null, null); + + $matched = new MatchedRoute($route, []); + + $dispatcher = new RouteDispatcher(); + + $request = Mockery::mock(Request::class); + $response = Mockery::mock(Response::class); + + $dispatcher->dispatch($matched, $request, $response); + } + + public function testDispatchExecutesControllerActionWithParams() + { + $controllerClass = new class () { + public static ?string $received = null; + + public function post(string $uuid): void + { + self::$received = $uuid; + } + }; + + $route = new Route(['GET'], '[:alpha:2]?/post/[uuid=:any]', get_class($controllerClass), 'post', null); + + $matched = new MatchedRoute($route, ['uuid' => 'abc-123']); + + $dispatcher = new RouteDispatcher(); + + $request = Mockery::mock(Request::class); + $response = Mockery::mock(Response::class); + + $dispatcher->dispatch($matched, $request, $response); + + $this->assertSame( + 'abc-123', + $controllerClass::$received, + 'Controller action did not receive matched parameters' + ); + } + + public function testDispatchCallsControllerHooksInCorrectOrder() + { + $controllerClass = new class () { + public static array $calls = []; + + public function __before(string $uuid): void + { + self::$calls[] = 'before:' . $uuid; + } + + public function post(string $uuid): void + { + self::$calls[] = 'action:' . $uuid; + } + + public function __after(string $uuid): void + { + self::$calls[] = 'after:' . $uuid; + } + }; + + $route = new Route(['GET', 'POST'], '[:alpha:2]?/post/[uuid=:any]', get_class($controllerClass), 'post', null); + + $matched = new MatchedRoute($route, ['uuid' => 'abc']); + + $dispatcher = new RouteDispatcher(); + + $request = Mockery::mock(Request::class); + $request->shouldReceive('getMethod')->andReturn('POST'); + + $response = Mockery::mock(Response::class); + + $dispatcher->dispatch($matched, $request, $response); + + $this->assertSame( + [ + 'before:abc', + 'action:abc', + 'after:abc', + ], + $controllerClass::$calls, + 'Controller hooks were not executed in the correct order' + ); + } + + public function testDispatchThrowsWhenControllerActionDoesNotExist() + { + $this->expectException(\RuntimeException::class); + + $controllerClass = new class () { + // Intentionally no action method + }; + + $route = new Route(['GET'], '[:alpha:2]?/broken', get_class($controllerClass), 'missingAction', null); + + $matched = new MatchedRoute($route, []); + + $dispatcher = new RouteDispatcher(); + + $request = Mockery::mock(Request::class); + $response = Mockery::mock(Response::class); + + $dispatcher->dispatch($matched, $request, $response); + } + + public function testDispatchFailsWhenCsrfIsEnabledAndTokenIsMissing() + { + $this->expectException(CsrfException::class); + + $controllerClass = new class () { + public bool $csrfVerification = true; + + public function submit(): void + { + // noop + } + }; + + $route = new Route(['POST'], '[:alpha:2]?/submit', get_class($controllerClass), 'submit', null); + + $matched = new MatchedRoute($route, []); + + $request = Mockery::mock(Request::class); + $request->shouldReceive('getMethod')->andReturn('POST'); + $request->shouldReceive('has') + ->with(Csrf::TOKEN_KEY) + ->andReturn(false); + + $response = Mockery::mock(Response::class); + + $dispatcher = new RouteDispatcher(); + + $dispatcher->dispatch($matched, $request, $response); + } +} diff --git a/tests/Unit/Router/RouteFinderTest.php b/tests/Unit/Router/RouteFinderTest.php new file mode 100644 index 00000000..3a5f3774 --- /dev/null +++ b/tests/Unit/Router/RouteFinderTest.php @@ -0,0 +1,106 @@ +collection = new RouteCollection(); + $this->finder = new RouteFinder($this->collection); + } + + public function testRouteFinderFindReturnsMatchedRouteForStaticMatch() + { + $route = new Route(['GET'], 'users', 'Ctrl', 'act'); + $this->collection->add($route); + + $req = new Request(); + $req->create('GET', '/users'); + + $result = $this->finder->find($req); + + $this->assertInstanceOf(MatchedRoute::class, $result); + $this->assertSame($route, $result->getRoute()); + $this->assertSame([], $result->getParams()); + } + + public function testRouteFinderFindReturnsNullWhenNoMatch() + { + $route = new Route(['GET'], 'users', 'Ctrl', 'act'); + $this->collection->add($route); + + $req = new Request(); + $req->create('GET', '/posts'); + + $this->assertNull($this->finder->find($req)); + } + + public function testRouteFinderFindSkipsWrongHttpMethod() + { + $route = new Route(['POST'], 'users', 'Ctrl', 'act'); + $this->collection->add($route); + + $req = new Request(); + $req->create('GET', '/users'); + + $this->assertNull($this->finder->find($req)); + } + + public function testRouteFinderFindReturnsFirstMatchingRouteOnly() + { + $r1 = new Route(['GET'], 'users', 'C1', 'a1'); + $r2 = new Route(['GET'], 'users', 'C2', 'a2'); + + $this->collection->add($r1); + $this->collection->add($r2); + + $req = new Request(); + $req->create('GET', '/users'); + + $result = $this->finder->find($req); + + $this->assertSame($r1, $result->getRoute()); + } + + public function testRouteFinderFindPassesExtractedParams() + { + $route = new Route(['GET'], 'users/[id=:num]', 'Ctrl', 'act'); + $this->collection->add($route); + + $req = new Request(); + $req->create('GET', '/users/42'); + + $result = $this->finder->find($req); + + $this->assertSame(['id' => '42'], $result->getParams()); + } + + public function testRouteFinderFindWithMultipleRoutesOnlyOneMatches() + { + $r1 = new Route(['GET'], 'posts', 'Ctrl', 'act'); + $this->collection->add($r1); + + $r2 = new Route(['GET'], 'users', 'C', 'a'); + $this->collection->add($r2); + + $req = new Request(); + $req->create('GET', '/users'); + + $result = $this->finder->find($req); + + $this->assertSame($r2, $result->getRoute()); + } +} diff --git a/tests/Unit/Router/RouteTest.php b/tests/Unit/Router/RouteTest.php index de197e32..6e9a1b95 100644 --- a/tests/Unit/Router/RouteTest.php +++ b/tests/Unit/Router/RouteTest.php @@ -2,284 +2,170 @@ namespace Quantum\Tests\Unit\Router; -use Quantum\Router\Exceptions\RouteException; use Quantum\Tests\Unit\AppTestCase; +use InvalidArgumentException; use Quantum\Router\Route; class RouteTest extends AppTestCase { - private $route; - - public function setUp(): void - { - parent::setUp(); - - $this->route = new Route('Test', ['prefix' => '', 'endabled' => true]); - } - - public function testCallbackRoute() + public function testRouteControllerRouteConstruction() { - $this->assertEmpty($this->route->getRuntimeRoutes()); - - $this->assertEmpty($this->route->getVirtualRoutes()['*']); - - $this->route->post('userinfo', function () { - info('Save user info'); - }); - - $this->route->get('userinfo', function () { - info('Get user info'); - }); - - $this->route->add('userinfo/add', 'GET', function () { - info('Add detail to user'); - }); - - $this->assertIsArray($this->route->getRuntimeRoutes()); + $route = new Route( + ['get', 'post'], + 'users', + 'UserController', + 'listAction' + ); - $this->assertIsArray($this->route->getVirtualRoutes()); + $this->assertSame(['GET', 'POST'], $route->getMethods()); - $this->assertCount(3, $this->route->getRuntimeRoutes()); + $this->assertSame('users', $route->getPattern()); - $this->assertCount(3, $this->route->getVirtualRoutes()['*']); + $this->assertSame('UserController', $route->getController()); - $virtualRoutes = $this->route->getVirtualRoutes()['*']; + $this->assertSame('listAction', $route->getAction()); - $this->assertEquals('POST', $virtualRoutes[0]['method']); - - $this->assertEquals('GET', $virtualRoutes[1]['method']); - - $this->assertEquals('GET', $virtualRoutes[2]['method']); - - $this->assertTrue(is_callable($virtualRoutes[0]['callback'])); - - $this->assertTrue(is_callable($virtualRoutes[1]['callback'])); - - $this->assertTrue(is_callable($virtualRoutes[2]['callback'])); + $this->assertFalse($route->isClosure()); } - public function testAddRoute() + public function testRouteClosureRouteConstruction() { - $this->assertEmpty($this->route->getRuntimeRoutes()); + $handler = function () { + }; - $this->assertEmpty($this->route->getVirtualRoutes()['*']); + $route = new Route( + ['GET'], + 'health', + null, + null, + $handler + ); - $this->route->add('signin', 'GET', 'AuthController', 'signin'); + $this->assertTrue($route->isClosure()); - $this->assertIsArray($this->route->getRuntimeRoutes()); + $this->assertSame($handler, $route->getClosure()); - $this->assertIsArray($this->route->getVirtualRoutes()); + $this->assertNull($route->getController()); - $this->assertCount(1, $this->route->getRuntimeRoutes()); - - $this->assertCount(1, $this->route->getVirtualRoutes()['*']); + $this->assertNull($route->getAction()); } - public function testGetRoute() + public function testRouteConstructorRejectsEmptyMethods() { - $this->assertEmpty($this->route->getRuntimeRoutes()); - - $this->assertEmpty($this->route->getVirtualRoutes()['*']); - - $this->route->get('signin', 'AuthController', 'signin'); - - $this->assertIsArray($this->route->getRuntimeRoutes()); + $this->expectException(InvalidArgumentException::class); - $this->assertIsArray($this->route->getVirtualRoutes()); - - $this->assertCount(1, $this->route->getRuntimeRoutes()); - - $this->assertCount(1, $this->route->getVirtualRoutes()['*']); + new Route([], 'users', 'UserController', 'listAction'); } - public function testPostRoute() + public function testRouteClosureRouteCannotDefineControllerOrAction() { - $this->assertEmpty($this->route->getRuntimeRoutes()); - - $this->assertEmpty($this->route->getVirtualRoutes()['*']); - - $this->route->post('signin', 'AuthController', 'signin'); - - $this->assertIsArray($this->route->getRuntimeRoutes()); + $this->expectException(InvalidArgumentException::class); - $this->assertIsArray($this->route->getVirtualRoutes()); - - $this->assertCount(1, $this->route->getRuntimeRoutes()); - - $this->assertCount(1, $this->route->getVirtualRoutes()['*']); + new Route( + ['GET'], + 'health', + 'HealthController', + 'checkAction', + function () { + } + ); } - public function testPutRoute() + public function testRouteControllerRouteRequiresControllerAndAction() { - $this->assertEmpty($this->route->getRuntimeRoutes()); - - $this->assertEmpty($this->route->getVirtualRoutes()['*']); - - $this->route->put('update', 'PostsController', 'update'); - - $this->assertIsArray($this->route->getRuntimeRoutes()); + $this->expectException(InvalidArgumentException::class); - $this->assertIsArray($this->route->getVirtualRoutes()); - - $this->assertCount(1, $this->route->getRuntimeRoutes()); - - $this->assertCount(1, $this->route->getVirtualRoutes()['*']); + new Route(['GET'], 'users', null, null); } - public function testDeleteRoute() + public function testRouteAllowsMethodIsCaseInsensitive() { - $this->assertEmpty($this->route->getRuntimeRoutes()); - - $this->assertEmpty($this->route->getVirtualRoutes()['*']); - - $this->route->delete('delete', 'PostsController', 'delete'); - - $this->assertIsArray($this->route->getRuntimeRoutes()); + $route = new Route( + ['GET'], + 'users', + 'UserController', + 'listAction' + ); - $this->assertIsArray($this->route->getVirtualRoutes()); + $this->assertTrue($route->allowsMethod('get')); - $this->assertCount(1, $this->route->getRuntimeRoutes()); + $this->assertTrue($route->allowsMethod('GET')); - $this->assertCount(1, $this->route->getVirtualRoutes()['*']); + $this->assertFalse($route->allowsMethod('POST')); } - public function testGroupRoute() + public function testRouteCacheConfigurationIsStored() { - $this->route->group('auth', function ($route) { - $route->add('dashboard', 'GET', 'AuthController', 'dashboard'); - $route->add('users', 'GET', 'AuthController', 'users'); - }); + $route = new Route( + ['GET'], + 'reports', + 'ReportController', + 'indexAction' + ); - $this->assertCount(2, $this->route->getRuntimeRoutes()); + $route->cache(true, 120); - $this->assertEquals('auth', $this->route->getRuntimeRoutes()[0]['group']); - - $this->assertEquals('auth', $this->route->getRuntimeRoutes()[1]['group']); - - $this->assertCount(2, $this->route->getVirtualRoutes()['auth']); + $this->assertSame( + ['enabled' => true, 'ttl' => 120], + $route->getCache() + ); } - public function testCacheable() + public function testRouteCompiledPatternCanBeStored() { - $this->route->post('posts', 'PostsController', 'posts')->cacheable(true, 100); + $route = new Route( + ['GET'], + 'users', + 'UserController', + 'listAction' + ); - $this->assertTrue($this->route->getRuntimeRoutes()[0]['cache_settings']['shouldCache']); - $this->assertEquals(100, $this->route->getRuntimeRoutes()[0]['cache_settings']['ttl']); - } + $route->setCompiledPattern('^users$'); - public function testMiddlewares() - { - $this->route->add('signup', 'POST', 'AuthController', 'signup')->middlewares(['signup', 'csrf']); - - $route = current($this->route->getVirtualRoutes()['*']); - - $this->assertCount(2, $route['middlewares']); - - $this->assertEquals('signup', $route['middlewares'][0]); - - $this->assertEquals('csrf', $route['middlewares'][1]); + $this->assertSame('^users$', $route->getCompiledPattern()); } - public function testGroupMiddlewares() + public function testRouteMiddlewareStackingOrder() { - $this->route->group('auth', function ($route) { - $route->add('dashboard', 'GET', 'AuthController', 'dashboard'); - $route->add('user', 'POST', 'AuthController', 'add')->middlewares(['csrf', 'ddos']); - })->middlewares(['auth', 'owner']); - - $authGroupRoutes = $this->route->getVirtualRoutes()['auth']; - - $this->assertCount(2, $authGroupRoutes[0]['middlewares']); - - $this->assertEquals('auth', $authGroupRoutes[0]['middlewares'][0]); - - $this->assertEquals('owner', $authGroupRoutes[0]['middlewares'][1]); - - $this->assertCount(4, $authGroupRoutes[1]['middlewares']); - - $this->assertEquals('auth', $authGroupRoutes[1]['middlewares'][0]); + $route = new Route( + ['GET'], + 'users', + 'UserController', + 'listAction' + ); - $this->assertEquals('owner', $authGroupRoutes[1]['middlewares'][1]); + $route->addMiddlewares(['auth', 'log']); + $route->addMiddlewares(['csrf', 'throttle']); - $this->assertEquals('csrf', $authGroupRoutes[1]['middlewares'][2]); - - $this->assertEquals('ddos', $authGroupRoutes[1]['middlewares'][3]); + $this->assertSame( + ['csrf', 'throttle', 'auth', 'log'], + $route->getMiddlewares() + ); } - public function testRoutesWithNames() + public function testRouteToArrayExportsRouteState() { - $this->route->post('post/1', function () { - info('Getting the first post'); - })->name('first'); - - $this->assertIsArray($this->route->getRuntimeRoutes()); + $route = new Route( + ['GET'], + 'users', + 'UserController', + 'listAction' + ); - $this->assertEquals('first', $this->route->getRuntimeRoutes()[0]['name']); - } - - public function testNamingRouteBeforeDefinition() - { - $this->expectException(RouteException::class); + $route->cache(true, 60); - $this->expectExceptionMessage('Names can not be set before route definition'); - - $this->route->name('myposts')->get('my-posts', 'PostController', 'myPosts'); - } - - public function testDuplicateNamesOnRoutes() - { - $this->expectException(RouteException::class); + $data = $route->toArray(); - $this->expectExceptionMessage('Route names should be unique'); + $this->assertIsArray($data); - $this->route->post('post/1', 'PostController', 'getPost')->name('post'); - - $this->route->post('post/2', 'PostController', 'getPost')->name('post'); - } - - public function testNameOnGroup() - { - $this->expectException(RouteException::class); - - $this->expectExceptionMessage('Name can not be set on route groups'); - - $this->route->group('auth', function ($route) { - $route->add('dashboard', 'GET', 'AuthController', 'dashboard'); - })->name('authGroup'); - } - - public function testNamedRoutesWithGroupRoutes() - { - $route = $this->route; - - $this->route->group('auth', function ($route) { - $route->add('dashboard', 'GET', 'AuthController', 'dashboard'); - }); - - $route->add('landing', 'GET', 'MainController', 'landing')->name('landing'); - - $this->assertArrayHasKey('name', $this->route->getRuntimeRoutes()[0]); - - $this->assertArrayNotHasKey('group', $this->route->getRuntimeRoutes()[0]); - - $this->assertArrayNotHasKey('name', $this->route->getRuntimeRoutes()[1]); - - $this->assertArrayHasKey('group', $this->route->getRuntimeRoutes()[1]); - } - - public function testNamedRoutesInGroup() - { - $this->route->group('auth', function ($route) { - $route->add('reports', 'GET', 'MainController', 'landing')->name('reports'); - $route->add('dashboard', 'GET', 'MainController', 'dashboard')->name('dash'); - }); + $this->assertSame(['GET'], $data['methods']); - $this->assertEquals('auth', $this->route->getRuntimeRoutes()[0]['group']); + $this->assertSame('users', $data['route']); - $this->assertEquals('reports', $this->route->getRuntimeRoutes()[0]['name']); + $this->assertSame('UserController', $data['controller']); - $this->assertEquals('auth', $this->route->getRuntimeRoutes()[1]['group']); + $this->assertSame('listAction', $data['action']); - $this->assertEquals('dash', $this->route->getRuntimeRoutes()[1]['name']); + $this->assertSame(['enabled' => true, 'ttl' => 60], $data['cache']); } } diff --git a/tests/Unit/Router/RouterTest.php b/tests/Unit/Router/RouterTest.php deleted file mode 100644 index 99c6313a..00000000 --- a/tests/Unit/Router/RouterTest.php +++ /dev/null @@ -1,297 +0,0 @@ -request = new Request(); - - $this->router = new Router($this->request); - - $reflectionClass = new \ReflectionClass(Router::class); - - $reflectionProperty = $reflectionClass->getProperty('currentRoute'); - - $reflectionProperty->setAccessible(true); - - $reflectionProperty->setValue(null); - } - - public function testSetGetRoutes() - { - Router::setRoutes([ - [ - 'route' => '[:alpha:2]?', - 'method' => 'GET', - 'controller' => 'MainController', - 'action' => 'index', - 'module' => 'Web', - ], - [ - 'route' => '[:alpha:2]?/about', - 'method' => 'GET', - 'controller' => 'MainController', - 'action' => 'about', - 'module' => 'Web', - ], - ]); - - $this->assertNotEmpty(Router::getRoutes()); - - $this->assertIsArray(Router::getRoutes()); - } - - public function testFindRoute() - { - $this->assertNull($this->router->getCurrentRoute()); - - Router::setRoutes([ - [ - 'route' => 'auth/api-signin', - 'method' => 'POST', - 'controller' => 'AuthController', - 'action' => 'signin', - 'module' => 'Api', - ], - [ - 'route' => 'auth/api-signout-test', - 'method' => 'GET', - 'controller' => 'AuthController', - 'action' => 'signout', - 'module' => 'Api', - 'middlewares' => [ - 0 => 'guest', - ], - ], - ]); - - $this->request->create('POST', 'http://testdomain.com/auth/api-signin'); - - $this->router->findRoute(); - - $this->assertNotNull($this->router->getCurrentRoute()); - - $this->assertEquals('AuthController', current_controller()); - - $this->assertEquals('signin', current_action()); - - $this->assertEmpty(route_params()); - } - - public function testFindRouteWithParams() - { - Router::setRoutes([ - [ - 'route' => '[:alpha:2]/my-posts/amend/[:any]', - 'method' => 'POST', - 'controller' => 'PostController', - 'action' => 'amendPost', - 'module' => 'Web', - ], - ]); - - $this->request->create('POST', 'http://testdomain.com/en/my-posts/amend/5e538098-1095-3976-b05f-29a0bb2a799f'); - - $this->router->findRoute(); - - $params = route_params(); - - $this->assertIsArray($params); - - $this->assertEquals('en', $params[0]['value']); - - $this->assertEquals('5e538098-1095-3976-b05f-29a0bb2a799f', $params[1]['value']); - } - - public function testFindRouteWithOptionalParams() - { - Router::setRoutes([ - [ - 'route' => '[:any]?/my-posts/amend/[:any]/[:num]?', - 'method' => 'POST', - 'controller' => 'PostController', - 'action' => 'amendPost', - 'module' => 'Web', - ], - ]); - - $this->request->create('POST', 'http://testdomain.com/my-posts/amend/5e538098-1095-3976-b05f-29a0bb2a799f'); - - $this->router->findRoute(); - - $params = route_params(); - - $this->assertNull($params[0]['value']); - - $this->assertEquals('5e538098-1095-3976-b05f-29a0bb2a799f', $params[1]['value']); - - $this->assertNull($params[2]['value']); - } - - public function testFindRouteWithNamedParams() - { - Router::setRoutes([ - [ - 'route' => '[lang=:alpha:2]?/my-posts/amend/[postId=:any]/[ref=:num]?', - 'method' => 'POST', - 'controller' => 'PostController', - 'action' => 'amendPost', - 'module' => 'Web', - ], - ]); - - $this->request->create('POST', 'http://testdomain.com/my-posts/amend/5e538098-1095-3976-b05f-29a0bb2a799f/523'); - - $this->router->findRoute(); - - $this->assertNull(route_param('lang')); - - $this->assertEquals('5e538098-1095-3976-b05f-29a0bb2a799f', route_param('postId')); - - $this->assertEquals('523', route_param('ref')); - } - - public function testRestfulRoutes() - { - Router::setRoutes([ - [ - 'route' => 'api-task', - 'method' => 'POST', - 'controller' => 'TaskController', - 'action' => 'create', - 'module' => 'Api', - ], - [ - 'route' => 'api-task', - 'method' => 'GET', - 'controller' => 'TaskController', - 'action' => 'show', - 'module' => 'Api', - ], - ]); - - $this->request->create('GET', 'http://testdomain.com/api-task'); - - $this->router->findRoute(); - - $this->assertEquals('GET', route_method()); - - $this->assertEquals('show', current_action()); - - $request = new Request(); - - $router = new Router($request); - - $request->create('POST', 'http://testdomain.com/api-task'); - - $router->findRoute(); - - $this->assertEquals('POST', route_method()); - - $this->assertEquals('create', current_action()); - } - - public function testRouteIncorrectMethod() - { - Router::setRoutes([ - [ - 'route' => 'api-signin', - 'method' => 'POST', - 'controller' => 'AuthController', - 'action' => 'signin', - 'module' => 'Api', - ], - ]); - - $this->request->create('GET', 'http://testdomain.com/api-signin'); - - $this->expectException(RouteException::class); - - $this->expectExceptionMessage('Incorrect route method `GET`'); - - $this->router->findRoute(); - } - - public function testRepetitiveRoutesWithSameMethod() - { - Router::setRoutes([ - [ - 'route' => 'api-signin', - 'method' => 'POST', - 'controller' => 'AuthController', - 'action' => 'signin', - 'module' => 'Api', - ], - [ - 'route' => 'api-signin', - 'method' => 'POST', - 'controller' => 'AuthController', - 'action' => 'signin', - 'module' => 'Api', - ], - ]); - - $this->request->create('POST', 'http://testdomain.com/api-signin'); - - $this->expectException(RouteException::class); - - $this->expectExceptionMessage('Repetitive Routes with same method `POST`'); - - $this->router->findRoute(); - } - - public function testRepetitiveRoutesInDifferentModules() - { - Router::setRoutes([ - [ - 'route' => 'api-signin', - 'method' => 'POST', - 'controller' => 'AuthController', - 'action' => 'signin', - 'module' => 'Api', - ], - [ - 'route' => 'api-signin', - 'method' => 'PUT', - 'controller' => 'AuthController', - 'action' => 'signin', - 'module' => 'Web', - ], - ]); - - $this->request->create('POST', 'http://testdomain.com/api-signin'); - - $this->expectException(RouteException::class); - - $this->expectExceptionMessage('Repetitive Routes in different modules'); - - $this->router->findRoute(); - } - - public function testRouteNotFound() - { - $this->request->create('GET', 'http://testdomain.com/something'); - - $this->expectException(StopExecutionException::class); - - $this->expectExceptionMessage('Execution was terminated'); - - $this->router->findRoute(); - } -} diff --git a/tests/Unit/View/Helpers/ViewHelperTest.php b/tests/Unit/View/Helpers/ViewHelperTest.php index 56e2d9fb..7b0b29d0 100644 --- a/tests/Unit/View/Helpers/ViewHelperTest.php +++ b/tests/Unit/View/Helpers/ViewHelperTest.php @@ -3,7 +3,10 @@ namespace Quantum\Tests\Unit\View\Helpers; use Quantum\View\Factories\ViewFactory; -use Quantum\Router\RouteController; +use Quantum\Router\MatchedRoute; +use Quantum\Router\Route; +use Quantum\Http\Request; +use Quantum\Di\Di; use Quantum\Tests\Unit\AppTestCase; class ViewHelperTest extends AppTestCase @@ -14,6 +17,22 @@ public function setUp(): void { parent::setUp(); + $route = new Route( + ['GET', 'POST'], + '/test', + 'TestController', + 'testAction', + null + ); + $route->module('Test'); + + $matchedRoute = new MatchedRoute($route, []); + Request::setMatchedRoute($matchedRoute); + + $request = Di::get(Request::class); + $request->create('POST', '/test'); + Request::setMatchedRoute($matchedRoute); + $this->view = ViewFactory::get(); } public function tearDown(): void @@ -23,14 +42,6 @@ public function tearDown(): void public function testView() { - RouteController::setCurrentRoute([ - 'route' => 'test', - 'method' => 'POST', - 'controller' => 'TestController', - 'action' => 'testAction', - 'module' => 'Test', - ]); - $this->view->setLayout('layout'); $this->view->render('index'); diff --git a/tests/Unit/View/QtViewTest.php b/tests/Unit/View/QtViewTest.php index ca64ddb7..766270b6 100644 --- a/tests/Unit/View/QtViewTest.php +++ b/tests/Unit/View/QtViewTest.php @@ -5,8 +5,11 @@ use Quantum\View\Exceptions\ViewException; use Quantum\View\Factories\ViewFactory; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Router\Router; +use Quantum\Router\MatchedRoute; use Quantum\View\RawParam; +use Quantum\Router\Route; +use Quantum\Http\Request; +use Quantum\Di\Di; class QtViewTest extends AppTestCase { @@ -16,13 +19,21 @@ public function setUp(): void { parent::setUp(); - Router::setCurrentRoute([ - 'route' => 'test', - 'method' => 'GET', - 'controller' => 'SomeController', - 'action' => 'test', - 'module' => 'Test', - ]); + $route = new Route( + ['GET'], + '/test', + 'SomeController', + 'test', + null + ); + $route->module('Test'); + + $matchedRoute = new MatchedRoute($route, []); + Request::setMatchedRoute($matchedRoute); + + $request = Di::get(Request::class); + $request->create('GET', '/test'); + Request::setMatchedRoute($matchedRoute); $this->view = ViewFactory::get(); } diff --git a/tests/_root/app.conf b/tests/_root/app.conf new file mode 100644 index 00000000..b9359e04 --- /dev/null +++ b/tests/_root/app.conf @@ -0,0 +1,10 @@ + 'Quantum PHP Framework', + 'version' => '2.9.5', + 'key' => env('APP_KEY'), + 'base_url' => 'http://localhost', + 'debug' => true, + 'test' => 'Testing', +]; \ No newline at end of file diff --git a/tests/_root/custom.text b/tests/_root/custom.text new file mode 100644 index 00000000..1b81b5f2 --- /dev/null +++ b/tests/_root/custom.text @@ -0,0 +1 @@ +Just a sample text \ No newline at end of file diff --git a/tests/_root/modules/Test/Controllers/TestController.php b/tests/_root/modules/Test/Controllers/TestController.php index da259bef..17e48e18 100644 --- a/tests/_root/modules/Test/Controllers/TestController.php +++ b/tests/_root/modules/Test/Controllers/TestController.php @@ -2,9 +2,7 @@ namespace Quantum\Tests\_root\modules\Test\Controllers; -use Quantum\Router\RouteController; - -class TestController extends RouteController +class TestController { public function tests() { diff --git a/tests/_root/session.conf b/tests/_root/session.conf new file mode 100644 index 00000000..014ad912 --- /dev/null +++ b/tests/_root/session.conf @@ -0,0 +1,14 @@ + 'native', + + 'native' => [ + 'timeout' => 300 + ], + + 'database' => [ + 'table' => 'sessions', + 'timeout' => 300, + ] +]; \ No newline at end of file diff --git a/tests/_root/shared/emails/4VT1sZywBYGFCnZarnxm7jWt3bbWkTJNw3lk3inlw.eml b/tests/_root/shared/emails/4VT1sZywBYGFCnZarnxm7jWt3bbWkTJNw3lk3inlw.eml new file mode 100644 index 00000000..a358d8f8 --- /dev/null +++ b/tests/_root/shared/emails/4VT1sZywBYGFCnZarnxm7jWt3bbWkTJNw3lk3inlw.eml @@ -0,0 +1,10 @@ +Date: Thu, 16 Oct 2025 19:41:01 +0300 +To: Benny +From: John Doe +Subject: Lorem +Message-ID: <4VT1sZywBYGFCnZarnxm7jWt3bbWkTJNw3lk3inlw@DELL-I7> +X-Mailer: PHPMailer 6.10.0 (https://github.com/PHPMailer/PHPMailer) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +Lorem ipsum dolor sit amet diff --git a/tests/_root/shared/emails/972be42a9f08c5ac8e76a3673cef0adf.eml b/tests/_root/shared/emails/972be42a9f08c5ac8e76a3673cef0adf.eml new file mode 100644 index 00000000..08514112 --- /dev/null +++ b/tests/_root/shared/emails/972be42a9f08c5ac8e76a3673cef0adf.eml @@ -0,0 +1,29 @@ +Date: Wed, 5 Apr 2023 19:15:37 +0300 +To: Jane Due +From: John Doe +Cc: Kevin , Bill +Bcc: Dan +Reply-To: Sam +Subject: Lorem +Message-ID: <2YILSA4zZk61tDdYEfGMw7lNznlhAQakjwNGr0QCq44@WIN-PO1MOI973IP> +X-Mailer: PHPMailer 6.8.0 (https://github.com/PHPMailer/PHPMailer) +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="b1=_2YILSA4zZk61tDdYEfGMw7lNznlhAQakjwNGr0QCq44" +Content-Transfer-Encoding: 8bit + +--b1=_2YILSA4zZk61tDdYEfGMw7lNznlhAQakjwNGr0QCq44 +Content-Type: text/plain; charset=us-ascii + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Vestibulum lobortis leo velit, et facilisis justo sollicitudin ultricies. +Praesent quis commodo diam. Duis lacinia ut quam ut finibus. + +--b1=_2YILSA4zZk61tDdYEfGMw7lNznlhAQakjwNGr0QCq44 +Content-Type: text/plain; name=document.txt +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename=document.txt + +Y29udGVudCBvZiB0aGUgZG9jdW1lbnQ= + +--b1=_2YILSA4zZk61tDdYEfGMw7lNznlhAQakjwNGr0QCq44-- \ No newline at end of file diff --git a/tests/_root/shared/emails/SGKaBZZgwnpj3heGHBv22ObeiZDM3Di3Wc7q4WcD1EY.eml b/tests/_root/shared/emails/SGKaBZZgwnpj3heGHBv22ObeiZDM3Di3Wc7q4WcD1EY.eml new file mode 100644 index 00000000..1976b010 --- /dev/null +++ b/tests/_root/shared/emails/SGKaBZZgwnpj3heGHBv22ObeiZDM3Di3Wc7q4WcD1EY.eml @@ -0,0 +1,10 @@ +Date: Sun, 28 Sep 2025 19:42:20 +0300 +To: Benny +From: John Doe +Subject: Lorem +Message-ID: +X-Mailer: PHPMailer 6.10.0 (https://github.com/PHPMailer/PHPMailer) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +Lorem ipsum dolor sit amet diff --git a/tests/_root/shared/emails/es0OmpM5NemSYmhWLecW2l7kzlgDKE3rG2ki3Q7nLk.eml b/tests/_root/shared/emails/es0OmpM5NemSYmhWLecW2l7kzlgDKE3rG2ki3Q7nLk.eml new file mode 100644 index 00000000..f6f8e483 --- /dev/null +++ b/tests/_root/shared/emails/es0OmpM5NemSYmhWLecW2l7kzlgDKE3rG2ki3Q7nLk.eml @@ -0,0 +1,10 @@ +Date: Thu, 16 Oct 2025 17:28:17 +0300 +To: Benny +From: John Doe +Subject: Lorem +Message-ID: +X-Mailer: PHPMailer 6.10.0 (https://github.com/PHPMailer/PHPMailer) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +Lorem ipsum dolor sit amet diff --git a/tests/_root/shared/emails/w0BArqHKDZZl7f1gDdKk1qm3SSGrtHr7PXv8kXLes.eml b/tests/_root/shared/emails/w0BArqHKDZZl7f1gDdKk1qm3SSGrtHr7PXv8kXLes.eml new file mode 100644 index 00000000..af32b3bc --- /dev/null +++ b/tests/_root/shared/emails/w0BArqHKDZZl7f1gDdKk1qm3SSGrtHr7PXv8kXLes.eml @@ -0,0 +1,10 @@ +Date: Thu, 16 Oct 2025 19:39:43 +0300 +To: Benny +From: John Doe +Subject: Lorem +Message-ID: +X-Mailer: PHPMailer 6.10.0 (https://github.com/PHPMailer/PHPMailer) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +Lorem ipsum dolor sit amet diff --git a/tests/_root/shared/emails/yi6OSwqGgquzgMmKQajIatNnR1OGEcVUFzAr76mdT8.eml b/tests/_root/shared/emails/yi6OSwqGgquzgMmKQajIatNnR1OGEcVUFzAr76mdT8.eml new file mode 100644 index 00000000..63bbd857 --- /dev/null +++ b/tests/_root/shared/emails/yi6OSwqGgquzgMmKQajIatNnR1OGEcVUFzAr76mdT8.eml @@ -0,0 +1,10 @@ +Date: Thu, 16 Oct 2025 17:33:21 +0300 +To: Benny +From: John Doe +Subject: Lorem +Message-ID: +X-Mailer: PHPMailer 6.10.0 (https://github.com/PHPMailer/PHPMailer) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +Lorem ipsum dolor sit amet