From 84aeb5555c2259005df91239bba7e62dd2d862a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Cobucci?= Date: Thu, 27 Jun 2019 17:11:28 +0200 Subject: [PATCH 1/8] Add marker interfaces for problem types --- src/Problem/AuthorizationRequired.php | 10 ++++++++++ src/Problem/Conflict.php | 10 ++++++++++ src/Problem/Forbidden.php | 10 ++++++++++ src/Problem/InvalidRequest.php | 10 ++++++++++ src/Problem/ResourceNoLongerAvailable.php | 10 ++++++++++ src/Problem/ResourceNotFound.php | 10 ++++++++++ src/Problem/ServiceUnavailable.php | 10 ++++++++++ src/Problem/UnprocessableRequest.php | 10 ++++++++++ 8 files changed, 80 insertions(+) create mode 100644 src/Problem/AuthorizationRequired.php create mode 100644 src/Problem/Conflict.php create mode 100644 src/Problem/Forbidden.php create mode 100644 src/Problem/InvalidRequest.php create mode 100644 src/Problem/ResourceNoLongerAvailable.php create mode 100644 src/Problem/ResourceNotFound.php create mode 100644 src/Problem/ServiceUnavailable.php create mode 100644 src/Problem/UnprocessableRequest.php diff --git a/src/Problem/AuthorizationRequired.php b/src/Problem/AuthorizationRequired.php new file mode 100644 index 0000000..a5179cd --- /dev/null +++ b/src/Problem/AuthorizationRequired.php @@ -0,0 +1,10 @@ + Date: Thu, 27 Jun 2019 17:12:17 +0200 Subject: [PATCH 2/8] Add interfaces to provide problem information --- src/Problem/Detailed.php | 14 ++++++++++++++ src/Problem/Titled.php | 11 +++++++++++ src/Problem/Typed.php | 11 +++++++++++ 3 files changed, 36 insertions(+) create mode 100644 src/Problem/Detailed.php create mode 100644 src/Problem/Titled.php create mode 100644 src/Problem/Typed.php diff --git a/src/Problem/Detailed.php b/src/Problem/Detailed.php new file mode 100644 index 0000000..7f3c155 --- /dev/null +++ b/src/Problem/Detailed.php @@ -0,0 +1,14 @@ + + */ + public function getExtraDetails(): array; +} diff --git a/src/Problem/Titled.php b/src/Problem/Titled.php new file mode 100644 index 0000000..fc5dfd7 --- /dev/null +++ b/src/Problem/Titled.php @@ -0,0 +1,11 @@ + Date: Thu, 27 Jun 2019 17:14:14 +0200 Subject: [PATCH 3/8] Add strategies to extract debug information Depending on the application mode, we may need extra debugging information in the response body - which can definitely help us to solve the issue more easily. This provides the extension point and two default implementations: - `NoDebugInfo`: aimed for production mode, doesn't add any debug information - `NoTrace`: aimed for development mode, extracts all exception data but the trace - also for previous exceptions. --- src/DebugInfoStrategy.php | 14 ++++ src/DebugInfoStrategy/NoDebugInfo.php | 18 +++++ src/DebugInfoStrategy/NoTrace.php | 52 ++++++++++++ tests/DebugInfoStrategy/NoDebugInfoTest.php | 26 ++++++ tests/DebugInfoStrategy/NoTraceTest.php | 88 +++++++++++++++++++++ 5 files changed, 198 insertions(+) create mode 100644 src/DebugInfoStrategy.php create mode 100644 src/DebugInfoStrategy/NoDebugInfo.php create mode 100644 src/DebugInfoStrategy/NoTrace.php create mode 100644 tests/DebugInfoStrategy/NoDebugInfoTest.php create mode 100644 tests/DebugInfoStrategy/NoTraceTest.php diff --git a/src/DebugInfoStrategy.php b/src/DebugInfoStrategy.php new file mode 100644 index 0000000..4bfbbe2 --- /dev/null +++ b/src/DebugInfoStrategy.php @@ -0,0 +1,14 @@ +|null + */ + public function extractDebugInfo(Throwable $error): ?array; +} diff --git a/src/DebugInfoStrategy/NoDebugInfo.php b/src/DebugInfoStrategy/NoDebugInfo.php new file mode 100644 index 0000000..2683435 --- /dev/null +++ b/src/DebugInfoStrategy/NoDebugInfo.php @@ -0,0 +1,18 @@ +format($error); + $stack = iterator_to_array($this->streamStack($error->getPrevious()), false); + + if ($stack !== []) { + $debugInfo['stack'] = $stack; + } + + return $debugInfo; + } + + private function streamStack(?Throwable $previous): Generator + { + if ($previous === null) { + return; + } + + yield $this->format($previous); + yield from $this->streamStack($previous->getPrevious()); + } + + /** + * @return array + */ + private function format(Throwable $error): array + { + return [ + 'class' => get_class($error), + 'code' => $error->getCode(), + 'message' => $error->getMessage(), + 'file' => $error->getFile(), + 'line' => $error->getLine(), + ]; + } +} diff --git a/tests/DebugInfoStrategy/NoDebugInfoTest.php b/tests/DebugInfoStrategy/NoDebugInfoTest.php new file mode 100644 index 0000000..cdcca46 --- /dev/null +++ b/tests/DebugInfoStrategy/NoDebugInfoTest.php @@ -0,0 +1,26 @@ +extractDebugInfo(new RuntimeException())); + } +} diff --git a/tests/DebugInfoStrategy/NoTraceTest.php b/tests/DebugInfoStrategy/NoTraceTest.php new file mode 100644 index 0000000..e771651 --- /dev/null +++ b/tests/DebugInfoStrategy/NoTraceTest.php @@ -0,0 +1,88 @@ + RuntimeException::class, + 'code' => 11, + 'message' => 'Testing', + 'file' => __FILE__, + 'line' => __LINE__ + 2, + ], + $strategy->extractDebugInfo(new RuntimeException('Testing', 11)) + ); + } + + /** + * @test + * + * @covers ::extractDebugInfo + * @covers ::format + * @covers ::streamStack + */ + public function extractDebugInfoShouldAlsoReturnPreviousExceptions(): void + { + $strategy = new NoTrace(); + + self::assertSame( + [ + 'class' => RuntimeException::class, + 'code' => 11, + 'message' => 'Testing', + 'file' => __FILE__, + 'line' => __LINE__ + 19, + 'stack' => [ + [ + 'class' => InvalidArgumentException::class, + 'code' => 25, + 'message' => 'Oh no!', + 'file' => __FILE__, + 'line' => __LINE__ + 15, + ], + [ + 'class' => LogicException::class, + 'code' => 0, + 'message' => 'Bummer', + 'file' => __FILE__, + 'line' => __LINE__ + 11, + ], + ], + ], + $strategy->extractDebugInfo( + new RuntimeException( + 'Testing', + 11, + new InvalidArgumentException( + 'Oh no!', + 25, + new LogicException('Bummer') + ) + ) + ) + ); + } +} From ce1d1246762220c51116c8df739f45f9cc5cb4d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Cobucci?= Date: Thu, 27 Jun 2019 17:18:52 +0200 Subject: [PATCH 4/8] Add strategy to extract the status code from errors One of the main differentiators of this package is the usage of marker interfaces to perform the translation from error/exception to HTTP responses. This provides the extension point for that operation and a default strategy using a translation map (class/interface -> HTTP status code). --- src/StatusCodeExtractionStrategy.php | 11 ++ src/StatusCodeExtractionStrategy/ClassMap.php | 47 +++++++ .../ClassMapTest.php | 127 ++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 src/StatusCodeExtractionStrategy.php create mode 100644 src/StatusCodeExtractionStrategy/ClassMap.php create mode 100644 tests/StatusCodeExtractionStrategy/ClassMapTest.php diff --git a/src/StatusCodeExtractionStrategy.php b/src/StatusCodeExtractionStrategy.php new file mode 100644 index 0000000..9086e90 --- /dev/null +++ b/src/StatusCodeExtractionStrategy.php @@ -0,0 +1,11 @@ + StatusCodeInterface::STATUS_BAD_REQUEST, + Problem\AuthorizationRequired::class => StatusCodeInterface::STATUS_UNAUTHORIZED, + Problem\Forbidden::class => StatusCodeInterface::STATUS_FORBIDDEN, + Problem\ResourceNotFound::class => StatusCodeInterface::STATUS_NOT_FOUND, + Problem\Conflict::class => StatusCodeInterface::STATUS_CONFLICT, + Problem\ResourceNoLongerAvailable::class => StatusCodeInterface::STATUS_GONE, + Problem\UnprocessableRequest::class => StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY, + Problem\ServiceUnavailable::class => StatusCodeInterface::STATUS_SERVICE_UNAVAILABLE, + ]; + + /** + * @var array + */ + private $conversionMap; + + /** + * @param array $conversionMap + */ + public function __construct(array $conversionMap = self::DEFAULT_MAP) + { + $this->conversionMap = $conversionMap; + } + + public function extractStatusCode(Throwable $error): int + { + foreach ($this->conversionMap as $class => $code) { + if ($error instanceof $class) { + return $code; + } + } + + return $error->getCode() ?: StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR; + } +} diff --git a/tests/StatusCodeExtractionStrategy/ClassMapTest.php b/tests/StatusCodeExtractionStrategy/ClassMapTest.php new file mode 100644 index 0000000..a60dc3f --- /dev/null +++ b/tests/StatusCodeExtractionStrategy/ClassMapTest.php @@ -0,0 +1,127 @@ + StatusCodeInterface::STATUS_SERVICE_UNAVAILABLE]); + + self::assertSame( + StatusCodeInterface::STATUS_SERVICE_UNAVAILABLE, + $extractor->extractStatusCode(new RuntimeException()) + ); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::extractStatusCode + */ + public function extractStatusCodeShouldUseExceptionCodeWhenItIsNotSetInTheMap(): void + { + $extractor = new ClassMap([]); + + self::assertSame( + StatusCodeInterface::STATUS_SERVICE_UNAVAILABLE, + $extractor->extractStatusCode(new RuntimeException('', StatusCodeInterface::STATUS_SERVICE_UNAVAILABLE)) + ); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::extractStatusCode + */ + public function extractStatusCodeShouldFallbackToInternalServerError(): void + { + $extractor = new ClassMap([]); + + self::assertSame( + StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + $extractor->extractStatusCode(new RuntimeException()) + ); + } + + /** + * @test + * @dataProvider defaultConversions + * + * @covers ::__construct + * @covers ::extractStatusCode + */ + public function extractStatusCodeShouldUseDefaultClassMapWhenNothingIsProvided( + Throwable $error, + int $expected + ): void { + $extractor = new ClassMap(); + + self::assertSame($expected, $extractor->extractStatusCode($error)); + } + + /** + * @return array> + */ + public function defaultConversions(): iterable + { + yield Problem\InvalidRequest::class => [ + $this->createMock(Problem\InvalidRequest::class), + StatusCodeInterface::STATUS_BAD_REQUEST, + ]; + + yield Problem\AuthorizationRequired::class => [ + $this->createMock(Problem\AuthorizationRequired::class), + StatusCodeInterface::STATUS_UNAUTHORIZED, + ]; + + yield Problem\Forbidden::class => [ + $this->createMock(Problem\Forbidden::class), + StatusCodeInterface::STATUS_FORBIDDEN, + ]; + + yield Problem\ResourceNotFound::class => [ + $this->createMock(Problem\ResourceNotFound::class), + StatusCodeInterface::STATUS_NOT_FOUND, + ]; + + yield Problem\Conflict::class => [ + $this->createMock(Problem\Conflict::class), + StatusCodeInterface::STATUS_CONFLICT, + ]; + + yield Problem\ResourceNoLongerAvailable::class => [ + $this->createMock(Problem\ResourceNoLongerAvailable::class), + StatusCodeInterface::STATUS_GONE, + ]; + + yield Problem\UnprocessableRequest::class => [ + $this->createMock(Problem\UnprocessableRequest::class), + StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY, + ]; + + yield Problem\ServiceUnavailable::class => [ + $this->createMock(Problem\ServiceUnavailable::class), + StatusCodeInterface::STATUS_SERVICE_UNAVAILABLE, + ]; + } +} From 574cbf572ca0691d26e68960e91fc4e812bc9d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Cobucci?= Date: Thu, 27 Jun 2019 17:33:58 +0200 Subject: [PATCH 5/8] Add middleware to log errors This proposes the split between logging and converting errors into response. The idea is to always add a debug message with the error that happened, which is handy for both production and development. In combination with an access log middleware we can log errors or warnings, depending on the final response status code. For production, we assume that a fingers crossed handler is used so that only errors are sent to log - with enough information. More info: - https://github.com/middlewares/access-log/blob/master/src/AccessLog.php - https://github.com/Seldaek/monolog/blob/master/src/Monolog/Handler/FingersCrossedHandler.php --- src/ErrorLoggingMiddleware.php | 40 +++++++++++++++ tests/ErrorLoggingMiddlewareTest.php | 75 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/ErrorLoggingMiddleware.php create mode 100644 tests/ErrorLoggingMiddlewareTest.php diff --git a/src/ErrorLoggingMiddleware.php b/src/ErrorLoggingMiddleware.php new file mode 100644 index 0000000..c710794 --- /dev/null +++ b/src/ErrorLoggingMiddleware.php @@ -0,0 +1,40 @@ +logger = $logger; + } + + /** + * {@inheritDoc} + * + * @throws Throwable + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + try { + return $handler->handle($request); + } catch (Throwable $error) { + $this->logger->debug('Error happened while processing request', ['exception' => $error]); + + throw $error; + } + } +} diff --git a/tests/ErrorLoggingMiddlewareTest.php b/tests/ErrorLoggingMiddlewareTest.php new file mode 100644 index 0000000..0a189f3 --- /dev/null +++ b/tests/ErrorLoggingMiddlewareTest.php @@ -0,0 +1,75 @@ +logger = $this->createMock(LoggerInterface::class); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::process + */ + public function processShouldLogAllExceptionsOrErrorsThatHappenedDuringRequestHandling(): void + { + $error = new RuntimeException('Testing'); + + $this->logger->expects(self::once()) + ->method('debug') + ->with('Error happened while processing request', ['exception' => $error]); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('handle')->willThrowException($error); + + $middleware = new ErrorLoggingMiddleware($this->logger); + + $this->expectExceptionObject($error); + $middleware->process(new ServerRequest(), $handler); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::process + */ + public function processShouldReturnResponseWhenEverythingIsAlright(): void + { + $this->logger->expects(self::never())->method('debug'); + + $response = new Response(); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($response); + + $middleware = new ErrorLoggingMiddleware($this->logger); + + self::assertSame($response, $middleware->process(new ServerRequest(), $handler)); + } +} From a3b036ceba7ac8b52b370d93a3911599b8a5ed6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Cobucci?= Date: Thu, 27 Jun 2019 17:57:26 +0200 Subject: [PATCH 6/8] Add middleware to convert errors into unformatted responses Leaving the formatting to another middleware in the pipeline. More info: - https://github.com/lcobucci/content-negotiation-middleware --- src/ErrorConversionMiddleware.php | 106 ++++++++++ tests/ErrorConversionMiddlewareTest.php | 256 ++++++++++++++++++++++++ tests/SampleProblem/All.php | 33 +++ tests/SampleProblem/Detailed.php | 21 ++ tests/SampleProblem/Titled.php | 15 ++ tests/SampleProblem/Typed.php | 15 ++ 6 files changed, 446 insertions(+) create mode 100644 src/ErrorConversionMiddleware.php create mode 100644 tests/ErrorConversionMiddlewareTest.php create mode 100644 tests/SampleProblem/All.php create mode 100644 tests/SampleProblem/Detailed.php create mode 100644 tests/SampleProblem/Titled.php create mode 100644 tests/SampleProblem/Typed.php diff --git a/src/ErrorConversionMiddleware.php b/src/ErrorConversionMiddleware.php new file mode 100644 index 0000000..461416e --- /dev/null +++ b/src/ErrorConversionMiddleware.php @@ -0,0 +1,106 @@ + 'application/problem+json', + 'application/xml' => 'application/problem+xml', + ]; + + private const STATUS_URL = 'https://httpstatuses.com/'; + + /** + * @var ResponseFactoryInterface + */ + private $responseFactory; + + /** + * @var DebugInfoStrategy + */ + private $debugInfoStrategy; + + /** + * @var StatusCodeExtractionStrategy + */ + private $statusCodeExtractor; + + public function __construct( + ResponseFactoryInterface $responseFactory, + DebugInfoStrategy $debugInfoStrategy, + StatusCodeExtractionStrategy $statusCodeExtractor + ) { + $this->responseFactory = $responseFactory; + $this->debugInfoStrategy = $debugInfoStrategy; + $this->statusCodeExtractor = $statusCodeExtractor; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + try { + return $handler->handle($request); + } catch (Throwable $error) { + $response = $this->generateResponse($request, $error); + + return new UnformattedResponse( + $response, + $this->extractData($error, $response), + ['error' => $error] + ); + } + } + + private function generateResponse(ServerRequestInterface $request, Throwable $error): ResponseInterface + { + $response = $this->responseFactory->createResponse($this->statusCodeExtractor->extractStatusCode($error)); + + $accept = $request->getHeaderLine('Accept'); + + if (! array_key_exists($accept, self::CONTENT_TYPE_CONVERSION)) { + return $response; + } + + return $response->withAddedHeader( + 'Content-Type', + self::CONTENT_TYPE_CONVERSION[$accept] . '; charset=' . $request->getHeaderLine('Accept-Charset') + ); + } + + /** + * @return array + */ + private function extractData(Throwable $error, ResponseInterface $response): array + { + $data = [ + 'type' => $error instanceof Typed ? $error->getTypeUri() : self::STATUS_URL . $response->getStatusCode(), + 'title' => $error instanceof Titled ? $error->getTitle() : $response->getReasonPhrase(), + 'details' => $error->getMessage(), + ]; + + if ($error instanceof Detailed) { + $data += $error->getExtraDetails(); + } + + $debugInfo = $this->debugInfoStrategy->extractDebugInfo($error); + + if ($debugInfo !== null) { + $data['_debug'] = $debugInfo; + } + + return $data; + } +} diff --git a/tests/ErrorConversionMiddlewareTest.php b/tests/ErrorConversionMiddlewareTest.php new file mode 100644 index 0000000..0ebf151 --- /dev/null +++ b/tests/ErrorConversionMiddlewareTest.php @@ -0,0 +1,256 @@ +responseFactory = new ResponseFactory(); + $this->statusCodeExtractor = new ClassMap(); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::process + */ + public function processShouldJustReturnTheResponseWhenEverythingIsAlright(): void + { + $response = new Response(); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($response); + + $middleware = new ErrorConversionMiddleware( + $this->responseFactory, + new NoDebugInfo(), + $this->statusCodeExtractor + ); + + self::assertSame($response, $middleware->process(new ServerRequest(), $handler)); + } + + /** + * @test + * @dataProvider possibleConversions + * + * @covers ::__construct + * @covers ::process + * @covers ::generateResponse + * @covers ::extractData + * + * @param array $expectedData + */ + public function processShouldConvertTheExceptionIntoAnUnformattedResponseWithTheProblemDetails( + Throwable $error, + int $expectedStatusCode, + array $expectedData + ): void { + $response = $this->handleProcessWithError(new ServerRequest(), $error); + + self::assertInstanceOf(UnformattedResponse::class, $response); + self::assertSame($expectedStatusCode, $response->getStatusCode()); + self::assertSame($expectedData, $response->getUnformattedContent()); + } + + /** + * @return array>> + */ + public function possibleConversions(): iterable + { + yield 'no customisation' => [ + new RuntimeException('Item #1 was not found', StatusCodeInterface::STATUS_NOT_FOUND), + StatusCodeInterface::STATUS_NOT_FOUND, + [ + 'type' => 'https://httpstatuses.com/404', + 'title' => 'Not Found', + 'details' => 'Item #1 was not found', + ], + ]; + + yield 'typed exceptions' => [ + new SampleProblem\Typed( + 'Your current balance is 30, but that costs 50.', + StatusCodeInterface::STATUS_FORBIDDEN + ), + StatusCodeInterface::STATUS_FORBIDDEN, + [ + 'type' => 'https://example.com/probs/out-of-credit', + 'title' => 'Forbidden', + 'details' => 'Your current balance is 30, but that costs 50.', + ], + ]; + + yield 'titled exceptions' => [ + new SampleProblem\Titled( + 'Your current balance is 30, but that costs 50.', + StatusCodeInterface::STATUS_FORBIDDEN + ), + StatusCodeInterface::STATUS_FORBIDDEN, + [ + 'type' => 'https://httpstatuses.com/403', + 'title' => 'You do not have enough credit.', + 'details' => 'Your current balance is 30, but that costs 50.', + ], + ]; + + yield 'detailed exceptions' => [ + new SampleProblem\Detailed( + 'Your current balance is 30, but that costs 50.', + StatusCodeInterface::STATUS_FORBIDDEN + ), + StatusCodeInterface::STATUS_FORBIDDEN, + [ + 'type' => 'https://httpstatuses.com/403', + 'title' => 'Forbidden', + 'details' => 'Your current balance is 30, but that costs 50.', + 'balance' => 30, + 'cost' => 50, + ], + ]; + + yield 'typed+titled+detailed exceptions' => [ + new SampleProblem\All( + 'Your current balance is 30, but that costs 50.', + StatusCodeInterface::STATUS_FORBIDDEN + ), + StatusCodeInterface::STATUS_FORBIDDEN, + [ + 'type' => 'https://example.com/probs/out-of-credit', + 'title' => 'You do not have enough credit.', + 'details' => 'Your current balance is 30, but that costs 50.', + 'balance' => 30, + 'cost' => 50, + ], + ]; + } + + /** + * @test + * + * @covers ::__construct + * @covers ::process + * @covers ::generateResponse + * @covers ::extractData + */ + public function processShouldKeepOriginalErrorAsResponseAttribute(): void + { + $error = new RuntimeException(); + $response = $this->handleProcessWithError(new ServerRequest(), $error); + + self::assertInstanceOf(UnformattedResponse::class, $response); + + $attributes = $response->getAttributes(); + self::assertArrayHasKey('error', $attributes); + self::assertSame($error, $attributes['error']); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::process + * @covers ::generateResponse + * @covers ::extractData + */ + public function processShouldAddDebugInfoData(): void + { + $response = $this->handleProcessWithError(new ServerRequest(), new RuntimeException(), new NoTrace()); + + self::assertInstanceOf(UnformattedResponse::class, $response); + self::assertArrayHasKey('_debug', $response->getUnformattedContent()); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::process + * @covers ::generateResponse + * @covers ::extractData + */ + public function processShouldModifyTheContentTypeHeaderForJson(): void + { + $request = (new ServerRequest())->withAddedHeader('Accept', 'application/json') + ->withAddedHeader('Accept-Charset', 'UTF-8'); + + $response = $this->handleProcessWithError($request, new RuntimeException()); + + self::assertSame('application/problem+json; charset=UTF-8', $response->getHeaderLine('Content-Type')); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::process + * @covers ::generateResponse + * @covers ::extractData + */ + public function processShouldModifyTheContentTypeHeaderForXml(): void + { + $request = (new ServerRequest())->withAddedHeader('Accept', 'application/xml') + ->withAddedHeader('Accept-Charset', 'UTF-8'); + + $response = $this->handleProcessWithError($request, new RuntimeException()); + + self::assertSame('application/problem+xml; charset=UTF-8', $response->getHeaderLine('Content-Type')); + } + + private function handleProcessWithError( + ServerRequestInterface $request, + Throwable $error, + ?DebugInfoStrategy $debugInfoStrategy = null + ): ResponseInterface { + $middleware = new ErrorConversionMiddleware( + $this->responseFactory, + $debugInfoStrategy ?? new NoDebugInfo(), + $this->statusCodeExtractor + ); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('handle')->willThrowException($error); + + return $middleware->process($request, $handler); + } +} diff --git a/tests/SampleProblem/All.php b/tests/SampleProblem/All.php new file mode 100644 index 0000000..1e7ec1d --- /dev/null +++ b/tests/SampleProblem/All.php @@ -0,0 +1,33 @@ + 30, + 'cost' => 50, + ]; + } +} diff --git a/tests/SampleProblem/Detailed.php b/tests/SampleProblem/Detailed.php new file mode 100644 index 0000000..acd2934 --- /dev/null +++ b/tests/SampleProblem/Detailed.php @@ -0,0 +1,21 @@ + 30, + 'cost' => 50, + ]; + } +} diff --git a/tests/SampleProblem/Titled.php b/tests/SampleProblem/Titled.php new file mode 100644 index 0000000..ef3ad75 --- /dev/null +++ b/tests/SampleProblem/Titled.php @@ -0,0 +1,15 @@ + Date: Thu, 27 Jun 2019 17:58:26 +0200 Subject: [PATCH 7/8] Configure QA tools --- .gitignore | 2 +- infection.json.dist | 9 +++++++++ phpcs.xml.dist | 14 ++++++++++++++ phpstan.neon.dist | 11 +++++++++++ phpunit.xml.dist | 24 ++++++++++++++++++++++++ 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 infection.json.dist create mode 100644 phpcs.xml.dist create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist diff --git a/.gitignore b/.gitignore index 4403801..1ddc143 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /vendor/ /composer.lock /.phpcs.cache -/infection-log.txt +/infection.log /.phpunit.result.cache diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..e11488e --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,9 @@ +{ + "timeout": 1, + "source": { + "directories": ["src"] + }, + "logs": { + "text": "infection.log" + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..563b0c8 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,14 @@ + + + + + + + + + + src + tests + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..46705ff --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-phpunit/rules.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + - vendor/phpstan/phpstan-strict-rules/rules.neon + +parameters: + level: 7 + paths: + - src + - tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..bfc538f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + tests + + + + + + src + + + From f7853054bdced32bd86304b0f297ee10cd7bcec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Cobucci?= Date: Thu, 27 Jun 2019 17:59:29 +0200 Subject: [PATCH 8/8] Configure CI tools --- .scrutinizer.yml | 38 ++++++++++++++++++++++++++++++++ .travis.yml | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 .scrutinizer.yml create mode 100644 .travis.yml diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..d754df6 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,38 @@ +build: + environment: + mysql: false + postgresql: false + redis: false + rabbitmq: false + mongodb: false + php: + version: 7.3 + + cache: + disabled: false + directories: + - ~/.composer/cache + + dependencies: + override: + - composer install --no-interaction --prefer-dist + + nodes: + analysis: + project_setup: + override: true + tests: + override: + - php-scrutinizer-run + - phpcs-run + +checks: + php : true + +tools: + external_code_coverage: true + +build_failure_conditions: + - 'elements.rating(<= C).new.exists' + - 'issues.severity(>= MAJOR).new.exists' + - 'project.metric_change("scrutinizer.test_coverage", < -0.01)' diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..cf7f671 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,56 @@ +dist: trusty +sudo: false +language: php + +php: + - 7.3 + - 7.4snapshot + - nightly + +cache: + directories: + - $HOME/.composer/cache + +before_install: + - mv ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini{,.disabled} || echo "xdebug not available" + - composer self-update + +install: travis_retry composer install + +script: + - ./vendor/bin/phpunit + +jobs: + allow_failures: + - php: 7.4snapshot + - php: nightly + + include: + - stage: Code Quality + env: TEST_COVERAGE=1 + before_script: + - mv ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini{.disabled,} + - if [[ ! $(php -m | grep -si xdebug) ]]; then echo "xdebug required for coverage"; exit 1; fi + script: + - ./vendor/bin/phpunit --coverage-clover ./clover.xml + after_script: + - wget https://scrutinizer-ci.com/ocular.phar + - php ocular.phar code-coverage:upload --format=php-clover ./clover.xml + + - stage: Code Quality + env: CODE_STANDARD=1 + script: + - ./vendor/bin/phpcs + + - stage: Code Quality + env: STATIC_ANALYSIS=1 + script: + - ./vendor/bin/phpstan analyse + + - stage: Code Quality + env: MUTATION_TESTS=1 + before_script: + - mv ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini{.disabled,} + - if [[ ! $(php -m | grep -si xdebug) ]]; then echo "xdebug required for mutation tests"; exit 1; fi + script: + - ./vendor/bin/infection --threads=$(nproc) --min-msi=100 --min-covered-msi=100