Skip to content

Commit 5e5f477

Browse files
authored
Merge pull request #627 from dunglas/errors
Add support for the API Error format (RFC 7807) and more tests
2 parents 7d275f0 + 095c4ee commit 5e5f477

25 files changed

+788
-181
lines changed

features/problem.feature

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
Feature: Error handling valid according to RFC 7807 (application/problem+json)
2+
In order to be able to handle error client side
3+
As a client software developer
4+
I need to retrieve an RFC 7807 compliant serialization of errors
5+
6+
Scenario: Get an error
7+
When I add "Accept" header equal to "application/json"
8+
And I send a "POST" request to "/dummies" with body:
9+
"""
10+
{}
11+
"""
12+
Then the response status code should be 400
13+
And the response should be in JSON
14+
And the header "Content-Type" should be equal to "application/problem+json"
15+
And the JSON should be equal to:
16+
"""
17+
{
18+
"type": "https://tools.ietf.org/html/rfc2616#section-10",
19+
"title": "An error occurred",
20+
"detail": "name: This value should not be blank.",
21+
"violations": [
22+
{
23+
"propertyPath": "name",
24+
"message": "This value should not be blank."
25+
}
26+
]
27+
}
28+
"""
29+
30+
Scenario: Get an error during deserialization of simple relation
31+
When I add "Accept" header equal to "application/json"
32+
And I send a "POST" request to "/dummies" with body:
33+
"""
34+
{
35+
"name": "Foo",
36+
"relatedDummy": {
37+
"name": "bar"
38+
}
39+
}
40+
"""
41+
Then the response status code should be 400
42+
And the response should be in JSON
43+
And the header "Content-Type" should be equal to "application/problem+json"
44+
And the JSON node "type" should be equal to "https://tools.ietf.org/html/rfc2616#section-10"
45+
And the JSON node "title" should be equal to "An error occurred"
46+
And the JSON node "detail" should be equal to 'Nested objects for attribute "relatedDummy" of "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" are not enabled. Use serialization groups to change that behavior.'
47+
And the JSON node "trace" should exist

src/Action/ExceptionAction.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
namespace ApiPlatform\Core\Action;
13+
14+
use ApiPlatform\Core\Exception\InvalidArgumentException;
15+
use ApiPlatform\Core\Util\ErrorFormatGuesser;
16+
use Symfony\Component\Debug\Exception\FlattenException;
17+
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\HttpFoundation\Response;
19+
use Symfony\Component\Serializer\Exception\ExceptionInterface;
20+
use Symfony\Component\Serializer\SerializerInterface;
21+
22+
/**
23+
* Renders a normalized exception for a given {@see \Symfony\Component\Debug\Exception\FlattenException}.
24+
*
25+
* @author Baptiste Meyer <[email protected]>
26+
* @author Kévin Dunglas <[email protected]>
27+
*/
28+
final class ExceptionAction
29+
{
30+
const DEFAULT_EXCEPTION_TO_STATUS = [
31+
ExceptionInterface::class => Response::HTTP_BAD_REQUEST,
32+
InvalidArgumentException::class => Response::HTTP_BAD_REQUEST,
33+
];
34+
35+
private $serializer;
36+
private $errorFormats;
37+
private $exceptionToStatus;
38+
39+
public function __construct(SerializerInterface $serializer, array $errorFormats, $exceptionToStatus = [])
40+
{
41+
$this->serializer = $serializer;
42+
$this->errorFormats = $errorFormats;
43+
$this->exceptionToStatus = array_merge(self::DEFAULT_EXCEPTION_TO_STATUS, $exceptionToStatus);
44+
}
45+
46+
/**
47+
* Converts a an exception to a JSON response.
48+
*
49+
* @param \Exception|FlattenException $exception
50+
* @param Request $request
51+
*
52+
* @return Response
53+
*/
54+
public function __invoke($exception, Request $request) : Response
55+
{
56+
$exceptionClass = $exception->getClass();
57+
foreach ($this->exceptionToStatus as $class => $status) {
58+
if (is_a($exceptionClass, $class, true)) {
59+
$exception->setStatusCode($status);
60+
61+
break;
62+
}
63+
}
64+
65+
$headers = $exception->getHeaders();
66+
$format = ErrorFormatGuesser::guessErrorFormat($request, $this->errorFormats);
67+
$headers['Content-Type'] = $format['value'][0];
68+
69+
return new Response($this->serializer->serialize($exception, $format['key']), $exception->getStatusCode(), $headers);
70+
}
71+
}

src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,23 +53,20 @@ public function load(array $configs, ContainerBuilder $container)
5353
$configuration = new Configuration();
5454
$config = $this->processConfiguration($configuration, $configs);
5555

56-
$formats = [];
57-
foreach ($config['formats'] as $format => $value) {
58-
foreach ($value['mime_types'] as $mimeType) {
59-
$formats[$format][] = $mimeType;
60-
}
61-
}
62-
6356
$container->setAlias('api_platform.naming.resource_path_naming_strategy', $config['naming']['resource_path_naming_strategy']);
6457

6558
if ($config['name_converter']) {
6659
$container->setAlias('api_platform.name_converter', $config['name_converter']);
6760
}
6861

62+
$formats = $this->getFormats($config['formats']);
63+
$errorFormats = $this->getFormats($config['error_formats']);
64+
6965
$container->setParameter('api_platform.title', $config['title']);
7066
$container->setParameter('api_platform.description', $config['description']);
7167
$container->setParameter('api_platform.version', $config['version']);
7268
$container->setParameter('api_platform.formats', $formats);
69+
$container->setParameter('api_platform.error_formats', $errorFormats);
7370
$container->setParameter('api_platform.collection.order', $config['collection']['order']);
7471
$container->setParameter('api_platform.collection.order_parameter_name', $config['collection']['order_parameter_name']);
7572
$container->setParameter('api_platform.collection.pagination.enabled', $config['collection']['pagination']['enabled']);
@@ -99,6 +96,10 @@ public function load(array $configs, ContainerBuilder $container)
9996
$loader->load('hal.xml');
10097
}
10198

99+
if (isset($errorFormats['jsonproblem'])) {
100+
$loader->load('problem.xml');
101+
}
102+
102103
$this->registerAnnotationLoaders($container);
103104
$this->registerFileLoaders($container);
104105

@@ -179,4 +180,23 @@ private function registerFileLoaders(ContainerBuilder $container)
179180
$container->getDefinition('api_platform.metadata.resource.name_collection_factory.xml')->replaceArgument(0, $xmlResources);
180181
$container->getDefinition('api_platform.metadata.resource.metadata_factory.xml')->replaceArgument(0, $xmlResources);
181182
}
183+
184+
/**
185+
* Normalizes the format from config to the one accepted by Symfony HttpFoundation.
186+
*
187+
* @param array $configFormats
188+
*
189+
* @return array
190+
*/
191+
private function getFormats(array $configFormats) : array
192+
{
193+
$formats = [];
194+
foreach ($configFormats as $format => $value) {
195+
foreach ($value['mime_types'] as $mimeType) {
196+
$formats[$format][] = $mimeType;
197+
}
198+
}
199+
200+
return $formats;
201+
}
182202
}

src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection;
1313

14+
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
1415
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
1516
use Symfony\Component\Config\Definition\ConfigurationInterface;
1617

@@ -34,31 +35,6 @@ public function getConfigTreeBuilder()
3435
->scalarNode('title')->defaultValue('')->info('The title of the API.')->end()
3536
->scalarNode('description')->defaultValue('')->info('The description of the API.')->end()
3637
->scalarNode('version')->defaultValue('0.0.0')->info('The version of the API.')->end()
37-
->arrayNode('formats')
38-
->defaultValue(['jsonld' => ['mime_types' => ['application/ld+json']]])
39-
->info('The list of enabled formats. The first one will be the default.')
40-
->normalizeKeys(false)
41-
->useAttributeAsKey('format')
42-
->beforeNormalization()
43-
->ifArray()
44-
->then(function ($v) {
45-
foreach ($v as $format => $value) {
46-
if (isset($value['mime_types'])) {
47-
continue;
48-
}
49-
50-
$v[$format] = ['mime_types' => $value];
51-
}
52-
53-
return $v;
54-
})
55-
->end()
56-
->prototype('array')
57-
->children()
58-
->arrayNode('mime_types')->prototype('scalar')->end()->end()
59-
->end()
60-
->end()
61-
->end()
6238
->arrayNode('naming')
6339
->addDefaultsIfNotSet()
6440
->children()
@@ -92,6 +68,51 @@ public function getConfigTreeBuilder()
9268
->end()
9369
->end();
9470

71+
$this->addFormatSection($rootNode, 'formats', ['jsonld' => ['mime_types' => ['application/ld+json']]]);
72+
$this->addFormatSection($rootNode, 'error_formats', [
73+
'jsonproblem' => ['mime_types' => ['application/problem+json']],
74+
'jsonld' => ['mime_types' => ['application/ld+json']],
75+
]);
76+
9577
return $treeBuilder;
9678
}
79+
80+
/**
81+
* Adds a format section.
82+
*
83+
* @param ArrayNodeDefinition $rootNode
84+
* @param string $key
85+
* @param array $defaultValue
86+
*/
87+
private function addFormatSection(ArrayNodeDefinition $rootNode, string $key, array $defaultValue)
88+
{
89+
$rootNode
90+
->children()
91+
->arrayNode($key)
92+
->defaultValue($defaultValue)
93+
->info('The list of enabled formats. The first one will be the default.')
94+
->normalizeKeys(false)
95+
->useAttributeAsKey('format')
96+
->beforeNormalization()
97+
->ifArray()
98+
->then(function ($v) {
99+
foreach ($v as $format => $value) {
100+
if (isset($value['mime_types'])) {
101+
continue;
102+
}
103+
104+
$v[$format] = ['mime_types' => $value];
105+
}
106+
107+
return $v;
108+
})
109+
->end()
110+
->prototype('array')
111+
->children()
112+
->arrayNode('mime_types')->prototype('scalar')->end()->end()
113+
->end()
114+
->end()
115+
->end()
116+
->end();
117+
}
97118
}

src/Bridge/Symfony/Bundle/Resources/config/api.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,21 @@
108108
<tag name="kernel.event_listener" event="kernel.view" method="onKernelView" priority="8" />
109109
</service>
110110

111+
<service id="api_platform.listener.exception.validation" class="ApiPlatform\Core\Bridge\Symfony\Validator\EventListener\ValidationExceptionListener">
112+
<argument type="service" id="api_platform.serializer" />
113+
<argument>%api_platform.error_formats%</argument>
114+
115+
<tag name="kernel.event_listener" event="kernel.exception" method="onKernelException" />
116+
</service>
117+
118+
<service id="api_platform.listener.exception" class="ApiPlatform\Core\EventListener\ExceptionListener">
119+
<argument>api_platform.action.exception</argument>
120+
<argument type="service" id="logger" on-invalid="null" />
121+
122+
<tag name="kernel.event_listener" event="kernel.exception" method="onKernelException" priority="-96" />
123+
<tag name="monolog.logger" channel="request" />
124+
</service>
125+
111126
<!-- Action -->
112127

113128
<service id="api_platform.action.placeholder" class="ApiPlatform\Core\Action\PlaceholderAction" />
@@ -120,6 +135,11 @@
120135
<service id="api_platform.action.entrypoint" class="ApiPlatform\Core\Action\EntrypointAction">
121136
<argument type="service" id="api_platform.metadata.resource.name_collection_factory" />
122137
</service>
138+
139+
<service id="api_platform.action.exception" class="ApiPlatform\Core\Action\ExceptionAction">
140+
<argument type="service" id="api_platform.serializer" />
141+
<argument>%api_platform.error_formats%</argument>
142+
</service>
123143
</services>
124144

125145
</container>

src/Bridge/Symfony/Bundle/Resources/config/hydra.xml

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,14 @@
2525
<tag name="kernel.event_listener" event="kernel.response" method="onKernelResponse" />
2626
</service>
2727

28-
<service id="api_platform.hydra.listener.exception.validation" class="ApiPlatform\Core\Bridge\Symfony\Validator\Hydra\EventListener\ValidationExceptionListener">
29-
<argument type="service" id="api_platform.hydra.normalizer.constraint_violation_list" />
30-
31-
<tag name="kernel.event_listener" event="kernel.exception" method="onKernelException" />
32-
</service>
28+
<!-- Serializer -->
3329

34-
<service id="api_platform.hydra.listener.exception" class="ApiPlatform\Core\Hydra\EventListener\ExceptionListener">
35-
<argument>api_platform.hydra.action.exception</argument>
36-
<argument type="service" id="logger" on-invalid="null" />
30+
<service id="api_platform.hydra.normalizer.constraint_violation_list" class="ApiPlatform\Core\Hydra\Serializer\ConstraintViolationListNormalizer" public="false">
31+
<argument type="service" id="api_platform.router" />
3732

38-
<tag name="kernel.event_listener" event="kernel.exception" method="onKernelException" priority="-96" />
39-
<tag name="monolog.logger" channel="request" />
33+
<tag name="serializer.normalizer" priority="64" />
4034
</service>
4135

42-
<!-- Serializer -->
43-
4436
<service id="api_platform.hydra.normalizer.resource_name_collection" class="ApiPlatform\Core\Hydra\Serializer\ResourceNameCollectionNormalizer" public="false">
4537
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
4638
<argument type="service" id="api_platform.iri_converter" />
@@ -49,6 +41,13 @@
4941
<tag name="serializer.normalizer" priority="32" />
5042
</service>
5143

44+
<service id="api_platform.hydra.normalizer.error" class="ApiPlatform\Core\Hydra\Serializer\ErrorNormalizer" public="false">
45+
<argument type="service" id="api_platform.router" />
46+
<argument>%kernel.debug%</argument>
47+
48+
<tag name="serializer.normalizer" priority="32" />
49+
</service>
50+
5251
<service id="api_platform.hydra.normalizer.collection" class="ApiPlatform\Core\Hydra\Serializer\CollectionNormalizer" public="false">
5352
<argument type="service" id="api_platform.jsonld.context_builder" />
5453
<argument type="service" id="api_platform.resource_class_resolver" />
@@ -57,19 +56,6 @@
5756
<tag name="serializer.normalizer" priority="16" />
5857
</service>
5958

60-
<service id="api_platform.hydra.normalizer.constraint_violation_list" class="ApiPlatform\Core\Bridge\Symfony\Validator\Hydra\Serializer\ConstraintViolationListNormalizer" public="false">
61-
<argument type="service" id="api_platform.router" />
62-
63-
<tag name="serializer.normalizer" />
64-
</service>
65-
66-
<service id="api_platform.hydra.normalizer.error" class="ApiPlatform\Core\Hydra\Serializer\ErrorNormalizer" public="false">
67-
<argument type="service" id="api_platform.router" />
68-
<argument>%kernel.debug%</argument>
69-
70-
<tag name="serializer.normalizer" />
71-
</service>
72-
7359
<service id="api_platform.hydra.normalizer.partial_collection_view" class="ApiPlatform\Core\Hydra\Serializer\PartialCollectionViewNormalizer" decorates="api_platform.hydra.normalizer.collection" public="false">
7460
<argument type="service" id="api_platform.hydra.normalizer.partial_collection_view.inner" />
7561
<argument>%api_platform.collection.pagination.page_parameter_name%</argument>
@@ -88,11 +74,6 @@
8874
<service id="api_platform.hydra.action.documentation" class="ApiPlatform\Core\Documentation\Action\DocumentationAction">
8975
<argument type="service" id="api_platform.hydra.documentation_builder" />
9076
</service>
91-
92-
<service id="api_platform.hydra.action.exception" class="ApiPlatform\Core\Hydra\Action\ExceptionAction">
93-
<argument type="service" id="api_platform.hydra.normalizer.error" />
94-
</service>
95-
9677
</services>
9778

9879
</container>

0 commit comments

Comments
 (0)