Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Engine/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down
64 changes: 63 additions & 1 deletion Engine/Service/ServiceContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

use Closure;
use InvalidArgumentException;
use ReflectionClass;
use ReflectionException;
use ReflectionNamedType;
use ReflectionParameter;

/**
* Class ServiceContainer
Expand Down Expand Up @@ -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<T> $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
*/
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')]`
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 22 additions & 3 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ <h3>Parameter Binding Attributes</h3>

<section id="controllers">
<h2>Controllers</h2>
<p>Controllers extend <code>Shift\Controller</code>. The current <code>Shift\Request</code> and <code>Shift\Service\ServiceContainer</code> are injected through the constructor.</p>
<p>Controllers extend <code>Shift\Controller</code>. Controllers are created through the service container, so typed constructor dependencies can be injected automatically.</p>

<p>Controller actions can return:</p>
<ul>
Expand All @@ -221,6 +221,24 @@ <h3>Controller Helpers</h3>
<li><code>$this-&gt;getRequest()</code></li>
<li><code>$this-&gt;getContainer()</code></li>
</ul>

<h3>Constructor Autowiring</h3>
<p>Register services in a module, then type-hint them in a controller constructor.</p>

<pre><code>final class UserController extends Controller
{
public function __construct(private readonly UserService $users)
{
}

#[Get('/{id}')]
public function show(#[PathParam] int $id): array
{
return $this-&gt;users-&gt;find($id);
}
}</code></pre>

<p>The app registers the current <code>Shift\Request</code>, <code>Shift\Routing\Router\Router</code>, and <code>Shift\Service\ServiceContainer</code> in the container by default.</p>
</section>

<section id="requests">
Expand Down Expand Up @@ -302,16 +320,17 @@ <h2>Middleware</h2>

<section id="services">
<h2>Service Container</h2>
<p><code>Shift\Service\ServiceContainer</code> stores regular services and singletons. It can resolve closures, class names, and already-created objects.</p>
<p><code>Shift\Service\ServiceContainer</code> stores regular services and singletons. It can resolve closures, class names, and already-created objects. It can also build classes with typed constructor dependencies.</p>

<pre><code>$container-&gt;register(UserRepository::class, UserRepository::class);
$container-&gt;singleton(HealthService::class, HealthService::class);
$container-&gt;singleton('request', $request);

$service = $container-&gt;resolve(HealthService::class);
$controller = $container-&gt;make(HealthController::class);
$exists = $container-&gt;has(HealthService::class);</code></pre>

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

<section id="cli">
Expand Down
55 changes: 55 additions & 0 deletions tests/ApiCoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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]);
Expand Down
Loading