Compare commits

...

39 Commits

Author SHA1 Message Date
z3n
1a8bd0b7b4 test(uploads): extraire extractMetadata() + tests unitaires
Refactoring minimal : le bloc de parsing de métadonnées (titre, version,
description) est extrait en méthode privée extractMetadata() dans
UploadsController. Le comportement de validate() est identique.

12 tests unitaires couvrent tous les cas : version standard (1.2.3, 1.0, 2,
0.0.0), extraction nom/description, et cas limites (version non-numérique,
absente, vide, info absent).

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-24 13:18:59 +00:00
z3n
b6a52ce554 test(apis): ajouter tests unitaires ApisService + fix upload nouvelle version
- 21 nouveaux tests couvrant create, findOneOrThrow, remove,
  findVersionsOf, setCurrent et regenerate (pattern vi.fn() sans NestJS TestingModule)
- apis.service.ts : ConflictException sur doublon de version (name+convention+version)
- upload.component.ts : verrouillage convention/name/type lors d'une nouvelle
  version, bannière d'info, alerte et blocage si version déjà existante

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-24 12:55:03 +00:00
z3n
3b9ccecd69 fix(uploads): ajouter shell:true à execFile pour compatibilité Windows (.cmd)
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-24 09:23:26 +00:00
z3n
60ed177335 fix(uploads): ignorer ENOENT si CLI asyncapi/redocly absent (dev local sans Docker)
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-24 09:22:17 +00:00
z3n
403850f21d revert(apis): supprimer le cast repo.save inutile
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-24 09:14:42 +00:00
z3n
2e9191e6a2 fix(apis): cast repo.save result pour résoudre l'overload TypeORM
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-24 09:13:05 +00:00
z3n
14e5821ec0 fix(apis): cast status as ApiStatus pour compatibilité shared non recompilé
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-24 09:07:05 +00:00
z3n
3836db1e1f feat(status): ajouter statut NO_YAML pour les entrées sans fichier YAML
- shared: ajoute 'NO_YAML' à ApiStatus
- back/apis: status initial dérivé de yamlContent (PENDING si YAML, NO_YAML sinon)
- back/uploads: supprime la contrainte "fichier requis", gère le cas sans fichier
- front/api-detail: badgeClass/statusLabel gèrent NO_YAML (badge warning "Sans fichier")
- front/catalog: badgeClass ajoute le case NO_YAML → badge warning
- front/upload: fichier optionnel (canProceedFromStep1 retourne true si 0 fichier)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-24 08:23:03 +00:00
z3n
f326efb7b0 fix(upload): bloquer l'étape 2 si aucun fichier sélectionné
canProceedFromStep1 retournait true sans fichier → l'utilisateur
pouvait atteindre la confirmation et cliquer Importer sans résultat

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-23 13:02:37 +00:00
z3n
68c714cc97 fix(upload): markAllAsTouched au submit pour afficher les erreurs de validation
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-23 12:37:10 +00:00
z3n
ac35250a06 fix(seeder): outDir assets yaml aligné avec la structure dist monorepo
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-22 12:59:36 +00:00
z3n
007a5d3fc8 feat(api-detail): bouton Voir doc en accès direct + assets seeder yaml
- Déplace "Voir la documentation" hors du dropdown Actions
  en bouton principal à gauche du menu (désactivé si status ≠ GENERATED)
- nest-cli.json : copie automatique des assets YAML du seeder
  (src/modules/seeder/assets/**/*.yaml → dist, watchAssets: true)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-22 12:53:00 +00:00
z3n
0976c33059 feat(api-detail): isCurrent + setCurrent + màj doc et seeder
- Shared types : ajout isCurrent sur ApiEntry et ApiEntryListItem
- Backend entity : colonne is_current (boolean, default true)
- Backend service : isCurrent calculé à la création, setCurrent()
  marque une version comme courante (siblings → false)
- Backend controller : POST /api/apis/:id/set-current
- Frontend ApiService : méthode setCurrent()
- Browse/upload : affichage badge "Courante" via isCurrent
- Seeder : données de démo mises à jour
- Documentation : api-reference, architecture, local-setup, CLAUDE.md

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-19 14:23:31 +00:00
z3n
c8b3e393ea fix(search): correction recherche fuzzy catégories
- Backend : seuil word_similarity >= 0.4 (au lieu de > 0.4) sur cat.name
- Frontend : correction erreur TypeScript dans CategoryService.list()
  (params typé Record<string,string> pour satisfaire Angular HttpClient)
  — le code précédent ne compilait pas, la recherche était ignorée

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-19 13:31:49 +00:00
z3n
eb07ca085f feat(browse): tag version sur les tuiles + statut francisé sur la fiche détail
- Supprime le badge statut (GENERATED/PENDING/ERROR) des tuiles browse et cartes search
- Ajoute un badge version (ex: v1.2.0) sur les tuiles et cartes
- Traduit le statut en français sur la fiche détail (Générée / En attente / Erreur)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-17 14:50:51 +00:00
z3n
dbf5af81ca fix(search): seuils word_similarity différenciés par champ
- name/provider : seuil 0.4 (textes courts, scores plus précis)
- description   : seuil 0.5 (textes longs, évite les faux positifs)
- "auten" trouve désormais "Authentification JWT" via fuzzy sur le nom

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-17 09:04:47 +00:00
z3n
498c6577e4 feat(search): recherche intelligente — parsing backend + pg_trgm fuzzy
- parseSearchTokens() côté backend : extrait type (async/openapi),
  version (v1, 1.2.3) et latestOnly depuis la chaîne brute
- Recherche fuzzy via word_similarity (pg_trgm, seuil 0.5) + ILIKE
- latestOnly automatique quand champ non vide sans version explicite
- Suppression de parseSearch() dans BrowseComponent et CatalogComponent
- Suppression de version/latestOnly de ApiListQuery (géré par le backend)
- Activation de pg_trgm via CREATE EXTENSION dans SeederService
- Tests unitaires Vitest pour parseSearchTokens (24 cas)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-17 08:57:45 +00:00
z3n
e1a3dbc85b feat(api-detail): navigation entre versions + bouton nouvelle version
- GET /api/apis/:id/versions — retourne toutes les versions du même API
  (même name + convention), triées par version DESC
- Sélecteur DSFR sur la page détail quand plusieurs versions existent ;
  changement de version navigue vers /catalog/:newId en rechargeant
  le composant via abonnement à paramMap (fix réutilisation instance Angular)
- Bouton "Nouvelle version" → /upload?from=:id avec pré-remplissage du
  formulaire (convention, name, provider, contacts, version patch+1)
- Seeder : 2 versions démo supplémentaires de "Référentiel Produits"
  (v3.0.3, v3.0.2) pour tester le sélecteur
- Fix import ApiStatus manquant dans apis.service.ts

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-16 15:07:31 +00:00
z3n
1adb6820dd refactor: fix bugs et réduire duplication + extraire modales BrowseComponent
- fix(dto): ajouter @IsEmail() sur contactFunctionalEmail/TechnicalEmail dans UpdateApiEntryDto
- fix(docs): corriger stringify des erreurs (String(error) → error.message)
- refactor(mapper): extraire toApiEntryListItem() dans api-entry.mapper.ts partagé
- refactor(categories): supprimer CONVENTION_LABELS et toApiEntryListItem locaux dupliqués
- refactor(browse): extraire CategoryFormModalComponent et CategoryDeleteModalComponent
- refactor(api-detail): convertir editPreviewTitle() en computed()

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-11 15:10:38 +00:00
z3n
e939d592f8 feat(browse): modale de suppression + crayon d'édition sur le titre
- Remplace le confirm() natif par une vraie modale de confirmation avec gestion d'erreur inline
- Ajoute un bouton crayon dans le <h1> pour éditer la catégorie courante (visible uniquement dans /browse/:id)
- Signature openEdit() assouplie pour accepter Category (au lieu de CategoryWithCounts)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-11 14:50:16 +00:00
z3n
b9d45c0c3c feat(api-detail): fil d'Ariane aligné sur la hiérarchie des catégories
Accueil > [ancêtres de catégorie] > [titre API], ou
Accueil > Parcourir > [titre API] si l'API n'a pas de catégorie.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-11 13:54:24 +00:00
z3n
e85696079c feat: refonte métadonnées API + UX dossiers et import
- Nouveau schéma api_entries : convention+name, version X.X.X, provider,
  contacts splitées (name/entity/email), suppression des anciens champs
- Formulaire upload étape 2 : convention+nom, version 3 champs, fournisseur,
  contacts métier et technique obligatoires ; type détecté auto → select désactivé
- Validation YAML : redocly.yaml (extends: spec), support noms avec espaces,
  extraction métadonnées (name, version, description) depuis info YAML
- Dossiers : modale création avec champ Emplacement pré-initialisé depuis
  le contexte de navigation, options └ avec indentation par profondeur
- Import depuis un dossier : pré-sélection de la catégorie via queryParam
- Recherche étendue : name, provider, description, contacts
- Résolution @datacat/shared via workspace:* + compilation CJS avant NestJS

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-11 13:38:14 +00:00
z3n
ef3884c68d feat(upload): validation YAML côté serveur dès la sélection du fichier
- Nouvel endpoint POST /api/uploads/validate : validation en 3 niveaux
  (syntaxe js-yaml → détection type → asyncapi validate / redocly lint)
- Extraction automatique de info.title/version/description pour pré-remplir le formulaire
- Validation déclenchée immédiatement à la sélection, résultat affiché inline
- Bouton "Suivant" désactivé pendant la validation et si le fichier est invalide
- Sans fichier sélectionné, on peut toujours passer à l'étape 2

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-10 08:48:40 +00:00
z3n
abc20bc9e8 fix(forms): erreurs de validation par champ au blur
- Erreurs affichées sous chaque champ obligatoire (fr-error-text DSFR)
  au lieu d'un message global en haut du formulaire
- Déclenchement sur blur (sortie du champ) et effacement sur input
- Concerne la modale édition API et la modale catégorie

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-09 13:17:18 +00:00
z3n
af02cf6808 feat(api-detail): modale d'édition des métadonnées d'une API
- Bouton "Modifier" sur la page détail ouvre une modale avec tous les
  champs éditables (titre, version, catégorie, description, docs, contacts,
  droits d'accès)
- ApiService.update() étendu à tous les champs du UpdateApiEntryDto
- Remplacement des labels "Obligatoire"/"Optionnel" par un astérisque
  rouge sur les champs requis dans tous les formulaires

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-09 13:06:53 +00:00
z3n
ecb45e8248 feat(browse): icône crayon inline sur les tuiles + apiCount récursif
- Bouton modifier réduit à l'icône crayon, placé à côté du titre de la tuile
- apiCount dans CategoryWithCounts inclut désormais les APIs de tous les
  sous-dossiers récursivement (countApisRecursively)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-09 13:01:46 +00:00
z3n
e54c21042f fix(assets): servir dist/icons/ au bon chemin pour dsfr.min.css
dsfr.min.css référence les SVG via ../icons/ (résout vers assets/icons/)
alors que icons.min.css les référence via ../../icons/ (assets/dsfr/icons/).
Les deux chemins sont maintenant mappés dans angular.json.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-09 12:53:12 +00:00
z3n
9a8f28bbce feat(browse): recherche unifiée dossiers + APIs + fix icônes DSFR
- Recherche simultanée dans les dossiers (filtrage client-side) et les
  APIs (backend) via forkJoin — affiche dossiers en tuiles puis APIs en
  cartes, avec labels "Dossiers"/"APIs" si les deux ont des résultats
- Loader instantané dès la frappe (avant le debounce 400ms)
- Fix angular.json : ajout du mapping dist/icons/ → assets/dsfr/icons/
  pour servir les SVG référencés par le CSS DSFR (tous les icônes étaient
  cassés car le chemin relatif ../../icons/ donnait 404)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-09 12:51:28 +00:00
z3n
a82c52c491 feat(browse): fusion Parcourir + Catalogue en une seule page
- Barre de recherche intégrée dans BrowseComponent
- Mode navigation (tuiles) si recherche vide, mode liste (cartes) si texte
- Debounce 400ms + pagination pour les résultats de recherche
- Suppression de l'entrée "Catalogue" du menu de navigation
- Redirection /catalog → /browse (vieux liens toujours fonctionnels)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-09 09:41:10 +00:00
z3n
a0677df5ec fix(uploads): validation YAML + logging erreurs 5xx détaillé
- Validation syntaxique js-yaml avant import (BadRequestException 400 avec message précis)
- Logger dans UploadsController : log du fichier principal détecté pour multi-upload
- AllExceptionsFilter : log complet avec stack trace pour toutes les erreurs 5xx
- @asyncapi/bundler version corrigée (0.9.0 → ^1.0.1, version inexistante)
- @types/js-yaml ajouté en devDependencies

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-08 12:50:50 +00:00
z3n
b975e01e15 fix(uploads): bundling AsyncAPI multi-fichiers sans appel réseau
- Ajout @asyncapi/bundler@0.9.0 en dépendance locale
- Remplace execFile('asyncapi bundle') par l'API programmatique
- redocly bundle conservé pour OpenAPI (pas de pb réseau)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-06-01 09:08:57 +00:00
z3n
8d6deb8691 fix(uploads): compatibilité Windows pour l'upload multi-fichiers
- Remplace /tmp/uploads par os.tmpdir() (cross-platform)
- Ajoute node_modules/.bin au PATH des commandes bundle asyncapi/redocly
- Supprime la déclaration en double de execFileAsync

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-29 14:45:39 +00:00
z3n
1e68ceb211 fix(docs-generation): utiliser @asyncapi/generator en API programmatique
Remplace execFile('asyncapi') par l'API Node.js directe — aucun appel
réseau vers registry.npmjs.org, template résolu depuis node_modules local.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-29 14:16:52 +00:00
z3n
67609231d9 fix(docs-generation): utiliser le chemin local du template asyncapi
Passer le chemin absolu node_modules/@asyncapi/html-template au lieu
du nom de package — bypasse la vérification registry.npmjs.org.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-29 14:02:30 +00:00
z3n
90eab514b2 fix(docs-generation): pré-installer @asyncapi/html-template sans accès npm
- Ajout @asyncapi/html-template@3.5.6 en dépendance locale
- Suppression du flag --install (template trouvé dans node_modules)
- Évite l'appel réseau vers registry.npmjs.org lors de la génération

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-29 13:52:02 +00:00
z3n
3a98b87ccf fix(apis): résoudre le chemin absolu avant sendFile
res.sendFile() ne supporte pas les chemins relatifs — path.resolve()
convertit ./docs-output/... en chemin absolu avant l'envoi.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-29 13:46:00 +00:00
z3n
f27542e71f fix: injecter node_modules/.bin dans le PATH des CLIs de génération
execFileAsync ne voit pas node_modules/.bin par défaut — on l'ajoute
explicitement à l'env du process enfant pour asyncapi et redocly.
Fonctionne aussi bien en local qu'en Docker (priorité locale, fallback global).

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-29 13:22:30 +00:00
z3n
3ef7246f38 chore(back): ajout @asyncapi/cli et @redocly/cli en dépendances locales
Plus besoin d'install globale — pnpm expose node_modules/.bin au PATH
lors de pnpm run start:dev, les CLIs sont trouvés automatiquement.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-29 12:20:40 +00:00
z3n
235e8053d1 fix: compatibilité Windows pour la génération de docs
- execFileAsync avec shell:true pour trouver asyncapi.cmd/redocly.cmd sur Windows
- DOCS_OUTPUT_DIR par défaut ./docs-output (cross-platform) au lieu de /app/docs-output

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-22 15:22:07 +00:00
43 changed files with 14768 additions and 1080 deletions

View File

@@ -83,4 +83,5 @@ docker compose -f infra/docker-compose.yml up -d
- [`documentation/architecture.md`](documentation/architecture.md) — flux, schéma DB, services
- [`documentation/api-reference.md`](documentation/api-reference.md) — référence complète des endpoints REST
- [`documentation/local-setup.md`](documentation/local-setup.md) — installation locale (sans Docker)
- [`documentation/CLAUDE.md`](documentation/CLAUDE.md) — guide d'implémentation interne

View File

@@ -3,7 +3,14 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
{
"include": "modules/seeder/assets/**/*.yaml",
"outDir": "./dist/back/src",
"watchAssets": true
}
]
},
"entryFile": "back/src/main"
}

View File

@@ -12,6 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@datacat/shared": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
@@ -27,7 +28,13 @@
"pino": "^9.0.0",
"pino-http": "^10.0.0",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.0"
"class-transformer": "^0.5.0",
"@asyncapi/cli": "6.0.0",
"@asyncapi/bundler": "^1.0.1",
"js-yaml": "^4.1.0",
"@asyncapi/generator": "3.0.1",
"@asyncapi/html-template": "3.5.6",
"@redocly/cli": "latest"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
@@ -35,6 +42,7 @@
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.0",
"@types/js-yaml": "^4.0.9",
"@types/uuid": "^10.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"

3
back/redocly.yaml Normal file
View File

@@ -0,0 +1,3 @@
# Validation structurelle uniquement — pas de règles de style
extends:
- spec

View File

@@ -1,8 +1,10 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger('ExceptionFilter');
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
@@ -18,6 +20,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
? exception.getResponse()
: 'Internal server error';
/* Logguer toutes les erreurs 5xx avec stack trace complète */
if (status >= 500) {
this.logger.error(
`[${request.method} ${request.url}] ${String(exception)}`,
exception instanceof Error ? exception.stack : undefined,
);
}
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),

View File

@@ -1,16 +1,16 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { ApiType, ApiStatus } from '@datacat/shared';
import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared';
@Entity('api_entries')
export class ApiEntryEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
title!: string;
@Column({ type: 'varchar', length: 30, default: 'CONSULTER' })
convention!: ApiConvention;
@Column()
version!: string;
@Column({ type: 'varchar', length: 255, default: '' })
name!: string;
@Column({ nullable: true, type: 'text' })
description!: string | null;
@@ -18,6 +18,18 @@ export class ApiEntryEntity {
@Column({ type: 'varchar', length: 10 })
type!: ApiType;
@Column({ type: 'varchar', length: 255, default: '', name: 'provider' })
provider!: string;
@Column({ type: 'int', default: 0, name: 'version_major' })
versionMajor!: number;
@Column({ type: 'int', default: 0, name: 'version_minor' })
versionMinor!: number;
@Column({ type: 'int', default: 0, name: 'version_patch' })
versionPatch!: number;
@Column({ type: 'text', name: 'yaml_content' })
yamlContent!: string;
@@ -33,23 +45,26 @@ export class ApiEntryEntity {
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
categoryId!: string | null;
@Column({ nullable: true, type: 'text', name: 'functional_doc' })
functionalDoc!: string | null;
@Column({ type: 'boolean', default: true, name: 'is_current' })
isCurrent!: boolean;
@Column({ nullable: true, type: 'text', name: 'technical_doc' })
technicalDoc!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
contactFunctionalName!: string;
@Column({ nullable: true, type: 'varchar', length: 500, name: 'external_doc_url' })
externalDocUrl!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_entity' })
contactFunctionalEntity!: string;
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_functional' })
contactFunctional!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_email' })
contactFunctionalEmail!: string;
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_technical' })
contactTechnical!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_name' })
contactTechnicalName!: string;
@Column({ nullable: true, type: 'text', name: 'access_rights' })
accessRights!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_entity' })
contactTechnicalEntity!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_email' })
contactTechnicalEmail!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;

View File

@@ -0,0 +1,20 @@
import { ApiEntryEntity } from './api-entry.entity';
import { ApiEntryListItem, API_CONVENTION_LABELS } from '@datacat/shared';
export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: `${API_CONVENTION_LABELS[entity.convention]} ${entity.name}`,
name: entity.name,
convention: entity.convention,
version: `${entity.versionMajor}.${entity.versionMinor}.${entity.versionPatch}`,
description: entity.description,
type: entity.type,
provider: entity.provider,
status: entity.status,
categoryId: entity.categoryId,
isCurrent: entity.isCurrent,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}

View File

@@ -43,6 +43,11 @@ export class ApisController {
return this.apisService.remove(id);
}
@Post(':id/set-current')
setCurrent(@Param('id') id: string) {
return this.apisService.setCurrent(id);
}
@Post(':id/regenerate')
regenerate(@Param('id') id: string) {
return this.apisService.regenerate(id);
@@ -52,7 +57,8 @@ export class ApisController {
async getDocs(@Param('id') id: string, @Res() res: Response) {
const entry = await this.apisService.findOneOrThrow(id);
if (!entry.htmlPath) throw new NotFoundException('Documentation not generated yet');
res.sendFile(entry.htmlPath);
const absolutePath = path.resolve(entry.htmlPath);
res.sendFile(absolutePath);
}
@Get(':id/yaml')
@@ -63,6 +69,11 @@ export class ApisController {
res.send(entry.yamlContent);
}
@Get(':id/versions')
getVersions(@Param('id') id: string) {
return this.apisService.findVersionsOf(id);
}
/** Sert les assets statiques (CSS, JS) générés par AsyncAPI CLI */
@Get(':id/*path')
async getDocAsset(@Param('id') id: string, @Req() req: Request, @Res() res: Response) {

View File

@@ -0,0 +1,347 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { ApisService } from './apis.service';
import { ApiType } from '@datacat/shared';
/** Accès à la méthode privée pour les tests unitaires */
function parse(raw: string) {
return (ApisService.prototype as any).parseSearchTokens.call({}, raw) as {
text: string;
type?: ApiType;
version?: string;
latestOnly: boolean;
};
}
describe('ApisService.parseSearchTokens()', () => {
describe('chaîne vide', () => {
it('retourne text vide, pas de type, pas de version, latestOnly=false', () => {
expect(parse('')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false });
});
it('gère les espaces seuls', () => {
expect(parse(' ')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false });
});
});
describe('texte libre seul (sans version ni type)', () => {
it('"auth" → latestOnly=true automatique', () => {
expect(parse('auth')).toEqual({ text: 'auth', type: undefined, version: undefined, latestOnly: true });
});
it('"paiement commande" → texte libre multi-mots, latestOnly=true', () => {
expect(parse('paiement commande')).toEqual({
text: 'paiement commande',
type: undefined,
version: undefined,
latestOnly: true,
});
});
});
describe('détection du type', () => {
it('"async" → ASYNCAPI', () => {
const r = parse('async');
expect(r.type).toBe('ASYNCAPI');
});
it('"asyncapi" → ASYNCAPI', () => {
expect(parse('asyncapi').type).toBe('ASYNCAPI');
});
it('"openapi" → OPENAPI', () => {
expect(parse('openapi').type).toBe('OPENAPI');
});
it('"open" → OPENAPI', () => {
expect(parse('open').type).toBe('OPENAPI');
});
it('type seul → latestOnly=true (champ non vide, pas de version)', () => {
expect(parse('async').latestOnly).toBe(true);
});
it('mots de type ne font pas partie du texte libre', () => {
expect(parse('auth async').text).toBe('auth');
});
});
describe('détection de version', () => {
it('"v1" → version="1", latestOnly=false', () => {
expect(parse('v1')).toEqual({ text: '', type: undefined, version: '1', latestOnly: false });
});
it('"1.2" → version="1.2"', () => {
expect(parse('1.2').version).toBe('1.2');
});
it('"v1.2.3" → version="1.2.3" (préfixe v supprimé)', () => {
expect(parse('v1.2.3').version).toBe('1.2.3');
});
it('"1.0.0" → version exacte', () => {
expect(parse('1.0.0').version).toBe('1.0.0');
});
it('version présente → latestOnly=false', () => {
expect(parse('auth v1').latestOnly).toBe(false);
});
it('numéro de version ne fait pas partie du texte libre', () => {
expect(parse('auth v1').text).toBe('auth');
});
});
describe('mot-clé "latest"', () => {
it('"latest" seul → latestOnly=true, pas de texte', () => {
expect(parse('latest')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: true });
});
it('"latest" + type → latestOnly=true + type détecté', () => {
const r = parse('latest async');
expect(r.latestOnly).toBe(true);
expect(r.type).toBe('ASYNCAPI');
});
it('"latest" + version → les deux flags actifs', () => {
const r = parse('latest v1');
expect(r.latestOnly).toBe(true);
expect(r.version).toBe('1');
});
});
describe('combinaisons', () => {
it('"auth async" → texte=auth, type=ASYNCAPI, latestOnly=true', () => {
expect(parse('auth async')).toEqual({
text: 'auth',
type: 'ASYNCAPI',
version: undefined,
latestOnly: true,
});
});
it('"auth async v1" → texte=auth, type=ASYNCAPI, version=1, latestOnly=false', () => {
expect(parse('auth async v1')).toEqual({
text: 'auth',
type: 'ASYNCAPI',
version: '1',
latestOnly: false,
});
});
it('"auth 1.0.0" → texte=auth, version=1.0.0, latestOnly=false', () => {
expect(parse('auth 1.0.0')).toEqual({
text: 'auth',
type: undefined,
version: '1.0.0',
latestOnly: false,
});
});
it('"openapi latest" → type=OPENAPI, latestOnly=true', () => {
expect(parse('openapi latest')).toEqual({
text: '',
type: 'OPENAPI',
version: undefined,
latestOnly: true,
});
});
it('insensible à la casse pour les mots-clés', () => {
const r = parse('ASYNC Auth LATEST');
expect(r.type).toBe('ASYNCAPI');
expect(r.latestOnly).toBe(true);
expect(r.text).toBe('Auth');
});
});
});
// ─── Mocks partagés pour les tests unitaires de ApisService ───────────────────
const mockRepo = {
findOne: vi.fn(),
count: vi.fn(),
create: vi.fn(),
save: vi.fn(),
find: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
const mockDocsService = { generate: vi.fn() };
let service: ApisService;
beforeEach(() => {
vi.clearAllMocks();
service = new ApisService(mockRepo as any, mockDocsService as any);
});
function makeEntity(overrides: Record<string, unknown> = {}) {
return {
id: 'uuid-1', name: 'Petstore', convention: 'CONSULTER',
versionMajor: 1, versionMinor: 0, versionPatch: 0,
type: 'OPENAPI', provider: 'DINUM', description: null,
yamlContent: 'openapi: "3.0.0"', htmlPath: null,
status: 'PENDING', errorMessage: null, categoryId: null,
isCurrent: true,
contactFunctionalName: '', contactFunctionalEntity: '', contactFunctionalEmail: '',
contactTechnicalName: '', contactTechnicalEntity: '', contactTechnicalEmail: '',
createdAt: new Date(), updatedAt: new Date(),
...overrides,
};
}
const baseDto = {
name: 'Petstore', convention: 'CONSULTER' as const,
versionMajor: 1, versionMinor: 0, versionPatch: 0,
type: 'OPENAPI' as const, provider: 'DINUM',
yamlContent: 'openapi: "3.0.0"',
contactFunctionalName: 'Alice', contactFunctionalEntity: 'DINUM', contactFunctionalEmail: 'alice@dinum.fr',
contactTechnicalName: 'Bob', contactTechnicalEntity: 'DINUM', contactTechnicalEmail: 'bob@dinum.fr',
};
// ─── ApisService.create() ─────────────────────────────────────────────────────
describe('ApisService.create()', () => {
it('crée l\'entrée et la retourne', async () => {
const entity = makeEntity();
mockRepo.findOne.mockResolvedValue(null);
mockRepo.count.mockResolvedValue(0);
mockRepo.create.mockReturnValue(entity);
mockRepo.save.mockResolvedValue(entity);
const result = await service.create(baseDto as any);
expect(result.id).toBe('uuid-1');
expect(result.title).toBe('Consulter Petstore');
expect(result.version).toBe('1.0.0');
});
it('lève ConflictException si doublon', async () => {
mockRepo.findOne.mockResolvedValue(makeEntity());
await expect(service.create(baseDto as any)).rejects.toThrow(ConflictException);
});
it('isCurrent=true si première version', async () => {
const entity = makeEntity({ isCurrent: true });
mockRepo.findOne.mockResolvedValue(null);
mockRepo.count.mockResolvedValue(0);
mockRepo.create.mockReturnValue(entity);
mockRepo.save.mockResolvedValue(entity);
await service.create(baseDto as any);
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ isCurrent: true }));
});
it('isCurrent=false si version ultérieure', async () => {
const entity = makeEntity({ isCurrent: false });
mockRepo.findOne.mockResolvedValue(null);
mockRepo.count.mockResolvedValue(1);
mockRepo.create.mockReturnValue(entity);
mockRepo.save.mockResolvedValue(entity);
await service.create(baseDto as any);
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ isCurrent: false }));
});
});
// ─── ApisService.findOneOrThrow() ────────────────────────────────────────────
describe('ApisService.findOneOrThrow()', () => {
it('retourne l\'entrée mappée si trouvée', async () => {
mockRepo.findOne.mockResolvedValue(makeEntity());
const result = await service.findOneOrThrow('uuid-1');
expect(result.id).toBe('uuid-1');
expect(result.title).toBe('Consulter Petstore');
expect(result.version).toBe('1.0.0');
});
it('lève NotFoundException si absente', async () => {
mockRepo.findOne.mockResolvedValue(null);
await expect(service.findOneOrThrow('uuid-unknown')).rejects.toThrow(NotFoundException);
});
});
// ─── ApisService.remove() ────────────────────────────────────────────────────
describe('ApisService.remove()', () => {
it('supprime l\'entrée si elle existe', async () => {
mockRepo.findOne.mockResolvedValue(makeEntity());
mockRepo.delete.mockResolvedValue({ affected: 1 });
await service.remove('uuid-1');
expect(mockRepo.delete).toHaveBeenCalledWith('uuid-1');
});
it('lève NotFoundException si absente', async () => {
mockRepo.findOne.mockResolvedValue(null);
await expect(service.remove('uuid-unknown')).rejects.toThrow(NotFoundException);
});
});
// ─── ApisService.findVersionsOf() ────────────────────────────────────────────
describe('ApisService.findVersionsOf()', () => {
it('retourne toutes les versions (même name+convention)', async () => {
const entity1 = makeEntity({ id: 'uuid-1', versionMajor: 2 });
const entity2 = makeEntity({ id: 'uuid-2', versionMajor: 1 });
mockRepo.findOne.mockResolvedValue(entity1);
mockRepo.find.mockResolvedValue([entity1, entity2]);
const result = await service.findVersionsOf('uuid-1');
expect(result).toHaveLength(2);
expect(result[0].id).toBe('uuid-1');
expect(result[1].id).toBe('uuid-2');
});
it('lève NotFoundException si id inconnu', async () => {
mockRepo.findOne.mockResolvedValue(null);
await expect(service.findVersionsOf('uuid-unknown')).rejects.toThrow(NotFoundException);
});
});
// ─── ApisService.setCurrent() ────────────────────────────────────────────────
describe('ApisService.setCurrent()', () => {
it('met isCurrent=false sur les siblings puis true sur la cible', async () => {
mockRepo.findOne.mockResolvedValue(makeEntity());
mockRepo.update.mockResolvedValue({ affected: 1 });
await service.setCurrent('uuid-1');
expect(mockRepo.update).toHaveBeenCalledWith(
{ name: 'Petstore', convention: 'CONSULTER' },
{ isCurrent: false },
);
expect(mockRepo.update).toHaveBeenCalledWith(
{ id: 'uuid-1' },
{ isCurrent: true },
);
});
});
// ─── ApisService.regenerate() ────────────────────────────────────────────────
describe('ApisService.regenerate()', () => {
it('remet le statut à PENDING et appelle docsService.generate()', async () => {
mockRepo.findOne.mockResolvedValue(makeEntity());
mockRepo.update.mockResolvedValue({ affected: 1 });
mockDocsService.generate.mockResolvedValue(undefined);
await service.regenerate('uuid-1');
expect(mockRepo.update).toHaveBeenCalledWith('uuid-1', { status: 'PENDING', errorMessage: null });
expect(mockDocsService.generate).toHaveBeenCalledWith('uuid-1');
});
});

View File

@@ -1,17 +1,15 @@
import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, Inject, forwardRef } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApiEntryEntity } from './api-entry.entity';
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus } from '@datacat/shared';
import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus, ApiConvention, API_CONVENTION_LABELS } from '@datacat/shared';
import { toApiEntryListItem } from './api-entry.mapper';
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
/** Champs internes mis à jour par DocsGenerationService */
interface InternalUpdateFields {
title?: string;
version?: string;
description?: string;
htmlPath?: string | null;
status?: ApiStatus;
errorMessage?: string | null;
@@ -27,55 +25,154 @@ export class ApisService {
) {}
async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
// Vérifier qu'il n'existe pas déjà une entrée avec le même tuple (name, convention, version)
const duplicate = await this.repo.findOne({
where: {
name: dto.name,
convention: dto.convention,
versionMajor: dto.versionMajor,
versionMinor: dto.versionMinor,
versionPatch: dto.versionPatch,
},
});
if (duplicate) {
throw new ConflictException(
`La version ${dto.versionMajor}.${dto.versionMinor}.${dto.versionPatch} existe déjà pour l'API "${dto.name}".`,
);
}
// Première version si aucune entrée avec le même name + convention
const existingCount = await this.repo.count({
where: { name: dto.name, convention: dto.convention },
});
const isCurrent = existingCount === 0;
const entity = this.repo.create({
title: dto.title,
version: dto.version,
convention: dto.convention,
name: dto.name,
provider: dto.provider,
versionMajor: dto.versionMajor,
versionMinor: dto.versionMinor,
versionPatch: dto.versionPatch,
description: dto.description ?? null,
type: dto.type,
yamlContent: dto.yamlContent,
status: 'PENDING',
status: (dto.yamlContent ? 'PENDING' : 'NO_YAML') as ApiStatus,
categoryId: dto.categoryId ?? null,
functionalDoc: dto.functionalDoc ?? null,
technicalDoc: dto.technicalDoc ?? null,
externalDocUrl: dto.externalDocUrl ?? null,
contactFunctional: dto.contactFunctional ?? null,
contactTechnical: dto.contactTechnical ?? null,
accessRights: dto.accessRights ?? null,
isCurrent,
contactFunctionalName: dto.contactFunctionalName,
contactFunctionalEntity: dto.contactFunctionalEntity,
contactFunctionalEmail: dto.contactFunctionalEmail,
contactTechnicalName: dto.contactTechnicalName,
contactTechnicalEntity: dto.contactTechnicalEntity,
contactTechnicalEmail: dto.contactTechnicalEmail,
});
const saved = await this.repo.save(entity);
return toApiEntry(saved);
}
async findAll(query: ApiListQuery): Promise<ApiListResponse> {
const page = Math.max(1, Number(query.page ?? 1));
const page = Math.max(1, Number(query.page ?? 1));
const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20)));
const offset = (page - 1) * limit;
const qb = this.repo.createQueryBuilder('api');
if (query.type) {
qb.andWhere('api.type = :type', { type: query.type as ApiType });
let effectiveType = query.type as ApiType | undefined;
let version: string | undefined;
let latestOnly = false;
if (query.search?.trim()) {
const parsed = this.parseSearchTokens(query.search);
// Le type détecté dans la saisie override le select UI
if (parsed.type) effectiveType = parsed.type;
version = parsed.version;
latestOnly = parsed.latestOnly;
if (parsed.text) {
// Recherche fuzzy via pg_trgm + ILIKE pour les matches partiels courts
qb.andWhere(
`(
api.name ILIKE :likeText
OR api.provider ILIKE :likeText
OR COALESCE(api.description, '') ILIKE :likeText
OR api.contact_functional_name ILIKE :likeText
OR api.contact_technical_name ILIKE :likeText
OR word_similarity(:text, api.name) > 0.4
OR word_similarity(:text, api.provider) > 0.4
OR word_similarity(:text, COALESCE(api.description, '')) > 0.5
)`,
{ likeText: `%${parsed.text}%`, text: parsed.text },
);
}
}
if (query.search) {
qb.andWhere(
'(LOWER(api.title) LIKE :search OR LOWER(api.description) LIKE :search)',
{ search: `%${query.search.toLowerCase()}%` },
);
if (effectiveType) {
qb.andWhere('api.type = :type', { type: effectiveType });
}
if (query.categoryId) {
qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId });
}
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
// Filtre par version (partielle ou complète)
if (version) {
const parts = version.split('.').map(Number);
if (parts.length >= 1 && !isNaN(parts[0]))
qb.andWhere('api.version_major = :major', { major: parts[0] });
if (parts.length >= 2 && !isNaN(parts[1]))
qb.andWhere('api.version_minor = :minor', { minor: parts[1] });
if (parts.length >= 3 && !isNaN(parts[2]))
qb.andWhere('api.version_patch = :patch', { patch: parts[2] });
}
// Latest-only : exclure les entrées dont il existe une version plus récente du même API
if (latestOnly) {
qb.andWhere(`NOT EXISTS (
SELECT 1 FROM api_entries e2
WHERE e2.name = api.name AND e2.convention = api.convention
AND (
e2.version_major > api.version_major
OR (e2.version_major = api.version_major AND e2.version_minor > api.version_minor)
OR (e2.version_major = api.version_major AND e2.version_minor = api.version_minor
AND e2.version_patch > api.version_patch)
)
)`);
}
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
const [entities, total] = await qb.getManyAndCount();
return {
items: entities.map(toApiEntryListItem),
total,
page,
limit,
return { items: entities.map(toApiEntryListItem), total, page, limit };
}
private parseSearchTokens(raw: string): {
text: string;
type?: ApiType;
version?: string;
latestOnly: boolean;
} {
const TYPE_MAP: Record<string, ApiType> = {
async: 'ASYNCAPI', asyncapi: 'ASYNCAPI',
openapi: 'OPENAPI', open: 'OPENAPI',
};
const VERSION_RE = /^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/i;
let type: ApiType | undefined;
let version: string | undefined;
let latestOnly = false;
const remaining: string[] = [];
for (const word of raw.trim().split(/\s+/).filter(Boolean)) {
const lower = word.toLowerCase();
if (lower === 'latest') { latestOnly = true; }
else if (TYPE_MAP[lower]) { type = TYPE_MAP[lower]; }
else if (VERSION_RE.test(word)) { version = word.replace(/^v/i, ''); }
else { remaining.push(word); }
}
const text = remaining.join(' ');
// Pas de version explicite ET champ non vide → latest par défaut
if (!latestOnly && raw.trim().length > 0 && !version) latestOnly = true;
return { text, type, version, latestOnly };
}
async findOneOrThrow(id: string): Promise<ApiEntry> {
@@ -101,6 +198,31 @@ export class ApisService {
await this.repo.delete(id);
}
async findVersionsOf(id: string): Promise<ApiEntryListItem[]> {
const entry = await this.findOneOrThrow(id);
const entities = await this.repo.find({
where: { name: entry.name, convention: entry.convention },
order: {
versionMajor: 'DESC',
versionMinor: 'DESC',
versionPatch: 'DESC',
},
});
return entities.map(toApiEntryListItem);
}
async setCurrent(id: string): Promise<ApiEntry> {
const entry = await this.findOneOrThrow(id);
// Mettre tous les siblings à false
await this.repo.update(
{ name: entry.name, convention: entry.convention },
{ isCurrent: false },
);
// Mettre cet entry à true
await this.repo.update({ id }, { isCurrent: true });
return this.findOneOrThrow(id);
}
async regenerate(id: string): Promise<ApiEntry> {
await this.findOneOrThrow(id);
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
@@ -109,39 +231,41 @@ export class ApisService {
}
}
function buildTitle(convention: ApiConvention, name: string): string {
return `${API_CONVENTION_LABELS[convention]} ${name}`;
}
function buildVersion(major: number, minor: number, patch: number): string {
return `${major}.${minor}.${patch}`;
}
function toApiEntry(entity: ApiEntryEntity): ApiEntry {
return {
id: entity.id,
title: entity.title,
version: entity.version,
title: buildTitle(entity.convention, entity.name),
name: entity.name,
convention: entity.convention,
version: buildVersion(entity.versionMajor, entity.versionMinor, entity.versionPatch),
versionMajor: entity.versionMajor,
versionMinor: entity.versionMinor,
versionPatch: entity.versionPatch,
description: entity.description,
type: entity.type,
provider: entity.provider,
yamlContent: entity.yamlContent,
htmlPath: entity.htmlPath,
status: entity.status,
errorMessage: entity.errorMessage,
categoryId: entity.categoryId,
functionalDoc: entity.functionalDoc,
technicalDoc: entity.technicalDoc,
externalDocUrl: entity.externalDocUrl,
contactFunctional: entity.contactFunctional,
contactTechnical: entity.contactTechnical,
accessRights: entity.accessRights,
isCurrent: entity.isCurrent,
contactFunctionalName: entity.contactFunctionalName,
contactFunctionalEntity: entity.contactFunctionalEntity,
contactFunctionalEmail: entity.contactFunctionalEmail,
contactTechnicalName: entity.contactTechnicalName,
contactTechnicalEntity: entity.contactTechnicalEntity,
contactTechnicalEmail: entity.contactTechnicalEmail,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: entity.title,
version: entity.version,
description: entity.description,
type: entity.type,
status: entity.status,
categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}

View File

@@ -1,14 +1,33 @@
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID } from 'class-validator';
import { ApiType } from '@datacat/shared';
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiType, ApiConvention } from '@datacat/shared';
export class CreateApiEntryDto {
@IsString()
@IsNotEmpty()
title!: string;
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
convention!: ApiConvention;
@IsString()
@IsNotEmpty()
version!: string;
name!: string;
@IsString()
@IsNotEmpty()
provider!: string;
@IsInt()
@Min(0)
@Type(() => Number)
versionMajor!: number;
@IsInt()
@Min(0)
@Type(() => Number)
versionMinor!: number;
@IsInt()
@Min(0)
@Type(() => Number)
versionPatch!: number;
@IsString()
@IsOptional()
@@ -26,26 +45,24 @@ export class CreateApiEntryDto {
categoryId?: string;
@IsString()
@IsOptional()
functionalDoc?: string;
@IsNotEmpty()
contactFunctionalName!: string;
@IsString()
@IsOptional()
technicalDoc?: string;
@IsNotEmpty()
contactFunctionalEntity!: string;
@IsEmail()
contactFunctionalEmail!: string;
@IsString()
@IsOptional()
externalDocUrl?: string;
@IsNotEmpty()
contactTechnicalName!: string;
@IsString()
@IsOptional()
contactFunctional?: string;
@IsNotEmpty()
contactTechnicalEntity!: string;
@IsString()
@IsOptional()
contactTechnical?: string;
@IsString()
@IsOptional()
accessRights?: string;
@IsEmail()
contactTechnicalEmail!: string;
}

View File

@@ -1,17 +1,41 @@
import { IsString, IsOptional, IsUUID } from 'class-validator';
import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiConvention } from '@datacat/shared';
export class UpdateApiEntryDto {
@IsString()
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
@IsOptional()
title?: string;
convention?: ApiConvention;
@IsString()
@IsOptional()
version?: string;
name?: string;
@IsString()
@IsOptional()
description?: string;
provider?: string;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionMajor?: number;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionMinor?: number;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionPatch?: number;
@IsString()
@IsOptional()
description?: string | null;
@IsUUID()
@IsOptional()
@@ -19,25 +43,25 @@ export class UpdateApiEntryDto {
@IsString()
@IsOptional()
functionalDoc?: string | null;
contactFunctionalName?: string;
@IsString()
@IsOptional()
technicalDoc?: string | null;
contactFunctionalEntity?: string;
@IsEmail()
@IsOptional()
contactFunctionalEmail?: string;
@IsString()
@IsOptional()
externalDocUrl?: string | null;
contactTechnicalName?: string;
@IsString()
@IsOptional()
contactFunctional?: string | null;
contactTechnicalEntity?: string;
@IsString()
@IsEmail()
@IsOptional()
contactTechnical?: string | null;
@IsString()
@IsOptional()
accessRights?: string | null;
contactTechnicalEmail?: string;
}

View File

@@ -6,6 +6,7 @@ import {
Delete,
Param,
Body,
Query,
HttpCode,
} from '@nestjs/common';
import { CategoriesService } from './categories.service';
@@ -17,8 +18,8 @@ export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Get()
findAll() {
return this.categoriesService.findAll();
findAll(@Query('search') search?: string) {
return this.categoriesService.findAll(search);
}
/* Déclarés AVANT :id pour éviter que NestJS interprète "browse" comme un UUID */

View File

@@ -10,10 +10,8 @@ import {
CategoryWithCounts,
CategoryBrowseResponse,
CategoryListResponse,
ApiEntryListItem,
ApiType,
ApiStatus,
} from '@datacat/shared';
import { toApiEntryListItem } from '../apis/api-entry.mapper';
@Injectable()
export class CategoriesService {
@@ -24,7 +22,26 @@ export class CategoriesService {
private readonly apiRepo: Repository<ApiEntryEntity>,
) {}
async findAll(): Promise<CategoryListResponse> {
async findAll(search?: string): Promise<CategoryListResponse> {
if (search?.trim()) {
const text = search.trim();
const entities = await this.categoryRepo
.createQueryBuilder('cat')
.where(
`(
cat.name ILIKE :likeText
OR COALESCE(cat.description, '') ILIKE :likeText
OR word_similarity(:text, cat.name) >= 0.4
OR word_similarity(:text, COALESCE(cat.description, '')) > 0.5
)`,
{ likeText: `%${text}%`, text },
)
.orderBy('cat.order_index', 'ASC')
.addOrderBy('cat.name', 'ASC')
.getMany();
return { items: entities.map(toCategory) };
}
const entities = await this.categoryRepo.find({
order: { orderIndex: 'ASC', name: 'ASC' },
});
@@ -81,6 +98,13 @@ export class CategoriesService {
await this.categoryRepo.delete(id);
}
private async countApisRecursively(categoryId: string): Promise<number> {
const direct = await this.apiRepo.count({ where: { categoryId } });
const children = await this.categoryRepo.find({ where: { parentId: categoryId }, select: ['id'] });
const childCounts = await Promise.all(children.map((c) => this.countApisRecursively(c.id)));
return direct + childCounts.reduce((sum, n) => sum + n, 0);
}
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
/* 1. Charge la catégorie courante */
let currentCategory: CategoryEntity | null = null;
@@ -111,7 +135,7 @@ export class CategoriesService {
const subcategories: CategoryWithCounts[] = await Promise.all(
directChildren.map(async (child) => {
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
const apiCount = await this.apiRepo.count({ where: { categoryId: child.id } });
const apiCount = await this.countApisRecursively(child.id);
return { ...toCategory(child), subcategoryCount, apiCount };
}),
);
@@ -143,16 +167,3 @@ function toCategory(entity: CategoryEntity): Category {
};
}
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: entity.title,
version: entity.version,
description: entity.description,
type: entity.type as ApiType,
status: entity.status as ApiStatus,
categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}

View File

@@ -3,13 +3,22 @@ import { execFile } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import * as path from 'path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const AsyncApiGenerator = require('@asyncapi/generator');
import { ApisService } from '../apis/apis.service';
const execFileAsync = promisify(execFile);
/* Ajoute node_modules/.bin au PATH pour trouver redocly en local */
const localBin = path.join(process.cwd(), 'node_modules', '.bin');
const execEnv = {
...process.env,
PATH: `${localBin}${path.delimiter}${process.env.PATH ?? ''}`,
};
@Injectable()
export class DocsGenerationService {
private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? './docs-output';
constructor(
@Inject(forwardRef(() => ApisService))
@@ -37,21 +46,16 @@ export class DocsGenerationService {
} catch (error) {
await this.apisService.updateInternal(entryId, {
status: 'ERROR',
errorMessage: String(error),
errorMessage: error instanceof Error ? error.message : String(error),
});
}
}
private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
await execFileAsync('asyncapi', [
'generate', 'fromTemplate',
yamlPath,
'@asyncapi/html-template',
'--output', outputDir,
'--no-interactive',
'--install',
'--force-write',
]);
/* API programmatique — aucun appel réseau, template résolu localement */
const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
await generator.generateFromFile(yamlPath);
}
private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
@@ -59,6 +63,6 @@ export class DocsGenerationService {
'build-docs',
yamlPath,
'--output', path.join(outputDir, 'index.html'),
]);
], { shell: true, env: execEnv });
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, DataSource } from 'typeorm';
import * as fs from 'fs/promises';
import { ApiEntryEntity } from '../apis/api-entry.entity';
import { CategoryEntity } from '../categories/category.entity';
@@ -593,18 +593,53 @@ export class SeederService implements OnApplicationBootstrap {
@InjectRepository(CategoryEntity)
private readonly categoryRepo: Repository<CategoryEntity>,
private readonly docsService: DocsGenerationService,
private readonly dataSource: DataSource,
) {}
async onApplicationBootstrap(): Promise<void> {
// Activer l'extension pg_trgm pour la recherche fuzzy
await this.dataSource.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
const forceReseed = process.env.FORCE_RESEED === 'true';
if (forceReseed) {
await this.reset();
}
const count = await this.apiRepo.count();
if (count > 0) return; /* Idempotent — ne ré-exécute pas si des données existent */
if (count > 0) {
await this.fixIsCurrentFlags();
return;
}
await this.seed();
}
/**
* Corrige les flags is_current après une migration :
* si toutes les versions d'un même groupe (name+convention) sont marquées courantes,
* on ne garde que la plus haute version.
*/
private async fixIsCurrentFlags(): Promise<void> {
await this.dataSource.query(`
UPDATE api_entries SET is_current = false
WHERE id IN (
SELECT a.id
FROM api_entries a
WHERE a.is_current = true
AND EXISTS (
SELECT 1 FROM api_entries b
WHERE b.name = a.name AND b.convention = a.convention
AND b.is_current = true
AND b.id != a.id
AND (
b.version_major > a.version_major
OR (b.version_major = a.version_major AND b.version_minor > a.version_minor)
OR (b.version_major = a.version_major AND b.version_minor = a.version_minor
AND b.version_patch > a.version_patch)
)
)
)
`);
}
private async reset(): Promise<void> {
/* Supprimer toutes les entrées puis les catégories */
await this.apiRepo.delete({});
@@ -688,85 +723,148 @@ export class SeederService implements OnApplicationBootstrap {
/* APIs de démo */
const entries = await this.apiRepo.save([
this.apiRepo.create({
title: 'Swagger Petstore',
version: '3.0.4',
convention: 'CONSULTER',
name: 'Référentiel Produits',
versionMajor: 3,
versionMinor: 0,
versionPatch: 4,
isCurrent: true,
description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI',
yamlContent: PETSTORE_YAML,
status: 'PENDING',
categoryId: produits.id,
functionalDoc: 'Cas d\'usage : gestion du catalogue produits. Permet de lister, créer et consulter des animaux de compagnie. Usage typique : intégration e-commerce, démo API REST.',
technicalDoc: 'Authentification Bearer token requise. Pagination via paramètre `limit` (max 100). Réponses JSON selon schéma OpenAPI 3.0. Endpoint base : https://petstore.swagger.io/v3.',
externalDocUrl: null,
contactFunctional: 'Équipe Produit',
contactTechnical: 'Équipe API',
accessRights: 'API publique — aucune habilitation requise.',
provider: 'DINUM',
contactFunctionalName: 'Alice Martin',
contactFunctionalEntity: 'Équipe Produit',
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
contactTechnicalName: 'Bob Dupont',
contactTechnicalEntity: 'Équipe API',
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
}),
this.apiRepo.create({
title: 'Streetlights Kafka API',
version: '1.0.0',
convention: 'ETRE_NOTIFIE',
name: 'Éclairage Urbain Kafka',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description:
'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.',
type: 'ASYNCAPI',
yamlContent: STREETLIGHTS_YAML,
status: 'PENDING',
categoryId: evenements.id,
functionalDoc: 'Gestion de l\'éclairage urbain IoT. Permet de recevoir les mesures de luminosité des lampadaires et d\'envoyer des commandes d\'allumage/extinction à distance.',
technicalDoc: 'Broker Kafka sécurisé avec SCRAM-SHA-512. Topics : smartylighting.streetlights.1.0.*. Schémas de messages définis en AsyncAPI 3.0. Adresse broker : test.mykafkacluster.org:18092.',
externalDocUrl: null,
contactFunctional: 'Bureau Infrastructure',
contactTechnical: 'ops@example.com',
accessRights: 'Accès sur demande — habilitation DSI requise.',
provider: 'Bureau Infrastructure',
contactFunctionalName: 'Claire Leroy',
contactFunctionalEntity: 'Bureau Infrastructure',
contactFunctionalEmail: 'claire.leroy@example.gouv.fr',
contactTechnicalName: 'David Chen',
contactTechnicalEntity: 'Ops',
contactTechnicalEmail: 'ops@example.com',
}),
this.apiRepo.create({
title: 'Account Service',
version: '1.0.0',
convention: 'ETRE_NOTIFIE',
name: 'Comptes Utilisateurs',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: "Service de gestion des comptes utilisateurs avec publication d'événements.",
type: 'ASYNCAPI',
yamlContent: ACCOUNT_SERVICE_YAML,
status: 'PENDING',
categoryId: notifications.id,
functionalDoc: 'Cycle de vie du compte utilisateur. Publie des événements lors de l\'inscription et du changement de mot de passe. Utilisé par les services de notification et d\'audit.',
technicalDoc: 'Consommer via broker AMQP ou Kafka. Canaux : user/signedup, user/passwordchanged. Payloads JSON avec horodatage ISO 8601. Spec AsyncAPI 3.0.',
externalDocUrl: null,
contactFunctional: 'Équipe Comptes',
contactTechnical: 'dev-core@example.com',
accessRights: 'Interne uniquement — accès réservé aux services du SI.',
provider: 'Équipe Comptes',
contactFunctionalName: 'Emma Roux',
contactFunctionalEntity: 'Équipe Comptes',
contactFunctionalEmail: 'emma.roux@example.gouv.fr',
contactTechnicalName: 'François Blanc',
contactTechnicalEntity: 'Dev Core',
contactTechnicalEmail: 'dev-core@example.com',
}),
this.apiRepo.create({
title: 'Orders API',
version: '1.0.0',
convention: 'ENREGISTRER',
name: 'Commandes Clients',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: 'API de gestion des commandes clients.',
type: 'OPENAPI',
yamlContent: ORDERS_YAML,
status: 'PENDING',
categoryId: commandes.id,
functionalDoc: 'Gestion des commandes clients : création, consultation, mise à jour du statut. Supporte les webhooks pour les transitions de statut (confirmed, shipped, delivered, cancelled).',
technicalDoc: 'REST + JWT Bearer. Pagination via paramètres `page` et `limit`. Webhook de statut : POST vers URL configurée lors de la transition. Base URL : https://api.example.com/v1.',
externalDocUrl: null,
contactFunctional: 'Équipe Commerce',
contactTechnical: 'api-orders@example.com',
accessRights: 'Partenaires agréés uniquement — convention de partenariat requise.',
provider: 'Équipe Commerce',
contactFunctionalName: 'Gaëlle Moreau',
contactFunctionalEntity: 'Équipe Commerce',
contactFunctionalEmail: 'gaelle.moreau@example.gouv.fr',
contactTechnicalName: 'Hugo Simon',
contactTechnicalEntity: 'API Commerce',
contactTechnicalEmail: 'api-orders@example.com',
}),
this.apiRepo.create({
title: 'Auth API',
version: '1.0.0',
convention: 'CONSULTER',
name: 'Référentiel Produits',
versionMajor: 3,
versionMinor: 0,
versionPatch: 3,
isCurrent: false,
description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI',
yamlContent: PETSTORE_YAML,
status: 'PENDING',
categoryId: produits.id,
provider: 'DINUM',
contactFunctionalName: 'Alice Martin',
contactFunctionalEntity: 'Équipe Produit',
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
contactTechnicalName: 'Bob Dupont',
contactTechnicalEntity: 'Équipe API',
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
}),
this.apiRepo.create({
convention: 'CONSULTER',
name: 'Référentiel Produits',
versionMajor: 3,
versionMinor: 0,
versionPatch: 2,
isCurrent: false,
description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI',
yamlContent: PETSTORE_YAML,
status: 'PENDING',
categoryId: produits.id,
provider: 'DINUM',
contactFunctionalName: 'Alice Martin',
contactFunctionalEntity: 'Équipe Produit',
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
contactTechnicalName: 'Bob Dupont',
contactTechnicalEntity: 'Équipe API',
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
}),
this.apiRepo.create({
convention: 'CONSULTER',
name: 'Authentification JWT',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: "Service d'authentification et d'autorisation.",
type: 'OPENAPI',
yamlContent: AUTH_YAML,
status: 'PENDING',
categoryId: auth.id,
functionalDoc: 'SSO centralisé avec tokens JWT. Gère la connexion, le rafraîchissement de token et la déconnexion. Exposé à tous les services du SI comme fournisseur d\'identité.',
technicalDoc: 'Bearer JWT (RS256). Endpoint principal : POST /auth/login. Refresh via POST /auth/refresh. Durée access token : 15 min. Durée refresh token : 7 jours. Base URL : https://auth.example.com/v1.',
externalDocUrl: null,
contactFunctional: 'Équipe Sécurité',
contactTechnical: 'securite@example.com',
accessRights: 'Restreint — habilitation RSSI requise avant intégration.',
provider: 'Équipe Sécurité',
contactFunctionalName: 'Isabelle Petit',
contactFunctionalEntity: 'Équipe Sécurité',
contactFunctionalEmail: 'isabelle.petit@example.gouv.fr',
contactTechnicalName: 'Julien Bernard',
contactTechnicalEntity: 'Sécurité SI',
contactTechnicalEmail: 'securite@example.com',
}),
]);
console.log('[Seeder] 8 catégories et 5 APIs de démo créées — génération docs en cours...');
console.log('[Seeder] 8 catégories et 7 APIs de démo créées — génération docs en cours...');
/* Déclenche la génération de docs en fire-and-forget pour chaque API */
for (const entry of entries) {

View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import { UploadsController } from './uploads.controller';
/** Accès à la méthode privée sans instancier le contrôleur complet */
function extract(yamlContent: string) {
return (UploadsController.prototype as any).extractMetadata.call({}, yamlContent);
}
describe('UploadsController.extractMetadata()', () => {
describe('version OpenAPI standard', () => {
it('"1.2.3" → major=1, minor=2, patch=3', () => {
const result = extract('info:\n title: Test\n version: "1.2.3"');
expect(result.versionMajor).toBe(1);
expect(result.versionMinor).toBe(2);
expect(result.versionPatch).toBe(3);
});
it('"1.0" → major=1, minor=0, patch=null', () => {
const result = extract('info:\n title: Test\n version: "1.0"');
expect(result.versionMajor).toBe(1);
expect(result.versionMinor).toBe(0);
expect(result.versionPatch).toBeNull();
});
it('"2" → major=2, minor=null, patch=null', () => {
const result = extract('info:\n title: Test\n version: "2"');
expect(result.versionMajor).toBe(2);
expect(result.versionMinor).toBeNull();
expect(result.versionPatch).toBeNull();
});
it('"0.0.0" → major=0, minor=0, patch=0', () => {
const result = extract('info:\n title: Test\n version: "0.0.0"');
expect(result.versionMajor).toBe(0);
expect(result.versionMinor).toBe(0);
expect(result.versionPatch).toBe(0);
});
});
describe('extraction du nom et description', () => {
it('title présent → name extrait', () => {
const result = extract('info:\n title: Mon API\n version: "1.0.0"');
expect(result.name).toBe('Mon API');
});
it('title absent → name=null', () => {
const result = extract('info:\n version: "1.0.0"');
expect(result.name).toBeNull();
});
it('description présente → description extraite', () => {
const result = extract('info:\n title: Test\n version: "1.0.0"\n description: Une description');
expect(result.description).toBe('Une description');
});
it('description absente → description=null', () => {
const result = extract('info:\n title: Test\n version: "1.0.0"');
expect(result.description).toBeNull();
});
});
describe('cas limite de version', () => {
it('version non numérique ("alpha") → major=null, minor=null, patch=null', () => {
const result = extract('info:\n title: Test\n version: alpha');
expect(result.versionMajor).toBeNull();
expect(result.versionMinor).toBeNull();
expect(result.versionPatch).toBeNull();
});
it('version absente → tout null', () => {
const result = extract('info:\n title: Test');
expect(result.versionMajor).toBeNull();
expect(result.versionMinor).toBeNull();
expect(result.versionPatch).toBeNull();
});
it('version vide string "" → tout null', () => {
const result = extract('info:\n title: Test\n version: ""');
expect(result.versionMajor).toBeNull();
expect(result.versionMinor).toBeNull();
expect(result.versionPatch).toBeNull();
});
it('info absent → tout null', () => {
const result = extract('asyncapi: "3.0.0"');
expect(result.name).toBeNull();
expect(result.description).toBeNull();
expect(result.versionMajor).toBeNull();
expect(result.versionMinor).toBeNull();
expect(result.versionPatch).toBeNull();
});
});
});

View File

@@ -5,32 +5,166 @@ import {
UploadedFiles,
Body,
BadRequestException,
Logger,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import * as fs from 'fs';
import * as fsPromises from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import * as yaml from 'js-yaml';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { randomUUID } from 'crypto';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const asyncapiBundle = require('@asyncapi/bundler');
const execFileAsync = promisify(execFile);
const localBin = path.join(process.cwd(), 'node_modules', '.bin');
const execEnv = {
...process.env,
PATH: `${localBin}${path.delimiter}${process.env.PATH ?? ''}`,
};
import { ApisService } from '../apis/apis.service';
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
import { ApiType } from '@datacat/shared';
const execFileAsync = promisify(execFile);
@Controller('uploads')
export class UploadsController {
private readonly logger = new Logger(UploadsController.name);
constructor(
private readonly apisService: ApisService,
private readonly docsService: DocsGenerationService,
) {}
@Post('validate')
@UseInterceptors(
FilesInterceptor('files', 20, {
storage: diskStorage({ destination: path.join(os.tmpdir(), 'datacat-uploads') }),
fileFilter: (_req, file, cb) => {
if (!file.originalname.match(/\.(yaml|yml)$/i)) {
return cb(new BadRequestException('Only YAML files are allowed'), false);
}
cb(null, true);
},
}),
)
async validate(
@UploadedFiles() files: Express.Multer.File[],
): Promise<{
valid: boolean;
type: ApiType | null;
mainFile: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}> {
if (!files || files.length === 0) {
return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors: [{ file: '', message: 'Aucun fichier fourni.' }] };
}
/* Lire tous les contenus en mémoire avant toute suppression */
const fileContents = files.map((file) => ({
name: file.originalname,
path: file.path,
content: fs.readFileSync(file.path, 'utf-8'),
}));
/* Nettoyage immédiat des fichiers temporaires */
for (const file of files) {
fsPromises.unlink(file.path).catch(() => undefined);
}
const errors: Array<{ file: string; message: string }> = [];
/* Validation syntaxique de chaque fichier */
for (const { name, content } of fileContents) {
try {
const parsed = yaml.load(content);
if (parsed === null || typeof parsed !== 'object') {
errors.push({ file: name, message: 'le fichier YAML est vide ou invalide' });
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push({ file: name, message: `erreur de syntaxe YAML — ${msg}` });
}
}
if (errors.length > 0) {
return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors };
}
/* Détection du fichier principal */
let mainFileName: string | null = null;
let detectedType: ApiType | null = null;
for (const { name, content } of fileContents) {
const type = this.detectTypeFromContent(content);
if (type) {
mainFileName = name;
detectedType = type;
break;
}
}
if (!mainFileName) {
return {
valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
errors: [{ file: '', message: 'Aucun fichier principal trouvé (clé openapi: ou asyncapi: manquante).' }],
};
}
/* Validation complète via CLI (asyncapi validate / redocly lint) */
const tempDir = path.join(os.tmpdir(), 'datacat-validate', randomUUID());
try {
await fsPromises.mkdir(tempDir, { recursive: true });
/* Écrire tous les fichiers dans le répertoire temp avec leurs noms d'origine (pour les $ref) */
for (const { name, content } of fileContents) {
await fsPromises.writeFile(path.join(tempDir, name), content, 'utf-8');
}
const mainPath = path.join(tempDir, mainFileName);
try {
if (detectedType === 'ASYNCAPI') {
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, shell: true, timeout: 30000 });
} else {
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, shell: true, timeout: 30000 });
}
} catch (cliError) {
const err = cliError as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
/* CLI non installé (dev local sans Docker) → skip la validation complète */
if (err.code !== 'ENOENT') {
const output = (err.stdout || err.stderr || String(cliError)).trim();
return {
valid: false, type: detectedType, mainFile: mainFileName,
name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
errors: [{ file: mainFileName, message: output }],
};
}
this.logger.warn(`CLI ${detectedType === 'ASYNCAPI' ? 'asyncapi' : 'redocly'} non trouvé — validation CLI ignorée`);
}
} finally {
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
/* Extraction des métadonnées depuis le fichier principal */
const mainContent = fileContents.find((f) => f.name === mainFileName)!.content;
const { name, description, versionMajor, versionMinor, versionPatch } = this.extractMetadata(mainContent);
return { valid: true, type: detectedType, mainFile: mainFileName, name, versionMajor, versionMinor, versionPatch, description, errors: [] };
}
@Post()
@UseInterceptors(
FilesInterceptor('files', 20, {
storage: diskStorage({ destination: '/tmp/uploads' }),
storage: diskStorage({ destination: path.join(os.tmpdir(), 'datacat-uploads') }),
fileFilter: (_req, file, cb) => {
if (!file.originalname.match(/\.(yaml|yml)$/i)) {
return cb(new BadRequestException('Only YAML files are allowed'), false);
@@ -43,39 +177,52 @@ export class UploadsController {
@UploadedFiles() files: Express.Multer.File[],
@Body()
body: {
title: string;
version: string;
convention: string;
name: string;
provider: string;
versionMajor: string;
versionMinor: string;
versionPatch: string;
description?: string;
type?: ApiType;
categoryId?: string;
functionalDoc?: string;
technicalDoc?: string;
externalDocUrl?: string;
contactFunctional?: string;
contactTechnical?: string;
accessRights?: string;
contactFunctionalName: string;
contactFunctionalEntity: string;
contactFunctionalEmail: string;
contactTechnicalName: string;
contactTechnicalEntity: string;
contactTechnicalEmail: string;
},
) {
if (!files || files.length === 0) throw new BadRequestException('YAML file is required');
if (!body.title) throw new BadRequestException('title is required');
if (!body.version) throw new BadRequestException('version is required');
if (!body.name) throw new BadRequestException('name is required');
if (!body.convention) throw new BadRequestException('convention is required');
/* Crée le répertoire si absent */
if (!fs.existsSync('/tmp/uploads')) {
fs.mkdirSync('/tmp/uploads', { recursive: true });
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
let yamlContent: string;
let detectedType: ApiType | null = null;
if (files.length === 1) {
/* Cas simple : un seul fichier, comportement identique à l'ancien */
if (!files || files.length === 0) {
/* Pas de fichier : entrée créée sans YAML (YAML pourra être ajouté plus tard) */
yamlContent = '';
} else if (files.length === 1) {
/* Cas simple : un seul fichier */
yamlContent = fs.readFileSync(files[0].path, 'utf-8');
/* Validation syntaxique YAML */
this.validateYamlSyntax(yamlContent, files[0].originalname);
detectedType = this.detectTypeFromContent(yamlContent);
} else {
/* Cas multi-fichiers : détecter le fichier principal et bundler */
const { mainFile, type } = this.detectMainFile(files);
detectedType = type;
this.logger.log(`Multi-file upload: main=${mainFile.originalname}, type=${type}, total=${files.length} files`);
yamlContent = await this.bundleFiles(files, mainFile, type);
}
@@ -86,26 +233,74 @@ export class UploadsController {
}
const entry = await this.apisService.create({
title: body.title,
version: body.version,
convention: (body.convention as 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE') ?? 'CONSULTER',
name: body.name,
provider: body.provider,
versionMajor: parseInt(body.versionMajor, 10) || 0,
versionMinor: parseInt(body.versionMinor, 10) || 0,
versionPatch: parseInt(body.versionPatch, 10) || 0,
description: body.description,
type: apiType,
yamlContent,
categoryId: body.categoryId || undefined,
functionalDoc: body.functionalDoc || undefined,
technicalDoc: body.technicalDoc || undefined,
externalDocUrl: body.externalDocUrl || undefined,
contactFunctional: body.contactFunctional || undefined,
contactTechnical: body.contactTechnical || undefined,
accessRights: body.accessRights || undefined,
contactFunctionalName: body.contactFunctionalName,
contactFunctionalEntity: body.contactFunctionalEntity,
contactFunctionalEmail: body.contactFunctionalEmail,
contactTechnicalName: body.contactTechnicalName,
contactTechnicalEntity: body.contactTechnicalEntity,
contactTechnicalEmail: body.contactTechnicalEmail,
});
/* Fire-and-forget : génération asynchrone */
this.docsService.generate(entry.id).catch(console.error);
/* Fire-and-forget : génération asynchrone (seulement si YAML fourni) */
if (yamlContent) {
this.docsService.generate(entry.id).catch(console.error);
}
return entry;
}
/** Extrait les métadonnées (titre, description, version) depuis le contenu YAML */
private extractMetadata(yamlContent: string): {
name: string | null;
description: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
} {
const parsed = yaml.load(yamlContent) as Record<string, unknown>;
const info = parsed['info'] as Record<string, unknown> | undefined;
const name = typeof info?.['title'] === 'string' ? info['title'] : null;
const description = typeof info?.['description'] === 'string' ? info['description'] : null;
let versionMajor: number | null = null;
let versionMinor: number | null = null;
let versionPatch: number | null = null;
const versionStr = typeof info?.['version'] === 'string' ? info['version'] : null;
if (versionStr) {
const parts = versionStr.split('.').map((p) => parseInt(p, 10));
if (parts.length >= 1 && !isNaN(parts[0])) versionMajor = parts[0];
if (parts.length >= 2 && !isNaN(parts[1])) versionMinor = parts[1];
if (parts.length >= 3 && !isNaN(parts[2])) versionPatch = parts[2];
}
return { name, description, versionMajor, versionMinor, versionPatch };
}
/** Valide la syntaxe YAML et lève une BadRequestException si invalide */
private validateYamlSyntax(content: string, filename: string): void {
try {
const parsed = yaml.load(content);
if (parsed === null || typeof parsed !== 'object') {
throw new BadRequestException(`${filename}: le fichier YAML est vide ou invalide`);
}
} catch (err) {
if (err instanceof BadRequestException) throw err;
const msg = err instanceof Error ? err.message : String(err);
this.logger.warn(`Validation YAML échouée pour ${filename}: ${msg}`);
throw new BadRequestException(`${filename}: erreur de syntaxe YAML — ${msg}`);
}
}
private detectTypeFromContent(content: string): ApiType | null {
/* Cherche la clé racine openapi: ou asyncapi: sans dépendance à js-yaml */
if (/^openapi\s*:/m.test(content)) return 'OPENAPI';
@@ -131,7 +326,7 @@ export class UploadsController {
mainFile: Express.Multer.File,
type: ApiType,
): Promise<string> {
const tempDir = `/tmp/uploads/${randomUUID()}`;
const tempDir = path.join(os.tmpdir(), 'datacat-uploads', randomUUID());
const bundledPath = path.join(tempDir, 'bundled.yaml');
try {
@@ -145,12 +340,15 @@ export class UploadsController {
const mainPath = path.join(tempDir, mainFile.originalname);
if (type === 'OPENAPI') {
await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath]);
/* redocly bundle : pas d'appel réseau, fonctionne en local */
await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath], { env: execEnv });
return await fsPromises.readFile(bundledPath, 'utf-8');
} else {
await execFileAsync('asyncapi', ['bundle', mainPath, '--output', bundledPath]);
/* @asyncapi/bundler API programmatique : aucun appel réseau */
const bundleFn = asyncapiBundle.default ?? asyncapiBundle;
const document = await bundleFn([mainPath], { base: mainPath });
return document.string();
}
return await fsPromises.readFile(bundledPath, 'utf-8');
} finally {
/* Nettoyage du répertoire temp même en cas d'erreur */
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);

View File

@@ -10,10 +10,9 @@
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "../",
"baseUrl": "./",
"incremental": true,
"paths": {
"@datacat/shared": ["../shared/src/index.ts"]
}
}
"incremental": true
},
"include": ["src/**/*.ts"]
}

9
back/vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'],
},
});

View File

@@ -3,7 +3,7 @@
## 1. Purpose & Architecture
**Datacat** est un catalogue de données d'API. Il permet d'importer des fichiers YAML
(AsyncAPI ou OpenAPI) et de les transformer en documentation HTML via des CLI spécialisées.
(AsyncAPI ou OpenAPI) et de les transformer en documentation HTML.
```
datacat/
@@ -19,8 +19,8 @@ datacat/
User uploads YAML → POST /api/uploads
→ ApiEntry created (status: PENDING)
→ DocsGenerationService.generate()
→ asyncapi generate fromFile input.yaml --output /docs-output/<id>/
→ OR redocly build-docs input.yaml --output /docs-output/<id>/index.html
AsyncAPI : API programmatique @asyncapi/generator (html-template local)
→ OpenAPI : redocly build-docs input.yaml --output /docs-output/<id>/index.html
→ ApiEntry updated (status: GENERATED, htmlPath: /docs-output/<id>/index.html)
→ GET /api/apis/:id/docs → sendFile(htmlPath)
```
@@ -31,17 +31,15 @@ User uploads YAML → POST /api/uploads
```bash
# Tout en Docker (recommandé)
pnpm run dev # docker compose up
pnpm run dev:build # docker compose up --build
docker compose -f infra/docker-compose.yml up -d
docker compose -f infra/docker-compose.yml up -d --build # avec rebuild
# Local (sans Docker)
cd back && pnpm run start:dev # NestJS watch mode — port 3000
cd back && pnpm run start:dev # NestJS watch mode — port 3000 interne / 3010 hôte
cd front-public && pnpm run start # Angular dev server — port 4200
# Install deps
cd back && pnpm install
cd front-public && pnpm install
cd shared && pnpm install
# Install deps (depuis la racine workspace)
pnpm install
# Build prod
cd back && pnpm run build
@@ -49,11 +47,21 @@ cd front-public && pnpm run build:prod
```
### Proxy Angular → API
Créer `front-public/proxy.conf.json` :
`front-public/proxy.conf.json` (développement local) :
```json
{
"/api": {
"target": "http://localhost:3000",
"target": "http://localhost:3010",
"secure": false
}
}
```
`front-public/proxy.conf.docker.json` (dans Docker) :
```json
{
"/api": {
"target": "http://backend:3000",
"secure": false
}
}
@@ -71,9 +79,10 @@ Créer `front-public/proxy.conf.json` :
| TypeORM | ^0.3.20 |
| PostgreSQL | 17 |
| Angular | ^19.0.0 |
| TypeScript | ^5.7.0 |
| @asyncapi/cli | latest (in Docker) |
| TypeScript | ~5.8.3 |
| @asyncapi/cli | 6.0.0 (in Docker) |
| @redocly/cli | latest (in Docker) |
| @gouvfr/dsfr | ^1.13.0 (npm devDep front-public) |
### Conventions TypeScript
- Pas de `any` — utiliser les types de `@datacat/shared`
@@ -88,61 +97,110 @@ Créer `front-public/proxy.conf.json` :
```sql
CREATE TABLE api_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
version VARCHAR(50) NOT NULL,
description TEXT,
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
yaml_content TEXT NOT NULL,
html_path VARCHAR(500),
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
convention VARCHAR(30) NOT NULL DEFAULT 'CONSULTER'
CHECK (convention IN ('CONSULTER','ENREGISTRER','ETRE_NOTIFIE')),
name VARCHAR(255) NOT NULL DEFAULT '',
provider VARCHAR(255) NOT NULL DEFAULT '',
version_major INT NOT NULL DEFAULT 0,
version_minor INT NOT NULL DEFAULT 0,
version_patch INT NOT NULL DEFAULT 0,
description TEXT,
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
yaml_content TEXT NOT NULL,
html_path VARCHAR(500),
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
error_message TEXT,
category_id VARCHAR(36),
contact_functional_name VARCHAR(255) NOT NULL DEFAULT '',
contact_functional_entity VARCHAR(255) NOT NULL DEFAULT '',
contact_functional_email VARCHAR(255) NOT NULL DEFAULT '',
contact_technical_name VARCHAR(255) NOT NULL DEFAULT '',
contact_technical_entity VARCHAR(255) NOT NULL DEFAULT '',
contact_technical_email VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
> `title` et `version` sont des champs **calculés** (pas en DB) :
> - `title` = `CONVENTION_LABEL + ' ' + name` (ex: `"Consulter Petstore"`)
> - `version` = `"major.minor.patch"` (ex: `"1.2.0"`)
### Entity TypeORM (`back/src/modules/apis/api-entry.entity.ts`)
```typescript
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { ApiType, ApiStatus } from '@datacat/shared';
import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared';
@Entity('api_entries')
export class ApiEntryEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
id!: string;
@Column()
title: string;
@Column({ type: 'varchar', length: 30, default: 'CONSULTER' })
convention!: ApiConvention;
@Column()
version: string;
@Column({ type: 'varchar', length: 255, default: '' })
name!: string;
@Column({ nullable: true, type: 'text' })
description: string | null;
description!: string | null;
@Column({ type: 'varchar', length: 10 })
type: ApiType;
type!: ApiType;
@Column({ type: 'varchar', length: 255, default: '', name: 'provider' })
provider!: string;
@Column({ type: 'int', default: 0, name: 'version_major' })
versionMajor!: number;
@Column({ type: 'int', default: 0, name: 'version_minor' })
versionMinor!: number;
@Column({ type: 'int', default: 0, name: 'version_patch' })
versionPatch!: number;
@Column({ type: 'text', name: 'yaml_content' })
yamlContent: string;
yamlContent!: string;
@Column({ nullable: true, name: 'html_path' })
htmlPath: string | null;
@Column({ nullable: true, type: 'varchar', length: 500, name: 'html_path' })
htmlPath!: string | null;
@Column({ type: 'varchar', length: 10, default: 'PENDING' })
status: ApiStatus;
status!: ApiStatus;
@Column({ nullable: true, type: 'text', name: 'error_message' })
errorMessage: string | null;
errorMessage!: string | null;
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
categoryId!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
contactFunctionalName!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_entity' })
contactFunctionalEntity!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_email' })
contactFunctionalEmail!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_name' })
contactTechnicalName!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_entity' })
contactTechnicalEntity!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_email' })
contactTechnicalEmail!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
updatedAt!: Date;
}
```
@@ -161,40 +219,60 @@ src/
│ │ ├── apis.module.ts
│ │ ├── apis.controller.ts # REST endpoints
│ │ ├── apis.service.ts # CRUD logique
│ │ ── api-entry.entity.ts # TypeORM entity
│ │ ── api-entry.entity.ts # TypeORM entity
│ │ ├── api-entry.mapper.ts # entity → DTO (calcul title/version)
│ │ └── dto/
│ │ ├── create-api-entry.dto.ts
│ │ └── update-api-entry.dto.ts
│ ├── uploads/
│ │ ├── uploads.module.ts
│ │ └── uploads.controller.ts # POST /api/uploads (multipart)
── docs-generation/
├── docs-generation.module.ts
└── docs-generation.service.ts # Appels AsyncAPI/Redocly CLI
├── database/
── database.module.ts # TypeORM config (si séparé)
│ │ └── uploads.controller.ts # POST /api/uploads + POST /api/uploads/validate
── docs-generation/
├── docs-generation.module.ts
└── docs-generation.service.ts # API programmatique @asyncapi/generator + redocly CLI
├── categories/
│ ├── categories.module.ts
│ │ ├── categories.controller.ts
│ │ ├── categories.service.ts
│ │ └── category.entity.ts
│ └── seeder/
│ ├── seeder.module.ts
│ └── seeder.service.ts # Données de démo (idempotent, OnApplicationBootstrap)
├── health/
│ ├── health.module.ts
│ └── health.controller.ts
└── common/
── filters/
└── all-exceptions.filter.ts
└── interceptors/
└── transform.interceptor.ts
── filters/
└── all-exceptions.filter.ts
```
### API REST
```
GET /health → { status: 'ok' }
GET /api/apis → ApiListResponse (+ ?search=&type=&page=&limit=)
GET /api/apis/:id → ApiEntry
POST /api/uploads → ApiEntry (multipart: file + title + version + description + type)
PATCH /api/apis/:id → ApiEntry (body: UpdateApiEntryDto)
DELETE /api/apis/:id → 204 No Content
POST /api/apis/:id/regenerate → ApiEntry (relance la génération de docs)
GET /api/apis/:id/docssendFile(htmlPath) ou 404 si pas encore généré
GET /api/apis/:id/yaml → download du YAML brut (Content-Disposition: attachment)
GET /health → { status: 'ok' }
GET /api/apis → ApiListResponse (+ ?search=&type=&categoryId=&page=&limit=)
GET /api/apis/:id → ApiEntry
PATCH /api/apis/:id → ApiEntry (body: UpdateApiEntryDto)
DELETE /api/apis/:id 204 No Content
POST /api/apis/:id/regenerate → ApiEntry (relance la génération de docs)
GET /api/apis/:id/docs → sendFile(htmlPath) ou 404 si pas encore généré
GET /api/apis/:id/*pathassets générés (CSS/JS référencés par index.html)
GET /api/apis/:id/yaml → download du YAML brut (Content-Disposition: attachment)
POST /api/uploads → ApiEntry (multipart: files 1..20 + métadonnées)
POST /api/uploads/validate → résultat de validation (sans sauvegarde)
GET /api/categories → liste flat toutes catégories
GET /api/categories/browse → navigation racine (sous-catégories + APIs sans catégorie)
GET /api/categories/browse/:id → navigation dans une catégorie
GET /api/categories/:id → détail catégorie
POST /api/categories → créer catégorie
PATCH /api/categories/:id → modifier catégorie
DELETE /api/categories/:id → supprimer (400 si non vide)
```
### ApisController
```typescript
import { Controller, Get, Patch, Delete, Post, Param, Body, Query, Res, NotFoundException } from '@nestjs/common';
import { Controller, Get, Patch, Delete, Post, Param, Body, Query, Res, NotFoundException, HttpCode } from '@nestjs/common';
import { Response } from 'express';
@Controller('apis')
@@ -237,7 +315,7 @@ export class ApisController {
@Get(':id/yaml')
async getYaml(@Param('id') id: string, @Res() res: Response) {
const entry = await this.apisService.findOneOrThrow(id);
res.setHeader('Content-Disposition', `attachment; filename="${entry.title}.yaml"`);
res.setHeader('Content-Disposition', `attachment; filename="${entry.name}.yaml"`);
res.setHeader('Content-Type', 'application/x-yaml');
res.send(entry.yamlContent);
}
@@ -247,8 +325,8 @@ export class ApisController {
### UploadsController
```typescript
import { Controller, Post, UseInterceptors, UploadedFile, Body } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Controller, Post, UseInterceptors, UploadedFiles, Body, BadRequestException } from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
@Controller('uploads')
@@ -258,22 +336,38 @@ export class UploadsController {
private readonly docsService: DocsGenerationService,
) {}
// POST /api/uploads/validate — valide sans sauvegarder
@Post('validate')
@UseInterceptors(FilesInterceptor('files', 20, { /* diskStorage + fileFilter yaml */ }))
async validate(@UploadedFiles() files: Express.Multer.File[]) {
// Retourne { valid, type, mainFile, name, versionMajor, versionMinor, versionPatch, description, errors }
}
// POST /api/uploads — crée l'entrée + lance la génération
@Post()
@UseInterceptors(FileInterceptor('file', {
storage: diskStorage({ destination: '/tmp/uploads' }),
fileFilter: (req, file, cb) => {
if (!file.originalname.match(/\.(yaml|yml)$/)) {
return cb(new Error('Only YAML files are allowed'), false);
}
cb(null, true);
},
}))
@UseInterceptors(FilesInterceptor('files', 20, { /* diskStorage + fileFilter yaml */ }))
async upload(
@UploadedFile() file: Express.Multer.File,
@Body() body: { title: string; version: string; description?: string; type: 'ASYNCAPI' | 'OPENAPI' },
@UploadedFiles() files: Express.Multer.File[],
@Body() body: {
convention: string;
name: string;
provider: string;
versionMajor: string; // string car multipart form-data
versionMinor: string;
versionPatch: string;
description?: string;
type?: 'ASYNCAPI' | 'OPENAPI'; // optionnel, détecté depuis le YAML
categoryId?: string;
contactFunctionalName: string;
contactFunctionalEntity: string;
contactFunctionalEmail: string;
contactTechnicalName: string;
contactTechnicalEntity: string;
contactTechnicalEmail: string;
},
) {
const yamlContent = fs.readFileSync(file.path, 'utf-8');
const entry = await this.apisService.create({ ...body, yamlContent });
// 1 fichier → lecture directe
// N fichiers → détection fichier principal + bundling (redocly bundle / @asyncapi/bundler)
// Fire and forget — génération asynchrone
this.docsService.generate(entry.id).catch(console.error);
return entry;
@@ -289,19 +383,19 @@ import { execFile } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import * as path from 'path';
const AsyncApiGenerator = require('@asyncapi/generator');
const execFileAsync = promisify(execFile);
@Injectable()
export class DocsGenerationService {
private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? './docs-output';
async generate(entryId: string): Promise<void> {
const entry = await this.apisService.findOneOrThrow(entryId);
const entryDir = path.join(this.outputDir, entryId);
await fs.mkdir(entryDir, { recursive: true });
const yamlPath = path.join(entryDir, 'input.yaml');
await fs.writeFile(yamlPath, entry.yamlContent, 'utf-8');
@@ -311,37 +405,32 @@ export class DocsGenerationService {
} else {
await this.generateOpenApi(yamlPath, entryDir);
}
const htmlPath = path.join(entryDir, 'index.html');
await this.apisService.update(entryId, { htmlPath, status: 'GENERATED' });
await this.apisService.updateInternal(entryId, { htmlPath: path.join(entryDir, 'index.html'), status: 'GENERATED' });
} catch (error) {
await this.apisService.update(entryId, {
status: 'ERROR',
errorMessage: String(error),
});
await this.apisService.updateInternal(entryId, { status: 'ERROR', errorMessage: String(error) });
}
}
private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
// asyncapi generate fromFile <input> --output <dir>
await execFileAsync('asyncapi', [
'generate', 'fromFile',
yamlPath,
'--output', outputDir,
]);
// API programmatique — aucun appel réseau, template résolu localement
const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
await generator.generateFromFile(yamlPath);
}
private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
// redocly build-docs <input> --output <file>
await execFileAsync('redocly', [
'build-docs',
yamlPath,
'build-docs', yamlPath,
'--output', path.join(outputDir, 'index.html'),
]);
], { shell: true, env: execEnv });
}
}
```
> **Note** : `ApisService` expose deux méthodes de mise à jour :
> - `update(id, dto: UpdateApiEntryDto)` — pour le controller (champs éditables par l'utilisateur)
> - `updateInternal(id, fields)` — pour `DocsGenerationService` (status / htmlPath / errorMessage)
---
## 6. Conventions Angular 19
@@ -362,44 +451,31 @@ import { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-catalog',
selector: 'app-browse',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w">
<h1 class="fr-h1">Catalogue des APIs</h1>
<h1 class="fr-h1">Parcourir les APIs</h1>
@if (loading()) {
<div class="fr-callout">Chargement...</div>
}
@for (api of apis(); track api.id) {
@for (cat of subcategories(); track cat.id) {
<div class="fr-card fr-mb-2w">
<h2>{{ api.title }}</h2>
<h2>{{ cat.name }}</h2>
</div>
}
</div>
`,
})
export class CatalogComponent implements OnInit {
export class BrowseComponent implements OnInit {
private http = inject(HttpClient);
apis = signal<ApiEntryListItem[]>([]);
subcategories = signal<CategoryWithCounts[]>([]);
loading = signal(false);
ngOnInit() {
this.loadApis();
}
private loadApis() {
this.loading.set(true);
this.http.get<ApiListResponse>('/api/apis').subscribe({
next: (res) => {
this.apis.set(res.items);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
ngOnInit() { /* ... */ }
}
```
@@ -438,23 +514,13 @@ export class ApiService {
## 7. DSFR (Système de Design de l'État)
### Installation (assets statiques — pas de package npm)
### Installation via npm
Télécharger la dernière version depuis https://www.systeme-de-design.gouv.fr/
et placer les assets dans `front-public/src/assets/dsfr/`.
Structure attendue :
```bash
# Déjà dans front-public/package.json :
# "@gouvfr/dsfr": "^1.13.0" (devDependencies)
# Assets copiés automatiquement via angular.json (assets config)
```
src/assets/dsfr/
├── dsfr.min.css
├── dsfr.module.min.js
├── dsfr.nomodule.min.js
└── utility/
└── icons/
└── icons.min.css
```
Déjà référencé dans `index.html`.
### Classes CSS essentielles
@@ -506,24 +572,24 @@ fr-mt-4w fr-mb-2w fr-ml-1w fr-p-3w fr-mx-auto
- `fr-icon-delete-bin-line` — supprimer
- `fr-icon-refresh-line` — régénérer
- `fr-icon-eye-line` — voir
- `fr-icon-folder-2-line` — dossier
- `fr-icon-edit-line` — éditer
---
## 8. Commandes AsyncAPI CLI & Redocly CLI
## 8. Génération de documentation
### AsyncAPI CLI
### AsyncAPI — API programmatique (pas CLI)
```bash
# Générer HTML depuis un fichier YAML AsyncAPI
asyncapi generate fromFile ./api.yaml --output ./output-dir/
# Valider un fichier
asyncapi validate ./api.yaml
# Format de sortie : ./output-dir/index.html
```typescript
// Utilise @asyncapi/generator directement (installé dans node_modules)
// Template @asyncapi/html-template pré-installé dans l'image Docker
const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
await generator.generateFromFile(yamlPath);
```
### Redocly CLI
### OpenAPI — CLI Redocly
```bash
# Générer HTML depuis un fichier OpenAPI YAML
@@ -531,15 +597,17 @@ redocly build-docs ./api.yaml --output ./output-dir/index.html
# Valider un fichier OpenAPI
redocly lint ./api.yaml
# Format de sortie : ./output-dir/index.html
```
### Bundling multi-fichiers
- **OpenAPI** : `redocly bundle <main> --output <bundled.yaml>`
- **AsyncAPI** : API programmatique `@asyncapi/bundler`
### Notes importantes
- Les deux CLIs sont installés dans l'image Docker prod (`infra/Dockerfile.prod`, target `back`)
- En dev, ils sont installés dans `infra/Dockerfile.back.dev`
- `@asyncapi/generator` et `@asyncapi/html-template` sont installés dans l'image Docker prod et dev
- `@redocly/cli` est installé globalement dans les images Docker
- La génération est **asynchrone** : l'upload crée l'entrée, puis la génération se fait en background
- Le répertoire de sortie : `DOCS_OUTPUT_DIR=/app/docs-output` (PVC en prod)
- Le répertoire de sortie : `DOCS_OUTPUT_DIR=/app/docs-output` (PVC en prod), `./docs-output` en dev
- Chaque API a son propre sous-répertoire : `/app/docs-output/<uuid>/index.html`
---
@@ -618,18 +686,6 @@ Dans `https://ci.chmod777.dev` → Settings → Secrets pour le dépôt `datacat
- `registry_password` : token Gitea avec accès packages
- `deploy_kubeconfig` : contenu du fichier `~/.kube/config`
### Dépôt Gitea
Créer le dépôt `datacat` dans l'organisation `z3n` sur `https://git.chmod777.dev`.
```bash
cd /home/flo/dev/datacat
git init
git remote add origin https://git.chmod777.dev/z3n/datacat.git
git add .
git commit -m "feat: initial scaffold"
git push -u origin main
```
---
## 11. Règles de code
@@ -637,7 +693,7 @@ git push -u origin main
- **Langue** : code en anglais, commentaires en français
- **Commits** : Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`)
- **TypeScript** : pas de `any`, `strict: true`, types depuis `@datacat/shared`
- **NestJS** : un module par feature (`ApisModule`, `UploadsModule`, `DocsGenerationModule`)
- **NestJS** : un module par feature (`ApisModule`, `UploadsModule`, `DocsGenerationModule`, `CategoriesModule`)
- **Angular** : standalone components, signals, inject(), OnPush, lazy loading
- **Tests** : Vitest pour le backend, `ng test` pour le frontend
- **Pas de `console.log`** en production — utiliser Pino (NestJS) ou le logger Angular
@@ -646,30 +702,48 @@ git push -u origin main
## 12. Pages Angular — Détail d'implémentation
### `/browse` et `/browse/:id` — BrowseComponent
- Navigation par dossiers DSFR (tuiles `fr-tile` ou cartes `fr-card`)
- Fil d'Ariane DSFR (`fr-breadcrumb`) depuis le breadcrumb retourné par l'API
- Boutons : créer dossier, éditer dossier (crayon), supprimer dossier
- Modales partagées : `CategoryFormModalComponent` (créer/éditer), `CategoryDeleteModalComponent`
- Recherche dans les APIs du dossier courant
- `BrowseComponent` réutilisé pour `/browse` et `/browse/:id` — s'abonne à `paramMap` via `takeUntilDestroyed`
### `/catalog` — CatalogComponent
- Grille DSFR de cartes (`fr-card`)
- Champ de recherche (`fr-input`) filtrant titre + description
- Champ de recherche (`fr-input`) filtrant `name`, `provider`, `description`, contacts
- Select DSFR pour filtrer par type (ASYNCAPI / OPENAPI)
- Pagination DSFR (`fr-pagination`)
- Lien vers `/upload` avec bouton `fr-btn fr-btn--icon-left fr-icon-upload-2-line`
### `/catalog/:id` — ApiDetailComponent
- Métadonnées : titre, version, description, type, statut (badge), dates
- Fil d'Ariane aligné sur la hiérarchie des catégories
- Titre affiché : `CONVENTION_LABEL + ' ' + name` (ex: `"Consulter Petstore"`)
- Métadonnées : convention (badge), name, provider, version, description, type, statut, dates
- Contacts métier (nom, entité, email) et technique (nom, entité, email)
- Badge de statut : `fr-badge--success` (GENERATED), `fr-badge--warning` (PENDING), `fr-badge--error` (ERROR)
- Boutons :
- "Voir la documentation" → `/catalog/:id/docs` (désactivé si status ≠ GENERATED)
- "Télécharger YAML" → `/api/apis/:id/yaml`
- "Régénérer" → POST `/api/apis/:id/regenerate`
- "Supprimer" → DELETE `/api/apis/:id` + redirect `/catalog`
- "Modifier" → modale d'édition (tous les champs sauf yamlContent)
- "Supprimer" → modale de confirmation → DELETE `/api/apis/:id` + redirect `/browse`
### `/catalog/:id/docs` — DocViewerComponent
- `<iframe>` plein écran affichant `/api/apis/:id/docs`
- Bouton "Retour" en haut
### `/upload` — UploadComponent
- Stepper DSFR 3 étapes : (1) Choisir le fichier, (2) Métadonnées, (3) Confirmation
- Étape 1 : Zone de drop DSFR (`fr-upload-group`) pour fichier `.yaml` / `.yml`
- Étape 2 : Formulaire (titre, version, description, type select AsyncAPI/OpenAPI)
- Stepper DSFR 3 étapes : (1) Choisir les fichiers, (2) Métadonnées, (3) Confirmation
- Étape 1 : Zone de drop DSFR (`fr-upload-group`) pour 1 à 20 fichiers `.yaml` / `.yml`
- Validation serveur via `POST /api/uploads/validate` dès la sélection
- Pré-remplit les champs `name`, `versionMajor/Minor/Patch`, `description` depuis le YAML
- Étape 2 : Formulaire — `convention` (select), `name`, `provider`, `versionMajor/Minor/Patch`,
`description`, `categoryId` (optionnel), contacts métier + technique
- Étape 3 : Récap + bouton "Importer"
- POST multipart vers `/api/uploads`
- Redirect vers `/catalog/:id` après succès
### Composants partagés
- `CategoryFormModalComponent` — modale DSFR pour créer ou éditer une catégorie (nom, description, ordre)
- `CategoryDeleteModalComponent` — modale DSFR de confirmation de suppression d'une catégorie

View File

@@ -29,7 +29,7 @@ Liste les entrées avec filtres optionnels et pagination.
| Paramètre | Type | Défaut | Description |
|-----------|------|--------|-------------|
| `search` | string | — | Recherche dans le titre et la description (case-insensitive) |
| `search` | string | — | Recherche dans `name`, `provider`, `description`, contacts (case-insensitive) |
| `type` | `ASYNCAPI` \| `OPENAPI` | — | Filtre par type |
| `categoryId` | UUID | — | Filtre par catégorie |
| `page` | number | `1` | Page (min 1) |
@@ -41,10 +41,13 @@ Liste les entrées avec filtres optionnels et pagination.
"items": [
{
"id": "uuid",
"title": "My API",
"title": "Consulter Petstore",
"name": "Petstore",
"convention": "CONSULTER",
"version": "1.0.0",
"description": "...",
"type": "OPENAPI",
"provider": "Équipe Produit",
"status": "GENERATED",
"categoryId": "uuid-or-null",
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -57,7 +60,7 @@ Liste les entrées avec filtres optionnels et pagination.
}
```
> `items` contient des `ApiEntryListItem` (sans `yamlContent` ni `htmlPath`).
> `items` contient des `ApiEntryListItem` (sans `yamlContent`, `htmlPath`, contacts ni `versionMajor/Minor/Patch`).
---
@@ -69,15 +72,27 @@ Retourne le détail complet d'une entrée.
```json
{
"id": "uuid",
"title": "My API",
"title": "Consulter Petstore",
"name": "Petstore",
"convention": "CONSULTER",
"version": "1.0.0",
"versionMajor": 1,
"versionMinor": 0,
"versionPatch": 0,
"description": "...",
"type": "OPENAPI",
"provider": "Équipe Produit",
"yamlContent": "openapi: '3.0.0'\n...",
"htmlPath": "/app/docs-output/uuid/index.html",
"status": "GENERATED",
"errorMessage": null,
"categoryId": "uuid-or-null",
"contactFunctionalName": "Alice Martin",
"contactFunctionalEntity": "DSI",
"contactFunctionalEmail": "alice@example.com",
"contactTechnicalName": "Bob Dupont",
"contactTechnicalEntity": "Équipe Backend",
"contactTechnicalEmail": "bob@example.com",
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
@@ -94,10 +109,20 @@ Met à jour les métadonnées d'une entrée (champs éditables par l'utilisateur
**Body JSON** (tous les champs sont optionnels)
```json
{
"title": "New title",
"version": "2.0.0",
"description": "Updated description",
"categoryId": "uuid-or-null"
"convention": "CONSULTER",
"name": "Nouveau nom",
"provider": "Équipe X",
"versionMajor": 2,
"versionMinor": 0,
"versionPatch": 0,
"description": "Description mise à jour",
"categoryId": "uuid-or-null",
"contactFunctionalName": "...",
"contactFunctionalEntity": "...",
"contactFunctionalEmail": "...",
"contactTechnicalName": "...",
"contactTechnicalEntity": "...",
"contactTechnicalEmail": "..."
}
```
@@ -146,7 +171,7 @@ Télécharge le fichier YAML source.
**Réponse 200**
- `Content-Type: application/x-yaml`
- `Content-Disposition: attachment; filename="<title>.yaml"`
- `Content-Disposition: attachment; filename="<name>.yaml"`
- Corps : contenu YAML brut
**Réponse 404** — entrée introuvable
@@ -155,6 +180,52 @@ Télécharge le fichier YAML source.
## Upload (`/api/uploads`)
### `POST /api/uploads/validate`
Valide 1 à 20 fichiers YAML sans créer d'entrée en base. Utilisé pour la validation en temps réel lors de l'upload.
**Content-Type** : `multipart/form-data`
**Champs du formulaire**
| Champ | Type | Requis | Description |
|-------|------|--------|-------------|
| `files` | file (1 à 20) | Oui | Fichiers YAML (`.yaml` ou `.yml`) |
**Réponse 200**
```json
{
"valid": true,
"type": "OPENAPI",
"mainFile": "petstore.yaml",
"name": "Petstore",
"versionMajor": 1,
"versionMinor": 0,
"versionPatch": 0,
"description": "A sample API",
"errors": []
}
```
En cas d'erreur :
```json
{
"valid": false,
"type": null,
"mainFile": null,
"name": null,
"versionMajor": null,
"versionMinor": null,
"versionPatch": null,
"description": null,
"errors": [
{ "file": "api.yaml", "message": "erreur de syntaxe YAML — unexpected token" }
]
}
```
---
### `POST /api/uploads`
Crée une nouvelle entrée à partir d'un ou plusieurs fichiers YAML.
@@ -167,11 +238,21 @@ Lance la génération de documentation en arrière-plan (fire-and-forget).
| Champ | Type | Requis | Description |
|-------|------|--------|-------------|
| `files` | file (1 à 20) | Oui | Fichiers YAML (`.yaml` ou `.yml`) |
| `title` | string | Oui | Titre de l'API |
| `version` | string | Oui | Version (ex: `1.0.0`) |
| `convention` | `CONSULTER` \| `ENREGISTRER` \| `ETRE_NOTIFIE` | Oui | Convention d'échange |
| `name` | string | Oui | Nom de l'API |
| `provider` | string | Oui | Fournisseur / équipe responsable |
| `versionMajor` | string (number) | Non | Version majeure (défaut: 0) |
| `versionMinor` | string (number) | Non | Version mineure (défaut: 0) |
| `versionPatch` | string (number) | Non | Version patch (défaut: 0) |
| `description` | string | Non | Description libre |
| `type` | `ASYNCAPI` \| `OPENAPI` | Non | Type (détecté automatiquement si absent) |
| `categoryId` | UUID | Non | Catégorie parente |
| `contactFunctionalName` | string | Non | Contact métier — nom |
| `contactFunctionalEntity` | string | Non | Contact métier — entité |
| `contactFunctionalEmail` | string | Non | Contact métier — email |
| `contactTechnicalName` | string | Non | Contact technique — nom |
| `contactTechnicalEntity` | string | Non | Contact technique — entité |
| `contactTechnicalEmail` | string | Non | Contact technique — email |
**Détection automatique du type**
@@ -180,13 +261,13 @@ Le type est détecté à partir du contenu YAML :
- Présence de `asyncapi:` en début de ligne → `ASYNCAPI`
Si plusieurs fichiers sont fournis, le fichier contenant la clé racine est considéré comme le fichier principal.
Les autres sont bundlés via `redocly bundle` (OpenAPI) ou `asyncapi bundle` (AsyncAPI).
Les autres sont bundlés via `redocly bundle` (OpenAPI) ou `@asyncapi/bundler` (AsyncAPI).
**Réponse 201**`ApiEntry` créé avec `status: "PENDING"`
**Réponse 400**
- Aucun fichier fourni
- `title` ou `version` manquant
- `name` ou `convention` manquant
- Fichier non YAML
- Type non détectable et non fourni
- Aucun fichier principal trouvé dans un upload multi-fichiers
@@ -243,8 +324,14 @@ Navigation à la racine : retourne les catégories de premier niveau et les APIs
"apis": [
{
"id": "uuid",
"title": "API sans catégorie",
...
"title": "Consulter API sans catégorie",
"name": "API sans catégorie",
"convention": "CONSULTER",
"version": "1.0.0",
"provider": "...",
"status": "GENERATED",
"categoryId": null,
"..."
}
]
}
@@ -331,10 +418,11 @@ Met à jour une catégorie.
### `DELETE /api/categories/:id`
Supprime une catégorie.
Les APIs et sous-catégories enfants sont désassociées (`categoryId`/`parentId` mis à null).
**Réponse 204** — succès (pas de corps)
**Réponse 400** — le dossier contient des APIs ou des sous-dossiers (vérification applicative avant suppression)
**Réponse 404** — catégorie introuvable
---
@@ -343,7 +431,7 @@ Les APIs et sous-catégories enfants sont désassociées (`categoryId`/`parentId
| Code | Description |
|------|-------------|
| 400 | Requête invalide (champ manquant, format incorrect) |
| 400 | Requête invalide (champ manquant, format incorrect, suppression non vide) |
| 404 | Ressource introuvable |
| 500 | Erreur interne serveur |
@@ -365,3 +453,11 @@ Les APIs et sous-catégories enfants sont désassociées (`categoryId`/`parentId
| `PENDING` | Entrée créée, génération de doc en cours |
| `GENERATED` | Documentation HTML disponible |
| `ERROR` | La génération a échoué (voir `errorMessage`) |
## Conventions d'échange
| Convention | Label affiché |
|------------|---------------|
| `CONSULTER` | Consulter |
| `ENREGISTRER` | Enregistrer |
| `ETRE_NOTIFIE` | Être notifié |

View File

@@ -83,22 +83,37 @@ Injecte des données de démonstration au démarrage (idempotent : ne re-seed qu
```sql
CREATE TABLE api_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
version VARCHAR(50) NOT NULL,
description TEXT,
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
yaml_content TEXT NOT NULL,
html_path VARCHAR(500),
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
error_message TEXT,
category_id VARCHAR(36),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
convention VARCHAR(30) NOT NULL DEFAULT 'CONSULTER'
CHECK (convention IN ('CONSULTER','ENREGISTRER','ETRE_NOTIFIE')),
name VARCHAR(255) NOT NULL DEFAULT '',
provider VARCHAR(255) NOT NULL DEFAULT '',
version_major INT NOT NULL DEFAULT 0,
version_minor INT NOT NULL DEFAULT 0,
version_patch INT NOT NULL DEFAULT 0,
description TEXT,
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
yaml_content TEXT NOT NULL,
html_path VARCHAR(500),
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
error_message TEXT,
category_id VARCHAR(36),
contact_functional_name VARCHAR(255) NOT NULL DEFAULT '',
contact_functional_entity VARCHAR(255) NOT NULL DEFAULT '',
contact_functional_email VARCHAR(255) NOT NULL DEFAULT '',
contact_technical_name VARCHAR(255) NOT NULL DEFAULT '',
contact_technical_entity VARCHAR(255) NOT NULL DEFAULT '',
contact_technical_email VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
> `title` et `version` sont des champs calculés (pas en DB) :
> - `title` = `CONVENTION_LABEL + ' ' + name` (ex: `"Consulter Petstore"`)
> - `version` = `"major.minor.patch"` (ex: `"1.0.0"`)
### Table `categories`
```sql
@@ -114,7 +129,7 @@ CREATE TABLE categories (
```
> Les relations `category_id` / `parent_id` sont des colonnes varchar sans contrainte de clé étrangère TypeORM.
> La suppression d'une catégorie désassocie automatiquement ses APIs et sous-catégories enfants (logique applicative).
> La suppression d'une catégorie est **rejetée avec 400** si le dossier contient des APIs ou des sous-dossiers (vérification applicative avant `DELETE`).
---

View File

@@ -138,6 +138,65 @@ Des exemples sont disponibles dans `documentation/` :
---
## Fixtures & re-seed
Les données de démo (catégories + APIs) sont définies dans `back/src/modules/seeder/seeder.service.ts`.
### Structure des fixtures
```
E-commerce (racine)
└─ Produits
└─ Commandes
└─ Notifications
Infrastructure (racine)
└─ Événements
Identité & Accès (racine)
└─ Authentification
```
5 APIs de démo y sont rattachées (Petstore, Streetlights Kafka, Account Service, Orders, Auth JWT).
### Modifier les fixtures
Éditer directement `back/src/modules/seeder/seeder.service.ts` :
- **Catégories** : bloc `categoryRepo.save(categoryRepo.create({...}))` dans la méthode `seed()`. Utiliser `parentId: null` pour une racine, ou `parentId: autreCategorie.id` pour un enfant.
- **APIs** : tableau passé à `apiRepo.save([...])`. Chaque entrée référence une catégorie via `categoryId`.
- **YAML** : les constantes en haut du fichier (`PETSTORE_YAML`, `ORDERS_YAML`, etc.) contiennent le YAML inliné de chaque API.
### Régénérer la base de données
Le seeder est **idempotent** : il ne s'exécute pas si la table `api_entries` contient déjà des lignes.
Pour forcer un re-seed complet (vide tout et recrée), utiliser la variable `FORCE_RESEED=true` :
**Avec Docker :**
```bash
docker compose -f infra/docker-compose.yml stop backend
docker compose -f infra/docker-compose.yml run --rm -e FORCE_RESEED=true backend
```
**Sans Docker (local) :**
```bash
cd back
FORCE_RESEED=true pnpm run start:dev
```
Ou via `back/.env` :
```env
FORCE_RESEED=true
```
> Penser à retirer `FORCE_RESEED=true` du `.env` après le redémarrage, sinon la base sera réinitialisée à chaque démarrage.
Le seeder supprime toutes les entrées `api_entries` et `categories`, nettoie le répertoire `docs-output`, puis recrée tout depuis `seed()`. Les logs confirment l'opération :
```
[Seeder] Reset complet — données supprimées, docs nettoyées.
[Seeder] 8 catégories et 5 APIs de démo créées — génération docs en cours...
```
---
## Notes
- **Schéma DB** : TypeORM crée et synchronise automatiquement les tables au démarrage (mode `synchronize: true` hors production). Aucune migration manuelle nécessaire.

View File

@@ -36,6 +36,16 @@
"glob": "**/*",
"input": "node_modules/@gouvfr/dsfr/dist/utility",
"output": "assets/dsfr/utility"
},
{
"glob": "**/*",
"input": "node_modules/@gouvfr/dsfr/dist/icons",
"output": "assets/dsfr/icons"
},
{
"glob": "**/*",
"input": "node_modules/@gouvfr/dsfr/dist/icons",
"output": "assets/icons"
}
],
"styles": ["src/styles.scss"],
@@ -65,7 +75,8 @@
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
"proxyConfig": "proxy.conf.json",
"allowedHosts": ["datacat.dev.chmod777.dev"]
}
}
}

View File

@@ -1,10 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { RouterOutlet, RouterLink } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
imports: [RouterOutlet, RouterLink],
template: `
<header role="banner" class="fr-header">
<div class="fr-header__body">
@@ -17,7 +17,7 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
</div>
</div>
<div class="fr-header__service">
<a href="/" routerLink="/browse" title="Accueil — Datacat">
<a href="/" routerLink="/" title="Accueil — Datacat">
<p class="fr-header__service-title">Datacat</p>
</a>
<p class="fr-header__service-tagline">Catalogue de données d'API</p>
@@ -26,34 +26,6 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
</div>
</div>
</div>
<div class="fr-header__menu">
<div class="fr-container">
<nav class="fr-nav" role="navigation" aria-label="Menu principal">
<ul class="fr-nav__list">
<li class="fr-nav__item">
<a
class="fr-nav__link"
routerLink="/browse"
routerLinkActive="fr-nav__link--active"
[routerLinkActiveOptions]="{ exact: false }"
>
Parcourir
</a>
</li>
<li class="fr-nav__item">
<a
class="fr-nav__link"
routerLink="/catalog"
routerLinkActive="fr-nav__link--active"
[routerLinkActiveOptions]="{ exact: false }"
>
Catalogue
</a>
</li>
</ul>
</nav>
</div>
</div>
</header>
<main>
<router-outlet />

View File

@@ -3,8 +3,8 @@ import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
redirectTo: 'browse',
pathMatch: 'full',
loadComponent: () =>
import('./pages/browse/browse.component').then((m) => m.BrowseComponent),
},
{
path: 'browse',
@@ -18,8 +18,8 @@ export const routes: Routes = [
},
{
path: 'catalog',
loadComponent: () =>
import('./pages/catalog/catalog.component').then((m) => m.CatalogComponent),
redirectTo: 'browse',
pathMatch: 'full',
},
{
path: 'catalog/:id',
@@ -38,6 +38,6 @@ export const routes: Routes = [
},
{
path: '**',
redirectTo: 'browse',
redirectTo: '',
},
];

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiEntry, ApiListResponse, ApiListQuery } from '@datacat/shared';
import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery } from '@datacat/shared';
@Injectable({ providedIn: 'root' })
export class ApiService {
@@ -22,7 +22,22 @@ export class ApiService {
return this.http.get<ApiEntry>(`${this.baseUrl}/${id}`);
}
update(id: string, data: { title?: string; version?: string; description?: string }): Observable<ApiEntry> {
update(id: string, data: {
convention?: string;
name?: string;
provider?: string;
versionMajor?: number;
versionMinor?: number;
versionPatch?: number;
description?: string | null;
categoryId?: string | null;
contactFunctionalName?: string;
contactFunctionalEntity?: string;
contactFunctionalEmail?: string;
contactTechnicalName?: string;
contactTechnicalEntity?: string;
contactTechnicalEmail?: string;
}): Observable<ApiEntry> {
return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data);
}
@@ -30,6 +45,14 @@ export class ApiService {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
getVersions(id: string): Observable<ApiEntryListItem[]> {
return this.http.get<ApiEntryListItem[]>(`${this.baseUrl}/${id}/versions`);
}
setCurrent(id: string): Observable<ApiEntry> {
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/set-current`, {});
}
regenerate(id: string): Observable<ApiEntry> {
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
}
@@ -37,4 +60,28 @@ export class ApiService {
upload(formData: FormData): Observable<ApiEntry> {
return this.http.post<ApiEntry>('/api/uploads', formData);
}
validate(formData: FormData): Observable<{
valid: boolean;
type: 'ASYNCAPI' | 'OPENAPI' | null;
mainFile: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}> {
return this.http.post<{
valid: boolean;
type: 'ASYNCAPI' | 'OPENAPI' | null;
mainFile: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}>('/api/uploads/validate', formData);
}
}

View File

@@ -12,8 +12,10 @@ export class CategoryService {
private http = inject(HttpClient);
private baseUrl = '/api/categories';
list(): Observable<CategoryListResponse> {
return this.http.get<CategoryListResponse>(this.baseUrl);
list(search?: string): Observable<CategoryListResponse> {
const params: Record<string, string> = {};
if (search?.trim()) params['search'] = search;
return this.http.get<CategoryListResponse>(this.baseUrl, { params });
}
browse(id: string | null): Observable<CategoryBrowseResponse> {

View File

@@ -1,20 +1,24 @@
import {
Component,
signal,
computed,
inject,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiService } from '../../core/api.service';
import { ApiEntry } from '@datacat/shared';
import { CategoryService } from '../../core/category.service';
import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
@Component({
selector: 'app-api-detail',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
@@ -22,8 +26,14 @@ import { ApiEntry } from '@datacat/shared';
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
<li>
<a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a>
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
</li>
@if (categoryBreadcrumb().length === 0) {
<li><a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a></li>
}
@for (crumb of categoryBreadcrumb(); track crumb.id) {
<li><a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a></li>
}
<li>
<a class="fr-breadcrumb__link" aria-current="page">
{{ api()?.title ?? 'Chargement...' }}
@@ -46,178 +56,356 @@ import { ApiEntry } from '@datacat/shared';
}
@if (api(); as entry) {
<!-- Retour -->
<a class="fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w" style="display:inline-flex;"
[routerLink]="backUrl()">Retour</a>
<!-- En-tête -->
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h1 class="fr-h2 fr-mb-1w">{{ entry.title }}</h1>
<div class="fr-tags-group">
<span class="fr-tag">{{ entry.type }}</span>
<div class="fr-mb-3w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<!-- Gauche : titre + version -->
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;min-width:0;">
<h1 class="fr-h2 fr-mb-0">{{ entry.title }}</h1>
@if (versions().length <= 1) {
<span class="fr-tag">v{{ entry.version }}</span>
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
</div>
</div>
</div>
<!-- Alerte statut PENDING -->
@if (entry.status === 'PENDING') {
<div class="fr-alert fr-alert--info fr-mb-3w">
<p class="fr-alert__title">Génération en cours</p>
<p>La documentation est en cours de génération. Cette page se rafraîchit automatiquement.</p>
</div>
}
<!-- Alerte statut ERROR -->
@if (entry.status === 'ERROR') {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Erreur de génération</p>
<p>{{ entry.errorMessage }}</p>
</div>
}
<!-- Métadonnées -->
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Informations</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ entry.title }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Version</dt>
<dd>{{ entry.version }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Type</dt>
<dd>{{ entry.type }}</dd>
</div>
@if (entry.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<dd>{{ entry.description }}</dd>
</div>
} @else {
<select class="fr-select" id="version-select" style="width:auto;" (change)="onVersionChange($event)">
@for (v of versions(); track v.id) {
<option [value]="v.id" [selected]="v.id === entry.id">
{{ v.version }}{{ v.isCurrent ? ' (courante)' : '' }}
</option>
}
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Créé le</dt>
<dd>{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Mis à jour le</dt>
<dd>{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
</dl>
</div>
</select>
}
</div>
</div>
<!-- Documentation fonctionnelle -->
@if (entry.functionalDoc) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Documentation fonctionnelle</h2>
<p style="white-space: pre-wrap">{{ entry.functionalDoc }}</p>
</div>
</div>
</div>
}
<!-- Documentation technique -->
@if (entry.technicalDoc) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Documentation technique</h2>
<p style="white-space: pre-wrap">{{ entry.technicalDoc }}</p>
</div>
</div>
</div>
}
<!-- Contacts -->
@if (entry.contactFunctional || entry.contactTechnical) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Contacts</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
@if (entry.contactFunctional) {
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact fonctionnel</dt>
<dd>{{ entry.contactFunctional }}</dd>
</div>
}
@if (entry.contactTechnical) {
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact technique</dt>
<dd>{{ entry.contactTechnical }}</dd>
</div>
}
</dl>
</div>
</div>
</div>
}
<!-- Droits d'accès -->
@if (entry.accessRights) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Droits d'accès</h2>
<p style="white-space: pre-wrap">{{ entry.accessRights }}</p>
</div>
</div>
</div>
}
<!-- Documentation externe -->
@if (entry.externalDocUrl) {
<div class="fr-mb-4w">
<a
[href]="entry.externalDocUrl"
target="_blank"
rel="noopener noreferrer"
class="fr-btn fr-btn--secondary fr-icon-external-link-line fr-btn--icon-left"
>
Documentation externe
<!-- Droite : bouton Voir doc + Actions dropdown -->
<div style="display:flex;align-items:center;gap:0.5rem;flex-shrink:0;">
<a class="fr-btn fr-btn--icon-left fr-icon-eye-line"
[routerLink]="entry.status === 'GENERATED' ? ['/catalog', entry.id, 'docs'] : null"
[style.opacity]="entry.status !== 'GENERATED' ? '0.5' : '1'"
[style.pointer-events]="entry.status !== 'GENERATED' ? 'none' : 'auto'">
Voir la documentation
</a>
<div style="position:relative;">
<button class="fr-btn fr-btn--secondary fr-btn--icon-right fr-icon-arrow-down-s-line"
(click)="actionsOpen.set(!actionsOpen())">
Actions
</button>
@if (actionsOpen()) {
<div style="position:absolute;right:0;top:calc(100% + 4px);z-index:1000;background:#fff;border:1px solid #ddd;box-shadow:0 4px 12px rgba(0,0,0,.15);min-width:220px;border-radius:4px;overflow:hidden;">
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;border-bottom:1px solid #eee;cursor:pointer;color:#161616;"
(click)="onNewVersion(); actionsOpen.set(false)">
Nouvelle version
</button>
<a [href]="'/api/apis/' + entry.id + '/yaml'"
download
(click)="actionsOpen.set(false)"
style="display:block;padding:0.75rem 1rem;text-decoration:none;color:#161616;border-bottom:1px solid #eee;">
Télécharger YAML
</a>
@if (entry.status !== 'GENERATED') {
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;border-bottom:1px solid #eee;cursor:pointer;color:#161616;"
[disabled]="regenerating()"
(click)="onRegenerate(); actionsOpen.set(false)">
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
</button>
}
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;cursor:pointer;color:#CE0500;"
[disabled]="deleting()"
(click)="onDelete(); actionsOpen.set(false)">
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
</button>
</div>
}
</div>
</div>
}
</div>
<!-- Actions -->
<div class="fr-btns-group fr-btns-group--inline-sm">
<a
[routerLink]="['/catalog', entry.id, 'docs']"
class="fr-btn"
[class.fr-btn--disabled]="entry.status !== 'GENERATED'"
[attr.aria-disabled]="entry.status !== 'GENERATED' ? 'true' : null"
>
Voir la documentation
</a>
<a
[href]="'/api/apis/' + entry.id + '/yaml'"
class="fr-btn fr-btn--secondary"
download
>
Télécharger YAML
</a>
<button
class="fr-btn fr-btn--secondary"
(click)="onRegenerate()"
[disabled]="regenerating()"
>
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
<!-- Navigation onglets -->
<div style="display:flex;border-bottom:2px solid #e5e5e5;margin-bottom:1.5rem;">
<button (click)="activeTab.set('info')"
[style.border-bottom]="activeTab()==='info' ? '2px solid #000091' : '2px solid transparent'"
[style.color]="activeTab()==='info' ? '#000091' : '#3a3a3a'"
[style.font-weight]="activeTab()==='info' ? '700' : '400'"
style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
Informations
</button>
<button
class="fr-btn fr-btn--secondary"
(click)="onDelete()"
[disabled]="deleting()"
>
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
<button (click)="activeTab.set('contacts')"
[style.border-bottom]="activeTab()==='contacts' ? '2px solid #000091' : '2px solid transparent'"
[style.color]="activeTab()==='contacts' ? '#000091' : '#3a3a3a'"
[style.font-weight]="activeTab()==='contacts' ? '700' : '400'"
style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
Contacts
</button>
</div>
<!-- Onglet Informations -->
@if (activeTab() === 'info') {
@if (!editingInfo()) {
<div class="fr-card fr-mb-3w">
<div class="fr-card__body">
<div class="fr-card__content">
<dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Version</dt>
<dd style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
{{ entry.version }}
@if (!entry.isCurrent) {
<button class="fr-btn fr-btn--sm fr-btn--secondary" [disabled]="settingCurrent()"
(click)="onSetCurrent()">
{{ settingCurrent() ? '...' : 'Passer en courante' }}
</button>
}
</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Type</dt>
<dd>{{ entry.type }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Statut</dt>
<dd>
<span [class]="badgeClass(entry.status)">{{ statusLabel(entry.status) }}</span>
@if (entry.status === 'ERROR' && entry.errorMessage) {
<p class="fr-text--sm fr-mt-1w">{{ entry.errorMessage }}</p>
}
</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Fournisseur</dt>
<dd>{{ entry.provider }}</dd>
</div>
@if (entry.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<dd>{{ entry.description }}</dd>
</div>
}
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Créé le</dt>
<dd>{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Mis à jour le</dt>
<dd>{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
</dl>
<div class="fr-mt-2w">
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-edit-line" (click)="startEditInfo()">
Modifier les informations
</button>
</div>
</div>
</div>
</div>
}
@if (editingInfo()) {
<form [formGroup]="infoForm" (ngSubmit)="submitInfo()" novalidate class="fr-mb-3w">
<!-- Convention + Nom -->
<p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-select-group">
<label class="fr-label" for="info-convention">Convention</label>
<select id="info-convention" class="fr-select" formControlName="convention">
<option value="CONSULTER">Consulter</option>
<option value="ENREGISTRER">Enregistrer</option>
<option value="ETRE_NOTIFIE">Être notifié</option>
</select>
</div>
</div>
<div class="fr-col-12 fr-col-md-8">
<div class="fr-input-group"
[class.fr-input-group--error]="infoForm.get('name')?.invalid && infoForm.get('name')?.touched">
<label class="fr-label" for="info-name">Nom de l'API <span style="color:var(--text-default-error)">*</span></label>
<input id="info-name" class="fr-input" type="text" formControlName="name"
[class.fr-input--error]="infoForm.get('name')?.invalid && infoForm.get('name')?.touched" />
@if (infoForm.get('name')?.invalid && infoForm.get('name')?.touched) {
<p class="fr-error-text">Le nom est obligatoire.</p>
}
</div>
</div>
</div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-2w"
[class.fr-input-group--error]="infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched">
<label class="fr-label" for="info-provider">Fournisseur <span style="color:var(--text-default-error)">*</span></label>
<input id="info-provider" class="fr-input" type="text" formControlName="provider"
[class.fr-input--error]="infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched" />
@if (infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched) {
<p class="fr-error-text">Le fournisseur est obligatoire.</p>
}
</div>
<!-- Version -->
<p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-2w">
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="info-v-major">Majeure (X)</label>
<input id="info-v-major" class="fr-input" type="number" min="0" formControlName="versionMajor" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="info-v-minor">Mineure (Y)</label>
<input id="info-v-minor" class="fr-input" type="number" min="0" formControlName="versionMinor" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="info-v-patch">Correctif (Z)</label>
<input id="info-v-patch" class="fr-input" type="number" min="0" formControlName="versionPatch" />
</div>
</div>
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="info-description">Description</label>
<textarea id="info-description" class="fr-input" rows="3" formControlName="description"></textarea>
</div>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="info-category">Catégorie</label>
<select id="info-category" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.name }}</option>
}
</select>
</div>
<div class="fr-btns-group fr-btns-group--inline fr-btns-group--right">
<button type="button" class="fr-btn fr-btn--secondary" (click)="editingInfo.set(false)">Annuler</button>
<button type="submit" class="fr-btn" [disabled]="savingInfo()">
{{ savingInfo() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</form>
}
}
<!-- Onglet Contacts -->
@if (activeTab() === 'contacts') {
@if (!editingContacts()) {
<div class="fr-card fr-mb-3w">
<div class="fr-card__body">
<div class="fr-card__content">
<div class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-6">
<p class="fr-text--bold fr-mb-1w">Contact métier</p>
<p class="fr-mb-0">{{ entry.contactFunctionalName }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactFunctionalEntity }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactFunctionalEmail }}</p>
</div>
<div class="fr-col-12 fr-col-md-6">
<p class="fr-text--bold fr-mb-1w">Contact technique</p>
<p class="fr-mb-0">{{ entry.contactTechnicalName }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEntity }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEmail }}</p>
</div>
</div>
<div class="fr-mt-2w">
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-edit-line" (click)="startEditContacts()">
Modifier les contacts
</button>
</div>
</div>
</div>
</div>
}
@if (editingContacts()) {
<form [formGroup]="contactForm" (ngSubmit)="submitContacts()" novalidate class="fr-mb-3w">
<!-- Contact métier -->
<p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched">
<label class="fr-label" for="cf-name">Nom et prénom</label>
<input id="cf-name" class="fr-input" type="text" formControlName="contactFunctionalName" placeholder="Prénom Nom"
[class.fr-input--error]="contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched" />
@if (contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched">
<label class="fr-label" for="cf-entity">Entité</label>
<input id="cf-entity" class="fr-input" type="text" formControlName="contactFunctionalEntity" placeholder="Direction XYZ"
[class.fr-input--error]="contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched" />
@if (contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched">
<label class="fr-label" for="cf-email">Email</label>
<input id="cf-email" class="fr-input" type="email" formControlName="contactFunctionalEmail" placeholder="prenom.nom@example.fr"
[class.fr-input--error]="contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched" />
@if (contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<!-- Contact technique -->
<p class="fr-label fr-mb-1w">Contact technique <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched">
<label class="fr-label" for="ct-name">Nom et prénom</label>
<input id="ct-name" class="fr-input" type="text" formControlName="contactTechnicalName" placeholder="Prénom Nom"
[class.fr-input--error]="contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched" />
@if (contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched">
<label class="fr-label" for="ct-entity">Entité</label>
<input id="ct-entity" class="fr-input" type="text" formControlName="contactTechnicalEntity" placeholder="Équipe Technique"
[class.fr-input--error]="contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched" />
@if (contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched">
<label class="fr-label" for="ct-email">Email</label>
<input id="ct-email" class="fr-input" type="email" formControlName="contactTechnicalEmail" placeholder="tech@example.fr"
[class.fr-input--error]="contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched" />
@if (contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<div class="fr-btns-group fr-btns-group--inline fr-btns-group--right">
<button type="button" class="fr-btn fr-btn--secondary" (click)="editingContacts.set(false)">Annuler</button>
<button type="submit" class="fr-btn" [disabled]="savingContacts()">
{{ savingContacts() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</form>
}
}
}
</div>
`,
@@ -226,25 +414,103 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
private fb = inject(FormBuilder);
api = signal<ApiEntry | null>(null);
versions = signal<ApiEntryListItem[]>([]);
categoryBreadcrumb = signal<Category[]>([]);
loading = signal(false);
error = signal<string | null>(null);
regenerating = signal(false);
deleting = signal(false);
settingCurrent = signal(false);
categories = signal<Category[]>([]);
actionsOpen = signal(false);
backUrl = computed<string[]>(() => {
const crumbs = this.categoryBreadcrumb();
if (crumbs.length > 0) return ['/browse', crumbs[crumbs.length - 1].id];
return ['/browse'];
});
/* Onglets */
activeTab = signal<'info' | 'contacts'>('info');
editingInfo = signal(false);
editingContacts = signal(false);
savingInfo = signal(false);
savingContacts = signal(false);
infoForm = this.fb.group({
convention: ['CONSULTER', Validators.required],
name: ['', Validators.required],
provider: ['', Validators.required],
versionMajor: [0, [Validators.required, Validators.min(0)]],
versionMinor: [0, [Validators.required, Validators.min(0)]],
versionPatch: [0, [Validators.required, Validators.min(0)]],
description: [''],
categoryId: [''],
});
contactForm = this.fb.group({
contactFunctionalName: ['', Validators.required],
contactFunctionalEntity: ['', Validators.required],
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
contactTechnicalName: ['', Validators.required],
contactTechnicalEntity: ['', Validators.required],
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
});
private pollInterval: ReturnType<typeof setInterval> | null = null;
private routeSub: Subscription | null = null;
private id = '';
ngOnInit() {
this.id = this.route.snapshot.paramMap.get('id') ?? '';
this.loadApi();
this.categoryService.list().subscribe((res) => this.categories.set(res.items));
this.routeSub = this.route.paramMap.subscribe((params) => {
this.id = params.get('id') ?? '';
this.api.set(null);
this.versions.set([]);
this.error.set(null);
this.categoryBreadcrumb.set([]);
this.editingInfo.set(false);
this.editingContacts.set(false);
this.clearPoll();
this.loadApi();
});
}
ngOnDestroy() {
this.routeSub?.unsubscribe();
this.clearPoll();
}
onVersionChange(event: Event): void {
const id = (event.target as HTMLSelectElement).value;
if (id && id !== this.api()?.id) {
this.router.navigate(['/catalog', id]);
}
}
onNewVersion(): void {
this.router.navigate(['/upload'], { queryParams: { from: this.api()?.id } });
}
onSetCurrent() {
this.settingCurrent.set(true);
this.apiService.setCurrent(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.settingCurrent.set(false);
this.apiService.getVersions(this.id).subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
},
error: () => this.settingCurrent.set(false),
});
}
onRegenerate() {
this.regenerating.set(true);
this.apiService.regenerate(this.id).subscribe({
@@ -257,11 +523,90 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
});
}
startEditInfo() {
const entry = this.api();
if (!entry) return;
this.infoForm.patchValue({
convention: entry.convention,
name: entry.name,
provider: entry.provider,
versionMajor: entry.versionMajor,
versionMinor: entry.versionMinor,
versionPatch: entry.versionPatch,
description: entry.description ?? '',
categoryId: entry.categoryId ?? '',
});
this.editingInfo.set(true);
}
startEditContacts() {
const entry = this.api();
if (!entry) return;
this.contactForm.patchValue({
contactFunctionalName: entry.contactFunctionalName,
contactFunctionalEntity: entry.contactFunctionalEntity,
contactFunctionalEmail: entry.contactFunctionalEmail,
contactTechnicalName: entry.contactTechnicalName,
contactTechnicalEntity: entry.contactTechnicalEntity,
contactTechnicalEmail: entry.contactTechnicalEmail,
});
this.editingContacts.set(true);
}
submitInfo() {
if (this.infoForm.invalid) { this.infoForm.markAllAsTouched(); return; }
const entry = this.api();
if (!entry) return;
this.savingInfo.set(true);
const raw = this.infoForm.getRawValue();
this.apiService.update(entry.id, {
convention: raw.convention ?? 'CONSULTER',
name: raw.name ?? '',
provider: raw.provider ?? '',
versionMajor: raw.versionMajor ?? 0,
versionMinor: raw.versionMinor ?? 0,
versionPatch: raw.versionPatch ?? 0,
categoryId: raw.categoryId || null,
description: raw.description || null,
}).subscribe({
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
error: () => this.savingInfo.set(false),
});
}
submitContacts() {
if (this.contactForm.invalid) { this.contactForm.markAllAsTouched(); return; }
const entry = this.api();
if (!entry) return;
this.savingContacts.set(true);
const raw = this.contactForm.getRawValue();
this.apiService.update(entry.id, {
contactFunctionalName: raw.contactFunctionalName ?? '',
contactFunctionalEntity: raw.contactFunctionalEntity ?? '',
contactFunctionalEmail: raw.contactFunctionalEmail ?? '',
contactTechnicalName: raw.contactTechnicalName ?? '',
contactTechnicalEntity: raw.contactTechnicalEntity ?? '',
contactTechnicalEmail: raw.contactTechnicalEmail ?? '',
}).subscribe({
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
error: () => this.savingContacts.set(false),
});
}
onDelete() {
if (!confirm('Supprimer cette API ?')) return;
this.deleting.set(true);
/* Calculer la cible de redirection avant la suppression */
const otherVersions = this.versions().filter((v) => v.id !== this.id);
const redirectTarget = otherVersions.find((v) => v.isCurrent) ?? otherVersions[0];
this.apiService.delete(this.id).subscribe({
next: () => this.router.navigate(['/catalog']),
next: () => {
if (redirectTarget) {
this.router.navigate(['/catalog', redirectTarget.id]);
} else {
this.router.navigate(this.backUrl());
}
},
error: () => this.deleting.set(false),
});
}
@@ -269,9 +614,17 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
badgeClass(status: string): string {
if (status === 'GENERATED') return 'fr-badge fr-badge--success';
if (status === 'ERROR') return 'fr-badge fr-badge--error';
if (status === 'NO_YAML') return 'fr-badge fr-badge--warning';
return 'fr-badge fr-badge--info';
}
statusLabel(status: string): string {
if (status === 'GENERATED') return 'Générée';
if (status === 'ERROR') return 'Erreur';
if (status === 'NO_YAML') return 'Sans fichier';
return 'En attente';
}
private loadApi() {
this.loading.set(true);
this.apiService.get(this.id).subscribe({
@@ -279,6 +632,16 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
this.api.set(entry);
this.loading.set(false);
this.startPollIfPending();
this.apiService.getVersions(this.id).subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
if (entry.categoryId) {
this.categoryService.browse(entry.categoryId).subscribe({
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
error: () => {},
});
}
},
error: (err) => {
this.error.set(err.message ?? 'Erreur lors du chargement');

View File

@@ -12,12 +12,16 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { CategoryService } from '../../core/category.service';
import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/shared';
import { ApiService } from '../../core/api.service';
import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem } from '@datacat/shared';
import { forkJoin } from 'rxjs';
import { CategoryFormModalComponent } from '../../shared/category-form-modal/category-form-modal.component';
import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal/category-delete-modal.component';
@Component({
selector: 'app-browse',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
@@ -25,7 +29,7 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
<li>
<a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a>
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
</li>
@for (crumb of ancestors(); track crumb.id) {
<li>
@@ -41,17 +45,44 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
</nav>
<!-- En-tête -->
@if (categoryId()) {
<a class="fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w" style="display:inline-flex;"
[routerLink]="backUrl()">Retour</a>
}
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h1 class="fr-h2 fr-mb-0">{{ browseData()?.category?.name ?? 'Catalogue' }}</h1>
<h1 class="fr-h2 fr-mb-0">
{{ browseData()?.category?.name ?? 'Catalogue' }}
@if (browseData()?.category) {
<button
title="Modifier ce dossier"
style="background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
(click)="openEdit(browseData()!.category!)">
<span class="fr-icon-edit-line" aria-hidden="true" style="font-size:0.875rem"></span>
</button>
}
</h1>
</div>
<div class="fr-col-auto">
<div class="fr-col-auto" style="display:flex;gap:0.5rem">
<a class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-upload-2-line"
[routerLink]="['/upload']"
[queryParams]="categoryId() ? { categoryId: categoryId() } : {}">
Importer une API
</a>
<button class="fr-btn fr-btn--icon-left fr-icon-folder-2-fill" (click)="openCreate()">
Nouveau dossier
</button>
</div>
</div>
<!-- Barre de recherche -->
<div class="fr-search-bar fr-mb-3w">
<label class="fr-label" for="search-api">Rechercher</label>
<input id="search-api" class="fr-input" type="search" placeholder="Ex : auth async v1.0, openapi latest..."
[value]="searchQuery()"
(input)="onSearchChange($any($event.target).value)" />
</div>
@if (loading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
@@ -64,134 +95,187 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
</div>
}
@if (!loading() && browseData()) {
<!-- Sous-catégories -->
@if (browseData()!.subcategories.length > 0) {
<h2 class="fr-h4 fr-mb-3w">Catégories</h2>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
@for (cat of browseData()!.subcategories; track cat.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
</h3>
@if (cat.description) {
<p class="fr-tile__desc">{{ cat.description }}</p>
}
<p class="fr-tile__detail">
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
</p>
<div style="position:relative;z-index:1;margin-top:0.5rem;display:flex;gap:0.25rem;order:5">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-edit-line"
(click)="openEdit(cat); $event.stopPropagation()">
Modifier
</button>
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-delete-bin-line"
(click)="deleteCategory(cat); $event.stopPropagation()">
Supprimer
@if (!isSearchMode()) {
@if (!loading() && browseData()) {
<!-- Sous-catégories -->
@if (browseData()!.subcategories.length > 0) {
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
@for (cat of browseData()!.subcategories; track cat.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
<button title="Modifier"
style="position:relative;z-index:1;background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
(click)="openEdit(cat); $event.stopPropagation()">
<span class="fr-icon-edit-line" aria-hidden="true" style="font-size:0.75rem"></span>
</button>
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
<button title="Supprimer"
style="position:relative;z-index:1;background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
(click)="openDelete(cat); $event.stopPropagation()">
<span class="fr-icon-delete-bin-line" aria-hidden="true" style="font-size:0.75rem"></span>
</button>
}
</h3>
@if (cat.description) {
<p class="fr-tile__desc">{{ cat.description }}</p>
}
<p class="fr-tile__detail">
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
</p>
</div>
</div>
</div>
</div>
}
</div>
}
<!-- APIs -->
@if (browseData()!.apis.length > 0) {
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of browseData()!.apis; track api.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
</h3>
@if (api.description) {
<p class="fr-tile__desc">{{ api.description }}</p>
}
<p class="fr-tile__detail">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
&nbsp;<span class="fr-badge fr-badge--sm">v{{ api.version }}</span>
@if (api.isCurrent) {
&nbsp;<span class="fr-badge fr-badge--success fr-badge--sm">Courante</span>
}
</p>
</div>
</div>
</div>
</div>
}
</div>
}
@if (browseData()!.subcategories.length === 0 && browseData()!.apis.length === 0) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
</div>
}
}
} @else {
<!-- Mode recherche -->
@if (searchLoading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (!searchLoading()) {
<!-- Dossiers -->
@if (searchCategoryResults().length > 0) {
@if (searchResults().length > 0) {
<p class="fr-text--sm fr-text--bold fr-mb-2w">Dossiers</p>
}
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
@for (cat of searchCategoryResults(); track cat.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
</h3>
@if (cat.description) {
<p class="fr-tile__desc">{{ cat.description }}</p>
}
</div>
</div>
</div>
</div>
</div>
}
</div>
}
}
</div>
}
<!-- APIs -->
@if (browseData()!.apis.length > 0) {
<h2 class="fr-h4 fr-mb-3w">APIs</h2>
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of browseData()!.apis; track api.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
</h3>
@if (api.description) {
<p class="fr-tile__desc">{{ api.description }}</p>
}
<p class="fr-tile__detail">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
&nbsp;
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
</p>
<!-- APIs -->
@if (searchResults().length > 0) {
@if (searchCategoryResults().length > 0) {
<p class="fr-text--sm fr-text--bold fr-mb-2w">APIs</p>
}
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of searchResults(); track api.id) {
<div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
<div class="fr-card fr-card--sm fr-enlarge-link">
<div class="fr-card__body">
<div class="fr-card__content">
<h3 class="fr-card__title">
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
</h3>
@if (api.description) {
<p class="fr-card__desc">{{ api.description }}</p>
}
<div class="fr-badges-group fr-mt-1w">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
<span class="fr-badge fr-badge--sm">v{{ api.version }}</span>
@if (api.isCurrent) {
<span class="fr-badge fr-badge--success fr-badge--sm">Courante</span>
}
</div>
</div>
</div>
</div>
</div>
}
</div>
@if (searchTotalPages() > 1) {
<div class="fr-grid-row fr-grid-row--center fr-mt-4w">
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === 1"
(click)="setSearchPage(searchPage() - 1)">Précédent</button>
<span class="fr-mx-2w">Page {{ searchPage() }} / {{ searchTotalPages() }}</span>
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === searchTotalPages()"
(click)="setSearchPage(searchPage() + 1)">Suivant</button>
</div>
}
</div>
}
}
@if (browseData()!.subcategories.length === 0 && browseData()!.apis.length === 0) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
</div>
<!-- Aucun résultat -->
@if (searchCategoryResults().length === 0 && searchResults().length === 0) {
<p class="fr-text--lead">Aucun résultat pour « {{ searchQuery() }} »</p>
}
}
}
<!-- Modale création / édition (overlay Angular, sans fr-modal pour éviter l'interférence du JS DSFR) -->
@if (showFormModal()) {
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:540px;margin:0 1rem 2rem">
<div class="fr-p-4w">
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h2 id="modal-title" class="fr-h3 fr-mb-0">
{{ formMode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
(click)="closeModal()">✕</button>
</div>
</div>
@if (formError()) {
<div class="fr-alert fr-alert--error fr-mb-2w">
<p>{{ formError() }}</p>
</div>
}
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="form-name">
Nom <span class="fr-hint-text">Obligatoire</span>
</label>
<input id="form-name" class="fr-input" type="text"
[value]="formName()"
(input)="formName.set($any($event.target).value)" />
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="form-desc">
Description <span class="fr-hint-text">Optionnel</span>
</label>
<textarea id="form-desc" class="fr-input" rows="3"
[value]="formDescription()"
(input)="formDescription.set($any($event.target).value)"></textarea>
</div>
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="closeModal()">Annuler</button>
<button class="fr-btn" (click)="submitForm()" [disabled]="formLoading()">
{{ formMode() === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</div>
}
</div>
<!-- Modale de suppression -->
@if (deletingCategory()) {
<app-category-delete-modal
[category]="deletingCategory()!"
(confirmed)="onDeleteConfirmed()"
(cancelled)="deletingCategory.set(null)"
/>
}
<!-- Modale création / édition -->
@if (showFormModal()) {
<app-category-form-modal
[mode]="formMode()"
[category]="editingCategory()"
[parentId]="categoryId()"
(saved)="onFormSaved()"
(closed)="showFormModal.set(false)"
/>
}
`,
})
export class BrowseComponent implements OnInit {
private route = inject(ActivatedRoute);
private categoryService = inject(CategoryService);
private apiService = inject(ApiService);
private destroyRef = inject(DestroyRef);
browseData = signal<CategoryBrowseResponse | null>(null);
@@ -199,22 +283,38 @@ export class BrowseComponent implements OnInit {
error = signal<string | null>(null);
categoryId = signal<string | null>(null);
/* Signaux pour la modale */
/* Signaux recherche */
searchQuery = signal('');
searchResults = signal<ApiEntryListItem[]>([]);
searchCategoryResults = signal<Category[]>([]);
searchTotal = signal(0);
searchPage = signal(1);
searchLoading = signal(false);
readonly searchLimit = 12;
isSearchMode = computed(() => this.searchQuery().trim().length > 0);
searchTotalPages = computed(() => Math.ceil(this.searchTotal() / this.searchLimit));
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
/* Signaux pour les modales */
showFormModal = signal(false);
formMode = signal<'create' | 'edit'>('create');
editingCategoryId = signal<string | null>(null);
formName = signal('');
formDescription = signal('');
formError = signal<string | null>(null);
formLoading = signal(false);
editingCategory = signal<Category | null>(null);
deletingCategory = signal<CategoryWithCounts | null>(null);
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
ancestors = computed<Category[]>(() => {
const d = this.browseData();
if (!d || !d.category) return [];
return d.breadcrumb.slice(0, -1);
});
backUrl = computed<string[]>(() => {
const anc = this.ancestors();
if (anc.length > 0) return ['/browse', anc[anc.length - 1].id];
return ['/browse'];
});
ngOnInit() {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -241,65 +341,65 @@ export class BrowseComponent implements OnInit {
openCreate() {
this.formMode.set('create');
this.editingCategoryId.set(null);
this.formName.set('');
this.formDescription.set('');
this.formError.set(null);
this.editingCategory.set(null);
this.showFormModal.set(true);
}
openEdit(cat: CategoryWithCounts) {
openEdit(cat: Category) {
this.formMode.set('edit');
this.editingCategoryId.set(cat.id);
this.formName.set(cat.name);
this.formDescription.set(cat.description ?? '');
this.formError.set(null);
this.editingCategory.set(cat);
this.showFormModal.set(true);
}
closeModal() {
openDelete(cat: CategoryWithCounts) {
this.deletingCategory.set(cat);
}
onFormSaved() {
this.showFormModal.set(false);
this.loadBrowse();
}
submitForm() {
if (!this.formName().trim()) {
this.formError.set('Le nom est obligatoire.');
return;
onDeleteConfirmed() {
this.deletingCategory.set(null);
this.loadBrowse();
}
onSearchChange(value: string) {
this.searchQuery.set(value);
this.searchPage.set(1);
if (this.searchDebounce) clearTimeout(this.searchDebounce);
if (value.trim()) {
this.searchLoading.set(true);
this.searchDebounce = setTimeout(() => this.loadSearch(), 400);
} else {
this.searchLoading.set(false);
this.searchCategoryResults.set([]);
}
this.formLoading.set(true);
const name = this.formName().trim();
const description = this.formDescription().trim() || undefined;
}
const obs =
this.formMode() === 'create'
? this.categoryService.create({
name,
description,
parentId: this.categoryId() ?? undefined,
})
: this.categoryService.update(this.editingCategoryId()!, { name, description });
loadSearch() {
this.searchLoading.set(true);
const raw = this.searchQuery();
obs.subscribe({
next: () => {
this.formLoading.set(false);
this.showFormModal.set(false);
this.loadBrowse();
},
error: (err: { error?: { message?: string } }) => {
this.formLoading.set(false);
this.formError.set(err?.error?.message ?? 'Une erreur est survenue.');
forkJoin({
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
categories: this.categoryService.list(raw || undefined),
}).subscribe({
next: ({ apis, categories }) => {
this.searchResults.set(apis.items);
this.searchTotal.set(apis.total);
this.searchCategoryResults.set(categories.items);
this.searchLoading.set(false);
},
error: () => this.searchLoading.set(false),
});
}
deleteCategory(cat: CategoryWithCounts) {
if (cat.apiCount > 0 || cat.subcategoryCount > 0) return;
if (!confirm(`Supprimer le dossier "${cat.name}" ?`)) return;
this.categoryService.delete(cat.id).subscribe({
next: () => this.loadBrowse(),
error: (err: { error?: { message?: string } }) =>
alert(err?.error?.message ?? 'Erreur lors de la suppression.'),
});
setSearchPage(p: number) {
if (p < 1 || p > this.searchTotalPages()) return;
this.searchPage.set(p);
this.loadSearch();
}
typeBadgeClass(type: string): string {
@@ -308,9 +408,4 @@ export class BrowseComponent implements OnInit {
: 'fr-badge fr-badge--sm fr-badge--blue-ecume';
}
statusBadgeClass(status: string): string {
if (status === 'GENERATED') return 'fr-badge fr-badge--sm fr-badge--success';
if (status === 'ERROR') return 'fr-badge fr-badge--sm fr-badge--error';
return 'fr-badge fr-badge--sm fr-badge--info';
}
}

View File

@@ -41,7 +41,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared';
id="search-input"
class="fr-input"
type="search"
placeholder="Rechercher par titre ou description..."
placeholder="Ex : auth async v1.0, openapi latest..."
[ngModel]="search()"
(ngModelChange)="onSearchChange($event)"
/>
@@ -208,6 +208,7 @@ export class CatalogComponent implements OnInit {
const base = 'fr-badge';
if (status === 'GENERATED') return `${base} fr-badge--success`;
if (status === 'ERROR') return `${base} fr-badge--error`;
if (status === 'NO_YAML') return `${base} fr-badge--warning`;
return `${base} fr-badge--info`;
}

View File

@@ -6,12 +6,13 @@ import {
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared';
import { ApiEntry, ApiEntryListItem, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
@Component({
selector: 'app-upload',
@@ -31,17 +32,32 @@ import { Category } from '@datacat/shared';
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
<!-- Stepper -->
<div class="fr-stepper fr-mb-4w">
<h2 class="fr-stepper__title">
{{ stepTitles[step() - 1] }}
<span class="fr-stepper__state">Étape {{ step() }} sur 3</span>
</h2>
<div
class="fr-stepper__steps"
[attr.data-fr-current-step]="step()"
data-fr-steps="3"
></div>
<!-- Stepper numéroté -->
<div class="fr-mb-4w" style="display:flex;align-items:flex-start;">
@for (title of stepTitles; track $index) {
<div style="display:flex;flex-direction:column;align-items:center;flex:none;min-width:6rem;">
<div
[style.background]="step() > $index + 1 ? '#1f8d49' : step() === $index + 1 ? '#000091' : '#e5e5e5'"
[style.color]="step() >= $index + 1 ? 'white' : '#666'"
style="width:2rem;height:2rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:0.875rem;transition:background 0.2s;">
@if (step() > $index + 1) {
<span class="fr-icon-check-line" aria-hidden="true" style="font-size:0.875rem"></span>
} @else {
{{ $index + 1 }}
}
</div>
<span style="font-size:0.75rem;margin-top:0.4rem;text-align:center;line-height:1.2;"
[style.font-weight]="step() === $index + 1 ? '700' : '400'"
[style.color]="step() === $index + 1 ? '#000091' : step() > $index + 1 ? '#1f8d49' : '#666'">
{{ title }}
</span>
</div>
@if ($index < stepTitles.length - 1) {
<div style="flex:1;height:2px;margin-top:1rem;transition:background 0.2s;"
[style.background]="step() > $index + 1 ? '#1f8d49' : '#e5e5e5'">
</div>
}
}
</div>
<!-- Étape 1 : Fichier(s) YAML -->
@@ -85,6 +101,26 @@ import { Category } from '@datacat/shared';
</div>
}
</div>
@if (validating()) {
<div class="fr-callout fr-mb-3w">
<p>Validation en cours...</p>
</div>
} @else if (validationDone()) {
@if (validationErrors().length > 0) {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Fichier(s) invalide(s)</p>
@for (err of validationErrors(); track err.file) {
<p>{{ err.file ? err.file + ' : ' : '' }}{{ err.message }}</p>
}
</div>
} @else {
<div class="fr-alert fr-alert--success fr-mb-3w">
<p class="fr-alert__title">Fichier(s) valide(s)</p>
<p>Le fichier a été validé avec succès.</p>
</div>
}
}
}
<div class="fr-btns-group fr-btns-group--inline-sm">
@@ -99,56 +135,64 @@ import { Category } from '@datacat/shared';
</div>
}
<!-- Étape 2 : Métadonnées -->
<!-- Étape 2 : Informations -->
@if (step() === 2) {
<!-- Bannière nouvelle version -->
@if (fromApi(); as origin) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-3w">
<p class="fr-callout__text">
Nouvelle version de : <strong>{{ origin.title }}</strong> (v{{ origin.version }})
— le nom et la convention sont verrouillés.
</p>
</div>
}
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="titleInvalid()">
<label class="fr-label" for="title-input">
Titre <span class="fr-hint-text">Obligatoire</span>
</label>
<input
id="title-input"
class="fr-input"
[class.fr-input--error]="titleInvalid()"
formControlName="title"
type="text"
placeholder="Nom de l'API"
/>
@if (titleInvalid()) {
<p class="fr-error-text">Le titre est obligatoire.</p>
}
</div>
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="versionInvalid()">
<label class="fr-label" for="version-input">
Version <span class="fr-hint-text">Obligatoire</span>
</label>
<input
id="version-input"
class="fr-input"
[class.fr-input--error]="versionInvalid()"
formControlName="version"
type="text"
placeholder="1.0.0"
/>
@if (versionInvalid()) {
<p class="fr-error-text">La version est obligatoire.</p>
}
</div>
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="type-input">
Type <span class="fr-hint-text">Obligatoire</span>
</label>
<select id="type-input" class="fr-select" formControlName="type">
<option value="ASYNCAPI">AsyncAPI</option>
<option value="OPENAPI">OpenAPI</option>
</select>
<!-- Titre : convention + nom -->
<p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-select-group" [class.fr-select-group--error]="conventionInvalid()">
<label class="fr-label" for="convention-input">Convention</label>
<select
id="convention-input"
class="fr-select"
[class.fr-select--error]="conventionInvalid()"
formControlName="convention"
>
<option value="CONSULTER">Consulter</option>
<option value="ENREGISTRER">Enregistrer</option>
<option value="ETRE_NOTIFIE">Être notifié</option>
</select>
@if (conventionInvalid()) {
<p class="fr-error-text">La convention est obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-8">
<div class="fr-input-group" [class.fr-input-group--error]="nameInvalid()">
<label class="fr-label" for="name-input">Nom de l'API</label>
<input
id="name-input"
class="fr-input"
[class.fr-input--error]="nameInvalid()"
formControlName="name"
type="text"
placeholder="Référentiel Adresses"
/>
@if (nameInvalid()) {
<p class="fr-error-text">Le nom est obligatoire.</p>
}
</div>
</div>
</div>
<p class="fr-hint-text fr-mb-3w">Titre complet : <strong>{{ previewTitle() }}</strong></p>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="category-input">
Catégorie <span class="fr-hint-text">Optionnel</span>
Catégorie
</label>
<select id="category-input" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option>
@@ -158,9 +202,42 @@ import { Category } from '@datacat/shared';
</select>
</div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="providerInvalid()">
<label class="fr-label" for="provider-input">
Fournisseur <span style="color:var(--text-default-error)">*</span>
</label>
<input
id="provider-input"
class="fr-input"
[class.fr-input--error]="providerInvalid()"
formControlName="provider"
type="text"
placeholder="DINUM, Direction XYZ..."
/>
@if (providerInvalid()) {
<p class="fr-error-text">Le fournisseur est obligatoire.</p>
}
</div>
<!-- Type -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="type-input">
Type <span style="color:var(--text-default-error)">*</span>
</label>
<select id="type-input" class="fr-select" formControlName="type">
<option value="ASYNCAPI">AsyncAPI</option>
<option value="OPENAPI">OpenAPI</option>
</select>
@if (detectedType()) {
<p class="fr-hint-text">Détecté automatiquement depuis le fichier YAML</p>
}
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="description-input">
Description <span class="fr-hint-text">Optionnel</span>
Description
</label>
<textarea
id="description-input"
@@ -171,86 +248,65 @@ import { Category } from '@datacat/shared';
></textarea>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="functional-doc-input">
Documentation fonctionnelle <span class="fr-hint-text">Optionnel</span>
</label>
<textarea
id="functional-doc-input"
class="fr-input"
formControlName="functionalDoc"
rows="4"
placeholder="Cas d'usage, contexte métier..."
></textarea>
<!-- Version X.Y.Z -->
<p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionMajorInvalid()">
<label class="fr-label" for="version-major-input">Majeure (X)</label>
<input
id="version-major-input"
class="fr-input"
[class.fr-input--error]="versionMajorInvalid()"
formControlName="versionMajor"
type="number"
min="0"
placeholder="1"
/>
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionMinorInvalid()">
<label class="fr-label" for="version-minor-input">Mineure (Y)</label>
<input
id="version-minor-input"
class="fr-input"
[class.fr-input--error]="versionMinorInvalid()"
formControlName="versionMinor"
type="number"
min="0"
placeholder="0"
/>
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionPatchInvalid()">
<label class="fr-label" for="version-patch-input">Correctif (Z)</label>
<input
id="version-patch-input"
class="fr-input"
[class.fr-input--error]="versionPatchInvalid()"
formControlName="versionPatch"
type="number"
min="0"
placeholder="0"
/>
</div>
</div>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="technical-doc-input">
Documentation technique <span class="fr-hint-text">Optionnel</span>
</label>
<textarea
id="technical-doc-input"
class="fr-input"
formControlName="technicalDoc"
rows="4"
placeholder="Guide d'intégration technique..."
></textarea>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="external-doc-url-input">
URL documentation externe <span class="fr-hint-text">Optionnel</span>
</label>
<input
id="external-doc-url-input"
class="fr-input"
formControlName="externalDocUrl"
type="text"
placeholder="https://..."
/>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="contact-functional-input">
Contact fonctionnel <span class="fr-hint-text">Optionnel</span>
</label>
<input
id="contact-functional-input"
class="fr-input"
formControlName="contactFunctional"
type="text"
placeholder="Nom, email, téléphone..."
/>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="contact-technical-input">
Contact technique <span class="fr-hint-text">Optionnel</span>
</label>
<input
id="contact-technical-input"
class="fr-input"
formControlName="contactTechnical"
type="text"
placeholder="Nom, email, téléphone..."
/>
</div>
<div class="fr-input-group fr-mb-4w">
<label class="fr-label" for="access-rights-input">
Droits d'accès <span class="fr-hint-text">Optionnel</span>
</label>
<textarea
id="access-rights-input"
class="fr-input"
formControlName="accessRights"
rows="3"
placeholder="Processus d'habilitation, conditions d'accès..."
></textarea>
</div>
@if (versionAlreadyExists()) {
<div class="fr-alert fr-alert--warning fr-mb-3w">
<p class="fr-alert__title">Version déjà existante</p>
<p>
La version {{ formValues().versionMajor }}.{{ formValues().versionMinor }}.{{ formValues().versionPatch }}
existe déjà pour cette API. Choisissez un numéro de version différent.
</p>
</div>
}
<div class="fr-btns-group fr-btns-group--inline-sm">
<button type="submit" class="fr-btn" [disabled]="metaForm.invalid">Suivant</button>
<button type="submit" class="fr-btn" [disabled]="versionAlreadyExists()">Suivant</button>
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
Retour
</button>
@@ -258,8 +314,97 @@ import { Category } from '@datacat/shared';
</form>
}
<!-- Étape 3 : Confirmation -->
<!-- Étape 3 : Contacts -->
@if (step() === 3) {
<form [formGroup]="metaForm" (ngSubmit)="goToStep4()" novalidate>
<!-- Contact métier -->
<p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalName')">
<label class="fr-label" for="cf-name-input">Nom et prénom</label>
<input id="cf-name-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalName')"
formControlName="contactFunctionalName" type="text" placeholder="Prénom Nom" />
@if (fieldInvalid('contactFunctionalName')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalEntity')">
<label class="fr-label" for="cf-entity-input">Entité</label>
<input id="cf-entity-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalEntity')"
formControlName="contactFunctionalEntity" type="text" placeholder="Direction XYZ" />
@if (fieldInvalid('contactFunctionalEntity')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalEmail')">
<label class="fr-label" for="cf-email-input">Email</label>
<input id="cf-email-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalEmail')"
formControlName="contactFunctionalEmail" type="email" placeholder="prenom.nom@example.fr" />
@if (fieldInvalid('contactFunctionalEmail')) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<!-- Contact technique -->
<p class="fr-label fr-mb-1w">Contact technique <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalName')">
<label class="fr-label" for="ct-name-input">Nom et prénom</label>
<input id="ct-name-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalName')"
formControlName="contactTechnicalName" type="text" placeholder="Prénom Nom" />
@if (fieldInvalid('contactTechnicalName')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEntity')">
<label class="fr-label" for="ct-entity-input">Entité</label>
<input id="ct-entity-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalEntity')"
formControlName="contactTechnicalEntity" type="text" placeholder="Équipe Technique" />
@if (fieldInvalid('contactTechnicalEntity')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEmail')">
<label class="fr-label" for="ct-email-input">Email</label>
<input id="ct-email-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalEmail')"
formControlName="contactTechnicalEmail" type="email" placeholder="tech@example.fr" />
@if (fieldInvalid('contactTechnicalEmail')) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<div class="fr-btns-group fr-btns-group--inline-sm">
<button type="submit" class="fr-btn">Suivant</button>
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(2)">
Retour
</button>
</div>
</form>
}
<!-- Étape 4 : Confirmation -->
@if (step() === 4) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
@@ -268,7 +413,9 @@ import { Category } from '@datacat/shared';
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
<dd>
@if (selectedFiles().length === 1) {
@if (selectedFiles().length === 0) {
<span class="fr-badge fr-badge--warning fr-badge--sm">Aucun fichier</span>
} @else if (selectedFiles().length === 1) {
{{ selectedFiles()[0].name }}
} @else {
@for (f of selectedFiles(); track f.name) {
@@ -284,15 +431,19 @@ import { Category } from '@datacat/shared';
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Type</dt>
<dd>{{ metaForm.value.type }}</dd>
<dd>{{ displayType() }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<div class="fr-col-12">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ metaForm.value.title }}</dd>
<dd>{{ previewTitle() }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Version</dt>
<dd>{{ metaForm.value.version }}</dd>
<dd>{{ metaForm.value.versionMajor }}.{{ metaForm.value.versionMinor }}.{{ metaForm.value.versionPatch }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Fournisseur</dt>
<dd>{{ metaForm.value.provider }}</dd>
</div>
@if (metaForm.value.categoryId) {
<div class="fr-col-12 fr-col-md-6">
@@ -306,6 +457,22 @@ import { Category } from '@datacat/shared';
<dd>{{ metaForm.value.description }}</dd>
</div>
}
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact métier</dt>
<dd>
{{ metaForm.value.contactFunctionalName }}<br>
<span class="fr-text--sm">{{ metaForm.value.contactFunctionalEntity }}</span><br>
<span class="fr-text--sm">{{ metaForm.value.contactFunctionalEmail }}</span>
</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact technique</dt>
<dd>
{{ metaForm.value.contactTechnicalName }}<br>
<span class="fr-text--sm">{{ metaForm.value.contactTechnicalEntity }}</span><br>
<span class="fr-text--sm">{{ metaForm.value.contactTechnicalEmail }}</span>
</dd>
</div>
</dl>
</div>
</div>
@@ -326,7 +493,7 @@ import { Category } from '@datacat/shared';
>
{{ uploading() ? 'Import en cours...' : 'Importer' }}
</button>
<button class="fr-btn fr-btn--secondary" (click)="step.set(2)" [disabled]="uploading()">
<button class="fr-btn fr-btn--secondary" (click)="step.set(3)" [disabled]="uploading()">
Retour
</button>
</div>
@@ -339,6 +506,7 @@ export class UploadComponent implements OnInit {
private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
private router = inject(Router);
private route = inject(ActivatedRoute);
step = signal(1);
selectedFiles = signal<File[]>([]);
@@ -347,50 +515,162 @@ export class UploadComponent implements OnInit {
fileError = signal<string | null>(null);
uploading = signal(false);
uploadError = signal<string | null>(null);
validating = signal(false);
validationDone = signal(false);
validationErrors = signal<Array<{ file: string; message: string }>>([]);
categories = signal<Category[]>([]);
/* API d'origine lors d'une création de nouvelle version (from=<id>) */
fromApi = signal<ApiEntry | null>(null);
/* Versions existantes de l'API d'origine (pour détecter les doublons) */
existingVersions = signal<ApiEntryListItem[]>([]);
readonly stepTitles = [
'Choisir le fichier',
'Renseigner les métadonnées',
'Informations',
'Contacts',
"Confirmer l'import",
];
private readonly INFO_FIELDS = [
'convention', 'name', 'provider', 'type',
'versionMajor', 'versionMinor', 'versionPatch',
];
private readonly CONTACT_FIELDS = [
'contactFunctionalName', 'contactFunctionalEntity', 'contactFunctionalEmail',
'contactTechnicalName', 'contactTechnicalEntity', 'contactTechnicalEmail',
];
metaForm = this.fb.group({
title: ['', Validators.required],
version: ['', Validators.required],
convention: ['CONSULTER', Validators.required],
name: ['', Validators.required],
provider: ['', Validators.required],
versionMajor: [0, [Validators.required, Validators.min(0)]],
versionMinor: [0, [Validators.required, Validators.min(0)]],
versionPatch: [0, [Validators.required, Validators.min(0)]],
type: ['ASYNCAPI', Validators.required],
categoryId: [''],
description: [''],
functionalDoc: [''],
technicalDoc: [''],
externalDocUrl: [''],
contactFunctional: [''],
contactTechnical: [''],
accessRights: [''],
contactFunctionalName: ['', Validators.required],
contactFunctionalEntity: ['', Validators.required],
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
contactTechnicalName: ['', Validators.required],
contactTechnicalEntity: ['', Validators.required],
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
});
/* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
previewTitle = computed(() => {
const values = this.formValues();
const origin = this.fromApi();
/* Quand convention/name sont désactivés, ils sont absents de valueChanges → fallback sur fromApi */
const convention = (values.convention ?? origin?.convention ?? 'CONSULTER') as ApiConvention;
const name = values.name ?? origin?.name ?? '';
return `${API_CONVENTION_LABELS[convention]} ${name}`.trim();
});
titleInvalid = computed(
() => !!(this.metaForm.get('title')?.invalid && this.metaForm.get('title')?.touched),
);
versionInvalid = computed(
() => !!(this.metaForm.get('version')?.invalid && this.metaForm.get('version')?.touched),
);
selectedCategoryName = computed(() => {
const id = this.metaForm.value.categoryId;
const id = this.formValues().categoryId;
if (!id) return '';
return this.categories().find((c) => c.id === id)?.name ?? '';
});
canProceedFromStep1 = computed(() => {
if (this.selectedFiles().length === 0) return false;
if (this.selectedFiles().length === 1) return true;
/* Plusieurs fichiers : un fichier principal doit être détecté */
return this.detectedMainFilename() !== null;
/* Vérifie si la version saisie existe déjà parmi les versions de l'API d'origine */
versionAlreadyExists = computed(() => {
const versions = this.existingVersions();
if (versions.length === 0) return false;
const values = this.formValues();
const candidate = `${values.versionMajor ?? 0}.${values.versionMinor ?? 0}.${values.versionPatch ?? 0}`;
return versions.some((v) => v.version === candidate);
});
/* Type effectif : getRawValue() inclut les champs désactivés */
displayType = computed(() => {
const origin = this.fromApi();
if (origin) return origin.type;
return (this.formValues().type ?? null) as 'ASYNCAPI' | 'OPENAPI' | null;
});
/* Méthodes de validation (pas de computed — la re-exécution est déclenchée par les événements DOM blur/input) */
conventionInvalid(): boolean {
return !!(this.metaForm.get('convention')?.invalid && this.metaForm.get('convention')?.touched);
}
nameInvalid(): boolean {
return !!(this.metaForm.get('name')?.invalid && this.metaForm.get('name')?.touched);
}
providerInvalid(): boolean {
return !!(this.metaForm.get('provider')?.invalid && this.metaForm.get('provider')?.touched);
}
versionMajorInvalid(): boolean {
return !!(this.metaForm.get('versionMajor')?.invalid && this.metaForm.get('versionMajor')?.touched);
}
versionMinorInvalid(): boolean {
return !!(this.metaForm.get('versionMinor')?.invalid && this.metaForm.get('versionMinor')?.touched);
}
versionPatchInvalid(): boolean {
return !!(this.metaForm.get('versionPatch')?.invalid && this.metaForm.get('versionPatch')?.touched);
}
canProceedFromStep1 = computed(() => {
if (this.selectedFiles().length === 0) return true; // fichier optionnel
if (this.validating()) return false;
return this.validationDone() && this.validationErrors().length === 0;
});
fieldInvalid(name: string): boolean {
const ctrl = this.metaForm.get(name);
return !!(ctrl?.invalid && ctrl?.touched);
}
ngOnInit() {
const params = this.route.snapshot.queryParamMap;
const preselectedCategoryId = params.get('categoryId');
const fromId = params.get('from');
this.categoryService.list().subscribe({
next: (res) => this.categories.set(res.items),
next: (res) => {
this.categories.set(res.items);
if (preselectedCategoryId) {
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
}
},
});
if (fromId) {
this.apiService.get(fromId).subscribe({
next: (api) => this.prefillFromApi(api),
error: () => {},
});
this.apiService.getVersions(fromId).subscribe({
next: (versions) => this.existingVersions.set(versions),
error: () => {},
});
}
}
private prefillFromApi(api: ApiEntry): void {
this.fromApi.set(api);
this.metaForm.patchValue({
convention: api.convention,
name: api.name,
provider: api.provider,
versionMajor: api.versionMajor,
versionMinor: api.versionMinor,
versionPatch: api.versionPatch + 1,
description: api.description ?? '',
categoryId: api.categoryId ?? '',
contactFunctionalName: api.contactFunctionalName,
contactFunctionalEntity: api.contactFunctionalEntity,
contactFunctionalEmail: api.contactFunctionalEmail,
contactTechnicalName: api.contactTechnicalName,
contactTechnicalEntity: api.contactTechnicalEntity,
contactTechnicalEmail: api.contactTechnicalEmail,
});
/* Verrouiller les champs qui définissent l'identité de l'API */
this.metaForm.get('convention')?.disable();
this.metaForm.get('name')?.disable();
this.metaForm.get('type')?.disable();
}
categoryLabel(cat: Category): string {
@@ -401,8 +681,15 @@ export class UploadComponent implements OnInit {
onFileChange(event: Event) {
this.fileError.set(null);
this.uploadError.set(null);
this.detectedType.set(null);
this.detectedMainFilename.set(null);
this.validationErrors.set([]);
this.validationDone.set(false);
/* Ne pas réactiver le type s'il est verrouillé par fromApi */
if (!this.fromApi()) {
this.metaForm.get('type')?.enable();
}
const input = event.target as HTMLInputElement;
const fileList = input.files;
@@ -423,6 +710,9 @@ export class UploadComponent implements OnInit {
/* Détection côté client du fichier principal (pré-remplissage du type) */
void this.detectMainFileClient(files);
/* Validation serveur immédiate */
this.runValidation(files);
}
goToStep2() {
@@ -431,51 +721,110 @@ export class UploadComponent implements OnInit {
}
goToStep3() {
if (this.metaForm.invalid) {
this.metaForm.markAllAsTouched();
const invalid = this.INFO_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
if (invalid) {
this.INFO_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched());
return;
}
if (this.versionAlreadyExists()) return;
this.step.set(3);
}
onSubmit() {
const files = this.selectedFiles();
if (files.length === 0 || this.metaForm.invalid) return;
goToStep4() {
const invalid = this.CONTACT_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
if (invalid) {
this.CONTACT_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched());
return;
}
this.step.set(4);
}
this.uploading.set(true);
this.uploadError.set(null);
private runValidation(files: File[]): void {
this.validating.set(true);
this.validationDone.set(false);
const formData = new FormData();
for (const f of files) {
formData.append('files', f);
}
formData.append('title', this.metaForm.value.title ?? '');
formData.append('version', this.metaForm.value.version ?? '');
formData.append('type', this.metaForm.value.type ?? 'ASYNCAPI');
if (this.metaForm.value.categoryId) {
formData.append('categoryId', this.metaForm.value.categoryId);
this.apiService.validate(formData).subscribe({
next: (result) => {
this.validating.set(false);
this.validationDone.set(true);
if (!result.valid) {
this.validationErrors.set(result.errors);
/* Ne pas réactiver le type s'il est verrouillé par fromApi */
if (!this.fromApi()) {
this.metaForm.get('type')?.enable();
}
} else {
this.validationErrors.set([]);
if (result.type) {
this.detectedType.set(result.type);
/* Ne pas modifier le type s'il est verrouillé par fromApi */
if (!this.fromApi()) {
this.metaForm.patchValue({ type: result.type });
this.metaForm.get('type')?.disable();
}
}
if (result.mainFile) {
this.detectedMainFilename.set(result.mainFile);
}
/* Pré-remplissage du formulaire depuis les métadonnées du fichier */
/* Ne pas écraser le nom s'il est verrouillé par fromApi */
if (result.name && !this.fromApi()) this.metaForm.patchValue({ name: result.name });
if (result.versionMajor !== null) this.metaForm.patchValue({ versionMajor: result.versionMajor });
if (result.versionMinor !== null) this.metaForm.patchValue({ versionMinor: result.versionMinor });
if (result.versionPatch !== null) this.metaForm.patchValue({ versionPatch: result.versionPatch });
if (result.description) this.metaForm.patchValue({ description: result.description });
}
},
error: (err) => {
this.validating.set(false);
this.validationDone.set(true);
this.validationErrors.set([{ file: '', message: err?.error?.message ?? 'Erreur de validation.' }]);
},
});
}
onSubmit() {
const files = this.selectedFiles();
this.metaForm.markAllAsTouched();
if (this.metaForm.invalid) {
/* Naviguer vers l'étape avec les erreurs pour les rendre visibles */
const infoInvalid = this.INFO_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
this.step.set(infoInvalid ? 2 : 3);
return;
}
if (this.metaForm.value.description) {
formData.append('description', this.metaForm.value.description);
this.uploading.set(true);
this.uploadError.set(null);
const raw = this.metaForm.getRawValue();
const formData = new FormData();
for (const f of files) {
formData.append('files', f);
}
if (this.metaForm.value.functionalDoc) {
formData.append('functionalDoc', this.metaForm.value.functionalDoc);
formData.append('convention', raw.convention ?? 'CONSULTER');
formData.append('name', raw.name ?? '');
formData.append('provider', raw.provider ?? '');
formData.append('versionMajor', String(raw.versionMajor ?? 0));
formData.append('versionMinor', String(raw.versionMinor ?? 0));
formData.append('versionPatch', String(raw.versionPatch ?? 0));
formData.append('type', raw.type ?? 'ASYNCAPI');
if (raw.categoryId) {
formData.append('categoryId', raw.categoryId);
}
if (this.metaForm.value.technicalDoc) {
formData.append('technicalDoc', this.metaForm.value.technicalDoc);
}
if (this.metaForm.value.externalDocUrl) {
formData.append('externalDocUrl', this.metaForm.value.externalDocUrl);
}
if (this.metaForm.value.contactFunctional) {
formData.append('contactFunctional', this.metaForm.value.contactFunctional);
}
if (this.metaForm.value.contactTechnical) {
formData.append('contactTechnical', this.metaForm.value.contactTechnical);
}
if (this.metaForm.value.accessRights) {
formData.append('accessRights', this.metaForm.value.accessRights);
if (raw.description) {
formData.append('description', raw.description);
}
formData.append('contactFunctionalName', raw.contactFunctionalName ?? '');
formData.append('contactFunctionalEntity', raw.contactFunctionalEntity ?? '');
formData.append('contactFunctionalEmail', raw.contactFunctionalEmail ?? '');
formData.append('contactTechnicalName', raw.contactTechnicalName ?? '');
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
this.apiService.upload(formData).subscribe({
next: (entry) => {
@@ -495,13 +844,13 @@ export class UploadComponent implements OnInit {
if (/^openapi\s*:/m.test(content)) {
this.detectedType.set('OPENAPI');
this.detectedMainFilename.set(file.name);
this.metaForm.patchValue({ type: 'OPENAPI' });
if (!this.fromApi()) this.metaForm.patchValue({ type: 'OPENAPI' });
return;
}
if (/^asyncapi\s*:/m.test(content)) {
this.detectedType.set('ASYNCAPI');
this.detectedMainFilename.set(file.name);
this.metaForm.patchValue({ type: 'ASYNCAPI' });
if (!this.fromApi()) this.metaForm.patchValue({ type: 'ASYNCAPI' });
return;
}
}

View File

@@ -0,0 +1,79 @@
import { Component, signal, inject, ChangeDetectionStrategy, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { CategoryWithCounts } from '@datacat/shared';
@Component({
selector: 'app-category-delete-modal',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
role="dialog" aria-modal="true" aria-labelledby="delete-modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:480px;margin:0 1rem 2rem">
<div class="fr-p-4w">
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h2 id="delete-modal-title" class="fr-h3 fr-mb-0">Supprimer le dossier</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
(click)="cancel()">✕</button>
</div>
</div>
<p class="fr-text--lead fr-mb-1w">
Êtes-vous sûr de vouloir supprimer le dossier
<strong>« {{ category().name }} »</strong> ?
</p>
<p class="fr-text--sm fr-text--mention-grey fr-mb-3w">
Cette action est irréversible.
</p>
@if (deleteError()) {
<div class="fr-alert fr-alert--error fr-mb-2w">
<p>{{ deleteError() }}</p>
</div>
}
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="cancel()"
[disabled]="deleteLoading()">Annuler</button>
<button class="fr-btn fr-btn--error" (click)="confirmDelete()"
[disabled]="deleteLoading()">
{{ deleteLoading() ? 'Suppression...' : 'Supprimer' }}
</button>
</div>
</div>
</div>
</div>
`,
})
export class CategoryDeleteModalComponent {
private categoryService = inject(CategoryService);
category = input.required<CategoryWithCounts>();
confirmed = output<void>();
cancelled = output<void>();
deleteLoading = signal(false);
deleteError = signal<string | null>(null);
cancel() {
this.cancelled.emit();
}
confirmDelete() {
const cat = this.category();
this.deleteLoading.set(true);
this.deleteError.set(null);
this.categoryService.delete(cat.id).subscribe({
next: () => {
this.deleteLoading.set(false);
this.confirmed.emit();
},
error: (err: { error?: { message?: string } }) => {
this.deleteLoading.set(false);
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
},
});
}
}

View File

@@ -0,0 +1,173 @@
import {
Component,
signal,
computed,
inject,
OnInit,
ChangeDetectionStrategy,
input,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared';
@Component({
selector: 'app-category-form-modal',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:540px;margin:0 1rem 2rem">
<div class="fr-p-4w">
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h2 id="modal-title" class="fr-h3 fr-mb-0">
{{ mode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
(click)="close()">✕</button>
</div>
</div>
@if (formError()) {
<div class="fr-alert fr-alert--error fr-mb-2w">
<p>{{ formError() }}</p>
</div>
}
@if (mode() === 'create') {
<div class="fr-select-group fr-mb-2w">
<label class="fr-label" for="form-parent">Emplacement</label>
<select id="form-parent" class="fr-select"
(change)="formParentId.set($any($event.target).value || null)">
<option value="" [selected]="!formParentId()"></option>
@for (opt of categoriesForSelect(); track opt.id) {
<option [value]="opt.id" [selected]="opt.id === formParentId()">{{ opt.label }}</option>
}
</select>
</div>
}
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="formNameError()">
<label class="fr-label" for="form-name">
Nom <span style="color:var(--text-default-error)">*</span>
</label>
<input id="form-name" class="fr-input" [class.fr-input--error]="formNameError()" type="text"
[value]="formName()"
(input)="formName.set($any($event.target).value); formNameError.set(false)"
(blur)="formNameError.set(!formName().trim())" />
@if (formNameError()) { <p class="fr-error-text">Le nom est obligatoire.</p> }
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="form-desc">Description</label>
<textarea id="form-desc" class="fr-input" rows="3"
[value]="formDescription()"
(input)="formDescription.set($any($event.target).value)"></textarea>
</div>
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="close()">Annuler</button>
<button class="fr-btn" (click)="submit()" [disabled]="formLoading()">
{{ mode() === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</div>
`,
})
export class CategoryFormModalComponent implements OnInit {
private categoryService = inject(CategoryService);
mode = input<'create' | 'edit'>('create');
category = input<Category | null>(null);
parentId = input<string | null>(null);
saved = output<void>();
closed = output<void>();
formName = signal('');
formDescription = signal('');
formParentId = signal<string | null>(null);
formError = signal<string | null>(null);
formNameError = signal(false);
formLoading = signal(false);
allCategories = signal<Category[]>([]);
editingCategoryId = signal<string | null>(null);
categoriesForSelect = computed<{ id: string; label: string }[]>(() => {
const cats = this.allCategories();
const map = new Map(cats.map((c) => [c.id, c]));
const getSortPath = (id: string, depth = 0): string => {
if (depth > 10) return '';
const cat = map.get(id);
if (!cat) return '';
if (!cat.parentId) return cat.name;
return `${getSortPath(cat.parentId, depth + 1)} / ${cat.name}`;
};
const getDepth = (id: string, depth = 0): number => {
if (depth > 10) return depth;
const cat = map.get(id);
if (!cat?.parentId) return depth;
return getDepth(cat.parentId, depth + 1);
};
return cats
.map((c) => {
const depth = getDepth(c.id);
const indent = '\u00a0\u00a0'.repeat(depth);
const prefix = depth > 0 ? `${indent}\u00a0` : '';
return { id: c.id, label: `${prefix}${c.name}`, _sort: getSortPath(c.id) };
})
.sort((a, b) => a._sort.localeCompare(b._sort, 'fr'))
.map(({ id, label }) => ({ id, label }));
});
ngOnInit() {
const cat = this.category();
if (this.mode() === 'edit' && cat) {
this.editingCategoryId.set(cat.id);
this.formName.set(cat.name);
this.formDescription.set(cat.description ?? '');
} else {
this.formParentId.set(this.parentId());
this.categoryService.list().subscribe({
next: (res) => this.allCategories.set(res.items),
error: () => this.allCategories.set([]),
});
}
}
close() {
this.closed.emit();
}
submit() {
if (!this.formName().trim()) {
this.formNameError.set(true);
return;
}
this.formNameError.set(false);
this.formLoading.set(true);
const name = this.formName().trim();
const description = this.formDescription().trim() || undefined;
const obs =
this.mode() === 'create'
? this.categoryService.create({ name, description, parentId: this.formParentId() ?? undefined })
: this.categoryService.update(this.editingCategoryId()!, { name, description });
obs.subscribe({
next: () => {
this.formLoading.set(false);
this.saved.emit();
},
error: (err: { error?: { message?: string } }) => {
this.formLoading.set(false);
this.formError.set(err?.error?.message ?? 'Une erreur est survenue.');
},
});
}
}

View File

@@ -21,7 +21,7 @@ services:
NODE_ENV: development
DOCS_OUTPUT_DIR: /app/docs-output
working_dir: /workspace/back
command: sh -c "cd /workspace && pnpm install && cd /workspace/back && pnpm run start:dev"
command: sh -c "cd /workspace && pnpm install && /workspace/back/node_modules/.bin/tsc --project /workspace/shared/tsconfig.build.json && cd /workspace/back && pnpm run start:dev"
networks:
- network
depends_on:

View File

@@ -2,6 +2,15 @@
"name": "datacat",
"version": "0.1.0",
"private": true,
"pnpm": {
"onlyBuiltDependencies": [
"@nestjs/core",
"@parcel/watcher",
"esbuild",
"lmdb",
"msgpackr-extract"
]
},
"scripts": {
"dev": "docker compose -f infra/docker-compose.yml up",
"dev:build": "docker compose -f infra/docker-compose.yml up --build",

11453
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -116,7 +116,7 @@ if command -v asyncapi &>/dev/null; then
ok "asyncapi CLI déjà installé ($(asyncapi --version 2>/dev/null || echo 'version inconnue'))"
else
info "Installation de @asyncapi/cli@6.0.0..."
npm install -g @asyncapi/cli@6.0.0
npm install -g @asyncapi/cli@6.0.0 --legacy-peer-deps
ok "asyncapi CLI installé"
fi

View File

@@ -2,6 +2,9 @@
"name": "@datacat/shared",
"version": "0.1.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts"
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc --project tsconfig.build.json"
}
}

View File

@@ -1,19 +1,3 @@
// Zod schemas for validation (used in backend)
// Install zod in back/ if needed: pnpm add zod
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const;
export interface CreateApiEntryDto {
title: string;
version: string;
description?: string;
type: 'ASYNCAPI' | 'OPENAPI';
}
export interface UpdateApiEntryDto {
title?: string;
version?: string;
description?: string;
categoryId?: string | null;
}
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;

View File

@@ -1,24 +1,39 @@
export type ApiType = 'ASYNCAPI' | 'OPENAPI';
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR';
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR' | 'NO_YAML';
export type ApiConvention = 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE';
export const API_CONVENTION_LABELS: Record<ApiConvention, string> = {
CONSULTER: 'Consulter',
ENREGISTRER: 'Enregistrer',
ETRE_NOTIFIE: 'Être notifié',
};
export interface ApiEntry {
id: string;
title: string;
name: string;
convention: ApiConvention;
version: string;
versionMajor: number;
versionMinor: number;
versionPatch: number;
description: string | null;
type: ApiType;
provider: string;
yamlContent: string;
htmlPath: string | null;
status: ApiStatus;
errorMessage: string | null;
categoryId: string | null;
functionalDoc: string | null;
technicalDoc: string | null;
externalDocUrl: string | null;
contactFunctional: string | null;
contactTechnical: string | null;
accessRights: string | null;
isCurrent: boolean;
contactFunctionalName: string;
contactFunctionalEntity: string;
contactFunctionalEmail: string;
contactTechnicalName: string;
contactTechnicalEntity: string;
contactTechnicalEmail: string;
createdAt: string;
updatedAt: string;
}
@@ -26,11 +41,15 @@ export interface ApiEntry {
export interface ApiEntryListItem {
id: string;
title: string;
name: string;
convention: ApiConvention;
version: string;
description: string | null;
type: ApiType;
provider: string;
status: ApiStatus;
categoryId: string | null;
isCurrent: boolean;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "ES2021",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"]
}