-
-
Notifications
You must be signed in to change notification settings - Fork 848
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: error as resources, jsonld errors are now problem-compliant #5433
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This implementation looks good to me, please keep the ApiErrorResource
for another PR we just want to be closer to the specification for now.
src/Action/ExceptionAction.php
Outdated
@@ -46,7 +49,7 @@ public function __construct(private readonly SerializerInterface $serializer, pr | |||
/** | |||
* Converts an exception to a JSON response. | |||
*/ | |||
public function __invoke(FlattenException $exception, Request $request): Response | |||
public function __invoke(FlattenException|ErrorExceptionInterface $exception, Request $request): Response |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
public function __invoke(FlattenException|ErrorExceptionInterface $exception, Request $request): Response | |
/** | |
* @param FlattenException|ErrorExceptionInterface $exception | |
*/ | |
public function __invoke($exception, Request $request): Response |
To avoid breaking if this class is used by users.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this considered breaking to add a union type ? I'm also not sure how this class could be directly used by users ?
One problem with your suggestion is that in ErrorListener::onControllerArgument
, I need to check the types of the controller argument (with Reflection) to transform the \Throwable
in an instance of ErrorExceptionInterface
.
I am definitely open to other possibilities though
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As long as a class is not marked @internal
you can't change a public method argument type (https://symfony.com/doc/current/contributing/code/bc.html). If we can't, you need to deprecate this controller and create a new one.
@@ -35,7 +37,7 @@ final class ErrorNormalizer implements NormalizerInterface, CacheableSupportsMet | |||
self::TITLE => 'An error occurred', | |||
]; | |||
|
|||
public function __construct(private readonly bool $debug = false, array $defaultContext = []) | |||
public function __construct(private readonly IriConverterInterface $iriConverter, private readonly bool $debug = false, array $defaultContext = []) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can't break this class, the argument must be last position and nullable + add an expect deprecation although I'm quite sure we can handle it with a default IriConverter to avoid the deprecation alltogether.
src/Action/ExceptionAction.php
Outdated
@@ -71,7 +74,13 @@ public function __invoke(FlattenException $exception, Request $request): Respons | |||
$headers['X-Content-Type-Options'] = 'nosniff'; | |||
$headers['X-Frame-Options'] = 'deny'; | |||
|
|||
return new Response($this->serializer->serialize($exception, $format['key'], ['statusCode' => $statusCode]), $statusCode, $headers); | |||
$error = match ($format['key']) { | |||
'jsonproblem' => $exception instanceof FlattenException ? $exception : new ProblemError($exception, $request->getPathInfo()), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why the $request->getPathInfo()
? looks like a security issue
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're most probably right, I wasn't too sure of how to hydrate the 'instance' part of the problem+json response, this is just a "filler" part while waiting for a global opinion on this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you show me an example, I'm not sure I understand.
@@ -34,4 +41,47 @@ protected function duplicateRequest(\Throwable $exception, Request $request): Re | |||
|
|||
return $dup; | |||
} | |||
|
|||
public function onControllerArguments(ControllerArgumentsEvent $event): void |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is that code from Symfony? It looks quite heavy could you explain to me why you needed that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is indeed base on code from Symfony.
When an Exception
is thrown, Symfony replays the request by duplicating it, adding an 'exception' key to it, and redirecting it to the Controller supposed to return a Response from the combo of the request and the exception (in our case ExceptionAction
handles the duplicated request). Just before handling it though (onControllerArguments), Symfony transform the original Exception in a FlattenException, which is then serialized as part of the Response.
From one of our discussions, we wanted to break this dependency on FlattenException, reconstruct our own kind (here ErrorException
), and build Error/ProblemError from it, hence the need of overriding Symfony's ErrorListener::onControllerArguments
Then again, I'm open to any suggestion as to make this in a cleaner way !
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't need to do this to get the original exception in my own implementation.
+++ ../src/Action/ExceptionAction.php
@@ -20,8 +20,10 @@
use ApiPlatform\Util\OperationRequestInitiatorTrait;
use ApiPlatform\Util\RequestAttributesExtractor;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
+use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
@@ -46,12 +48,20 @@
/**
* Converts an exception to a JSON response.
*/
- public function __invoke(FlattenException $exception, Request $request): Response
+ public function __invoke(\Throwable $exception, Request $request): Response
{
$operation = $this->initializeOperation($request);
- $exceptionClass = $exception->getClass();
- $statusCode = $exception->getStatusCode();
+ $exceptionClass = $exception::class;
+ $statusCode = 500;
+ $headers = [];
+ if ($exception instanceof HttpExceptionInterface) {
+ $statusCode = $exception->getStatusCode();
+ $headers = $exception->getHeaders();
+ } elseif ($exception instanceof RequestExceptionInterface) {
+ $statusCode = 400;
+ }
+
$exceptionToStatus = array_merge(
$this->exceptionToStatus,
$operation ? $operation->getExceptionToStatus() ?? [] : $this->getOperationExceptionToStatus($request)
@@ -65,7 +75,6 @@
}
}
- $headers = $exception->getHeaders();
$format = ErrorFormatGuesser::guessErrorFormat($request, $this->errorFormats);
$headers['Content-Type'] = sprintf('%s; charset=utf-8', $format['value'][0]);
$headers['X-Content-Type-Options'] = 'nosniff';
This was all of the change that I needed to be able to get the actual exception.
src/ApiResource/Error.php
Outdated
|
||
private array $trace; | ||
|
||
public function __construct(ErrorExceptionInterface $exception) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use public readonly promoted constructor parameters instead of private with getter properties.
src/ApiResource/Error.php
Outdated
use ApiPlatform\Metadata\NotExposed; | ||
|
||
#[NotExposed(uriTemplate: '/errors/{statusCode}')] | ||
class Error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
class Error | |
final class Error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was hesitant about this, should we (or not) allow users to extend it for their custom Errors ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not the resource itself no
|
||
use ApiPlatform\Metadata\Resource\ResourceNameCollection; | ||
|
||
final class StaticResourceNameCollectionFactory implements ResourceNameCollectionFactoryInterface |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't like the "static" wording. Internal? Included?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that this will get removed as its not used? It is kind of static as it declares classes directly within the ResourceNameCollectionFactory. I'll use that for the Playground for example.
'title' => $object instanceof ProblemError ? $object->getTitle() : $context[self::TITLE] ?? $this->defaultContext[self::TITLE], | ||
'detail' => $object instanceof ProblemError ? $object->getDetail() : $this->getErrorMessage($object, $context, $this->debug), | ||
'instance' => $object instanceof ProblemError ? $object->getInstance() : null, | ||
'status' => $object instanceof ProblemError ? $object->getStatus() : $context['statusCode'] ?? $object->getStatusCode(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't adding the status
a BC?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think adding 'status' and 'instance' breaks the current return responses, I should try to add more checks about the existence of the extraProperties: ['hydra_errors' => true])
for the current request
I'm not seeing That brings to me to what I think the big missing piece here, which is how the user is going to populate the |
@nesl247 yes there is one: https://github.com/api-platform/core/pull/5433/files#diff-877c6e433ecaadc292cdac7d6b42f139dc48e86ab831a8fc489e7976a908a630R57
We'll implement that in a second PR, as you can see the |
Not too fond of it either, any idea on how to make it more extensible for users ? Edit (suggestion): Would you be ok with some kind of "Exception to Error" mapping (similar to the current "Exception to Status" one) ? # config/packages/api_platform.yaml
api_platform:
exception_to_error:
- App\Exception\MyCustomException: App\ProblemError\MyCustomError
... class MyCustomException extends ProblemException implements ProblemExceptionInterface
{
public $title;
public $type;
public $detail;
public $instance; // these values are assured by ProblemException
public $status;
...
} #[NotExposed(uriTemplate: '/my/custom/error')]
class MyCustomError extends ProblemError
{
public $title;
public $type;
public $detail;
public $instance;
public $status;
...
} Workflow: // src/State/Processor/MyCustomProcessor.php
public function process( ...)
{
// do stuff
if ( $someCondition) {
throw new MyCustomException($title, $type, $detail, $status, $instance)
}
} A bit later, in public function __invoke(ErrorExceptionInterface|FlattenException $exception, Request $request)
{
$operation = $this->initializeOperation($request);
$exceptionClass = $exception->getClass();
if ($errorClass = $this->matchExceptionToError($exceptionClass)) {
$error = new $errorClass($exception);
} else {
$error = new ProblemError($exception);
}
....
return new Response($this->serializer->serialize($error), ....) This might allow users, with relatively simple configuration, to create their own Exception and map them to Custom If that suits the needs, I think it might be "easier" to implement it at the same time than the base specification, wdyt ? |
I missed that because it's not in the One thing that seems like there is some confusion on is the difference between what the response is, and what the
As you can see, it's just supposed to be human-readable documentation. That doesn't necessitate having a status code, the details can't be there because that is only available when the response occurs, and the type must be static IIRC, the instance, and trace couldn't be there for the same reasons. Semi-related: to keep it simple, internally we actually used a |
@nesl247 Imo the #[NotExposed(uriTemplate: '/problems/out-of-credit')]
class OutOfCreditError extends ProblemError
{
} should directly produce a response with {
"type": "https://example.com/problems/out-of-credit"
...
} Also this PR is mainly a draft prototype open to discussion, a lot might still change :) Edit: did not see your edit. Edit 2 Maybe something like this ? #[ApiResource(uriTemplate: '/problems/out-of-credit')]
class OutOfCreditError extends ProblemError
{
// the other attributes needed for the error Response
#[Group('some_serialization_group_available_only_through_the_type_uri')]
public string $typeDescription = 'A generic description for an OutOfCreditError without specific details'
} |
This is why I would scope this to using a https://gist.github.com/nesl247/3ae6b1f9575e4dcd0960ca4a9016434e - this is a gist of how we implemented it. It could be extended to having a mapper if you need to control third party exceptions. |
Type is also an URI here so no problem on that. About the specification by end users, we want to handle this in another PR but basically you'd need to implement your own @JacquesDurand for the "routing" or "mapping" can't an exception be marked with the |
I still think there is some confusion here @soyuka, but I think @JacquesDurand understands it. There's 2 parts to the RFC, the response and the documentation endpoint (what type is supposed to point to if not a URN). The first part, which IMO should be the main focus, is the error response. The current implementation conflates the two. The error response is the only thing that really needs to be implemented by api-platform. Having a default Also, the I'm happy to discuss this more in Slack as well if that would be easier. I'm |
80a51a8
to
6a33ba7
Compare
src/ApiResource/ProblemError.php
Outdated
return $this->instance; | ||
} | ||
|
||
public function setInstance(?string $instance): void |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't it be better to make the object immutable either by never allowing it to be changed, or to use withers
instead?
534117d
to
43c7ed4
Compare
b23baaf
to
c6a0309
Compare
$identifiers = [ | ||
'uri_variables' => ['status' => $problem->getStatus()], | ||
]; | ||
$problem->setType($this->iriConverter->getIriFromResource($problem, context: $identifiers)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isn't that done by the normalizer already?
f4e78cc
to
0d0d83b
Compare
Hello !
This is an attempt to implement RFC-7807 for hydra error responses
My current (draft) try on the implementation is the following:
'jsonld' === $config['error_format']
:\Exception
, construct a new kind of exception:ErrorException
, and pass it to theControllerArgumentEvent
(cf the diff onsrc/Symfony/EventListener/ErrorListener.php
. The goal of this operation is to progressively break the dependency on Symfony's FlattenException (and then potentially also link stuff from Laravel for instance)Error
ApiResource inExceptionAction
, and normalize it in Hydra\ErrorNormalizer. This is mainly for'@id'
purposes, and also maybe to enable user to create their own Error resources based on this one.'jsonproblem' === $config['error_format']
:ErrorException
happensProblemError
ApiResource instead, which matches the json schema required by the RFC in case the server returns an 'application/problem+json' response.Problem\ErrorNormalizer
BC Layer for now: enable/disable these error serializations via an
extraProperty
field on each resource (false by default), e.g.As for now, what remains to be done (if this kind of implementation suits you):
AddLinkListener
#[ErrorResource]
Attribute to allow users to easily define their own errors with more precise details based on their needs ?This is still a pretty large work in progress, if I am going in the completely wrong direction i'd be happy to change anything !