feat(modules) : expose GET /api/modules and module registry

This commit is contained in:
Matthieu
2026-06-19 14:33:53 +02:00
parent 2d0e9de155
commit 748289b61a
8 changed files with 224 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
/*
* Liste ordonnée des modules actifs (classes implémentant App\Shared\Domain\Module\ModuleInterface).
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
*/
return [
// Aucun module pour l'instant — les modules arrivent à partir du ticket 1.1 (Core).
];
+2
View File
@@ -62,6 +62,8 @@ security:
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
# Liste des modules actifs en public (consommée au boot du front)
- { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
# Mail : requiert authentification (le check ROLE_USER est dans MailAccessChecker)
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Module;
/**
* Implemented by every `*Module` declaration class. The set of active modules
* is listed in config/modules.php and exposed via GET /api/modules.
*/
interface ModuleInterface
{
public static function id(): string;
public static function label(): string;
public static function isRequired(): bool;
/**
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Module;
final class ModuleRegistry
{
/**
* @param list<class-string> $moduleClasses
*
* @return list<string>
*/
public static function ids(array $moduleClasses): array
{
$ids = [];
foreach ($moduleClasses as $moduleClass) {
if (is_a($moduleClass, ModuleInterface::class, true)) {
$ids[] = $moduleClass::id();
}
}
return $ids;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Shared\Infrastructure\ApiPlatform\State\ModulesProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/modules',
normalizationContext: ['groups' => ['modules:read']],
provider: ModulesProvider::class,
),
],
)]
final class ModulesResource
{
/**
* @var list<string>
*/
#[Groups(['modules:read'])]
public array $modules = [];
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Infrastructure\ApiPlatform\Resource\ModulesResource;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ModulesProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private string $projectDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ModulesResource
{
/** @var list<class-string> $classes */
$classes = require $this->projectDir.'/config/modules.php';
$dto = new ModulesResource();
$dto->modules = ModuleRegistry::ids($classes);
return $dto;
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Shared;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class ModulesEndpointTest extends WebTestCase
{
public function testModulesEndpointIsPublicAndReturnsModulesKey(): void
{
$client = self::createClient();
$client->request('GET', '/api/modules');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('modules', $data);
self::assertIsArray($data['modules']);
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Module;
use App\Shared\Domain\Module\ModuleInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use PHPUnit\Framework\TestCase;
use stdClass;
/**
* @internal
*/
final class ModuleRegistryTest extends TestCase
{
public function testIdsExtractsDeclaredModuleIds(): void
{
$classes = [FakeAlphaModule::class, FakeBetaModule::class];
self::assertSame(['alpha', 'beta'], ModuleRegistry::ids($classes));
}
public function testIdsIgnoresClassesNotImplementingModuleInterface(): void
{
$classes = [FakeAlphaModule::class, stdClass::class];
self::assertSame(['alpha'], ModuleRegistry::ids($classes));
}
public function testIdsReturnsEmptyArrayForNoModules(): void
{
self::assertSame([], ModuleRegistry::ids([]));
}
}
final class FakeAlphaModule implements ModuleInterface
{
public static function id(): string
{
return 'alpha';
}
public static function label(): string
{
return 'Alpha';
}
public static function isRequired(): bool
{
return false;
}
public static function permissions(): array
{
return [];
}
}
final class FakeBetaModule implements ModuleInterface
{
public static function id(): string
{
return 'beta';
}
public static function label(): string
{
return 'Beta';
}
public static function isRequired(): bool
{
return true;
}
public static function permissions(): array
{
return [];
}
}