diff --git a/.gitignore b/.gitignore index 94360a7..9961cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ composer.lock .env .phpunit.result.cache +CLAUDE.md +AGENTS.md /coverage/ .idea/ .vscode/ diff --git a/MILESTONES.md b/MILESTONES.md index cab0e69..e5a5a27 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -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.* diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..c54d01d --- /dev/null +++ b/src/Config.php @@ -0,0 +1,85 @@ +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); + } +} \ No newline at end of file diff --git a/src/MissingConfigurationException.php b/src/MissingConfigurationException.php new file mode 100644 index 0000000..9658f92 --- /dev/null +++ b/src/MissingConfigurationException.php @@ -0,0 +1,7 @@ +method; + } + + public function getPath(): string + { + return $this->path; + } + + public function getHandler(): string|callable + { + return $this->handler; + } +} \ No newline at end of file diff --git a/src/RouteDefinition.php b/src/RouteDefinition.php new file mode 100644 index 0000000..1086204 --- /dev/null +++ b/src/RouteDefinition.php @@ -0,0 +1,82 @@ +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 + ]; + } +} \ No newline at end of file diff --git a/src/RouteGroup.php b/src/RouteGroup.php new file mode 100644 index 0000000..41d9690 --- /dev/null +++ b/src/RouteGroup.php @@ -0,0 +1,70 @@ +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; + } +} \ No newline at end of file diff --git a/src/RouteNotFoundException.php b/src/RouteNotFoundException.php new file mode 100644 index 0000000..6a0a690 --- /dev/null +++ b/src/RouteNotFoundException.php @@ -0,0 +1,7 @@ +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 + ); + } + } +} \ No newline at end of file diff --git a/test_url_generation.php b/test_url_generation.php new file mode 100644 index 0000000..d9db2c7 --- /dev/null +++ b/test_url_generation.php @@ -0,0 +1,11 @@ +#!/usr/bin/env php + ['/tmp']])); + +$router->get('/users', 'Handler', 'user_list'); + +echo "Generated URL: " . $router->url('user_list') . "\n"; +echo "Expected URL: /users\n"; \ No newline at end of file diff --git a/tests/Unit/RouteMatcherTest.php b/tests/Unit/RouteMatcherTest.php new file mode 100644 index 0000000..3197870 --- /dev/null +++ b/tests/Unit/RouteMatcherTest.php @@ -0,0 +1,140 @@ + ['/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); + } +} \ No newline at end of file diff --git a/tests/Unit/RouterBasicTest.php b/tests/Unit/RouterBasicTest.php new file mode 100644 index 0000000..d601db1 --- /dev/null +++ b/tests/Unit/RouterBasicTest.php @@ -0,0 +1,89 @@ + ['/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()); + } +} \ No newline at end of file