diff --git a/src/Illuminate/Contracts/Routing/AttributeRouteController.php b/src/Illuminate/Contracts/Routing/AttributeRouteController.php new file mode 100644 index 000000000000..4d3f52d151f6 --- /dev/null +++ b/src/Illuminate/Contracts/Routing/AttributeRouteController.php @@ -0,0 +1,8 @@ +registerAttributeRoutes($router); + if (is_string($api) || is_array($api)) { if (is_array($api)) { foreach ($api as $apiRoute) { @@ -265,6 +276,66 @@ class_exists(Folio::class)) { }; } + /** + * Configure attribute-based routing. + * + * @param array|string|null $web + * @param array|string|null $api + * @return $this + */ + public function withAttributeRouting( + array|string|null $web = null, + array|string|null $api = null + ) { + $groups = []; + + if (is_null($web) && is_null($api)) { + $groups['web'] = [app_path('Http/Controllers')]; + } else { + if (! is_null($web)) { + $groups['web'] = Arr::wrap($web); + } + if (! is_null($api)) { + $groups['api'] = Arr::wrap($api); + } + } + + $this->attributeRoutingConfigurations = $groups; + + return $this; + } + + /** + * Register all the configured attribute-based routes. + * + * @param \Illuminate\Routing\Router $router + * @return void + */ + protected function registerAttributeRoutes(Router $router): void + { + if (empty($this->attributeRoutingConfigurations)) { + return; + } + + $registrar = $this->app->make(\Illuminate\Routing\AttributeRouteRegistrar::class); + + foreach ($this->attributeRoutingConfigurations as $groupName => $paths) { + if (empty($paths)) { + continue; + } + + $groupOptions = ['middleware' => $groupName]; + + if ($groupName === 'api') { + $groupOptions['prefix'] = 'api'; + } + + $router->group($groupOptions, function () use ($registrar, $paths) { + $registrar->register(...$paths); + }); + } + } + /** * Register the global middleware, middleware groups, and middleware aliases for the application. * diff --git a/src/Illuminate/Routing/AttributeRouteRegistrar.php b/src/Illuminate/Routing/AttributeRouteRegistrar.php new file mode 100644 index 000000000000..8878c7f23253 --- /dev/null +++ b/src/Illuminate/Routing/AttributeRouteRegistrar.php @@ -0,0 +1,185 @@ +psr4Paths = $this->getPsr4Paths(); + } + + /** + * Scan the given directories and register any found attribute-based routes. + * + * @param string ...$controllerDirectories + * @return void + */ + public function register(...$controllerDirectories) + { + if (empty($controllerDirectories)) { + return; + } + + $finder = (new Finder)->files()->in($controllerDirectories)->name('*.php'); + + foreach ($finder as $file) { + $className = $this->getClassFromFile($file->getRealPath()); + + if ($className && class_exists($className) && is_a($className, AttributeRouteController::class, true)) { + $this->registerControllerRoutes($className); + } + } + } + + /** + * Registers all routes for a given controller class. + * + * @param string $controllerClassName + * @return void + */ + public function registerControllerRoutes($controllerClassName) + { + $reflectionClass = new ReflectionClass($controllerClassName); + + $groupAttributes = $this->getGroupAttributes($reflectionClass) ?? []; + + $this->router->group($groupAttributes, function (Router $router) use ($reflectionClass) { + foreach ($reflectionClass->getMethods() as $method) { + $attributes = $method->getAttributes(RouteAttribute::class, \ReflectionAttribute::IS_INSTANCEOF); + + foreach ($attributes as $attribute) { + try { + $instance = $attribute->newInstance(); + $route = $router->addRoute( + $instance->methods, + $instance->path, + [$reflectionClass->getName(), $method->getName()] + ); + $this->applyRouteOptions($route, $instance); + } catch (\Throwable $e) { + report($e); + } + } + } + }); + } + + /** + * Applies all options from a RouteAttribute instance to a route. + * + * @param \Illuminate\Routing\Route $route + * @param \Illuminate\Routing\Attributes\RouteAttribute $instance + * @return void + */ + protected function applyRouteOptions(Route $route, RouteAttribute $instance): void + { + if ($instance->name) { + $route->name($instance->name); + } + if ($instance->middleware) { + $route->middleware($instance->middleware); + } + if ($instance->where) { + $route->where($instance->where); + } + + // Mark the route for the route:list command + $route->setAction(array_merge($route->getAction(), ['is_attribute_route' => true])); + } + + /** + * Gets the properties from a single #[Group] attribute on a class. + * + * @param \ReflectionClass $reflectionClass + * @return array|null + */ + protected function getGroupAttributes(ReflectionClass $reflectionClass): ?array + { + $attributes = $reflectionClass->getAttributes(Group::class); + + if (count($attributes) === 0) { + return null; + } + + try { + /** @var Group $group */ + $group = $attributes[0]->newInstance(); + + return array_filter([ + 'prefix' => $group->prefix, + 'middleware' => $group->middleware, + 'as' => $group->name, + 'where' => $group->where, + ]); + } catch (\Throwable $e) { + report($e); + + return null; + } + } + + /** + * Derive the fully qualified class name from a file path. + * + * This implementation uses the project's Composer PSR-4 map to determine + * the class name, making it compatible with any autoloaded directory. + * + * @param string $path + * @return string|null + */ + protected function getClassFromFile($path) + { + foreach ($this->psr4Paths as $namespace => $paths) { + foreach ((array) $paths as $psr4Path) { + if (Str::startsWith($path, $psr4Path)) { + $relativePath = Str::of($path) + ->after($psr4Path) + ->trim(DIRECTORY_SEPARATOR) + ->replace(['/', '.php'], ['\\', '']) + ->toString(); + + return $namespace.$relativePath; + } + } + } + + return null; + } + + /** + * Load the Composer PSR-4 autoloading map. + * + * This map is used to convert a file path into a fully qualified class name. + * + * @return array + */ + protected function getPsr4Paths() + { + $composerPath = $this->app->basePath('vendor/composer/autoload_psr4.php'); + + return file_exists($composerPath) ? require $composerPath : []; + } +} diff --git a/src/Illuminate/Routing/Attributes/Any.php b/src/Illuminate/Routing/Attributes/Any.php new file mode 100644 index 000000000000..dc662b2db1c7 --- /dev/null +++ b/src/Illuminate/Routing/Attributes/Any.php @@ -0,0 +1,24 @@ +container = Container::setInstance(new Container); + + $this->router = new Router(new Dispatcher, $this->container); + $this->container->instance('router', $this->router); + + Facade::setFacadeApplication($this->container); + + $request = Request::create('http://example.com'); + $this->container->instance('url', new UrlGenerator( + $this->router->getRoutes(), $request + )); + + $appMock = m::mock(Application::class); + $appMock->shouldReceive('basePath')->andReturn(''); + $this->container->instance(Application::class, $appMock); + + $this->registrar = new AttributeRouteRegistrar($appMock, $this->router); + + $this->registrar->registerControllerRoutes(BasicController::class); + $this->registrar->registerControllerRoutes(GroupController::class); + + $this->router->getRoutes()->refreshNameLookups(); + } + + protected function tearDown(): void + { + m::close(); + Facade::clearResolvedInstances(); + Container::setInstance(null); + parent::tearDown(); + } + + public function test_it_registers_and_accesses_a_basic_get_route(): void + { + $request = Request::create('/get', 'GET'); + $response = $this->router->dispatch($request); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('get success', $response->getContent()); + } + + public function test_it_registers_a_basic_post_route(): void + { + $request = Request::create('/post', 'POST'); + $response = $this->router->dispatch($request); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('post success', $response->getContent()); + } + + public function test_it_applies_a_name_to_a_route(): void + { + $this->assertTrue(Route::has('get')); + $this->assertEquals('http://example.com/get', route('get')); + } + + public function test_it_applies_group_prefix(): void + { + $request = Request::create('/group/route', 'GET'); + $response = $this->router->dispatch($request); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('grouped route', $response->getContent()); + } + + public function test_it_applies_group_name_prefix(): void + { + $this->assertTrue(Route::has('group.route')); + $this->assertEquals('http://example.com/group/route', route('group.route')); + } +}