diff --git a/composer.json b/composer.json index e7c7332..b73ee20 100644 --- a/composer.json +++ b/composer.json @@ -10,11 +10,6 @@ "email": "christian@clue.engineering" } ], - "autoload": { - "psr-4": { - "Frugal\\": "src/" - } - }, "require": { "php": ">=7.1", "nikic/fast-route": "^1.3", @@ -23,5 +18,15 @@ }, "require-dev": { "phpunit/phpunit": "^9.5 || ^7.5" + }, + "autoload": { + "psr-4": { + "Frugal\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Frugal\\Tests\\": "tests/" + } } } diff --git a/examples/index.php b/examples/index.php index 114d707..ef3013a 100644 --- a/examples/index.php +++ b/examples/index.php @@ -129,5 +129,12 @@ ); }); +$app->get('/error', function () { + throw new RuntimeException('Error'); +}); +$app->get('/error/null', function () { + return null; +}); + $app->run(); $loop->run(); diff --git a/src/App.php b/src/App.php index d2ac668..b39bbd3 100644 --- a/src/App.php +++ b/src/App.php @@ -300,7 +300,11 @@ private function sendResponse(ServerRequestInterface $request, ResponseInterface /** * @param ServerRequestInterface $request * @param Dispatcher $dispatcher - * @return ResponseInterface|PromiseInterface + * @return ResponseInterface|PromiseInterface + * Returns a response or a Promise which eventually fulfills with a + * response. This method never throws or resolves a rejected promise. + * If the request can not be routed or the handler fails, it will be + * turned into a valid error response before returning. */ private function handleRequest(ServerRequestInterface $request, Dispatcher $dispatcher) { @@ -326,7 +330,30 @@ private function handleRequest(ServerRequestInterface $request, Dispatcher $disp $request = $request->withAttribute($key, rawurldecode($value)); } - return $handler($request); + try { + $response = $handler($request); + } catch (\Throwable $e) { + return $this->errorHandlerException($e, $handler); + } + + if ($response instanceof ResponseInterface) { + return $response; + } elseif ($response instanceof PromiseInterface) { + return $response->then(function ($response) use ($handler) { + if (!$response instanceof ResponseInterface) { + return $this->errorHandlerResponse($response, $handler); + } + return $response; + }, function ($e) use ($handler) { + if ($e instanceof \Throwable) { + return $this->errorHandlerException($e, $handler); + } else { + return $this->errorHandlerResponse(\React\Promise\reject($e), $handler); + } + }); + } else { + return $this->errorHandlerResponse($response, $handler); + } } } // @codeCoverageIgnore @@ -403,4 +430,55 @@ private function errorMethodNotAllowed(ServerRequestInterface $request): Respons implode(', ', $request->getAttribute('allowed')) )->withHeader('Allowed', implode(', ', $request->getAttribute('allowed'))); } + + private function errorHandlerException(\Throwable $e, callable $handler): ResponseInterface + { + $where = ' (' . \basename($e->getFile()) . ':' . $e->getLine() . ')'; + + return $this->error( + 500, + 'Uncaught ' . \get_class($e) . ' from ' . \lcfirst($this->describeHandler($handler, false)) . $where . ': ' . $e->getMessage() + ); + } + + private function errorHandlerResponse($value, callable $handler): ResponseInterface + { + return $this->error( + 500, + $this->describeHandler($handler, true) . ' returned invalid value (' . $this->describeType($value) . ')' + ); + } + + private function describeType($value): string + { + if ($value === null) { + return 'null'; + } elseif (\is_scalar($value) && !\is_string($value)) { + return \var_export($value, true); + } + return \is_object($value) ? \get_class($value) : \gettype($value); + } + + private function describeHandler(callable $handler, bool $linkSourceFile): string + { + if (\is_object($handler) && !$handler instanceof \Closure) { + $ref = new \ReflectionMethod($handler, '__invoke'); + $name = '' . \get_class($handler) . ''; + } elseif (\is_string($handler)) { + $ref = new \ReflectionFunction($handler); + $name = '' . $handler . '()'; + } elseif (\is_array($handler)) { + $ref = new \ReflectionMethod($handler[0], $handler[1]); + $name = '' . (\is_string($handler[0]) ? $handler[0] : \get_class($handler[0])) . '::' . $handler[1] . '()'; + } else { + $ref = new \ReflectionFunction($handler); + $name = 'Request handler'; + } + + if ($linkSourceFile && !$ref->isInternal()) { + $name .= ' (' . \basename($ref->getFileName()) . ':' . $ref->getStartLine() . ')'; + } + + return $name; + } } diff --git a/tests/AppTest.php b/tests/AppTest.php index 256c0fb..49a78e4 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -1,5 +1,7 @@ assertEquals("OK\n", (string) $response->getBody()); } + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithResponseWhenHandlerReturnsPromiseWhichFulfillsWithResponse() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $handler = function () { + return \React\Promise\resolve(new Response( + 200, + [ + 'Content-Type' => 'text/html' + ], + "OK\n" + )); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + /** @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 testHandleRequestWithDispatcherWithRouteFoundAndRouteVariablesReturnsResponseFromHandlerWithRouteVariablesAssignedAsRequestAttributes() { $loop = $this->createMock(LoopInterface::class); @@ -437,6 +483,312 @@ public function testHandleRequestWithDispatcherWithRouteFoundAndRouteVariablesRe $this->assertEquals("Hello alice\n", (string) $response->getBody()); } + public function testHandleRequestWithDispatcherWithRouteFoundReturnsInternalServerErrorResponseWhenHandlerThrowsException() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = __LINE__ + 2; + $handler = function () { + throw new \RuntimeException('Foo'); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $response = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $response = $ref->invoke($app, $request, $dispatcher); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Uncaught RuntimeException from request handler (AppTest.php:$line): Foo\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsPromiseWhichRejectsWithException() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = __LINE__ + 2; + $handler = function () { + return \React\Promise\reject(new \RuntimeException('Foo')); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Uncaught RuntimeException from request handler (AppTest.php:$line): Foo\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsPromiseWhichRejectsWithNull() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = __LINE__ + 1; + $handler = function () { + return \React\Promise\reject(''); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Request handler (AppTest.php:$line) returned invalid value (React\Promise\RejectedPromise)\n", (string) $response->getBody()); + } + + public function provideInvalidReturnValue() + { + return [ + [ + null, + 'null', + ], + [ + 'hello', + 'string' + ], + [ + 42, + '42' + ], + [ + 1.0, + '1.0' + ], + [ + false, + 'false' + ], + [ + [], + 'array' + ], + [ + (object)[], + 'stdClass' + ], + [ + tmpfile(), + 'resource' + ] + ]; + } + + /** + * @dataProvider provideInvalidReturnValue + * @param mixed $value + * @param string $name + */ + public function testHandleRequestWithDispatcherWithRouteFoundReturnsInternalServerErrorResponseWhenHandlerReturnsWrongValue($value, $name) + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = __LINE__ + 1; + $handler = function () use ($value) { + return $value; + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $response = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $response = $ref->invoke($app, $request, $dispatcher); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Request handler (AppTest.php:$line) returned invalid value ($name)\n", (string) $response->getBody()); + } + + /** + * @dataProvider provideInvalidReturnValue + * @param mixed $value + * @param string $name + */ + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsPromiseWhichFulfillsWithWrongValue($value, $name) + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = __LINE__ + 1; + $handler = function () use ($value) { + return \React\Promise\resolve($value); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Request handler (AppTest.php:$line) returned invalid value ($name)\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsInternalServerErrorResponseWhenClassHandlerReturnsStringValue() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = 7; + $handler = new InvalidHandlerStub(); + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $response = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $response = $ref->invoke($app, $request, $dispatcher); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Frugal\Tests\Stub\InvalidHandlerStub (InvalidHandlerStub.php:$line) returned invalid value (null)\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsInternalServerErrorResponseWhenClassMethodHandlerReturnsStringValue() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = 12; + $handler = [new InvalidHandlerStub(), 'index']; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $response = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $response = $ref->invoke($app, $request, $dispatcher); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Frugal\Tests\Stub\InvalidHandlerStub::index() (InvalidHandlerStub.php:$line) returned invalid value (null)\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsInternalServerErrorResponseWhenStaticClassMethodHandlerReturnsStringValue() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = 17; + $handler = [InvalidHandlerStub::class, 'static']; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $response = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $response = $ref->invoke($app, $request, $dispatcher); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Frugal\Tests\Stub\InvalidHandlerStub::static() (InvalidHandlerStub.php:$line) returned invalid value (null)\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsInternalServerErrorResponseWhenGlobalFunctionHandlerReturnsStringValue() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $handler = 'gettype'; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $response = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $response = $ref->invoke($app, $request, $dispatcher); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): gettype() returned invalid value (string)\n", (string) $response->getBody()); + } + public function testLogRequestResponsePrintsRequestLogWithCurrentDateAndTime() { $loop = $this->createMock(LoopInterface::class); diff --git a/tests/FilesystemHandlerTest.php b/tests/FilesystemHandlerTest.php index 3e8600f..6cd207c 100644 --- a/tests/FilesystemHandlerTest.php +++ b/tests/FilesystemHandlerTest.php @@ -1,9 +1,12 @@ &1); match "HTTP/.* 200" && match -iv "Content-Ty out=$(curl -v $base/invalid 2>&1); match "HTTP/.* 404" && match -iP "Content-Type: text/html[\r\n]" out=$(curl -v $base// 2>&1); match "HTTP/.* 404" out=$(curl -v $base/ 2>&1 -X POST); match "HTTP/.* 405" +out=$(curl -v $base/error 2>&1); match "HTTP/.* 500" && match -iP "Content-Type: text/html[\r\n]" +out=$(curl -v $base/error/null 2>&1); match "HTTP/.* 500" && match -iP "Content-Type: text/html[\r\n]" out=$(curl -v $base/uri 2>&1); match "HTTP/.* 200" && match "$base/uri" out=$(curl -v $base/uri/ 2>&1); match "HTTP/.* 200" && match "$base/uri/"