diff --git a/src/App.php b/src/App.php index 4d135df..2e88c4e 100644 --- a/src/App.php +++ b/src/App.php @@ -37,8 +37,18 @@ class App */ public function __construct(...$middleware) { + $container = new Container(); $errorHandler = new ErrorHandler(); - $this->router = new RouteHandler(); + $this->router = new RouteHandler($container); + + if ($middleware) { + $middleware = array_map( + function ($handler) use ($container) { + return is_callable($handler) ? $handler : $container->callable($handler); + }, + $middleware + ); + } // new MiddlewareHandler([$accessLogHandler, $errorHandler, ...$middleware, $routeHandler]) \array_unshift($middleware, $errorHandler); diff --git a/src/Container.php b/src/Container.php new file mode 100644 index 0000000..d3f91a6 --- /dev/null +++ b/src/Container.php @@ -0,0 +1,132 @@ + */ + private $container; + + /** + * @param class-string $class + * @return callable + */ + public function callable(string $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) && !interface_exists($class, false) && !trait_exists($class, false)) { + throw new \BadMethodCallException('Request handler class ' . $class . ' not found'); + } + + try { + $handler = $this->load($class); + } catch (\Throwable $e) { + throw new \BadMethodCallException( + 'Request handler class ' . $class . ' failed to load: ' . $e->getMessage(), + 0, + $e + ); + } + + // Check `$handler` references a class name that is callable, i.e. has an `__invoke()` method. + // 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('Request handler class "' . $class . '" has no public __invoke() method'); + } + + // invoke request handler as middleware handler or final controller + if ($next === null) { + return $handler($request); + } + return $handler($request, $next); + }; + } + + /** + * @param class-string $name + * @return object + * @throws \BadMethodCallException + */ + private function load(string $name, int $depth = 64) + { + if (isset($this->container[$name])) { + return $this->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[] = $this->load($type->getName(), --$depth); + } + + // instantiate with list of parameters + return $this->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() . '()'; + } +} diff --git a/src/RouteHandler.php b/src/RouteHandler.php index 526d8d3..3dbc16e 100644 --- a/src/RouteHandler.php +++ b/src/RouteHandler.php @@ -24,13 +24,14 @@ class RouteHandler /** @var ErrorHandler */ private $errorHandler; - /** @var array */ - private static $container = []; + /** @var Container */ + private $container; - public function __construct() + public function __construct(Container $container = null) { $this->routeCollector = new RouteCollector(new RouteParser(), new RouteGenerator()); $this->errorHandler = new ErrorHandler(); + $this->container = $container ?? new Container(); } /** @@ -44,12 +45,12 @@ public function map(array $methods, string $route, $handler, ...$handlers): void if ($handlers) { $handler = new MiddlewareHandler(array_map( function ($handler) { - return is_callable($handler) ? $handler : self::callable($handler); + return is_callable($handler) ? $handler : $this->container->callable($handler); }, array_merge([$handler], $handlers) )); } elseif (!is_callable($handler)) { - $handler = self::callable($handler); + $handler = $this->container->callable($handler); } $this->routeDispatcher = null; @@ -86,117 +87,4 @@ public function __invoke(ServerRequestInterface $request) return $handler($request); } } // @codeCoverageIgnore - - /** - * @param class-string $class - * @return callable - */ - 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) && !interface_exists($class, false) && !trait_exists($class, false)) { - throw new \BadMethodCallException('Request handler class ' . $class . ' not found'); - } - - try { - $handler = self::load($class); - } catch (\Throwable $e) { - throw new \BadMethodCallException( - 'Request handler class ' . $class . ' failed to load: ' . $e->getMessage(), - 0, - $e - ); - } - - // Check `$handler` references a class name that is callable, i.e. has an `__invoke()` method. - // 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('Request handler class "' . $class . '" has no public __invoke() method'); - } - - // invoke request handler as middleware handler or final controller - if ($next === null) { - return $handler($request); - } - 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() . '()'; - } } diff --git a/tests/AppMiddlewareTest.php b/tests/AppMiddlewareTest.php index 3c47d2a..d891851 100644 --- a/tests/AppMiddlewareTest.php +++ b/tests/AppMiddlewareTest.php @@ -452,6 +452,112 @@ public function testGlobalMiddlewareCallsNextReturnsResponseFromController() $this->assertEquals("OK\n", (string) $response->getBody()); } + public function testGlobalMiddlewareInstanceCallsNextReturnsResponseFromController() + { + $middleware = new class { + public function __invoke(ServerRequestInterface $request, callable $next) + { + return $next($request); + } + }; + + $app = $this->createAppWithoutLogger($middleware); + + $app->get('/', function () { + return new Response( + 200, + [ + 'Content-Type' => 'text/html' + ], + "OK\n" + ); + }); + + $request = new ServerRequest('GET', 'http://localhost/'); + + // $response = $app->handleRequest($request); + $ref = new \ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $response = $ref->invoke($app, $request); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("OK\n", (string) $response->getBody()); + } + + public function testGlobalMiddlewareClassNameCallsNextReturnsResponseFromController() + { + $middleware = new class { + public function __invoke(ServerRequestInterface $request, callable $next) + { + return $next($request); + } + }; + + $app = $this->createAppWithoutLogger(get_class($middleware)); + + $app->get('/', function () { + return new Response( + 200, + [ + 'Content-Type' => 'text/html' + ], + "OK\n" + ); + }); + + $request = new ServerRequest('GET', 'http://localhost/'); + + // $response = $app->handleRequest($request); + $ref = new \ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $response = $ref->invoke($app, $request); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("OK\n", (string) $response->getBody()); + } + + public function testGlobalMiddlewareClassNameAndSameForRouterCallsSameMiddlewareInstanceTwiceAndNextReturnsResponseFromController() + { + $middleware = new class { + private $called = 0; + public function __invoke(ServerRequestInterface $request, callable $next) + { + return $next($request->withAttribute('called', ++$this->called)); + } + }; + + $app = $this->createAppWithoutLogger(get_class($middleware)); + + $app->get('/', get_class($middleware), function (ServerRequestInterface $request) { + return new Response( + 200, + [ + 'Content-Type' => 'text/html' + ], + $request->getAttribute('called') . "\n" + ); + }); + + $request = new ServerRequest('GET', 'http://localhost/'); + + // $response = $app->handleRequest($request); + $ref = new \ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $response = $ref->invoke($app, $request); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("2\n", (string) $response->getBody()); + } + public function testGlobalMiddlewareCallsNextWithModifiedRequestWillBeUsedForRouting() { $app = $this->createAppWithoutLogger(function (ServerRequestInterface $request, callable $next) { @@ -669,7 +775,8 @@ public function testGlobalMiddlewareCallsNextReturnsPromiseWhichResolvesWithModi $this->assertEquals("OK\n", (string) $response->getBody()); } - private function createAppWithoutLogger(callable ...$middleware): App + /** @param callable|class-string ...$middleware */ + private function createAppWithoutLogger(...$middleware): App { $app = new App(...$middleware); diff --git a/tests/AppTest.php b/tests/AppTest.php index c553f4f..6810be9 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -1077,7 +1077,7 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp $this->assertStringContainsString("Error 500: Internal Server Error\n", (string) $response->getBody()); $this->assertStringContainsString("

The requested page failed to load, please try again later.

\n", (string) $response->getBody()); - $this->assertStringMatchesFormat("%a

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Request handler class UnknownClass not found in RouteHandler.php:%d.

\n%a", (string) $response->getBody()); + $this->assertStringMatchesFormat("%a

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Request handler class UnknownClass not found in Container.php:%d.

\n%a", (string) $response->getBody()); } public function provideInvalidClasses() @@ -1168,7 +1168,7 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp $this->assertStringContainsString("Error 500: Internal Server Error\n", (string) $response->getBody()); $this->assertStringContainsString("

The requested page failed to load, please try again later.

\n", (string) $response->getBody()); - $this->assertStringMatchesFormat("%a

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Request handler class " . addslashes($class) . " failed to load: $error in RouteHandler.php:%d.

\n%a", (string) $response->getBody()); + $this->assertStringMatchesFormat("%a

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Request handler class " . addslashes($class) . " failed to load: $error in Container.php:%d.

\n%a", (string) $response->getBody()); } public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResponseWhenHandlerClassRequiresUnexpectedCallableParameter() @@ -1223,7 +1223,7 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp $this->assertStringContainsString("Error 500: Internal Server Error\n", (string) $response->getBody()); $this->assertStringContainsString("

The requested page failed to load, please try again later.

\n", (string) $response->getBody()); - $this->assertStringMatchesFormat("%a

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Request handler class %s has no public __invoke() method in RouteHandler.php:%d.

\n%a", (string) $response->getBody()); + $this->assertStringMatchesFormat("%a

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Request handler class %s has no public __invoke() method in Container.php:%d.

\n%a", (string) $response->getBody()); } public function testHandleRequestWithMatchingRouteReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsPromiseWhichFulfillsWithWrongValue()