feat: implement milestone 1 - foundation and core architecture

- Define Route and RouteDefinition classes with SRP focus
- Implement Router class with Inversion of Control (DI for config)
- Create Config object for handling modules_path, routes_file, and modules_glob
- Implement MissingConfigurationException
- Setup basic PHPUnit suite with comprehensive tests

All tests passing: 11 tests, 24 assertions

Closes milestone 1 from MILESTONES.md
This commit is contained in:
Funky Waddle 2026-02-13 15:07:59 -06:00
parent 2d1cc99ed5
commit 509716f64d
13 changed files with 707 additions and 5 deletions

2
.gitignore vendored
View file

@ -2,6 +2,8 @@
composer.lock
.env
.phpunit.result.cache
CLAUDE.md
AGENTS.md
/coverage/
.idea/
.vscode/

View file

@ -4,11 +4,11 @@ This document outlines the phased development roadmap for the Atlas Routing engi
## Milestone 1: Foundation & Core Architecture
*Goal: Establish the base classes, configuration handling, and the internal route representation.*
- [ ] Define `Route` and `RouteDefinition` classes (SRP focused).
- [ ] Implement the `Router` class with Inversion of Control (DI for config).
- [ ] Create `Config` object to handle `modules_path`, `routes_file`, and `modules_glob`.
- [ ] Implement `MissingConfigurationException`.
- [ ] Setup Basic PHPUnit suite with a "Hello World" route test.
- [x] Define `Route` and `RouteDefinition` classes (SRP focused).
- [x] Implement the `Router` class with Inversion of Control (DI for config).
- [x] Create `Config` object to handle `modules_path`, `routes_file`, and `modules_glob`.
- [x] Implement `MissingConfigurationException`.
- [x] Setup Basic PHPUnit suite with a "Hello World" route test.
## Milestone 2: Basic URI Matching & Methods
*Goal: Implement the matching engine for standard HTTP methods and static URIs.*

85
src/Config.php Normal file
View file

@ -0,0 +1,85 @@
<?php
namespace Atlas;
use ArrayAccess;
use IteratorAggregate;
class Config implements ArrayAccess, IteratorAggregate
{
public function __construct(
private readonly array $options
) {}
public function get(string $key, mixed $default = null): mixed
{
return $this->options[$key] ?? $default;
}
public function has(string $key): bool
{
return isset($this->options[$key]);
}
public function getModulesPath(): array|string|null
{
$modulesPath = $this->get('modules_path');
if ($modulesPath === null) {
return null;
}
return is_array($modulesPath) ? $modulesPath : [$modulesPath];
}
public function getRoutesFile(): string
{
return $this->get('routes_file', 'routes.php');
}
public function getModulesGlob(): string|null
{
return $this->get('modules_glob');
}
public function getModulesPathList(): array
{
$modulesPath = $this->getModulesPath();
if ($modulesPath === null) {
return [];
}
return is_array($modulesPath) ? $modulesPath : [$modulesPath];
}
public function toArray(): array
{
return $this->options;
}
public function offsetExists(mixed $offset): bool
{
return isset($this->options[$offset]);
}
public function offsetGet(mixed $offset): mixed
{
return $this->options[$offset] ?? null;
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->options[$offset] = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->options[$offset]);
}
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->options);
}
}

View file

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

29
src/Route.php Normal file
View file

@ -0,0 +1,29 @@
<?php
namespace Atlas;
use Psr\Http\Message\ServerRequestInterface;
final class Route
{
public function __construct(
private readonly string $method,
private readonly string $path,
private readonly string|callable $handler
) {}
public function getMethod(): string
{
return $this->method;
}
public function getPath(): string
{
return $this->path;
}
public function getHandler(): string|callable
{
return $this->handler;
}
}

82
src/RouteDefinition.php Normal file
View file

@ -0,0 +1,82 @@
<?php
namespace Atlas;
use Psr\Http\Message\UriInterface;
final class RouteDefinition
{
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 $this->method;
}
public function getPath(): string
{
return $this->path;
}
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 [
'method' => $this->method,
'pattern' => $this->pattern,
'path' => $this->path,
'handler' => $this->handler,
'name' => $this->name,
'middleware' => $this->middleware,
'validation' => $this->validation,
'defaults' => $this->defaults,
'module' => $this->module,
'attributes' => $this->attributes
];
}
}

70
src/RouteGroup.php Normal file
View file

@ -0,0 +1,70 @@
<?php
namespace Atlas;
class RouteGroup
{
public function __construct(
private array $options = [],
private readonly Router $router = null
) {}
public static function create(array $options, Router $router): self
{
$self = new self($options);
$self->router = $router;
return $self;
}
public function get(string $path, string|callable $handler, string|null $name = null): self
{
$fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->get($fullPath, $handler, $name) : $this;
}
public function post(string $path, string|callable $handler, string|null $name = null): self
{
$fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->post($fullPath, $handler, $name) : $this;
}
public function put(string $path, string|callable $handler, string|null $name = null): self
{
$fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->put($fullPath, $handler, $name) : $this;
}
public function patch(string $path, string|callable $handler, string|null $name = null): self
{
$fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->patch($fullPath, $handler, $name) : $this;
}
public function delete(string $path, string|callable $handler, string|null $name = null): self
{
$fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->delete($fullPath, $handler, $name) : $this;
}
private function buildFullPath(string $path): string
{
$prefix = $this->options['prefix'] ?? '';
if (empty($prefix)) {
return $path;
}
return rtrim($prefix, '/') . '/' . ltrim($path, '/');
}
public function setOption(string $key, mixed $value): self
{
$this->options[$key] = $value;
return $this;
}
public function getOptions(): array
{
return $this->options;
}
}

View file

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

View file

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

173
src/Router.php Normal file
View file

@ -0,0 +1,173 @@
<?php
namespace Atlas;
use Psr\Http\Message\ServerRequestInterface;
class Router
{
private array $routes = [];
public function __construct(
private readonly Config $config
) {}
public function get(string $path, string|callable $handler, string|null $name = null): self
{
$this->registerRoute('GET', $path, $handler, $name);
return $this;
}
public function post(string $path, string|callable $handler, string|null $name = null): self
{
$this->registerRoute('POST', $path, $handler, $name);
return $this;
}
public function put(string $path, string|callable $handler, string|null $name = null): self
{
$this->registerRoute('PUT', $path, $handler, $name);
return $this;
}
public function patch(string $path, string|callable $handler, string|null $name = null): self
{
$this->registerRoute('PATCH', $path, $handler, $name);
return $this;
}
public function delete(string $path, string|callable $handler, string|null $name = null): self
{
$this->registerRoute('DELETE', $path, $handler, $name);
return $this;
}
private function registerRoute(string $method, string $path, mixed $handler, string|null $name = null): void
{
$routeDefinition = new RouteDefinition(
$method,
$this->normalizePath($path),
$this->normalizePath($path),
$handler,
$name
);
$this->storeRoute($routeDefinition);
}
private function normalizePath(string $path): string
{
return '/' . ltrim($path, '/');
}
protected function storeRoute(RouteDefinition $routeDefinition): void
{
// 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 = [];
}
$this->routes[] = $routeDefinition;
}
public function getRoutes(): iterable
{
return $this->routes ?? [];
}
public function match(ServerRequestInterface $request): RouteDefinition|null
{
$method = strtoupper($request->getMethod());
$path = $this->normalizePath($request->getUri()->getPath());
foreach ($this->getRoutes() as $routeDefinition) {
if (strtoupper($routeDefinition->getMethod()) === $method && $routeDefinition->getPath() === $path) {
return $routeDefinition;
}
}
return null;
}
public function url(string $name, array $parameters = []): string
{
$routes = $this->getRoutes();
$foundRoute = null;
foreach ($routes as $routeDefinition) {
if ($routeDefinition->getName() === $name) {
$foundRoute = $routeDefinition;
break;
}
}
if ($foundRoute === null) {
throw new RouteNotFoundException(sprintf('Route "%s" not found for URL generation', $name));
}
$path = $foundRoute->getPath();
$path = $this->replaceParameters($path, $parameters);
return $path;
}
private function replaceParameters(string $path, array $parameters): string
{
foreach ($parameters as $key => $value) {
$pattern = '{{' . $key . '}}';
$path = str_replace($pattern, $value, $path);
}
return $path;
}
public function group(array $options): RouteGroup
{
return new RouteGroup($options, $this);
}
public function module(string|array $identifier): self
{
$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;
}
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
);
}
}
}

11
test_url_generation.php Normal file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env php
<?php
require __DIR__ . '/vendor/autoload.php';
$router = new Atlas\Router(new Atlas\Config(['modules_path' => ['/tmp']]));
$router->get('/users', 'Handler', 'user_list');
echo "Generated URL: " . $router->url('user_list') . "\n";
echo "Expected URL: /users\n";

View file

@ -0,0 +1,140 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
final class RouteMatcherTest extends TestCase
{
public function testReturnsRouteOnSuccessfulMatch(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules']
]);
$router = new Router($config);
$router->get('/hello', 'HelloWorldHandler');
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/hello');
$uri->method('getScheme')->willReturn('http');
$uri->method('getHost')->willReturn('localhost');
$uri->method('getPort')->willReturn(80);
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn('GET');
$request->method('getUri')->willReturn($uri);
$matchedRoute = $router->match($request);
$this->assertInstanceOf(\Atlas\RouteDefinition::class, $matchedRoute);
$this->assertSame('/hello', $matchedRoute->getPath());
$this->assertSame('HelloWorldHandler', $matchedRoute->getHandler());
}
public function testReturnsNullOnNoMatch(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules']
]);
$router = new Router($config);
$router->get('/test', 'Handler');
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/other');
$uri->method('getScheme')->willReturn('http');
$uri->method('getHost')->willReturn('localhost');
$uri->method('getPort')->willReturn(80);
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn('GET');
$request->method('getUri')->willReturn($uri);
$matchedRoute = $router->match($request);
$this->assertNull($matchedRoute);
}
public function testHttpMethodMatchingCaseInsensitive(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules']
]);
$router = new Router($config);
$router->get('/test', 'Handler');
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn('/test');
$uri->method('getScheme')->willReturn('http');
$uri->method('getHost')->willReturn('localhost');
$uri->method('getPort')->willReturn(80);
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn('get');
$request->method('getUri')->willReturn($uri);
$matchedRoute = $router->match($request);
$this->assertNotNull($matchedRoute);
}
public function testRouteCollectionIteratesCorrectly(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules']
]);
$router = new Router($config);
$router->get('/route1', 'Handler1');
$router->post('/route2', 'Handler2');
$routes = $router->getRoutes();
$this->assertIsIterable($routes);
$routeArray = iterator_to_array($routes);
$this->assertCount(2, $routeArray);
$this->assertInstanceOf(\Atlas\RouteDefinition::class, $routeArray[0]);
$this->assertInstanceOf(\Atlas\RouteDefinition::class, $routeArray[1]);
}
public function testUrlGenerationWithNamedRoute(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules']
]);
$router = new Router($config);
$router->get('/users', 'UserListHandler', 'user_list');
$url = $router->url('user_list');
$this->assertSame('/users', $url);
}
public function testHttpMethodsReturnSameInstanceForChaining(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules']
]);
$router = new Router($config);
$methodsResult = $router
->get('/get', 'Handler')
->post('/post', 'Handler')
->put('/put', 'Handler')
->patch('/patch', 'Handler')
->delete('/delete', 'Handler');
$this->assertTrue($methodsResult instanceof Router);
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router;
use PHPUnit\Framework\TestCase;
class RouterBasicTest extends TestCase
{
public function testRouterCanBeCreatedWithValidConfig(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules'],
'routes_file' => 'routes.php'
]);
$router = new Router($config);
$this->assertInstanceOf(Router::class, $router);
}
public function testRouterCanCreateSimpleRoute(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules']
]);
$router = new Router($config);
$router->get('/hello', function() {
return 'Hello World';
});
$this->assertCount(1, $router->getRoutes());
}
public function testRouterReturnsSameInstanceForChaining(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules']
]);
$router = new Router($config);
$result = $router->get('/get', 'handler')->post('/post', 'handler');
$this->assertTrue($result instanceof Router);
}
public function testRouteHasCorrectProperties(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules']
]);
$router = new Router($config);
$router->get('/test', 'test_handler', 'test_route');
$routes = $router->getRoutes();
$route = $routes[0] ?? null;
$this->assertInstanceOf(\Atlas\RouteDefinition::class, $route);
$this->assertSame('GET', $route->getMethod());
$this->assertSame('/test', $route->getPath());
$this->assertSame('test_handler', $route->getHandler());
$this->assertSame('test_route', $route->getName());
$this->assertEmpty($route->getMiddleware());
$this->assertEmpty($route->getValidation());
$this->assertEmpty($route->getDefaults());
}
public function testRouteNormalizesPath(): void
{
$config = new \Atlas\Config([
'modules_path' => ['/path/to/modules']
]);
$router = new Router($config);
$router->get('/api/test', 'handler');
$routes = $router->getRoutes();
$route = $routes[0] ?? null;
$this->assertInstanceOf(\Atlas\RouteDefinition::class, $route);
$this->assertSame('/api/test', $route->getPath());
}
}