diff --git a/src/Action/ExceptionAction.php b/src/Action/ExceptionAction.php index 492768bb027..2199ac37048 100644 --- a/src/Action/ExceptionAction.php +++ b/src/Action/ExceptionAction.php @@ -11,36 +11,48 @@ namespace ApiPlatform\Core\Action; -use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Util\ErrorFormatGuesser; use Symfony\Component\Debug\Exception\FlattenException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Serializer\Exception\ExceptionInterface; use Symfony\Component\Serializer\SerializerInterface; /** * Renders a normalized exception for a given {@see \Symfony\Component\Debug\Exception\FlattenException}. * + * Usage: + * + * $exceptionAction = new ExceptionAction( + * new Serializer(), + * [ + * 'jsonproblem' => ['application/problem+json'], + * 'jsonld' => ['application/ld+json'], + * ], + * [ + * ExceptionInterface::class => Response::HTTP_BAD_REQUEST, + * InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, + * ] + * ); + * * @author Baptiste Meyer * @author Kévin Dunglas */ final class ExceptionAction { - const DEFAULT_EXCEPTION_TO_STATUS = [ - ExceptionInterface::class => Response::HTTP_BAD_REQUEST, - InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, - ]; - private $serializer; private $errorFormats; private $exceptionToStatus; - public function __construct(SerializerInterface $serializer, array $errorFormats, $exceptionToStatus = []) + /** + * @param SerializerInterface $serializer + * @param array $errorFormats A list of enabled formats, the first one will be the default + * @param array $exceptionToStatus A list of exceptions mapped to their HTTP status code + */ + public function __construct(SerializerInterface $serializer, array $errorFormats, array $exceptionToStatus = []) { $this->serializer = $serializer; $this->errorFormats = $errorFormats; - $this->exceptionToStatus = self::DEFAULT_EXCEPTION_TO_STATUS + $exceptionToStatus; + $this->exceptionToStatus = $exceptionToStatus; } /** diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 3cfb0f224e2..d6b0f7489f3 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -87,6 +87,7 @@ private function handleConfig(ContainerBuilder $container, array $config, array $container->setParameter('api_platform.title', $config['title']); $container->setParameter('api_platform.description', $config['description']); $container->setParameter('api_platform.version', $config['version']); + $container->setParameter('api_platform.exception_to_status', $config['exception_to_status']); $container->setParameter('api_platform.formats', $formats); $container->setParameter('api_platform.error_formats', $errorFormats); $container->setParameter('api_platform.collection.order', $config['collection']['order']); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 888f164f16e..e59bfb665da 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -11,14 +11,19 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection; +use ApiPlatform\Core\Exception\InvalidArgumentException; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Exception\ExceptionInterface; /** * The configuration of the bundle. * * @author Kévin Dunglas + * @author Baptiste Meyer */ final class Configuration implements ConfigurationInterface { @@ -41,7 +46,7 @@ public function getConfigTreeBuilder() ->booleanNode('enable_nelmio_api_doc')->defaultValue(false)->info('Enable the Nelmio Api doc integration.')->end() ->booleanNode('enable_swagger')->defaultValue(true)->info('Enable the Swagger documentation and export.')->end() - ->arrayNode('collection') + ->arrayNode('collection') ->addDefaultsIfNotSet() ->children() ->scalarNode('order')->defaultNull()->info('The default order of results.')->end() @@ -64,6 +69,8 @@ public function getConfigTreeBuilder() ->end() ->end(); + $this->addExceptionToStatusSection($rootNode); + $this->addFormatSection($rootNode, 'formats', [ 'jsonld' => ['mime_types' => ['application/ld+json']], 'json' => ['mime_types' => ['application/json']], // Swagger support @@ -77,6 +84,58 @@ public function getConfigTreeBuilder() return $treeBuilder; } + /** + * Adds an exception to status section. + * + * @param ArrayNodeDefinition $rootNode + * + * @throws InvalidConfigurationException + */ + private function addExceptionToStatusSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('exception_to_status') + ->defaultValue([ + ExceptionInterface::class => Response::HTTP_BAD_REQUEST, + InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, + ]) + ->info('The list of exceptions mapped to their HTTP status code.') + ->normalizeKeys(false) + ->useAttributeAsKey('exception_class') + ->beforeNormalization() + ->ifArray() + ->then(function (array $exceptionToStatus) { + foreach ($exceptionToStatus as &$httpStatusCode) { + if (is_int($httpStatusCode)) { + continue; + } + + if (defined($httpStatusCodeConstant = sprintf('%s::%s', Response::class, $httpStatusCode))) { + $httpStatusCode = constant($httpStatusCodeConstant); + } + } + + return $exceptionToStatus; + }) + ->end() + ->prototype('integer')->end() + ->validate() + ->ifArray() + ->then(function (array $exceptionToStatus) { + foreach ($exceptionToStatus as $httpStatusCode) { + if ($httpStatusCode < 100 || $httpStatusCode >= 600) { + throw new InvalidConfigurationException(sprintf('The HTTP status code "%s" is not valid.', $httpStatusCode)); + } + } + + return $exceptionToStatus; + }) + ->end() + ->end() + ->end(); + } + /** * Adds a format section. * diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 2aa01cdd9fd..308a4352388 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -168,6 +168,7 @@ %api_platform.error_formats% + %api_platform.exception_to_status% diff --git a/tests/Action/ExceptionActionTest.php b/tests/Action/ExceptionActionTest.php index b5d80f18336..7ce9e07f45d 100644 --- a/tests/Action/ExceptionActionTest.php +++ b/tests/Action/ExceptionActionTest.php @@ -16,32 +16,60 @@ use Symfony\Component\Debug\Exception\FlattenException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Exception\ExceptionInterface; use Symfony\Component\Serializer\SerializerInterface; /** * @author Amrouche Hamza + * @author Baptiste Meyer */ class ExceptionActionTest extends \PHPUnit_Framework_TestCase { - public function testGetException() + public function testActionWithCatchableException() { - $flattenException = $this->prophesize(FlattenException::class); - $flattenException->getClass()->willReturn(InvalidArgumentException::class); - $flattenException->setStatusCode(Response::HTTP_BAD_REQUEST)->willReturn(); - $flattenException->getHeaders()->willReturn(['Content-Type' => 'application/problem+json']); + $serializerException = $this->prophesize(ExceptionInterface::class); + $serializerException->willExtend(\Exception::class); + + $flattenException = FlattenException::create($serializerException->reveal()); - $flattenException->getStatusCode()->willReturn(Response::HTTP_BAD_REQUEST); $serializer = $this->prophesize(SerializerInterface::class); - $exceptionAction = new ExceptionAction($serializer->reveal(), ['jsonproblem' => ['application/problem+json'], 'jsonld' => ['application/ld+json']]); + $serializer->serialize($flattenException, 'jsonproblem')->willReturn(); + + $exceptionAction = new ExceptionAction($serializer->reveal(), ['jsonproblem' => ['application/problem+json'], 'jsonld' => ['application/ld+json']], [ExceptionInterface::class => Response::HTTP_BAD_REQUEST, InvalidArgumentException::class => Response::HTTP_BAD_REQUEST]); + $request = new Request(); $request->setFormat('jsonproblem', 'application/problem+json'); - $serializer->serialize($flattenException, 'jsonproblem')->willReturn(); + $expected = new Response('', Response::HTTP_BAD_REQUEST, [ 'Content-Type' => 'application/problem+json; charset=utf-8', 'X-Content-Type-Options' => 'nosniff', 'X-Frame-Options' => 'deny', ]); - $this->assertEquals($expected, $exceptionAction($flattenException->reveal(), $request)); + $this->assertEquals($expected, $exceptionAction($flattenException, $request)); + } + + public function testActionWithUncatchableException() + { + $serializerException = $this->prophesize(ExceptionInterface::class); + $serializerException->willExtend(\Exception::class); + + $flattenException = FlattenException::create($serializerException->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize($flattenException, 'jsonproblem')->willReturn(); + + $exceptionAction = new ExceptionAction($serializer->reveal(), ['jsonproblem' => ['application/problem+json'], 'jsonld' => ['application/ld+json']]); + + $request = new Request(); + $request->setFormat('jsonproblem', 'application/problem+json'); + + $expected = new Response('', Response::HTTP_INTERNAL_SERVER_ERROR, [ + 'Content-Type' => 'application/problem+json; charset=utf-8', + 'X-Content-Type-Options' => 'nosniff', + 'X-Frame-Options' => 'deny', + ]); + + $this->assertEquals($expected, $exceptionAction($flattenException, $request)); } } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index ebaa2175123..9e8de1ab717 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -12,6 +12,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\DependencyInjection; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\ApiPlatformExtension; +use ApiPlatform\Core\Exception\InvalidArgumentException; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use FOS\UserBundle\FOSUserBundle; use Nelmio\ApiDocBundle\NelmioApiDocBundle; @@ -23,6 +24,8 @@ use Symfony\Component\DependencyInjection\Extension\ConfigurationExtensionInterface; use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Exception\ExceptionInterface; /** * @author Kévin Dunglas @@ -190,6 +193,7 @@ private function getContainerBuilderProphecy() 'api_platform.description' => 'description', 'api_platform.error_formats' => ['jsonproblem' => ['application/problem+json'], 'jsonld' => ['application/ld+json']], 'api_platform.formats' => ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']], + 'api_platform.exception_to_status' => [ExceptionInterface::class => Response::HTTP_BAD_REQUEST, InvalidArgumentException::class => Response::HTTP_BAD_REQUEST], 'api_platform.title' => 'title', 'api_platform.version' => 'version', ]; diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index bbf4eda5dff..8cb84bc5e06 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -12,23 +12,41 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\DependencyInjection; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Configuration; +use ApiPlatform\Core\Exception\InvalidArgumentException; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\Processor; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Exception\ExceptionInterface; /** * @author Kévin Dunglas + * @author Baptiste Meyer */ class ConfigurationTest extends \PHPUnit_Framework_TestCase { + /** + * @var Configuration + */ + private $configuration; + + /** + * @var Processor + */ + private $processor; + + public function setUp() + { + $this->configuration = new Configuration(); + $this->processor = new Processor(); + } + public function testDefaultConfig() { - $configuration = new Configuration(); - $treeBuilder = $configuration->getConfigTreeBuilder(); - $processor = new Processor(); - $config = $processor->processConfiguration($configuration, ['api_platform' => ['title' => 'title', 'description' => 'description', 'version' => '1.0.0']]); + $treeBuilder = $this->configuration->getConfigTreeBuilder(); + $config = $this->processor->processConfiguration($this->configuration, ['api_platform' => ['title' => 'title', 'description' => 'description', 'version' => '1.0.0']]); - $this->assertInstanceOf(ConfigurationInterface::class, $configuration); + $this->assertInstanceOf(ConfigurationInterface::class, $this->configuration); $this->assertInstanceOf(TreeBuilder::class, $treeBuilder); $this->assertEquals([ 'title' => 'title', @@ -43,6 +61,10 @@ public function testDefaultConfig() 'jsonproblem' => ['mime_types' => ['application/problem+json']], 'jsonld' => ['mime_types' => ['application/ld+json']], ], + 'exception_to_status' => [ + ExceptionInterface::class => Response::HTTP_BAD_REQUEST, + InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, + ], 'default_operation_path_resolver' => 'api_platform.operation_path_resolver.underscore', 'name_converter' => null, 'enable_fos_user' => false, @@ -64,4 +86,76 @@ public function testDefaultConfig() ], ], $config); } + + public function testExceptionToStatusConfig() + { + $config = $this->processor->processConfiguration($this->configuration, [ + 'api_platform' => [ + 'exception_to_status' => [ + \InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, + \RuntimeException::class => 'HTTP_INTERNAL_SERVER_ERROR', + ], + ], + ]); + + $this->assertTrue(isset($config['exception_to_status'])); + $this->assertSame([ + \InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, + \RuntimeException::class => Response::HTTP_INTERNAL_SERVER_ERROR, + ], $config['exception_to_status']); + } + + public function invalidHttpStatusCodeProvider() + { + return [ + [0], + [99], + [700], + [1000], + ]; + } + + /** + * @dataProvider invalidHttpStatusCodeProvider + * @expectedException \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException + * @expectedExceptionMessageRegExp /The HTTP status code ".+" is not valid\./ + */ + public function testExceptionToStatusConfigWithInvalidHttpStatusCode($invalidHttpStatusCode) + { + $this->processor->processConfiguration($this->configuration, [ + 'api_platform' => [ + 'exception_to_status' => [ + \Exception::class => $invalidHttpStatusCode, + ], + ], + ]); + } + + public function invalidHttpStatusCodeValueProvider() + { + return [ + [true], + [null], + [-INF], + [40.4], + ['foo'], + ['HTTP_FOO_BAR'], + ]; + } + + /** + * @dataProvider invalidHttpStatusCodeValueProvider + * @expectedException \Symfony\Component\Config\Definition\Exception\InvalidTypeException + * @expectedExceptionMessageRegExp /Invalid type for path "api_platform\.exception_to_status\.Exception". Expected int, but got .+\./ + */ + public function testExceptionToStatusConfigWithInvalidHttpStatusCodeValue($invalidHttpStatusCodeValue) + { + $this->processor->processConfiguration($this->configuration, [ + 'api_platform' => [ + 'exception_to_status' => [ + \Exception::class => $invalidHttpStatusCodeValue, + ], + ], + ]); + } } diff --git a/tests/Fixtures/app/config/config.yml b/tests/Fixtures/app/config/config.yml index ffc11905615..c7e9c8db609 100644 --- a/tests/Fixtures/app/config/config.yml +++ b/tests/Fixtures/app/config/config.yml @@ -47,6 +47,9 @@ api_platform: client_items_per_page: true items_per_page: 3 enable_nelmio_api_doc: true + exception_to_status: + Symfony\Component\Serializer\Exception\ExceptionInterface: 400 + ApiPlatform\Core\Exception\InvalidArgumentException: 'HTTP_BAD_REQUEST' fos_user: db_driver: 'orm'