Skip to content

Commit 0f022b6

Browse files
committed
Add ErrorResponseGenerator
1 parent 3fb2f87 commit 0f022b6

File tree

3 files changed

+392
-0
lines changed

3 files changed

+392
-0
lines changed

src/ErrorResponseGenerator.php

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HttpSoft\ErrorHandler;
6+
7+
use HttpSoft\Response\HtmlResponse;
8+
use HttpSoft\Response\JsonResponse;
9+
use HttpSoft\Response\ResponseStatusCodeInterface;
10+
use HttpSoft\Response\TextResponse;
11+
use HttpSoft\Response\XmlResponse;
12+
use Psr\Http\Message\ResponseInterface;
13+
use Psr\Http\Message\ServerRequestInterface;
14+
use Throwable;
15+
16+
use function array_key_exists;
17+
use function array_keys;
18+
use function explode;
19+
use function preg_match;
20+
use function strtolower;
21+
use function trim;
22+
use function uasort;
23+
24+
final class ErrorResponseGenerator implements ErrorResponseGeneratorInterface, ResponseStatusCodeInterface
25+
{
26+
/**
27+
* {@inheritDoc}
28+
*/
29+
public function generate(Throwable $error, ServerRequestInterface $request): ResponseInterface
30+
{
31+
$errorCode = (int) $error->getCode();
32+
$responseCode = self::STATUS_INTERNAL_SERVER_ERROR;
33+
34+
if ($errorCode >= 400 && $errorCode < 600 && array_key_exists($errorCode, self::PHRASES)) {
35+
$responseCode = $errorCode;
36+
}
37+
38+
$requestMimeTypes = $this->getSortedMimeTypesByRequest($request);
39+
return $this->getResponse($responseCode, self::PHRASES[$responseCode], $requestMimeTypes);
40+
}
41+
42+
/**
43+
* @param int $code
44+
* @param string $message
45+
* @param string[] $mimeTypes
46+
* @return ResponseInterface
47+
*/
48+
private function getResponse(int $code, string $message, array $mimeTypes): ResponseInterface
49+
{
50+
foreach ($mimeTypes as $mimeType) {
51+
if ($mimeType === 'text/html' || $mimeType === '*/*') {
52+
return $this->getHtmlResponse($code, $message);
53+
}
54+
55+
if ($mimeType === 'text/plain') {
56+
return new TextResponse("Error {$code} - {$message}", $code);
57+
}
58+
59+
if ($mimeType === 'application/json') {
60+
return new JsonResponse(['name' => 'Error', 'code' => $code, 'message' => $message], $code);
61+
}
62+
63+
if ($mimeType === 'application/xml' || $mimeType === 'text/xml') {
64+
$xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>';
65+
$xml .= "\n<error>\n<code>{$code}</code>\n<message>{$message}</message>\n</error>";
66+
return new XmlResponse($xml, $code);
67+
}
68+
}
69+
70+
return $this->getHtmlResponse($code, $message);
71+
}
72+
73+
/**
74+
* @param int $code
75+
* @param string $message
76+
* @return HtmlResponse
77+
*/
78+
private function getHtmlResponse(int $code, string $message): HtmlResponse
79+
{
80+
$title = "Error {$code} - {$message}";
81+
$html = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>' . $title . '</title></head>';
82+
$html .= '<body style="padding:20px 10px"><h1 style="text-align:center">' . $title . '</h1></body></html>';
83+
return new HtmlResponse($html, $code);
84+
}
85+
86+
/**
87+
* @param ServerRequestInterface $request
88+
* @return string[]
89+
*/
90+
private function getSortedMimeTypesByRequest(ServerRequestInterface $request): array
91+
{
92+
if (!$acceptParameters = $request->getHeaderLine('accept')) {
93+
return [];
94+
}
95+
96+
$mimeTypes = [];
97+
98+
foreach (explode(',', $acceptParameters) as $acceptParameter) {
99+
$parts = explode(';', $acceptParameter);
100+
101+
if (!isset($parts[0]) || isset($mimeTypes[$parts[0]]) || !($mimeType = strtolower(trim($parts[0])))) {
102+
continue;
103+
}
104+
105+
if (!isset($parts[1])) {
106+
$mimeTypes[$mimeType] = 1.0;
107+
continue;
108+
}
109+
110+
if (preg_match('/^\s*q=\s*(0(?:\.\d{1,3})?|1(?:\.0{1,3})?)\s*$/i', $parts[1], $matches)) {
111+
$mimeTypes[$mimeType] = (float) ($matches[1] ?? 1.0);
112+
}
113+
}
114+
115+
uasort($mimeTypes, static fn(float $a, float $b) => ($a === $b) ? 0 : ($a > $b ? -1 : 1));
116+
return array_keys($mimeTypes);
117+
}
118+
}

tests/.gitkeep

Whitespace-only changes.
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HttpSoft\Tests\ErrorHandler;
6+
7+
use Exception;
8+
use HttpSoft\ErrorHandler\ErrorResponseGenerator;
9+
use HttpSoft\Request\ServerRequest;
10+
use HttpSoft\Response\HtmlResponse;
11+
use HttpSoft\Response\JsonResponse;
12+
use HttpSoft\Response\ResponseStatusCodeInterface;
13+
use HttpSoft\Response\TextResponse;
14+
use HttpSoft\Response\XmlResponse;
15+
use PHPUnit\Framework\TestCase;
16+
use Psr\Http\Message\ResponseInterface;
17+
use Psr\Http\Message\ServerRequestInterface;
18+
use Throwable;
19+
20+
class ErrorResponseGeneratorTest extends TestCase implements ResponseStatusCodeInterface
21+
{
22+
private ErrorResponseGenerator $generator;
23+
24+
public function setUp(): void
25+
{
26+
$this->generator = new ErrorResponseGenerator();
27+
}
28+
29+
public function testGenerateByDefault(): void
30+
{
31+
$response = $this->generateResponse();
32+
$this->assertInstanceOf(ResponseInterface::class, $response);
33+
$this->assertInstanceOf(HtmlResponse::class, $response);
34+
$this->assertSame(self::STATUS_INTERNAL_SERVER_ERROR, $response->getStatusCode());
35+
$this->assertSame(self::PHRASES[self::STATUS_INTERNAL_SERVER_ERROR], $response->getReasonPhrase());
36+
}
37+
38+
public function testGenerateWithNotSupportedAcceptHeader(): void
39+
{
40+
$response = $this->generateResponse('image/webp');
41+
$this->assertInstanceOf(ResponseInterface::class, $response);
42+
$this->assertInstanceOf(HtmlResponse::class, $response);
43+
$this->assertSame(self::STATUS_INTERNAL_SERVER_ERROR, $response->getStatusCode());
44+
$this->assertSame(self::PHRASES[self::STATUS_INTERNAL_SERVER_ERROR], $response->getReasonPhrase());
45+
}
46+
47+
public function testGenerateWithNotSupportedErrorCode(): void
48+
{
49+
$response = $this->generateResponse('application/json,text/html;q=0.9,image/webp,*/*;q=0.8', 399);
50+
$this->assertInstanceOf(ResponseInterface::class, $response);
51+
$this->assertInstanceOf(JsonResponse::class, $response);
52+
$this->assertSame(self::STATUS_INTERNAL_SERVER_ERROR, $response->getStatusCode());
53+
$this->assertSame(self::PHRASES[self::STATUS_INTERNAL_SERVER_ERROR], $response->getReasonPhrase());
54+
55+
$response = $this->generateResponse(',application/xml,text/html;q=0.9,image/webp,*/*;q=0.8', 600);
56+
$this->assertInstanceOf(ResponseInterface::class, $response);
57+
$this->assertInstanceOf(XmlResponse::class, $response);
58+
$this->assertSame(self::STATUS_INTERNAL_SERVER_ERROR, $response->getStatusCode());
59+
$this->assertSame(self::PHRASES[self::STATUS_INTERNAL_SERVER_ERROR], $response->getReasonPhrase());
60+
}
61+
62+
public function testGenerateWithSupportedAcceptHeaderAndErrorCode(): void
63+
{
64+
$response = $this->generateResponse('application/json', self::STATUS_BAD_REQUEST);
65+
$this->assertInstanceOf(ResponseInterface::class, $response);
66+
$this->assertInstanceOf(JsonResponse::class, $response);
67+
$this->assertSame(self::STATUS_BAD_REQUEST, $response->getStatusCode());
68+
$this->assertSame(self::PHRASES[self::STATUS_BAD_REQUEST], $response->getReasonPhrase());
69+
$this->assertSame('{"name":"Error","code":400,"message":"Bad Request"}', (string) $response->getBody());
70+
71+
$response = $this->generateResponse('text/plain', self::STATUS_NETWORK_AUTHENTICATION_REQUIRED);
72+
$this->assertInstanceOf(ResponseInterface::class, $response);
73+
$this->assertInstanceOf(TextResponse::class, $response);
74+
$this->assertSame(self::STATUS_NETWORK_AUTHENTICATION_REQUIRED, $response->getStatusCode());
75+
$this->assertSame(self::PHRASES[self::STATUS_NETWORK_AUTHENTICATION_REQUIRED], $response->getReasonPhrase());
76+
$this->assertSame('Error 511 - Network Authentication Required', (string) $response->getBody());
77+
}
78+
79+
/**
80+
* @return array
81+
*/
82+
public function supportedErrorCodeAllProvider(): array
83+
{
84+
return [
85+
// Client Errors 4xx
86+
self::STATUS_BAD_REQUEST => [self::STATUS_BAD_REQUEST],
87+
self::STATUS_UNAUTHORIZED => [self::STATUS_UNAUTHORIZED],
88+
self::STATUS_PAYMENT_REQUIRED => [self::STATUS_PAYMENT_REQUIRED],
89+
self::STATUS_FORBIDDEN => [self::STATUS_FORBIDDEN],
90+
self::STATUS_NOT_FOUND => [self::STATUS_NOT_FOUND],
91+
self::STATUS_METHOD_NOT_ALLOWED => [self::STATUS_METHOD_NOT_ALLOWED],
92+
self::STATUS_NOT_ACCEPTABLE => [self::STATUS_NOT_ACCEPTABLE],
93+
self::STATUS_PROXY_AUTHENTICATION_REQUIRED => [self::STATUS_PROXY_AUTHENTICATION_REQUIRED],
94+
self::STATUS_REQUEST_TIMEOUT => [self::STATUS_REQUEST_TIMEOUT],
95+
self::STATUS_CONFLICT => [self::STATUS_CONFLICT],
96+
self::STATUS_GONE => [self::STATUS_GONE],
97+
self::STATUS_LENGTH_REQUIRED => [self::STATUS_LENGTH_REQUIRED],
98+
self::STATUS_PRECONDITION_FAILED => [self::STATUS_PRECONDITION_FAILED],
99+
self::STATUS_PAYLOAD_TOO_LARGE => [self::STATUS_PAYLOAD_TOO_LARGE],
100+
self::STATUS_URI_TOO_LONG => [self::STATUS_URI_TOO_LONG],
101+
self::STATUS_UNSUPPORTED_MEDIA_TYPE => [self::STATUS_UNSUPPORTED_MEDIA_TYPE],
102+
self::STATUS_RANGE_NOT_SATISFIABLE => [self::STATUS_RANGE_NOT_SATISFIABLE],
103+
self::STATUS_EXPECTATION_FAILED => [self::STATUS_EXPECTATION_FAILED],
104+
self::STATUS_IM_A_TEAPOT => [self::STATUS_IM_A_TEAPOT],
105+
self::STATUS_MISDIRECTED_REQUEST => [self::STATUS_MISDIRECTED_REQUEST],
106+
self::STATUS_UNPROCESSABLE_ENTITY => [self::STATUS_UNPROCESSABLE_ENTITY],
107+
self::STATUS_LOCKED => [self::STATUS_LOCKED],
108+
self::STATUS_FAILED_DEPENDENCY => [self::STATUS_FAILED_DEPENDENCY],
109+
self::STATUS_TOO_EARLY => [self::STATUS_TOO_EARLY],
110+
self::STATUS_UPGRADE_REQUIRED => [self::STATUS_UPGRADE_REQUIRED],
111+
self::STATUS_PRECONDITION_REQUIRED => [self::STATUS_PRECONDITION_REQUIRED],
112+
self::STATUS_TOO_MANY_REQUESTS => [self::STATUS_TOO_MANY_REQUESTS],
113+
self::STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE => [self::STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE],
114+
self::STATUS_UNAVAILABLE_FOR_LEGAL_REASONS => [self::STATUS_UNAVAILABLE_FOR_LEGAL_REASONS],
115+
// Server Errors 5xx
116+
self::STATUS_INTERNAL_SERVER_ERROR => [self::STATUS_INTERNAL_SERVER_ERROR],
117+
self::STATUS_NOT_IMPLEMENTED => [self::STATUS_NOT_IMPLEMENTED],
118+
self::STATUS_BAD_GATEWAY => [self::STATUS_BAD_GATEWAY],
119+
self::STATUS_SERVICE_UNAVAILABLE => [self::STATUS_SERVICE_UNAVAILABLE],
120+
self::STATUS_GATEWAY_TIMEOUT => [self::STATUS_GATEWAY_TIMEOUT],
121+
self::STATUS_VERSION_NOT_SUPPORTED => [self::STATUS_VERSION_NOT_SUPPORTED],
122+
self::STATUS_VARIANT_ALSO_NEGOTIATES => [self::STATUS_VARIANT_ALSO_NEGOTIATES],
123+
self::STATUS_INSUFFICIENT_STORAGE => [self::STATUS_INSUFFICIENT_STORAGE],
124+
self::STATUS_LOOP_DETECTED => [self::STATUS_LOOP_DETECTED],
125+
self::STATUS_NOT_EXTENDED => [self::STATUS_NOT_EXTENDED],
126+
self::STATUS_NETWORK_AUTHENTICATION_REQUIRED => [self::STATUS_NETWORK_AUTHENTICATION_REQUIRED],
127+
];
128+
}
129+
130+
/**
131+
* @dataProvider supportedErrorCodeAllProvider
132+
* @param int $statusCode
133+
*/
134+
public function testGenerateWithSupportedAndErrorCodeAll(int $statusCode): void
135+
{
136+
$reasonPhrase = self::PHRASES[$statusCode];
137+
$response = $this->generateResponse('text/plain', $statusCode);
138+
$this->assertInstanceOf(ResponseInterface::class, $response);
139+
$this->assertInstanceOf(TextResponse::class, $response);
140+
$this->assertSame($statusCode, $response->getStatusCode());
141+
$this->assertSame($reasonPhrase, $response->getReasonPhrase());
142+
$this->assertSame("Error {$statusCode} - {$reasonPhrase}", (string) $response->getBody());
143+
}
144+
145+
/**
146+
* @return array
147+
*/
148+
public function invalidAcceptHeaderQualityExceptTextPlainProvider(): array
149+
{
150+
return [
151+
['application/json;q=10,*/*;q=9.0,text/plain;q=1.0,'],
152+
['text/html;q=0.0000,text/html;q=0.9876,text/plain;q=0.000'],
153+
['application/xml;q=1.0000,text/html;q=1.001,text/plain;Q=1.000'],
154+
['application/json;q=string,text/html;q=9876,text/plain;q=0.999'],
155+
];
156+
}
157+
158+
/**
159+
* @dataProvider invalidAcceptHeaderQualityExceptTextPlainProvider
160+
* @param string $acceptHeaderValue
161+
*/
162+
public function testGenerateWithInvalidAcceptHeaderQualityExceptTextPlain(string $acceptHeaderValue): void
163+
{
164+
$response = $this->generateResponse($acceptHeaderValue, self::STATUS_INTERNAL_SERVER_ERROR);
165+
$this->assertInstanceOf(ResponseInterface::class, $response);
166+
$this->assertInstanceOf(TextResponse::class, $response);
167+
$this->assertSame(self::STATUS_INTERNAL_SERVER_ERROR, $response->getStatusCode());
168+
$this->assertSame(self::PHRASES[self::STATUS_INTERNAL_SERVER_ERROR], $response->getReasonPhrase());
169+
}
170+
171+
public function testGenerateHtmlResponse(): void
172+
{
173+
$response = $this->generateResponse();
174+
$this->assertInstanceOf(ResponseInterface::class, $response);
175+
$this->assertInstanceOf(HtmlResponse::class, $response);
176+
177+
$response = $this->generateResponse('text/html');
178+
$this->assertInstanceOf(ResponseInterface::class, $response);
179+
$this->assertInstanceOf(HtmlResponse::class, $response);
180+
181+
$response = $this->generateResponse('*/*;q=0.9, application/json;q=0.89');
182+
$this->assertInstanceOf(ResponseInterface::class, $response);
183+
$this->assertInstanceOf(HtmlResponse::class, $response);
184+
185+
$response = $this->generateResponse('text/html, text/plain, application/json');
186+
$this->assertInstanceOf(ResponseInterface::class, $response);
187+
$this->assertInstanceOf(HtmlResponse::class, $response);
188+
189+
$response = $this->generateResponse('application/json;q=0.99, text/html, text/plain;q=0.98');
190+
$this->assertInstanceOf(ResponseInterface::class, $response);
191+
$this->assertInstanceOf(HtmlResponse::class, $response);
192+
}
193+
194+
public function testGenerateTextResponse(): void
195+
{
196+
$response = $this->generateResponse('text/plain');
197+
$this->assertInstanceOf(ResponseInterface::class, $response);
198+
$this->assertInstanceOf(TextResponse::class, $response);
199+
200+
$response = $this->generateResponse('text/plain, text/html, application/json');
201+
$this->assertInstanceOf(ResponseInterface::class, $response);
202+
$this->assertInstanceOf(TextResponse::class, $response);
203+
204+
$response = $this->generateResponse('application/json;q=0.99, text/plain, text/html;q=0.98');
205+
$this->assertInstanceOf(ResponseInterface::class, $response);
206+
$this->assertInstanceOf(TextResponse::class, $response);
207+
}
208+
209+
public function testGenerateJsonResponse(): void
210+
{
211+
$response = $this->generateResponse('application/json');
212+
$this->assertInstanceOf(ResponseInterface::class, $response);
213+
$this->assertInstanceOf(JsonResponse::class, $response);
214+
215+
$response = $this->generateResponse('application/json, text/plain, text/html');
216+
$this->assertInstanceOf(ResponseInterface::class, $response);
217+
$this->assertInstanceOf(JsonResponse::class, $response);
218+
219+
$response = $this->generateResponse('text/plain;q=0.99, application/json, text/html;q=0.98');
220+
$this->assertInstanceOf(ResponseInterface::class, $response);
221+
$this->assertInstanceOf(JsonResponse::class, $response);
222+
}
223+
224+
public function testGenerateXmlResponse(): void
225+
{
226+
$response = $this->generateResponse('text/xml');
227+
$this->assertInstanceOf(ResponseInterface::class, $response);
228+
$this->assertInstanceOf(XmlResponse::class, $response);
229+
230+
$response = $this->generateResponse('application/xml');
231+
$this->assertInstanceOf(ResponseInterface::class, $response);
232+
$this->assertInstanceOf(XmlResponse::class, $response);
233+
234+
$response = $this->generateResponse('application/xml, text/plain, text/html');
235+
$this->assertInstanceOf(ResponseInterface::class, $response);
236+
$this->assertInstanceOf(XmlResponse::class, $response);
237+
238+
$response = $this->generateResponse('text/plain;q=0.99, application/xml, text/html;q=0.98');
239+
$this->assertInstanceOf(ResponseInterface::class, $response);
240+
$this->assertInstanceOf(XmlResponse::class, $response);
241+
}
242+
243+
/**
244+
* @param int $errorCode
245+
* @param string $acceptHeader
246+
* @return ResponseInterface
247+
*/
248+
private function generateResponse(string $acceptHeader = '', int $errorCode = 0): ResponseInterface
249+
{
250+
return $this->generator->generate(
251+
$this->createThrowable($errorCode),
252+
$this->createServerRequest($acceptHeader)
253+
);
254+
}
255+
256+
/**
257+
* @param int $errorCode
258+
* @return Throwable
259+
*/
260+
private function createThrowable(int $errorCode = 0): Throwable
261+
{
262+
return new Exception('Test Error', $errorCode);
263+
}
264+
265+
/**
266+
* @param string $acceptHeader
267+
* @return ServerRequestInterface
268+
*/
269+
private function createServerRequest(string $acceptHeader = ''): ServerRequestInterface
270+
{
271+
$serverRequest = new ServerRequest();
272+
return empty($acceptHeader) ? $serverRequest : $serverRequest->withHeader('accept', $acceptHeader);
273+
}
274+
}

0 commit comments

Comments
 (0)