Skip to content

Commit 47360a5

Browse files
committed
fix(laravel): jsonapi error serialization
1 parent 5a8ef11 commit 47360a5

File tree

9 files changed

+139
-24
lines changed

9 files changed

+139
-24
lines changed

src/JsonApi/.php-cs-fixer.cache

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"php":"8.3.10","version":"3.64.0:v3.64.0#58dd9c931c785a79739310aef5178928305ffa67","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"new_with_parentheses":true,"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"visibility_required":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true},"hashes":{"stdin.php":"5e32b2953a271b5ac0f072f4ef6004a0"}}

src/JsonApi/Serializer/ErrorNormalizer.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ 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+
$error['links'] = ['type' => $error['type']];
41+
if (!isset($error['code']) && method_exists($object, 'getId')) {
42+
$error['code'] = $object->getId();
43+
}
44+
unset($error['type']);
4145

4246
return ['errors' => [$error]];
4347
}

src/Laravel/ApiPlatformProvider.php

Lines changed: 11 additions & 3 deletions
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

Lines changed: 11 additions & 5 deletions
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()
129+
{
130+
return $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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,19 @@ public function getDescription(): string
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;

src/Laravel/Tests/EloquentTest.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -386,18 +386,19 @@ 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
397397
{
398398
BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
399399
$res = $this->get('/api/authors?order[name]=something', ['Accept' => ['application/ld+json']]);
400400
$this->assertEquals($res->getStatusCode(), 422);
401+
dump($res->json());
401402
}
402403

403404
public function testWithAccessor(): void

src/Laravel/Tests/JsonApiTest.php

Lines changed: 61 additions & 6 deletions
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,56 @@ 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' => [
224+
'prop' => 1,
225+
],
226+
],
227+
],
228+
[
229+
'accept' => 'application/vnd.api+json',
230+
'content_type' => 'application/vnd.api+json',
231+
]
232+
);
233+
234+
$response->assertStatus(422);
235+
$response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8');
236+
$json = $response->json();
237+
$this->assertJsonContains([
238+
'errors' => [
239+
[
240+
'description' => 'The prop field is required.',
241+
'title' => 'Validation Error',
242+
'detail' => 'The prop field is required.',
243+
'status' => 422,
244+
'code' => 'ac88443768512709',
245+
],
246+
],
247+
], $json);
248+
249+
$this->assertArrayHasKey('id', $json['errors'][0]);
250+
$this->assertArrayHasKey('links', $json['errors'][0]);
251+
$this->assertArrayHasKey('type', $json['errors'][0]['links']);
252+
}
253+
254+
public function testNotFound(): void
255+
{
256+
$response = $this->get('/api/books/notfound', headers: ['accept' => 'application/vnd.api+json']);
257+
$response->assertStatus(404);
258+
$response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8');
259+
260+
$this->assertJsonContains([
261+
'links' => ['type' => '/errors/404'],
262+
'title' => 'An error occurred',
263+
'status' => 404,
264+
'detail' => 'Not Found',
265+
], $response->json()['errors'][0]);
266+
}
212267
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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']
23+
)]
24+
class RuleValidation
25+
{
26+
public function __construct(private int $prop)
27+
{
28+
}
29+
30+
public function getProp(): int
31+
{
32+
return $this->prop;
33+
}
34+
}

src/State/ApiResource/Error.php

Lines changed: 6 additions & 0 deletions
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()
95+
{
96+
return $this->status;
97+
}
98+
9399
#[SerializedName('trace')]
94100
#[Groups(['trace'])]
95101
public ?array $originalTrace = null;

0 commit comments

Comments
 (0)