From 7a7dd81a34e0fad0380dda160e64a7c544414408 Mon Sep 17 00:00:00 2001 From: Caio Fernandes Date: Thu, 8 Oct 2026 22:53:26 -0300 Subject: [PATCH] feat(callable): resolve instance-method array callables via container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Application::resolveHandler() resolves [Class::class, 'métodoDeInstância'] through the DI container; falls back to lazy instantiation otherwise. - CallableResolver now returns a lazy closure for instance methods referenced by class name (consistent with core-routing). - Bump pivotphp/core-routing to ^1.2 (lazy callable + brace params support). - Update routing syntax documentation. Refs: SPEC-003 (PivotPHP/pivotphp-core#17) --- composer.json | 2 +- docs/technical/routing/SYNTAX_GUIDE.md | 15 +++++ src/Core/Application.php | 44 ++++++++++++-- src/Utils/CallableResolver.php | 24 +++++--- .../ArrayCallableContainerResolutionTest.php | 60 +++++++++++++++++++ .../Routing/DiGreetingController.php | 25 ++++++++ tests/Utils/CallableResolverTest.php | 7 ++- 7 files changed, 161 insertions(+), 16 deletions(-) create mode 100644 tests/Integration/Routing/ArrayCallableContainerResolutionTest.php create mode 100644 tests/Integration/Routing/DiGreetingController.php diff --git a/composer.json b/composer.json index 8893746..c0d6e5e 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "psr/log": "^3.0", "psr/cache": "^2.0|^3.0", "psr/simple-cache": "^2.0|^3.0", - "pivotphp/core-routing": "^1.0" + "pivotphp/core-routing": "^1.2" }, "require-dev": { "phpunit/phpunit": "^9.0|^10.0", diff --git a/docs/technical/routing/SYNTAX_GUIDE.md b/docs/technical/routing/SYNTAX_GUIDE.md index 4a2fdae..69a9698 100644 --- a/docs/technical/routing/SYNTAX_GUIDE.md +++ b/docs/technical/routing/SYNTAX_GUIDE.md @@ -94,6 +94,21 @@ $app->get('/status', [HealthController::class, 'getStatus']); $app->get('/info', [ApiController::class, 'getInfo']); ``` +> **✅ Método de instância via nome de classe**: também é possível referenciar um +> método de instância pelo nome da classe (`[UserController::class, 'index']`). +> A instância é resolvida pelo container de DI quando o controller está registrado +> nele; caso contrário, a classe é instanciada de forma lazy (construtor sem +> argumentos obrigatórios). + +```php +// Controller registrado no container (DI) +$app->singleton(UserController::class, fn($c) => new UserController($c->get(UserRepository::class))); +$app->get('/users', [UserController::class, 'index']); // DI via container + +// Fallback: instanciação lazy (construtor sem argumentos) +$app->get('/health', [HealthController::class, 'healthCheck']); +``` + #### Vantagens dos Array Callables - **Organização**: Código organizado em classes e métodos diff --git a/src/Core/Application.php b/src/Core/Application.php index cfd457c..8eeb83f 100644 --- a/src/Core/Application.php +++ b/src/Core/Application.php @@ -515,6 +515,40 @@ public function getMiddleware(string $name): mixed return null; } + /** + * Resolve um handler de rota em formato array para a forma executável, + * resolvendo métodos de instância via container (DI) quando disponível. + * + * @param callable|array $handler Handler original + * @return callable|array + */ + private function resolveHandler(callable|array $handler): callable|array + { + if (!is_array($handler) || count($handler) !== 2) { + return $handler; + } + + $class = $handler[0] ?? null; + $method = $handler[1] ?? null; + + if (!is_string($class) || !is_string($method) || !class_exists($class) || !method_exists($class, $method)) { + return $handler; + } + + if ((new \ReflectionMethod($class, $method))->isStatic()) { + return $handler; + } + + if ($this->container->has($class)) { + /** @var object $instance */ + $instance = $this->container->get($class); + + return [$instance, $method]; + } + + return $handler; + } + /** * Registra uma rota GET. * @@ -524,7 +558,7 @@ public function getMiddleware(string $name): mixed */ public function get(string $path, callable|array $handler): self { - $this->router->get($path, $handler); + $this->router->get($path, $this->resolveHandler($handler)); return $this; } @@ -537,7 +571,7 @@ public function get(string $path, callable|array $handler): self */ public function post(string $path, callable|array $handler): self { - $this->router->post($path, $handler); + $this->router->post($path, $this->resolveHandler($handler)); return $this; } @@ -550,7 +584,7 @@ public function post(string $path, callable|array $handler): self */ public function put(string $path, callable|array $handler): self { - $this->router->put($path, $handler); + $this->router->put($path, $this->resolveHandler($handler)); return $this; } @@ -563,7 +597,7 @@ public function put(string $path, callable|array $handler): self */ public function delete(string $path, callable|array $handler): self { - $this->router->delete($path, $handler); + $this->router->delete($path, $this->resolveHandler($handler)); return $this; } @@ -576,7 +610,7 @@ public function delete(string $path, callable|array $handler): self */ public function patch(string $path, callable|array $handler): self { - $this->router->patch($path, $handler); + $this->router->patch($path, $this->resolveHandler($handler)); return $this; } diff --git a/src/Utils/CallableResolver.php b/src/Utils/CallableResolver.php index aca61e4..bbd4fb4 100644 --- a/src/Utils/CallableResolver.php +++ b/src/Utils/CallableResolver.php @@ -81,16 +81,26 @@ private static function resolveArrayCallable(array $handler): callable ); } - // Verificar se o método é realmente estático + // Verificar se o método é estático ou de instância $reflection = new \ReflectionMethod($objectOrClass, $method); - if (!$reflection->isStatic()) { - throw new InvalidArgumentException( - "Method '{$objectOrClass}::{$method}' is not static. Use an instance instead." - ); + + if ($reflection->isStatic()) { + /** @var callable */ + return [$objectOrClass, $method]; } - /** @var callable */ - return [$objectOrClass, $method]; + // Método de instância referenciado pelo nome da classe: resolve de + // forma lazy (instancia na chamada). Quando a aplicação usa o + // container de DI, Application::resolveHandler() já converte para + // [$instance, 'method'] antes de chegar aqui. + return static function (...$args) use ($objectOrClass, $method) { + $instance = new $objectOrClass(); + + /** @var callable $handler */ + $handler = [$instance, $method]; + + return $handler(...$args); + }; } // Caso 2: Método de instância [$instance, 'method'] diff --git a/tests/Integration/Routing/ArrayCallableContainerResolutionTest.php b/tests/Integration/Routing/ArrayCallableContainerResolutionTest.php new file mode 100644 index 0000000..65cf36e --- /dev/null +++ b/tests/Integration/Routing/ArrayCallableContainerResolutionTest.php @@ -0,0 +1,60 @@ +instance(DiGreetingController::class, new DiGreetingController('di:')); + + $app->get('/di-greet', [DiGreetingController::class, 'greet']); + + $response = $app->handle(new Request('GET', '/di-greet', '/di-greet')); + + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode($response->getBodyAsString(), true); + $this->assertSame('di: hello', $body['greeting']); + } + + public function testNonBoundControllerFallsBackToLazyInstantiation(): void + { + $app = new Application(__DIR__ . '/../../..'); + + // HealthController tem construtor sem argumentos → instanciação lazy funciona. + $app->get('/lazy-health', [HealthController::class, 'healthCheck']); + + $response = $app->handle(new Request('GET', '/lazy-health', '/lazy-health')); + + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode($response->getBodyAsString(), true); + $this->assertSame('ok', $body['status']); + } +} diff --git a/tests/Integration/Routing/DiGreetingController.php b/tests/Integration/Routing/DiGreetingController.php new file mode 100644 index 0000000..a30ab02 --- /dev/null +++ b/tests/Integration/Routing/DiGreetingController.php @@ -0,0 +1,25 @@ +json(['greeting' => $this->prefix . ' hello']); + } +} diff --git a/tests/Utils/CallableResolverTest.php b/tests/Utils/CallableResolverTest.php index 0faf2fd..4eafc2b 100644 --- a/tests/Utils/CallableResolverTest.php +++ b/tests/Utils/CallableResolverTest.php @@ -83,10 +83,11 @@ public function testArrayCallableWithNonExistentInstanceMethod(): void public function testArrayCallableWithNonStaticMethod(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("is not static. Use an instance instead"); + // [Classe::class, 'métodoDeInstância'] resolve de forma lazy (closure). + $resolved = CallableResolver::resolve([TestCallableClass::class, 'instanceMethod']); - CallableResolver::resolve([TestCallableClass::class, 'instanceMethod']); + $this->assertInstanceOf(\Closure::class, $resolved); + $this->assertEquals('instance method called', $resolved()); } public function testArrayCallableWithPrivateMethod(): void