diff --git a/src/Router/ModuleLoader.php b/src/Router/ModuleLoader.php new file mode 100644 index 0000000..00b2be8 --- /dev/null +++ b/src/Router/ModuleLoader.php @@ -0,0 +1,94 @@ +config->getModulesPath(); + $routesFile = $this->config->getRoutesFile(); + + if ($modulesPath === null) { + throw new MissingConfigurationException( + 'modules_path configuration is required to use module() method' + ); + } + + $moduleName = $identifier[0] ?? ''; + + foreach ((array)$modulesPath as $basePath) { + $modulePath = $basePath . '/' . $moduleName . '/' . $routesFile; + + if (file_exists($modulePath)) { + $this->loadModuleRoutes($modulePath, $prefix, $moduleName); + } + } + } + + /** + * Loads route definitions from a file and registers them. + * + * @param string $routesFile The path to the routes file + * @param string|null $prefix Optional URI prefix + * @param string|null $moduleName Optional module name + * @return void + */ + private function loadModuleRoutes(string $routesFile, string|null $prefix = null, string|null $moduleName = null): void + { + $moduleRoutes = require $routesFile; + + $options = []; + if ($prefix) { + $options['prefix'] = $prefix; + } + + $group = $this->target->group($options); + + foreach ($moduleRoutes as $routeData) { + if (!isset($routeData['method'], $routeData['path'], $routeData['handler'])) { + continue; + } + + $route = $group->registerCustomRoute( + $routeData['method'], + $routeData['path'], + $routeData['handler'], + $routeData['name'] ?? null, + $routeData['middleware'] ?? [], + $routeData['validation'] ?? [], + $routeData['defaults'] ?? [] + ); + + if ($moduleName) { + $route->setModule($moduleName); + } + } + } +} diff --git a/src/Router/RouteCollection.php b/src/Router/RouteCollection.php new file mode 100644 index 0000000..cda5464 --- /dev/null +++ b/src/Router/RouteCollection.php @@ -0,0 +1,100 @@ + + */ +class RouteCollection implements \IteratorAggregate, \Serializable +{ + public function serialize(): string + { + return serialize($this->__serialize()); + } + + public function unserialize(string $data): void + { + $this->__unserialize(unserialize($data)); + } + + public function __serialize(): array + { + return [ + 'routes' => $this->routes, + 'namedRoutes' => $this->namedRoutes, + ]; + } + + public function __unserialize(array $data): void + { + $this->routes = $data['routes']; + $this->namedRoutes = $data['namedRoutes']; + } + /** + * @var RouteDefinition[] + */ + private array $routes = []; + + /** + * @var array + */ + private array $namedRoutes = []; + + /** + * Adds a route definition to the collection. + * + * @param RouteDefinition $route The route to add + * @return void + */ + public function add(RouteDefinition $route): void + { + $this->routes[] = $route; + + if ($name = $route->getName()) { + $this->namedRoutes[$name] = $route; + } + } + + /** + * Finds a route by its name. + * + * @param string $name The name of the route + * @return RouteDefinition|null The route if found, null otherwise + */ + public function getByName(string $name): ?RouteDefinition + { + return $this->namedRoutes[$name] ?? null; + } + + /** + * Returns all route definitions in the collection. + * + * @return RouteDefinition[] + */ + public function all(): array + { + return $this->routes; + } + + /** + * Returns an iterator for the route definitions. + * + * @return \Traversable + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->routes); + } + + /** + * Gets the number of routes in the collection. + * + * @return int + */ + public function count(): int + { + return count($this->routes); + } +} diff --git a/src/Router/RouteMatcher.php b/src/Router/RouteMatcher.php new file mode 100644 index 0000000..f5850c2 --- /dev/null +++ b/src/Router/RouteMatcher.php @@ -0,0 +1,236 @@ +getMethod()); + $path = $this->normalizePath($request->getUri()->getPath()); + $host = $request->getUri()->getHost(); + + $routesToMatch = $routes; + + foreach ($routesToMatch as $route) { + $attributes = []; + if ($this->isMatch($method, $path, $host, $route, $attributes)) { + $attributes = $this->mergeDefaults($route, $attributes); + return $this->applyAttributes($route, $attributes); + } + + // i18n support: check alternative paths + $routeAttributes = $route->getAttributes(); + if (isset($routeAttributes['i18n']) && is_array($routeAttributes['i18n'])) { + foreach ($routeAttributes['i18n'] as $lang => $i18nPath) { + $normalizedI18nPath = $this->normalizePath($i18nPath); + if ($this->isMatch($method, $path, $host, $route, $attributes, $normalizedI18nPath)) { + $attributes['lang'] = $lang; + $attributes = $this->mergeDefaults($route, $attributes); + return $this->applyAttributes($route, $attributes); + } + } + } + } + + // Try to find a fallback + return $this->matchFallback($path, $routes); + } + + /** + * Attempts to match a fallback handler for the given path. + * + * @param string $path + * @param RouteCollection $routes + * @return RouteDefinition|null + */ + private function matchFallback(string $path, RouteCollection $routes): ?RouteDefinition + { + $bestFallback = null; + $longestPrefix = -1; + + foreach ($routes as $route) { + $attributes = $route->getAttributes(); + if (isset($attributes['_fallback'])) { + $prefix = $attributes['_fallback_prefix'] ?? ''; + if (str_starts_with($path, $prefix) && strlen($prefix) > $longestPrefix) { + $longestPrefix = strlen($prefix); + $bestFallback = $route; + } + } + } + + if ($bestFallback) { + $attributes = $bestFallback->getAttributes(); + return new RouteDefinition( + 'FALLBACK', + $path, + $path, + $attributes['_fallback'], + null, + $bestFallback->getMiddleware() + ); + } + + return null; + } + + /** + * Merges default values for missing optional parameters. + * + * @param RouteDefinition $route + * @param array $attributes + * @return array + */ + private function mergeDefaults(RouteDefinition $route, array $attributes): array + { + return array_merge($route->getDefaults(), $attributes); + } + + /** + * Determines if a request matches a route definition. + * + * @param string $method The request method + * @param string $path The request path + * @param RouteDefinition $route The route to check + * @param array $attributes Extracted attributes + * @return bool + */ + private function isMatch(string $method, string $path, string $host, RouteDefinition $route, array &$attributes, ?string $overridePath = null): bool + { + $routeMethod = strtoupper($route->getMethod()); + if ($routeMethod !== $method && $routeMethod !== 'REDIRECT') { + return false; + } + + // Subdomain constraint check + $routeAttributes = $route->getAttributes(); + if (isset($routeAttributes['subdomain'])) { + $subdomain = $routeAttributes['subdomain']; + if (!str_starts_with($host, $subdomain . '.')) { + return false; + } + } + + $pattern = $overridePath ? $this->compilePatternFromPath($overridePath, $route) : $this->getPatternForRoute($route); + + if (preg_match($pattern, $path, $matches)) { + $attributes = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); + return true; + } + + return false; + } + + /** + * @internal + */ + public function getPatternForRoute(RouteDefinition $route): string + { + return $this->compilePattern($route); + } + + /** + * Compiles a route path into a regex pattern. + * + * @param RouteDefinition $route + * @return string + */ + private function compilePattern(RouteDefinition $route): string + { + $id = spl_object_id($route); + if (isset($this->compiledPatterns[$id])) { + return $this->compiledPatterns[$id]; + } + + $this->compiledPatterns[$id] = $this->compilePatternFromPath($route->getPath(), $route); + return $this->compiledPatterns[$id]; + } + + /** + * Compiles a specific path into a regex pattern using route's validation and defaults. + * + * @param string $path + * @param RouteDefinition $route + * @return string + */ + private function compilePatternFromPath(string $path, RouteDefinition $route): string + { + $validation = $route->getValidation(); + $defaults = $route->getDefaults(); + + // Replace {{param?}} and {{param}} with regex + $pattern = preg_replace_callback('#/\{\{([a-zA-Z0-9_]+)(\?)?\}\}#', function ($matches) use ($validation, $defaults) { + $name = $matches[1]; + $optional = (isset($matches[2]) && $matches[2] === '?') || array_key_exists($name, $defaults); + + $rules = $validation[$name] ?? []; + $regex = '[^/]+'; + + // Validation rules support + foreach ((array)$rules as $rule) { + if ($rule === 'numeric' || $rule === 'int') { + $regex = '[0-9]+'; + } elseif ($rule === 'alpha') { + $regex = '[a-zA-Z]+'; + } elseif ($rule === 'alphanumeric') { + $regex = '[a-zA-Z0-9]+'; + } elseif (str_starts_with($rule, 'regex:')) { + $regex = substr($rule, 6); + } + } + + if ($optional) { + return '(?:/(?P<' . $name . '>' . $regex . '))?'; + } + + return '/(?P<' . $name . '>' . $regex . ')'; + }, $path); + + $pattern = str_replace('//', '/', $pattern); + + return '#^' . $pattern . '/?$#'; + } + + /** + * Applies extracted attributes to a new route definition. + * + * @param RouteDefinition $route + * @param array $attributes + * @return RouteDefinition + */ + private function applyAttributes(RouteDefinition $route, array $attributes): RouteDefinition + { + $data = $route->toArray(); + $data['attributes'] = array_merge($data['attributes'], $attributes); + + return new RouteDefinition( + $data['method'], + $data['pattern'], + $data['path'], + $data['handler'], + $data['name'], + $data['middleware'], + $data['validation'], + $data['defaults'], + $data['module'], + $data['attributes'] + ); + } +} diff --git a/src/Router/Router.php b/src/Router/Router.php index 746ab57..1bff7e6 100644 --- a/src/Router/Router.php +++ b/src/Router/Router.php @@ -4,357 +4,228 @@ namespace Atlas\Router; use Psr\Http\Message\ServerRequestInterface; use Atlas\Config\Config; -use Atlas\Exception\MissingConfigurationException; -use Atlas\Exception\NotFoundRouteException; use Atlas\Exception\RouteNotFoundException; +use Atlas\Exception\MissingConfigurationException; -/** - * Main routing engine for the Atlas framework. - * - * Provides fluent, chainable API for route registration and request matching. - * Supports static and dynamic URIs, named routes, module routing, and error handling. - * - * @implements \IteratorAggregate - */ -class Router implements \IteratorAggregate +class Router { - /** - * Private array to store registered route definitions. - * - * @var array - */ - private array $routes = []; + use PathHelper; - /** - * Protected fallback handler for 404 scenarios. - * - * @var mixed mixed callable|string|null - */ + private RouteCollection $routes; + private readonly RouteMatcher $matcher; + private readonly ModuleLoader $loader; protected mixed $fallbackHandler = null; - /** - * Constructs a new Router instance with configuration. - * - * @param Config\Config $config Configuration object containing routing settings - */ public function __construct( - private readonly Config\Config $config + private readonly Config $config ) { + $this->routes = new RouteCollection(); + $this->matcher = new RouteMatcher(); + $this->loader = new ModuleLoader($this->config, $this); } - /** - * Registers a GET route. - * - * @param string $path URI path - * @param string|callable $handler Route handler or string reference - * @param string|null $name Optional route name for reverse routing - * @return self Fluent interface for method chaining - */ - public function get(string $path, string|callable $handler, string|null $name = null): self + public function get(string $path, string|callable $handler, string|null $name = null): RouteDefinition { - $this->registerRoute('GET', $path, $handler, $name); - return $this; + return $this->registerRoute('GET', $path, $handler, $name); } - /** - * Registers a POST route. - * - * @param string $path URI path - * @param string|callable $handler Route handler or string reference - * @param string|null $name Optional route name for reverse routing - * @return self Fluent interface for method chaining - */ - public function post(string $path, string|callable $handler, string|null $name = null): self + public function post(string $path, string|callable $handler, string|null $name = null): RouteDefinition { - $this->registerRoute('POST', $path, $handler, $name); - return $this; + return $this->registerRoute('POST', $path, $handler, $name); } - /** - * Registers a PUT route. - * - * @param string $path URI path - * @param string|callable $handler Route handler or string reference - * @param string|null $name Optional route name for reverse routing - * @return self Fluent interface for method chaining - */ - public function put(string $path, string|callable $handler, string|null $name = null): self + public function put(string $path, string|callable $handler, string|null $name = null): RouteDefinition { - $this->registerRoute('PUT', $path, $handler, $name); - return $this; + return $this->registerRoute('PUT', $path, $handler, $name); } - /** - * Registers a PATCH route. - * - * @param string $path URI path - * @param string|callable $handler Route handler or string reference - * @param string|null $name Optional route name for reverse routing - * @return self Fluent interface for method chaining - */ - public function patch(string $path, string|callable $handler, string|null $name = null): self + public function patch(string $path, string|callable $handler, string|null $name = null): RouteDefinition { - $this->registerRoute('PATCH', $path, $handler, $name); - return $this; + return $this->registerRoute('PATCH', $path, $handler, $name); } - /** - * Registers a DELETE route. - * - * @param string $path URI path - * @param string|callable $handler Route handler or string reference - * @param string|null $name Optional route name for reverse routing - * @return self Fluent interface for method chaining - */ - public function delete(string $path, string|callable $handler, string|null $name = null): self + public function delete(string $path, string|callable $handler, string|null $name = null): RouteDefinition { - $this->registerRoute('DELETE', $path, $handler, $name); - return $this; + return $this->registerRoute('DELETE', $path, $handler, $name); } - /** - * Normalizes a path string. - * - * Removes leading and trailing slashes and ensures proper format. - * - * @param string $path Raw path string - * @return string Normalized path - */ - private function normalizePath(string $path): string + public function registerCustomRoute(string $method, string $path, mixed $handler, string|null $name = null, array $middleware = [], array $validation = [], array $defaults = []): RouteDefinition { - $normalized = trim($path, '/'); - - if (empty($normalized)) { - return '/'; - } - - return '/' . $normalized; + return $this->registerRoute($method, $path, $handler, $name, $middleware, $validation, $defaults); } - /** - * Registers a route definition and stores it. - * - * @param string $method HTTP method - * @param string $path URI path - * @param mixed $middleware middleware (not yet implemented) - * @param string|callable $handler Route handler - * @param string|null $name Optional route name - */ - private function registerRoute(string $method, string $path, mixed $middleware, string|callable $handler, string|null $name = null): void + private function registerRoute(string $method, string $path, mixed $handler, string|null $name = null, array $middleware = [], array $validation = [], array $defaults = []): RouteDefinition { + $normalizedPath = $this->normalizePath($path); $routeDefinition = new RouteDefinition( $method, - $this->normalizePath($path), - $this->normalizePath($path), + $normalizedPath, + $normalizedPath, $handler, - $name + $name, + $middleware, + $validation, + $defaults ); $this->storeRoute($routeDefinition); + + return $routeDefinition; } - /** - * Stores a route definition for later matching. - * - * @param RouteDefinition $routeDefinition Route definition instance - */ protected function storeRoute(RouteDefinition $routeDefinition): void { - // Routes will be managed by a route collection class (to be implemented) - // For now, we register them in an array property - if (!isset($this->routes)) { - $this->routes = []; - } - - $this->routes[] = $routeDefinition; + $this->routes->add($routeDefinition); } - /** - * Retrieves all registered route definitions. - * - * @return iterable All route definitions - */ - public function getRoutes(): iterable + public function getRoutes(): RouteCollection { - return $this->routes ?? []; + return $this->routes; } - /** - * Matches a request to registered routes. - * - * Returns null if no match is found. - * - * @param ServerRequestInterface $request PSR-7 request object - * @return RouteDefinition|null Matched route or null - */ - public function match(ServerRequestInterface $request): RouteDefinition|null + public function setRoutes(RouteCollection $routes): self { - $method = strtoupper($request->getMethod()); - $path = $this->normalizePath($request->getUri()->getPath()); - - foreach ($this->getRoutes() as $routeDefinition) { - if (strtoupper($routeDefinition->getMethod()) === $method && $routeDefinition->getPath() === $path) { - return $routeDefinition; - } - } - - return null; - } - - /** - * Matches a request and throws exception if no match found. - * - * @param ServerRequestInterface $request PSR-7 request object - * @return RouteDefinition Matched route definition - * @throws NotFoundRouteException If no route matches - */ - public function matchOrFail(ServerRequestInterface $request): RouteDefinition - { - $method = strtoupper($request->getMethod()); - $path = $this->normalizePath($request->getUri()->getPath()); - - foreach ($this->getRoutes() as $routeDefinition) { - if (strtoupper($routeDefinition->getMethod()) === $method && $routeDefinition->getPath() === $path) { - return $routeDefinition; - } - } - - throw new NotFoundRouteException('No route matched the request'); - } - - /** - * Sets a fallback handler for unmatched requests. - * - * @param callable|string|null $handler Fallback handler - * @return self Fluent interface - */ - public function fallback(mixed $handler): self - { - $this->fallbackHandler = $handler; + $this->routes = $routes; return $this; } /** - * Generates a URL for a named route with parameters. - * - * @param string $name Route name - * @param array $parameters Route parameters - * @return string Generated URL path - * @throws RouteNotFoundException If route name not found + * @internal */ + public function getConfig(): Config + { + return $this->config; + } + + public function match(ServerRequestInterface $request): RouteDefinition|null + { + return $this->matcher->match($request, $this->routes); + } + + public function inspect(ServerRequestInterface $request): MatchResult + { + $method = strtoupper($request->getMethod()); + $path = $this->normalizePath($request->getUri()->getPath()); + $host = $request->getUri()->getHost(); + + $diagnostics = [ + 'method' => $method, + 'path' => $path, + 'host' => $host, + 'attempts' => [] + ]; + + foreach ($this->routes as $route) { + $attributes = []; + $routeMethod = strtoupper($route->getMethod()); + $routePath = $route->getPath(); + + $matchStatus = 'mismatch'; + if ($routeMethod !== $method && $routeMethod !== 'REDIRECT') { + $matchStatus = 'method_mismatch'; + } else { + $pattern = $this->matcher->getPatternForRoute($route); + if (preg_match($pattern, $path, $matches)) { + $matchStatus = 'matched'; + $attributes = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); + $attributes = array_merge($route->getDefaults(), $attributes); + + return new MatchResult(true, $route, $attributes, $diagnostics); + } + } + + $diagnostics['attempts'][] = [ + 'route' => $route->getName() ?? $route->getMethod() . ' ' . $route->getPath(), + 'status' => $matchStatus, + 'pattern' => $this->matcher->getPatternForRoute($route) + ]; + } + + return new MatchResult(false, null, [], $diagnostics); + } + + public function matchOrFail(ServerRequestInterface $request): RouteDefinition + { + $route = $this->match($request); + + if ($route === null) { + throw new RouteNotFoundException('No route matched the request'); + } + + return $route; + } + + public function redirect(string $path, string $destination, int $status = 302): RouteDefinition + { + return $this->registerRoute('REDIRECT', $path, $destination)->attr('status', $status); + } + + public function fallback(mixed $handler): self + { + $this->fallbackHandler = $handler; + + // Register a special route to carry the global fallback + $this->registerRoute('FALLBACK', '/_fallback', $handler) + ->attr('_fallback', $handler) + ->attr('_fallback_prefix', '/'); + + return $this; + } + + public function getFallbackHandler(): mixed + { + return $this->fallbackHandler; + } + public function url(string $name, array $parameters = []): string { - $routes = $this->getRoutes(); - $foundRoute = null; - - foreach ($routes as $routeDefinition) { - if ($routeDefinition->getName() === $name) { - $foundRoute = $routeDefinition; - break; - } - } + $foundRoute = $this->routes->getByName($name); if ($foundRoute === null) { throw new RouteNotFoundException(sprintf('Route "%s" not found for URL generation', $name)); } $path = $foundRoute->getPath(); - $path = $this->replaceParameters($path, $parameters); + $path = $this->replaceParameters($path, $parameters, $foundRoute->getDefaults()); return $path; } - /** - * Replaces {{param}} placeholders in a path. - * - * @param string $path Path string with placeholders - * @param array $parameters Parameter values - * @return string Path with parameters replaced - */ - private function replaceParameters(string $path, array $parameters): string + private function replaceParameters(string $path, array $parameters, array $defaults = []): string { - foreach ($parameters as $key => $value) { - $pattern = '{{' . $key . '}}'; - $path = str_replace($pattern, $value, $path); + if (!str_contains($path, '{{')) { + return $this->normalizePath($path); } - return $path; + preg_match_all('/\{\{([a-zA-Z0-9_]+)(\?)?\}\}/', $path, $matches); + $parameterNames = $matches[1]; + $isOptional = $matches[2]; + + foreach ($parameterNames as $index => $name) { + $pattern = '{{' . $name . ($isOptional[$index] === '?' ? '?' : '') . '}}'; + + if (array_key_exists($name, $parameters)) { + $path = str_replace($pattern, (string)$parameters[$name], $path); + } elseif (array_key_exists($name, $defaults)) { + $path = str_replace($pattern, (string)$defaults[$name], $path); + } elseif ($isOptional[$index] === '?') { + $path = str_replace($pattern, '', $path); + } else { + throw new \InvalidArgumentException(sprintf('Missing required parameter "%s" for route URL generation', $name)); + } + } + + return $this->normalizePath($path); } - /** - * Creates a new route group for nested routing. - * - * @param array $options Group options including prefix and middleware - * @return RouteGroup Route group instance - */ public function group(array $options): RouteGroup { return new RouteGroup($options, $this); } - /** - * Auto-discovers and registers routes from modules. - * - * @param string|array $identifier Module identifier or array containing identifier and options - * @return self Fluent interface - * @throws MissingConfigurationException If modules_path not configured - */ - public function module(string|array $identifier): self + public function module(string|array $identifier, string|null $prefix = null): self { - $identifier = is_string($identifier) ? [$identifier] : $identifier; - - $modulesPath = $this->config->getModulesPath(); - $routesFile = $this->config->getRoutesFile(); - - if ($modulesPath === null) { - throw new MissingConfigurationException( - 'modules_path configuration is required to use module() method' - ); - } - - $prefix = $identifier[0] ?? ''; - - foreach ($modulesPath as $basePath) { - $modulePath = $basePath . '/' . $prefix . '/' . $routesFile; - - if (file_exists($modulePath)) { - $this->loadModuleRoutes($modulePath); - } - } - + $this->loader->load($identifier, $prefix); return $this; } - - /** - * Loads and registers routes from a module routes file. - * - * @param string $routesFile Path to routes.php file - */ - private function loadModuleRoutes(string $routesFile): void - { - $moduleRoutes = require $routesFile; - - foreach ($moduleRoutes as $routeData) { - if (!isset($routeData['method'], $routeData['path'], $routeData['handler'])) { - continue; - } - - $this->registerRoute( - $routeData['method'], - $routeData['path'], - $routeData['handler'], - $routeData['name'] ?? null - ); - } - } - - /** - * Creates an iterator over registered routes. - * - * @return \Traversable Array iterator over route collection - */ - public function getIterator(): \Traversable - { - return new \ArrayIterator($this->routes ?? []); - } } \ No newline at end of file diff --git a/tests/Unit/ErrorHandlingTest.php b/tests/Unit/ErrorHandlingTest.php index 3454f73..86b1a8a 100644 --- a/tests/Unit/ErrorHandlingTest.php +++ b/tests/Unit/ErrorHandlingTest.php @@ -2,7 +2,10 @@ namespace Atlas\Tests\Unit; +use Atlas\Exception\RouteNotFoundException; +use Atlas\Router\RouteDefinition; use Atlas\Router\Router; +use Atlas\Config\Config; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; @@ -11,11 +14,11 @@ final class ErrorHandlingTest extends TestCase { public function testMatchOrFailThrowsExceptionWhenNoRouteFound(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new Config([ 'modules_path' => ['/path/to/modules'] ]); - $router = new \Atlas\Router\Router($config); + $router = new Router($config); $uri = $this->createMock(UriInterface::class); $uri->method('getPath')->willReturn('/nonexistent'); @@ -27,18 +30,18 @@ final class ErrorHandlingTest extends TestCase $request->method('getMethod')->willReturn('GET'); $request->method('getUri')->willReturn($uri); - $this->expectException(\Atlas\Exception\NotFoundRouteException::class); + $this->expectException(RouteNotFoundException::class); $router->matchOrFail($request); } public function testMatchReturnsNullWhenNoRouteFound(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new Config([ 'modules_path' => ['/path/to/modules'] ]); - $router = new \Atlas\Router\Router($config); + $router = new Router($config); $uri = $this->createMock(UriInterface::class); $uri->method('getPath')->willReturn('/nonexistent'); @@ -57,38 +60,39 @@ final class ErrorHandlingTest extends TestCase public function testRouteChainingWithDifferentHttpMethods(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new Config([ 'modules_path' => ['/path/to/modules'] ]); - $result = new \Atlas\Router\Router($config)->get('/test', 'GetHandler')->post('/test', 'PostHandler'); + $router = new Router($config); + $router->get('/test', 'GetHandler'); + $router->post('/test', 'PostHandler'); - $this->assertTrue($result instanceof \Atlas\Router\Router); - $this->assertCount(2, $result->getRoutes()); + $this->assertCount(2, $router->getRoutes()); } public function testMatchUsingRouteDefinition(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new Config([ 'modules_path' => ['/path/to/modules'] ]); - $router = new \Atlas\Router\Router($config); + $router = new Router($config); $router->get('/test', 'TestMethod'); - $routes = $router->getRoutes(); + $routes = iterator_to_array($router->getRoutes()); $this->assertCount(1, $routes); - $this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $routes[0]); + $this->assertInstanceOf(RouteDefinition::class, $routes[0]); } public function testCaseInsensitiveHttpMethodMatching(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new Config([ 'modules_path' => ['/path/to/modules'] ]); - $router = new \Atlas\Router\Router($config); + $router = new Router($config); $router->get('/test', 'TestHandler'); @@ -109,11 +113,11 @@ final class ErrorHandlingTest extends TestCase public function testPathNormalizationLeadingSlashes(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new Config([ 'modules_path' => ['/path/to/modules'] ]); - $router = new \Atlas\Router\Router($config); + $router = new Router($config); $router->get('/test', 'TestHandler'); @@ -134,11 +138,11 @@ final class ErrorHandlingTest extends TestCase public function testMatchOrFailThrowsExceptionForMultipleRoutes(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new Config([ 'modules_path' => ['/path/to/modules'] ]); - $router = new \Atlas\Router\Router($config); + $router = new Router($config); $router->get('/test', 'TestHandler'); @@ -154,6 +158,15 @@ final class ErrorHandlingTest extends TestCase $matchedRoute = $router->matchOrFail($request); - $this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $matchedRoute); + $this->assertInstanceOf(RouteDefinition::class, $matchedRoute); + } + + public function testModuleThrowsExceptionWhenModulesPathIsMissing(): void + { + $config = new Config([]); + $router = new Router($config); + + $this->expectException(\Atlas\Exception\MissingConfigurationException::class); + $router->module('User'); } } \ No newline at end of file diff --git a/tests/Unit/RouterBasicTest.php b/tests/Unit/RouterBasicTest.php index 89ada9b..9d2f8a9 100644 --- a/tests/Unit/RouterBasicTest.php +++ b/tests/Unit/RouterBasicTest.php @@ -9,7 +9,7 @@ class RouterBasicTest extends TestCase { public function testRouterCanBeCreatedWithValidConfig(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new \Atlas\Config\Config([ 'modules_path' => ['/path/to/modules'], 'routes_file' => 'routes.php' ]); @@ -21,7 +21,7 @@ class RouterBasicTest extends TestCase public function testRouterCanCreateSimpleRoute(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new \Atlas\Config\Config([ 'modules_path' => ['/path/to/modules'] ]); @@ -36,20 +36,21 @@ class RouterBasicTest extends TestCase public function testRouterReturnsSameInstanceForChaining(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new \Atlas\Config\Config([ 'modules_path' => ['/path/to/modules'] ]); $router = new \Atlas\Router\Router($config); - $result = $router->get('/get', 'handler')->post('/post', 'handler'); + $router->get('/get', 'handler'); + $router->post('/post', 'handler'); - $this->assertTrue($result instanceof \Atlas\Router\Router); + $this->assertCount(2, $router->getRoutes()); } public function testRouteHasCorrectProperties(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new \Atlas\Config\Config([ 'modules_path' => ['/path/to/modules'] ]); @@ -57,7 +58,7 @@ class RouterBasicTest extends TestCase $router->get('/test', 'test_handler', 'test_route'); - $routes = $router->getRoutes(); + $routes = iterator_to_array($router->getRoutes()); $route = $routes[0] ?? null; $this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $route); @@ -72,7 +73,7 @@ class RouterBasicTest extends TestCase public function testRouteNormalizesPath(): void { - $config = new \Atlas\Tests\Config\Config([ + $config = new \Atlas\Config\Config([ 'modules_path' => ['/path/to/modules'] ]); @@ -80,7 +81,7 @@ class RouterBasicTest extends TestCase $router->get('/api/test', 'handler'); - $routes = $router->getRoutes(); + $routes = iterator_to_array($router->getRoutes()); $route = $routes[0] ?? null; $this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $route);