Skip to content

Commit 01b5524

Browse files
authored
Merge pull request #92 from clue-labs/di-container
Add autowiring support when loading request handler classes (DI container)
2 parents e8c1cd7 + a26719a commit 01b5524

15 files changed

+441
-26
lines changed

docs/best-practices/controllers.md

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
# Controller classes to structure your app
1+
# Controller classes
2+
3+
## First steps
24

35
When starting with X, it's often easiest to start with simple closure definitions like suggested in the [quickstart guide](../getting-started/quickstart.md).
46

@@ -113,6 +115,8 @@ class UserController
113115
}
114116
```
115117

118+
## Composer autoloading
119+
116120
Doesn't look too complex, right? Now, we only need to tell Composer's autoloader
117121
about our vendor namespace `Acme\Todo` in the `src/` folder. Make sure to include
118122
the following lines in your `composer.json` file:
@@ -142,11 +146,83 @@ assured this is the only time you have to worry about this, new classes can
142146
simply be added without having to run Composer again.
143147

144148
Again, let's see our web application still works by using your favorite
145-
webbrowser or command line tool:
149+
web browser or command-line tool:
146150

147151
```bash
148152
$ curl http://localhost:8080/
149153
Hello wörld!
150154
```
151155

152156
If everything works as expected, we can continue with writing our first tests to automate this.
157+
158+
## Container
159+
160+
X has a powerful, built-in dependency injection container (DI container or DIC).
161+
It allows you to automatically create request handler classes and their
162+
dependencies with zero configuration for most common use cases.
163+
164+
> ℹ️ **Dependency Injection (DI)**
165+
>
166+
> Dependency injection (DI) is a technique in which an object receives other
167+
> objects that it depends on, rather than creating these dependencies within its
168+
> class. In its most basic form, this means creating all required object
169+
> dependencies upfront and manually injecting them into the controller class.
170+
> This can be done manually or you can use the optional container which does
171+
> this for you.
172+
173+
### Autowiring
174+
175+
To use autowiring, simply pass in the class name of your request handler classes
176+
like this:
177+
178+
```php title="public/index.php"
179+
<?php
180+
181+
require __DIR__ . '/../vendor/autoload.php';
182+
183+
$app = new FrameworkX\App();
184+
185+
$app->get('/', Acme\Todo\HelloController::class);
186+
$app->get('/users/{name}', Acme\Todo\UserController::class);
187+
188+
$app->run();
189+
```
190+
191+
X will automatically take care of instantiating the required request handler
192+
classes and their dependencies when a request comes in. This autowiring feature
193+
covers most common use cases:
194+
195+
* Names always reference existing class names.
196+
* Class names need to be loadable through the autoloader. See
197+
[composer autoloading](#composer-autoloading) above.
198+
* Each class may or may not have a constructor.
199+
* If the constructor has an optional argument, it will be omitted.
200+
* If the constructor has a nullable argument, it will be given a `null` value.
201+
* If the constructor references another class, it will load this class next.
202+
203+
This covers most common use cases where the request handler class uses a
204+
constructor with type definitions to explicitly reference other classes.
205+
206+
### Container configuration
207+
208+
> ⚠️ **Feature preview**
209+
>
210+
> This is a feature preview, i.e. it might not have made it into the current beta.
211+
> Give feedback to help us prioritize.
212+
> We also welcome [contributors](../getting-started/community.md) to help out!
213+
214+
Autowiring should cover most common use cases with zero configuration. If you
215+
want to have more control over this behavior, you may also explicitly configure
216+
the dependency injection container. This can be useful in these cases:
217+
218+
* Constructor parameter references an interface and you want to explicitly
219+
define an instance that implements this interface.
220+
* Constructor parameter has a primitive type (scalars such as `int` or `string`
221+
etc.) or has no type at all and you want to explicitly bind a given value.
222+
* Constructor parameter references a class, but you want to inject a specific
223+
instance or subclass in place of a default class.
224+
225+
In the future, we will also allow you to pass in a custom
226+
[PSR-11: Container interface](https://www.php-fig.org/psr/psr-11/) implementing
227+
the well-established `Psr\Container\ContainerInterface`.
228+
We love standards and interoperability.

src/RouteHandler.php

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class RouteHandler
2424
/** @var ErrorHandler */
2525
private $errorHandler;
2626

27+
/** @var array<string,mixed> */
28+
private static $container = [];
29+
2730
public function __construct()
2831
{
2932
$this->routeCollector = new RouteCollector(new RouteParser(), new RouteGenerator());
@@ -92,17 +95,15 @@ private static function callable($class): callable
9295
{
9396
return function (ServerRequestInterface $request, callable $next = null) use ($class) {
9497
// Check `$class` references a valid class name that can be autoloaded
95-
if (!\class_exists($class, true)) {
96-
throw new \BadMethodCallException('Unable to load request handler class "' . $class . '"');
98+
if (!\class_exists($class, true) && !interface_exists($class, false) && !trait_exists($class, false)) {
99+
throw new \BadMethodCallException('Request handler class ' . $class . ' not found');
97100
}
98101

99-
// This initial version is intentionally limited to loading classes that require no arguments.
100-
// A follow-up version will invoke a DI container here to load the appropriate hierarchy of arguments.
101102
try {
102-
$handler = new $class();
103+
$handler = self::load($class);
103104
} catch (\Throwable $e) {
104105
throw new \BadMethodCallException(
105-
'Unable to instantiate request handler class "' . $class . '": ' . $e->getMessage(),
106+
'Request handler class ' . $class . ' failed to load: ' . $e->getMessage(),
106107
0,
107108
$e
108109
);
@@ -112,7 +113,7 @@ private static function callable($class): callable
112113
// This initial version is intentionally limited to checking the method name only.
113114
// A follow-up version will likely use reflection to check request handler argument types.
114115
if (!is_callable($handler)) {
115-
throw new \BadMethodCallException('Unable to use request handler class "' . $class . '" because it has no "public function __invoke()"');
116+
throw new \BadMethodCallException('Request handler class "' . $class . '" has no public __invoke() method');
116117
}
117118

118119
// invoke request handler as middleware handler or final controller
@@ -122,4 +123,80 @@ private static function callable($class): callable
122123
return $handler($request, $next);
123124
};
124125
}
126+
127+
private static function load(string $name, int $depth = 64)
128+
{
129+
if (isset(self::$container[$name])) {
130+
return self::$container[$name];
131+
}
132+
133+
// Check `$name` references a valid class name that can be autoloaded
134+
if (!\class_exists($name, true) && !interface_exists($name, false) && !trait_exists($name, false)) {
135+
throw new \BadMethodCallException('Class ' . $name . ' not found');
136+
}
137+
138+
$class = new \ReflectionClass($name);
139+
if (!$class->isInstantiable()) {
140+
$modifier = 'class';
141+
if ($class->isInterface()) {
142+
$modifier = 'interface';
143+
} elseif ($class->isAbstract()) {
144+
$modifier = 'abstract class';
145+
} elseif ($class->isTrait()) {
146+
$modifier = 'trait';
147+
}
148+
throw new \BadMethodCallException('Cannot instantiate ' . $modifier . ' '. $name);
149+
}
150+
151+
// build list of constructor parameters based on parameter types
152+
$params = [];
153+
$ctor = $class->getConstructor();
154+
assert($ctor === null || $ctor instanceof \ReflectionMethod);
155+
foreach ($ctor !== null ? $ctor->getParameters() : [] as $parameter) {
156+
assert($parameter instanceof \ReflectionParameter);
157+
158+
// stop building parameters when encountering first optional parameter
159+
if ($parameter->isOptional()) {
160+
break;
161+
}
162+
163+
// ensure parameter is typed
164+
$type = $parameter->getType();
165+
if ($type === null) {
166+
throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type');
167+
}
168+
169+
// if allowed, use null value without injecting any instances
170+
assert($type instanceof \ReflectionType);
171+
if ($type->allowsNull()) {
172+
$params[] = null;
173+
continue;
174+
}
175+
176+
// abort for union types (PHP 8.0+) and intersection types (PHP 8.1+)
177+
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) {
178+
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type); // @codeCoverageIgnore
179+
}
180+
181+
assert($type instanceof \ReflectionNamedType);
182+
if ($type->isBuiltin()) {
183+
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName());
184+
}
185+
186+
// abort for unreasonably deep nesting or recursive types
187+
if ($depth < 1) {
188+
throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive');
189+
}
190+
191+
$params[] = self::load($type->getName(), --$depth);
192+
}
193+
194+
// instantiate with list of parameters
195+
return self::$container[$name] = $params === [] ? new $name() : $class->newInstance(...$params);
196+
}
197+
198+
private static function parameterError(\ReflectionParameter $parameter): string
199+
{
200+
return 'Argument ' . ($parameter->getPosition() + 1) . ' ($' . $parameter->getName() . ') of ' . explode("\0", $parameter->getDeclaringClass()->getName())[0] . '::' . $parameter->getDeclaringFunction()->getName() . '()';
201+
}
125202
}

tests/AppTest.php

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,21 @@
88
use FrameworkX\MiddlewareHandler;
99
use FrameworkX\RouteHandler;
1010
use FrameworkX\SapiHandler;
11+
use FrameworkX\Tests\Fixtures\InvalidAbstract;
12+
use FrameworkX\Tests\Fixtures\InvalidConstructorInt;
13+
use FrameworkX\Tests\Fixtures\InvalidConstructorIntersection;
14+
use FrameworkX\Tests\Fixtures\InvalidConstructorPrivate;
15+
use FrameworkX\Tests\Fixtures\InvalidConstructorProtected;
16+
use FrameworkX\Tests\Fixtures\InvalidConstructorSelf;
17+
use FrameworkX\Tests\Fixtures\InvalidConstructorUnion;
18+
use FrameworkX\Tests\Fixtures\InvalidConstructorUnknown;
19+
use FrameworkX\Tests\Fixtures\InvalidConstructorUntyped;
20+
use FrameworkX\Tests\Fixtures\InvalidInterface;
21+
use FrameworkX\Tests\Fixtures\InvalidTrait;
1122
use PHPUnit\Framework\TestCase;
1223
use Psr\Http\Message\ResponseInterface;
1324
use Psr\Http\Message\ServerRequestInterface;
14-
use React\EventLoop\LoopInterface;
25+
use React\EventLoop\Loop;
1526
use React\Http\Message\Response;
1627
use React\Http\Message\ServerRequest;
1728
use React\Promise\Promise;
@@ -20,7 +31,6 @@
2031
use ReflectionProperty;
2132
use function React\Promise\reject;
2233
use function React\Promise\resolve;
23-
use React\EventLoop\Loop;
2434

2535
class AppTest extends TestCase
2636
{
@@ -1050,7 +1060,6 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp
10501060
{
10511061
$app = $this->createAppWithoutLogger();
10521062

1053-
$line = __LINE__ + 2;
10541063
$app->get('/users', 'UnknownClass');
10551064

10561065
$request = new ServerRequest('GET', 'http://localhost/users');
@@ -1068,19 +1077,81 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp
10681077

10691078
$this->assertStringContainsString("<title>Error 500: Internal Server Error</title>\n", (string) $response->getBody());
10701079
$this->assertStringContainsString("<p>The requested page failed to load, please try again later.</p>\n", (string) $response->getBody());
1071-
$this->assertStringMatchesFormat("%a<p>Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>BadMethodCallException</code> with message <code>Unable to load request handler class \"UnknownClass\"</code> in <code title=\"See %s\">RouteHandler.php:%d</code>.</p>\n%a", (string) $response->getBody());
1080+
$this->assertStringMatchesFormat("%a<p>Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>BadMethodCallException</code> with message <code>Request handler class UnknownClass not found</code> in <code title=\"See %s\">RouteHandler.php:%d</code>.</p>\n%a", (string) $response->getBody());
10721081
}
10731082

1074-
public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResponseWhenHandlerClassRequiresConstructorParameter()
1083+
public function provideInvalidClasses()
10751084
{
1076-
$app = $this->createAppWithoutLogger();
1085+
yield [
1086+
InvalidConstructorPrivate::class,
1087+
'Cannot instantiate class ' . addslashes(InvalidConstructorPrivate::class)
1088+
];
1089+
1090+
yield [
1091+
InvalidConstructorProtected::class,
1092+
'Cannot instantiate class ' . addslashes(InvalidConstructorProtected::class)
1093+
];
1094+
1095+
yield [
1096+
InvalidAbstract::class,
1097+
'Cannot instantiate abstract class ' . addslashes(InvalidAbstract::class)
1098+
];
1099+
1100+
yield [
1101+
InvalidInterface::class,
1102+
'Cannot instantiate interface ' . addslashes(InvalidInterface::class)
1103+
];
1104+
1105+
yield [
1106+
InvalidTrait::class,
1107+
'Cannot instantiate trait ' . addslashes(InvalidTrait::class)
1108+
];
1109+
1110+
yield [
1111+
InvalidConstructorUntyped::class,
1112+
'Argument 1 ($value) of %s::__construct() has no type'
1113+
];
1114+
1115+
yield [
1116+
InvalidConstructorInt::class,
1117+
'Argument 1 ($value) of %s::__construct() expects unsupported type int'
1118+
];
1119+
1120+
if (PHP_VERSION_ID >= 80000) {
1121+
yield [
1122+
InvalidConstructorUnion::class,
1123+
'Argument 1 ($value) of %s::__construct() expects unsupported type int|float'
1124+
];
1125+
}
10771126

1078-
$controller = new class(42) {
1079-
public function __construct(int $value) { }
1080-
};
1127+
if (PHP_VERSION_ID >= 80100) {
1128+
yield [
1129+
InvalidConstructorIntersection::class,
1130+
'Argument 1 ($value) of %s::__construct() expects unsupported type Traversable&amp;ArrayAccess'
1131+
];
1132+
}
10811133

1082-
$line = __LINE__ + 2;
1083-
$app->get('/users', get_class($controller));
1134+
yield [
1135+
InvalidConstructorUnknown::class,
1136+
'Class UnknownClass not found'
1137+
];
1138+
1139+
yield [
1140+
InvalidConstructorSelf::class,
1141+
'Argument 1 ($value) of %s::__construct() is recursive'
1142+
];
1143+
}
1144+
1145+
/**
1146+
* @dataProvider provideInvalidClasses
1147+
* @param class-string $class
1148+
* @param string $error
1149+
*/
1150+
public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResponseWhenHandlerClassIsInvalid(string $class, string $error)
1151+
{
1152+
$app = $this->createAppWithoutLogger();
1153+
1154+
$app->get('/users', $class);
10841155

10851156
$request = new ServerRequest('GET', 'http://localhost/users');
10861157

@@ -1097,7 +1168,7 @@ public function __construct(int $value) { }
10971168

10981169
$this->assertStringContainsString("<title>Error 500: Internal Server Error</title>\n", (string) $response->getBody());
10991170
$this->assertStringContainsString("<p>The requested page failed to load, please try again later.</p>\n", (string) $response->getBody());
1100-
$this->assertStringMatchesFormat("%a<p>Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>BadMethodCallException</code> with message <code>Unable to instantiate request handler class \"%s\": %s</code> in <code title=\"See %s\">RouteHandler.php:%d</code>.</p>\n%a", (string) $response->getBody());
1171+
$this->assertStringMatchesFormat("%a<p>Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>BadMethodCallException</code> with message <code>Request handler class " . addslashes($class) . " failed to load: $error</code> in <code title=\"See %s\">RouteHandler.php:%d</code>.</p>\n%a", (string) $response->getBody());
11011172
}
11021173

11031174
public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResponseWhenHandlerClassRequiresUnexpectedCallableParameter()
@@ -1135,7 +1206,6 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp
11351206

11361207
$controller = new class { };
11371208

1138-
$line = __LINE__ + 2;
11391209
$app->get('/users', get_class($controller));
11401210

11411211
$request = new ServerRequest('GET', 'http://localhost/users');
@@ -1153,7 +1223,7 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp
11531223

11541224
$this->assertStringContainsString("<title>Error 500: Internal Server Error</title>\n", (string) $response->getBody());
11551225
$this->assertStringContainsString("<p>The requested page failed to load, please try again later.</p>\n", (string) $response->getBody());
1156-
$this->assertStringMatchesFormat("%a<p>Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>BadMethodCallException</code> with message <code>Unable to use request handler class \"%s\" because it has no \"public function __invoke()\"</code> in <code title=\"See %s\">RouteHandler.php:%d</code>.</p>\n%a", (string) $response->getBody());
1226+
$this->assertStringMatchesFormat("%a<p>Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>BadMethodCallException</code> with message <code>Request handler class %s has no public __invoke() method</code> in <code title=\"See %s\">RouteHandler.php:%d</code>.</p>\n%a", (string) $response->getBody());
11571227
}
11581228

11591229
public function testHandleRequestWithMatchingRouteReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsPromiseWhichFulfillsWithWrongValue()

tests/Fixtures/InvalidAbstract.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace FrameworkX\Tests\Fixtures;
4+
5+
abstract class InvalidAbstract
6+
{
7+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace FrameworkX\Tests\Fixtures;
4+
5+
class InvalidConstructorInt
6+
{
7+
public function __construct(int $value)
8+
{
9+
}
10+
}

0 commit comments

Comments
 (0)