├── .gitignore ├── src ├── ApiProblemBundle.php ├── Transformer │ ├── ExceptionTransformerInterface.php │ ├── ApiProblemExceptionTransformer.php │ ├── SecurityExceptionTransformer.php │ ├── HttpExceptionTransformer.php │ └── Chain.php ├── DependencyInjection │ └── ApiProblemExtension.php ├── Exception │ └── ApiProblemHttpException.php └── EventListener │ └── JsonApiProblemExceptionListener.php ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── Support_Question.md │ ├── Feature_Request.md │ └── Bug.md └── workflows │ └── grumphp.yaml ├── grumphp.yml.dist ├── test ├── ApiProblemBundleTest.php ├── Transformer │ ├── HttpExceptionTransformerTest.php │ ├── ApiProblemExceptionTransformerTest.php │ ├── ChainTest.php │ └── SecurityExceptionTransformerTest.php ├── Exception │ └── ApiProblemHttpExceptionTest.php ├── DependencyInjection │ └── ApiProblemExtensionTest.php └── EventListener │ └── JsonApiProblemExceptionListenerTest.php ├── CONTRIBUTING ├── phpunit.xml.dist ├── LICENSE ├── composer.json ├── config └── services.xml ├── README.md └── .php-cs-fixer.dist.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | .php-cs-fixer.cache 4 | .php_cs 5 | grumphp.yml 6 | phpunit.xml 7 | var 8 | .phpunit.result.cache 9 | -------------------------------------------------------------------------------- /src/ApiProblemBundle.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | | Q | A 4 | |------------- | ----------- 5 | | Type | bug/feature/improvement 6 | | BC Break | yes/no 7 | | Fixed issues | 8 | 9 | #### Summary 10 | 11 | 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Support_Question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ❓ Support Question 3 | about: Have a problem that you can't figure out? 🤔 4 | --- 5 | 6 | 7 | 8 | | Q | A 9 | |------------ | ----- 10 | | Version | x.y.z 11 | 12 | 13 | ### Support Question 14 | 15 | 16 | -------------------------------------------------------------------------------- /grumphp.yml.dist: -------------------------------------------------------------------------------- 1 | grumphp: 2 | tasks: 3 | phpcsfixer: 4 | config: ".php-cs-fixer.dist.php" 5 | config_contains_finder: true 6 | phpunit: ~ 7 | clover_coverage: 8 | clover_file: var/coverage.xml 9 | level: 100 10 | metadata: 11 | priority: -1 12 | composer: 13 | no_check_lock: true 14 | xmllint: 15 | load_from_net: true 16 | scheme_validation: true 17 | triggered_by: ['xml'] 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_Request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🎉 Feature Request 3 | about: You have a neat idea that should be implemented? 🎩 4 | --- 5 | 6 | ### Feature Request 7 | 8 | 9 | 10 | | Q | A 11 | |------------ | ------ 12 | | New Feature | yes 13 | | RFC | yes/no 14 | | BC Break | yes/no 15 | 16 | #### Summary 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Transformer/ExceptionTransformerInterface.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Bundle::class, new ApiProblemBundle()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/DependencyInjection/ApiProblemExtension.php: -------------------------------------------------------------------------------- 1 | load('services.xml'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Api Problem Bundle is an open source, community-driven project. If you'd like to contribute, 4 | feel free to do this, but remember to follow this few simple rules: 5 | 6 | ## Branching strategy 7 | 8 | - __Always__ base your changes on the `master` branch (all new development happens here), 9 | - When you create Pull Request, always select `master` branch as target, otherwise it 10 | will be closed (this is selected by default). 11 | 12 | ## Coverage 13 | 14 | - All classes that interact solely with the core logic should be covered by Tests 15 | 16 | ## Code style / Formatting 17 | 18 | - All code in the `src` and `test` folder must follow the PSR-2 standard and should comply with the php-cs-fixer config. 19 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./test 14 | 15 | 16 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐞 Bug Report 3 | about: Something is broken? 🔨 4 | --- 5 | 6 | ### Bug Report 7 | 8 | 9 | 10 | | Q | A 11 | |------------ | ------ 12 | | BC Break | yes/no 13 | | Version | x.y.z 14 | 15 | #### Summary 16 | 17 | 18 | 19 | #### Current behaviour 20 | 21 | 22 | 23 | #### How to reproduce 24 | 25 | 30 | 31 | #### Expected behaviour 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Transformer/ApiProblemExceptionTransformer.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class ApiProblemExceptionTransformer implements ExceptionTransformerInterface 16 | { 17 | /** 18 | * @param ApiProblemException|ApiProblemHttpException $exception 19 | */ 20 | public function transform(Throwable $exception): ApiProblemInterface 21 | { 22 | return $exception->getApiProblem(); 23 | } 24 | 25 | public function accepts(Throwable $exception): bool 26 | { 27 | return $exception instanceof ApiProblemException || $exception instanceof ApiProblemHttpException; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Transformer/SecurityExceptionTransformer.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class SecurityExceptionTransformer implements ExceptionTransformerInterface 18 | { 19 | public function transform(Throwable $exception): ApiProblemInterface 20 | { 21 | if ($exception instanceof AuthenticationException) { 22 | return new UnauthorizedProblem($exception->getMessage()); 23 | } 24 | 25 | return new ForbiddenProblem($exception->getMessage()); 26 | } 27 | 28 | public function accepts(Throwable $exception): bool 29 | { 30 | return $exception instanceof SecurityException; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Phpro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Transformer/HttpExceptionTransformer.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class HttpExceptionTransformer implements ExceptionTransformerInterface 16 | { 17 | /** 18 | * @param HttpException $exception 19 | */ 20 | public function transform(Throwable $exception): ApiProblemInterface 21 | { 22 | return new ExceptionApiProblem( 23 | new HttpException( 24 | $exception->getStatusCode(), 25 | $exception->getMessage(), 26 | $exception, 27 | $exception->getHeaders(), 28 | $exception->getStatusCode() 29 | ) 30 | ); 31 | } 32 | 33 | public function accepts(Throwable $exception): bool 34 | { 35 | return $exception instanceof HttpException; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exception/ApiProblemHttpException.php: -------------------------------------------------------------------------------- 1 | toArray(); 18 | $message = $data['detail'] ?? ($data['title'] ?? ''); 19 | $code = (int) ($data['status'] ?? 0); 20 | 21 | parent::__construct($code, $message); 22 | $this->apiProblem = $apiProblem; 23 | } 24 | 25 | public function getApiProblem(): ApiProblemInterface 26 | { 27 | return $this->apiProblem; 28 | } 29 | 30 | public function getStatusCode(): int 31 | { 32 | return parent::getStatusCode() > 0 ? parent::getStatusCode() : Response::HTTP_BAD_REQUEST; 33 | } 34 | 35 | public function getHeaders(): array 36 | { 37 | return ['Content-Type' => 'application/problem+json']; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Transformer/Chain.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class Chain implements ExceptionTransformerInterface 15 | { 16 | /** 17 | * @var ExceptionTransformerInterface[] 18 | */ 19 | private $transformers = []; 20 | 21 | public function __construct(iterable $transformers) 22 | { 23 | foreach ($transformers as $transformer) { 24 | $this->addTransformer($transformer); 25 | } 26 | } 27 | 28 | public function transform(Throwable $exception): ApiProblemInterface 29 | { 30 | foreach ($this->transformers as $transformer) { 31 | if ($transformer->accepts($exception)) { 32 | return $transformer->transform($exception); 33 | } 34 | } 35 | 36 | return new ExceptionApiProblem($exception); 37 | } 38 | 39 | public function accepts(Throwable $exception): bool 40 | { 41 | return true; 42 | } 43 | 44 | private function addTransformer(ExceptionTransformerInterface $transformer): void 45 | { 46 | $this->transformers[] = $transformer; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phpro/api-problem-bundle", 3 | "description": "RFC7807 Problem details integration for Symfony", 4 | "type": "symfony-bundle", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Toon Verwerft", 9 | "email": "toon.verwerft@phpro.be" 10 | } 11 | ], 12 | "require": { 13 | "php": "~8.3.0 || ~8.4.0 || ~8.5.0", 14 | "phpro/api-problem": "^1.0", 15 | "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", 16 | "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", 17 | "symfony/http-kernel": "^6.4 || ^7.0" 18 | }, 19 | "require-dev": { 20 | "matthiasnoback/symfony-dependency-injection-test": "^4.3", 21 | "php-cs-fixer/shim": "^3.88", 22 | "phpro/grumphp-shim": "^2.17", 23 | "phpspec/prophecy": "^1.17", 24 | "phpspec/prophecy-phpunit": "^2.0", 25 | "phpunit/phpunit": "^12.4", 26 | "symfony/security-core": "^5.4 || ^6.0 || ^7.0" 27 | }, 28 | "config": { 29 | "sort-packages": true, 30 | "allow-plugins": { 31 | "phpro/grumphp-shim": true 32 | } 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Phpro\\ApiProblemBundle\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "PhproTest\\ApiProblemBundle\\": "test/" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 11 | %kernel.debug% 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /test/Transformer/HttpExceptionTransformerTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ExceptionTransformerInterface::class, $transformer); 25 | } 26 | 27 | #[Test] 28 | public function it_accepts_api_problem_exceptions(): void 29 | { 30 | $transformer = new HttpExceptionTransformer(); 31 | 32 | $this->assertTrue($transformer->accepts(new HttpException(400, 'Bad Request'))); 33 | $this->assertFalse($transformer->accepts(new Exception())); 34 | } 35 | 36 | #[Test] 37 | public function it_transforms_exception_to_api_problem(): void 38 | { 39 | $transformer = new HttpExceptionTransformer(); 40 | $exception = new HttpException($statusCode = 400, $detail = 'Bad Request'); 41 | $apiProblem = $transformer->transform($exception); 42 | 43 | $this->assertInstanceOf(ExceptionApiProblem::class, $apiProblem); 44 | $this->assertEquals([ 45 | 'status' => $statusCode, 46 | 'title' => HttpApiProblem::getTitleForStatusCode($statusCode), 47 | 'detail' => $detail, 48 | 'type' => HttpApiProblem::TYPE_HTTP_RFC, 49 | ], $apiProblem->toArray()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/Transformer/ApiProblemExceptionTransformerTest.php: -------------------------------------------------------------------------------- 1 | apiProblem = $this->prophesize(ApiProblemInterface::class); 31 | $this->apiProblem->toArray()->willReturn([]); 32 | } 33 | 34 | #[Test] 35 | public function it_is_an_exception_transformer(): void 36 | { 37 | $transformer = new ApiProblemExceptionTransformer(); 38 | $this->assertInstanceOf(ExceptionTransformerInterface::class, $transformer); 39 | } 40 | 41 | #[Test] 42 | public function it_accepts_api_problem_exceptions(): void 43 | { 44 | $transformer = new ApiProblemExceptionTransformer(); 45 | 46 | $this->assertTrue($transformer->accepts(new ApiProblemException($this->apiProblem->reveal()))); 47 | $this->assertFalse($transformer->accepts(new Exception())); 48 | } 49 | 50 | #[Test] 51 | public function it_transforms_exception_to_api_problem(): void 52 | { 53 | $transformer = new ApiProblemExceptionTransformer(); 54 | $apiProblem = $this->apiProblem->reveal(); 55 | 56 | $this->assertSame($apiProblem, $transformer->transform(new ApiProblemException($apiProblem))); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/grumphp.yaml: -------------------------------------------------------------------------------- 1 | name: GrumPHP 2 | 3 | on: [push, pull_request] 4 | jobs: 5 | run: 6 | runs-on: ${{ matrix.operating-system }} 7 | strategy: 8 | matrix: 9 | operating-system: [ubuntu-latest] 10 | php-versions: ['8.3', '8.4', '8.5'] 11 | composer-options: ['', '--prefer-lowest'] 12 | fail-fast: false 13 | name: PHP ${{ matrix.php-versions }} @ ${{ matrix.operating-system }} with ${{ matrix.composer-options }} 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@master 17 | - name: Install PHP 18 | uses: shivammathur/setup-php@master 19 | with: 20 | php-version: ${{ matrix.php-versions }} 21 | tools: 'composer:v2' 22 | extensions: pcov, mbstring, posix 23 | - name: Set env vars for latest PHP version 24 | if: matrix.php-versions == '8.5' 25 | run: | 26 | export COMPOSER_IGNORE_PLATFORM_REQ=php+ 27 | export BOX_REQUIREMENT_CHECKER=0 28 | - name: Check Versions 29 | run: | 30 | php -v 31 | php -m 32 | composer --version 33 | - name: Get composer cache directory 34 | id: composercache 35 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 36 | - name: Cache dependencies 37 | uses: actions/cache@v4 38 | with: 39 | path: ${{ steps.composercache.outputs.dir }} 40 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 41 | restore-keys: ${{ runner.os }}-composer- 42 | - name: Install dependencies PHP 43 | run: composer update --prefer-dist --no-progress --ignore-platform-req=php+ ${{ matrix.composer-options }} 44 | - name: Run the tests 45 | run: php vendor/bin/grumphp run --no-interaction 46 | continue-on-error: ${{ matrix.php-versions == '8.5' }} 47 | env: 48 | PHP_CS_FIXER_IGNORE_ENV: 1 49 | -------------------------------------------------------------------------------- /src/EventListener/JsonApiProblemExceptionListener.php: -------------------------------------------------------------------------------- 1 | exceptionTransformer = $exceptionTransformer; 31 | $this->debug = $debug; 32 | } 33 | 34 | public function onKernelException(ExceptionEvent $event): void 35 | { 36 | $request = $event->getRequest(); 37 | if ( 38 | false === mb_strpos($request->getPreferredFormat(), 'json') 39 | && false === mb_strpos((string) $request->getContentTypeFormat(), 'json') 40 | ) { 41 | return; 42 | } 43 | 44 | $apiProblem = $this->convertExceptionToProblem($event->getThrowable()); 45 | 46 | $event->setResponse($this->generateResponse($apiProblem)); 47 | } 48 | 49 | private function convertExceptionToProblem(Throwable $exception): ApiProblemInterface 50 | { 51 | if (!$this->exceptionTransformer->accepts($exception)) { 52 | return new ExceptionApiProblem($exception); 53 | } 54 | 55 | return $this->exceptionTransformer->transform($exception); 56 | } 57 | 58 | private function generateResponse(ApiProblemInterface $apiProblem): JsonResponse 59 | { 60 | $data = ($this->debug && $apiProblem instanceof DebuggableApiProblemInterface) 61 | ? $apiProblem->toDebuggableArray() 62 | : $apiProblem->toArray(); 63 | 64 | $statusCode = (int) ($data['status'] ?? Response::HTTP_BAD_REQUEST); 65 | 66 | return new JsonResponse( 67 | $data, 68 | $statusCode, 69 | ['Content-Type' => 'application/problem+json'] 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/Transformer/ChainTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ExceptionTransformerInterface::class, $transformer); 29 | } 30 | 31 | #[Test] 32 | public function it_accepts_any_exception(): void 33 | { 34 | $transformer = new Chain([]); 35 | $this->assertTrue($transformer->accepts(new Exception())); 36 | } 37 | 38 | #[Test] 39 | public function it_transforms_with_first_acceptable_transformer(): void 40 | { 41 | $transformer = new Chain([ 42 | $this->mockTransformer(false), 43 | $this->mockTransformer(true, $apiProblem1 = $this->prophesize(ApiProblemInterface::class)->reveal()), 44 | $this->mockTransformer(true, $apiProblem2 = $this->prophesize(ApiProblemInterface::class)->reveal()), 45 | ]); 46 | 47 | $this->assertEquals($apiProblem1, $transformer->transform(new Exception())); 48 | } 49 | 50 | #[Test] 51 | public function it_transforms_to_basic_exception_problem_when_no_transformer_matches(): void 52 | { 53 | $transformer = new Chain([$this->mockTransformer(false)]); 54 | 55 | $this->assertInstanceOf(ExceptionApiProblem::class, $transformer->transform(new Exception())); 56 | } 57 | 58 | private function mockTransformer(bool $accepts, ?ApiProblemInterface $apiProblem = null): ExceptionTransformerInterface 59 | { 60 | /** @var ExceptionTransformerInterface|ObjectProphecy $transformer */ 61 | $transformer = $this->prophesize(ExceptionTransformerInterface::class); 62 | $transformer->accepts(Argument::any())->willReturn($accepts); 63 | 64 | if ($apiProblem) { 65 | $transformer->transform(Argument::any())->willReturn($apiProblem); 66 | } 67 | if (!$accepts) { 68 | $transformer->transform(Argument::any())->shouldNotBeCalled(); 69 | } 70 | 71 | return $transformer->reveal(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/Exception/ApiProblemHttpExceptionTest.php: -------------------------------------------------------------------------------- 1 | apiProblem = $this->prophesize(HttpApiProblem::class); 28 | $this->apiProblem->toArray()->willReturn([]); 29 | } 30 | 31 | #[Test] 32 | public function it_is_accepted_by_the__api_problem_exception_transformer(): void 33 | { 34 | $transformer = new ApiProblemExceptionTransformer(); 35 | 36 | $this->assertTrue($transformer->accepts(new ApiProblemHttpException($this->apiProblem->reveal()))); 37 | } 38 | 39 | #[Test] 40 | public function it_is_an_instance_of__http_exception(): void 41 | { 42 | $exception = new ApiProblemHttpException($this->apiProblem->reveal()); 43 | 44 | $this->assertInstanceOf(HttpException::class, $exception); 45 | } 46 | 47 | #[Test] 48 | public function it_contains_an_api_problem(): void 49 | { 50 | $apiProblem = $this->apiProblem->reveal(); 51 | 52 | $exception = new ApiProblemHttpException($apiProblem); 53 | $this->assertEquals($apiProblem, $exception->getApiProblem()); 54 | } 55 | 56 | #[Test] 57 | public function it_returns_the_correct_http_headers(): void 58 | { 59 | $exception = new ApiProblemHttpException($this->apiProblem->reveal()); 60 | 61 | $this->assertEquals(['Content-Type' => 'application/problem+json'], $exception->getHeaders()); 62 | } 63 | 64 | #[Test] 65 | public function it_returns_the_correct_default_http_statuscode(): void 66 | { 67 | $exception = new ApiProblemHttpException($this->apiProblem->reveal()); 68 | 69 | $this->assertEquals(400, $exception->getStatusCode()); 70 | } 71 | 72 | #[Test] 73 | public function it_returns_the_correct_specified_http_statuscode(): void 74 | { 75 | $this->apiProblem->toArray()->willReturn(['status' => 401]); 76 | $exception = new ApiProblemHttpException($this->apiProblem->reveal()); 77 | 78 | $this->assertEquals(401, $exception->getStatusCode()); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/Transformer/SecurityExceptionTransformerTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ExceptionTransformerInterface::class, $transformer); 28 | } 29 | 30 | #[Test] 31 | public function it_accepts_api_problem_exceptions(): void 32 | { 33 | $transformer = new SecurityExceptionTransformer(); 34 | 35 | $this->assertTrue($transformer->accepts(new RuntimeException())); 36 | $this->assertFalse($transformer->accepts(new Exception())); 37 | } 38 | 39 | #[Test] 40 | public function it_transforms_authentication_exception_to_api_problem(): void 41 | { 42 | $transformer = new SecurityExceptionTransformer(); 43 | $exception = new AuthenticationException($detail = 'not authenticated'); 44 | $apiProblem = $transformer->transform($exception); 45 | 46 | $this->assertInstanceOf(UnauthorizedProblem::class, $apiProblem); 47 | $this->assertEquals([ 48 | 'status' => 401, 49 | 'title' => HttpApiProblem::getTitleForStatusCode(401), 50 | 'detail' => $detail, 51 | 'type' => HttpApiProblem::TYPE_HTTP_RFC, 52 | ], $apiProblem->toArray()); 53 | } 54 | 55 | #[Test] 56 | public function it_transforms_other_security_exceptions_to_api_problem(): void 57 | { 58 | $transformer = new SecurityExceptionTransformer(); 59 | $exception = new AccessDeniedException($detail = 'Invalid roles'); 60 | $apiProblem = $transformer->transform($exception); 61 | 62 | $this->assertInstanceOf(ForbiddenProblem::class, $apiProblem); 63 | $this->assertEquals([ 64 | 'status' => 403, 65 | 'title' => HttpApiProblem::getTitleForStatusCode(403), 66 | 'detail' => $detail, 67 | 'type' => HttpApiProblem::TYPE_HTTP_RFC, 68 | ], $apiProblem->toArray()); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/DependencyInjection/ApiProblemExtensionTest.php: -------------------------------------------------------------------------------- 1 | load([]); 29 | 30 | $this->assertContainerBuilderHasService( 31 | JsonApiProblemExceptionListener::class, 32 | JsonApiProblemExceptionListener::class 33 | ); 34 | $this->assertContainerBuilderHasServiceDefinitionWithArgument( 35 | JsonApiProblemExceptionListener::class, 36 | '$exceptionTransformer', 37 | Transformer\Chain::class 38 | ); 39 | $this->assertContainerBuilderHasServiceDefinitionWithArgument( 40 | JsonApiProblemExceptionListener::class, 41 | '$debug', 42 | '%kernel.debug%' 43 | ); 44 | $this->assertContainerBuilderHasServiceDefinitionWithTag( 45 | JsonApiProblemExceptionListener::class, 46 | 'kernel.event_listener', 47 | [ 48 | 'event' => 'kernel.exception', 49 | 'method' => 'onKernelException', 50 | 'priority' => '-5', 51 | ] 52 | ); 53 | } 54 | 55 | #[Test] 56 | public function it_contains_exception_transformers(): void 57 | { 58 | $this->load([]); 59 | 60 | $this->assertContainerBuilderHasService( 61 | Transformer\Chain::class, 62 | Transformer\Chain::class 63 | ); 64 | $this->assertContainerBuilderHasServiceDefinitionWithArgument( 65 | Transformer\Chain::class, 66 | 0, 67 | new TaggedIteratorArgument(self::TRANSFORMER_TAG) 68 | ); 69 | 70 | $this->assertContainerHasTransformer(Transformer\ApiProblemExceptionTransformer::class); 71 | $this->assertContainerHasTransformer(Transformer\HttpExceptionTransformer::class); 72 | $this->assertContainerHasTransformer(Transformer\SecurityExceptionTransformer::class); 73 | } 74 | 75 | private function assertContainerHasTransformer(string $serviceId): void 76 | { 77 | $this->assertContainerBuilderHasService($serviceId, $serviceId); 78 | $this->assertContainerBuilderHasServiceDefinitionWithTag($serviceId, self::TRANSFORMER_TAG); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Installs](https://img.shields.io/packagist/dt/phpro/api-problem-bundle.svg)](https://packagist.org/packages/phpro/api-problem-bundle/stats) 2 | [![Packagist](https://img.shields.io/packagist/v/phpro/api-problem-bundle.svg)](https://packagist.org/packages/phpro/api-problem-bundle) 3 | 4 | 5 | # Api Problem Bundle 6 | 7 | This package provides a [RFC7807](https://tools.ietf.org/html/rfc7807) Problem details exception listener for Symfony. 8 | Internal, this package uses the models provided by [`phpro/api-problem`](https://www.github.com/phpro/api-problem). 9 | When an `ApiProblemException` is triggered, this bundle will return the correct response. 10 | 11 | 12 | ## Installation 13 | 14 | ```sh 15 | composer require phpro/api-problem-bundle 16 | ``` 17 | 18 | If you are not using `symfony/flex`, you'll have to manually add the bundle to your bundles file: 19 | 20 | ```php 21 | // config/bundles.php 22 | 23 | return [ 24 | // ... 25 | Phpro\ApiProblemBundle\ApiProblemBundle::class => ['all' => true], 26 | ]; 27 | ``` 28 | 29 | ## Supported response formats 30 | 31 | - application/problem+json 32 | 33 | 34 | ## How it works 35 | 36 | ```php 37 | use Phpro\ApiProblem\Exception\ApiProblemException; 38 | use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; 39 | 40 | class SomeController 41 | { 42 | /** 43 | * @Route('/some-route', defaults={"_format" = "json"}) 44 | */ 45 | public function someAction() { 46 | throw new ApiProblemException( 47 | new HttpApiProblem('400', 'It aint all bad ...') 48 | ); 49 | } 50 | } 51 | ``` 52 | 53 | When the controller is marked as a "json" format, the request `Content-Type` is `*/json` or the request `Accept` header first value contains json (i.e. `application/json, text/html`), this bundle kicks in. 54 | It will transform the exception to following response: 55 | 56 | Headers: 57 | ``` 58 | Content-Type: application/problem+json 59 | ``` 60 | 61 | Body: 62 | ```json 63 | { 64 | "status": 400, 65 | "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", 66 | "title": "Bad Request", 67 | "detail": "It ain't all bad ..." 68 | } 69 | ``` 70 | 71 | As an alternative, use ```ApiProblemHttpException``` instead of ```ApiProblemException```, to make it possible to 72 | [exclude the specific status code from the log](https://symfony.com/doc/current/logging/monolog_exclude_http_codes.html) 73 | 74 | ## Adding custom exception transformations 75 | 76 | Currently, we automatically transform exceptions from following packages to an ApiProblem instance: 77 | 78 | - phpro/api-problem 79 | - symfony/http-kernel 80 | - symfony/security 81 | 82 | Besides that, all other errors are transformed to a basic `ExceptionApiProblem` instance. 83 | 84 | If you want to add custom transformations, you can implement the `ExceptionTransformerInterface` 85 | and register it in the symfony container with the `phpro.api_problem.exception_transformer` tag. 86 | 87 | ```php 88 | use Phpro\ApiProblemBundle\Transformer\ExceptionTransformerInterface; 89 | 90 | class MyTransformer implements ExceptionTransformerInterface 91 | { 92 | public function transform(\Throwable $exception): ApiProblemInterface 93 | { 94 | return new MyApiProblem($exception); 95 | } 96 | 97 | public function accepts(\Throwable $exception): bool 98 | { 99 | return $exception instanceof MyException; 100 | } 101 | } 102 | ``` 103 | 104 | ## About 105 | 106 | ### Submitting bugs and feature requests 107 | 108 | Bugs and feature request are tracked on [GitHub](https://github.com/phpro/api-problem-bundle/issues). 109 | Please take a look at our rules before [contributing your code](CONTRIBUTING). 110 | 111 | ### License 112 | 113 | api-problem-bundle is licensed under the [MIT License](LICENSE). 114 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setFinder( 5 | \Symfony\Component\Finder\Finder::create() 6 | ->in([ 7 | __DIR__ . '/src', 8 | __DIR__ . '/test', 9 | ]) 10 | ) 11 | ->setRiskyAllowed(true) 12 | ->setRules([ 13 | '@Symfony' => true, 14 | 'align_multiline_comment' => true, 15 | 'array_indentation' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'backtick_to_shell_exec' => true, 18 | 'blank_line_before_statement' => ['statements' => ['return']], 19 | 'class_keyword_remove' => false, 20 | 'combine_consecutive_issets' => true, 21 | 'combine_consecutive_unsets' => true, 22 | 'comment_to_phpdoc' => true, 23 | 'compact_nullable_typehint' => true, 24 | 'date_time_immutable' => true, 25 | 'declare_strict_types' => true, 26 | 'doctrine_annotation_array_assignment' => true, 27 | 'doctrine_annotation_braces' => true, 28 | 'doctrine_annotation_indentation' => true, 29 | 'doctrine_annotation_spaces' => true, 30 | 'escape_implicit_backslashes' => true, 31 | 'explicit_indirect_variable' => true, 32 | 'explicit_string_variable' => true, 33 | 'final_internal_class' => true, 34 | 'fully_qualified_strict_types' => true, 35 | 'general_phpdoc_annotation_remove' => false, 36 | 'header_comment' => false, 37 | 'heredoc_to_nowdoc' => false, 38 | 'linebreak_after_opening_tag' => true, 39 | 'list_syntax' => ['syntax' => 'short'], 40 | 'mb_str_functions' => true, 41 | 'method_chaining_indentation' => true, 42 | 'multiline_comment_opening_closing' => true, 43 | 'multiline_whitespace_before_semicolons' => true, 44 | 'native_function_invocation' => false, 45 | 'no_alternative_syntax' => true, 46 | 'no_blank_lines_before_namespace' => false, 47 | 'no_null_property_initialization' => true, 48 | 'no_php4_constructor' => true, 49 | 'echo_tag_syntax' => ['format'=> 'long'], 50 | 'no_superfluous_elseif' => true, 51 | 'no_unreachable_default_argument_value' => true, 52 | 'no_useless_else' => true, 53 | 'no_useless_return' => true, 54 | 'not_operator_with_space' => false, 55 | 'not_operator_with_successor_space' => false, 56 | 'ordered_class_elements' => false, 57 | 'ordered_imports' => true, 58 | 'php_unit_dedicate_assert' => false, 59 | 'php_unit_expectation' => false, 60 | 'php_unit_mock' => false, 61 | 'php_unit_namespaced' => false, 62 | 'php_unit_no_expectation_annotation' => false, 63 | 'phpdoc_order_by_value' => ['annotations' => ['covers']], 64 | 'php_unit_set_up_tear_down_visibility' => true, 65 | 'php_unit_strict' => false, 66 | 'php_unit_test_annotation' => false, 67 | 'php_unit_test_class_requires_covers' => false, 68 | 'php_unit_method_casing' => ['case' => 'snake_case'], 69 | 'phpdoc_add_missing_param_annotation' => true, 70 | 'phpdoc_order' => true, 71 | 'phpdoc_types_order' => ['null_adjustment' => 'always_last'], 72 | 'pow_to_exponentiation' => true, 73 | 'psr_autoloading' => ['dir' => 'src'], 74 | 'random_api_migration' => false, 75 | 'simplified_null_return' => true, 76 | 'static_lambda' => false, 77 | 'strict_comparison' => true, 78 | 'strict_param' => true, 79 | 'string_line_ending' => true, 80 | 'ternary_to_null_coalescing' => true, 81 | 'void_return' => true, 82 | 'yoda_style' => true, 83 | 'single_line_throw' => false, 84 | 'phpdoc_align' => ['align' => 'left'], 85 | 'phpdoc_to_comment' => false, 86 | 'global_namespace_import' => [ 87 | 'import_classes' => true, 88 | 'import_constants' => true, 89 | 'import_functions' => true, 90 | ], 91 | 'nullable_type_declaration_for_default_null_value' => true, 92 | ]) 93 | ; 94 | -------------------------------------------------------------------------------- /test/EventListener/JsonApiProblemExceptionListenerTest.php: -------------------------------------------------------------------------------- 1 | exceptionTransformer = $this->prophesize(ExceptionTransformerInterface::class); 40 | $this->exceptionTransformer->accepts(Argument::any())->willReturn(false); 41 | } 42 | 43 | #[Test] 44 | public function it_does_nothing_on_non_json_requests(): void 45 | { 46 | $listener = new JsonApiProblemExceptionListener($this->exceptionTransformer->reveal(), false); 47 | 48 | $request = new Request(); 49 | $request->headers->set('Accept', 'text/html'); 50 | $request->headers->set('Content-Type', 'text/html'); 51 | 52 | $event = $this->buildEvent($request); 53 | $listener->onKernelException($event); 54 | 55 | $this->assertNull($event->getResponse()); 56 | } 57 | 58 | #[Test] 59 | public function it_runs_on_json_route_formats(): void 60 | { 61 | $listener = new JsonApiProblemExceptionListener($this->exceptionTransformer->reveal(), false); 62 | 63 | $request = new Request(); 64 | $request->headers->set('Accept', 'application/json'); 65 | $request->headers->remove('Content-Type'); 66 | $exception = new Exception('error'); 67 | $event = $this->buildEvent($request, $exception); 68 | 69 | $listener->onKernelException($event); 70 | 71 | $this->assertApiProblemWithResponseBody($event, 500, $this->parseDataForException($exception)); 72 | } 73 | 74 | #[Test] 75 | public function it_runs_on_json_content_types(): void 76 | { 77 | $listener = new JsonApiProblemExceptionListener($this->exceptionTransformer->reveal(), false); 78 | 79 | $request = new Request(); 80 | $request->headers->set('Accept', 'text/html'); 81 | $request->headers->set('Content-Type', 'application/json'); 82 | $exception = new Exception('error'); 83 | $event = $this->buildEvent($request, $exception); 84 | 85 | $listener->onKernelException($event); 86 | $this->assertApiProblemWithResponseBody($event, 500, $this->parseDataForException($exception)); 87 | } 88 | 89 | #[Test] 90 | public function it_runs_on_json_accept_header(): void 91 | { 92 | $listener = new JsonApiProblemExceptionListener($this->exceptionTransformer->reveal(), false); 93 | 94 | $request = new Request(); 95 | $request->headers->set('Accept', 'application/json'); 96 | $request->headers->set('Content-Type', 'text/html'); 97 | $exception = new Exception('error'); 98 | $event = $this->buildEvent($request, $exception); 99 | 100 | $listener->onKernelException($event); 101 | $this->assertApiProblemWithResponseBody($event, 500, $this->parseDataForException($exception)); 102 | } 103 | 104 | #[Test] 105 | public function it_parses_an_api_problem_response(): void 106 | { 107 | $listener = new JsonApiProblemExceptionListener($this->exceptionTransformer->reveal(), false); 108 | 109 | $request = new Request(); 110 | $request->headers->set('Accept', 'application/json'); 111 | $request->headers->set('Content-Type', 'application/json'); 112 | $exception = new Exception('error'); 113 | $event = $this->buildEvent($request, $exception); 114 | 115 | $listener->onKernelException($event); 116 | $this->assertApiProblemWithResponseBody($event, 500, $this->parseDataForException($exception)); 117 | } 118 | 119 | #[Test] 120 | public function it_uses_an_exception_transformer(): void 121 | { 122 | $listener = new JsonApiProblemExceptionListener($this->exceptionTransformer->reveal(), false); 123 | 124 | $request = new Request(); 125 | $request->headers->set('Accept', 'application/json'); 126 | $request->headers->set('Content-Type', 'text/html'); 127 | $event = $this->buildEvent($request); 128 | 129 | $apiProblem = $this->prophesize(ApiProblemInterface::class); 130 | $apiProblem->toArray()->willReturn([]); 131 | 132 | $this->exceptionTransformer->accepts(Argument::type(Exception::class))->willReturn(true); 133 | $this->exceptionTransformer->transform(Argument::type(Exception::class))->willReturn($apiProblem->reveal()); 134 | 135 | $listener->onKernelException($event); 136 | $this->assertApiProblemWithResponseBody($event, 400, []); 137 | } 138 | 139 | #[Test] 140 | public function it_returns_the_status_code_from_the_api_problem(): void 141 | { 142 | $listener = new JsonApiProblemExceptionListener($this->exceptionTransformer->reveal(), false); 143 | 144 | $request = new Request(); 145 | $request->headers->set('Accept', 'application/json'); 146 | $request->headers->set('Content-Type', 'text/html'); 147 | $event = $this->buildEvent($request); 148 | 149 | $apiProblem = $this->prophesize(ApiProblemInterface::class); 150 | $apiProblem->toArray()->willReturn(['status' => 123]); 151 | 152 | $this->exceptionTransformer->accepts(Argument::type(Exception::class))->willReturn(true); 153 | $this->exceptionTransformer->transform(Argument::type(Exception::class))->willReturn($apiProblem->reveal()); 154 | 155 | $listener->onKernelException($event); 156 | $this->assertApiProblemWithResponseBody($event, 123, ['status' => 123]); 157 | } 158 | 159 | #[Test] 160 | public function it_parses_a_debuggable_api_problem_response(): void 161 | { 162 | $listener = new JsonApiProblemExceptionListener($this->exceptionTransformer->reveal(), true); 163 | $apiProblem = $this->prophesize(DebuggableApiProblemInterface::class); 164 | 165 | $data = ['status' => 500, 'detail' => 'detail', 'debug' => true]; 166 | $apiProblem->toDebuggableArray()->willReturn($data); 167 | $apiProblem->toArray()->willReturn($data); 168 | 169 | $exception = new Exception('error'); 170 | $this->exceptionTransformer->accepts($exception)->willReturn(true); 171 | $this->exceptionTransformer->transform($exception)->willReturn($apiProblem->reveal()); 172 | 173 | $request = new Request(); 174 | $request->headers->set('Accept', 'application/json'); 175 | $request->headers->set('Content-Type', 'text/html'); 176 | $event = $this->buildEvent($request, $exception); 177 | 178 | $listener->onKernelException($event); 179 | $this->assertApiProblemWithResponseBody($event, 500, $data); 180 | } 181 | 182 | private function assertApiProblemWithResponseBody(ExceptionEvent $event, int $expectedResponseCode, array $expectedData): void 183 | { 184 | $response = $event->getResponse(); 185 | $this->assertInstanceOf(JsonResponse::class, $response); 186 | $this->assertSame($expectedResponseCode, $response->getStatusCode()); 187 | $this->assertSame('application/problem+json', $response->headers->get('Content-Type')); 188 | $this->assertJsonStringEqualsJsonString( 189 | json_encode($expectedData), 190 | $event->getResponse()->getContent() 191 | ); 192 | } 193 | 194 | private function parseDataForException(Exception $exception): array 195 | { 196 | return [ 197 | 'status' => 500, 198 | 'type' => HttpApiProblem::TYPE_HTTP_RFC, 199 | 'title' => HttpApiProblem::getTitleForStatusCode(500), 200 | 'detail' => $exception->getMessage(), 201 | ]; 202 | } 203 | 204 | private function buildEvent(Request $request, ?Exception $exception = null): ExceptionEvent 205 | { 206 | $exception ??= new Exception('error'); 207 | 208 | $httpKernel = $this->prophesize(HttpKernelInterface::class); 209 | 210 | return new ExceptionEvent( 211 | $httpKernel->reveal(), 212 | $request, 213 | HttpKernelInterface::MAIN_REQUEST, 214 | $exception 215 | ); 216 | } 217 | } 218 | --------------------------------------------------------------------------------