refactor: implement milestone 4 - architectural refinement (SRP & SOLID)

This commit is contained in:
Funky Waddle 2026-02-14 17:27:20 -06:00
parent 7152e2b3e7
commit ea23140a40
6 changed files with 622 additions and 307 deletions

View file

@ -0,0 +1,94 @@
<?php
namespace Atlas\Router;
use Atlas\Config\Config;
use Atlas\Exception\MissingConfigurationException;
/**
* Handles module discovery and route loading.
*/
class ModuleLoader
{
/**
* Constructs a new ModuleLoader instance.
*
* @param Config $config The configuration object
* @param Router|RouteGroup $target The target to register routes to
*/
public function __construct(
private readonly Config $config,
private readonly Router|RouteGroup $target
) {}
/**
* Loads routes for a given module or modules.
*
* @param string|array $identifier The module identifier or array of identifiers
* @param string|null $prefix Optional URI prefix for the module
* @return void
* @throws MissingConfigurationException if modules_path is not configured
*/
public function load(string|array $identifier, string|null $prefix = null): void
{
$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'
);
}
$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);
}
}
}
}

View file

@ -0,0 +1,100 @@
<?php
namespace Atlas\Router;
/**
* Manages the storage and retrieval of route definitions.
*
* @implements \IteratorAggregate<int, RouteDefinition>
*/
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<string, RouteDefinition>
*/
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<int, RouteDefinition>
*/
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);
}
}

236
src/Router/RouteMatcher.php Normal file
View file

@ -0,0 +1,236 @@
<?php
namespace Atlas\Router;
use Psr\Http\Message\ServerRequestInterface;
/**
* Handles the logic for matching a request to a route.
*/
class RouteMatcher
{
use PathHelper;
private array $compiledPatterns = [];
/**
* Matches a request against a collection of routes.
*
* @param ServerRequestInterface $request The request to match
* @param RouteCollection $routes The collection of routes to match against
* @return RouteDefinition|null The matched route or null if no match found
*/
public function match(ServerRequestInterface $request, RouteCollection $routes): ?RouteDefinition
{
$method = strtoupper($request->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']
);
}
}

View file

@ -4,357 +4,228 @@ namespace Atlas\Router;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Atlas\Config\Config; use Atlas\Config\Config;
use Atlas\Exception\MissingConfigurationException;
use Atlas\Exception\NotFoundRouteException;
use Atlas\Exception\RouteNotFoundException; use Atlas\Exception\RouteNotFoundException;
use Atlas\Exception\MissingConfigurationException;
/** class Router
* 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<array-key, RouteDefinition>
*/
class Router implements \IteratorAggregate
{ {
/** use PathHelper;
* Private array to store registered route definitions.
*
* @var array<RouteDefinition>
*/
private array $routes = [];
/** private RouteCollection $routes;
* Protected fallback handler for 404 scenarios. private readonly RouteMatcher $matcher;
* private readonly ModuleLoader $loader;
* @var mixed mixed callable|string|null
*/
protected mixed $fallbackHandler = null; protected mixed $fallbackHandler = null;
/**
* Constructs a new Router instance with configuration.
*
* @param Config\Config $config Configuration object containing routing settings
*/
public function __construct( 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);
} }
/** public function get(string $path, string|callable $handler, string|null $name = null): RouteDefinition
* 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
{ {
$this->registerRoute('GET', $path, $handler, $name); return $this->registerRoute('GET', $path, $handler, $name);
return $this;
} }
/** public function post(string $path, string|callable $handler, string|null $name = null): RouteDefinition
* 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
{ {
$this->registerRoute('POST', $path, $handler, $name); return $this->registerRoute('POST', $path, $handler, $name);
return $this;
} }
/** public function put(string $path, string|callable $handler, string|null $name = null): RouteDefinition
* 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
{ {
$this->registerRoute('PUT', $path, $handler, $name); return $this->registerRoute('PUT', $path, $handler, $name);
return $this;
} }
/** public function patch(string $path, string|callable $handler, string|null $name = null): RouteDefinition
* 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
{ {
$this->registerRoute('PATCH', $path, $handler, $name); return $this->registerRoute('PATCH', $path, $handler, $name);
return $this;
} }
/** public function delete(string $path, string|callable $handler, string|null $name = null): RouteDefinition
* 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
{ {
$this->registerRoute('DELETE', $path, $handler, $name); return $this->registerRoute('DELETE', $path, $handler, $name);
return $this;
} }
/** public function registerCustomRoute(string $method, string $path, mixed $handler, string|null $name = null, array $middleware = [], array $validation = [], array $defaults = []): RouteDefinition
* 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
{ {
$normalized = trim($path, '/'); return $this->registerRoute($method, $path, $handler, $name, $middleware, $validation, $defaults);
if (empty($normalized)) {
return '/';
}
return '/' . $normalized;
} }
/** private function registerRoute(string $method, string $path, mixed $handler, string|null $name = null, array $middleware = [], array $validation = [], array $defaults = []): RouteDefinition
* 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
{ {
$normalizedPath = $this->normalizePath($path);
$routeDefinition = new RouteDefinition( $routeDefinition = new RouteDefinition(
$method, $method,
$this->normalizePath($path), $normalizedPath,
$this->normalizePath($path), $normalizedPath,
$handler, $handler,
$name $name,
$middleware,
$validation,
$defaults
); );
$this->storeRoute($routeDefinition); $this->storeRoute($routeDefinition);
return $routeDefinition;
} }
/**
* Stores a route definition for later matching.
*
* @param RouteDefinition $routeDefinition Route definition instance
*/
protected function storeRoute(RouteDefinition $routeDefinition): void protected function storeRoute(RouteDefinition $routeDefinition): void
{ {
// Routes will be managed by a route collection class (to be implemented) $this->routes->add($routeDefinition);
// For now, we register them in an array property
if (!isset($this->routes)) {
$this->routes = [];
}
$this->routes[] = $routeDefinition;
} }
/** public function getRoutes(): RouteCollection
* Retrieves all registered route definitions.
*
* @return iterable All route definitions
*/
public function getRoutes(): iterable
{ {
return $this->routes ?? []; return $this->routes;
} }
/** public function setRoutes(RouteCollection $routes): self
* 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
{ {
$method = strtoupper($request->getMethod()); $this->routes = $routes;
$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;
return $this; return $this;
} }
/** /**
* Generates a URL for a named route with parameters. * @internal
*
* @param string $name Route name
* @param array $parameters Route parameters
* @return string Generated URL path
* @throws RouteNotFoundException If route name not found
*/ */
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 public function url(string $name, array $parameters = []): string
{ {
$routes = $this->getRoutes(); $foundRoute = $this->routes->getByName($name);
$foundRoute = null;
foreach ($routes as $routeDefinition) {
if ($routeDefinition->getName() === $name) {
$foundRoute = $routeDefinition;
break;
}
}
if ($foundRoute === null) { if ($foundRoute === null) {
throw new RouteNotFoundException(sprintf('Route "%s" not found for URL generation', $name)); throw new RouteNotFoundException(sprintf('Route "%s" not found for URL generation', $name));
} }
$path = $foundRoute->getPath(); $path = $foundRoute->getPath();
$path = $this->replaceParameters($path, $parameters); $path = $this->replaceParameters($path, $parameters, $foundRoute->getDefaults());
return $path; return $path;
} }
/** private function replaceParameters(string $path, array $parameters, array $defaults = []): string
* 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
{ {
foreach ($parameters as $key => $value) { if (!str_contains($path, '{{')) {
$pattern = '{{' . $key . '}}'; return $this->normalizePath($path);
$path = str_replace($pattern, $value, $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 public function group(array $options): RouteGroup
{ {
return new RouteGroup($options, $this); return new RouteGroup($options, $this);
} }
/** public function module(string|array $identifier, string|null $prefix = null): self
* 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
{ {
$identifier = is_string($identifier) ? [$identifier] : $identifier; $this->loader->load($identifier, $prefix);
$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);
}
}
return $this; 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 ?? []);
}
} }

View file

@ -2,7 +2,10 @@
namespace Atlas\Tests\Unit; namespace Atlas\Tests\Unit;
use Atlas\Exception\RouteNotFoundException;
use Atlas\Router\RouteDefinition;
use Atlas\Router\Router; use Atlas\Router\Router;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
@ -11,11 +14,11 @@ final class ErrorHandlingTest extends TestCase
{ {
public function testMatchOrFailThrowsExceptionWhenNoRouteFound(): void public function testMatchOrFailThrowsExceptionWhenNoRouteFound(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
$router = new \Atlas\Router\Router($config); $router = new Router($config);
$uri = $this->createMock(UriInterface::class); $uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/nonexistent'); $uri->method('getPath')->willReturn('/nonexistent');
@ -27,18 +30,18 @@ final class ErrorHandlingTest extends TestCase
$request->method('getMethod')->willReturn('GET'); $request->method('getMethod')->willReturn('GET');
$request->method('getUri')->willReturn($uri); $request->method('getUri')->willReturn($uri);
$this->expectException(\Atlas\Exception\NotFoundRouteException::class); $this->expectException(RouteNotFoundException::class);
$router->matchOrFail($request); $router->matchOrFail($request);
} }
public function testMatchReturnsNullWhenNoRouteFound(): void public function testMatchReturnsNullWhenNoRouteFound(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
$router = new \Atlas\Router\Router($config); $router = new Router($config);
$uri = $this->createMock(UriInterface::class); $uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/nonexistent'); $uri->method('getPath')->willReturn('/nonexistent');
@ -57,38 +60,39 @@ final class ErrorHandlingTest extends TestCase
public function testRouteChainingWithDifferentHttpMethods(): void public function testRouteChainingWithDifferentHttpMethods(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new Config([
'modules_path' => ['/path/to/modules'] '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, $router->getRoutes());
$this->assertCount(2, $result->getRoutes());
} }
public function testMatchUsingRouteDefinition(): void public function testMatchUsingRouteDefinition(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
$router = new \Atlas\Router\Router($config); $router = new Router($config);
$router->get('/test', 'TestMethod'); $router->get('/test', 'TestMethod');
$routes = $router->getRoutes(); $routes = iterator_to_array($router->getRoutes());
$this->assertCount(1, $routes); $this->assertCount(1, $routes);
$this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $routes[0]); $this->assertInstanceOf(RouteDefinition::class, $routes[0]);
} }
public function testCaseInsensitiveHttpMethodMatching(): void public function testCaseInsensitiveHttpMethodMatching(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
$router = new \Atlas\Router\Router($config); $router = new Router($config);
$router->get('/test', 'TestHandler'); $router->get('/test', 'TestHandler');
@ -109,11 +113,11 @@ final class ErrorHandlingTest extends TestCase
public function testPathNormalizationLeadingSlashes(): void public function testPathNormalizationLeadingSlashes(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
$router = new \Atlas\Router\Router($config); $router = new Router($config);
$router->get('/test', 'TestHandler'); $router->get('/test', 'TestHandler');
@ -134,11 +138,11 @@ final class ErrorHandlingTest extends TestCase
public function testMatchOrFailThrowsExceptionForMultipleRoutes(): void public function testMatchOrFailThrowsExceptionForMultipleRoutes(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
$router = new \Atlas\Router\Router($config); $router = new Router($config);
$router->get('/test', 'TestHandler'); $router->get('/test', 'TestHandler');
@ -154,6 +158,15 @@ final class ErrorHandlingTest extends TestCase
$matchedRoute = $router->matchOrFail($request); $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');
} }
} }

View file

@ -9,7 +9,7 @@ class RouterBasicTest extends TestCase
{ {
public function testRouterCanBeCreatedWithValidConfig(): void public function testRouterCanBeCreatedWithValidConfig(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new \Atlas\Config\Config([
'modules_path' => ['/path/to/modules'], 'modules_path' => ['/path/to/modules'],
'routes_file' => 'routes.php' 'routes_file' => 'routes.php'
]); ]);
@ -21,7 +21,7 @@ class RouterBasicTest extends TestCase
public function testRouterCanCreateSimpleRoute(): void public function testRouterCanCreateSimpleRoute(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new \Atlas\Config\Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
@ -36,20 +36,21 @@ class RouterBasicTest extends TestCase
public function testRouterReturnsSameInstanceForChaining(): void public function testRouterReturnsSameInstanceForChaining(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new \Atlas\Config\Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
$router = new \Atlas\Router\Router($config); $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 public function testRouteHasCorrectProperties(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new \Atlas\Config\Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
@ -57,7 +58,7 @@ class RouterBasicTest extends TestCase
$router->get('/test', 'test_handler', 'test_route'); $router->get('/test', 'test_handler', 'test_route');
$routes = $router->getRoutes(); $routes = iterator_to_array($router->getRoutes());
$route = $routes[0] ?? null; $route = $routes[0] ?? null;
$this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $route); $this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $route);
@ -72,7 +73,7 @@ class RouterBasicTest extends TestCase
public function testRouteNormalizesPath(): void public function testRouteNormalizesPath(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new \Atlas\Config\Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
@ -80,7 +81,7 @@ class RouterBasicTest extends TestCase
$router->get('/api/test', 'handler'); $router->get('/api/test', 'handler');
$routes = $router->getRoutes(); $routes = iterator_to_array($router->getRoutes());
$route = $routes[0] ?? null; $route = $routes[0] ?? null;
$this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $route); $this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $route);