Skip to content

Commit a3b036c

Browse files
committed
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
1 parent 574cbf5 commit a3b036c

File tree

6 files changed

+446
-0
lines changed

6 files changed

+446
-0
lines changed

src/ErrorConversionMiddleware.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Lcobucci\ErrorHandling;
5+
6+
use Lcobucci\ContentNegotiation\UnformattedResponse;
7+
use Lcobucci\ErrorHandling\Problem\Detailed;
8+
use Lcobucci\ErrorHandling\Problem\Titled;
9+
use Lcobucci\ErrorHandling\Problem\Typed;
10+
use Psr\Http\Message\ResponseFactoryInterface;
11+
use Psr\Http\Message\ResponseInterface;
12+
use Psr\Http\Message\ServerRequestInterface;
13+
use Psr\Http\Server\MiddlewareInterface;
14+
use Psr\Http\Server\RequestHandlerInterface;
15+
use Throwable;
16+
use function array_key_exists;
17+
18+
final class ErrorConversionMiddleware implements MiddlewareInterface
19+
{
20+
private const CONTENT_TYPE_CONVERSION = [
21+
'application/json' => 'application/problem+json',
22+
'application/xml' => 'application/problem+xml',
23+
];
24+
25+
private const STATUS_URL = 'https://httpstatuses.com/';
26+
27+
/**
28+
* @var ResponseFactoryInterface
29+
*/
30+
private $responseFactory;
31+
32+
/**
33+
* @var DebugInfoStrategy
34+
*/
35+
private $debugInfoStrategy;
36+
37+
/**
38+
* @var StatusCodeExtractionStrategy
39+
*/
40+
private $statusCodeExtractor;
41+
42+
public function __construct(
43+
ResponseFactoryInterface $responseFactory,
44+
DebugInfoStrategy $debugInfoStrategy,
45+
StatusCodeExtractionStrategy $statusCodeExtractor
46+
) {
47+
$this->responseFactory = $responseFactory;
48+
$this->debugInfoStrategy = $debugInfoStrategy;
49+
$this->statusCodeExtractor = $statusCodeExtractor;
50+
}
51+
52+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
53+
{
54+
try {
55+
return $handler->handle($request);
56+
} catch (Throwable $error) {
57+
$response = $this->generateResponse($request, $error);
58+
59+
return new UnformattedResponse(
60+
$response,
61+
$this->extractData($error, $response),
62+
['error' => $error]
63+
);
64+
}
65+
}
66+
67+
private function generateResponse(ServerRequestInterface $request, Throwable $error): ResponseInterface
68+
{
69+
$response = $this->responseFactory->createResponse($this->statusCodeExtractor->extractStatusCode($error));
70+
71+
$accept = $request->getHeaderLine('Accept');
72+
73+
if (! array_key_exists($accept, self::CONTENT_TYPE_CONVERSION)) {
74+
return $response;
75+
}
76+
77+
return $response->withAddedHeader(
78+
'Content-Type',
79+
self::CONTENT_TYPE_CONVERSION[$accept] . '; charset=' . $request->getHeaderLine('Accept-Charset')
80+
);
81+
}
82+
83+
/**
84+
* @return array<string, mixed>
85+
*/
86+
private function extractData(Throwable $error, ResponseInterface $response): array
87+
{
88+
$data = [
89+
'type' => $error instanceof Typed ? $error->getTypeUri() : self::STATUS_URL . $response->getStatusCode(),
90+
'title' => $error instanceof Titled ? $error->getTitle() : $response->getReasonPhrase(),
91+
'details' => $error->getMessage(),
92+
];
93+
94+
if ($error instanceof Detailed) {
95+
$data += $error->getExtraDetails();
96+
}
97+
98+
$debugInfo = $this->debugInfoStrategy->extractDebugInfo($error);
99+
100+
if ($debugInfo !== null) {
101+
$data['_debug'] = $debugInfo;
102+
}
103+
104+
return $data;
105+
}
106+
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Lcobucci\ErrorHandling\Tests;
5+
6+
use Fig\Http\Message\StatusCodeInterface;
7+
use Lcobucci\ContentNegotiation\UnformattedResponse;
8+
use Lcobucci\ErrorHandling\DebugInfoStrategy;
9+
use Lcobucci\ErrorHandling\DebugInfoStrategy\NoDebugInfo;
10+
use Lcobucci\ErrorHandling\DebugInfoStrategy\NoTrace;
11+
use Lcobucci\ErrorHandling\ErrorConversionMiddleware;
12+
use Lcobucci\ErrorHandling\StatusCodeExtractionStrategy\ClassMap;
13+
use PHPUnit\Framework\TestCase;
14+
use Psr\Http\Message\ResponseInterface;
15+
use Psr\Http\Message\ServerRequestInterface;
16+
use Psr\Http\Server\RequestHandlerInterface;
17+
use RuntimeException;
18+
use Throwable;
19+
use Zend\Diactoros\Response;
20+
use Zend\Diactoros\ResponseFactory;
21+
use Zend\Diactoros\ServerRequest;
22+
23+
/**
24+
* @coversDefaultClass \Lcobucci\ErrorHandling\ErrorConversionMiddleware
25+
*
26+
* @uses \Lcobucci\ErrorHandling\DebugInfoStrategy\NoDebugInfo
27+
* @uses \Lcobucci\ErrorHandling\DebugInfoStrategy\NoTrace
28+
* @uses \Lcobucci\ErrorHandling\StatusCodeExtractionStrategy\ClassMap
29+
*/
30+
final class ErrorConversionMiddlewareTest extends TestCase
31+
{
32+
/**
33+
* @var ResponseFactory
34+
*/
35+
private $responseFactory;
36+
37+
/**
38+
* @var ClassMap
39+
*/
40+
private $statusCodeExtractor;
41+
42+
/**
43+
* @before
44+
*/
45+
public function createDependencies(): void
46+
{
47+
$this->responseFactory = new ResponseFactory();
48+
$this->statusCodeExtractor = new ClassMap();
49+
}
50+
51+
/**
52+
* @test
53+
*
54+
* @covers ::__construct
55+
* @covers ::process
56+
*/
57+
public function processShouldJustReturnTheResponseWhenEverythingIsAlright(): void
58+
{
59+
$response = new Response();
60+
61+
$handler = $this->createMock(RequestHandlerInterface::class);
62+
$handler->method('handle')->willReturn($response);
63+
64+
$middleware = new ErrorConversionMiddleware(
65+
$this->responseFactory,
66+
new NoDebugInfo(),
67+
$this->statusCodeExtractor
68+
);
69+
70+
self::assertSame($response, $middleware->process(new ServerRequest(), $handler));
71+
}
72+
73+
/**
74+
* @test
75+
* @dataProvider possibleConversions
76+
*
77+
* @covers ::__construct
78+
* @covers ::process
79+
* @covers ::generateResponse
80+
* @covers ::extractData
81+
*
82+
* @param array<string, mixed> $expectedData
83+
*/
84+
public function processShouldConvertTheExceptionIntoAnUnformattedResponseWithTheProblemDetails(
85+
Throwable $error,
86+
int $expectedStatusCode,
87+
array $expectedData
88+
): void {
89+
$response = $this->handleProcessWithError(new ServerRequest(), $error);
90+
91+
self::assertInstanceOf(UnformattedResponse::class, $response);
92+
self::assertSame($expectedStatusCode, $response->getStatusCode());
93+
self::assertSame($expectedData, $response->getUnformattedContent());
94+
}
95+
96+
/**
97+
* @return array<string, array<Throwable|array<string, mixed>>>
98+
*/
99+
public function possibleConversions(): iterable
100+
{
101+
yield 'no customisation' => [
102+
new RuntimeException('Item #1 was not found', StatusCodeInterface::STATUS_NOT_FOUND),
103+
StatusCodeInterface::STATUS_NOT_FOUND,
104+
[
105+
'type' => 'https://httpstatuses.com/404',
106+
'title' => 'Not Found',
107+
'details' => 'Item #1 was not found',
108+
],
109+
];
110+
111+
yield 'typed exceptions' => [
112+
new SampleProblem\Typed(
113+
'Your current balance is 30, but that costs 50.',
114+
StatusCodeInterface::STATUS_FORBIDDEN
115+
),
116+
StatusCodeInterface::STATUS_FORBIDDEN,
117+
[
118+
'type' => 'https://example.com/probs/out-of-credit',
119+
'title' => 'Forbidden',
120+
'details' => 'Your current balance is 30, but that costs 50.',
121+
],
122+
];
123+
124+
yield 'titled exceptions' => [
125+
new SampleProblem\Titled(
126+
'Your current balance is 30, but that costs 50.',
127+
StatusCodeInterface::STATUS_FORBIDDEN
128+
),
129+
StatusCodeInterface::STATUS_FORBIDDEN,
130+
[
131+
'type' => 'https://httpstatuses.com/403',
132+
'title' => 'You do not have enough credit.',
133+
'details' => 'Your current balance is 30, but that costs 50.',
134+
],
135+
];
136+
137+
yield 'detailed exceptions' => [
138+
new SampleProblem\Detailed(
139+
'Your current balance is 30, but that costs 50.',
140+
StatusCodeInterface::STATUS_FORBIDDEN
141+
),
142+
StatusCodeInterface::STATUS_FORBIDDEN,
143+
[
144+
'type' => 'https://httpstatuses.com/403',
145+
'title' => 'Forbidden',
146+
'details' => 'Your current balance is 30, but that costs 50.',
147+
'balance' => 30,
148+
'cost' => 50,
149+
],
150+
];
151+
152+
yield 'typed+titled+detailed exceptions' => [
153+
new SampleProblem\All(
154+
'Your current balance is 30, but that costs 50.',
155+
StatusCodeInterface::STATUS_FORBIDDEN
156+
),
157+
StatusCodeInterface::STATUS_FORBIDDEN,
158+
[
159+
'type' => 'https://example.com/probs/out-of-credit',
160+
'title' => 'You do not have enough credit.',
161+
'details' => 'Your current balance is 30, but that costs 50.',
162+
'balance' => 30,
163+
'cost' => 50,
164+
],
165+
];
166+
}
167+
168+
/**
169+
* @test
170+
*
171+
* @covers ::__construct
172+
* @covers ::process
173+
* @covers ::generateResponse
174+
* @covers ::extractData
175+
*/
176+
public function processShouldKeepOriginalErrorAsResponseAttribute(): void
177+
{
178+
$error = new RuntimeException();
179+
$response = $this->handleProcessWithError(new ServerRequest(), $error);
180+
181+
self::assertInstanceOf(UnformattedResponse::class, $response);
182+
183+
$attributes = $response->getAttributes();
184+
self::assertArrayHasKey('error', $attributes);
185+
self::assertSame($error, $attributes['error']);
186+
}
187+
188+
/**
189+
* @test
190+
*
191+
* @covers ::__construct
192+
* @covers ::process
193+
* @covers ::generateResponse
194+
* @covers ::extractData
195+
*/
196+
public function processShouldAddDebugInfoData(): void
197+
{
198+
$response = $this->handleProcessWithError(new ServerRequest(), new RuntimeException(), new NoTrace());
199+
200+
self::assertInstanceOf(UnformattedResponse::class, $response);
201+
self::assertArrayHasKey('_debug', $response->getUnformattedContent());
202+
}
203+
204+
/**
205+
* @test
206+
*
207+
* @covers ::__construct
208+
* @covers ::process
209+
* @covers ::generateResponse
210+
* @covers ::extractData
211+
*/
212+
public function processShouldModifyTheContentTypeHeaderForJson(): void
213+
{
214+
$request = (new ServerRequest())->withAddedHeader('Accept', 'application/json')
215+
->withAddedHeader('Accept-Charset', 'UTF-8');
216+
217+
$response = $this->handleProcessWithError($request, new RuntimeException());
218+
219+
self::assertSame('application/problem+json; charset=UTF-8', $response->getHeaderLine('Content-Type'));
220+
}
221+
222+
/**
223+
* @test
224+
*
225+
* @covers ::__construct
226+
* @covers ::process
227+
* @covers ::generateResponse
228+
* @covers ::extractData
229+
*/
230+
public function processShouldModifyTheContentTypeHeaderForXml(): void
231+
{
232+
$request = (new ServerRequest())->withAddedHeader('Accept', 'application/xml')
233+
->withAddedHeader('Accept-Charset', 'UTF-8');
234+
235+
$response = $this->handleProcessWithError($request, new RuntimeException());
236+
237+
self::assertSame('application/problem+xml; charset=UTF-8', $response->getHeaderLine('Content-Type'));
238+
}
239+
240+
private function handleProcessWithError(
241+
ServerRequestInterface $request,
242+
Throwable $error,
243+
?DebugInfoStrategy $debugInfoStrategy = null
244+
): ResponseInterface {
245+
$middleware = new ErrorConversionMiddleware(
246+
$this->responseFactory,
247+
$debugInfoStrategy ?? new NoDebugInfo(),
248+
$this->statusCodeExtractor
249+
);
250+
251+
$handler = $this->createMock(RequestHandlerInterface::class);
252+
$handler->method('handle')->willThrowException($error);
253+
254+
return $middleware->process($request, $handler);
255+
}
256+
}

tests/SampleProblem/All.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Lcobucci\ErrorHandling\Tests\SampleProblem;
5+
6+
use Lcobucci\ErrorHandling\Problem\Detailed as DetailedInterface;
7+
use Lcobucci\ErrorHandling\Problem\Titled as TitledInterface;
8+
use Lcobucci\ErrorHandling\Problem\Typed as TypedInterface;
9+
use RuntimeException;
10+
11+
final class All extends RuntimeException implements TypedInterface, TitledInterface, DetailedInterface
12+
{
13+
public function getTypeUri(): string
14+
{
15+
return 'https://example.com/probs/out-of-credit';
16+
}
17+
18+
public function getTitle(): string
19+
{
20+
return 'You do not have enough credit.';
21+
}
22+
23+
/**
24+
* {@inheritDoc}
25+
*/
26+
public function getExtraDetails(): array
27+
{
28+
return [
29+
'balance' => 30,
30+
'cost' => 50,
31+
];
32+
}
33+
}

0 commit comments

Comments
 (0)