diff --git a/README.md b/README.md index 19aec2b..4fd28a5 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,159 @@ several routes including one with a variable. If present, the extra element is merged into the parameters array before it is passed to the routes closure. +## Attribute-Based Routing + +Modern PHP 8+ attribute-based routing allows you to define routes directly on controller methods using PHP attributes, providing a modern alternative to YAML configuration files. + +### Basic Usage + +#### Simple Route + +```php +use Neuron\Routing\Attributes\Get; + +class HomeController +{ + #[Get('/')] + public function index() + { + return 'Hello World'; + } +} +``` + +#### HTTP Method Attributes + +```php +use Neuron\Routing\Attributes\Get; +use Neuron\Routing\Attributes\Post; +use Neuron\Routing\Attributes\Put; +use Neuron\Routing\Attributes\Delete; + +class UsersController +{ + #[Get('/users')] + public function index() { } + + #[Get('/users/:id')] + public function show(int $id) { } + + #[Post('/users')] + public function store() { } + + #[Put('/users/:id')] + public function update(int $id) { } + + #[Delete('/users/:id')] + public function destroy(int $id) { } +} +``` + +#### Route Names and Filters + +```php +#[Get('/admin/users', name: 'admin.users.index', filters: ['auth'])] +public function index() { } + +#[Post('/admin/users', name: 'admin.users.store', filters: ['auth', 'csrf'])] +public function store() { } +``` + +#### Route Groups + +Apply common settings to all routes in a controller: + +```php +use Neuron\Routing\Attributes\RouteGroup; +use Neuron\Routing\Attributes\Get; +use Neuron\Routing\Attributes\Post; + +#[RouteGroup(prefix: '/admin', filters: ['auth'])] +class AdminController +{ + #[Get('/dashboard')] // Becomes /admin/dashboard with 'auth' filter + public function dashboard() { } + + #[Post('/users', filters: ['csrf'])] // Becomes /admin/users with ['auth', 'csrf'] filters + public function createUser() { } +} +``` + +#### Multiple Routes on Same Method + +```php +#[Get('/api/v1/users')] +#[Get('/api/v2/users')] +public function getUsers() +{ + // Handle both API versions +} +``` + +### Configuration + +#### Enable Attribute Routing in MVC Application + +Add controller paths to your `config/neuron.yaml`: + +```yaml +routing: + controller_paths: + - path: 'src/Controllers' + namespace: 'App\Controllers' + - path: 'src/Admin/Controllers' + namespace: 'App\Admin\Controllers' +``` + +#### Hybrid Approach (YAML + Attributes) + +You can use both YAML routes and attribute routes together: + +- **YAML routes**: Legacy routes, package-provided routes +- **Attribute routes**: New application routes + +The MVC Application will load both automatically. + +### Benefits + +- **Co-location**: Routes live with controller logic +- **Type Safety**: IDE autocomplete and validation +- **Refactor-Friendly**: Routes update when controllers change +- **No Sync Issues**: Can't have orphaned routes +- **Modern Standard**: Used by Symfony, Laravel, ASP.NET, Spring Boot +- **Self-Documenting**: Route definition IS the documentation + +### Performance + +Route scanning uses PHP Reflection, which could be slow. For production: + +1. Routes are scanned once during application initialization +2. The Router caches RouteMap objects in memory +3. No reflection happens during request handling +4. Future: Add route caching to file for zero-cost production routing + +### Migration from YAML + +**Before (YAML):** +```yaml +# routes.yaml +home: + method: GET + route: / + controller: App\Controllers\Home@index +``` + +**After (Attributes):** +```php +class Home +{ + #[Get('/', name: 'home')] + public function index() { } +} +``` + +See `tests/unit/RouteScannerTest.php` for working examples of basic route definition, route groups with prefixes, filter composition, and multiple routes per method. + ## Rate Limiting The routing component includes a powerful rate limiting system with multiple storage backends and flexible configuration options. diff --git a/src/Routing/Attributes/Delete.php b/src/Routing/Attributes/Delete.php new file mode 100644 index 0000000..974ac93 --- /dev/null +++ b/src/Routing/Attributes/Delete.php @@ -0,0 +1,34 @@ +path; + } + + /** + * Get the HTTP method + */ + public function getMethod(): string + { + return strtoupper( $this->method ); + } + + /** + * Get the route name + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Get the filters + */ + public function getFilters(): array + { + return $this->filters; + } +} diff --git a/src/Routing/Attributes/RouteGroup.php b/src/Routing/Attributes/RouteGroup.php new file mode 100644 index 0000000..c421323 --- /dev/null +++ b/src/Routing/Attributes/RouteGroup.php @@ -0,0 +1,91 @@ +prefix; + } + + /** + * Get the group filters + */ + public function getFilters(): array + { + return $this->filters; + } + + /** + * Apply group settings to a route path + * + * @param string $routePath The route path from the method attribute + * @return string The full path with prefix applied + */ + public function applyPrefix( string $routePath ): string + { + if( $this->prefix === '' ) + { + return $routePath; + } + + // Ensure prefix starts with / and doesn't end with / + $prefix = '/' . trim( $this->prefix, '/' ); + + // Ensure route starts with / + $path = '/' . ltrim( $routePath, '/' ); + + return $prefix . $path; + } + + /** + * Merge group filters with route filters + * + * @param array $routeFilters Filters from the route attribute + * @return array Combined filters (group filters first, then route filters) + */ + public function mergeFilters( array $routeFilters ): array + { + return array_merge( $this->filters, $routeFilters ); + } +} diff --git a/src/Routing/RouteDefinition.php b/src/Routing/RouteDefinition.php new file mode 100644 index 0000000..a9b4016 --- /dev/null +++ b/src/Routing/RouteDefinition.php @@ -0,0 +1,42 @@ +controller . '@' . $this->action; + } +} diff --git a/src/Routing/RouteScanner.php b/src/Routing/RouteScanner.php new file mode 100644 index 0000000..26b65e9 --- /dev/null +++ b/src/Routing/RouteScanner.php @@ -0,0 +1,196 @@ +_scannedClasses[ $className ] ) ) + { + return $this->_scannedClasses[ $className ]; + } + + $reflection = new ReflectionClass( $className ); + $routes = []; + + // Get class-level RouteGroup attribute if present + $groupAttributes = $reflection->getAttributes( RouteGroup::class ); + $routeGroup = null; + + if( count( $groupAttributes ) > 0 ) + { + $routeGroup = $groupAttributes[0]->newInstance(); + } + + // Scan all public methods for route attributes + foreach( $reflection->getMethods( ReflectionMethod::IS_PUBLIC ) as $method ) + { + $methodRoutes = $this->scanMethod( $method, $className, $routeGroup ); + $routes = array_merge( $routes, $methodRoutes ); + } + + $this->_scannedClasses[ $className ] = $routes; + + return $routes; + } + + /** + * Scan multiple controller classes + * + * @param array $classNames Array of fully qualified class names + * @return array Array of RouteDefinition objects + * @throws \ReflectionException + */ + public function scanClasses( array $classNames ): array + { + $allRoutes = []; + + foreach( $classNames as $className ) + { + $routes = $this->scanClass( $className ); + $allRoutes = array_merge( $allRoutes, $routes ); + } + + return $allRoutes; + } + + /** + * Scan a directory for controller classes and extract routes + * + * @param string $directory Directory to scan + * @param string $namespace Base namespace for controllers + * @return array Array of RouteDefinition objects + * @throws \ReflectionException + */ + public function scanDirectory( string $directory, string $namespace ): array + { + if( !is_dir( $directory ) ) + { + return []; + } + + $classes = $this->findClassesInDirectory( $directory, $namespace ); + return $this->scanClasses( $classes ); + } + + /** + * Scan a method for route attributes + * + * @param ReflectionMethod $method Method to scan + * @param string $className Controller class name + * @param RouteGroup|null $routeGroup Optional route group from class + * @return array Array of RouteDefinition objects + */ + protected function scanMethod( + ReflectionMethod $method, + string $className, + ?RouteGroup $routeGroup + ): array + { + $routes = []; + + // Get all Route attributes (including subclasses like Get, Post, etc.) + $routeAttributes = $method->getAttributes( Route::class, \ReflectionAttribute::IS_INSTANCEOF ); + + foreach( $routeAttributes as $attribute ) + { + /** @var Route $routeAttr */ + $routeAttr = $attribute->newInstance(); + + // Apply route group settings if present + $path = $routeAttr->getPath(); + $filters = $routeAttr->getFilters(); + + if( $routeGroup ) + { + $path = $routeGroup->applyPrefix( $path ); + $filters = $routeGroup->mergeFilters( $filters ); + } + + $routes[] = new RouteDefinition( + path: $path, + method: $routeAttr->getMethod(), + controller: $className, + action: $method->getName(), + name: $routeAttr->getName(), + filters: $filters + ); + } + + return $routes; + } + + /** + * Find all PHP classes in a directory + * + * @param string $directory Directory to scan + * @param string $namespace Base namespace + * @return array Array of fully qualified class names + */ + protected function findClassesInDirectory( string $directory, string $namespace ): array + { + $classes = []; + $directory = rtrim( $directory, '/\\' ); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $directory ) + ); + + foreach( $iterator as $file ) + { + if( $file->isFile() && $file->getExtension() === 'php' ) + { + $relativePath = str_replace( + $directory . DIRECTORY_SEPARATOR, + '', + $file->getPathname() + ); + $relativePath = str_replace( '.php', '', $relativePath ); + $className = $namespace . '\\' . str_replace( DIRECTORY_SEPARATOR, '\\', $relativePath ); + + if( class_exists( $className ) ) + { + $classes[] = $className; + } + } + } + + return $classes; + } + + /** + * Clear the scanned classes cache + */ + public function clearCache(): void + { + $this->_scannedClasses = []; + } +} diff --git a/tests/unit/RouteScannerTest.php b/tests/unit/RouteScannerTest.php new file mode 100644 index 0000000..5ce4d17 --- /dev/null +++ b/tests/unit/RouteScannerTest.php @@ -0,0 +1,390 @@ +scanner = new RouteScanner(); + } + + public function testScanClassWithRouteGroup() + { + $routes = $this->scanner->scanClass( TestAdminController::class ); + + $this->assertCount( 3, $routes ); + + // Test first route + $this->assertEquals( '/admin/users', $routes[0]->path ); + $this->assertEquals( 'GET', $routes[0]->method ); + $this->assertEquals( TestAdminController::class, $routes[0]->controller ); + $this->assertEquals( 'index', $routes[0]->action ); + $this->assertEquals( ['auth'], $routes[0]->filters ); + + // Test second route - filters should merge + $this->assertEquals( '/admin/users', $routes[1]->path ); + $this->assertEquals( 'POST', $routes[1]->method ); + $this->assertEquals( ['auth', 'csrf'], $routes[1]->filters ); + + // Test third route - with name + $this->assertEquals( '/admin/users/:id', $routes[2]->path ); + $this->assertEquals( 'admin.users.show', $routes[2]->name ); + } + + public function testScanClassWithoutRouteGroup() + { + $routes = $this->scanner->scanClass( TestSimpleController::class ); + + $this->assertCount( 2, $routes ); + + $this->assertEquals( '/home', $routes[0]->path ); + $this->assertEquals( 'GET', $routes[0]->method ); + $this->assertEquals( [], $routes[0]->filters ); + + $this->assertEquals( '/contact', $routes[1]->path ); + $this->assertEquals( 'POST', $routes[1]->method ); + $this->assertEquals( ['csrf'], $routes[1]->filters ); + } + + public function testScanClassWithMultipleRoutesOnSameMethod() + { + $routes = $this->scanner->scanClass( TestMultiRouteController::class ); + + $this->assertCount( 2, $routes ); + + $this->assertEquals( '/api/v1/users', $routes[0]->path ); + $this->assertEquals( '/api/v2/users', $routes[1]->path ); + $this->assertEquals( 'users', $routes[0]->action ); + $this->assertEquals( 'users', $routes[1]->action ); + } + + public function testScanNonExistentClass() + { + $routes = $this->scanner->scanClass( 'NonExistentClass' ); + $this->assertEmpty( $routes ); + } + + public function testScanClassesCachesResults() + { + // First scan + $routes1 = $this->scanner->scanClass( TestSimpleController::class ); + + // Second scan should return cached results + $routes2 = $this->scanner->scanClass( TestSimpleController::class ); + + $this->assertSame( $routes1, $routes2 ); + } + + public function testClearCache() + { + $this->scanner->scanClass( TestSimpleController::class ); + $this->scanner->clearCache(); + + // After clearing cache, scanning again should create new instances + $routes = $this->scanner->scanClass( TestSimpleController::class ); + $this->assertCount( 2, $routes ); + } + + public function testGetControllerMethod() + { + $routes = $this->scanner->scanClass( TestSimpleController::class ); + $this->assertEquals( TestSimpleController::class . '@home', $routes[0]->getControllerMethod() ); + } + + public function testScanMultipleClasses() + { + $routes = $this->scanner->scanClasses([ + TestAdminController::class, + TestSimpleController::class + ]); + + $this->assertCount( 5, $routes ); // 3 from admin + 2 from simple + } + + public function testScanRestfulController() + { + $routes = $this->scanner->scanClass( TestRestfulController::class ); + + $this->assertCount( 5, $routes ); + + // Test GET routes + $this->assertEquals( '/posts', $routes[0]->path ); + $this->assertEquals( 'GET', $routes[0]->method ); + + $this->assertEquals( '/posts/:id', $routes[1]->path ); + $this->assertEquals( 'GET', $routes[1]->method ); + + // Test POST route + $this->assertEquals( '/posts', $routes[2]->path ); + $this->assertEquals( 'POST', $routes[2]->method ); + + // Test PUT route + $this->assertEquals( '/posts/:id', $routes[3]->path ); + $this->assertEquals( 'PUT', $routes[3]->method ); + $this->assertEquals( ['auth'], $routes[3]->filters ); + + // Test DELETE route + $this->assertEquals( '/posts/:id', $routes[4]->path ); + $this->assertEquals( 'DELETE', $routes[4]->method ); + $this->assertEquals( 'posts.delete', $routes[4]->name ); + $this->assertEquals( ['auth', 'csrf'], $routes[4]->filters ); + } + + public function testRouteGroupApplyPrefix() + { + $group = new RouteGroup( prefix: '/admin' ); + + $this->assertEquals( '/admin/users', $group->applyPrefix( '/users' ) ); + $this->assertEquals( '/admin/users', $group->applyPrefix( 'users' ) ); + } + + public function testRouteGroupApplyPrefixWithoutPrefix() + { + $group = new RouteGroup(); + + $this->assertEquals( '/users', $group->applyPrefix( '/users' ) ); + } + + public function testRouteGroupMergeFilters() + { + $group = new RouteGroup( prefix: '/admin', filters: ['auth', 'admin'] ); + + $merged = $group->mergeFilters( ['csrf'] ); + + $this->assertEquals( ['auth', 'admin', 'csrf'], $merged ); + } + + public function testRouteGroupGetters() + { + $group = new RouteGroup( prefix: '/api', filters: ['api-key'] ); + + $this->assertEquals( '/api', $group->getPrefix() ); + $this->assertEquals( ['api-key'], $group->getFilters() ); + } + + public function testScanNonExistentDirectory() + { + $routes = $this->scanner->scanDirectory( '/nonexistent/path', 'App\\Controllers' ); + $this->assertEmpty( $routes ); + } + + public function testScanDirectoryWithControllers() + { + // Create a temporary directory with a test controller + $tempDir = sys_get_temp_dir() . '/neuron_scanner_test_' . uniqid(); + mkdir( $tempDir, 0777, true ); + + // Create a test controller file + $controllerCode = <<<'PHP' +scanner->scanDirectory( $tempDir, 'TempTest' ); + + $this->assertCount( 2, $routes ); + $this->assertEquals( '/temp/index', $routes[0]->path ); + $this->assertEquals( 'GET', $routes[0]->method ); + $this->assertEquals( '/temp/store', $routes[1]->path ); + $this->assertEquals( 'POST', $routes[1]->method ); + + // Clean up + unlink( $tempDir . '/TempController.php' ); + rmdir( $tempDir ); + } + + public function testScanDirectoryWithNonControllerFiles() + { + // Create a temporary directory with non-controller PHP files + $tempDir = sys_get_temp_dir() . '/neuron_scanner_noncontroller_' . uniqid(); + mkdir( $tempDir, 0777, true ); + + // Create a PHP file without a class + file_put_contents( $tempDir . '/config.php', ' "value"];' ); + + // Create a non-PHP file + file_put_contents( $tempDir . '/readme.txt', 'This is a readme file' ); + + // Scan the directory - should return empty array + $routes = $this->scanner->scanDirectory( $tempDir, 'TempTest' ); + + $this->assertEmpty( $routes ); + + // Clean up + unlink( $tempDir . '/config.php' ); + unlink( $tempDir . '/readme.txt' ); + rmdir( $tempDir ); + } + + public function testScanDirectoryWithNestedStructure() + { + // Create a temporary directory with nested controllers + $tempDir = sys_get_temp_dir() . '/neuron_scanner_nested_' . uniqid(); + mkdir( $tempDir . '/Admin', 0777, true ); + + // Create a controller in a subdirectory + $controllerCode = <<<'PHP' +scanner->scanDirectory( $tempDir, 'TempNested' ); + + $this->assertCount( 1, $routes ); + $this->assertEquals( '/admin/dashboard', $routes[0]->path ); + $this->assertEquals( 'TempNested\Admin\AdminController', $routes[0]->controller ); + + // Clean up + unlink( $tempDir . '/Admin/AdminController.php' ); + rmdir( $tempDir . '/Admin' ); + rmdir( $tempDir ); + } + + public function testFindClassesInDirectorySkipsNonExistentClasses() + { + // Create a temporary directory with a PHP file that doesn't define the expected class + $tempDir = sys_get_temp_dir() . '/neuron_scanner_invalid_' . uniqid(); + mkdir( $tempDir, 0777, true ); + + // Create a PHP file with a class that doesn't match the namespace pattern + $invalidCode = <<<'PHP' +scanner->scanDirectory( $tempDir, 'ExpectedNamespace' ); + + $this->assertEmpty( $routes ); + + // Clean up + unlink( $tempDir . '/TestFile.php' ); + rmdir( $tempDir ); + } + + public function testScanClassWithNoRouteAttributes() + { + // Create a class with no route attributes + eval(' + class NoRoutesController { + public function index() {} + } + '); + + $routes = $this->scanner->scanClass( 'NoRoutesController' ); + $this->assertEmpty( $routes ); + } + + public function testRouteGroupWithEmptyPrefix() + { + $group = new RouteGroup( prefix: '', filters: [] ); + + $path = $group->applyPrefix( '/users' ); + $this->assertEquals( '/users', $path ); + } + + public function testRouteGroupPrefixNormalization() + { + // Test that prefixes are normalized correctly + $group = new RouteGroup( prefix: 'admin/' ); // Has trailing slash + + $this->assertEquals( '/admin/users', $group->applyPrefix( '/users' ) ); + $this->assertEquals( '/admin/users', $group->applyPrefix( 'users' ) ); + } +}