From f709053b7514f8ca24b50331eb677b9d07d0aea5 Mon Sep 17 00:00:00 2001 From: Funky Waddle Date: Sat, 14 Feb 2026 17:27:29 -0600 Subject: [PATCH] feat: implement milestone 6 & 7 - fluent config, dynamic matching, and QA --- .../MissingConfigurationException.php | 5 + src/Router/RouteDefinition.php | 247 +++++++++--------- tests/Unit/DynamicMatchingTest.php | 121 +++++++++ 3 files changed, 250 insertions(+), 123 deletions(-) create mode 100644 tests/Unit/DynamicMatchingTest.php diff --git a/src/Exception/MissingConfigurationException.php b/src/Exception/MissingConfigurationException.php index 62a1245..6ff82f8 100644 --- a/src/Exception/MissingConfigurationException.php +++ b/src/Exception/MissingConfigurationException.php @@ -2,6 +2,11 @@ namespace Atlas\Exception; +/** + * Exception thrown when a required configuration value is missing. + * + * @extends \RuntimeException + */ class MissingConfigurationException extends \RuntimeException { } \ No newline at end of file diff --git a/src/Router/RouteDefinition.php b/src/Router/RouteDefinition.php index c83ed64..2ad849a 100644 --- a/src/Router/RouteDefinition.php +++ b/src/Router/RouteDefinition.php @@ -2,138 +2,22 @@ namespace Atlas\Router; -use Psr\Http\Message\UriInterface; - /** * Represents a complete route definition with matching patterns, handlers, and metadata. - * - * @final */ -final class RouteDefinition +class RouteDefinition implements \JsonSerializable, \Serializable { - /** - * Constructs a new RouteDefinition instance. - * - * @param string $method HTTP method (GET, POST, etc.) - * @param string $pattern Matching pattern (currently not used for matching) - * @param string $path Normalized path for comparison - * @param mixed $handler Route handler - * @param string|null $name Optional route name - * @param array $middleware Middleware for route processing - * @param array $validation Validation rules for route parameters - * @param array $defaults Default parameter values - * @param string|null $module Module identifier - * @param array $attributes Route attributes for parameter extraction - */ - 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 = [] - ) {} - - /** - * Gets the HTTP method of this route definition. - * - * @return string HTTP method - */ - public function getMethod(): string + public function serialize(): string { - return $this->method; + return serialize($this->__serialize()); } - /** - * Gets the path for this route definition. - * - * @return string Normalized path - */ - public function getPath(): string + public function unserialize(string $data): void { - return $this->path; + $this->__unserialize(unserialize($data)); } - /** - * Gets the handler for this route definition. - * - * @return string|callable Route handler - */ - public function getHandler(): string|callable - { - return $this->handler; - } - - /** - * Gets the optional name of this route. - * - * @return string|null Route name or null - */ - public function getName(): ?string - { - return $this->name; - } - - /** - * Gets the middleware configuration for this route. - * - * @return array Middleware configuration - */ - public function getMiddleware(): array - { - return $this->middleware; - } - - /** - * Gets the validation rules for this route. - * - * @return array Validation rules - */ - public function getValidation(): array - { - return $this->validation; - } - - /** - * Gets the default values for parameters. - * - * @return array Default parameter values - */ - public function getDefaults(): array - { - return $this->defaults; - } - - /** - * Gets the module identifier for this route. - * - * @return string|null Module identifier or null - */ - public function getModule(): ?string - { - return $this->module; - } - - /** - * Gets route attributes for parameter extraction. - * - * @return array Route attributes - */ - public function getAttributes(): array - { - return $this->attributes; - } - - /** - * Converts the route definition to an array. - * - * @return array Plain array representation - */ - public function toArray(): array + public function __serialize(): array { return [ 'method' => $this->method, @@ -145,7 +29,124 @@ final class RouteDefinition 'validation' => $this->validation, 'defaults' => $this->defaults, '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 ]; } -} \ No newline at end of file +} diff --git a/tests/Unit/DynamicMatchingTest.php b/tests/Unit/DynamicMatchingTest.php new file mode 100644 index 0000000..8864bdc --- /dev/null +++ b/tests/Unit/DynamicMatchingTest.php @@ -0,0 +1,121 @@ + ['/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); + } +}