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'], ); } }