diff --git a/src/Config/Config.php b/src/Config/Config.php index 5bb6375..b49a63c 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -22,7 +22,7 @@ class Config implements ArrayAccess, IteratorAggregate * @param array $options Configuration array containing routing settings */ public function __construct( - private readonly array $options + private array $options ) {} /** diff --git a/src/Router/RouteGroup.php b/src/Router/RouteGroup.php index a4ec579..0176886 100644 --- a/src/Router/RouteGroup.php +++ b/src/Router/RouteGroup.php @@ -9,6 +9,8 @@ namespace Atlas\Router; */ class RouteGroup { + use PathHelper; + /** * Constructs a new RouteGroup instance. * @@ -34,74 +36,131 @@ class RouteGroup return $self; } - /** - * Registers a GET route with group prefix. - * - * @param string $path URI path - * @param string|callable $handler Route handler - * @param string|null $name Optional route name - * @return self Fluent interface - */ - public function get(string $path, string|callable $handler, string|null $name = null): self + public function get(string $path, mixed $handler, string|null $name = null): RouteDefinition { $fullPath = $this->buildFullPath($path); - return $this->router ? $this->router->get($fullPath, $handler, $name) : $this; + $middleware = $this->options['middleware'] ?? []; + $validation = $this->options['validation'] ?? []; + $defaults = $this->options['defaults'] ?? []; + + if ($this->router) { + return $this->router->registerCustomRoute('GET', $fullPath, $handler, $name, $middleware, $validation, $defaults); + } + + return new RouteDefinition('GET', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults); } - /** - * Registers a POST route with group prefix. - * - * @param string $path URI path - * @param string|callable $handler Route handler - * @param string|null $name Optional route name - * @return self Fluent interface - */ - public function post(string $path, string|callable $handler, string|null $name = null): self + public function post(string $path, mixed $handler, string|null $name = null): RouteDefinition { $fullPath = $this->buildFullPath($path); - return $this->router ? $this->router->post($fullPath, $handler, $name) : $this; + $middleware = $this->options['middleware'] ?? []; + $validation = $this->options['validation'] ?? []; + $defaults = $this->options['defaults'] ?? []; + + if ($this->router) { + return $this->router->registerCustomRoute('POST', $fullPath, $handler, $name, $middleware, $validation, $defaults); + } + + return new RouteDefinition('POST', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults); } - /** - * Registers a PUT route with group prefix. - * - * @param string $path URI path - * @param string|callable $handler Route handler - * @param string|null $name Optional route name - * @return self Fluent interface - */ - public function put(string $path, string|callable $handler, string|null $name = null): self + public function put(string $path, mixed $handler, string|null $name = null): RouteDefinition { $fullPath = $this->buildFullPath($path); - return $this->router ? $this->router->put($fullPath, $handler, $name) : $this; + $middleware = $this->options['middleware'] ?? []; + $validation = $this->options['validation'] ?? []; + $defaults = $this->options['defaults'] ?? []; + + if ($this->router) { + return $this->router->registerCustomRoute('PUT', $fullPath, $handler, $name, $middleware, $validation, $defaults); + } + + return new RouteDefinition('PUT', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults); } - /** - * Registers a PATCH route with group prefix. - * - * @param string $path URI path - * @param string|callable $handler Route handler - * @param string|null $name Optional route name - * @return self Fluent interface - */ - public function patch(string $path, string|callable $handler, string|null $name = null): self + public function patch(string $path, mixed $handler, string|null $name = null): RouteDefinition { $fullPath = $this->buildFullPath($path); - return $this->router ? $this->router->patch($fullPath, $handler, $name) : $this; + $middleware = $this->options['middleware'] ?? []; + $validation = $this->options['validation'] ?? []; + $defaults = $this->options['defaults'] ?? []; + + if ($this->router) { + return $this->router->registerCustomRoute('PATCH', $fullPath, $handler, $name, $middleware, $validation, $defaults); + } + + return new RouteDefinition('PATCH', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults); } - /** - * Registers a DELETE route with group prefix. - * - * @param string $path URI path - * @param string|callable $handler Route handler - * @param string|null $name Optional route name - * @return self Fluent interface - */ - public function delete(string $path, string|callable $handler, string|null $name = null): self + public function delete(string $path, mixed $handler, string|null $name = null): RouteDefinition { $fullPath = $this->buildFullPath($path); - return $this->router ? $this->router->delete($fullPath, $handler, $name) : $this; + $middleware = $this->options['middleware'] ?? []; + $validation = $this->options['validation'] ?? []; + $defaults = $this->options['defaults'] ?? []; + + if ($this->router) { + return $this->router->registerCustomRoute('DELETE', $fullPath, $handler, $name, $middleware, $validation, $defaults); + } + + return new RouteDefinition('DELETE', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults); + } + + public function redirect(string $path, string $destination, int $status = 302): RouteDefinition + { + $fullPath = $this->buildFullPath($path); + $middleware = $this->options['middleware'] ?? []; + $validation = $this->options['validation'] ?? []; + $defaults = $this->options['defaults'] ?? []; + + if ($this->router) { + return $this->router->registerCustomRoute('REDIRECT', $fullPath, $destination, null, $middleware, $validation, $defaults)->attr('status', $status); + } + + return (new RouteDefinition('REDIRECT', $fullPath, $fullPath, $destination, null, $middleware, $validation, $defaults))->attr('status', $status); + } + + public function fallback(mixed $handler): self + { + $this->options['fallback'] = $handler; + + $prefix = $this->options['prefix'] ?? '/'; + $middleware = $this->options['middleware'] ?? []; + + if ($this->router) { + $this->router->registerCustomRoute('FALLBACK', $this->joinPaths($prefix, '/_fallback'), $handler, null, $middleware) + ->attr('_fallback', $handler) + ->attr('_fallback_prefix', $this->normalizePath($prefix)); + } + + return $this; + } + + public function registerCustomRoute(string $method, string $path, mixed $handler, string|null $name = null, array $middleware = [], array $validation = [], array $defaults = []): RouteDefinition + { + $fullPath = $this->buildFullPath($path); + $mergedMiddleware = array_merge($this->options['middleware'] ?? [], $middleware); + + $route = null; + if ($this->router) { + $route = $this->router->registerCustomRoute($method, $fullPath, $handler, $name, $mergedMiddleware); + } else { + $route = new RouteDefinition($method, $fullPath, $fullPath, $handler, $name, $mergedMiddleware); + } + + // Apply group-level validation and defaults + $route->valid($this->options['validation'] ?? []); + foreach ($this->options['defaults'] ?? [] as $p => $v) { + $route->default($p, $v); + } + + // Apply route-level validation and defaults + $route->valid($validation); + foreach ($defaults as $p => $v) { + $route->default($p, $v); + } + + return $route; } /** @@ -112,13 +171,72 @@ class RouteGroup */ private function buildFullPath(string $path): string { - $prefix = $this->options['prefix'] ?? ''; + return $this->joinPaths($this->options['prefix'] ?? '', $path); + } - if (empty($prefix)) { - return $path; + public function group(array $options): RouteGroup + { + $prefix = $this->options['prefix'] ?? ''; + $newPrefix = $this->joinPaths($prefix, $options['prefix'] ?? ''); + + $middleware = $this->options['middleware'] ?? []; + $newMiddleware = array_merge($middleware, $options['middleware'] ?? []); + + $validation = $this->options['validation'] ?? []; + $newValidation = array_merge($validation, $options['validation'] ?? []); + + $defaults = $this->options['defaults'] ?? []; + $newDefaults = array_merge($defaults, $options['defaults'] ?? []); + + $mergedOptions = array_merge($this->options, $options); + $mergedOptions['prefix'] = $newPrefix; + $mergedOptions['middleware'] = $newMiddleware; + $mergedOptions['validation'] = $newValidation; + $mergedOptions['defaults'] = $newDefaults; + + return new RouteGroup($mergedOptions, $this->router); + } + + /** + * Sets validation rules for parameters at the group level. + * + * @param array|string $param Parameter name or array of rules + * @param array|string $rules Rules if first param is string + * @return self + */ + public function valid(array|string $param, array|string $rules = []): self + { + if (!isset($this->options['validation'])) { + $this->options['validation'] = []; } - return rtrim($prefix, '/') . '/' . ltrim($path, '/'); + if (is_array($param)) { + foreach ($param as $p => $r) { + $this->valid($p, $r); + } + } else { + $this->options['validation'][$param] = is_string($rules) ? [$rules] : $rules; + } + + return $this; + } + + /** + * Sets a default value for a parameter at the group level. + * + * @param string $param + * @param mixed $value + * @return self + */ + public function default(string $param, mixed $value): self + { + if (!isset($this->options['defaults'])) { + $this->options['defaults'] = []; + } + + $this->options['defaults'][$param] = $value; + + return $this; } /** @@ -139,8 +257,33 @@ class RouteGroup * * @return array Group options configuration */ + public function module(string|array $identifier, string|null $prefix = null): self + { + if ($this->router) { + // We need to pass the group context to the module loading. + // But ModuleLoader uses the router directly. + // If we use $this->router->module(), it won't have the group prefix/middleware. + // We should probably allow ModuleLoader to take a "target" which can be a Router or RouteGroup. + + // For now, let's just use the router but we have a problem: inheritance. + // A better way is to make RouteGroup have a way to load modules. + + $moduleLoader = new ModuleLoader($this->router->getConfig(), $this); + $moduleLoader->load($identifier, $prefix); + } + return $this; + } + public function getOptions(): array { return $this->options; } + + /** + * @internal + */ + public function getConfig(): Config + { + return $this->router->getConfig(); + } } \ No newline at end of file diff --git a/tests/Integration/ModuleLoadingTest.php b/tests/Integration/ModuleLoadingTest.php new file mode 100644 index 0000000..0b2e9e9 --- /dev/null +++ b/tests/Integration/ModuleLoadingTest.php @@ -0,0 +1,112 @@ +tempDir = sys_get_temp_dir() . '/atlas_test_' . uniqid(); + mkdir($this->tempDir); + mkdir($this->tempDir . '/User'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + private function removeDirectory(string $path): void + { + $files = array_diff(scandir($path), ['.', '..']); + foreach ($files as $file) { + (is_dir("$path/$file")) ? $this->removeDirectory("$path/$file") : unlink("$path/$file"); + } + rmdir($path); + } + + public function testModuleLoadsRoutesFromFilesystem(): void + { + $routesContent = ' "GET", "path" => "/profile", "handler" => "UserHandler", "name" => "user_profile"]];'; + file_put_contents($this->tempDir . '/User/routes.php', $routesContent); + + $config = new Config([ + 'modules_path' => [$this->tempDir], + 'routes_file' => 'routes.php' + ]); + + $router = new Router($config); + $router->module('User'); + + $routes = iterator_to_array($router->getRoutes()); + $this->assertCount(1, $routes); + $this->assertSame('/profile', $routes[0]->getPath()); + $this->assertSame('user_profile', $routes[0]->getName()); + } + + public function testModuleThrowsExceptionWhenModulesPathIsMissing(): void + { + $config = new Config([]); + $router = new Router($config); + + $this->expectException(MissingConfigurationException::class); + $router->module('User'); + } + + public function testModuleSkipsWhenRoutesFileDoesNotExist(): void + { + $config = new Config([ + 'modules_path' => [$this->tempDir] + ]); + + $router = new Router($config); + $router->module('NonExistent'); + + $this->assertCount(0, $router->getRoutes()); + } + + public function testModuleWithMultipleSearchPaths(): void + { + $secondPath = sys_get_temp_dir() . '/atlas_test_2_' . uniqid(); + mkdir($secondPath); + mkdir($secondPath . '/Shared'); + + $routesContent = ' "GET", "path" => "/shared", "handler" => "SharedHandler"]];'; + file_put_contents($secondPath . '/Shared/routes.php', $routesContent); + + $config = new Config([ + 'modules_path' => [$this->tempDir, $secondPath] + ]); + + $router = new Router($config); + $router->module('Shared'); + + $routes = iterator_to_array($router->getRoutes()); + $this->assertCount(1, $routes); + $this->assertSame('/shared', $routes[0]->getPath()); + + $this->removeDirectory($secondPath); + } + + public function testModuleLoadsMultipleRoutes(): void + { + $routesContent = ' "GET", "path" => "/u1", "handler" => "h1"], + ["method" => "POST", "path" => "/u2", "handler" => "h2"] + ];'; + file_put_contents($this->tempDir . '/User/routes.php', $routesContent); + + $config = new Config(['modules_path' => $this->tempDir]); + $router = new Router($config); + $router->module('User'); + + $this->assertCount(2, $router->getRoutes()); + } +} diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php new file mode 100644 index 0000000..588a5d5 --- /dev/null +++ b/tests/Unit/ConfigTest.php @@ -0,0 +1,103 @@ + 'value']); + + $this->assertSame('value', $config->get('key')); + $this->assertSame('default', $config->get('non_existent', 'default')); + $this->assertNull($config->get('non_existent')); + } + + public function testHasChecksExistence(): void + { + $config = new Config(['key' => 'value']); + + $this->assertTrue($config->has('key')); + $this->assertFalse($config->has('non_existent')); + } + + public function testGetModulesPathNormalizesToArray(): void + { + $config1 = new Config(['modules_path' => '/single/path']); + $this->assertSame(['/single/path'], $config1->getModulesPath()); + + $config2 = new Config(['modules_path' => ['/path/1', '/path/2']]); + $this->assertSame(['/path/1', '/path/2'], $config2->getModulesPath()); + + $config3 = new Config([]); + $this->assertNull($config3->getModulesPath()); + } + + public function testGetModulesPathListAlwaysReturnsArray(): void + { + $config1 = new Config(['modules_path' => '/single/path']); + $this->assertSame(['/single/path'], $config1->getModulesPathList()); + + $config2 = new Config(['modules_path' => ['/path/1', '/path/2']]); + $this->assertSame(['/path/1', '/path/2'], $config2->getModulesPathList()); + + $config3 = new Config([]); + $this->assertSame([], $config3->getModulesPathList()); + } + + public function testGetRoutesFileWithDefault(): void + { + $config1 = new Config(['routes_file' => 'custom.php']); + $this->assertSame('custom.php', $config1->getRoutesFile()); + + $config2 = new Config([]); + $this->assertSame('routes.php', $config2->getRoutesFile()); + } + + public function testGetModulesGlob(): void + { + $config1 = new Config(['modules_glob' => 'src/*/routes.php']); + $this->assertSame('src/*/routes.php', $config1->getModulesGlob()); + + $config2 = new Config([]); + $this->assertNull($config2->getModulesGlob()); + } + + public function testToArray(): void + { + $options = ['a' => 1, 'b' => 2]; + $config = new Config($options); + + $this->assertSame($options, $config->toArray()); + } + + public function testArrayAccess(): void + { + $config = new Config(['key' => 'value']); + + $this->assertTrue(isset($config['key'])); + $this->assertSame('value', $config['key']); + + $config['new'] = 'val'; + $this->assertSame('val', $config['new']); + + unset($config['key']); + $this->assertFalse(isset($config['key'])); + } + + public function testIteratorAggregate(): void + { + $options = ['a' => 1, 'b' => 2]; + $config = new Config($options); + + $result = []; + foreach ($config as $key => $value) { + $result[$key] = $value; + } + + $this->assertSame($options, $result); + } +} diff --git a/tests/Unit/RouteGroupTest.php b/tests/Unit/RouteGroupTest.php new file mode 100644 index 0000000..21137c5 --- /dev/null +++ b/tests/Unit/RouteGroupTest.php @@ -0,0 +1,99 @@ + ['/path/to/modules']]); + $this->router = new Router($config); + } + + public function testGroupAppliesPrefixToRoutes(): void + { + $group = $this->router->group(['prefix' => '/api']); + + $group->get('/users', 'Handler'); + + $routes = iterator_to_array($this->router->getRoutes()); + $this->assertCount(1, $routes); + $this->assertSame('/api/users', $routes[0]->getPath()); + } + + public function testGroupAppliesPrefixWithLeadingSlashToRoutes(): void + { + $group = $this->router->group(['prefix' => 'api']); + + $group->get('users', 'Handler'); + + $routes = iterator_to_array($this->router->getRoutes()); + $this->assertCount(1, $routes); + $this->assertSame('/api/users', $routes[0]->getPath()); + } + + public function testGroupWithTrailingSlashInPrefix(): void + { + $group = $this->router->group(['prefix' => '/api/']); + + $group->get('/users', 'Handler'); + + $routes = iterator_to_array($this->router->getRoutes()); + $this->assertCount(1, $routes); + $this->assertSame('/api/users', $routes[0]->getPath()); + } + + public function testAllHttpMethodsInGroup(): void + { + $group = $this->router->group(['prefix' => '/api']); + + $group->get('/test', 'handler'); + $group->post('/test', 'handler'); + $group->put('/test', 'handler'); + $group->patch('/test', 'handler'); + $group->delete('/test', 'handler'); + + $routes = iterator_to_array($this->router->getRoutes()); + $this->assertCount(5, $routes); + + foreach ($routes as $route) { + $this->assertSame('/api/test', $route->getPath()); + } + } + + public function testGroupReturnsSameInstanceForChaining(): void + { + $group = $this->router->group(['prefix' => '/api']); + + $group->get('/users', 'Handler'); + $group->post('/users', 'Handler'); + + $routes = iterator_to_array($this->router->getRoutes()); + $this->assertCount(2, $routes); + } + + public function testGroupCanBeCreatedWithoutRouterAndStillWorks(): void + { + // This tests the case where RouteGroup might be used partially or in isolation + // although buildFullPath is the main logic. + $group = new RouteGroup(['prefix' => '/api']); + + // Use reflection or just check options if public (it is protected/private) + $this->assertSame(['prefix' => '/api'], $group->getOptions()); + } + + public function testSetOptionOnGroup(): void + { + $group = new RouteGroup(); + $group->setOption('prefix', '/test'); + + $this->assertSame(['prefix' => '/test'], $group->getOptions()); + } +} diff --git a/tests/Unit/RouterFallbackTest.php b/tests/Unit/RouterFallbackTest.php new file mode 100644 index 0000000..8ff2f64 --- /dev/null +++ b/tests/Unit/RouterFallbackTest.php @@ -0,0 +1,36 @@ + ['/path/to/modules']]); + $router = new Router($config); + + $handler = function() { return '404'; }; + $router->fallback($handler); + + // Use reflection to check if fallbackHandler is set correctly since there is no getter + $reflection = new \ReflectionClass($router); + $property = $reflection->getProperty('fallbackHandler'); + $property->setAccessible(true); + + $this->assertSame($handler, $property->getValue($router)); + } + + public function testFallbackReturnsRouterInstanceForChaining(): void + { + $config = new Config(['modules_path' => ['/path/to/modules']]); + $router = new Router($config); + + $result = $router->fallback('Handler'); + + $this->assertSame($router, $result); + } +} diff --git a/tests/Unit/RouterUrlTest.php b/tests/Unit/RouterUrlTest.php new file mode 100644 index 0000000..c4d3f32 --- /dev/null +++ b/tests/Unit/RouterUrlTest.php @@ -0,0 +1,67 @@ + ['/path/to/modules']]); + $this->router = new Router($config); + } + + public function testUrlGeneratesForStaticRoute(): void + { + $this->router->get('/users', 'handler', 'user_list'); + + $url = $this->router->url('user_list'); + + $this->assertSame('/users', $url); + } + + public function testUrlGeneratesWithParameters(): void + { + $this->router->get('/users/{{user_id}}', 'handler', 'user_detail'); + + $url = $this->router->url('user_detail', ['user_id' => 42]); + + $this->assertSame('/users/42', $url); + } + + public function testUrlGeneratesWithMultipleParameters(): void + { + $this->router->get('/posts/{{post_id}}/comments/{{comment_id}}', 'handler', 'comment_detail'); + + $url = $this->router->url('comment_detail', [ + 'post_id' => 10, + 'comment_id' => 5 + ]); + + $this->assertSame('/posts/10/comments/5', $url); + } + + public function testUrlThrowsExceptionWhenRouteNotFound(): void + { + $this->expectException(RouteNotFoundException::class); + $this->expectExceptionMessage('Route "non_existent" not found'); + + $this->router->url('non_existent'); + } + + public function testUrlWithMissingParametersThrowsException(): void + { + $this->router->get('/users/{{user_id}}', 'handler', 'user_detail'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Missing required parameter "user_id"'); + + $this->router->url('user_detail', []); + } +}