Skip to content

Commit 67c5a2a

Browse files
authoredOct 25, 2024··
fix(laravel): jsonapi error serialization (#6755)
1 parent 5a8ef11 commit 67c5a2a

File tree

10 files changed

+191
-29
lines changed

10 files changed

+191
-29
lines changed
 

‎src/JsonApi/Serializer/ErrorNormalizer.php

+27-2
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,34 @@ public function normalize(mixed $object, ?string $format = null, array $context
3737
$jsonApiObject = $this->itemNormalizer->normalize($object, $format, $context);
3838
$error = $jsonApiObject['data']['attributes'];
3939
$error['id'] = $jsonApiObject['data']['id'];
40-
$error['type'] = $jsonApiObject['data']['id'];
40+
if (isset($error['type'])) {
41+
$error['links'] = ['type' => $error['type']];
42+
}
43+
44+
if (!isset($error['code']) && method_exists($object, 'getId')) {
45+
$error['code'] = $object->getId();
46+
}
47+
48+
if (!isset($error['violations'])) {
49+
return ['errors' => [$error]];
50+
}
51+
52+
$errors = [];
53+
foreach ($error['violations'] as $violation) {
54+
$e = ['detail' => $violation['message']] + $error;
55+
if (isset($error['links']['type'])) {
56+
$type = $error['links']['type'];
57+
$e['links']['type'] = \sprintf('%s/%s', $type, $violation['propertyPath']);
58+
$e['id'] = str_replace($type, $e['links']['type'], $e['id']);
59+
}
60+
if (isset($e['code'])) {
61+
$e['code'] = \sprintf('%s/%s', $error['code'], $violation['propertyPath']);
62+
}
63+
unset($e['violations']);
64+
$errors[] = $e;
65+
}
4166

42-
return ['errors' => [$error]];
67+
return ['errors' => $errors];
4368
}
4469

4570
/**

‎src/JsonApi/Serializer/ReservedAttributeNameConverter.php

+5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\JsonApi\Serializer;
1515

16+
use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
1617
use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface;
1718
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
1819

@@ -44,6 +45,10 @@ public function normalize(string $propertyName, ?string $class = null, ?string $
4445
$propertyName = $this->nameConverter->normalize($propertyName, $class, $format, $context);
4546
}
4647

48+
if ($class && is_a($class, ProblemExceptionInterface::class, true)) {
49+
return $propertyName;
50+
}
51+
4752
if (isset(self::JSON_API_RESERVED_ATTRIBUTES[$propertyName])) {
4853
$propertyName = self::JSON_API_RESERVED_ATTRIBUTES[$propertyName];
4954
}

‎src/Laravel/ApiPlatformProvider.php

+11-3
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
use ApiPlatform\JsonApi\JsonSchema\SchemaFactory as JsonApiSchemaFactory;
5959
use ApiPlatform\JsonApi\Serializer\CollectionNormalizer as JsonApiCollectionNormalizer;
6060
use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer as JsonApiEntrypointNormalizer;
61+
use ApiPlatform\JsonApi\Serializer\ErrorNormalizer as JsonApiErrorNormalizer;
6162
use ApiPlatform\JsonApi\Serializer\ItemNormalizer as JsonApiItemNormalizer;
6263
use ApiPlatform\JsonApi\Serializer\ObjectNormalizer as JsonApiObjectNormalizer;
6364
use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter;
@@ -907,6 +908,10 @@ public function register(): void
907908
return new ReservedAttributeNameConverter($app->make(NameConverterInterface::class));
908909
});
909910

911+
if (interface_exists(FieldsBuilderEnumInterface::class)) {
912+
$this->registerGraphQl($this->app);
913+
}
914+
910915
$this->app->singleton(JsonApiEntrypointNormalizer::class, function (Application $app) {
911916
return new JsonApiEntrypointNormalizer(
912917
$app->make(ResourceMetadataCollectionFactoryInterface::class),
@@ -946,9 +951,11 @@ public function register(): void
946951
);
947952
});
948953

949-
if (interface_exists(FieldsBuilderEnumInterface::class)) {
950-
$this->registerGraphQl($this->app);
951-
}
954+
$this->app->singleton(JsonApiErrorNormalizer::class, function (Application $app) {
955+
return new JsonApiErrorNormalizer(
956+
$app->make(JsonApiItemNormalizer::class),
957+
);
958+
});
952959

953960
$this->app->singleton(JsonApiObjectNormalizer::class, function (Application $app) {
954961
return new JsonApiObjectNormalizer(
@@ -985,6 +992,7 @@ public function register(): void
985992
$list->insert($app->make(JsonApiEntrypointNormalizer::class), -800);
986993
$list->insert($app->make(JsonApiCollectionNormalizer::class), -985);
987994
$list->insert($app->make(JsonApiItemNormalizer::class), -890);
995+
$list->insert($app->make(JsonApiErrorNormalizer::class), -790);
988996
$list->insert($app->make(JsonApiObjectNormalizer::class), -995);
989997

990998
if (interface_exists(FieldsBuilderEnumInterface::class)) {

‎src/Laravel/ApiResource/Error.php

+11-5
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
name: '_api_errors_jsonapi',
5353
outputFormats: ['jsonapi' => ['application/vnd.api+json']],
5454
normalizationContext: ['groups' => ['jsonapi'], 'skip_null_values' => true],
55-
uriTemplate: '/errros/{status}.jsonapi'
55+
uriTemplate: '/errors/{status}.jsonapi'
5656
),
5757
],
5858
graphQlOperations: []
@@ -124,6 +124,12 @@ public function getStatusCode(): int
124124
return $this->status;
125125
}
126126

127+
#[Groups(['jsonapi'])]
128+
public function getId(): string
129+
{
130+
return (string) $this->status;
131+
}
132+
127133
/**
128134
* @param array<string, string> $headers
129135
*/
@@ -132,7 +138,7 @@ public function setHeaders(array $headers): void
132138
$this->headers = $headers;
133139
}
134140

135-
#[Groups(['jsonld', 'jsonproblem'])]
141+
#[Groups(['jsonld', 'jsonproblem', 'jsonapi'])]
136142
public function getType(): string
137143
{
138144
return $this->type;
@@ -149,7 +155,7 @@ public function setType(string $type): void
149155
$this->type = $type;
150156
}
151157

152-
#[Groups(['jsonld', 'jsonproblem'])]
158+
#[Groups(['jsonld', 'jsonproblem', 'jsonapi'])]
153159
public function getStatus(): ?int
154160
{
155161
return $this->status;
@@ -160,13 +166,13 @@ public function setStatus(int $status): void
160166
$this->status = $status;
161167
}
162168

163-
#[Groups(['jsonld', 'jsonproblem'])]
169+
#[Groups(['jsonld', 'jsonproblem', 'jsonapi'])]
164170
public function getDetail(): ?string
165171
{
166172
return $this->detail;
167173
}
168174

169-
#[Groups(['jsonld', 'jsonproblem'])]
175+
#[Groups(['jsonld', 'jsonproblem', 'jsonapi'])]
170176
public function getInstance(): ?string
171177
{
172178
return $this->instance;

‎src/Laravel/ApiResource/ValidationError.php

+7-7
Original file line numberDiff line numberDiff line change
@@ -86,25 +86,25 @@ public function getId(): string
8686
}
8787

8888
#[SerializedName('description')]
89-
#[Groups(['jsonapi', 'jsonld', 'json'])]
89+
#[Groups(['jsonld', 'json'])]
9090
public function getDescription(): string
9191
{
9292
return $this->detail;
9393
}
9494

95-
#[Groups(['jsonld', 'json'])]
95+
#[Groups(['jsonld', 'json', 'jsonapi'])]
9696
public function getType(): string
9797
{
9898
return '/validation_errors/'.$this->id;
9999
}
100100

101-
#[Groups(['jsonld', 'json'])]
101+
#[Groups(['jsonld', 'json', 'jsonapi'])]
102102
public function getTitle(): ?string
103103
{
104104
return 'Validation Error';
105105
}
106106

107-
#[Groups(['jsonld', 'json'])]
107+
#[Groups(['jsonld', 'json', 'jsonapi'])]
108108
private string $detail;
109109

110110
public function getDetail(): ?string
@@ -117,7 +117,7 @@ public function setDetail(string $detail): void
117117
$this->detail = $detail;
118118
}
119119

120-
#[Groups(['jsonld', 'json'])]
120+
#[Groups(['jsonld', 'json', 'jsonapi'])]
121121
public function getStatus(): ?int
122122
{
123123
return $this->status;
@@ -128,7 +128,7 @@ public function setStatus(int $status): void
128128
$this->status = $status;
129129
}
130130

131-
#[Groups(['jsonld', 'json'])]
131+
#[Groups(['jsonld', 'json', 'jsonapi'])]
132132
public function getInstance(): ?string
133133
{
134134
return null;
@@ -138,7 +138,7 @@ public function getInstance(): ?string
138138
* @return array<int,array{propertyPath:string,message:string,code?:string}>
139139
*/
140140
#[SerializedName('violations')]
141-
#[Groups(['json', 'jsonld'])]
141+
#[Groups(['json', 'jsonld', 'jsonapi'])]
142142
public function getViolations(): array
143143
{
144144
return $this->violations;

‎src/Laravel/State/ValidateProvider.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,14 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
7474
return $body;
7575
}
7676

77-
$validator = Validator::make($request->request->all(), $rules);
77+
// In Symfony, validation is done on the Resource object (here $body) using Deserialization before Validation
78+
// Here, we did not deserialize yet, we validate on the raw body before.
79+
$validationBody = $request->request->all();
80+
if ('jsonapi' === $request->getRequestFormat()) {
81+
$validationBody = $validationBody['data']['attributes'];
82+
}
83+
84+
$validator = Validator::make($validationBody, $rules);
7885
if ($validator->fails()) {
7986
throw $this->getValidationError($validator, new ValidationException($validator));
8087
}

‎src/Laravel/Tests/EloquentTest.php

+4-4
Original file line numberDiff line numberDiff line change
@@ -386,11 +386,11 @@ public function testRangeGreaterThanEqualFilter(): void
386386
'Content-Type' => ['application/merge-patch+json'],
387387
]
388388
);
389-
390389
$response = $this->get('api/books?isbn_range[gte]='.$updated['isbn'], ['Accept' => ['application/ld+json']]);
391-
$this->assertSame($response->json()['member'][0]['@id'], $bookBefore['@id']);
392-
$this->assertSame($response->json()['member'][1]['@id'], $bookAfter['@id']);
393-
$this->assertSame($response->json()['totalItems'], 2);
390+
$json = $response->json();
391+
$this->assertSame($json['member'][0]['@id'], $bookBefore['@id']);
392+
$this->assertSame($json['member'][1]['@id'], $bookAfter['@id']);
393+
$this->assertSame($json['totalItems'], 2);
394394
}
395395

396396
public function testWrongOrderFilter(): void

‎src/Laravel/Tests/JsonApiTest.php

+82-6
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ protected function defineEnvironment($app): void
3939
tap($app['config'], function (Repository $config): void {
4040
$config->set('api-platform.formats', ['jsonapi' => ['application/vnd.api+json']]);
4141
$config->set('api-platform.docs_formats', ['jsonapi' => ['application/vnd.api+json']]);
42+
$config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]);
4243
$config->set('app.debug', true);
4344
});
4445
}
@@ -48,13 +49,15 @@ public function testGetEntrypoint(): void
4849
$response = $this->get('/api/', ['accept' => ['application/vnd.api+json']]);
4950
$response->assertStatus(200);
5051
$response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8');
51-
$this->assertJsonContains([
52-
'links' => [
53-
'self' => 'http://localhost/api',
54-
'book' => 'http://localhost/api/books',
52+
$this->assertJsonContains(
53+
[
54+
'links' => [
55+
'self' => 'http://localhost/api',
56+
'book' => 'http://localhost/api/books',
57+
],
5558
],
56-
],
57-
$response->json());
59+
$response->json()
60+
);
5861
}
5962

6063
public function testGetCollection(): void
@@ -209,4 +212,77 @@ public function testRelationWithGroups(): void
209212
$this->assertArrayHasKey('relation', $content['data']['relationships']);
210213
$this->assertArrayHasKey('data', $content['data']['relationships']['relation']);
211214
}
215+
216+
public function testValidateJsonApi(): void
217+
{
218+
$response = $this->postJson(
219+
'/api/issue6745/rule_validations',
220+
[
221+
'data' => [
222+
'type' => 'string',
223+
'attributes' => ['max' => 3],
224+
],
225+
],
226+
[
227+
'accept' => 'application/vnd.api+json',
228+
'content_type' => 'application/vnd.api+json',
229+
]
230+
);
231+
232+
$response->assertStatus(422);
233+
$response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8');
234+
$json = $response->json();
235+
$this->assertJsonContains([
236+
'errors' => [
237+
[
238+
'detail' => 'The prop field is required.',
239+
'title' => 'Validation Error',
240+
'status' => 422,
241+
'code' => '58350900e0fc6b8e/prop',
242+
],
243+
[
244+
'detail' => 'The max field must be less than 2.',
245+
'title' => 'Validation Error',
246+
'status' => 422,
247+
'code' => '58350900e0fc6b8e/max',
248+
],
249+
],
250+
], $json);
251+
252+
$this->assertArrayHasKey('id', $json['errors'][0]);
253+
$this->assertArrayHasKey('links', $json['errors'][0]);
254+
$this->assertArrayHasKey('type', $json['errors'][0]['links']);
255+
256+
$response = $this->postJson(
257+
'/api/issue6745/rule_validations',
258+
[
259+
'data' => [
260+
'type' => 'string',
261+
'attributes' => [
262+
'prop' => 1,
263+
'max' => 1,
264+
],
265+
],
266+
],
267+
[
268+
'accept' => 'application/vnd.api+json',
269+
'content_type' => 'application/vnd.api+json',
270+
]
271+
);
272+
$response->assertStatus(201);
273+
}
274+
275+
public function testNotFound(): void
276+
{
277+
$response = $this->get('/api/books/notfound', headers: ['accept' => 'application/vnd.api+json']);
278+
$response->assertStatus(404);
279+
$response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8');
280+
281+
$this->assertJsonContains([
282+
'links' => ['type' => '/errors/404'],
283+
'title' => 'An error occurred',
284+
'status' => 404,
285+
'detail' => 'Not Found',
286+
], $response->json()['errors'][0]);
287+
}
212288
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\App\ApiResource;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Post;
18+
19+
#[ApiResource(
20+
uriTemplate: '/issue6745/rule_validations',
21+
operations: [new Post()],
22+
rules: ['prop' => 'required', 'max' => 'lt:2']
23+
)]
24+
class RuleValidation
25+
{
26+
public function __construct(public int $prop, public ?int $max = null)
27+
{
28+
}
29+
}

‎src/State/ApiResource/Error.php

+7-1
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ public function __construct(
9090
}
9191
}
9292

93+
#[Groups(['jsonapi'])]
94+
public function getId(): string
95+
{
96+
return (string) $this->status;
97+
}
98+
9399
#[SerializedName('trace')]
94100
#[Groups(['trace'])]
95101
public ?array $originalTrace = null;
@@ -129,7 +135,7 @@ public function setHeaders(array $headers): void
129135
$this->headers = $headers;
130136
}
131137

132-
#[Groups(['jsonld', 'jsonproblem'])]
138+
#[Groups(['jsonld', 'jsonproblem', 'jsonapi'])]
133139
public function getType(): string
134140
{
135141
return $this->type;

0 commit comments

Comments
 (0)
Please sign in to comment.