feat: implement milestone 6 & 7 - fluent config, dynamic matching, and QA

This commit is contained in:
Funky Waddle 2026-02-14 17:27:29 -06:00
parent d8db903d2e
commit f709053b75
3 changed files with 250 additions and 123 deletions

View file

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

View file

@ -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
];
}
}
}

View file

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