From 47360a50b9442298d8de8a5383bb9689963ff040 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 25 Oct 2024 12:41:35 +0200 Subject: [PATCH] fix(laravel): jsonapi error serialization --- src/JsonApi/.php-cs-fixer.cache | 1 + src/JsonApi/Serializer/ErrorNormalizer.php | 6 +- src/Laravel/ApiPlatformProvider.php | 14 +++- src/Laravel/ApiResource/Error.php | 16 +++-- src/Laravel/ApiResource/ValidationError.php | 10 +-- src/Laravel/Tests/EloquentTest.php | 9 +-- src/Laravel/Tests/JsonApiTest.php | 67 +++++++++++++++++-- .../app/ApiResource/RuleValidation.php | 34 ++++++++++ src/State/ApiResource/Error.php | 6 ++ 9 files changed, 139 insertions(+), 24 deletions(-) create mode 100644 src/JsonApi/.php-cs-fixer.cache create mode 100644 src/Laravel/workbench/app/ApiResource/RuleValidation.php diff --git a/src/JsonApi/.php-cs-fixer.cache b/src/JsonApi/.php-cs-fixer.cache new file mode 100644 index 00000000000..37a06de435e --- /dev/null +++ b/src/JsonApi/.php-cs-fixer.cache @@ -0,0 +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"}} \ No newline at end of file diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index c6a5997283b..4d9f18da04c 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -37,7 +37,11 @@ public function normalize(mixed $object, ?string $format = null, array $context $jsonApiObject = $this->itemNormalizer->normalize($object, $format, $context); $error = $jsonApiObject['data']['attributes']; $error['id'] = $jsonApiObject['data']['id']; - $error['type'] = $jsonApiObject['data']['id']; + $error['links'] = ['type' => $error['type']]; + if (!isset($error['code']) && method_exists($object, 'getId')) { + $error['code'] = $object->getId(); + } + unset($error['type']); return ['errors' => [$error]]; } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index a9725ebdbea..a71a43ebb92 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -58,6 +58,7 @@ use ApiPlatform\JsonApi\JsonSchema\SchemaFactory as JsonApiSchemaFactory; use ApiPlatform\JsonApi\Serializer\CollectionNormalizer as JsonApiCollectionNormalizer; use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer as JsonApiEntrypointNormalizer; +use ApiPlatform\JsonApi\Serializer\ErrorNormalizer as JsonApiErrorNormalizer; use ApiPlatform\JsonApi\Serializer\ItemNormalizer as JsonApiItemNormalizer; use ApiPlatform\JsonApi\Serializer\ObjectNormalizer as JsonApiObjectNormalizer; use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; @@ -907,6 +908,10 @@ public function register(): void return new ReservedAttributeNameConverter($app->make(NameConverterInterface::class)); }); + if (interface_exists(FieldsBuilderEnumInterface::class)) { + $this->registerGraphQl($this->app); + } + $this->app->singleton(JsonApiEntrypointNormalizer::class, function (Application $app) { return new JsonApiEntrypointNormalizer( $app->make(ResourceMetadataCollectionFactoryInterface::class), @@ -946,9 +951,11 @@ public function register(): void ); }); - if (interface_exists(FieldsBuilderEnumInterface::class)) { - $this->registerGraphQl($this->app); - } + $this->app->singleton(JsonApiErrorNormalizer::class, function (Application $app) { + return new JsonApiErrorNormalizer( + $app->make(JsonApiItemNormalizer::class), + ); + }); $this->app->singleton(JsonApiObjectNormalizer::class, function (Application $app) { return new JsonApiObjectNormalizer( @@ -985,6 +992,7 @@ public function register(): void $list->insert($app->make(JsonApiEntrypointNormalizer::class), -800); $list->insert($app->make(JsonApiCollectionNormalizer::class), -985); $list->insert($app->make(JsonApiItemNormalizer::class), -890); + $list->insert($app->make(JsonApiErrorNormalizer::class), -790); $list->insert($app->make(JsonApiObjectNormalizer::class), -995); if (interface_exists(FieldsBuilderEnumInterface::class)) { diff --git a/src/Laravel/ApiResource/Error.php b/src/Laravel/ApiResource/Error.php index fc4a2963a48..375bd122dc3 100644 --- a/src/Laravel/ApiResource/Error.php +++ b/src/Laravel/ApiResource/Error.php @@ -52,7 +52,7 @@ name: '_api_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: ['groups' => ['jsonapi'], 'skip_null_values' => true], - uriTemplate: '/errros/{status}.jsonapi' + uriTemplate: '/errors/{status}.jsonapi' ), ], graphQlOperations: [] @@ -124,6 +124,12 @@ public function getStatusCode(): int return $this->status; } + #[Groups(['jsonapi'])] + public function getId() + { + return $this->status; + } + /** * @param array $headers */ @@ -132,7 +138,7 @@ public function setHeaders(array $headers): void $this->headers = $headers; } - #[Groups(['jsonld', 'jsonproblem'])] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] public function getType(): string { return $this->type; @@ -149,7 +155,7 @@ public function setType(string $type): void $this->type = $type; } - #[Groups(['jsonld', 'jsonproblem'])] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] public function getStatus(): ?int { return $this->status; @@ -160,13 +166,13 @@ public function setStatus(int $status): void $this->status = $status; } - #[Groups(['jsonld', 'jsonproblem'])] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] public function getDetail(): ?string { return $this->detail; } - #[Groups(['jsonld', 'jsonproblem'])] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] public function getInstance(): ?string { return $this->instance; diff --git a/src/Laravel/ApiResource/ValidationError.php b/src/Laravel/ApiResource/ValidationError.php index 0eb36413462..5e3c47e5e55 100644 --- a/src/Laravel/ApiResource/ValidationError.php +++ b/src/Laravel/ApiResource/ValidationError.php @@ -92,19 +92,19 @@ public function getDescription(): string return $this->detail; } - #[Groups(['jsonld', 'json'])] + #[Groups(['jsonld', 'json', 'jsonapi'])] public function getType(): string { return '/validation_errors/'.$this->id; } - #[Groups(['jsonld', 'json'])] + #[Groups(['jsonld', 'json', 'jsonapi'])] public function getTitle(): ?string { return 'Validation Error'; } - #[Groups(['jsonld', 'json'])] + #[Groups(['jsonld', 'json', 'jsonapi'])] private string $detail; public function getDetail(): ?string @@ -117,7 +117,7 @@ public function setDetail(string $detail): void $this->detail = $detail; } - #[Groups(['jsonld', 'json'])] + #[Groups(['jsonld', 'json', 'jsonapi'])] public function getStatus(): ?int { return $this->status; @@ -128,7 +128,7 @@ public function setStatus(int $status): void $this->status = $status; } - #[Groups(['jsonld', 'json'])] + #[Groups(['jsonld', 'json', 'jsonapi'])] public function getInstance(): ?string { return null; diff --git a/src/Laravel/Tests/EloquentTest.php b/src/Laravel/Tests/EloquentTest.php index 23960b7ad20..84bd1afeb84 100644 --- a/src/Laravel/Tests/EloquentTest.php +++ b/src/Laravel/Tests/EloquentTest.php @@ -386,11 +386,11 @@ public function testRangeGreaterThanEqualFilter(): void 'Content-Type' => ['application/merge-patch+json'], ] ); - $response = $this->get('api/books?isbn_range[gte]='.$updated['isbn'], ['Accept' => ['application/ld+json']]); - $this->assertSame($response->json()['member'][0]['@id'], $bookBefore['@id']); - $this->assertSame($response->json()['member'][1]['@id'], $bookAfter['@id']); - $this->assertSame($response->json()['totalItems'], 2); + $json = $response->json(); + $this->assertSame($json['member'][0]['@id'], $bookBefore['@id']); + $this->assertSame($json['member'][1]['@id'], $bookAfter['@id']); + $this->assertSame($json['totalItems'], 2); } public function testWrongOrderFilter(): void @@ -398,6 +398,7 @@ public function testWrongOrderFilter(): void BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $res = $this->get('/api/authors?order[name]=something', ['Accept' => ['application/ld+json']]); $this->assertEquals($res->getStatusCode(), 422); + dump($res->json()); } public function testWithAccessor(): void diff --git a/src/Laravel/Tests/JsonApiTest.php b/src/Laravel/Tests/JsonApiTest.php index 74833c3ebc3..16d274d1844 100644 --- a/src/Laravel/Tests/JsonApiTest.php +++ b/src/Laravel/Tests/JsonApiTest.php @@ -39,6 +39,7 @@ protected function defineEnvironment($app): void tap($app['config'], function (Repository $config): void { $config->set('api-platform.formats', ['jsonapi' => ['application/vnd.api+json']]); $config->set('api-platform.docs_formats', ['jsonapi' => ['application/vnd.api+json']]); + $config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]); $config->set('app.debug', true); }); } @@ -48,13 +49,15 @@ public function testGetEntrypoint(): void $response = $this->get('/api/', ['accept' => ['application/vnd.api+json']]); $response->assertStatus(200); $response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8'); - $this->assertJsonContains([ - 'links' => [ - 'self' => 'http://localhost/api', - 'book' => 'http://localhost/api/books', + $this->assertJsonContains( + [ + 'links' => [ + 'self' => 'http://localhost/api', + 'book' => 'http://localhost/api/books', + ], ], - ], - $response->json()); + $response->json() + ); } public function testGetCollection(): void @@ -209,4 +212,56 @@ public function testRelationWithGroups(): void $this->assertArrayHasKey('relation', $content['data']['relationships']); $this->assertArrayHasKey('data', $content['data']['relationships']['relation']); } + + public function testValidateJsonApi(): void + { + $response = $this->postJson( + '/api/issue6745/rule_validations', + [ + 'data' => [ + 'type' => 'string', + 'attributes' => [ + 'prop' => 1, + ], + ], + ], + [ + 'accept' => 'application/vnd.api+json', + 'content_type' => 'application/vnd.api+json', + ] + ); + + $response->assertStatus(422); + $response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8'); + $json = $response->json(); + $this->assertJsonContains([ + 'errors' => [ + [ + 'description' => 'The prop field is required.', + 'title' => 'Validation Error', + 'detail' => 'The prop field is required.', + 'status' => 422, + 'code' => 'ac88443768512709', + ], + ], + ], $json); + + $this->assertArrayHasKey('id', $json['errors'][0]); + $this->assertArrayHasKey('links', $json['errors'][0]); + $this->assertArrayHasKey('type', $json['errors'][0]['links']); + } + + public function testNotFound(): void + { + $response = $this->get('/api/books/notfound', headers: ['accept' => 'application/vnd.api+json']); + $response->assertStatus(404); + $response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8'); + + $this->assertJsonContains([ + 'links' => ['type' => '/errors/404'], + 'title' => 'An error occurred', + 'status' => 404, + 'detail' => 'Not Found', + ], $response->json()['errors'][0]); + } } diff --git a/src/Laravel/workbench/app/ApiResource/RuleValidation.php b/src/Laravel/workbench/app/ApiResource/RuleValidation.php new file mode 100644 index 00000000000..d953b33a428 --- /dev/null +++ b/src/Laravel/workbench/app/ApiResource/RuleValidation.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + uriTemplate: '/issue6745/rule_validations', + operations: [new Post()], + rules: ['prop' => 'required'] +)] +class RuleValidation +{ + public function __construct(private int $prop) + { + } + + public function getProp(): int + { + return $this->prop; + } +} diff --git a/src/State/ApiResource/Error.php b/src/State/ApiResource/Error.php index 4a5061a65a1..0a4162617f3 100644 --- a/src/State/ApiResource/Error.php +++ b/src/State/ApiResource/Error.php @@ -90,6 +90,12 @@ public function __construct( } } + #[Groups(['jsonapi'])] + public function getId() + { + return $this->status; + } + #[SerializedName('trace')] #[Groups(['trace'])] public ?array $originalTrace = null;