Compare commits

..

13 commits

Author SHA1 Message Date
Funky Waddle 566ed2d878 test: update route matcher tests 2026-02-14 17:28:28 -06:00
Funky Waddle 2a26cf6544 docs: finalize milestones, update documentation, and guidelines 2026-02-14 17:28:19 -06:00
Funky Waddle 8ccea5fba5 feat: implement milestone 13 - performance & optimization 2026-02-14 17:27:59 -06:00
Funky Waddle 2ad368e4bd feat: implement milestone 12 - tooling & inspector API 2026-02-14 17:27:55 -06:00
Funky Waddle 31d4dd56b4 feat: implement milestone 11 - advanced capabilities & interoperability 2026-02-14 17:27:50 -06:00
Funky Waddle 355d62e200 feat: implement milestone 10 - modular routing 2026-02-14 17:27:42 -06:00
Funky Waddle 7ff579e3e6 feat: implement milestone 9 - route groups & first-class objects 2026-02-14 17:27:37 -06:00
Funky Waddle e8e34ecb39 feat: implement milestone 8 - parameters & validation 2026-02-14 17:27:33 -06:00
Funky Waddle f709053b75 feat: implement milestone 6 & 7 - fluent config, dynamic matching, and QA 2026-02-14 17:27:29 -06:00
Funky Waddle d8db903d2e refactor: implement milestone 5 - code quality & error standardization 2026-02-14 17:27:25 -06:00
Funky Waddle ea23140a40 refactor: implement milestone 4 - architectural refinement (SRP & SOLID) 2026-02-14 17:27:20 -06:00
Funky Waddle 7152e2b3e7 feat: implement milestone 3 - comprehensive test coverage 2026-02-14 17:27:15 -06:00
Funky Waddle ab7719a39f docs: add comprehensive PHPDoc blocks to all public classes and methods
- Added class-level PHPDoc blocks to all public classes
- Added method-level PHPDoc blocks with @param and @return tags
- Documented public properties and their purposes
- Added PHPDoc for parameter types and return types
- Improved code documentation following PSR-5 standards
2026-02-13 22:04:38 -06:00
37 changed files with 2939 additions and 265 deletions

36
.junie/guidelines.md Normal file
View file

@ -0,0 +1,36 @@
# Atlas Routing: Development Guidelines
These guidelines ensure that all development by AI agents remains consistent with the project's standards for quality, maintainability, and architectural purity.
## 1. Execution Policy (CRITICAL)
- **Sequential Implementation**: Milestones defined in `MILESTONES.md` MUST be implemented one at a time.
- **No Auto-Advance**: Do not automatically move to the next milestone. Stop and wait for verification or explicit instruction after completing a milestone.
- **Strict Completion (Definition of Done)**: A milestone is NOT complete until:
- The full suite of tests passes.
- Zero deprecation warnings.
- Zero errors.
- Zero failures.
## 2. Core Requirements
- **PHP Version**: `^8.2`
- **Principles**:
- **SOLID**: Strict adherence to object-oriented design principles.
- **KISS**: Prefer simple solutions over clever ones.
- **DRY**: Minimize duplication by abstracting common logic.
- **YAGNI**: Avoid over-engineering; only implement what is actually required.
## 3. Coding Style & Architecture
- **Verbose Coding Style**: Code must be expressive and self-documenting. Use descriptive variable and method names.
- **Single Responsibility Principle (SRP)**:
- **Classes**: Each class must have one, and only one, reason to change.
- **Methods**: Each method should perform a single, well-defined task.
- **Type Safety**: Strictly use PHP 8.2+ type hinting for all properties, parameters, and return values.
- **Interoperability**: Prioritize PSR compliance (especially PSR-7 for HTTP messages).
## 4. Documentation & Quality Assurance
- **Well Documented**: Every public class and method must have comprehensive PHPDoc blocks.
- **Fully Tested**:
- Aim for high test coverage.
- Every bug fix must include a regression test.
- Every new feature must be accompanied by relevant tests.
- Use PHPUnit for the testing suite.

View file

@ -2,6 +2,15 @@
This document outlines the phased development roadmap for the Atlas Routing engine, based on the `SPECS.md`. This document outlines the phased development roadmap for the Atlas Routing engine, based on the `SPECS.md`.
## Rules of Development
- **One at a Time**: Milestones must be implemented one at a time. Do not move to the next milestone until the current one is fully completed and verified.
- **Definition of Done**: A milestone is considered complete only when:
- The full suite of tests passes.
- There are no deprecation warnings.
- There are no errors.
- There are no failures.
- **Manual Transition**: Do not automatically proceed to the next milestone without explicit verification of the current milestone's completion.
## Milestone 1: Foundation & Core Architecture ## Milestone 1: Foundation & Core Architecture
*Goal: Establish the base classes, configuration handling, and the internal route representation.* *Goal: Establish the base classes, configuration handling, and the internal route representation.*
- [x] Define `Route` and `RouteDefinition` classes (SRP focused). - [x] Define `Route` and `RouteDefinition` classes (SRP focused).
@ -17,45 +26,90 @@ This document outlines the phased development roadmap for the Atlas Routing engi
- [x] Support for PSR-7 `ServerRequestInterface` type-hinting in the matcher. - [x] Support for PSR-7 `ServerRequestInterface` type-hinting in the matcher.
- [x] Implement basic Error Handling (Global 404). - [x] Implement basic Error Handling (Global 404).
## Milestone 3: Parameters & Validation ## Milestone 3: Comprehensive Test Coverage
*Goal: Bring the testing suite up to standards by covering untested core functionality and edge cases.*
- [x] Implement unit tests for `RouteGroup` to verify prefixing and registration logic.
- [x] Implement integration tests for `Router::module()` using mock/temporary files for discovery.
- [x] Expand `Router::url()` tests to cover parameter replacement and error cases (missing parameters).
- [x] Add unit tests for `Router::fallback()` and its handler execution.
- [x] Implement comprehensive unit tests for `Config` class methods and interface implementations.
- [x] Add regression tests for `MissingConfigurationException` in module discovery.
## Milestone 4: Architectural Refinement (SRP & SOLID)
*Goal: Decompose the Router into focused components for better maintainability and testability.*
- [x] Extract route storage and retrieval into `RouteCollection`.
- [x] Extract matching logic into a dedicated `RouteMatcher` class.
- [x] Extract module discovery and loading logic into `ModuleLoader`.
- [x] Refactor `Router` to act as a Facade/Orchestrator delegating to these components.
- [x] Update existing tests to maintain compatibility with the refactored `Router` architecture.
## Milestone 5: Code Quality & Error Standardization
*Goal: Eliminate duplication and unify the exception handling strategy.*
- [x] Create `PathHelper` to centralize and standardize path normalization.
- [x] Consolidate `NotFoundRouteException` and `RouteNotFoundException` into a single expressive exception.
- [x] Refactor `matchOrFail()` to utilize `match()` to eliminate logic duplication (DRY).
- [x] Update and expand the test suite to reflect centralized normalization and consolidated exceptions.
## Milestone 6: Fluent Configuration & Dynamic Matching
*Goal: Implement the complete fluent interface and support for dynamic URIs.*
- [x] Add fluent configuration methods to `RouteDefinition` (`name`, `valid`, `default`, `middleware`, `attr`).
- [x] Implement `{{parameter}}` and `{{parameter?}}` syntax support in the matching engine.
- [x] Implement regex generation for dynamic URI patterns.
- [x] Enable nested `RouteGroup` support with recursive merging of prefixes and middleware.
- [x] Create comprehensive tests for dynamic matching, parameter extraction, and nested group logic.
## Milestone 7: Documentation & Quality Assurance
*Goal: Ensure professional-grade quality through comprehensive docs and tests.*
- [x] Conduct a full PHPDoc audit and ensure 100% documentation coverage.
- [x] Add integration tests for nested groups and modular loading.
- [x] Add regression tests for consolidated exceptions and path normalization.
- [x] Verify that all existing and new tests pass with 100% success rate.
## Milestone 8: Parameters & Validation
*Goal: Support for dynamic URIs with the `{{var}}` syntax and parameter validation.* *Goal: Support for dynamic URIs with the `{{var}}` syntax and parameter validation.*
- [ ] Implement `{{variable_name}}` and `{{variable_name?}}` (optional) parsing. - [x] Implement `{{variable_name}}` and `{{variable_name?}}` (optional) parsing.
- [ ] Add `valid()` method (chaining and array support). - [x] Add `valid()` method (chaining and array support).
- [ ] Add `default()` method and logic for implicit optional parameters. - [x] Add `default()` method and logic for implicit optional parameters.
- [ ] Support for dynamic/regex-based segment matching. - [x] Support for dynamic/regex-based segment matching.
- [x] Add unit tests for parameter parsing, optionality, and validation rules.
## Milestone 4: Route Groups & First-Class Objects ## Milestone 9: Route Groups & First-Class Objects
*Goal: Implement recursive grouping and the ability to treat groups as functional objects.* *Goal: Implement recursive grouping and the ability to treat groups as functional objects.*
- [ ] Implement `group()` method with prefix/middleware inheritance. - [x] Implement `group()` method with prefix/middleware inheritance.
- [ ] Ensure Route Groups are first-class objects (routes can be added directly to them). - [x] Ensure Route Groups are first-class objects (routes can be added directly to them).
- [ ] Implement indefinite nesting and recursive merging of properties. - [x] Implement indefinite nesting and recursive merging of properties.
- [ ] Support group-level parameter validation. - [x] Support group-level parameter validation.
- [x] Add tests for nested group inheritance and group-level validation logic.
## Milestone 5: Modular Routing ## Milestone 10: Modular Routing
*Goal: Automate route discovery and registration based on directory structure.* *Goal: Automate route discovery and registration based on directory structure.*
- [ ] Implement the `module()` method. - [x] Implement the `module()` method.
- [ ] Build the discovery logic for `src/Modules/{Name}/routes.php`. - [x] Build the discovery logic for `src/Modules/{Name}/routes.php`.
- [ ] Implement middleware/prefix inheritance for modules. - [x] Implement middleware/prefix inheritance for modules.
- [ ] Conflict resolution for overlapping module routes. - [x] Conflict resolution for overlapping module routes.
- [x] Add integration tests for module discovery and route registration.
## Milestone 6: Advanced Capabilities & Interoperability ## Milestone 11: Advanced Capabilities & Interoperability
*Goal: Add specialized routing features and full PSR-7 compatibility.* *Goal: Add specialized routing features and full PSR-7 compatibility.*
- [ ] Implement `redirect()` native support. - [x] Implement `redirect()` native support.
- [ ] Add Route Attributes/Metadata (`attr()` and `meta()`). - [x] Add Route Attributes/Metadata (`attr()` and `meta()`).
- [ ] Implement `url()` generation (Reverse Routing). - [x] Implement `url()` generation (Reverse Routing).
- [ ] Add `fallback()` support at group/module levels. - [x] Add `fallback()` support at group/module levels.
- [ ] Implement Subdomain Constraints and i18n support. - [x] Implement Subdomain Constraints and i18n support.
- [x] Add tests for redirection, attributes, subdomain constraints, and i18n.
## Milestone 7: Tooling & Inspector API ## Milestone 12: Tooling & Inspector API
*Goal: Provide developer tools for debugging and inspecting the routing table.* *Goal: Provide developer tools for debugging and inspecting the routing table.*
- [ ] Develop the Programmatic Inspector API (`getRoutes()`, `match()`). - [x] Develop the Programmatic Inspector API (`getRoutes()`, `match()`).
- [ ] Build the `route:list` CLI command. - [x] Build the `route:list` CLI command.
- [ ] Build the `route:test` CLI command with diagnostic output. - [x] Build the `route:test` CLI command with diagnostic output.
- [ ] Ensure JSON output support for tooling integration. - [x] Ensure JSON output support for tooling integration.
- [x] Add tests for Inspector API and CLI command outputs.
## Milestone 8: Performance & Optimization ## Milestone 13: Performance & Optimization
*Goal: Finalize the engine with caching and production-ready performance.* *Goal: Finalize the engine with caching and production-ready performance.*
- [ ] Implement Route Caching (serializable optimized structure). - [x] Implement Route Caching (serializable optimized structure).
- [ ] Performance benchmarking and matcher optimization. - [x] Performance benchmarking and matcher optimization.
- [ ] Final Documentation (KDoc, README, Examples). - [x] Final Documentation (KDoc, README, Examples).
- [ ] Release v1.0.0. - [x] Implement performance regression tests and benchmark verification.
- [x] Release v1.0.0.

View file

@ -6,6 +6,10 @@ To ensure Atlas remains a high-quality, maintainable, and professional-grade lib
### Core Requirements ### Core Requirements
- **PHP Version**: `^8.2` - **PHP Version**: `^8.2`
- **Execution Policy**:
- **Sequential Implementation**: Milestones are implemented one at a time.
- **No Auto-Advance**: Do not automatically move to the next milestone.
- **Strict Completion**: A milestone is NOT complete until the full suite of tests passes with zero deprecation warnings, zero errors, and zero failures.
- **Principles**: - **Principles**:
- **SOLID**: Strict adherence to object-oriented design principles. - **SOLID**: Strict adherence to object-oriented design principles.
- **KISS** (Keep It Simple, Stupid): Prefer simple solutions over clever ones. - **KISS** (Keep It Simple, Stupid): Prefer simple solutions over clever ones.

106
README.md Normal file
View file

@ -0,0 +1,106 @@
# Atlas Routing
A high-performance, modular PHP routing engine designed for professional-grade applications. It prioritizes developer experience, architectural purity, and interoperability through PSR-7 support.
## Features
- **Fluent API**: Expressive and chainable route definitions.
- **Dynamic Matching**: Support for `{{parameters}}` and `{{optional?}}` segments.
- **Parameter Validation**: Strict validation rules (numeric, alpha, regex, etc.).
- **Route Groups**: Recursive grouping with prefix and middleware inheritance.
- **Modular Routing**: Automatic route discovery from modules.
- **Reverse Routing**: Safe URL generation with parameter validation.
- **PSR-7 Support**: Built on standard HTTP message interfaces.
- **Advanced Capabilities**: Subdomain constraints, i18n support, and redirects.
- **Developer Tooling**: Programmatic Inspector API and CLI tools.
- **Performance**: Optimized matching engine with route caching support.
## Installation
```bash
composer require getphred/atlas
```
## Basic Usage
```php
use Atlas\Router\Router;
use Atlas\Config\Config;
use GuzzleHttp\Psr7\ServerRequest;
// 1. Setup Configuration
$config = new Config([
'modules_path' => __DIR__ . '/src/Modules',
]);
// 2. Initialize Router
$router = new Router($config);
// 3. Define Routes
$router->get('/users', function() {
return 'User List';
})->name('users.index');
$router->get('/users/{{id}}', function($id) {
return "User $id";
})->name('users.show')->valid('id', 'numeric');
// 4. Match Request
$request = ServerRequest::fromGlobals();
$route = $router->match($request);
if ($route) {
$handler = $route->getHandler();
// Execute handler...
} else {
// 404 Not Found
}
```
## Route Groups
```php
$router->group(['prefix' => '/api', 'middleware' => ['auth']])->group(function($group) {
$group->get('/profile', 'ProfileHandler');
$group->get('/settings', 'SettingsHandler');
});
```
You can also save a route group to a variable for more flexible route definitions:
```php
$api = $router->group(['prefix' => '/api']);
$api->get('/users', 'UserIndexHandler');
$api->post('/users', 'UserCreateHandler');
```
## Performance & Caching
For production environments, you can cache the route collection:
```php
if ($cache->has('routes')) {
$routes = unserialize($cache->get('routes'));
$router->setRoutes($routes);
} else {
// Define your routes...
$cache->set('routes', serialize($router->getRoutes()));
}
```
## CLI Tools
Atlas comes with a CLI tool to help you debug your routes:
```bash
# List all routes
./atlas route:list
# Test a specific request
./atlas route:test GET /users/5
```
## License
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.

162
atlas Normal file
View file

@ -0,0 +1,162 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
use Atlas\Router\Router;
use Atlas\Config\Config;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
$command = $argv[1] ?? 'help';
$config = new Config([
'modules_path' => __DIR__ . '/src/Modules',
'routes_file' => 'routes.php'
]);
$router = new Router($config);
// Load routes from a central routes file if it exists, or provide a way to load them
// For this CLI tool, we might need a way to bootstrap the application's router.
// Usually, this would be part of a framework. For Atlas as a library, we provide
// the tool that can be integrated.
// In a real scenario, the user would point this tool to their router bootstrap file.
// For demonstration, let's assume we have a `bootstrap/router.php` that returns the Router.
$bootstrapFile = getcwd() . '/bootstrap/router.php';
if (file_exists($bootstrapFile)) {
$router = require $bootstrapFile;
}
switch ($command) {
case 'route:list':
$json = in_array('--json', $argv);
$routes = $router->getRoutes();
$output = [];
foreach ($routes as $route) {
$output[] = $route->toArray();
}
if ($json) {
echo json_encode($output, JSON_PRETTY_PRINT) . PHP_EOL;
} else {
printf("%-10s | %-30s | %-20s | %-30s\n", "Method", "Path", "Name", "Handler");
echo str_repeat("-", 100) . PHP_EOL;
foreach ($output as $r) {
printf("%-10s | %-30s | %-20s | %-30s\n",
$r['method'],
$r['path'],
$r['name'] ?? '',
is_string($r['handler']) ? $r['handler'] : 'Closure'
);
}
}
break;
case 'route:test':
$method = $argv[2] ?? 'GET';
$path = $argv[3] ?? '/';
$host = 'localhost'; // Default
// PSR-7 mock request
$uri = new class($path, $host) implements UriInterface {
public function __construct(private $path, private $host) {}
public function getScheme(): string { return 'http'; }
public function getAuthority(): string { return $this->host; }
public function getUserInfo(): string { return ''; }
public function getHost(): string { return $this->host; }
public function getPort(): ?int { return null; }
public function getPath(): string { return $this->path; }
public function getQuery(): string { return ''; }
public function getFragment(): string { return ''; }
public function withScheme($scheme): UriInterface { return $this; }
public function withUserInfo($user, $password = null): UriInterface { return $this; }
public function withHost($host): UriInterface { return $this; }
public function withPort($port): UriInterface { return $this; }
public function withPath($path): UriInterface { return $this; }
public function withQuery($query): UriInterface { return $this; }
public function withFragment($fragment): UriInterface { return $this; }
public function __toString(): string { return "http://{$this->host}{$this->path}"; }
};
$request = new class($method, $uri) implements ServerRequestInterface {
public function __construct(private $method, private $uri) {}
public function getProtocolVersion(): string { return '1.1'; }
public function withProtocolVersion($version): ServerRequestInterface { return $this; }
public function getHeaders(): array { return []; }
public function hasHeader($name): bool { return false; }
public function getHeader($name): array { return []; }
public function getHeaderLine($name): string { return ''; }
public function withHeader($name, $value): ServerRequestInterface { return $this; }
public function withAddedHeader($name, $value): ServerRequestInterface { return $this; }
public function withoutHeader($name): ServerRequestInterface { return $this; }
public function getBody(): \Psr\Http\Message\StreamInterface { return $this->createMockStream(); }
public function withBody(\Psr\Http\Message\StreamInterface $body): ServerRequestInterface { return $this; }
public function getRequestTarget(): string { return $this->uri->getPath(); }
public function withRequestTarget($requestTarget): ServerRequestInterface { return $this; }
public function getMethod(): string { return $this->method; }
public function withMethod($method): ServerRequestInterface { return $this; }
public function getUri(): UriInterface { return $this->uri; }
public function withUri(UriInterface $uri, $preserveHost = false): ServerRequestInterface { return $this; }
public function getServerParams(): array { return []; }
public function getCookieParams(): array { return []; }
public function withCookieParams(array $cookies): ServerRequestInterface { return $this; }
public function getQueryParams(): array { return []; }
public function withQueryParams(array $query): ServerRequestInterface { return $this; }
public function getUploadedFiles(): array { return []; }
public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface { return $this; }
public function getParsedBody(): null|array|object { return null; }
public function withParsedBody($data): ServerRequestInterface { return $this; }
public function getAttributes(): array { return []; }
public function getAttribute($name, $default = null): mixed { return $default; }
public function withAttribute($name, $value): ServerRequestInterface { return $this; }
public function withoutAttribute($name): ServerRequestInterface { return $this; }
private function createMockStream() {
return new class implements \Psr\Http\Message\StreamInterface {
public function __toString(): string { return ''; }
public function close(): void {}
public function detach() { return null; }
public function getSize(): ?int { return 0; }
public function tell(): int { return 0; }
public function eof(): bool { return true; }
public function isSeekable(): bool { return false; }
public function seek($offset, $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isWritable(): bool { return false; }
public function write($string): int { return 0; }
public function isReadable(): bool { return true; }
public function read($length): string { return ''; }
public function getContents(): string { return ''; }
public function getMetadata($key = null) { return $key ? null : []; }
};
}
};
$result = $router->inspect($request);
if ($result->isFound()) {
echo "Match Found!" . PHP_EOL;
echo "Route: " . $result->getRoute()->getName() . " [" . $result->getRoute()->getMethod() . " " . $result->getRoute()->getPath() . "]" . PHP_EOL;
echo "Parameters: " . json_encode($result->getParameters()) . PHP_EOL;
exit(0);
} else {
echo "No Match Found." . PHP_EOL;
if (in_array('--verbose', $argv)) {
echo "Diagnostics:" . PHP_EOL;
foreach ($result->getDiagnostics()['attempts'] as $attempt) {
echo " - {$attempt['route']}: {$attempt['status']} (Pattern: {$attempt['pattern']})" . PHP_EOL;
}
}
exit(2);
}
break;
default:
echo "Atlas Routing CLI" . PHP_EOL;
echo "Usage:" . PHP_EOL;
echo " php atlas route:list [--json]" . PHP_EOL;
echo " php atlas route:test <METHOD> <PATH> [--verbose]" . PHP_EOL;
break;
}

View file

@ -16,7 +16,9 @@
"psr/http-message": "^1.0 || ^2.0" "psr/http-message": "^1.0 || ^2.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^10.0" "phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.7"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -25,7 +27,6 @@
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"Atlas\\": "src/",
"Atlas\\Tests\\": "tests/" "Atlas\\Tests\\": "tests/"
} }
}, },

View file

@ -5,22 +5,56 @@ namespace Atlas\Config;
use ArrayAccess; use ArrayAccess;
use IteratorAggregate; use IteratorAggregate;
/**
* Provides configuration management for the Atlas routing engine.
*
* Implements ArrayAccess and IteratorAggregate for flexible configuration access
* and iteration over configuration options.
*
* @implements ArrayAccess<mixed, mixed>
* @implements IteratorAggregate<mixed, mixed>
*/
class Config implements ArrayAccess, IteratorAggregate class Config implements ArrayAccess, IteratorAggregate
{ {
/**
* Constructs a new Config instance with the provided options.
*
* @param array $options Configuration array containing routing settings
*/
public function __construct( public function __construct(
private readonly array $options private array $options
) {} ) {}
/**
* Retrieves a configuration value by key.
*
* @param string $key The configuration key to retrieve
* @param mixed $default Default value if key does not exist
* @return mixed The configuration value or default
*/
public function get(string $key, mixed $default = null): mixed public function get(string $key, mixed $default = null): mixed
{ {
return $this->options[$key] ?? $default; return $this->options[$key] ?? $default;
} }
/**
* Checks if a configuration key exists.
*
* @param string $key The configuration key to check
* @return bool True if the key exists, false otherwise
*/
public function has(string $key): bool public function has(string $key): bool
{ {
return isset($this->options[$key]); return isset($this->options[$key]);
} }
/**
* Retrieves the module path(s) configuration.
*
* Returns a single string as an array or an array of strings.
*
* @return array|string|null Module path(s) or null if not configured
*/
public function getModulesPath(): array|string|null public function getModulesPath(): array|string|null
{ {
$modulesPath = $this->get('modules_path'); $modulesPath = $this->get('modules_path');
@ -32,16 +66,33 @@ class Config implements ArrayAccess, IteratorAggregate
return is_array($modulesPath) ? $modulesPath : [$modulesPath]; return is_array($modulesPath) ? $modulesPath : [$modulesPath];
} }
/**
* Gets the default routes file name.
*
* @return string Default routes file name
*/
public function getRoutesFile(): string public function getRoutesFile(): string
{ {
return $this->get('routes_file', 'routes.php'); return $this->get('routes_file', 'routes.php');
} }
/**
* Retrieves the custom modules glob pattern.
*
* @return string|null Modules glob pattern or null
*/
public function getModulesGlob(): string|null public function getModulesGlob(): string|null
{ {
return $this->get('modules_glob'); return $this->get('modules_glob');
} }
/**
* Gets a normalized list of module paths.
*
* Ensures always returns an array, converting single string paths.
*
* @return array List of module paths
*/
public function getModulesPathList(): array public function getModulesPathList(): array
{ {
$modulesPath = $this->getModulesPath(); $modulesPath = $this->getModulesPath();
@ -53,31 +104,64 @@ class Config implements ArrayAccess, IteratorAggregate
return is_array($modulesPath) ? $modulesPath : [$modulesPath]; return is_array($modulesPath) ? $modulesPath : [$modulesPath];
} }
/**
* Converts the configuration to an array.
*
* @return array Configuration options as array
*/
public function toArray(): array public function toArray(): array
{ {
return $this->options; return $this->options;
} }
/**
* Checks if a configuration key exists (ArrayAccess interface).
*
* @param mixed $offset The configuration key
* @return bool True if offset exists
*/
public function offsetExists(mixed $offset): bool public function offsetExists(mixed $offset): bool
{ {
return isset($this->options[$offset]); return isset($this->options[$offset]);
} }
/**
* Retrieves a configuration value by offset (ArrayAccess interface).
*
* @param mixed $offset The configuration key
* @return mixed Configuration value or null
*/
public function offsetGet(mixed $offset): mixed public function offsetGet(mixed $offset): mixed
{ {
return $this->options[$offset] ?? null; return $this->options[$offset] ?? null;
} }
/**
* Sets a configuration value by offset (ArrayAccess interface).
*
* @param mixed $offset The configuration key
* @param mixed $value The configuration value
*/
public function offsetSet(mixed $offset, mixed $value): void public function offsetSet(mixed $offset, mixed $value): void
{ {
$this->options[$offset] = $value; $this->options[$offset] = $value;
} }
/**
* Unsets a configuration key (ArrayAccess interface).
*
* @param mixed $offset The configuration key to unset
*/
public function offsetUnset(mixed $offset): void public function offsetUnset(mixed $offset): void
{ {
unset($this->options[$offset]); unset($this->options[$offset]);
} }
/**
* Creates an iterator for the configuration options.
*
* @return Traversable Array iterator over configuration
*/
public function getIterator(): \Traversable public function getIterator(): \Traversable
{ {
return new \ArrayIterator($this->options); return new \ArrayIterator($this->options);

View file

@ -2,6 +2,11 @@
namespace Atlas\Exception; namespace Atlas\Exception;
/**
* Exception thrown when a required configuration value is missing.
*
* @extends \RuntimeException
*/
class MissingConfigurationException extends \RuntimeException class MissingConfigurationException extends \RuntimeException
{ {
} }

View file

@ -1,7 +0,0 @@
<?php
namespace Atlas\Exception;
class NotFoundRouteException extends \RuntimeException
{
}

View file

@ -2,6 +2,14 @@
namespace Atlas\Exception; namespace Atlas\Exception;
/**
* Exception thrown when a requested route is not found.
*
* This may occur during matching if no route corresponds to the request,
* or during URL generation if the requested route name is not registered.
*
* @extends \RuntimeException
*/
class RouteNotFoundException extends \RuntimeException class RouteNotFoundException extends \RuntimeException
{ {
} }

View file

@ -2,6 +2,11 @@
namespace Atlas\Exception; namespace Atlas\Exception;
/**
* Exception thrown when route parameter validation fails.
*
* @extends \RuntimeException
*/
class RouteValidationException extends \RuntimeException class RouteValidationException extends \RuntimeException
{ {
} }

View file

@ -0,0 +1,46 @@
<?php
namespace Atlas\Router;
/**
* Represents the result of a route matching operation.
*/
class MatchResult implements \JsonSerializable
{
public function __construct(
private readonly bool $found,
private readonly RouteDefinition|null $route = null,
private readonly array $parameters = [],
private readonly array $diagnostics = []
) {}
public function isFound(): bool
{
return $this->found;
}
public function getRoute(): ?RouteDefinition
{
return $this->route;
}
public function getParameters(): array
{
return $this->parameters;
}
public function getDiagnostics(): array
{
return $this->diagnostics;
}
public function jsonSerialize(): mixed
{
return [
'found' => $this->found,
'route' => $this->route,
'parameters' => $this->parameters,
'diagnostics' => $this->diagnostics
];
}
}

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);
}
}
}
}

45
src/Router/PathHelper.php Normal file
View file

@ -0,0 +1,45 @@
<?php
namespace Atlas\Router;
/**
* Helper trait for path normalization across routing components.
*/
trait PathHelper
{
/**
* Normalizes a URI path by ensuring it has a leading slash and no trailing slash.
*
* @param string $path The path to normalize
* @return string The normalized path
*/
protected function normalizePath(string $path): string
{
$normalized = trim($path, '/');
if (empty($normalized)) {
return '/';
}
return '/' . $normalized;
}
/**
* Joins two path segments ensuring proper slash separation.
*
* @param string $prefix The path prefix
* @param string $path The path suffix
* @return string The joined and normalized path
*/
protected function joinPaths(string $prefix, string $path): string
{
$prefix = rtrim($prefix, '/');
$path = ltrim($path, '/');
if (empty($prefix)) {
return $this->normalizePath($path);
}
return $this->normalizePath($prefix . '/' . $path);
}
}

View file

@ -4,25 +4,55 @@ namespace Atlas\Router;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
/**
* Represents a single route definition with its HTTP method, path, and handler.
*
* Though not currently used for matching, this class provides a base structure
* for routes and may be extended for future matching capabilities.
*
* @final
*/
final class Route final class Route
{ {
/**
* Constructs a new Route instance.
*
* @param string $method HTTP method (GET, POST, etc.)
* @param string $path URI path
* @param mixed $handler Route handler or string reference
*/
public function __construct( public function __construct(
private readonly string $method, private readonly string $method,
private readonly string $path, private readonly string $path,
private readonly string|callable $handler private readonly mixed $handler
) {} ) {}
/**
* Gets the HTTP method of this route.
*
* @return string HTTP method
*/
public function getMethod(): string public function getMethod(): string
{ {
return $this->method; return $this->method;
} }
/**
* Gets the URI path of this route.
*
* @return string URI path
*/
public function getPath(): string public function getPath(): string
{ {
return $this->path; return $this->path;
} }
public function getHandler(): string|callable /**
* Gets the handler for this route.
*
* @return mixed Route handler
*/
public function getHandler(): mixed
{ {
return $this->handler; return $this->handler;
} }

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);
}
}

View file

@ -2,69 +2,22 @@
namespace Atlas\Router; namespace Atlas\Router;
use Psr\Http\Message\UriInterface; /**
* Represents a complete route definition with matching patterns, handlers, and metadata.
final class RouteDefinition */
class RouteDefinition implements \JsonSerializable, \Serializable
{ {
public function __construct( public function serialize(): string
private readonly string $method,
private readonly string $pattern,
private readonly string $path,
private readonly mixed $handler,
private readonly string|null $name = null,
private readonly array $middleware = [],
private readonly array $validation = [],
private readonly array $defaults = [],
private readonly string|null $module = null,
private readonly array $attributes = []
) {}
public function getMethod(): string
{ {
return $this->method; return serialize($this->__serialize());
} }
public function getPath(): string public function unserialize(string $data): void
{ {
return $this->path; $this->__unserialize(unserialize($data));
} }
public function getHandler(): string|callable public function __serialize(): array
{
return $this->handler;
}
public function getName(): ?string
{
return $this->name;
}
public function getMiddleware(): array
{
return $this->middleware;
}
public function getValidation(): array
{
return $this->validation;
}
public function getDefaults(): array
{
return $this->defaults;
}
public function getModule(): ?string
{
return $this->module;
}
public function getAttributes(): array
{
return $this->attributes;
}
public function toArray(): array
{ {
return [ return [
'method' => $this->method, 'method' => $this->method,
@ -76,6 +29,123 @@ final class RouteDefinition
'validation' => $this->validation, 'validation' => $this->validation,
'defaults' => $this->defaults, 'defaults' => $this->defaults,
'module' => $this->module, 'module' => $this->module,
'attributes' => $this->attributes,
];
}
public function __unserialize(array $data): void
{
$this->method = $data['method'];
$this->pattern = $data['pattern'];
$this->path = $data['path'];
$this->handler = $data['handler'];
$this->name = $data['name'];
$this->middleware = $data['middleware'];
$this->validation = $data['validation'];
$this->defaults = $data['defaults'];
$this->module = $data['module'];
$this->attributes = $data['attributes'];
}
/**
* @internal
*/
public function setModule(?string $module): void
{
$this->module = $module;
}
public function __construct(
private string $method,
private string $pattern,
private string $path,
private mixed $handler,
private string|null $name = null,
private array $middleware = [],
private array $validation = [],
private array $defaults = [],
private string|null $module = null,
private array $attributes = []
) {}
public function getMethod(): string { return $this->method; }
public function getPattern(): string { return $this->pattern; }
public function getPath(): string { return $this->path; }
public function getHandler(): mixed { return $this->handler; }
public function getName(): ?string { return $this->name; }
public function name(string $name): self
{
$this->name = $name;
return $this;
}
public function getMiddleware(): array { return $this->middleware; }
public function middleware(string|array $middleware): self
{
if (is_string($middleware)) {
$this->middleware[] = $middleware;
} else {
$this->middleware = array_merge($this->middleware, $middleware);
}
return $this;
}
public function getValidation(): array { return $this->validation; }
public function valid(array|string $param, array|string $rules = []): self
{
if (is_array($param)) {
foreach ($param as $p => $r) {
$this->valid($p, $r);
}
} else {
$this->validation[$param] = is_string($rules) ? [$rules] : $rules;
}
return $this;
}
public function getDefaults(): array { return $this->defaults; }
public function default(string $param, mixed $value): self
{
$this->defaults[$param] = $value;
return $this;
}
public function getModule(): ?string { return $this->module; }
public function getAttributes(): array { return $this->attributes; }
public function attr(string $key, mixed $value): self
{
$this->attributes[$key] = $value;
return $this;
}
public function meta(array $data): self
{
$this->attributes = array_merge($this->attributes, $data);
return $this;
}
public function jsonSerialize(): mixed
{
return $this->toArray();
}
public function toArray(): array
{
return [
'method' => $this->method,
'pattern' => $this->pattern,
'path' => $this->path,
'handler' => is_callable($this->handler) ? 'Closure' : $this->handler,
'name' => $this->name,
'middleware' => $this->middleware,
'validation' => $this->validation,
'defaults' => $this->defaults,
'module' => $this->module,
'attributes' => $this->attributes 'attributes' => $this->attributes
]; ];
} }

View file

@ -2,13 +2,33 @@
namespace Atlas\Router; namespace Atlas\Router;
/**
* Manages groupings of routes for prefix and middleware organization.
*
* @implements \IteratorAggregate<array-key, mixed>
*/
class RouteGroup class RouteGroup
{ {
use PathHelper;
/**
* Constructs a new RouteGroup instance.
*
* @param array $options Group options including 'prefix' and optional middleware
* @param Router|null $router Optional parent router instance
*/
public function __construct( public function __construct(
private array $options = [], private array $options = [],
private readonly Router $router = null private readonly Router|null $router = null
) {} ) {}
/**
* Creates a new route group with options and router.
*
* @param array $options Group options including 'prefix' and optional middleware
* @param Router $router Parent router instance
* @return self New instance configured with router
*/
public static function create(array $options, Router $router): self public static function create(array $options, Router $router): self
{ {
$self = new self($options); $self = new self($options);
@ -16,55 +36,254 @@ class RouteGroup
return $self; return $self;
} }
public function get(string $path, string|callable $handler, string|null $name = null): self public function get(string $path, mixed $handler, string|null $name = null): RouteDefinition
{ {
$fullPath = $this->buildFullPath($path); $fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->get($fullPath, $handler, $name) : $this; $middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('GET', $fullPath, $handler, $name, $middleware, $validation, $defaults);
} }
public function post(string $path, string|callable $handler, string|null $name = null): self return new RouteDefinition('GET', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
public function post(string $path, mixed $handler, string|null $name = null): RouteDefinition
{ {
$fullPath = $this->buildFullPath($path); $fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->post($fullPath, $handler, $name) : $this; $middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('POST', $fullPath, $handler, $name, $middleware, $validation, $defaults);
} }
public function put(string $path, string|callable $handler, string|null $name = null): self return new RouteDefinition('POST', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
public function put(string $path, mixed $handler, string|null $name = null): RouteDefinition
{ {
$fullPath = $this->buildFullPath($path); $fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->put($fullPath, $handler, $name) : $this; $middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('PUT', $fullPath, $handler, $name, $middleware, $validation, $defaults);
} }
public function patch(string $path, string|callable $handler, string|null $name = null): self return new RouteDefinition('PUT', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
public function patch(string $path, mixed $handler, string|null $name = null): RouteDefinition
{ {
$fullPath = $this->buildFullPath($path); $fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->patch($fullPath, $handler, $name) : $this; $middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('PATCH', $fullPath, $handler, $name, $middleware, $validation, $defaults);
} }
public function delete(string $path, string|callable $handler, string|null $name = null): self return new RouteDefinition('PATCH', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
public function delete(string $path, mixed $handler, string|null $name = null): RouteDefinition
{ {
$fullPath = $this->buildFullPath($path); $fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->delete($fullPath, $handler, $name) : $this; $middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('DELETE', $fullPath, $handler, $name, $middleware, $validation, $defaults);
} }
return new RouteDefinition('DELETE', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
public function redirect(string $path, string $destination, int $status = 302): RouteDefinition
{
$fullPath = $this->buildFullPath($path);
$middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('REDIRECT', $fullPath, $destination, null, $middleware, $validation, $defaults)->attr('status', $status);
}
return (new RouteDefinition('REDIRECT', $fullPath, $fullPath, $destination, null, $middleware, $validation, $defaults))->attr('status', $status);
}
public function fallback(mixed $handler): self
{
$this->options['fallback'] = $handler;
$prefix = $this->options['prefix'] ?? '/';
$middleware = $this->options['middleware'] ?? [];
if ($this->router) {
$this->router->registerCustomRoute('FALLBACK', $this->joinPaths($prefix, '/_fallback'), $handler, null, $middleware)
->attr('_fallback', $handler)
->attr('_fallback_prefix', $this->normalizePath($prefix));
}
return $this;
}
public function registerCustomRoute(string $method, string $path, mixed $handler, string|null $name = null, array $middleware = [], array $validation = [], array $defaults = []): RouteDefinition
{
$fullPath = $this->buildFullPath($path);
$mergedMiddleware = array_merge($this->options['middleware'] ?? [], $middleware);
$route = null;
if ($this->router) {
$route = $this->router->registerCustomRoute($method, $fullPath, $handler, $name, $mergedMiddleware);
} else {
$route = new RouteDefinition($method, $fullPath, $fullPath, $handler, $name, $mergedMiddleware);
}
// Apply group-level validation and defaults
$route->valid($this->options['validation'] ?? []);
foreach ($this->options['defaults'] ?? [] as $p => $v) {
$route->default($p, $v);
}
// Apply route-level validation and defaults
$route->valid($validation);
foreach ($defaults as $p => $v) {
$route->default($p, $v);
}
return $route;
}
/**
* Builds the full path with group prefix.
*
* @param string $path Route path without prefix
* @return string Complete path with prefix
*/
private function buildFullPath(string $path): string private function buildFullPath(string $path): string
{
return $this->joinPaths($this->options['prefix'] ?? '', $path);
}
public function group(array $options): RouteGroup
{ {
$prefix = $this->options['prefix'] ?? ''; $prefix = $this->options['prefix'] ?? '';
$newPrefix = $this->joinPaths($prefix, $options['prefix'] ?? '');
if (empty($prefix)) { $middleware = $this->options['middleware'] ?? [];
return $path; $newMiddleware = array_merge($middleware, $options['middleware'] ?? []);
$validation = $this->options['validation'] ?? [];
$newValidation = array_merge($validation, $options['validation'] ?? []);
$defaults = $this->options['defaults'] ?? [];
$newDefaults = array_merge($defaults, $options['defaults'] ?? []);
$mergedOptions = array_merge($this->options, $options);
$mergedOptions['prefix'] = $newPrefix;
$mergedOptions['middleware'] = $newMiddleware;
$mergedOptions['validation'] = $newValidation;
$mergedOptions['defaults'] = $newDefaults;
return new RouteGroup($mergedOptions, $this->router);
} }
return rtrim($prefix, '/') . '/' . ltrim($path, '/'); /**
* Sets validation rules for parameters at the group level.
*
* @param array|string $param Parameter name or array of rules
* @param array|string $rules Rules if first param is string
* @return self
*/
public function valid(array|string $param, array|string $rules = []): self
{
if (!isset($this->options['validation'])) {
$this->options['validation'] = [];
} }
if (is_array($param)) {
foreach ($param as $p => $r) {
$this->valid($p, $r);
}
} else {
$this->options['validation'][$param] = is_string($rules) ? [$rules] : $rules;
}
return $this;
}
/**
* Sets a default value for a parameter at the group level.
*
* @param string $param
* @param mixed $value
* @return self
*/
public function default(string $param, mixed $value): self
{
if (!isset($this->options['defaults'])) {
$this->options['defaults'] = [];
}
$this->options['defaults'][$param] = $value;
return $this;
}
/**
* Sets an option value.
*
* @param string $key Option key
* @param mixed $value Option value
* @return self Fluent interface
*/
public function setOption(string $key, mixed $value): self public function setOption(string $key, mixed $value): self
{ {
$this->options[$key] = $value; $this->options[$key] = $value;
return $this; return $this;
} }
/**
* Gets all group options.
*
* @return array Group options configuration
*/
public function module(string|array $identifier, string|null $prefix = null): self
{
if ($this->router) {
// We need to pass the group context to the module loading.
// But ModuleLoader uses the router directly.
// If we use $this->router->module(), it won't have the group prefix/middleware.
// We should probably allow ModuleLoader to take a "target" which can be a Router or RouteGroup.
// For now, let's just use the router but we have a problem: inheritance.
// A better way is to make RouteGroup have a way to load modules.
$moduleLoader = new ModuleLoader($this->router->getConfig(), $this);
$moduleLoader->load($identifier, $prefix);
}
return $this;
}
public function getOptions(): array public function getOptions(): array
{ {
return $this->options; return $this->options;
} }
/**
* @internal
*/
public function getConfig(): Config
{
return $this->router->getConfig();
}
} }

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

@ -3,151 +3,219 @@
namespace Atlas\Router; namespace Atlas\Router;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Atlas\Config\Config;
use Atlas\Exception\RouteNotFoundException;
use Atlas\Exception\MissingConfigurationException;
class Router class Router
{ {
private array $routes = []; use PathHelper;
private RouteCollection $routes;
private readonly RouteMatcher $matcher;
private readonly ModuleLoader $loader;
protected mixed $fallbackHandler = null; protected mixed $fallbackHandler = null;
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): self public function get(string $path, string|callable $handler, string|null $name = null): RouteDefinition
{ {
$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): self public function post(string $path, string|callable $handler, string|null $name = null): RouteDefinition
{ {
$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): self public function put(string $path, string|callable $handler, string|null $name = null): RouteDefinition
{ {
$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): self public function patch(string $path, string|callable $handler, string|null $name = null): RouteDefinition
{ {
$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): self public function delete(string $path, string|callable $handler, string|null $name = null): RouteDefinition
{ {
$this->registerRoute('DELETE', $path, $handler, $name); return $this->registerRoute('DELETE', $path, $handler, $name);
return $this;
} }
private function registerRoute(string $method, string $path, mixed $handler, string|null $name = null): void public function registerCustomRoute(string $method, string $path, mixed $handler, string|null $name = null, array $middleware = [], array $validation = [], array $defaults = []): RouteDefinition
{ {
return $this->registerRoute($method, $path, $handler, $name, $middleware, $validation, $defaults);
}
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( $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);
}
private function normalizePath(string $path): string return $routeDefinition;
{
$normalized = trim($path, '/');
if (empty($normalized)) {
return '/';
}
return '/' . $normalized;
} }
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
}
public function getRoutes(): iterable
{ {
return $this->routes ?? []; return $this->routes;
}
public function setRoutes(RouteCollection $routes): self
{
$this->routes = $routes;
return $this;
}
/**
* @internal
*/
public function getConfig(): Config
{
return $this->config;
} }
public function match(ServerRequestInterface $request): RouteDefinition|null public function match(ServerRequestInterface $request): RouteDefinition|null
{
return $this->matcher->match($request, $this->routes);
}
public function inspect(ServerRequestInterface $request): MatchResult
{ {
$method = strtoupper($request->getMethod()); $method = strtoupper($request->getMethod());
$path = $this->normalizePath($request->getUri()->getPath()); $path = $this->normalizePath($request->getUri()->getPath());
$host = $request->getUri()->getHost();
foreach ($this->getRoutes() as $routeDefinition) { $diagnostics = [
if (strtoupper($routeDefinition->getMethod()) === $method && $routeDefinition->getPath() === $path) { 'method' => $method,
return $routeDefinition; '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);
} }
} }
return null; $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 public function matchOrFail(ServerRequestInterface $request): RouteDefinition
{ {
$method = strtoupper($request->getMethod()); $route = $this->match($request);
$path = $this->normalizePath($request->getUri()->getPath());
foreach ($this->getRoutes() as $routeDefinition) { if ($route === null) {
if (strtoupper($routeDefinition->getMethod()) === $method && $routeDefinition->getPath() === $path) { throw new RouteNotFoundException('No route matched the request');
return $routeDefinition;
}
} }
throw new NotFoundRouteException('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 public function fallback(mixed $handler): self
{ {
$this->fallbackHandler = $handler; $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; 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): string private function replaceParameters(string $path, array $parameters, array $defaults = []): 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);
} }
public function group(array $options): RouteGroup public function group(array $options): RouteGroup
@ -155,47 +223,9 @@ class Router
return new RouteGroup($options, $this); return new RouteGroup($options, $this);
} }
public function module(string|array $identifier): self public function module(string|array $identifier, string|null $prefix = null): 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;
} }
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
);
}
}
} }

View file

@ -0,0 +1,110 @@
<?php
namespace Atlas\Tests\Integration;
use Atlas\Router\Router;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
class ModuleDiscoveryTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/atlas_module_test_' . uniqid();
mkdir($this->tempDir);
}
protected function tearDown(): void
{
$this->removeDirectory($this->tempDir);
}
private function removeDirectory(string $path): void
{
if (!is_dir($path)) return;
$files = array_diff(scandir($path), ['.', '..']);
foreach ($files as $file) {
(is_dir("$path/$file")) ? $this->removeDirectory("$path/$file") : unlink("$path/$file");
}
rmdir($path);
}
private function createModule(string $name, string $content): void
{
mkdir($this->tempDir . '/' . $name, 0777, true);
file_put_contents($this->tempDir . '/' . $name . '/routes.php', $content);
}
public function testModuleDiscoveryWithPrefix(): void
{
$routesContent = '<?php return [["method" => "GET", "path" => "/index", "handler" => "UserHandler"]];';
$this->createModule('User', $routesContent);
$config = new Config(['modules_path' => $this->tempDir]);
$router = new Router($config);
$router->module('User', '/user');
$routes = iterator_to_array($router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/user/index', $routes[0]->getPath());
$this->assertSame('User', $routes[0]->getModule());
}
public function testModuleInheritanceOfMiddlewareAndValidation(): void
{
$routesContent = '<?php return [
[
"method" => "POST",
"path" => "/save",
"handler" => "SaveHandler",
"middleware" => ["module_mid"],
"validation" => ["id" => "numeric"]
]
];';
$this->createModule('Admin', $routesContent);
$config = new Config(['modules_path' => $this->tempDir]);
$router = new Router($config);
// Group at router level
$router->group(['middleware' => ['global_mid']])->module('Admin', '/admin');
$routes = iterator_to_array($router->getRoutes());
$this->assertCount(1, $routes);
$route = $routes[0];
$this->assertSame('/admin/save', $route->getPath());
$this->assertContains('global_mid', $route->getMiddleware());
$this->assertContains('module_mid', $route->getMiddleware());
$this->assertArrayHasKey('id', $route->getValidation());
$this->assertSame('numeric', $route->getValidation()['id'][0]);
$this->assertSame('Admin', $route->getModule());
}
public function testOverlappingModuleRoutes(): void
{
// Conflict resolution: first registered wins or both stay?
// Typically, router matches sequentially, so first registered wins.
$userRoutes = '<?php return [["method" => "GET", "path" => "/profile", "handler" => "UserHandler"]];';
$this->createModule('User', $userRoutes);
$adminRoutes = '<?php return [["method" => "GET", "path" => "/profile", "handler" => "AdminHandler"]];';
$this->createModule('Admin', $adminRoutes);
$config = new Config(['modules_path' => $this->tempDir]);
$router = new Router($config);
$router->module('User', '/common');
$router->module('Admin', '/common');
$routes = iterator_to_array($router->getRoutes());
$this->assertCount(2, $routes);
$this->assertSame('/common/profile', $routes[0]->getPath());
$this->assertSame('UserHandler', $routes[0]->getHandler());
$this->assertSame('/common/profile', $routes[1]->getPath());
$this->assertSame('AdminHandler', $routes[1]->getHandler());
}
}

View file

@ -0,0 +1,112 @@
<?php
namespace Atlas\Tests\Integration;
use Atlas\Router\Router;
use Atlas\Config\Config;
use Atlas\Exception\MissingConfigurationException;
use PHPUnit\Framework\TestCase;
class ModuleLoadingTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/atlas_test_' . uniqid();
mkdir($this->tempDir);
mkdir($this->tempDir . '/User');
}
protected function tearDown(): void
{
$this->removeDirectory($this->tempDir);
}
private function removeDirectory(string $path): void
{
$files = array_diff(scandir($path), ['.', '..']);
foreach ($files as $file) {
(is_dir("$path/$file")) ? $this->removeDirectory("$path/$file") : unlink("$path/$file");
}
rmdir($path);
}
public function testModuleLoadsRoutesFromFilesystem(): void
{
$routesContent = '<?php return [["method" => "GET", "path" => "/profile", "handler" => "UserHandler", "name" => "user_profile"]];';
file_put_contents($this->tempDir . '/User/routes.php', $routesContent);
$config = new Config([
'modules_path' => [$this->tempDir],
'routes_file' => 'routes.php'
]);
$router = new Router($config);
$router->module('User');
$routes = iterator_to_array($router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/profile', $routes[0]->getPath());
$this->assertSame('user_profile', $routes[0]->getName());
}
public function testModuleThrowsExceptionWhenModulesPathIsMissing(): void
{
$config = new Config([]);
$router = new Router($config);
$this->expectException(MissingConfigurationException::class);
$router->module('User');
}
public function testModuleSkipsWhenRoutesFileDoesNotExist(): void
{
$config = new Config([
'modules_path' => [$this->tempDir]
]);
$router = new Router($config);
$router->module('NonExistent');
$this->assertCount(0, $router->getRoutes());
}
public function testModuleWithMultipleSearchPaths(): void
{
$secondPath = sys_get_temp_dir() . '/atlas_test_2_' . uniqid();
mkdir($secondPath);
mkdir($secondPath . '/Shared');
$routesContent = '<?php return [["method" => "GET", "path" => "/shared", "handler" => "SharedHandler"]];';
file_put_contents($secondPath . '/Shared/routes.php', $routesContent);
$config = new Config([
'modules_path' => [$this->tempDir, $secondPath]
]);
$router = new Router($config);
$router->module('Shared');
$routes = iterator_to_array($router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/shared', $routes[0]->getPath());
$this->removeDirectory($secondPath);
}
public function testModuleLoadsMultipleRoutes(): void
{
$routesContent = '<?php return [
["method" => "GET", "path" => "/u1", "handler" => "h1"],
["method" => "POST", "path" => "/u2", "handler" => "h2"]
];';
file_put_contents($this->tempDir . '/User/routes.php', $routesContent);
$config = new Config(['modules_path' => $this->tempDir]);
$router = new Router($config);
$router->module('User');
$this->assertCount(2, $router->getRoutes());
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace Atlas\Tests\Unit;
use PHPUnit\Framework\TestCase;
class CliToolTest extends TestCase
{
private string $atlasPath;
private string $bootstrapDir;
private string $bootstrapFile;
protected function setUp(): void
{
$this->atlasPath = realpath(__DIR__ . '/../../atlas');
$this->bootstrapDir = __DIR__ . '/../../bootstrap';
$this->bootstrapFile = $this->bootstrapDir . '/router.php';
if (!is_dir($this->bootstrapDir)) {
mkdir($this->bootstrapDir);
}
// Create a bootstrap file for testing
$content = <<<'PHP'
<?php
use Atlas\Router\Router;
use Atlas\Config\Config;
$config = new Config(['modules_path' => __DIR__ . '/../src/Modules']);
$router = new Router($config);
$router->get('/hello', 'handler', 'hello_route');
$router->get('/users/{{id}}', 'handler', 'user_detail')->valid('id', 'numeric');
return $router;
PHP;
file_put_contents($this->bootstrapFile, $content);
}
protected function tearDown(): void
{
if (file_exists($this->bootstrapFile)) {
unlink($this->bootstrapFile);
}
if (is_dir($this->bootstrapDir)) {
rmdir($this->bootstrapDir);
}
}
public function testRouteList(): void
{
exec("php {$this->atlasPath} route:list", $output, $returnCode);
$this->assertSame(0, $returnCode);
$this->assertStringContainsString('hello_route', implode("\n", $output));
$this->assertStringContainsString('user_detail', implode("\n", $output));
}
public function testRouteListJson(): void
{
exec("php {$this->atlasPath} route:list --json", $output, $returnCode);
$this->assertSame(0, $returnCode);
$json = implode("\n", $output);
$data = json_decode($json, true);
$this->assertIsArray($data);
$this->assertCount(2, $data);
$this->assertSame('hello_route', $data[0]['name']);
}
public function testRouteTestSuccess(): void
{
exec("php {$this->atlasPath} route:test GET /hello", $output, $returnCode);
$this->assertSame(0, $returnCode);
$this->assertStringContainsString('Match Found!', implode("\n", $output));
$this->assertStringContainsString('hello_route', implode("\n", $output));
}
public function testRouteTestFailure(): void
{
exec("php {$this->atlasPath} route:test GET /nonexistent", $output, $returnCode);
$this->assertSame(2, $returnCode);
$this->assertStringContainsString('No Match Found.', implode("\n", $output));
}
public function testRouteTestVerbose(): void
{
exec("php {$this->atlasPath} route:test GET /users/abc --verbose", $output, $returnCode);
$this->assertSame(2, $returnCode);
$this->assertStringContainsString('No Match Found.', implode("\n", $output));
$this->assertStringContainsString('Diagnostics:', implode("\n", $output));
$this->assertStringContainsString('user_detail: mismatch', implode("\n", $output));
}
}

103
tests/Unit/ConfigTest.php Normal file
View file

@ -0,0 +1,103 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
class ConfigTest extends TestCase
{
public function testGetReturnsValueOrPlaceholder(): void
{
$config = new Config(['key' => 'value']);
$this->assertSame('value', $config->get('key'));
$this->assertSame('default', $config->get('non_existent', 'default'));
$this->assertNull($config->get('non_existent'));
}
public function testHasChecksExistence(): void
{
$config = new Config(['key' => 'value']);
$this->assertTrue($config->has('key'));
$this->assertFalse($config->has('non_existent'));
}
public function testGetModulesPathNormalizesToArray(): void
{
$config1 = new Config(['modules_path' => '/single/path']);
$this->assertSame(['/single/path'], $config1->getModulesPath());
$config2 = new Config(['modules_path' => ['/path/1', '/path/2']]);
$this->assertSame(['/path/1', '/path/2'], $config2->getModulesPath());
$config3 = new Config([]);
$this->assertNull($config3->getModulesPath());
}
public function testGetModulesPathListAlwaysReturnsArray(): void
{
$config1 = new Config(['modules_path' => '/single/path']);
$this->assertSame(['/single/path'], $config1->getModulesPathList());
$config2 = new Config(['modules_path' => ['/path/1', '/path/2']]);
$this->assertSame(['/path/1', '/path/2'], $config2->getModulesPathList());
$config3 = new Config([]);
$this->assertSame([], $config3->getModulesPathList());
}
public function testGetRoutesFileWithDefault(): void
{
$config1 = new Config(['routes_file' => 'custom.php']);
$this->assertSame('custom.php', $config1->getRoutesFile());
$config2 = new Config([]);
$this->assertSame('routes.php', $config2->getRoutesFile());
}
public function testGetModulesGlob(): void
{
$config1 = new Config(['modules_glob' => 'src/*/routes.php']);
$this->assertSame('src/*/routes.php', $config1->getModulesGlob());
$config2 = new Config([]);
$this->assertNull($config2->getModulesGlob());
}
public function testToArray(): void
{
$options = ['a' => 1, 'b' => 2];
$config = new Config($options);
$this->assertSame($options, $config->toArray());
}
public function testArrayAccess(): void
{
$config = new Config(['key' => 'value']);
$this->assertTrue(isset($config['key']));
$this->assertSame('value', $config['key']);
$config['new'] = 'val';
$this->assertSame('val', $config['new']);
unset($config['key']);
$this->assertFalse(isset($config['key']));
}
public function testIteratorAggregate(): void
{
$options = ['a' => 1, 'b' => 2];
$config = new Config($options);
$result = [];
foreach ($config as $key => $value) {
$result[$key] = $value;
}
$this->assertSame($options, $result);
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
class DynamicMatchingTest extends TestCase
{
private Router $router;
protected function setUp(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$this->router = new Router($config);
}
private function createRequest(string $method, string $path): ServerRequestInterface
{
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn($path);
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn($method);
$request->method('getUri')->willReturn($uri);
return $request;
}
public function testMatchesDynamicParameters(): void
{
$this->router->get('/users/{{user_id}}', 'handler');
$request = $this->createRequest('GET', '/users/42');
$match = $this->router->match($request);
$this->assertNotNull($match);
$this->assertSame('42', $match->getAttributes()['user_id']);
}
public function testMatchesMultipleParameters(): void
{
$this->router->get('/posts/{{post_id}}/comments/{{comment_id}}', 'handler');
$request = $this->createRequest('GET', '/posts/10/comments/5');
$match = $this->router->match($request);
$this->assertNotNull($match);
$this->assertSame('10', $match->getAttributes()['post_id']);
$this->assertSame('5', $match->getAttributes()['comment_id']);
}
public function testMatchesOptionalParameters(): void
{
$this->router->get('/blog/{{slug?}}', 'handler');
// With parameter
$request1 = $this->createRequest('GET', '/blog/my-post');
$match1 = $this->router->match($request1);
$this->assertNotNull($match1);
$this->assertSame('my-post', $match1->getAttributes()['slug']);
// Without parameter
$request2 = $this->createRequest('GET', '/blog');
$match2 = $this->router->match($request2);
$this->assertNotNull($match2);
$this->assertArrayNotHasKey('slug', $match2->getAttributes());
}
public function testFluentConfiguration(): void
{
$route = $this->router->get('/test', 'handler')
->name('test_route')
->middleware('auth')
->attr('key', 'value');
$this->assertSame('test_route', $route->getName());
$this->assertContains('auth', $route->getMiddleware());
$this->assertSame('value', $route->getAttributes()['key']);
}
public function testNestedGroupsInheritPrefixAndMiddleware(): void
{
$group = $this->router->group(['prefix' => '/api', 'middleware' => ['api_middleware']]);
$nested = $group->group(['prefix' => '/v1', 'middleware' => ['v1_middleware']]);
$route = $nested->get('/users', 'handler');
$this->assertSame('/api/v1/users', $route->getPath());
$this->assertContains('api_middleware', $route->getMiddleware());
$this->assertContains('v1_middleware', $route->getMiddleware());
}
public function testDefaultValuesAndValidation(): void
{
$this->router->get('/blog/{{page}}', 'handler')
->default('page', 1)
->valid('page', ['int']);
// With value
$request1 = $this->createRequest('GET', '/blog/5');
$match1 = $this->router->match($request1);
$this->assertNotNull($match1);
$this->assertSame('5', $match1->getAttributes()['page']);
// With default
$request2 = $this->createRequest('GET', '/blog');
$match2 = $this->router->match($request2);
$this->assertNotNull($match2);
$this->assertSame(1, $match2->getAttributes()['page']);
// Invalid value (non-int)
$request3 = $this->createRequest('GET', '/blog/abc');
$match3 = $this->router->match($request3);
$this->assertNull($match3);
}
}

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

@ -0,0 +1,95 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Config\Config;
use Atlas\Router\MatchResult;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
class InspectorApiTest extends TestCase
{
private Router $router;
protected function setUp(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$this->router = new Router($config);
}
public function testInspectFindsMatch(): void
{
$this->router->get('/users/{{id}}', 'handler', 'user_detail');
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/users/42');
$uri->method('getHost')->willReturn('localhost');
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn('GET');
$request->method('getUri')->willReturn($uri);
$result = $this->router->inspect($request);
$this->assertInstanceOf(MatchResult::class, $result);
$this->assertTrue($result->isFound());
$this->assertSame('user_detail', $result->getRoute()->getName());
$this->assertSame(['id' => '42'], $result->getParameters());
}
public function testInspectReturnsDiagnosticsOnMismatch(): void
{
$this->router->get('/users/{{id}}', 'handler', 'user_detail')->valid('id', 'numeric');
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/users/abc');
$uri->method('getHost')->willReturn('localhost');
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn('GET');
$request->method('getUri')->willReturn($uri);
$result = $this->router->inspect($request);
$this->assertFalse($result->isFound());
$diagnostics = $result->getDiagnostics();
$this->assertArrayHasKey('attempts', $diagnostics);
$this->assertCount(1, $diagnostics['attempts']);
$this->assertSame('user_detail', $diagnostics['attempts'][0]['route']);
$this->assertSame('mismatch', $diagnostics['attempts'][0]['status']);
}
public function testRouteDefinitionIsJsonSerializable(): void
{
$route = $this->router->get('/test', 'handler', 'test_route');
$json = json_encode($route);
$data = json_decode($json, true);
$this->assertSame('GET', $data['method']);
$this->assertSame('/test', $data['path']);
$this->assertSame('test_route', $data['name']);
$this->assertSame('handler', $data['handler']);
}
public function testMatchResultIsJsonSerializable(): void
{
$this->router->get('/test', 'handler', 'test_route');
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/test');
$uri->method('getHost')->willReturn('localhost');
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn('GET');
$request->method('getUri')->willReturn($uri);
$result = $this->router->inspect($request);
$json = json_encode($result);
$data = json_decode($json, true);
$this->assertTrue($data['found']);
$this->assertSame('test_route', $data['route']['name']);
}
}

View file

@ -0,0 +1,162 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Config\Config;
use Atlas\Exception\RouteNotFoundException;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
class Milestone11Test extends TestCase
{
private Router $router;
protected function setUp(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$this->router = new Router($config);
}
public function testRedirectSupport(): void
{
$this->router->redirect('/old', '/new', 301);
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/old');
$uri->method('getHost')->willReturn('localhost');
$uri->method('getScheme')->willReturn('http');
$uri->method('getPort')->willReturn(80);
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn('GET');
$request->method('getUri')->willReturn($uri);
$route = $this->router->match($request);
$this->assertNotNull($route, 'Route should not be null for /old');
$this->assertSame('REDIRECT', $route->getMethod());
$this->assertSame('/new', $route->getHandler());
$this->assertSame(301, $route->getAttributes()['status']);
}
public function testUrlGenerationStrictChecks(): void
{
$this->router->get('/users/{{user_id}}', 'handler', 'user_detail');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Missing required parameter "user_id"');
$this->router->url('user_detail', []);
}
public function testUrlGenerationWithDefaultsAndOptional(): void
{
$this->router->get('/blog/{{slug?}}', 'handler', 'blog_post')->default('slug', 'index');
$this->assertSame('/blog/index', $this->router->url('blog_post', []));
$this->assertSame('/blog/hello', $this->router->url('blog_post', ['slug' => 'hello']));
}
public function testFallbackAtGroupLevel(): void
{
$this->router->fallback('global_fallback');
$group = $this->router->group(['prefix' => '/api']);
$group->fallback('api_fallback');
// Request for /something-else -> global_fallback
$uri1 = $this->createMock(UriInterface::class);
$uri1->method('getPath')->willReturn('/something-else');
$uri1->method('getHost')->willReturn('localhost');
$uri1->method('getScheme')->willReturn('http');
$uri1->method('getPort')->willReturn(80);
$request1 = $this->createMock(ServerRequestInterface::class);
$request1->method('getMethod')->willReturn('GET');
$request1->method('getUri')->willReturn($uri1);
$route1 = $this->router->match($request1);
$this->assertNotNull($route1);
$this->assertSame('global_fallback', $route1->getHandler());
// Request for /api/nonexistent -> api_fallback
$uri2 = $this->createMock(UriInterface::class);
$uri2->method('getPath')->willReturn('/api/nonexistent');
$uri2->method('getHost')->willReturn('localhost');
$uri2->method('getScheme')->willReturn('http');
$uri2->method('getPort')->willReturn(80);
$request2 = $this->createMock(ServerRequestInterface::class);
$request2->method('getMethod')->willReturn('GET');
$request2->method('getUri')->willReturn($uri2);
$route2 = $this->router->match($request2);
$this->assertNotNull($route2);
$this->assertSame('api_fallback', $route2->getHandler());
}
public function testSubdomainConstraints(): void
{
$this->router->get('/test', 'handler')->attr('subdomain', 'api');
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/test');
$uri->method('getHost')->willReturn('api.example.com');
$uri->method('getScheme')->willReturn('http');
$uri->method('getPort')->willReturn(80);
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn('GET');
$request->method('getUri')->willReturn($uri);
$route = $this->router->match($request);
$this->assertNotNull($route);
$uri2 = $this->createMock(UriInterface::class);
$uri2->method('getPath')->willReturn('/test');
$uri2->method('getHost')->willReturn('www.example.com');
$uri2->method('getScheme')->willReturn('http');
$uri2->method('getPort')->willReturn(80);
$request2 = $this->createMock(ServerRequestInterface::class);
$request2->method('getMethod')->willReturn('GET');
$request2->method('getUri')->willReturn($uri2);
$route2 = $this->router->match($request2);
$this->assertNull($route2);
}
public function testI18nSupport(): void
{
$this->router->get('/users', 'UserHandler', 'users')->attr('i18n', [
'fr' => '/utilisateurs',
'es' => '/usuarios'
]);
// Default
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/users');
$uri->method('getHost')->willReturn('localhost');
$uri->method('getScheme')->willReturn('http');
$uri->method('getPort')->willReturn(80);
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn('GET');
$request->method('getUri')->willReturn($uri);
$this->assertNotNull($this->router->match($request));
// French
$uriFr = $this->createMock(UriInterface::class);
$uriFr->method('getPath')->willReturn('/utilisateurs');
$uriFr->method('getHost')->willReturn('localhost');
$uriFr->method('getScheme')->willReturn('http');
$uriFr->method('getPort')->willReturn(80);
$requestFr = $this->createMock(ServerRequestInterface::class);
$requestFr->method('getMethod')->willReturn('GET');
$requestFr->method('getUri')->willReturn($uriFr);
$routeFr = $this->router->match($requestFr);
$this->assertNotNull($routeFr);
$this->assertSame('users', $routeFr->getName());
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
class ParameterValidationTest extends TestCase
{
private Router $router;
protected function setUp(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$this->router = new Router($config);
}
private function createRequest(string $method, string $path): ServerRequestInterface
{
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn($path);
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn($method);
$request->method('getUri')->willReturn($uri);
return $request;
}
public function testNumericValidation(): void
{
$this->router->get('/users/{{id}}', 'handler')->valid('id', 'numeric');
$this->assertNotNull($this->router->match($this->createRequest('GET', '/users/123')));
$this->assertNull($this->router->match($this->createRequest('GET', '/users/abc')));
}
public function testAlphaValidation(): void
{
$this->router->get('/tags/{{tag}}', 'handler')->valid('tag', 'alpha');
$this->assertNotNull($this->router->match($this->createRequest('GET', '/tags/php')));
$this->assertNull($this->router->match($this->createRequest('GET', '/tags/123')));
}
public function testRegexValidation(): void
{
$this->router->get('/date/{{year}}', 'handler')->valid('year', 'regex:[0-9]{4}');
$this->assertNotNull($this->router->match($this->createRequest('GET', '/date/2024')));
$this->assertNull($this->router->match($this->createRequest('GET', '/date/24')));
$this->assertNull($this->router->match($this->createRequest('GET', '/date/abcd')));
}
public function testMultipleValidationRules(): void
{
// Test that alphanumeric works as expected
$this->router->get('/product/{{sku}}', 'handler')->valid('sku', 'alphanumeric');
$this->assertNotNull($this->router->match($this->createRequest('GET', '/product/ABC123')));
$this->assertNull($this->router->match($this->createRequest('GET', '/product/ABC_123')));
}
public function testDefaultValuesMarkParametersAsOptional(): void
{
$this->router->get('/shop/{{category}}', 'handler')->default('category', 'all');
$match1 = $this->router->match($this->createRequest('GET', '/shop/electronics'));
$this->assertNotNull($match1);
$this->assertSame('electronics', $match1->getAttributes()['category']);
$match2 = $this->router->match($this->createRequest('GET', '/shop'));
$this->assertNotNull($match2);
$this->assertSame('all', $match2->getAttributes()['category']);
}
public function testOptionalParameterWithDefault(): void
{
$this->router->get('/archive/{{year?}}', 'handler')->default('year', 2023);
$match1 = $this->router->match($this->createRequest('GET', '/archive/2024'));
$this->assertSame('2024', $match1->getAttributes()['year']);
$match2 = $this->router->match($this->createRequest('GET', '/archive'));
$this->assertSame(2023, $match2->getAttributes()['year']);
}
public function testAlphanumericValidation(): void
{
$this->router->get('/profile/{{username}}', 'handler')->valid('username', 'alphanumeric');
$this->assertNotNull($this->router->match($this->createRequest('GET', '/profile/user123')));
$this->assertNull($this->router->match($this->createRequest('GET', '/profile/user_123')));
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\PathHelper;
use PHPUnit\Framework\TestCase;
class PathHelperTest extends TestCase
{
private $helper;
protected function setUp(): void
{
$this->helper = new class {
use PathHelper;
public function testNormalize(string $path): string
{
return $this->normalizePath($path);
}
public function testJoin(string $prefix, string $path): string
{
return $this->joinPaths($prefix, $path);
}
};
}
public function testNormalizePathEnsuresLeadingSlash(): void
{
$this->assertSame('/test', $this->helper->testNormalize('test'));
$this->assertSame('/test', $this->helper->testNormalize('/test'));
}
public function testNormalizePathRemovesTrailingSlash(): void
{
$this->assertSame('/test', $this->helper->testNormalize('test/'));
$this->assertSame('/test', $this->helper->testNormalize('/test/'));
}
public function testNormalizePathHandlesEmptyOrSlash(): void
{
$this->assertSame('/', $this->helper->testNormalize(''));
$this->assertSame('/', $this->helper->testNormalize('/'));
$this->assertSame('/', $this->helper->testNormalize('///'));
}
public function testJoinPathsCombinesCorrectly(): void
{
$this->assertSame('/api/users', $this->helper->testJoin('/api', 'users'));
$this->assertSame('/api/users', $this->helper->testJoin('/api/', '/users'));
$this->assertSame('/api/users', $this->helper->testJoin('api', 'users'));
}
public function testJoinPathsHandlesRootPrefix(): void
{
$this->assertSame('/users', $this->helper->testJoin('/', 'users'));
$this->assertSame('/users', $this->helper->testJoin('', 'users'));
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Router\RouteCollection;
use Atlas\Router\RouteDefinition;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
class PerformanceOptimizationTest extends TestCase
{
private Router $router;
protected function setUp(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$this->router = new Router($config);
}
public function testRouteCollectionIsSerializable(): void
{
$this->router->get('/users', 'UserHandler', 'user_list');
$this->router->get('/users/{{id}}', 'UserDetailHandler', 'user_detail')->valid('id', 'numeric');
$routes = $this->router->getRoutes();
$serialized = serialize($routes);
/** @var RouteCollection $unserialized */
$unserialized = unserialize($serialized);
$this->assertInstanceOf(RouteCollection::class, $unserialized);
$this->assertCount(2, iterator_to_array($unserialized));
$route = $unserialized->getByName('user_detail');
$this->assertNotNull($route);
$this->assertSame('/users/{{id}}', $route->getPath());
$this->assertSame(['id' => ['numeric']], $route->getValidation());
}
public function testRouterCanLoadCachedRoutes(): void
{
$this->router->get('/old', 'OldHandler');
$newCollection = new RouteCollection();
$newCollection->add(new RouteDefinition('GET', '/new', '/new', 'NewHandler', 'new_route'));
$this->router->setRoutes($newCollection);
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/new', $routes[0]->getPath());
}
public function testMatcherCacheWorks(): void
{
// Internal test to ensure compilePattern doesn't re-run unnecessarily
// Hard to test private cache directly without reflection, but we can verify it doesn't break matching
$this->router->get('/test/{{id}}', 'handler');
$uri = $this->createMock(\Psr\Http\Message\UriInterface::class);
$uri->method('getPath')->willReturn('/test/123');
$uri->method('getScheme')->willReturn('http');
$uri->method('getHost')->willReturn('localhost');
$request = $this->createMock(\Psr\Http\Message\ServerRequestInterface::class);
$request->method('getMethod')->willReturn('GET');
$request->method('getUri')->willReturn($uri);
$match1 = $this->router->match($request);
$this->assertNotNull($match1);
$match2 = $this->router->match($request);
$this->assertNotNull($match2);
$this->assertSame($match1->getHandler(), $match2->getHandler());
}
}

View file

@ -0,0 +1,98 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
class RouteGroupDeepTest extends TestCase
{
private Router $router;
protected function setUp(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$this->router = new Router($config);
}
private function createRequest(string $method, string $path): ServerRequestInterface
{
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn($path);
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn($method);
$request->method('getUri')->willReturn($uri);
return $request;
}
public function testIndefiniteNestingInheritance(): void
{
$this->router->group(['prefix' => '/api', 'middleware' => ['api']])
->group(['prefix' => '/v1', 'middleware' => ['v1']])
->group(['prefix' => '/users', 'middleware' => ['users']])
->get('/list', 'handler', 'user_list');
$routes = iterator_to_array($this->router->getRoutes());
$route = $routes[0];
$this->assertSame('/api/v1/users/list', $route->getPath());
$this->assertContains('api', $route->getMiddleware());
$this->assertContains('v1', $route->getMiddleware());
$this->assertContains('users', $route->getMiddleware());
}
public function testGroupLevelValidation(): void
{
$group = $this->router->group(['prefix' => '/users/{{user_id}}'])
->valid('user_id', 'numeric');
$group->get('/profile', 'profile_handler');
$group->get('/posts', 'posts_handler');
// Valid match
$request1 = $this->createRequest('GET', '/users/123/profile');
$match1 = $this->router->match($request1);
$this->assertNotNull($match1);
$this->assertSame('123', $match1->getAttributes()['user_id']);
// Invalid match (non-numeric)
$request2 = $this->createRequest('GET', '/users/abc/profile');
$match2 = $this->router->match($request2);
$this->assertNull($match2);
}
public function testGroupLevelDefaults(): void
{
$group = $this->router->group(['prefix' => '/blog/{{lang?}}'])
->default('lang', 'en');
$group->get('/recent', 'recent_handler');
// With value
$request1 = $this->createRequest('GET', '/blog/fr/recent');
$match1 = $this->router->match($request1);
$this->assertSame('fr', $match1->getAttributes()['lang']);
// With default
$request2 = $this->createRequest('GET', '/blog/recent');
$match2 = $this->router->match($request2);
$this->assertSame('en', $match2->getAttributes()['lang']);
}
public function testFluentGroupConfiguration(): void
{
$group = $this->router->group(['prefix' => '/admin']);
$group->valid('id', 'numeric')->default('id', 0);
$route = $group->get('/dashboard/{{id}}', 'handler');
$this->assertSame('/admin/dashboard/{{id}}', $route->getPath());
$this->assertArrayHasKey('id', $route->getValidation());
$this->assertSame(0, $route->getDefaults()['id']);
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Router\RouteGroup;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
class RouteGroupTest extends TestCase
{
private Router $router;
protected function setUp(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$this->router = new Router($config);
}
public function testGroupAppliesPrefixToRoutes(): void
{
$group = $this->router->group(['prefix' => '/api']);
$group->get('/users', 'Handler');
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/api/users', $routes[0]->getPath());
}
public function testGroupAppliesPrefixWithLeadingSlashToRoutes(): void
{
$group = $this->router->group(['prefix' => 'api']);
$group->get('users', 'Handler');
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/api/users', $routes[0]->getPath());
}
public function testGroupWithTrailingSlashInPrefix(): void
{
$group = $this->router->group(['prefix' => '/api/']);
$group->get('/users', 'Handler');
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/api/users', $routes[0]->getPath());
}
public function testAllHttpMethodsInGroup(): void
{
$group = $this->router->group(['prefix' => '/api']);
$group->get('/test', 'handler');
$group->post('/test', 'handler');
$group->put('/test', 'handler');
$group->patch('/test', 'handler');
$group->delete('/test', 'handler');
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(5, $routes);
foreach ($routes as $route) {
$this->assertSame('/api/test', $route->getPath());
}
}
public function testGroupReturnsSameInstanceForChaining(): void
{
$group = $this->router->group(['prefix' => '/api']);
$group->get('/users', 'Handler');
$group->post('/users', 'Handler');
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(2, $routes);
}
public function testGroupCanBeCreatedWithoutRouterAndStillWorks(): void
{
// This tests the case where RouteGroup might be used partially or in isolation
// although buildFullPath is the main logic.
$group = new RouteGroup(['prefix' => '/api']);
// Use reflection or just check options if public (it is protected/private)
$this->assertSame(['prefix' => '/api'], $group->getOptions());
}
public function testSetOptionOnGroup(): void
{
$group = new RouteGroup();
$group->setOption('prefix', '/test');
$this->assertSame(['prefix' => '/test'], $group->getOptions());
}
}

View file

@ -11,7 +11,7 @@ final class RouteMatcherTest extends TestCase
{ {
public function testReturnsRouteOnSuccessfulMatch(): void public function testReturnsRouteOnSuccessfulMatch(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new \Atlas\Config\Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
@ -38,7 +38,7 @@ final class RouteMatcherTest extends TestCase
public function testReturnsNullOnNoMatch(): void public function testReturnsNullOnNoMatch(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new \Atlas\Config\Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
@ -63,7 +63,7 @@ final class RouteMatcherTest extends TestCase
public function testCaseInsensitiveHttpMethodMatching(): void public function testCaseInsensitiveHttpMethodMatching(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new \Atlas\Config\Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
@ -88,7 +88,7 @@ final class RouteMatcherTest extends TestCase
public function testRouteCollectionIteratesCorrectly(): void public function testRouteCollectionIteratesCorrectly(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new \Atlas\Config\Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
@ -108,7 +108,7 @@ final class RouteMatcherTest extends TestCase
public function testUrlGenerationWithNamedRoute(): void public function testUrlGenerationWithNamedRoute(): void
{ {
$config = new \Atlas\Tests\Config\Config([ $config = new \Atlas\Config\Config([
'modules_path' => ['/path/to/modules'] 'modules_path' => ['/path/to/modules']
]); ]);
@ -123,19 +123,18 @@ final class RouteMatcherTest extends TestCase
public function testHttpMethodsReturnSameInstanceForChaining(): void public function testHttpMethodsReturnSameInstanceForChaining(): 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);
$methodsResult = $router $router->get('/get', 'Handler');
->get('/get', 'Handler') $router->post('/post', 'Handler');
->post('/post', 'Handler') $router->put('/put', 'Handler');
->put('/put', 'Handler') $router->patch('/patch', 'Handler');
->patch('/patch', 'Handler') $router->delete('/delete', 'Handler');
->delete('/delete', 'Handler');
$this->assertTrue($methodsResult instanceof \Atlas\Router\Router); $this->assertCount(5, $router->getRoutes());
} }
} }

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);

View file

@ -0,0 +1,36 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
class RouterFallbackTest extends TestCase
{
public function testFallbackHandlerCanBeSet(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$router = new Router($config);
$handler = function() { return '404'; };
$router->fallback($handler);
// Use reflection to check if fallbackHandler is set correctly since there is no getter
$reflection = new \ReflectionClass($router);
$property = $reflection->getProperty('fallbackHandler');
$property->setAccessible(true);
$this->assertSame($handler, $property->getValue($router));
}
public function testFallbackReturnsRouterInstanceForChaining(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$router = new Router($config);
$result = $router->fallback('Handler');
$this->assertSame($router, $result);
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Config\Config;
use Atlas\Exception\RouteNotFoundException;
use PHPUnit\Framework\TestCase;
class RouterUrlTest extends TestCase
{
private Router $router;
protected function setUp(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$this->router = new Router($config);
}
public function testUrlGeneratesForStaticRoute(): void
{
$this->router->get('/users', 'handler', 'user_list');
$url = $this->router->url('user_list');
$this->assertSame('/users', $url);
}
public function testUrlGeneratesWithParameters(): void
{
$this->router->get('/users/{{user_id}}', 'handler', 'user_detail');
$url = $this->router->url('user_detail', ['user_id' => 42]);
$this->assertSame('/users/42', $url);
}
public function testUrlGeneratesWithMultipleParameters(): void
{
$this->router->get('/posts/{{post_id}}/comments/{{comment_id}}', 'handler', 'comment_detail');
$url = $this->router->url('comment_detail', [
'post_id' => 10,
'comment_id' => 5
]);
$this->assertSame('/posts/10/comments/5', $url);
}
public function testUrlThrowsExceptionWhenRouteNotFound(): void
{
$this->expectException(RouteNotFoundException::class);
$this->expectExceptionMessage('Route "non_existent" not found');
$this->router->url('non_existent');
}
public function testUrlWithMissingParametersThrowsException(): void
{
$this->router->get('/users/{{user_id}}', 'handler', 'user_detail');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Missing required parameter "user_id"');
$this->router->url('user_detail', []);
}
}