Skip to content

Commit 9d111cd

Browse files
author
Florian Krämer
committed
Refactoring errors and extensions
1 parent a60d38d commit 9d111cd

10 files changed

+129
-91
lines changed

.idea/php-test-framework.xml

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/php.xml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

composer.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@
33
"type": "library",
44
"description": "Symfony bundle for the Problem Details for HTTP APIs RFC",
55
"require": {
6-
"php": "^8.2"
6+
"php": "^8.2",
7+
"symfony/http-kernel": "~7.0",
8+
"symfony/serializer": "~7.0",
9+
"symfony/uid": "~7.0",
10+
"symfony/validator": "~7.0"
711
},
812
"require-dev": {
913
"infection/infection": "^0.29.10",
1014
"phpmd/phpmd": "^2.5",
1115
"phpstan/phpstan": "^2.0",
1216
"phpunit/phpunit": "^11.5.0",
13-
"squizlabs/php_codesniffer": "^3.7.2",
14-
"symfony/http-kernel": "~7.0",
15-
"symfony/serializer": "~7.0",
16-
"symfony/uid": "~7.0",
17-
"symfony/validator": "~7.0"
17+
"squizlabs/php_codesniffer": "^3.7.2"
1818
},
1919
"license": "MIT",
2020
"autoload": {

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,6 @@ Content-Language: en
6262

6363
## License
6464

65-
This bundle is under the MIT license.
65+
This bundle is under the [MIT license](LICENSE).
6666

6767
Copyright Florian Krämer

src/ProblemDetailsFactory.php

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
use Symfony\Component\HttpFoundation\Response;
88
use Symfony\Component\HttpKernel\Event\ExceptionEvent as KernelExceptionEvent;
99

10+
/**
11+
*
12+
*/
1013
readonly class ProblemDetailsFactory implements ProblemDetailsFactoryInterface, FromExceptionEventFactoryInterface
1114
{
1215
public function __construct(
13-
private string $type = 'about:blank',
14-
private string $title = 'Validation Failed',
15-
private int $status = Response::HTTP_UNPROCESSABLE_ENTITY,
16+
private string $defaultType = 'about:blank',
17+
private string $defaultTitle = 'Validation Failed',
18+
private int $defaultStatus = Response::HTTP_UNPROCESSABLE_ENTITY,
19+
private string $errorField = 'errors'
1620
) {
1721
}
1822

@@ -25,14 +29,14 @@ public function createResponse(
2529
string $type = 'about:blank',
2630
?string $title = null,
2731
?string $instance = null,
28-
array $errors = []
32+
array $extensions = []
2933
): Response {
3034
return ProblemDetailsResponse::create(
3135
status: $status,
3236
type: $type,
3337
title: $title,
3438
instance: $instance,
35-
errors: $errors
39+
extensions: $extensions
3640
);
3741
}
3842

@@ -45,11 +49,13 @@ public function createResponse(
4549
public function createResponseFromKernelExceptionEvent(KernelExceptionEvent $event, array $errors): Response
4650
{
4751
return ProblemDetailsResponse::create(
48-
status: $this->status,
49-
type: $this->type,
50-
title: $this->title,
52+
status: $this->defaultStatus,
53+
type: $this->defaultType,
54+
title: $this->defaultTitle,
5155
instance: $event->getRequest()->getRequestUri(),
52-
errors: $errors
56+
extensions: [
57+
$this->errorField => $errors,
58+
]
5359
);
5460
}
5561
}

src/ProblemDetailsFactoryInterface.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface ProblemDetailsFactoryInterface
1313
* @param string $type
1414
* @param string|null $title
1515
* @param string|null $instance
16-
* @param array<int, array<string, mixed>> $errors
16+
* @param array<string, mixed> $extensions
1717
* @return Response
1818
* @link https://www.rfc-editor.org/rfc/rfc9457.html#name-members-of-a-problem-detail
1919
*/
@@ -22,6 +22,6 @@ public function createResponse(
2222
string $type = 'about:blank',
2323
?string $title = null,
2424
?string $instance = null,
25-
array $errors = []
25+
array $extensions = []
2626
): Response;
2727
}

src/ProblemDetailsResponse.php

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Phauthentic\Symfony\ProblemDetails;
66

7+
use InvalidArgumentException;
78
use LogicException;
89
use Symfony\Component\HttpFoundation\JsonResponse;
910

@@ -13,15 +14,25 @@
1314
class ProblemDetailsResponse extends JsonResponse
1415
{
1516
protected static string $contentType = 'application/problem+json';
16-
protected static string $errorsField = 'errors';
17+
18+
/**
19+
* @var array<string>
20+
*/
21+
protected static array $problemDetailsProtectedFields = [
22+
'status',
23+
'type',
24+
'title',
25+
'detail',
26+
'instance',
27+
];
1728

1829
/**
1930
* @param int $status
2031
* @param string $type
2132
* @param string|null $title
2233
* @param string|null $detail
2334
* @param string|null $instance
24-
* @param array<int, array<string, mixed>> $errors
35+
* @param array<string, mixed> $extensions
2536
* @return self
2637
*/
2738
public static function create(
@@ -30,9 +41,10 @@ public static function create(
3041
?string $title = null,
3142
?string $detail = null,
3243
?string $instance = null,
33-
array $errors = []
44+
array $extensions = []
3445
): self {
3546
self::assertValidstatusCode($status);
47+
self::assertReservedResponseFields($extensions);
3648

3749
$data = [
3850
'status' => $status,
@@ -48,19 +60,30 @@ public static function create(
4860
$data['instance'] = $instance;
4961
}
5062

51-
if (!empty($errors)) {
52-
$data[self::$errorsField] = $errors;
53-
}
54-
5563
return new self(
56-
$data,
64+
array_merge($data, $extensions),
5765
$status,
5866
[
5967
'Content-Type' => self::$contentType
6068
]
6169
);
6270
}
6371

72+
/**
73+
* @param array<string, mixed> $extensions
74+
*/
75+
public static function assertReservedResponseFields(array $extensions): void
76+
{
77+
foreach (array_keys($extensions) as $key) {
78+
if (in_array($key, self::$problemDetailsProtectedFields, true)) {
79+
throw new InvalidArgumentException(sprintf(
80+
'The key "%s" is a reserved key and cannot be used as an extension.',
81+
$key
82+
));
83+
}
84+
}
85+
}
86+
6487
/**
6588
* Validates if the given status code is a valid client-side (4xx) or server-side (5xx) error.
6689
*/

src/ThrowableToProblemDetailsKernelListener.php

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,23 @@
2727
class ThrowableToProblemDetailsKernelListener
2828
{
2929
/**
30+
* @param ProblemDetailsFactoryInterface $problemDetailsFactory
3031
* @param string $environment
3132
* @param array<callable> $mappers
3233
*/
3334
public function __construct(
35+
protected ProblemDetailsFactoryInterface $problemDetailsFactory,
3436
protected string $environment = 'prod',
3537
protected array $mappers = []
3638
) {
3739
}
3840

3941
public function onKernelException(ExceptionEvent $event): void
4042
{
43+
if ($this->isNotAJsonRequest($event)) {
44+
return;
45+
}
46+
4147
$throwable = $event->getThrowable();
4248

4349
$class = get_class($throwable);
@@ -53,24 +59,22 @@ public function onKernelException(ExceptionEvent $event): void
5359
$event->setResponse($this->buildResponse($throwable));
5460
}
5561

56-
private function buildResponse(Throwable $throwable): JsonResponse
62+
private function isNotAJsonRequest(ExceptionEvent $event): bool
5763
{
58-
$data = [
59-
'type' => 'about:blank',
60-
'title' => $throwable->getMessage(),
61-
'status' => Response::HTTP_INTERNAL_SERVER_ERROR,
62-
];
64+
return $event->getRequest()->getPreferredFormat() !== 'json';
65+
}
6366

64-
if ($this->environment === 'dev') {
65-
$data['trace'] = $throwable->getTrace();
67+
private function buildResponse(Throwable $throwable): Response
68+
{
69+
$extensions = [];
70+
if ($this->environment === 'dev' || $this->environment === 'test') {
71+
$extensions['trace'] = $throwable->getTrace();
6672
}
6773

68-
return new JsonResponse(
69-
data: $data,
74+
return $this->problemDetailsFactory->createResponse(
7075
status: Response::HTTP_INTERNAL_SERVER_ERROR,
71-
headers: [
72-
'Content-Type' => 'application/problem+json',
73-
]
76+
title: $throwable->getMessage(),
77+
extensions: $extensions
7478
);
7579
}
7680
}

tests/Unit/HttpExceptionToProblemDetailsKernelListenerTest.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ public function testOnKernelExceptionWithHttpException(): void
2323
// Arrange
2424
$exception = new HttpException(404, 'Not Found');
2525
$kernel = $this->createMock(HttpKernelInterface::class);
26-
$request = new Request();
26+
$request = new Request(
27+
server: ['HTTP_ACCEPT' => 'application/json']
28+
);
2729
$event = new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception);
2830
$listener = new HttpExceptionToProblemDetailsKernelListener();
2931

@@ -49,7 +51,9 @@ public function testOnKernelExceptionWithNonHttpException(): void
4951
// Arrange
5052
$exception = new Exception('Some other exception');
5153
$kernel = $this->createMock(HttpKernelInterface::class);
52-
$request = new Request();
54+
$request = new Request(
55+
server: ['HTTP_ACCEPT' => 'application/json']
56+
);
5357
$event = new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception);
5458
$listener = new HttpExceptionToProblemDetailsKernelListener();
5559

0 commit comments

Comments
 (0)