From 6ecde48072dea121280f56222b4b6b869ad0328f Mon Sep 17 00:00:00 2001 From: rtcoder Date: Tue, 16 Jun 2026 22:01:31 +0200 Subject: [PATCH] Add controller autowiring --- Engine/App.php | 14 ++++++- Engine/Service/ServiceContainer.php | 64 ++++++++++++++++++++++++++++- README.md | 27 ++++++++++++ REFACTORING.md | 2 +- docs/index.html | 25 +++++++++-- tests/ApiCoreTest.php | 55 +++++++++++++++++++++++++ 6 files changed, 181 insertions(+), 6 deletions(-) diff --git a/Engine/App.php b/Engine/App.php index 75f8629..1a4bacf 100755 --- a/Engine/App.php +++ b/Engine/App.php @@ -100,7 +100,7 @@ private function dispatch(Request $request): Response throw new HttpError('Endpoint not found', 404); } - $controller = new $controllerClass($request, $this->container); + $controller = $this->createController($controllerClass); $reflectionClass = new ReflectionClass($controller); if (!$reflectionClass->hasMethod($methodName)) { @@ -119,6 +119,15 @@ private function dispatch(Request $request): Response ); } + private function createController(string $controllerClass): object + { + if ($this->container->has($controllerClass)) { + return $this->container->resolve($controllerClass); + } + + return $this->container->make($controllerClass); + } + /** * @param ReflectionParameter[] $parameters */ @@ -306,7 +315,10 @@ public function getRequest(): Request private function registerDefaultServices(): void { $this->container->singleton('request', $this->request); + $this->container->singleton(Request::class, $this->request); $this->container->singleton('router', $this->router); + $this->container->singleton(Router::class, $this->router); + $this->container->singleton(ServiceContainer::class, $this->container); } public function getContainer(): ServiceContainer diff --git a/Engine/Service/ServiceContainer.php b/Engine/Service/ServiceContainer.php index bc19e74..2cbc6ac 100644 --- a/Engine/Service/ServiceContainer.php +++ b/Engine/Service/ServiceContainer.php @@ -4,6 +4,10 @@ use Closure; use InvalidArgumentException; +use ReflectionClass; +use ReflectionException; +use ReflectionNamedType; +use ReflectionParameter; /** * Class ServiceContainer @@ -76,12 +80,70 @@ private function createInstance($service) } if (is_string($service) && class_exists($service)) { - return new $service(); + return $this->make($service); } return $service; } + /** + * @template T of object + * @param class-string $class + * @return T + */ + public function make(string $class): object + { + if (!class_exists($class)) { + throw new InvalidArgumentException("Class '{$class}' not found"); + } + + try { + $reflectionClass = new ReflectionClass($class); + } catch (ReflectionException $exception) { + throw new InvalidArgumentException("Class '{$class}' cannot be reflected", 0, $exception); + } + + if (!$reflectionClass->isInstantiable()) { + throw new InvalidArgumentException("Class '{$class}' is not instantiable"); + } + + $constructor = $reflectionClass->getConstructor(); + + if ($constructor === null) { + return new $class(); + } + + $dependencies = array_map( + fn (ReflectionParameter $parameter): mixed => $this->resolveParameter($parameter), + $constructor->getParameters() + ); + + return $reflectionClass->newInstanceArgs($dependencies); + } + + private function resolveParameter(ReflectionParameter $parameter): mixed + { + $type = $parameter->getType(); + + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $name = $type->getName(); + + if ($this->has($name)) { + return $this->resolve($name); + } + + if (class_exists($name)) { + return $this->make($name); + } + } + + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + throw new InvalidArgumentException("Unable to resolve parameter '{$parameter->getName()}'"); + } + /** * Clear all services */ diff --git a/README.md b/README.md index 943c620..a51d419 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,23 @@ class UserController extends Controller Route parameters are passed by method parameter name. A controller action can also request the current `Shift\Request`. +Controllers are created through the service container, so constructor dependencies can be type-hinted: + +```php +class UserController extends Controller +{ + public function __construct(private readonly UserService $users) + { + } + + #[Get('/{id}')] + public function show(#[PathParam] int $id): array + { + return $this->users->find($id); + } +} +``` + You can use these routing attributes: - `#[RoutePrefix('/prefix')]` @@ -168,6 +185,16 @@ $app->middleware(function (Request $request, callable $next): Response { }); ``` +## Service Container + +The container can resolve registered services and build classes with typed constructor dependencies: + +```php +$container->singleton(UserService::class, UserService::class); +$service = $container->resolve(UserService::class); +$controller = $container->make(UserController::class); +``` + ## Run Locally ```sh diff --git a/REFACTORING.md b/REFACTORING.md index 9d3c08a..79b0437 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -19,6 +19,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] Modular monolith support through `application/modules/*/Module.php`. - [x] Module-owned controllers, routes, services and commands. - [x] Middleware pipeline. +- [x] Controller autowiring through the container. - [x] Removal of view storage and example page assets from runtime. - [x] Removal of legacy `application/controllers` and `application/routes.php`. - [x] Domain-oriented framework namespaces: @@ -143,7 +144,6 @@ Internal errors return a generic `500` message unless `display_errors` is enable ## Next -- [ ] Controller autowiring through the container. - [ ] Validation helpers and typed request DTOs. - [ ] CORS middleware. - [ ] Authentication and authorization middleware contracts. diff --git a/docs/index.html b/docs/index.html index f8091d0..0c48e96 100644 --- a/docs/index.html +++ b/docs/index.html @@ -202,7 +202,7 @@

Parameter Binding Attributes

Controllers

-

Controllers extend Shift\Controller. The current Shift\Request and Shift\Service\ServiceContainer are injected through the constructor.

+

Controllers extend Shift\Controller. Controllers are created through the service container, so typed constructor dependencies can be injected automatically.

Controller actions can return:

    @@ -221,6 +221,24 @@

    Controller Helpers

  • $this->getRequest()
  • $this->getContainer()
+ +

Constructor Autowiring

+

Register services in a module, then type-hint them in a controller constructor.

+ +
final class UserController extends Controller
+{
+    public function __construct(private readonly UserService $users)
+    {
+    }
+
+    #[Get('/{id}')]
+    public function show(#[PathParam] int $id): array
+    {
+        return $this->users->find($id);
+    }
+}
+ +

The app registers the current Shift\Request, Shift\Routing\Router\Router, and Shift\Service\ServiceContainer in the container by default.

@@ -302,16 +320,17 @@

Middleware

Service Container

-

Shift\Service\ServiceContainer stores regular services and singletons. It can resolve closures, class names, and already-created objects.

+

Shift\Service\ServiceContainer stores regular services and singletons. It can resolve closures, class names, and already-created objects. It can also build classes with typed constructor dependencies.

$container->register(UserRepository::class, UserRepository::class);
 $container->singleton(HealthService::class, HealthService::class);
 $container->singleton('request', $request);
 
 $service = $container->resolve(HealthService::class);
+$controller = $container->make(HealthController::class);
 $exists = $container->has(HealthService::class);
-

The app registers the current request and router as default singleton services under request and router.

+

The app registers the current request and router as default singleton services under request, Shift\Request, router, and Shift\Routing\Router\Router. It also registers the current Shift\Service\ServiceContainer instance.

diff --git a/tests/ApiCoreTest.php b/tests/ApiCoreTest.php index ea50a1a..59651f0 100644 --- a/tests/ApiCoreTest.php +++ b/tests/ApiCoreTest.php @@ -85,6 +85,37 @@ public function handle(Request $request, callable $next): Response } } +final class AutowiredGreetingService +{ + public function message(): string + { + return 'autowired'; + } +} + +final class AutowiredConsumer +{ + public function __construct(public readonly AutowiredGreetingService $service) + { + } +} + +#[RoutePrefix('/autowired')] +final class AutowiredController extends Controller +{ + public function __construct(private readonly AutowiredGreetingService $service) + { + } + + #[Get('/service')] + public function service(): array + { + return [ + 'message' => $this->service->message(), + ]; + } +} + function assertSameValue(mixed $expected, mixed $actual, string $message): void { if ($expected !== $actual) { @@ -201,6 +232,30 @@ function makeRequest(string $method, string $uri, string $body = '', array $quer assertSameValue('demo', $payload['data']['routeParams']['argument'] ?? null, 'App should pass route params to controller.'); }; +$tests['app autowires controller constructor dependencies'] = function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [AutowiredController::class]); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('GET', '/autowired/service'), $router, $emitter); + $app->getContainer()->singleton(AutowiredGreetingService::class, AutowiredGreetingService::class); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(200, $emitter->statusCode, 'Autowired controller should emit successful status.'); + assertSameValue('autowired', $payload['message'] ?? null, 'Controller dependency should be injected from the container.'); +}; + +$tests['service container makes classes with typed dependencies'] = function (): void { + $container = new ServiceContainer(); + $container->singleton(AutowiredGreetingService::class, AutowiredGreetingService::class); + + $consumer = $container->make(AutowiredConsumer::class); + + assertSameValue('autowired', $consumer->service->message(), 'Container should autowire typed constructor dependencies.'); +}; + $tests['app binds path and query params from attributes'] = function (): void { $router = new Router(); (new AttributeRouteLoader())->load($router, [TestAttributeController::class]);