diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..1e199c5 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +C:37:"PHPUnit\Runner\DefaultTestResultCache":1144:{a:2:{s:7:"defects";a:3:{s:104:"Chubbyphp\Tests\ApiHttp\Unit\Middleware\ExceptionMiddlewareTest::testWithoutExceptionWithDebugWithLogger";i:3;s:101:"Chubbyphp\Tests\ApiHttp\Unit\Middleware\ExceptionMiddlewareTest::testWithExceptionWithDebugWithLogger";i:3;s:114:"Chubbyphp\Tests\ApiHttp\Unit\Middleware\ExceptionMiddlewareTest::testWithExceptionWithDebugWithLoggerWithoutAccept";i:4;}s:5:"times";a:6:{s:104:"Chubbyphp\Tests\ApiHttp\Unit\Middleware\ExceptionMiddlewareTest::testWithoutExceptionWithDebugWithLogger";d:0.001;s:110:"Chubbyphp\Tests\ApiHttp\Unit\Middleware\ExceptionMiddlewareTest::testWithoutExceptionWithoutDebugWithoutLogger";d:0.059;s:101:"Chubbyphp\Tests\ApiHttp\Unit\Middleware\ExceptionMiddlewareTest::testWithExceptionWithDebugWithLogger";d:0.004;s:104:"Chubbyphp\Tests\ApiHttp\Unit\Middleware\ExceptionMiddlewareTest::testWithExceptionWithoutDebugWithLogger";d:0.002;s:107:"Chubbyphp\Tests\ApiHttp\Unit\Middleware\ExceptionMiddlewareTest::testWithExceptionWithoutDebugWithoutLogger";d:0.003;s:114:"Chubbyphp\Tests\ApiHttp\Unit\Middleware\ExceptionMiddlewareTest::testWithExceptionWithDebugWithLoggerWithoutAccept";d:0.009;}}} \ No newline at end of file diff --git a/README.md b/README.md index 295bec3..9a908e1 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,14 @@ A simple http handler implementation for API. * psr/http-factory: ^1.0.1 * psr/http-message: ^1.0.1 * psr/http-server-middleware: ^1.0.1 + * psr/log: ^1.1.2 ## Installation Through [Composer](http://getcomposer.org) as [chubbyphp/chubbyphp-api-http][1]. ```sh -composer require chubbyphp/chubbyphp-api-http "^3.3" +composer require chubbyphp/chubbyphp-api-http "^3.4" ``` ## Usage @@ -35,9 +36,10 @@ composer require chubbyphp/chubbyphp-api-http "^3.3" * [RequestManager][3] * [ResponseManager][4] * [AcceptAndContentTypeMiddleware][5] - * [ApiHttpServiceFactory][6] - * [ApiHttpServiceProvider][7] - * [ApiProblemMapping (example)][8] + * [ExceptionMiddleware][6] + * [ApiHttpServiceFactory][7] + * [ApiHttpServiceProvider][8] + * [ApiProblemMapping (example)][9] ## Copyright @@ -48,6 +50,7 @@ Dominik Zogg 2020 [3]: doc/Manager/RequestManager.md [4]: doc/Manager/ResponseManager.md [5]: doc/Middleware/AcceptAndContentTypeMiddleware.md -[6]: doc/ServiceFactory/ApiHttpServiceFactory.md -[7]: doc/ServiceProvider/ApiHttpServiceProvider.md -[8]: doc/Serialization/ApiProblemMapping.md +[6]: doc/ServiceFactory/ExceptionMiddleware.md +[7]: doc/ServiceFactory/ApiHttpServiceFactory.md +[8]: doc/ServiceProvider/ApiHttpServiceProvider.md +[9]: doc/Serialization/ApiProblemMapping.md diff --git a/composer.json b/composer.json index 4564508..496581c 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "chubbyphp/chubbyphp-serialization": "^2.12.2", "psr/http-factory": "^1.0.1", "psr/http-message": "^1.0.1", - "psr/http-server-middleware": "^1.0.1" + "psr/http-server-middleware": "^1.0.1", + "psr/log": "^1.1.2" }, "require-dev": { "chubbyphp/chubbyphp-container": "^1.0", @@ -42,7 +43,7 @@ }, "extra": { "branch-alias": { - "dev-master": "3.3-dev" + "dev-master": "3.4-dev" } }, "scripts": { diff --git a/doc/Middleware/ExceptionMiddleware.md b/doc/Middleware/ExceptionMiddleware.md new file mode 100644 index 0000000..e018b5a --- /dev/null +++ b/doc/Middleware/ExceptionMiddleware.md @@ -0,0 +1,33 @@ +# ExceptionMiddleware + +```php +process($request, $handler); +``` diff --git a/src/ApiProblem/ServerError/InternalServerError.php b/src/ApiProblem/ServerError/InternalServerError.php index 7335fcb..bd15308 100644 --- a/src/ApiProblem/ServerError/InternalServerError.php +++ b/src/ApiProblem/ServerError/InternalServerError.php @@ -8,6 +8,11 @@ final class InternalServerError extends AbstractApiProblem { + /** + * @var array>|null + */ + private $backtrace; + public function __construct(?string $detail = null, ?string $instance = null) { parent::__construct( @@ -18,4 +23,20 @@ public function __construct(?string $detail = null, ?string $instance = null) $instance ); } + + /** + * @param array>|null $backtrace + */ + public function setBacktrace(?array $backtrace): void + { + $this->backtrace = $backtrace; + } + + /** + * @return array>|null + */ + public function getBacktrace(): ?array + { + return $this->backtrace; + } } diff --git a/src/Middleware/ExceptionMiddleware.php b/src/Middleware/ExceptionMiddleware.php new file mode 100644 index 0000000..a02dd3a --- /dev/null +++ b/src/Middleware/ExceptionMiddleware.php @@ -0,0 +1,91 @@ +responseManager = $responseManager; + $this->debug = $debug; + $this->logger = $logger ?? new NullLogger(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + try { + return $handler->handle($request); + } catch (\Throwable $exception) { + return $this->handleException($request, $exception); + } + } + + private function handleException(ServerRequestInterface $request, \Throwable $exception): ResponseInterface + { + $backtrace = $this->backtrace($exception); + + $this->logger->error('Exception', ['backtrace' => $backtrace]); + + if (null === $accept = $request->getAttribute('accept')) { + throw $exception; + } + + if ($this->debug) { + $internalServerError = new InternalServerError($exception->getMessage()); + $internalServerError->setBacktrace($backtrace); + } else { + $internalServerError = new InternalServerError(); + } + + return $this->responseManager->createFromApiProblem($internalServerError, $accept); + } + + /** + * @return array> + */ + private function backtrace(\Throwable $exception): array + { + $exceptions = []; + do { + $exceptions[] = [ + 'class' => get_class($exception), + 'message' => $exception->getMessage(), + 'code' => $exception->getCode(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTraceAsString(), + ]; + } while ($exception = $exception->getPrevious()); + + return $exceptions; + } +} diff --git a/src/Serialization/ApiProblem/ServerError/InternalServerErrorMapping.php b/src/Serialization/ApiProblem/ServerError/InternalServerErrorMapping.php index 7b067ef..6236829 100644 --- a/src/Serialization/ApiProblem/ServerError/InternalServerErrorMapping.php +++ b/src/Serialization/ApiProblem/ServerError/InternalServerErrorMapping.php @@ -6,6 +6,8 @@ use Chubbyphp\ApiHttp\ApiProblem\ServerError\InternalServerError; use Chubbyphp\ApiHttp\Serialization\ApiProblem\AbstractApiProblemMapping; +use Chubbyphp\Serialization\Mapping\NormalizationFieldMappingBuilder; +use Chubbyphp\Serialization\Mapping\NormalizationFieldMappingInterface; final class InternalServerErrorMapping extends AbstractApiProblemMapping { @@ -13,4 +15,16 @@ public function getClass(): string { return InternalServerError::class; } + + /** + * @return array + */ + public function getNormalizationFieldMappings(string $path): array + { + $fieldMappings = parent::getNormalizationFieldMappings($path); + + $fieldMappings[] = NormalizationFieldMappingBuilder::create('backtrace')->getMapping(); + + return $fieldMappings; + } } diff --git a/tests/Unit/ApiProblem/ServerError/InternalServerErrorTest.php b/tests/Unit/ApiProblem/ServerError/InternalServerErrorTest.php index a2c83e6..e7087e7 100644 --- a/tests/Unit/ApiProblem/ServerError/InternalServerErrorTest.php +++ b/tests/Unit/ApiProblem/ServerError/InternalServerErrorTest.php @@ -24,11 +24,21 @@ public function testMinimal(): void self::assertSame('Internal Server Error', $apiProblem->getTitle()); self::assertNull($apiProblem->getDetail()); self::assertNull($apiProblem->getInstance()); + self::assertNull($apiProblem->getBacktrace()); } public function testMaximal(): void { + $backtrace = [ + [ + 'class' => 'RuntimeException', + 'message' => 'runtime exception', + 'code' => 5000, + ], + ]; + $apiProblem = new InternalServerError('detail', '/cccdfd0f-0da3-4070-8e55-61bd832b47c0'); + $apiProblem->setBacktrace($backtrace); self::assertSame(500, $apiProblem->getStatus()); self::assertSame([], $apiProblem->getHeaders()); @@ -36,5 +46,6 @@ public function testMaximal(): void self::assertSame('Internal Server Error', $apiProblem->getTitle()); self::assertSame('detail', $apiProblem->getDetail()); self::assertSame('/cccdfd0f-0da3-4070-8e55-61bd832b47c0', $apiProblem->getInstance()); + self::assertSame($backtrace, $apiProblem->getBacktrace()); } } diff --git a/tests/Unit/Middleware/ExceptionMiddlewareTest.php b/tests/Unit/Middleware/ExceptionMiddlewareTest.php new file mode 100644 index 0000000..d2e9142 --- /dev/null +++ b/tests/Unit/Middleware/ExceptionMiddlewareTest.php @@ -0,0 +1,315 @@ +getMockByCalls(ServerRequestInterface::class); + + /** @var ResponseInterface|MockObject $response */ + $response = $this->getMockByCalls(ResponseInterface::class); + + $requestHandler = new class($response) implements RequestHandlerInterface { + /** + * @var ResponseInterface + */ + private $response; + + public function __construct(ResponseInterface $response) + { + $this->response = $response; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->response; + } + }; + + /** @var ResponseManagerInterface|MockObject $responseManager */ + $responseManager = $this->getMockByCalls(ResponseManagerInterface::class); + + /** @var LoggerInterface|MockObject $logger */ + $logger = $this->getMockByCalls(LoggerInterface::class); + + $middleware = new ExceptionMiddleware($responseManager, true, $logger); + + self::assertSame($response, $middleware->process($request, $requestHandler)); + } + + public function testWithExceptionWithDebugWithLoggerWithoutAccept(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('runtime exception'); + $this->expectExceptionCode(5000); + + /** @var ServerRequestInterface|MockObject $request */ + $request = $this->getMockByCalls(ServerRequestInterface::class, [ + Call::create('getAttribute')->with('accept', null)->willReturn(null), + ]); + + /** @var ResponseInterface|MockObject $response */ + $response = $this->getMockByCalls(ResponseInterface::class); + + $requestHandler = new class() implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new \RuntimeException('runtime exception', 5000, new \LogicException('logic exception', 10000)); + } + }; + + /** @var ResponseManagerInterface|MockObject $responseManager */ + $responseManager = $this->getMockByCalls(ResponseManagerInterface::class); + + /** @var LoggerInterface|MockObject $logger */ + $logger = $this->getMockByCalls(LoggerInterface::class, [ + Call::create('error') + ->with( + 'Exception', + new ArgumentCallback(function (array $context): void { + $backtrace = $context['backtrace']; + + self::assertCount(2, $backtrace); + + $exception = array_shift($backtrace); + + self::assertSame('RuntimeException', $exception['class']); + self::assertSame('runtime exception', $exception['message']); + self::assertSame(5000, $exception['code']); + self::assertArrayHasKey('file', $exception); + self::assertArrayHasKey('line', $exception); + self::assertArrayHasKey('trace', $exception); + + $exception = array_shift($backtrace); + + self::assertSame('LogicException', $exception['class']); + self::assertSame('logic exception', $exception['message']); + self::assertSame(10000, $exception['code']); + self::assertArrayHasKey('file', $exception); + self::assertArrayHasKey('line', $exception); + self::assertArrayHasKey('trace', $exception); + }) + ), + ]); + + $middleware = new ExceptionMiddleware($responseManager, true, $logger); + + self::assertSame($response, $middleware->process($request, $requestHandler)); + } + + public function testWithExceptionWithDebugWithLogger(): void + { + /** @var ServerRequestInterface|MockObject $request */ + $request = $this->getMockByCalls(ServerRequestInterface::class, [ + Call::create('getAttribute')->with('accept', null)->willReturn('application/xml'), + ]); + + /** @var ResponseInterface|MockObject $response */ + $response = $this->getMockByCalls(ResponseInterface::class); + + $requestHandler = new class() implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new \RuntimeException('runtime exception', 5000, new \LogicException('logic exception', 10000)); + } + }; + + /** @var ResponseManagerInterface|MockObject $responseManager */ + $responseManager = $this->getMockByCalls(ResponseManagerInterface::class, [ + Call::create('createFromApiProblem') + ->with( + new ArgumentCallback(function (InternalServerError $error): void { + self::assertSame('runtime exception', $error->getDetail()); + + $backtrace = $error->getBacktrace(); + + self::assertCount(2, $backtrace); + + $exception = array_shift($backtrace); + + self::assertSame('RuntimeException', $exception['class']); + self::assertSame('runtime exception', $exception['message']); + self::assertSame(5000, $exception['code']); + self::assertArrayHasKey('file', $exception); + self::assertArrayHasKey('line', $exception); + self::assertArrayHasKey('trace', $exception); + + $exception = array_shift($backtrace); + + self::assertSame('LogicException', $exception['class']); + self::assertSame('logic exception', $exception['message']); + self::assertSame(10000, $exception['code']); + self::assertArrayHasKey('file', $exception); + self::assertArrayHasKey('line', $exception); + self::assertArrayHasKey('trace', $exception); + }), + 'application/xml', + null + ) + ->willReturn($response), + ]); + + /** @var LoggerInterface|MockObject $logger */ + $logger = $this->getMockByCalls(LoggerInterface::class, [ + Call::create('error') + ->with( + 'Exception', + new ArgumentCallback(function (array $context): void { + $backtrace = $context['backtrace']; + + self::assertCount(2, $backtrace); + + $exception = array_shift($backtrace); + + self::assertSame('RuntimeException', $exception['class']); + self::assertSame('runtime exception', $exception['message']); + self::assertSame(5000, $exception['code']); + self::assertArrayHasKey('file', $exception); + self::assertArrayHasKey('line', $exception); + self::assertArrayHasKey('trace', $exception); + + $exception = array_shift($backtrace); + + self::assertSame('LogicException', $exception['class']); + self::assertSame('logic exception', $exception['message']); + self::assertSame(10000, $exception['code']); + self::assertArrayHasKey('file', $exception); + self::assertArrayHasKey('line', $exception); + self::assertArrayHasKey('trace', $exception); + }) + ), + ]); + + $middleware = new ExceptionMiddleware($responseManager, true, $logger); + + self::assertSame($response, $middleware->process($request, $requestHandler)); + } + + public function testWithExceptionWithoutDebugWithLogger(): void + { + /** @var ServerRequestInterface|MockObject $request */ + $request = $this->getMockByCalls(ServerRequestInterface::class, [ + Call::create('getAttribute')->with('accept', null)->willReturn('application/xml'), + ]); + + /** @var ResponseInterface|MockObject $response */ + $response = $this->getMockByCalls(ResponseInterface::class); + + $requestHandler = new class() implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new \RuntimeException('runtime exception', 5000, new \LogicException('logic exception', 10000)); + } + }; + + /** @var ResponseManagerInterface|MockObject $responseManager */ + $responseManager = $this->getMockByCalls(ResponseManagerInterface::class, [ + Call::create('createFromApiProblem') + ->with( + new ArgumentCallback(function (InternalServerError $error): void { + self::assertNull($error->getDetail()); + self::assertNull($error->getBacktrace()); + }), + 'application/xml', + null + ) + ->willReturn($response), + ]); + + /** @var LoggerInterface|MockObject $logger */ + $logger = $this->getMockByCalls(LoggerInterface::class, [ + Call::create('error') + ->with( + 'Exception', + new ArgumentCallback(function (array $context): void { + $backtrace = $context['backtrace']; + + self::assertCount(2, $backtrace); + + $exception = array_shift($backtrace); + + self::assertSame('RuntimeException', $exception['class']); + self::assertSame('runtime exception', $exception['message']); + self::assertSame(5000, $exception['code']); + self::assertArrayHasKey('file', $exception); + self::assertArrayHasKey('line', $exception); + self::assertArrayHasKey('trace', $exception); + + $exception = array_shift($backtrace); + + self::assertSame('LogicException', $exception['class']); + self::assertSame('logic exception', $exception['message']); + self::assertSame(10000, $exception['code']); + self::assertArrayHasKey('file', $exception); + self::assertArrayHasKey('line', $exception); + self::assertArrayHasKey('trace', $exception); + }) + ), + ]); + + $middleware = new ExceptionMiddleware($responseManager, false, $logger); + + self::assertSame($response, $middleware->process($request, $requestHandler)); + } + + public function testWithExceptionWithoutDebugWithoutLogger(): void + { + /** @var ServerRequestInterface|MockObject $request */ + $request = $this->getMockByCalls(ServerRequestInterface::class, [ + Call::create('getAttribute')->with('accept', null)->willReturn('application/xml'), + ]); + + /** @var ResponseInterface|MockObject $response */ + $response = $this->getMockByCalls(ResponseInterface::class); + + $requestHandler = new class() implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new \RuntimeException('runtime exception', 5000, new \LogicException('logic exception', 10000)); + } + }; + + /** @var ResponseManagerInterface|MockObject $responseManager */ + $responseManager = $this->getMockByCalls(ResponseManagerInterface::class, [ + Call::create('createFromApiProblem') + ->with( + new ArgumentCallback(function (InternalServerError $error): void { + self::assertNull($error->getDetail()); + self::assertNull($error->getBacktrace()); + }), + 'application/xml', + null + ) + ->willReturn($response), + ]); + + $middleware = new ExceptionMiddleware($responseManager); + + self::assertSame($response, $middleware->process($request, $requestHandler)); + } +} diff --git a/tests/Unit/Serialization/ApiProblem/ServerError/InternalServerErrorMappingTest.php b/tests/Unit/Serialization/ApiProblem/ServerError/InternalServerErrorMappingTest.php index 2a2185e..be1ae84 100644 --- a/tests/Unit/Serialization/ApiProblem/ServerError/InternalServerErrorMappingTest.php +++ b/tests/Unit/Serialization/ApiProblem/ServerError/InternalServerErrorMappingTest.php @@ -41,6 +41,7 @@ public function testGetNormalizationFieldMappings(): void NormalizationFieldMappingBuilder::create('title')->getMapping(), NormalizationFieldMappingBuilder::create('detail')->getMapping(), NormalizationFieldMappingBuilder::create('instance')->getMapping(), + NormalizationFieldMappingBuilder::create('backtrace')->getMapping(), ], $fieldMappings); }