Files
Inventory/src/OpenApi/OpenApiDecorator.php
r-dev 74f77a3ba8 refactor(backend) : extract CuidEntityTrait, abstract audit subscriber, merge history controllers
- Extract shared ID generation + timestamps into CuidEntityTrait used by all entities
- Create AbstractAuditSubscriber to deduplicate audit logic across 7 subscribers
- Merge per-entity history controllers into single EntityHistoryController
- Delete redundant ComposantHistory/MachineHistory/PieceHistory/ProductHistoryController
- Add OpenApiDecorator for API documentation customization
- Disable failOnDeprecation in PHPUnit (vendor API Platform deprecation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:39:03 +01:00

521 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace App\OpenApi;
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\OpenApi\Model;
use ApiPlatform\OpenApi\OpenApi;
use ArrayObject;
final class OpenApiDecorator implements OpenApiFactoryInterface
{
public function __construct(
private readonly OpenApiFactoryInterface $decorated,
) {}
public function __invoke(array $context = []): OpenApi
{
$openApi = ($this->decorated)($context);
$this->addHealthCheck($openApi);
$this->addSessionRoutes($openApi);
$this->addAdminProfileRoutes($openApi);
$this->addActivityLogs($openApi);
$this->addCommentRoutes($openApi);
$this->addCustomFieldValueRoutes($openApi);
$this->addDocumentQueryRoutes($openApi);
$this->addDocumentServeRoutes($openApi);
$this->addEntityHistoryRoutes($openApi);
$this->addMachineStructureRoutes($openApi);
$this->addMachineCustomFieldsRoutes($openApi);
$this->addModelTypeConversionRoutes($openApi);
return $this->addTagDescriptions($openApi);
}
private function addHealthCheck(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/health', new Model\PathItem(
get: new Model\Operation(
operationId: 'getHealthCheck',
tags: ['Monitoring'],
summary: 'Vérification de santé du système',
description: 'Retourne le statut du système, la version, la latence BDD et la mémoire utilisée.',
responses: [
'200' => $this->jsonResponse('Système opérationnel.'),
'503' => $this->jsonResponse('Système dégradé.'),
],
),
));
}
private function addSessionRoutes(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/session/profiles', new Model\PathItem(
get: new Model\Operation(
operationId: 'getSessionProfiles',
tags: ['Session'],
summary: 'Lister les profils disponibles',
description: 'Retourne les profils actifs (id, prénom, nom, hasPassword). Aucune authentification requise.',
responses: ['200' => $this->jsonResponse('Liste des profils.')],
),
));
$openApi->getPaths()->addPath('/api/session/profile', new Model\PathItem(
get: new Model\Operation(
operationId: 'getSessionProfile',
tags: ['Session'],
summary: 'Profil actif de la session',
description: 'Retourne le profil actuellement connecté via la session.',
responses: [
'200' => $this->jsonResponse('Profil actif.'),
'401' => $this->jsonResponse('Aucun profil actif.'),
],
),
post: new Model\Operation(
operationId: 'loginSessionProfile',
tags: ['Session'],
summary: 'Connexion — activer un profil',
description: 'Active un profil dans la session. Requiert profileId et password.',
requestBody: $this->jsonRequestBody('Identifiants de connexion.'),
responses: [
'200' => $this->jsonResponse('Connexion réussie.'),
'401' => $this->jsonResponse('Mot de passe incorrect.'),
'404' => $this->jsonResponse('Profil introuvable.'),
],
),
delete: new Model\Operation(
operationId: 'logoutSessionProfile',
tags: ['Session'],
summary: 'Déconnexion — invalider la session',
responses: ['200' => $this->jsonResponse('Session invalidée.')],
),
));
}
private function addAdminProfileRoutes(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/admin/profiles', new Model\PathItem(
get: new Model\Operation(
operationId: 'adminListProfiles',
tags: ['Admin — Profils'],
summary: 'Lister tous les profils',
description: 'Liste complète des profils triés par prénom. Requiert ROLE_ADMIN.',
responses: ['200' => $this->jsonResponse('Liste des profils.')],
),
post: new Model\Operation(
operationId: 'adminCreateProfile',
tags: ['Admin — Profils'],
summary: 'Créer un profil',
description: 'Crée un nouveau profil avec rôle. Requiert ROLE_ADMIN.',
requestBody: $this->jsonRequestBody('Données du profil (firstName, lastName, email, password, role).'),
responses: [
'201' => $this->jsonResponse('Profil créé.'),
'400' => $this->jsonResponse('Données invalides.'),
'409' => $this->jsonResponse('Email déjà utilisé.'),
],
),
));
$idParam = $this->pathParam('id', 'Identifiant du profil');
$openApi->getPaths()->addPath('/api/admin/profiles/{id}/role', new Model\PathItem(
put: new Model\Operation(
operationId: 'adminUpdateProfileRole',
tags: ['Admin — Profils'],
summary: 'Modifier le rôle d\'un profil',
description: 'Change le rôle d\'un profil. Empêche la suppression du dernier admin. Requiert ROLE_ADMIN.',
parameters: [$idParam],
requestBody: $this->jsonRequestBody('Nouveau rôle.'),
responses: [
'200' => $this->jsonResponse('Rôle mis à jour.'),
'400' => $this->jsonResponse('Rôle invalide ou dernier admin.'),
],
),
));
$openApi->getPaths()->addPath('/api/admin/profiles/{id}/password', new Model\PathItem(
put: new Model\Operation(
operationId: 'adminUpdateProfilePassword',
tags: ['Admin — Profils'],
summary: 'Modifier le mot de passe d\'un profil',
description: 'Requiert ROLE_ADMIN.',
parameters: [$idParam],
requestBody: $this->jsonRequestBody('Nouveau mot de passe.'),
responses: ['200' => $this->jsonResponse('Mot de passe mis à jour.')],
),
));
$openApi->getPaths()->addPath('/api/admin/profiles/{id}/deactivate', new Model\PathItem(
put: new Model\Operation(
operationId: 'adminDeactivateProfile',
tags: ['Admin — Profils'],
summary: 'Désactiver un profil',
description: 'Désactive un profil. Empêche la désactivation du dernier admin. Requiert ROLE_ADMIN.',
parameters: [$idParam],
responses: [
'200' => $this->jsonResponse('Profil désactivé.'),
'400' => $this->jsonResponse('Dernier admin, impossible de désactiver.'),
],
),
));
}
private function addActivityLogs(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/activity-logs', new Model\PathItem(
get: new Model\Operation(
operationId: 'getActivityLogs',
tags: ['Audit'],
summary: 'Journal d\'activité paginé',
description: 'Retourne les logs d\'audit avec filtrage optionnel par type d\'entité et action. Requiert ROLE_VIEWER.',
parameters: [
$this->queryParam('page', 'Numéro de page'),
$this->queryParam('itemsPerPage', 'Éléments par page'),
$this->queryParam('entityType', 'Filtrer par type d\'entité'),
$this->queryParam('action', 'Filtrer par action'),
],
responses: ['200' => $this->jsonResponse('Logs paginés.')],
),
));
}
private function addCommentRoutes(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/comments', new Model\PathItem(
post: new Model\Operation(
operationId: 'createComment',
tags: ['Commentaires'],
summary: 'Créer un commentaire',
description: 'Ajoute un commentaire à une entité (machine, pièce, composant, produit, catégorie, squelette). Requiert ROLE_VIEWER.',
requestBody: $this->jsonRequestBody('Données du commentaire (content, entityType, entityId, entityName).'),
responses: [
'201' => $this->jsonResponse('Commentaire créé.'),
'400' => $this->jsonResponse('Données invalides.'),
],
),
));
$openApi->getPaths()->addPath('/api/comments/{id}/resolve', new Model\PathItem(
patch: new Model\Operation(
operationId: 'resolveComment',
tags: ['Commentaires'],
summary: 'Résoudre un commentaire',
description: 'Marque un commentaire comme résolu. Requiert ROLE_GESTIONNAIRE.',
parameters: [$this->pathParam('id', 'Identifiant du commentaire')],
responses: [
'200' => $this->jsonResponse('Commentaire résolu.'),
'404' => $this->jsonResponse('Commentaire introuvable.'),
],
),
));
$openApi->getPaths()->addPath('/api/comments/stats/unresolved-count', new Model\PathItem(
get: new Model\Operation(
operationId: 'getUnresolvedCommentCount',
tags: ['Commentaires'],
summary: 'Nombre de commentaires non résolus',
description: 'Requiert ROLE_VIEWER.',
responses: ['200' => $this->jsonResponse('Compteur.')],
),
));
}
private function addCustomFieldValueRoutes(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/custom-fields/values', new Model\PathItem(
post: new Model\Operation(
operationId: 'createCustomFieldValue',
tags: ['Champs personnalisés'],
summary: 'Créer une valeur de champ personnalisé',
description: 'Crée une valeur pour un champ personnalisé sur une entité. Auto-crée le champ si nécessaire. Requiert ROLE_GESTIONNAIRE.',
requestBody: $this->jsonRequestBody('Données (customFieldId/customFieldName, value, entité cible).'),
responses: [
'201' => $this->jsonResponse('Valeur créée.'),
'400' => $this->jsonResponse('Données invalides.'),
],
),
));
$openApi->getPaths()->addPath('/api/custom-fields/values/upsert', new Model\PathItem(
post: new Model\Operation(
operationId: 'upsertCustomFieldValue',
tags: ['Champs personnalisés'],
summary: 'Créer ou mettre à jour une valeur de champ personnalisé',
description: 'Requiert ROLE_GESTIONNAIRE.',
requestBody: $this->jsonRequestBody('Données du champ.'),
responses: ['200' => $this->jsonResponse('Valeur créée ou mise à jour.')],
),
));
$openApi->getPaths()->addPath('/api/custom-fields/values/{entityType}/{entityId}', new Model\PathItem(
get: new Model\Operation(
operationId: 'listCustomFieldValues',
tags: ['Champs personnalisés'],
summary: 'Lister les valeurs de champs personnalisés d\'une entité',
description: 'Requiert ROLE_VIEWER.',
parameters: [
$this->pathParam('entityType', 'Type d\'entité (machine, composant, piece, product)'),
$this->pathParam('entityId', 'Identifiant de l\'entité'),
],
responses: ['200' => $this->jsonResponse('Liste des valeurs.')],
),
));
$idParam = $this->pathParam('id', 'Identifiant de la valeur');
$openApi->getPaths()->addPath('/api/custom-fields/values/{id}', new Model\PathItem(
patch: new Model\Operation(
operationId: 'updateCustomFieldValue',
tags: ['Champs personnalisés'],
summary: 'Modifier une valeur de champ personnalisé',
description: 'Requiert ROLE_GESTIONNAIRE.',
parameters: [$idParam],
requestBody: $this->jsonRequestBody('Nouvelle valeur.'),
responses: ['200' => $this->jsonResponse('Valeur mise à jour.')],
),
delete: new Model\Operation(
operationId: 'deleteCustomFieldValue',
tags: ['Champs personnalisés'],
summary: 'Supprimer une valeur de champ personnalisé',
description: 'Requiert ROLE_GESTIONNAIRE.',
parameters: [$idParam],
responses: ['204' => new Model\Response(description: 'Valeur supprimée.')],
),
));
}
private function addDocumentQueryRoutes(OpenApi $openApi): void
{
$entities = [
'site' => 'un site',
'machine' => 'une machine',
'composant' => 'un composant',
'piece' => 'une pièce',
'product' => 'un produit',
];
foreach ($entities as $entity => $label) {
$openApi->getPaths()->addPath("/api/documents/{$entity}/{id}", new Model\PathItem(
get: new Model\Operation(
operationId: 'getDocumentsBy'.ucfirst($entity),
tags: ['Documents'],
summary: "Documents rattachés à {$label}",
description: 'Requiert ROLE_VIEWER.',
parameters: [$this->pathParam('id', "Identifiant de l'entité")],
responses: ['200' => $this->jsonResponse('Liste des documents.')],
),
));
}
}
private function addDocumentServeRoutes(OpenApi $openApi): void
{
$idParam = $this->pathParam('id', 'Identifiant du document');
$openApi->getPaths()->addPath('/api/documents/{id}/file', new Model\PathItem(
get: new Model\Operation(
operationId: 'serveDocumentFile',
tags: ['Documents'],
summary: 'Afficher un fichier document (inline)',
description: 'Sert le fichier pour affichage dans le navigateur. Requiert ROLE_VIEWER.',
parameters: [$idParam],
responses: ['200' => new Model\Response(description: 'Contenu du fichier.')],
),
));
$openApi->getPaths()->addPath('/api/documents/{id}/download', new Model\PathItem(
get: new Model\Operation(
operationId: 'downloadDocumentFile',
tags: ['Documents'],
summary: 'Télécharger un fichier document',
description: 'Sert le fichier en téléchargement (attachment). Requiert ROLE_VIEWER.',
parameters: [$idParam],
responses: ['200' => new Model\Response(description: 'Fichier en téléchargement.')],
),
));
}
private function addEntityHistoryRoutes(OpenApi $openApi): void
{
$entities = [
'machines' => 'une machine',
'pieces' => 'une pièce',
'composants' => 'un composant',
'products' => 'un produit',
];
foreach ($entities as $plural => $label) {
$openApi->getPaths()->addPath("/api/{$plural}/{id}/history", new Model\PathItem(
get: new Model\Operation(
operationId: 'get'.ucfirst(rtrim($plural, 's')).'History',
tags: ['Audit'],
summary: "Historique d'audit de {$label}",
description: "Retourne les 200 derniers événements d'audit. Requiert ROLE_VIEWER.",
parameters: [$this->pathParam('id', "Identifiant de l'entité")],
responses: ['200' => $this->jsonResponse('Historique paginé.')],
),
));
}
}
private function addMachineStructureRoutes(OpenApi $openApi): void
{
$idParam = $this->pathParam('id', 'Identifiant de la machine');
$openApi->getPaths()->addPath('/api/machines/{id}/structure', new Model\PathItem(
get: new Model\Operation(
operationId: 'getMachineStructure',
tags: ['Machines — Structure'],
summary: 'Structure complète d\'une machine',
description: 'Retourne les composants, pièces et produits avec hiérarchie. Requiert ROLE_VIEWER.',
parameters: [$idParam],
responses: ['200' => $this->jsonResponse('Structure de la machine.')],
),
patch: new Model\Operation(
operationId: 'updateMachineStructure',
tags: ['Machines — Structure'],
summary: 'Modifier la structure d\'une machine',
description: 'Crée, met à jour ou supprime les liens composants/pièces/produits. Requiert ROLE_GESTIONNAIRE.',
parameters: [$idParam],
requestBody: $this->jsonRequestBody('Liens à créer/modifier/supprimer.'),
responses: ['200' => $this->jsonResponse('Structure mise à jour.')],
),
));
$openApi->getPaths()->addPath('/api/machines/{id}/clone', new Model\PathItem(
post: new Model\Operation(
operationId: 'cloneMachine',
tags: ['Machines — Structure'],
summary: 'Cloner une machine',
description: 'Clone une machine avec tous ses composants, pièces, produits, champs personnalisés et constructeurs. Requiert ROLE_GESTIONNAIRE.',
parameters: [$idParam],
requestBody: $this->jsonRequestBody('Données de la copie (name, siteId, reference).'),
responses: [
'201' => $this->jsonResponse('Machine clonée.'),
'404' => $this->jsonResponse('Machine source introuvable.'),
],
),
));
}
private function addMachineCustomFieldsRoutes(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/machines/{id}/add-custom-fields', new Model\PathItem(
post: new Model\Operation(
operationId: 'addMachineCustomFields',
tags: ['Machines — Structure'],
summary: 'Initialiser les champs personnalisés manquants',
description: 'Crée les entrées de valeur manquantes pour les champs personnalisés définis. Requiert ROLE_GESTIONNAIRE.',
parameters: [$this->pathParam('id', 'Identifiant de la machine')],
responses: ['200' => $this->jsonResponse('Champs ajoutés.')],
),
));
}
private function addModelTypeConversionRoutes(OpenApi $openApi): void
{
$idParam = $this->pathParam('id', 'Identifiant du ModelType');
$openApi->getPaths()->addPath('/api/model_types/{id}/conversion-check', new Model\PathItem(
get: new Model\Operation(
operationId: 'checkModelTypeConversion',
tags: ['ModelType'],
summary: 'Vérifier la convertibilité d\'un ModelType',
description: 'Vérifie si la catégorie du ModelType peut être convertie. Requiert ROLE_VIEWER.',
parameters: [$idParam],
responses: ['200' => $this->jsonResponse('Résultat de la vérification.')],
),
));
$openApi->getPaths()->addPath('/api/model_types/{id}/convert', new Model\PathItem(
post: new Model\Operation(
operationId: 'convertModelType',
tags: ['ModelType'],
summary: 'Convertir la catégorie d\'un ModelType',
description: 'Convertit la catégorie. Retourne 409 en cas de conflit. Requiert ROLE_GESTIONNAIRE.',
parameters: [$idParam],
responses: [
'200' => $this->jsonResponse('Conversion effectuée.'),
'409' => $this->jsonResponse('Conflit — conversion impossible.'),
],
),
));
}
private function addTagDescriptions(OpenApi $openApi): OpenApi
{
$customTags = [
'Monitoring' => 'Supervision et vérification de l\'état de santé du système (statut, version, latence BDD, mémoire).',
'Session' => 'Authentification par session. Permet de lister les profils disponibles, se connecter (activer un profil) et se déconnecter.',
'Admin — Profils' => 'Administration des profils utilisateurs. Création, modification des rôles et mots de passe, désactivation. Réservé aux administrateurs.',
'Audit' => 'Journal d\'activité et historique d\'audit. Consultation des modifications apportées aux entités avec détail des changements (diff et snapshot).',
'Commentaires' => 'Système de commentaires et annotations sur les entités. Permet de créer des commentaires, les résoudre et suivre le nombre de commentaires ouverts.',
'Champs personnalisés' => 'Gestion des valeurs de champs personnalisés. Permet d\'ajouter, modifier et supprimer des données dynamiques sur les machines, pièces, composants et produits.',
'Documents' => 'Gestion des fichiers joints. Consultation des documents par entité, affichage inline et téléchargement.',
'Machines — Structure' => 'Structure hiérarchique des machines. Consultation et modification des liaisons composants/pièces/produits, clonage de machines et initialisation des champs personnalisés.',
'ModelType' => 'Conversion de catégories de types. Vérification de compatibilité et conversion effective des catégories de ModelType.',
];
$existingTags = $openApi->getTags();
$existingNames = array_map(static fn (Model\Tag $tag) => $tag->getName(), $existingTags);
foreach ($customTags as $name => $description) {
if (!in_array($name, $existingNames, true)) {
$existingTags[] = new Model\Tag(name: $name, description: $description);
}
}
return $openApi->withTags($existingTags);
}
private function jsonResponse(string $description): Model\Response
{
return new Model\Response(
description: $description,
content: new ArrayObject([
'application/json' => new Model\MediaType(
schema: new ArrayObject(['type' => 'object']),
),
]),
);
}
private function jsonRequestBody(string $description): Model\RequestBody
{
return new Model\RequestBody(
description: $description,
content: new ArrayObject([
'application/json' => new Model\MediaType(
schema: new ArrayObject(['type' => 'object']),
),
]),
required: true,
);
}
private function pathParam(string $name, string $description): Model\Parameter
{
return new Model\Parameter(
name: $name,
in: 'path',
description: $description,
required: true,
schema: ['type' => 'string'],
);
}
private function queryParam(string $name, string $description): Model\Parameter
{
return new Model\Parameter(
name: $name,
in: 'query',
description: $description,
required: false,
schema: ['type' => 'string'],
);
}
}