Compare commits
No commits in common. "566ed2d8781655bd1632fe1e0822ebae752b28cc" and "30f84a5023997f8dc743e4bc3c0eac28e0bb74d4" have entirely different histories.
566ed2d878
...
30f84a5023
|
|
@ -1,36 +0,0 @@
|
||||||
# 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.
|
|
||||||
116
MILESTONES.md
116
MILESTONES.md
|
|
@ -2,15 +2,6 @@
|
||||||
|
|
||||||
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).
|
||||||
|
|
@ -26,90 +17,45 @@ 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: Comprehensive Test Coverage
|
## Milestone 3: Parameters & Validation
|
||||||
*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.*
|
||||||
- [x] Implement `{{variable_name}}` and `{{variable_name?}}` (optional) parsing.
|
- [ ] Implement `{{variable_name}}` and `{{variable_name?}}` (optional) parsing.
|
||||||
- [x] Add `valid()` method (chaining and array support).
|
- [ ] Add `valid()` method (chaining and array support).
|
||||||
- [x] Add `default()` method and logic for implicit optional parameters.
|
- [ ] Add `default()` method and logic for implicit optional parameters.
|
||||||
- [x] Support for dynamic/regex-based segment matching.
|
- [ ] Support for dynamic/regex-based segment matching.
|
||||||
- [x] Add unit tests for parameter parsing, optionality, and validation rules.
|
|
||||||
|
|
||||||
## Milestone 9: Route Groups & First-Class Objects
|
## Milestone 4: 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.*
|
||||||
- [x] Implement `group()` method with prefix/middleware inheritance.
|
- [ ] Implement `group()` method with prefix/middleware inheritance.
|
||||||
- [x] Ensure Route Groups are first-class objects (routes can be added directly to them).
|
- [ ] Ensure Route Groups are first-class objects (routes can be added directly to them).
|
||||||
- [x] Implement indefinite nesting and recursive merging of properties.
|
- [ ] Implement indefinite nesting and recursive merging of properties.
|
||||||
- [x] Support group-level parameter validation.
|
- [ ] Support group-level parameter validation.
|
||||||
- [x] Add tests for nested group inheritance and group-level validation logic.
|
|
||||||
|
|
||||||
## Milestone 10: Modular Routing
|
## Milestone 5: Modular Routing
|
||||||
*Goal: Automate route discovery and registration based on directory structure.*
|
*Goal: Automate route discovery and registration based on directory structure.*
|
||||||
- [x] Implement the `module()` method.
|
- [ ] Implement the `module()` method.
|
||||||
- [x] Build the discovery logic for `src/Modules/{Name}/routes.php`.
|
- [ ] Build the discovery logic for `src/Modules/{Name}/routes.php`.
|
||||||
- [x] Implement middleware/prefix inheritance for modules.
|
- [ ] Implement middleware/prefix inheritance for modules.
|
||||||
- [x] Conflict resolution for overlapping module routes.
|
- [ ] Conflict resolution for overlapping module routes.
|
||||||
- [x] Add integration tests for module discovery and route registration.
|
|
||||||
|
|
||||||
## Milestone 11: Advanced Capabilities & Interoperability
|
## Milestone 6: Advanced Capabilities & Interoperability
|
||||||
*Goal: Add specialized routing features and full PSR-7 compatibility.*
|
*Goal: Add specialized routing features and full PSR-7 compatibility.*
|
||||||
- [x] Implement `redirect()` native support.
|
- [ ] Implement `redirect()` native support.
|
||||||
- [x] Add Route Attributes/Metadata (`attr()` and `meta()`).
|
- [ ] Add Route Attributes/Metadata (`attr()` and `meta()`).
|
||||||
- [x] Implement `url()` generation (Reverse Routing).
|
- [ ] Implement `url()` generation (Reverse Routing).
|
||||||
- [x] Add `fallback()` support at group/module levels.
|
- [ ] Add `fallback()` support at group/module levels.
|
||||||
- [x] Implement Subdomain Constraints and i18n support.
|
- [ ] Implement Subdomain Constraints and i18n support.
|
||||||
- [x] Add tests for redirection, attributes, subdomain constraints, and i18n.
|
|
||||||
|
|
||||||
## Milestone 12: Tooling & Inspector API
|
## Milestone 7: 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.*
|
||||||
- [x] Develop the Programmatic Inspector API (`getRoutes()`, `match()`).
|
- [ ] Develop the Programmatic Inspector API (`getRoutes()`, `match()`).
|
||||||
- [x] Build the `route:list` CLI command.
|
- [ ] Build the `route:list` CLI command.
|
||||||
- [x] Build the `route:test` CLI command with diagnostic output.
|
- [ ] Build the `route:test` CLI command with diagnostic output.
|
||||||
- [x] Ensure JSON output support for tooling integration.
|
- [ ] Ensure JSON output support for tooling integration.
|
||||||
- [x] Add tests for Inspector API and CLI command outputs.
|
|
||||||
|
|
||||||
## Milestone 13: Performance & Optimization
|
## Milestone 8: Performance & Optimization
|
||||||
*Goal: Finalize the engine with caching and production-ready performance.*
|
*Goal: Finalize the engine with caching and production-ready performance.*
|
||||||
- [x] Implement Route Caching (serializable optimized structure).
|
- [ ] Implement Route Caching (serializable optimized structure).
|
||||||
- [x] Performance benchmarking and matcher optimization.
|
- [ ] Performance benchmarking and matcher optimization.
|
||||||
- [x] Final Documentation (KDoc, README, Examples).
|
- [ ] Final Documentation (KDoc, README, Examples).
|
||||||
- [x] Implement performance regression tests and benchmark verification.
|
- [ ] Release v1.0.0.
|
||||||
- [x] Release v1.0.0.
|
|
||||||
|
|
|
||||||
4
NOTES.md
4
NOTES.md
|
|
@ -6,10 +6,6 @@ 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
106
README.md
|
|
@ -1,106 +0,0 @@
|
||||||
# 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
162
atlas
|
|
@ -1,162 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
|
|
@ -16,9 +16,7 @@
|
||||||
"psr/http-message": "^1.0 || ^2.0"
|
"psr/http-message": "^1.0 || ^2.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpstan/phpstan": "^1.10",
|
"phpunit/phpunit": "^10.0"
|
||||||
"phpunit/phpunit": "^10.0",
|
|
||||||
"squizlabs/php_codesniffer": "^3.7"
|
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|
@ -27,6 +25,7 @@
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
"Atlas\\": "src/",
|
||||||
"Atlas\\Tests\\": "tests/"
|
"Atlas\\Tests\\": "tests/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,56 +5,22 @@ 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 array $options
|
private readonly 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');
|
||||||
|
|
@ -66,33 +32,16 @@ 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();
|
||||||
|
|
@ -104,64 +53,31 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,6 @@
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
7
src/Exception/NotFoundRouteException.php
Normal file
7
src/Exception/NotFoundRouteException.php
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Atlas\Exception;
|
||||||
|
|
||||||
|
class NotFoundRouteException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,6 @@
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -2,11 +2,6 @@
|
||||||
|
|
||||||
namespace Atlas\Exception;
|
namespace Atlas\Exception;
|
||||||
|
|
||||||
/**
|
|
||||||
* Exception thrown when route parameter validation fails.
|
|
||||||
*
|
|
||||||
* @extends \RuntimeException
|
|
||||||
*/
|
|
||||||
class RouteValidationException extends \RuntimeException
|
class RouteValidationException extends \RuntimeException
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<?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
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,55 +4,25 @@ 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 mixed $handler
|
private readonly string|callable $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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,22 +2,69 @@
|
||||||
|
|
||||||
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 serialize(): string
|
public function __construct(
|
||||||
|
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 serialize($this->__serialize());
|
return $this->method;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function unserialize(string $data): void
|
public function getPath(): string
|
||||||
{
|
{
|
||||||
$this->__unserialize(unserialize($data));
|
return $this->path;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __serialize(): array
|
public function getHandler(): string|callable
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
|
@ -29,123 +76,6 @@ class RouteDefinition implements \JsonSerializable, \Serializable
|
||||||
'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
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,13 @@
|
||||||
|
|
||||||
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|null $router = null
|
private readonly Router $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);
|
||||||
|
|
@ -36,254 +16,55 @@ class RouteGroup
|
||||||
return $self;
|
return $self;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get(string $path, mixed $handler, string|null $name = null): RouteDefinition
|
public function get(string $path, string|callable $handler, string|null $name = null): self
|
||||||
{
|
{
|
||||||
$fullPath = $this->buildFullPath($path);
|
$fullPath = $this->buildFullPath($path);
|
||||||
$middleware = $this->options['middleware'] ?? [];
|
return $this->router ? $this->router->get($fullPath, $handler, $name) : $this;
|
||||||
$validation = $this->options['validation'] ?? [];
|
|
||||||
$defaults = $this->options['defaults'] ?? [];
|
|
||||||
|
|
||||||
if ($this->router) {
|
|
||||||
return $this->router->registerCustomRoute('GET', $fullPath, $handler, $name, $middleware, $validation, $defaults);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RouteDefinition('GET', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
|
public function post(string $path, string|callable $handler, string|null $name = null): self
|
||||||
}
|
|
||||||
|
|
||||||
public function post(string $path, mixed $handler, string|null $name = null): RouteDefinition
|
|
||||||
{
|
{
|
||||||
$fullPath = $this->buildFullPath($path);
|
$fullPath = $this->buildFullPath($path);
|
||||||
$middleware = $this->options['middleware'] ?? [];
|
return $this->router ? $this->router->post($fullPath, $handler, $name) : $this;
|
||||||
$validation = $this->options['validation'] ?? [];
|
|
||||||
$defaults = $this->options['defaults'] ?? [];
|
|
||||||
|
|
||||||
if ($this->router) {
|
|
||||||
return $this->router->registerCustomRoute('POST', $fullPath, $handler, $name, $middleware, $validation, $defaults);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RouteDefinition('POST', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
|
public function put(string $path, string|callable $handler, string|null $name = null): self
|
||||||
}
|
|
||||||
|
|
||||||
public function put(string $path, mixed $handler, string|null $name = null): RouteDefinition
|
|
||||||
{
|
{
|
||||||
$fullPath = $this->buildFullPath($path);
|
$fullPath = $this->buildFullPath($path);
|
||||||
$middleware = $this->options['middleware'] ?? [];
|
return $this->router ? $this->router->put($fullPath, $handler, $name) : $this;
|
||||||
$validation = $this->options['validation'] ?? [];
|
|
||||||
$defaults = $this->options['defaults'] ?? [];
|
|
||||||
|
|
||||||
if ($this->router) {
|
|
||||||
return $this->router->registerCustomRoute('PUT', $fullPath, $handler, $name, $middleware, $validation, $defaults);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RouteDefinition('PUT', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
|
public function patch(string $path, string|callable $handler, string|null $name = null): self
|
||||||
}
|
|
||||||
|
|
||||||
public function patch(string $path, mixed $handler, string|null $name = null): RouteDefinition
|
|
||||||
{
|
{
|
||||||
$fullPath = $this->buildFullPath($path);
|
$fullPath = $this->buildFullPath($path);
|
||||||
$middleware = $this->options['middleware'] ?? [];
|
return $this->router ? $this->router->patch($fullPath, $handler, $name) : $this;
|
||||||
$validation = $this->options['validation'] ?? [];
|
|
||||||
$defaults = $this->options['defaults'] ?? [];
|
|
||||||
|
|
||||||
if ($this->router) {
|
|
||||||
return $this->router->registerCustomRoute('PATCH', $fullPath, $handler, $name, $middleware, $validation, $defaults);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RouteDefinition('PATCH', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
|
public function delete(string $path, string|callable $handler, string|null $name = null): self
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(string $path, mixed $handler, string|null $name = null): RouteDefinition
|
|
||||||
{
|
{
|
||||||
$fullPath = $this->buildFullPath($path);
|
$fullPath = $this->buildFullPath($path);
|
||||||
$middleware = $this->options['middleware'] ?? [];
|
return $this->router ? $this->router->delete($fullPath, $handler, $name) : $this;
|
||||||
$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'] ?? '');
|
|
||||||
|
|
||||||
$middleware = $this->options['middleware'] ?? [];
|
if (empty($prefix)) {
|
||||||
$newMiddleware = array_merge($middleware, $options['middleware'] ?? []);
|
return $path;
|
||||||
|
|
||||||
$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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
<?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']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,219 +3,151 @@
|
||||||
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
|
||||||
{
|
{
|
||||||
use PathHelper;
|
private array $routes = [];
|
||||||
|
|
||||||
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
|
private readonly Config\Config $config
|
||||||
) {
|
) {
|
||||||
$this->routes = new RouteCollection();
|
|
||||||
$this->matcher = new RouteMatcher();
|
|
||||||
$this->loader = new ModuleLoader($this->config, $this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get(string $path, string|callable $handler, string|null $name = null): RouteDefinition
|
public function get(string $path, string|callable $handler, string|null $name = null): self
|
||||||
{
|
{
|
||||||
return $this->registerRoute('GET', $path, $handler, $name);
|
$this->registerRoute('GET', $path, $handler, $name);
|
||||||
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function post(string $path, string|callable $handler, string|null $name = null): RouteDefinition
|
public function post(string $path, string|callable $handler, string|null $name = null): self
|
||||||
{
|
{
|
||||||
return $this->registerRoute('POST', $path, $handler, $name);
|
$this->registerRoute('POST', $path, $handler, $name);
|
||||||
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function put(string $path, string|callable $handler, string|null $name = null): RouteDefinition
|
public function put(string $path, string|callable $handler, string|null $name = null): self
|
||||||
{
|
{
|
||||||
return $this->registerRoute('PUT', $path, $handler, $name);
|
$this->registerRoute('PUT', $path, $handler, $name);
|
||||||
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function patch(string $path, string|callable $handler, string|null $name = null): RouteDefinition
|
public function patch(string $path, string|callable $handler, string|null $name = null): self
|
||||||
{
|
{
|
||||||
return $this->registerRoute('PATCH', $path, $handler, $name);
|
$this->registerRoute('PATCH', $path, $handler, $name);
|
||||||
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(string $path, string|callable $handler, string|null $name = null): RouteDefinition
|
public function delete(string $path, string|callable $handler, string|null $name = null): self
|
||||||
{
|
{
|
||||||
return $this->registerRoute('DELETE', $path, $handler, $name);
|
$this->registerRoute('DELETE', $path, $handler, $name);
|
||||||
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerCustomRoute(string $method, string $path, mixed $handler, string|null $name = null, array $middleware = [], array $validation = [], array $defaults = []): RouteDefinition
|
private function registerRoute(string $method, string $path, mixed $handler, string|null $name = null): void
|
||||||
{
|
{
|
||||||
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,
|
||||||
$normalizedPath,
|
$this->normalizePath($path),
|
||||||
$normalizedPath,
|
$this->normalizePath($path),
|
||||||
$handler,
|
$handler,
|
||||||
$name,
|
$name
|
||||||
$middleware,
|
|
||||||
$validation,
|
|
||||||
$defaults
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->storeRoute($routeDefinition);
|
$this->storeRoute($routeDefinition);
|
||||||
|
}
|
||||||
|
|
||||||
return $routeDefinition;
|
private function normalizePath(string $path): string
|
||||||
|
{
|
||||||
|
$normalized = trim($path, '/');
|
||||||
|
|
||||||
|
if (empty($normalized)) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/' . $normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function storeRoute(RouteDefinition $routeDefinition): void
|
protected function storeRoute(RouteDefinition $routeDefinition): void
|
||||||
{
|
{
|
||||||
$this->routes->add($routeDefinition);
|
// Routes will be managed by a route collection class (to be implemented)
|
||||||
|
// For now, we register them in an array property
|
||||||
|
if (!isset($this->routes)) {
|
||||||
|
$this->routes = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRoutes(): RouteCollection
|
$this->routes[] = $routeDefinition;
|
||||||
{
|
|
||||||
return $this->routes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setRoutes(RouteCollection $routes): self
|
public function getRoutes(): iterable
|
||||||
{
|
{
|
||||||
$this->routes = $routes;
|
return $this->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();
|
|
||||||
|
|
||||||
$diagnostics = [
|
foreach ($this->getRoutes() as $routeDefinition) {
|
||||||
'method' => $method,
|
if (strtoupper($routeDefinition->getMethod()) === $method && $routeDefinition->getPath() === $path) {
|
||||||
'path' => $path,
|
return $routeDefinition;
|
||||||
'host' => $host,
|
|
||||||
'attempts' => []
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($this->routes as $route) {
|
|
||||||
$attributes = [];
|
|
||||||
$routeMethod = strtoupper($route->getMethod());
|
|
||||||
$routePath = $route->getPath();
|
|
||||||
|
|
||||||
$matchStatus = 'mismatch';
|
|
||||||
if ($routeMethod !== $method && $routeMethod !== 'REDIRECT') {
|
|
||||||
$matchStatus = 'method_mismatch';
|
|
||||||
} else {
|
|
||||||
$pattern = $this->matcher->getPatternForRoute($route);
|
|
||||||
if (preg_match($pattern, $path, $matches)) {
|
|
||||||
$matchStatus = 'matched';
|
|
||||||
$attributes = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
|
|
||||||
$attributes = array_merge($route->getDefaults(), $attributes);
|
|
||||||
|
|
||||||
return new MatchResult(true, $route, $attributes, $diagnostics);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$diagnostics['attempts'][] = [
|
return null;
|
||||||
'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
|
||||||
{
|
{
|
||||||
$route = $this->match($request);
|
$method = strtoupper($request->getMethod());
|
||||||
|
$path = $this->normalizePath($request->getUri()->getPath());
|
||||||
|
|
||||||
if ($route === null) {
|
foreach ($this->getRoutes() as $routeDefinition) {
|
||||||
throw new RouteNotFoundException('No route matched the request');
|
if (strtoupper($routeDefinition->getMethod()) === $method && $routeDefinition->getPath() === $path) {
|
||||||
|
return $routeDefinition;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $route;
|
throw new NotFoundRouteException('No route matched the request');
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
$foundRoute = $this->routes->getByName($name);
|
$routes = $this->getRoutes();
|
||||||
|
$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, $foundRoute->getDefaults());
|
$path = $this->replaceParameters($path, $parameters);
|
||||||
|
|
||||||
return $path;
|
return $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function replaceParameters(string $path, array $parameters, array $defaults = []): string
|
private function replaceParameters(string $path, array $parameters): string
|
||||||
{
|
{
|
||||||
if (!str_contains($path, '{{')) {
|
foreach ($parameters as $key => $value) {
|
||||||
return $this->normalizePath($path);
|
$pattern = '{{' . $key . '}}';
|
||||||
|
$path = str_replace($pattern, $value, $path);
|
||||||
}
|
}
|
||||||
|
|
||||||
preg_match_all('/\{\{([a-zA-Z0-9_]+)(\?)?\}\}/', $path, $matches);
|
return $path;
|
||||||
$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
|
||||||
|
|
@ -223,9 +155,47 @@ class Router
|
||||||
return new RouteGroup($options, $this);
|
return new RouteGroup($options, $this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function module(string|array $identifier, string|null $prefix = null): self
|
public function module(string|array $identifier): self
|
||||||
{
|
{
|
||||||
$this->loader->load($identifier, $prefix);
|
$identifier = is_string($identifier) ? [$identifier] : $identifier;
|
||||||
|
|
||||||
|
$modulesPath = $this->config->getModulesPath();
|
||||||
|
$routesFile = $this->config->getRoutesFile();
|
||||||
|
|
||||||
|
if ($modulesPath === null) {
|
||||||
|
throw new MissingConfigurationException(
|
||||||
|
'modules_path configuration is required to use module() method'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = $identifier[0] ?? '';
|
||||||
|
|
||||||
|
foreach ($modulesPath as $basePath) {
|
||||||
|
$modulePath = $basePath . '/' . $prefix . '/' . $routesFile;
|
||||||
|
|
||||||
|
if (file_exists($modulePath)) {
|
||||||
|
$this->loadModuleRoutes($modulePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
<?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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,10 +2,7 @@
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -14,11 +11,11 @@ final class ErrorHandlingTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testMatchOrFailThrowsExceptionWhenNoRouteFound(): void
|
public function testMatchOrFailThrowsExceptionWhenNoRouteFound(): void
|
||||||
{
|
{
|
||||||
$config = new Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$router = new Router($config);
|
$router = new \Atlas\Router\Router($config);
|
||||||
|
|
||||||
$uri = $this->createMock(UriInterface::class);
|
$uri = $this->createMock(UriInterface::class);
|
||||||
$uri->method('getPath')->willReturn('/nonexistent');
|
$uri->method('getPath')->willReturn('/nonexistent');
|
||||||
|
|
@ -30,18 +27,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(RouteNotFoundException::class);
|
$this->expectException(\Atlas\Exception\NotFoundRouteException::class);
|
||||||
|
|
||||||
$router->matchOrFail($request);
|
$router->matchOrFail($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchReturnsNullWhenNoRouteFound(): void
|
public function testMatchReturnsNullWhenNoRouteFound(): void
|
||||||
{
|
{
|
||||||
$config = new Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$router = new Router($config);
|
$router = new \Atlas\Router\Router($config);
|
||||||
|
|
||||||
$uri = $this->createMock(UriInterface::class);
|
$uri = $this->createMock(UriInterface::class);
|
||||||
$uri->method('getPath')->willReturn('/nonexistent');
|
$uri->method('getPath')->willReturn('/nonexistent');
|
||||||
|
|
@ -60,39 +57,38 @@ final class ErrorHandlingTest extends TestCase
|
||||||
|
|
||||||
public function testRouteChainingWithDifferentHttpMethods(): void
|
public function testRouteChainingWithDifferentHttpMethods(): void
|
||||||
{
|
{
|
||||||
$config = new Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$router = new Router($config);
|
$result = new \Atlas\Router\Router($config)->get('/test', 'GetHandler')->post('/test', 'PostHandler');
|
||||||
$router->get('/test', 'GetHandler');
|
|
||||||
$router->post('/test', 'PostHandler');
|
|
||||||
|
|
||||||
$this->assertCount(2, $router->getRoutes());
|
$this->assertTrue($result instanceof \Atlas\Router\Router);
|
||||||
|
$this->assertCount(2, $result->getRoutes());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchUsingRouteDefinition(): void
|
public function testMatchUsingRouteDefinition(): void
|
||||||
{
|
{
|
||||||
$config = new Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$router = new Router($config);
|
$router = new \Atlas\Router\Router($config);
|
||||||
|
|
||||||
$router->get('/test', 'TestMethod');
|
$router->get('/test', 'TestMethod');
|
||||||
|
|
||||||
$routes = iterator_to_array($router->getRoutes());
|
$routes = $router->getRoutes();
|
||||||
$this->assertCount(1, $routes);
|
$this->assertCount(1, $routes);
|
||||||
$this->assertInstanceOf(RouteDefinition::class, $routes[0]);
|
$this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $routes[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCaseInsensitiveHttpMethodMatching(): void
|
public function testCaseInsensitiveHttpMethodMatching(): void
|
||||||
{
|
{
|
||||||
$config = new Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$router = new Router($config);
|
$router = new \Atlas\Router\Router($config);
|
||||||
|
|
||||||
$router->get('/test', 'TestHandler');
|
$router->get('/test', 'TestHandler');
|
||||||
|
|
||||||
|
|
@ -113,11 +109,11 @@ final class ErrorHandlingTest extends TestCase
|
||||||
|
|
||||||
public function testPathNormalizationLeadingSlashes(): void
|
public function testPathNormalizationLeadingSlashes(): void
|
||||||
{
|
{
|
||||||
$config = new Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$router = new Router($config);
|
$router = new \Atlas\Router\Router($config);
|
||||||
|
|
||||||
$router->get('/test', 'TestHandler');
|
$router->get('/test', 'TestHandler');
|
||||||
|
|
||||||
|
|
@ -138,11 +134,11 @@ final class ErrorHandlingTest extends TestCase
|
||||||
|
|
||||||
public function testMatchOrFailThrowsExceptionForMultipleRoutes(): void
|
public function testMatchOrFailThrowsExceptionForMultipleRoutes(): void
|
||||||
{
|
{
|
||||||
$config = new Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$router = new Router($config);
|
$router = new \Atlas\Router\Router($config);
|
||||||
|
|
||||||
$router->get('/test', 'TestHandler');
|
$router->get('/test', 'TestHandler');
|
||||||
|
|
||||||
|
|
@ -158,15 +154,6 @@ final class ErrorHandlingTest extends TestCase
|
||||||
|
|
||||||
$matchedRoute = $router->matchOrFail($request);
|
$matchedRoute = $router->matchOrFail($request);
|
||||||
|
|
||||||
$this->assertInstanceOf(RouteDefinition::class, $matchedRoute);
|
$this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $matchedRoute);
|
||||||
}
|
|
||||||
|
|
||||||
public function testModuleThrowsExceptionWhenModulesPathIsMissing(): void
|
|
||||||
{
|
|
||||||
$config = new Config([]);
|
|
||||||
$router = new Router($config);
|
|
||||||
|
|
||||||
$this->expectException(\Atlas\Exception\MissingConfigurationException::class);
|
|
||||||
$router->module('User');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
<?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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
<?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')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<?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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
<?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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,7 @@ final class RouteMatcherTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testReturnsRouteOnSuccessfulMatch(): void
|
public function testReturnsRouteOnSuccessfulMatch(): void
|
||||||
{
|
{
|
||||||
$config = new \Atlas\Config\Config([
|
$config = new \Atlas\Tests\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\Config\Config([
|
$config = new \Atlas\Tests\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\Config\Config([
|
$config = new \Atlas\Tests\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\Config\Config([
|
$config = new \Atlas\Tests\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\Config\Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -123,18 +123,19 @@ final class RouteMatcherTest extends TestCase
|
||||||
|
|
||||||
public function testHttpMethodsReturnSameInstanceForChaining(): void
|
public function testHttpMethodsReturnSameInstanceForChaining(): void
|
||||||
{
|
{
|
||||||
$config = new \Atlas\Config\Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$router = new \Atlas\Router\Router($config);
|
$router = new \Atlas\Router\Router($config);
|
||||||
|
|
||||||
$router->get('/get', 'Handler');
|
$methodsResult = $router
|
||||||
$router->post('/post', 'Handler');
|
->get('/get', 'Handler')
|
||||||
$router->put('/put', 'Handler');
|
->post('/post', 'Handler')
|
||||||
$router->patch('/patch', 'Handler');
|
->put('/put', 'Handler')
|
||||||
$router->delete('/delete', 'Handler');
|
->patch('/patch', 'Handler')
|
||||||
|
->delete('/delete', 'Handler');
|
||||||
|
|
||||||
$this->assertCount(5, $router->getRoutes());
|
$this->assertTrue($methodsResult instanceof \Atlas\Router\Router);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ class RouterBasicTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testRouterCanBeCreatedWithValidConfig(): void
|
public function testRouterCanBeCreatedWithValidConfig(): void
|
||||||
{
|
{
|
||||||
$config = new \Atlas\Config\Config([
|
$config = new \Atlas\Tests\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\Config\Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -36,21 +36,20 @@ class RouterBasicTest extends TestCase
|
||||||
|
|
||||||
public function testRouterReturnsSameInstanceForChaining(): void
|
public function testRouterReturnsSameInstanceForChaining(): void
|
||||||
{
|
{
|
||||||
$config = new \Atlas\Config\Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$router = new \Atlas\Router\Router($config);
|
$router = new \Atlas\Router\Router($config);
|
||||||
|
|
||||||
$router->get('/get', 'handler');
|
$result = $router->get('/get', 'handler')->post('/post', 'handler');
|
||||||
$router->post('/post', 'handler');
|
|
||||||
|
|
||||||
$this->assertCount(2, $router->getRoutes());
|
$this->assertTrue($result instanceof \Atlas\Router\Router);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRouteHasCorrectProperties(): void
|
public function testRouteHasCorrectProperties(): void
|
||||||
{
|
{
|
||||||
$config = new \Atlas\Config\Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -58,7 +57,7 @@ class RouterBasicTest extends TestCase
|
||||||
|
|
||||||
$router->get('/test', 'test_handler', 'test_route');
|
$router->get('/test', 'test_handler', 'test_route');
|
||||||
|
|
||||||
$routes = iterator_to_array($router->getRoutes());
|
$routes = $router->getRoutes();
|
||||||
$route = $routes[0] ?? null;
|
$route = $routes[0] ?? null;
|
||||||
|
|
||||||
$this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $route);
|
$this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $route);
|
||||||
|
|
@ -73,7 +72,7 @@ class RouterBasicTest extends TestCase
|
||||||
|
|
||||||
public function testRouteNormalizesPath(): void
|
public function testRouteNormalizesPath(): void
|
||||||
{
|
{
|
||||||
$config = new \Atlas\Config\Config([
|
$config = new \Atlas\Tests\Config\Config([
|
||||||
'modules_path' => ['/path/to/modules']
|
'modules_path' => ['/path/to/modules']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -81,7 +80,7 @@ class RouterBasicTest extends TestCase
|
||||||
|
|
||||||
$router->get('/api/test', 'handler');
|
$router->get('/api/test', 'handler');
|
||||||
|
|
||||||
$routes = iterator_to_array($router->getRoutes());
|
$routes = $router->getRoutes();
|
||||||
$route = $routes[0] ?? null;
|
$route = $routes[0] ?? null;
|
||||||
|
|
||||||
$this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $route);
|
$this->assertInstanceOf(\Atlas\Router\RouteDefinition::class, $route);
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
<?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', []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue