Skip to content

Commit fe5e635

Browse files
authored
IBX-10366: Provided Edition Badges to REST API reference (#197)
For more details see https://issues.ibexa.co/browse/IBX-10366 and #197 Key changes: * Implemented and configured EditionBadgeFactory service * Injected EditionBadgeRegistry into OpenApiFactory and refactored the factory * Created and handled semantic configuration for 1st party package badges * Ensured container warmup doesn't crash when API platform config is not loaded * [Tests] Added coverage for EditionBadgeFactory * [Tests] Added coverage for DI EditionBadgesProcessor * [Tests] Added Symfony DI functional coverage for badges configuration * [PHPStan] Added unsolvable Symfony semantic configuration builder issue to the PHPStan config * [Tests] Aligned coverage with EditionBadgesProcessor change
1 parent 13f6a50 commit fe5e635

File tree

13 files changed

+546
-16
lines changed

13 files changed

+546
-16
lines changed

phpstan.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ parameters:
1313
ignoreErrors:
1414
-
1515
identifier: missingType.generics
16+
-
17+
message: '#^Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface\:\:arrayNode\(\)\.$#'
18+
identifier: method.notFound
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\Bundle\Rest\ApiPlatform\EditionBadge;
10+
11+
use ApiPlatform\OpenApi\Model\Operation;
12+
use LogicException;
13+
14+
/**
15+
* @internal
16+
*
17+
* @phpstan-type TBadgeData array{name: string, color: string, position?: 'before'|'after'}
18+
* @phpstan-type TBadgeConfig array{name: string, color: string, position?: 'before'|'after'}
19+
* @phpstan-type TBadgesConfig array<string, TBadgeConfig>
20+
* @phpstan-type TTagToEditionMap array<string, string[]>
21+
*/
22+
final readonly class EditionBadgeFactory implements EditionBadgeFactoryInterface
23+
{
24+
/**
25+
* @phpstan-param TBadgesConfig $badgesConfig
26+
* @phpstan-param TTagToEditionMap $tagToEditionMap
27+
*/
28+
public function __construct(private array $badgesConfig, private array $tagToEditionMap)
29+
{
30+
}
31+
32+
public function getBadgesForOperation(Operation $operation): array
33+
{
34+
$badges = [];
35+
foreach ($operation->getTags() ?? [] as $tag) {
36+
$tagEditions = $this->tagToEditionMap[$tag] ?? [];
37+
foreach ($tagEditions as $tagEdition) {
38+
if (!isset($this->badgesConfig[$tagEdition])) {
39+
// gets also validated when processing configuration, so theoretically should never happen here
40+
throw new LogicException("No badge configuration for $tagEdition for $tag tag");
41+
}
42+
43+
$badges[] = $this->buildBadgeDataFromConfig($this->badgesConfig[$tagEdition]);
44+
}
45+
}
46+
47+
return $badges;
48+
}
49+
50+
/**
51+
* @phpstan-param TBadgeConfig $badgeConfig
52+
*
53+
* @phpstan-return TBadgeData
54+
*/
55+
private function buildBadgeDataFromConfig(array $badgeConfig): array
56+
{
57+
$badge = [
58+
'name' => $badgeConfig['name'],
59+
'color' => $badgeConfig['color'],
60+
];
61+
if (isset($badgeConfig['position'])) {
62+
$badge['position'] = $badgeConfig['position'];
63+
}
64+
65+
return $badge;
66+
}
67+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\Bundle\Rest\ApiPlatform\EditionBadge;
10+
11+
use ApiPlatform\OpenApi\Model\Operation;
12+
13+
/**
14+
* @internal
15+
*
16+
* @phpstan-type TBadgeData array{name: string, color: string, position?: 'before'|'after'}
17+
* @phpstan-type TBadgeList list<TBadgeData>
18+
*/
19+
interface EditionBadgeFactoryInterface
20+
{
21+
/**
22+
* @phpstan-return TBadgeList
23+
*/
24+
public function getBadgesForOperation(Operation $operation): array;
25+
}

src/bundle/ApiPlatform/OpenApiFactory.php

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use ApiPlatform\OpenApi\Model\Server;
1616
use ApiPlatform\OpenApi\OpenApi;
1717
use ArrayObject;
18+
use Ibexa\Bundle\Rest\ApiPlatform\EditionBadge\EditionBadgeFactoryInterface;
1819
use Ibexa\Contracts\Core\Ibexa;
1920
use Symfony\Component\HttpKernel\KernelInterface;
2021

@@ -24,24 +25,25 @@ public function __construct(
2425
private OpenApiFactoryInterface $decorated,
2526
private SchemasCollectionFactory $schemaCollectionFactory,
2627
private KernelInterface $kernel,
28+
private EditionBadgeFactoryInterface $editionBadgeFactory,
2729
private string $prefix,
2830
) {
2931
}
3032

3133
/**
3234
* @param array<string, mixed> $context
35+
*
36+
* @throws \JsonException
3337
*/
3438
public function __invoke(array $context = []): OpenApi
3539
{
3640
$openApi = ($this->decorated)($context);
3741
$openApi = $openApi->withInfo((new Info('Ibexa DXP REST API', Ibexa::VERSION)));
3842
$openApi = $this->addSchemas($openApi);
3943

40-
$this->insertExampleFilesContent($openApi);
44+
$this->processPaths($openApi);
4145

42-
$openApi = $openApi->withServers([new Server($this->prefix, 'Current server')]);
43-
44-
return $openApi;
46+
return $openApi->withServers([new Server($this->prefix, 'Current server')]);
4547
}
4648

4749
private function addSchemas(OpenApi $openApi): OpenApi
@@ -55,7 +57,10 @@ private function addSchemas(OpenApi $openApi): OpenApi
5557
return $openApi->withComponents($components);
5658
}
5759

58-
private function insertExampleFilesContent(OpenApi $openApi): void
60+
/**
61+
* @throws \JsonException
62+
*/
63+
private function processPaths(OpenApi $openApi): void
5964
{
6065
$paths = $openApi->getPaths();
6166

@@ -78,7 +83,7 @@ private function insertExampleFilesContent(OpenApi $openApi): void
7883
continue;
7984
}
8085

81-
$newOperation = $this->processOperationResponses($operation);
86+
$newOperation = $this->processOperations($operation);
8287
if ($newOperation !== $operation) {
8388
$newPathItem = $newPathItem->$setter($newOperation);
8489
}
@@ -90,7 +95,35 @@ private function insertExampleFilesContent(OpenApi $openApi): void
9095
}
9196
}
9297

93-
private function processOperationResponses(Operation $operation): Operation
98+
/**
99+
* @throws \JsonException
100+
*/
101+
private function processOperations(Operation $operation): Operation
102+
{
103+
$newOperation = $operation;
104+
$newOperation = $this->insertIbexaResponseExample($newOperation);
105+
106+
return $this->insertIbexaEditionBadges($newOperation);
107+
}
108+
109+
private function insertIbexaEditionBadges(Operation $operation): Operation
110+
{
111+
if (isset($operation->getExtensionProperties()['x-badges'])) {
112+
return $operation;
113+
}
114+
115+
$badges = $this->editionBadgeFactory->getBadgesForOperation($operation);
116+
if (!empty($badges)) {
117+
$operation = $operation->withExtensionProperty('x-badges', $badges);
118+
}
119+
120+
return $operation;
121+
}
122+
123+
/**
124+
* @throws \JsonException
125+
*/
126+
private function insertIbexaResponseExample(Operation $operation): Operation
94127
{
95128
$newOperation = $operation;
96129

src/bundle/DependencyInjection/Configuration.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ public function getConfigTreeBuilder(): TreeBuilder
2424
->defaultValue('%kernel.debug%')
2525
->info('Throw exceptions for missing normalizers.')
2626
->end()
27+
->arrayNode('badges')
28+
->info('Mapping of REST endpoint tag to Ibexa edition, used to render badges in the REST API documentation.')
29+
->arrayPrototype()
30+
->children()
31+
->stringNode('tag')->isRequired()->end()
32+
->arrayNode('editions')->isRequired()->stringPrototype()->end()
33+
->end()
34+
->end()
35+
->end()
2736
->end();
2837

2938
$this->addRestRootResourcesSection($rootNode);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\Bundle\Rest\DependencyInjection;
10+
11+
use Symfony\Component\DependencyInjection\ContainerBuilder;
12+
use Symfony\Component\DependencyInjection\Exception\LogicException;
13+
14+
/**
15+
* @internal
16+
*
17+
* @phpstan-import-type TBadgesConfig from \Ibexa\Bundle\Rest\ApiPlatform\EditionBadge\EditionBadgeFactory
18+
*/
19+
final readonly class EditionBadgesProcessor implements EditionBadgesProcessorInterface
20+
{
21+
public const string TAG_EDITION_MAP_PARAMETER_NAME = 'ibexa.rest.edition_badges.badges.tag_editions.map';
22+
public const string BADGES_CONFIG_PARAMETER_NAME = 'ibexa.rest.edition_badges.badges.config';
23+
24+
public function __construct(private ContainerBuilder $container)
25+
{
26+
}
27+
28+
/**
29+
* Transforms a list of `array{tag, editions[]}` into a map of `tag => editions[]`.
30+
*/
31+
public function process(array $tagToEditionMappingConfig): void
32+
{
33+
if (!$this->container->hasParameter(self::BADGES_CONFIG_PARAMETER_NAME)) {
34+
// API platform config wasn't loaded because of the missing api_platform extension
35+
return;
36+
}
37+
38+
/** @phpstan-var TBadgesConfig $config */
39+
$config = $this->container->getParameter(self::BADGES_CONFIG_PARAMETER_NAME);
40+
$editions = array_keys($config);
41+
$tagToEditionMap = [];
42+
foreach ($tagToEditionMappingConfig as $tagToEditionMapping) {
43+
$unknownEditions = array_diff($tagToEditionMapping['editions'], $editions);
44+
if (!empty($unknownEditions)) {
45+
throw new LogicException(
46+
sprintf(
47+
'Unknown editions: %s. Expecting one of: %s',
48+
implode(', ', $unknownEditions),
49+
implode(', ', $editions)
50+
)
51+
);
52+
}
53+
54+
$tagToEditionMap[$tagToEditionMapping['tag']] = array_unique(
55+
array_merge(
56+
$tagToEditionMap[$tagToEditionMapping['tag']] ?? [],
57+
$tagToEditionMapping['editions']
58+
)
59+
);
60+
}
61+
62+
$this->container->setParameter(self::TAG_EDITION_MAP_PARAMETER_NAME, $tagToEditionMap);
63+
}
64+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\Bundle\Rest\DependencyInjection;
10+
11+
/**
12+
* @internal
13+
*
14+
* @phpstan-type TTagToEditionMappingConfig list<array{tag: string, editions: string[]}>
15+
*/
16+
interface EditionBadgesProcessorInterface
17+
{
18+
/**
19+
* @phpstan-param TTagToEditionMappingConfig $tagToEditionMappingConfig
20+
*/
21+
public function process(array $tagToEditionMappingConfig): void;
22+
}

src/bundle/DependencyInjection/IbexaRestExtension.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,6 @@
2020
use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension;
2121
use Symfony\Component\Yaml\Yaml;
2222

23-
/**
24-
* This is the class that loads and manages your bundle configuration.
25-
*
26-
* To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html}
27-
*/
2823
class IbexaRestExtension extends ConfigurableExtension implements PrependExtensionInterface
2924
{
3025
public const string EXTENSION_NAME = 'ibexa_rest';
@@ -54,6 +49,10 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container
5449

5550
$processor = new ConfigurationProcessor($container, 'ibexa.site_access.config');
5651
$processor->mapConfigArray('rest_root_resources', $mergedConfig);
52+
53+
if (!empty($mergedConfig['badges'])) {
54+
(new EditionBadgesProcessor($container))->process($mergedConfig['badges']);
55+
}
5756
}
5857

5958
public function prepend(ContainerBuilder $container): void

src/bundle/Resources/config/api_platform.yml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
imports:
2+
- { resource: api_platform/badges.yaml }
3+
14
services:
25
ibexa.api_platform.action.entrypoint:
36
parent: api_platform.action.entrypoint
@@ -19,10 +22,11 @@ services:
1922
class: Ibexa\Bundle\Rest\ApiPlatform\OpenApiFactory
2023
decorates: ibexa.api_platform.openapi.factory
2124
arguments:
22-
- '@.inner'
23-
- '@Ibexa\Bundle\Rest\ApiPlatform\SchemasCollectionFactory'
24-
- '@Symfony\Component\HttpKernel\KernelInterface'
25-
- '%ibexa.rest.path_prefix%'
25+
$decorated: '@.inner'
26+
$schemaCollectionFactory: '@Ibexa\Bundle\Rest\ApiPlatform\SchemasCollectionFactory'
27+
$kernel: '@Symfony\Component\HttpKernel\KernelInterface'
28+
$prefix: '%ibexa.rest.path_prefix%'
29+
$editionBadgeFactory: '@Ibexa\Bundle\Rest\ApiPlatform\EditionBadge\EditionBadgeFactoryInterface'
2630

2731
ibexa.api_platform.openapi.factory:
2832
parent: api_platform.openapi.factory
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
parameters:
2+
ibexa.rest.edition_badges.badges.config:
3+
experience:
4+
name: 'Experience'
5+
color: '#D3822B'
6+
commerce:
7+
name: 'Commerce'
8+
color: '#A32768'
9+
position: after
10+
lts-update:
11+
name: 'LTS Update'
12+
color: '#5DA7C0'
13+
position: after
14+
15+
ibexa.rest.edition_badges.badges.tag_editions.map: []
16+
17+
services:
18+
_defaults:
19+
autowire: true
20+
autoconfigure: true
21+
public: false
22+
23+
Ibexa\Bundle\Rest\ApiPlatform\EditionBadge\EditionBadgeFactoryInterface:
24+
alias: Ibexa\Bundle\Rest\ApiPlatform\EditionBadge\EditionBadgeFactory
25+
26+
Ibexa\Bundle\Rest\ApiPlatform\EditionBadge\EditionBadgeFactory:
27+
arguments:
28+
$badgesConfig: '%ibexa.rest.edition_badges.badges.config%'
29+
$tagToEditionMap: '%ibexa.rest.edition_badges.badges.tag_editions.map%'

0 commit comments

Comments
 (0)