├── .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 | [](https://packagist.org/packages/phpro/api-problem-bundle/stats)
2 | [](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 |
--------------------------------------------------------------------------------