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
80 changes: 78 additions & 2 deletions docs/best-practices/controllers.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Controller classes to structure your app
# Controller classes

## First steps

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

Expand Down Expand Up @@ -113,6 +115,8 @@ class UserController
}
```

## Composer autoloading

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

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

```bash
$ curl http://localhost:8080/
Hello wörld!
```

If everything works as expected, we can continue with writing our first tests to automate this.

## Container

X has a powerful, built-in dependency injection container (DI container or DIC).
It allows you to automatically create request handler classes and their
dependencies with zero configuration for most common use cases.

> ℹ️ **Dependency Injection (DI)**
>
> Dependency injection (DI) is a technique in which an object receives other
> objects that it depends on, rather than creating these dependencies within its
> class. In its most basic form, this means creating all required object
> dependencies upfront and manually injecting them into the controller class.
> This can be done manually or you can use the optional container which does
> this for you.

### Autowiring

To use autowiring, simply pass in the class name of your request handler classes
like this:

```php title="public/index.php"
<?php

require __DIR__ . '/../vendor/autoload.php';

$app = new FrameworkX\App();

$app->get('/', Acme\Todo\HelloController::class);
$app->get('/users/{name}', Acme\Todo\UserController::class);

$app->run();
```

X will automatically take care of instantiating the required request handler
classes and their dependencies when a request comes in. This autowiring feature
covers most common use cases:

* Names always reference existing class names.
* Class names need to be loadable through the autoloader. See
[composer autoloading](#composer-autoloading) above.
* Each class may or may not have a constructor.
* If the constructor has an optional argument, it will be omitted.
* If the constructor has a nullable argument, it will be given a `null` value.
* If the constructor references another class, it will load this class next.

This covers most common use cases where the request handler class uses a
constructor with type definitions to explicitly reference other classes.

### Container configuration

> ⚠️ **Feature preview**
>
> This is a feature preview, i.e. it might not have made it into the current beta.
> Give feedback to help us prioritize.
> We also welcome [contributors](../getting-started/community.md) to help out!

Autowiring should cover most common use cases with zero configuration. If you
want to have more control over this behavior, you may also explicitly configure
the dependency injection container. This can be useful in these cases:

* Constructor parameter references an interface and you want to explicitly
define an instance that implements this interface.
* Constructor parameter has a primitive type (scalars such as `int` or `string`
etc.) or has no type at all and you want to explicitly bind a given value.
* Constructor parameter references a class, but you want to inject a specific
instance or subclass in place of a default class.

In the future, we will also allow you to pass in a custom
[PSR-11: Container interface](https://www.php-fig.org/psr/psr-11/) implementing
the well-established `Psr\Container\ContainerInterface`.
We love standards and interoperability.
91 changes: 84 additions & 7 deletions src/RouteHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class RouteHandler
/** @var ErrorHandler */
private $errorHandler;

/** @var array<string,mixed> */
private static $container = [];

public function __construct()
{
$this->routeCollector = new RouteCollector(new RouteParser(), new RouteGenerator());
Expand Down Expand Up @@ -92,17 +95,15 @@ private static function callable($class): callable
{
return function (ServerRequestInterface $request, callable $next = null) use ($class) {
// Check `$class` references a valid class name that can be autoloaded
if (!\class_exists($class, true)) {
throw new \BadMethodCallException('Unable to load request handler class "' . $class . '"');
if (!\class_exists($class, true) && !interface_exists($class, false) && !trait_exists($class, false)) {
throw new \BadMethodCallException('Request handler class ' . $class . ' not found');
}

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

// invoke request handler as middleware handler or final controller
Expand All @@ -122,4 +123,80 @@ private static function callable($class): callable
return $handler($request, $next);
};
}

private static function load(string $name, int $depth = 64)
{
if (isset(self::$container[$name])) {
return self::$container[$name];
}

// Check `$name` references a valid class name that can be autoloaded
if (!\class_exists($name, true) && !interface_exists($name, false) && !trait_exists($name, false)) {
throw new \BadMethodCallException('Class ' . $name . ' not found');
}

$class = new \ReflectionClass($name);
if (!$class->isInstantiable()) {
$modifier = 'class';
if ($class->isInterface()) {
$modifier = 'interface';
} elseif ($class->isAbstract()) {
$modifier = 'abstract class';
} elseif ($class->isTrait()) {
$modifier = 'trait';
}
throw new \BadMethodCallException('Cannot instantiate ' . $modifier . ' '. $name);
}

// build list of constructor parameters based on parameter types
$params = [];
$ctor = $class->getConstructor();
assert($ctor === null || $ctor instanceof \ReflectionMethod);
foreach ($ctor !== null ? $ctor->getParameters() : [] as $parameter) {
assert($parameter instanceof \ReflectionParameter);

// stop building parameters when encountering first optional parameter
if ($parameter->isOptional()) {
break;
}

// ensure parameter is typed
$type = $parameter->getType();
if ($type === null) {
throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type');
}

// if allowed, use null value without injecting any instances
assert($type instanceof \ReflectionType);
if ($type->allowsNull()) {
$params[] = null;
continue;
}

// abort for union types (PHP 8.0+) and intersection types (PHP 8.1+)
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) {
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type); // @codeCoverageIgnore
}

assert($type instanceof \ReflectionNamedType);
if ($type->isBuiltin()) {
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName());
}

// abort for unreasonably deep nesting or recursive types
if ($depth < 1) {
throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive');
}

$params[] = self::load($type->getName(), --$depth);
}

// instantiate with list of parameters
return self::$container[$name] = $params === [] ? new $name() : $class->newInstance(...$params);
}

private static function parameterError(\ReflectionParameter $parameter): string
{
return 'Argument ' . ($parameter->getPosition() + 1) . ' ($' . $parameter->getName() . ') of ' . explode("\0", $parameter->getDeclaringClass()->getName())[0] . '::' . $parameter->getDeclaringFunction()->getName() . '()';
}
}
98 changes: 84 additions & 14 deletions tests/AppTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,21 @@
use FrameworkX\MiddlewareHandler;
use FrameworkX\RouteHandler;
use FrameworkX\SapiHandler;
use FrameworkX\Tests\Fixtures\InvalidAbstract;
use FrameworkX\Tests\Fixtures\InvalidConstructorInt;
use FrameworkX\Tests\Fixtures\InvalidConstructorIntersection;
use FrameworkX\Tests\Fixtures\InvalidConstructorPrivate;
use FrameworkX\Tests\Fixtures\InvalidConstructorProtected;
use FrameworkX\Tests\Fixtures\InvalidConstructorSelf;
use FrameworkX\Tests\Fixtures\InvalidConstructorUnion;
use FrameworkX\Tests\Fixtures\InvalidConstructorUnknown;
use FrameworkX\Tests\Fixtures\InvalidConstructorUntyped;
use FrameworkX\Tests\Fixtures\InvalidInterface;
use FrameworkX\Tests\Fixtures\InvalidTrait;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\LoopInterface;
use React\EventLoop\Loop;
use React\Http\Message\Response;
use React\Http\Message\ServerRequest;
use React\Promise\Promise;
Expand All @@ -20,7 +31,6 @@
use ReflectionProperty;
use function React\Promise\reject;
use function React\Promise\resolve;
use React\EventLoop\Loop;

class AppTest extends TestCase
{
Expand Down Expand Up @@ -1050,7 +1060,6 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp
{
$app = $this->createAppWithoutLogger();

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

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

$this->assertStringContainsString("<title>Error 500: Internal Server Error</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>The requested page failed to load, please try again later.</p>\n", (string) $response->getBody());
$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());
$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());
}

public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResponseWhenHandlerClassRequiresConstructorParameter()
public function provideInvalidClasses()
{
$app = $this->createAppWithoutLogger();
yield [
InvalidConstructorPrivate::class,
'Cannot instantiate class ' . addslashes(InvalidConstructorPrivate::class)
];

yield [
InvalidConstructorProtected::class,
'Cannot instantiate class ' . addslashes(InvalidConstructorProtected::class)
];

yield [
InvalidAbstract::class,
'Cannot instantiate abstract class ' . addslashes(InvalidAbstract::class)
];

yield [
InvalidInterface::class,
'Cannot instantiate interface ' . addslashes(InvalidInterface::class)
];

yield [
InvalidTrait::class,
'Cannot instantiate trait ' . addslashes(InvalidTrait::class)
];

yield [
InvalidConstructorUntyped::class,
'Argument 1 ($value) of %s::__construct() has no type'
];

yield [
InvalidConstructorInt::class,
'Argument 1 ($value) of %s::__construct() expects unsupported type int'
];

if (PHP_VERSION_ID >= 80000) {
yield [
InvalidConstructorUnion::class,
'Argument 1 ($value) of %s::__construct() expects unsupported type int|float'
];
}

$controller = new class(42) {
public function __construct(int $value) { }
};
if (PHP_VERSION_ID >= 80100) {
yield [
InvalidConstructorIntersection::class,
'Argument 1 ($value) of %s::__construct() expects unsupported type Traversable&amp;ArrayAccess'
];
}

$line = __LINE__ + 2;
$app->get('/users', get_class($controller));
yield [
InvalidConstructorUnknown::class,
'Class UnknownClass not found'
];

yield [
InvalidConstructorSelf::class,
'Argument 1 ($value) of %s::__construct() is recursive'
];
}

/**
* @dataProvider provideInvalidClasses
* @param class-string $class
* @param string $error
*/
public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResponseWhenHandlerClassIsInvalid(string $class, string $error)
{
$app = $this->createAppWithoutLogger();

$app->get('/users', $class);

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

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

$this->assertStringContainsString("<title>Error 500: Internal Server Error</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>The requested page failed to load, please try again later.</p>\n", (string) $response->getBody());
$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());
$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());
}

public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResponseWhenHandlerClassRequiresUnexpectedCallableParameter()
Expand Down Expand Up @@ -1135,7 +1206,6 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp

$controller = new class { };

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

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

$this->assertStringContainsString("<title>Error 500: Internal Server Error</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>The requested page failed to load, please try again later.</p>\n", (string) $response->getBody());
$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());
$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());
}

public function testHandleRequestWithMatchingRouteReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsPromiseWhichFulfillsWithWrongValue()
Expand Down
7 changes: 7 additions & 0 deletions tests/Fixtures/InvalidAbstract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace FrameworkX\Tests\Fixtures;

abstract class InvalidAbstract
{
}
10 changes: 10 additions & 0 deletions tests/Fixtures/InvalidConstructorInt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace FrameworkX\Tests\Fixtures;

class InvalidConstructorInt
{
public function __construct(int $value)
{
}
}
Loading