Skip to content

Commit

Permalink
feat: error as resources, jsonld errors are now problem-compliant (#5433
Browse files Browse the repository at this point in the history
)
  • Loading branch information
JacquesDurand authored Jun 5, 2023
1 parent 039ba86 commit 4ef0ef8
Show file tree
Hide file tree
Showing 44 changed files with 1,026 additions and 144 deletions.
5 changes: 3 additions & 2 deletions features/hal/problem.feature
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ Feature: Error handling valid according to RFC 7807 (application/problem+json)
And the JSON should be equal to:
"""
{
"type": "https://tools.ietf.org/html/rfc2616#section-10",
"type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3",
"title": "An error occurred",
"detail": "name: This value should not be blank.",
"status": "422",
"violations": [
{
"propertyPath": "name",
Expand All @@ -44,7 +45,7 @@ Feature: Error handling valid according to RFC 7807 (application/problem+json)
Then the response status code should be 400
And the response should be in JSON
And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8"
And the JSON node "type" should be equal to "https://tools.ietf.org/html/rfc2616#section-10"
And the JSON node "type" should be equal to "/errors/400"
And the JSON node "title" should be equal to "An error occurred"
And the JSON node "detail" should be equal to 'Nested documents for attribute "relatedDummy" are not allowed. Use IRIs instead.'
And the JSON node "trace" should exist
130 changes: 130 additions & 0 deletions src/ApiResource/Error.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\ApiResource;

use ApiPlatform\Exception\ProblemExceptionInterface;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ErrorResource;
use ApiPlatform\Metadata\Exception\HttpExceptionInterface;
use ApiPlatform\Metadata\Get;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\Ignore;
use Symfony\Component\Serializer\Annotation\SerializedName;

#[ErrorResource(
uriTemplate: '/errors/{status}',
provider: 'api_platform.state_provider.default_error',
types: ['hydra:Error'],
operations: [
new Get(name: '_api_errors_hydra', outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true]),
new Get(name: '_api_errors_problem', outputFormats: ['json' => ['application/problem+json']], normalizationContext: ['groups' => ['jsonproblem'], 'skip_null_values' => true]),
new Get(name: '_api_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: ['groups' => ['jsonapi'], 'skip_null_values' => true], provider: 'api_platform.json_api.state_provider.default_error'),
]
)]
class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface
{
public function __construct(
private readonly string $title,
private readonly string $detail,
#[ApiProperty(identifier: true)] private readonly int $status,
#[Groups(['trace'])]
public readonly array $trace,
private ?string $instance = null,
private string $type = 'about:blank',
private array $headers = []
) {
parent::__construct();
}

#[SerializedName('hydra:title')]
#[Groups(['jsonld', 'legacy_jsonld'])]
public function getHydraTitle(): string
{
return $this->title;
}

#[SerializedName('hydra:description')]
#[Groups(['jsonld', 'legacy_jsonld'])]
public function getHydraDescription(): string
{
return $this->detail;
}

#[SerializedName('description')]
#[Groups(['jsonapi', 'legacy_jsonapi'])]
public function getDescription(): string
{
return $this->detail;
}

public static function createFromException(\Exception|\Throwable $exception, int $status): self
{
$headers = ($exception instanceof SymfonyHttpExceptionInterface || $exception instanceof HttpExceptionInterface) ? $exception->getHeaders() : [];

return new self('An error occurred', $exception->getMessage(), $status, $exception->getTrace(), type: '/errors/'.$status, headers: $headers);
}

#[Ignore]
public function getHeaders(): array
{
return $this->headers;
}

#[Ignore]
public function getStatusCode(): int
{
return $this->status;
}

public function setHeaders(array $headers): void
{
$this->headers = $headers;
}

#[Groups(['jsonld', 'jsonproblem'])]
public function getType(): string
{
return $this->type;
}

#[Groups(['jsonld', 'legacy_jsonproblem', 'jsonproblem', 'jsonapi', 'legacy_jsonapi'])]
public function getTitle(): ?string
{
return $this->title;
}

public function setType(string $type): void
{
$this->type = $type;
}

#[Groups(['jsonld', 'jsonproblem', 'legacy_jsonproblem'])]
public function getStatus(): ?int
{
return $this->status;
}

#[Groups(['jsonld', 'jsonproblem', 'legacy_jsonproblem'])]
public function getDetail(): ?string
{
return $this->detail;
}

#[Groups(['jsonld', 'jsonproblem', 'legacy_jsonproblem'])]
public function getInstance(): ?string
{
return $this->instance;
}
}
31 changes: 31 additions & 0 deletions src/Exception/ProblemExceptionInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Exception;

interface ProblemExceptionInterface
{
public function getType(): string;

/**
* Note from RFC rfc7807: "title" (string) - A short, human-readable summary of the problem type.
* It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization.
*/
public function getTitle(): ?string;

public function getStatus(): ?int;

public function getDetail(): ?string;

public function getInstance(): ?string;
}
7 changes: 6 additions & 1 deletion src/Hydra/Serializer/ErrorNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public function __construct(private readonly UrlGeneratorInterface $urlGenerator
*/
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource.', __CLASS__));
$data = [
'@context' => $this->urlGenerator->generate('api_jsonld_context', ['shortName' => 'Error']),
'@type' => 'hydra:Error',
Expand All @@ -62,11 +63,15 @@ public function normalize(mixed $object, string $format = null, array $context =
*/
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
if ($context['skip_deprecated_exception_normalizers'] ?? false) {
return false;
}

return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException);
}

public function hasCacheableSupportsMethod(): bool
{
return true;
return false;
}
}
7 changes: 6 additions & 1 deletion src/JsonApi/Serializer/ErrorNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public function __construct(private readonly bool $debug = false, array $default
*/
public function normalize(mixed $object, string $format = null, array $context = []): array
{
trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource.', __CLASS__));
$data = [
'title' => $context[self::TITLE] ?? $this->defaultContext[self::TITLE],
'description' => $this->getErrorMessage($object, $context, $this->debug),
Expand All @@ -64,11 +65,15 @@ public function normalize(mixed $object, string $format = null, array $context =
*/
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
if ($context['skip_deprecated_exception_normalizers'] ?? false) {
return false;
}

return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException);
}

public function hasCacheableSupportsMethod(): bool
{
return true;
return false;
}
}
3 changes: 2 additions & 1 deletion src/JsonApi/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use ApiPlatform\Serializer\CacheKeyTrait;
use ApiPlatform\Serializer\ContextTrait;
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
Expand Down Expand Up @@ -62,7 +63,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName
*/
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context);
return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context) && !($data instanceof \Exception || $data instanceof FlattenException);
}

/**
Expand Down
35 changes: 35 additions & 0 deletions src/JsonApi/State/DefaultErrorProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\JsonApi\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\Symfony\Validator\Exception\ConstraintViolationListAwareExceptionInterface;

/**
* @internal
*/
final class DefaultErrorProvider implements ProviderInterface
{
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object
{
$exception = $context['previous_data'];

if ($exception instanceof ConstraintViolationListAwareExceptionInterface) {
return $exception->getConstraintViolationList();
}

return $exception;
}
}
1 change: 1 addition & 0 deletions src/JsonLd/Action/ContextAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public function __invoke(string $shortName): array
return ['@context' => $this->contextBuilder->getEntrypointContext()];
}

// TODO: remove this, exceptions are resources since 3.2
if (isset(self::RESERVED_SHORT_NAMES[$shortName])) {
return ['@context' => $this->contextBuilder->getBaseContext()];
}
Expand Down
2 changes: 1 addition & 1 deletion src/JsonLd/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public function normalize(mixed $object, string $format = null, array $context =
$context['item_uri_template'] = $itemUriTemplate;
}

if ($iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) {
if (true === ($context['force_iri_generation'] ?? true) && $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) {
$context['iri'] = $iri;
$metadata['@id'] = $iri;
}
Expand Down
Loading

0 comments on commit 4ef0ef8

Please sign in to comment.