feat: implement milestone 6 & 7 - fluent config, dynamic matching, and QA
This commit is contained in:
parent
d8db903d2e
commit
f709053b75
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
namespace Atlas\Exception;
|
namespace Atlas\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a required configuration value is missing.
|
||||||
|
*
|
||||||
|
* @extends \RuntimeException
|
||||||
|
*/
|
||||||
class MissingConfigurationException extends \RuntimeException
|
class MissingConfigurationException extends \RuntimeException
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -2,138 +2,22 @@
|
||||||
|
|
||||||
namespace Atlas\Router;
|
namespace Atlas\Router;
|
||||||
|
|
||||||
use Psr\Http\Message\UriInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a complete route definition with matching patterns, handlers, and metadata.
|
* Represents a complete route definition with matching patterns, handlers, and metadata.
|
||||||
*
|
|
||||||
* @final
|
|
||||||
*/
|
*/
|
||||||
final class RouteDefinition
|
class RouteDefinition implements \JsonSerializable, \Serializable
|
||||||
{
|
{
|
||||||
/**
|
public function serialize(): string
|
||||||
* 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
|
|
||||||
{
|
{
|
||||||
return $this->method;
|
return serialize($this->__serialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function unserialize(string $data): void
|
||||||
* Gets the path for this route definition.
|
|
||||||
*
|
|
||||||
* @return string Normalized path
|
|
||||||
*/
|
|
||||||
public function getPath(): string
|
|
||||||
{
|
{
|
||||||
return $this->path;
|
$this->__unserialize(unserialize($data));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function __serialize(): array
|
||||||
* 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
|
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'method' => $this->method,
|
'method' => $this->method,
|
||||||
|
|
@ -145,6 +29,123 @@ final class RouteDefinition
|
||||||
'validation' => $this->validation,
|
'validation' => $this->validation,
|
||||||
'defaults' => $this->defaults,
|
'defaults' => $this->defaults,
|
||||||
'module' => $this->module,
|
'module' => $this->module,
|
||||||
|
'attributes' => $this->attributes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __unserialize(array $data): void
|
||||||
|
{
|
||||||
|
$this->method = $data['method'];
|
||||||
|
$this->pattern = $data['pattern'];
|
||||||
|
$this->path = $data['path'];
|
||||||
|
$this->handler = $data['handler'];
|
||||||
|
$this->name = $data['name'];
|
||||||
|
$this->middleware = $data['middleware'];
|
||||||
|
$this->validation = $data['validation'];
|
||||||
|
$this->defaults = $data['defaults'];
|
||||||
|
$this->module = $data['module'];
|
||||||
|
$this->attributes = $data['attributes'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public function setModule(?string $module): void
|
||||||
|
{
|
||||||
|
$this->module = $module;
|
||||||
|
}
|
||||||
|
public function __construct(
|
||||||
|
private string $method,
|
||||||
|
private string $pattern,
|
||||||
|
private string $path,
|
||||||
|
private mixed $handler,
|
||||||
|
private string|null $name = null,
|
||||||
|
private array $middleware = [],
|
||||||
|
private array $validation = [],
|
||||||
|
private array $defaults = [],
|
||||||
|
private string|null $module = null,
|
||||||
|
private array $attributes = []
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getMethod(): string { return $this->method; }
|
||||||
|
public function getPattern(): string { return $this->pattern; }
|
||||||
|
public function getPath(): string { return $this->path; }
|
||||||
|
public function getHandler(): mixed { return $this->handler; }
|
||||||
|
public function getName(): ?string { return $this->name; }
|
||||||
|
|
||||||
|
public function name(string $name): self
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMiddleware(): array { return $this->middleware; }
|
||||||
|
|
||||||
|
public function middleware(string|array $middleware): self
|
||||||
|
{
|
||||||
|
if (is_string($middleware)) {
|
||||||
|
$this->middleware[] = $middleware;
|
||||||
|
} else {
|
||||||
|
$this->middleware = array_merge($this->middleware, $middleware);
|
||||||
|
}
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValidation(): array { return $this->validation; }
|
||||||
|
|
||||||
|
public function valid(array|string $param, array|string $rules = []): self
|
||||||
|
{
|
||||||
|
if (is_array($param)) {
|
||||||
|
foreach ($param as $p => $r) {
|
||||||
|
$this->valid($p, $r);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->validation[$param] = is_string($rules) ? [$rules] : $rules;
|
||||||
|
}
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDefaults(): array { return $this->defaults; }
|
||||||
|
|
||||||
|
public function default(string $param, mixed $value): self
|
||||||
|
{
|
||||||
|
$this->defaults[$param] = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModule(): ?string { return $this->module; }
|
||||||
|
|
||||||
|
public function getAttributes(): array { return $this->attributes; }
|
||||||
|
|
||||||
|
public function attr(string $key, mixed $value): self
|
||||||
|
{
|
||||||
|
$this->attributes[$key] = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function meta(array $data): self
|
||||||
|
{
|
||||||
|
$this->attributes = array_merge($this->attributes, $data);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): mixed
|
||||||
|
{
|
||||||
|
return $this->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'method' => $this->method,
|
||||||
|
'pattern' => $this->pattern,
|
||||||
|
'path' => $this->path,
|
||||||
|
'handler' => is_callable($this->handler) ? 'Closure' : $this->handler,
|
||||||
|
'name' => $this->name,
|
||||||
|
'middleware' => $this->middleware,
|
||||||
|
'validation' => $this->validation,
|
||||||
|
'defaults' => $this->defaults,
|
||||||
|
'module' => $this->module,
|
||||||
'attributes' => $this->attributes
|
'attributes' => $this->attributes
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
tests/Unit/DynamicMatchingTest.php
Normal file
121
tests/Unit/DynamicMatchingTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue