Compare commits

...

27 Commits

Author SHA1 Message Date
z3n
787d8f32ee back(test): specs validation env + DTO upload (48 -> 59 tests)
- env.validation.spec : config valide/coercion/défauts + rejets (var manquante,
  port hors plage, NODE_ENV inconnu)
- create-api-upload.dto.spec : name requis, emails @ValidateIf (vides OK,
  invalides rejetés), énum convention, MaxLength
- vitest-setup charge reflect-metadata (décorateurs class-validator en test)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:21:36 +00:00
z3n
d2d140640e front: extrait les templates inline vers des fichiers .html
upload (900->427 l.) et api-detail (700->330 l.) : le template inline géant
passe en templateUrl. Aucun changement de comportement (build + eslint a11y
+ runtime OK). Composants bien plus navigables ; les .html sont désormais
lintés par angular-eslint (a11y).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:19:21 +00:00
z3n
f64efda8a3 front: composant <app-breadcrumb> mutualisé (dédup markup DSFR)
Extrait le fil d'Ariane DSFR dupliqué dans browse/api-detail/upload vers un
composant présentationnel piloté par une liste d'items. Chaque page construit
ses items (computed pour browse/api-detail, statique pour upload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:17:18 +00:00
z3n
15e05cc46c front(upload): garde-fou de taille de fichier (5 Mo)
Rejette les fichiers trop volumineux à la sélection avec un message clair,
avant lecture FileReader/envoi serveur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 07:06:45 +00:00
z3n
5cdaa7d4a1 back(db): infra migrations TypeORM + migration initiale
- data-source.ts (CLI) + scripts migration:generate/run/revert/show
- migration InitialSchema : schéma complet (categories, api_entries),
  vérifiée en l'appliquant sur une base vierge (crée aussi l'ext uuid-ossp)
- app.module: charge les migrations et migrationsRun en prod uniquement.
  Corrige un trou : la prod avait synchronize:false SANS migrations
  (schéma vide au déploiement). Dev inchangé (synchronize:true).

Bascule de dev vers migrations-only (synchronize:false + baseline de la base
dev existante) = étape ultérieure à faire en supervisé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 07:05:30 +00:00
z3n
f9df2dd1bb front(test): Vitest + premier spec (ErrorService)
- Vitest (cohérent avec le back) pour les tests de logique/services purs ;
  vitest-setup charge @angular/compiler (fallback JIT requis pour importer
  les symboles @angular/* partiellement compilés)
- error.service.spec.ts : 6 cas couvrant toMessage()
- script test -> vitest run (+ test:watch). Tests de composants TestBed =
  plugin Angular dédié, hors périmètre pour l'instant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:17:00 +00:00
z3n
c9fa0cd97b front(lint): ESLint 9 flat config + angular-eslint 19
- eslint.config.js (flat) : recommended TS + angular-eslint (ts + templates
  a11y), règles selectors app + no-unused-vars (^_ ignoré)
- script lint -> 'eslint .' (+ lint:fix), évite la résolution du builder
  Angular sous pnpm
- fix de la seule violation (variable _ inutilisée dans l'effect catalog)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:13:54 +00:00
z3n
cd43c88294 back(test): coverage vitest (@vitest/coverage-v8)
Ajoute le provider de coverage + section coverage dans vitest.config (reporter
text/html/lcov, exclusions modules/entities/dto/main) et un script test:cov.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:11:44 +00:00
z3n
9151932eb6 back(uploads): DTO validé sur l'upload + contraintes de longueur
- CreateApiUploadDto : valide le multipart POST /uploads via ValidationPipe
  (types, @IsIn énums partagés, @IsUUID, @MaxLength(255)). Emails validés
  seulement si renseignés (@ValidateIf) -> n'casse pas les contacts vides.
  Remplace le body inline + les checks manuels du contrôleur.
- create-api-entry.dto: @MaxLength(255) sur name/provider/contacts (matche les
  colonnes entité) + @MaxLength sur emails.

Vérifié: email invalide -> 400 message clair, contacts vides -> 201 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:08:02 +00:00
z3n
c38adf3a7c front: stoppe les fuites de souscriptions RxJS (takeUntilDestroyed)
Toutes les souscriptions des composants passent par
takeUntilDestroyed(this.destroyRef) : browse, catalog, api-detail, upload,
category-form-modal, category-delete-modal. api-detail abandonne le pattern
manuel Subscription/ngOnDestroy au profit de DestroyRef (ngOnDestroy ne garde
que le clearPoll du setInterval).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:49:11 +00:00
z3n
34a83233f2 front: config env, error interceptor fonctionnel, DSFR en deps, fix build
- src/environments/{environment,environment.prod}.ts + fileReplacements :
  baseUrl API configurable au lieu de '/api' hardcodé dans les services
- ErrorService + errorInterceptor (HttpInterceptorFn) ; app.config passe à
  withInterceptors([...]) (remplace withInterceptorsFromDi legacy)
- @gouvfr/dsfr déplacé en dependencies (chargé au runtime)
- api.service: extrait ValidateResponse (dédup du type inline)
- fix: formValues passe en protected (utilisé dans le template -> build NG1
  cassé, le conteneur servait un ancien bundle)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:43:06 +00:00
z3n
f7f706e1e1 back(uploads): I/O fichiers en async (non bloquant)
readFileSync/existsSync/mkdirSync -> fs/promises (readFile, mkdir recursive
idempotent). detectMainFile devient async. Retire l'import fs sync inutilisé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:02:13 +00:00
z3n
38f6f6ab52 back: validation d'env, transaction setCurrent, fix N+1 catégories, logging
- ConfigModule.forRoot({ validate }) : schéma class-validator des variables
  d'env, l'app refuse de démarrer si une var manque/mal typée ; suppression
  des fallbacks de credentials DB (getOrThrow)
- setCurrent() enveloppé dans une transaction (plus de fenêtre incohérente
  sur isCurrent entre les 2 updates)
- browse() : compteurs de toutes les catégories en 2 requêtes + calcul en
  mémoire au lieu d'un N+1 récursif par enfant
- console.* -> Logger Nest partout (apis, uploads, seeder, bootstrap) ;
  .catch(console.error) -> logger.error avec contexte

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:00:13 +00:00
z3n
558bf763e7 shared: source de vérité unique pour les énums d'API
- API_STATUSES inclut 'NO_YAML' (était manquant alors que le type et le
  code l'utilisent)
- ApiType/ApiStatus/ApiConvention dérivés des constantes (typeof X[number])
  -> plus de double maintenance type <-> tableau
- DTO back réutilisent API_CONVENTIONS/API_TYPES dans @IsIn (fin de la
  triple duplication des valeurs)
- front: déclare la dépendance @datacat/shared (workspace:*)
- shared: exports map + métadonnées + README (double build front/back)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:54:27 +00:00
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
56 changed files with 3790 additions and 1385 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

@@ -9,41 +9,47 @@
"start:debug": "nest start --debug --watch",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/data-source.ts",
"migration:run": "typeorm-ts-node-commonjs migration:run -d src/data-source.ts",
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/data-source.ts",
"migration:show": "typeorm-ts-node-commonjs migration:show -d src/data-source.ts"
},
"dependencies": {
"@datacat/shared": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/typeorm": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/serve-static": "^5.0.0",
"typeorm": "^0.3.20",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"multer": "^1.4.5-lts.1",
"uuid": "^11.0.0",
"pino": "^9.0.0",
"pino-http": "^10.0.0",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.0",
"@asyncapi/cli": "6.0.0",
"@asyncapi/bundler": "^1.0.1",
"js-yaml": "^4.1.0",
"@asyncapi/cli": "6.0.0",
"@asyncapi/generator": "3.0.1",
"@asyncapi/html-template": "3.5.6",
"@redocly/cli": "latest"
"@datacat/shared": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/serve-static": "^5.0.0",
"@nestjs/typeorm": "^11.0.0",
"@redocly/cli": "latest",
"class-transformer": "^0.5.0",
"class-validator": "^0.14.0",
"js-yaml": "^4.1.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.0",
"pino": "^9.0.0",
"pino-http": "^10.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"typeorm": "^0.3.20",
"uuid": "^11.0.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/express": "^5.0.0",
"@types/js-yaml": "^4.0.9",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.0",
"@types/js-yaml": "^4.0.9",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.2.6",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}

View File

@@ -7,21 +7,26 @@ import { UploadsModule } from './modules/uploads/uploads.module';
import { DocsGenerationModule } from './modules/docs-generation/docs-generation.module';
import { CategoriesModule } from './modules/categories/categories.module';
import { SeederModule } from './modules/seeder/seeder.module';
import { validateEnv } from './config/env.validation';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DATABASE_HOST', 'localhost'),
port: config.get<number>('DATABASE_PORT', 5432),
username: config.get('DATABASE_USER', 'datacat'),
password: config.get('DATABASE_PASSWORD', 'datacat'),
database: config.get('DATABASE_NAME', 'datacat'),
host: config.getOrThrow<string>('DATABASE_HOST'),
port: config.getOrThrow<number>('DATABASE_PORT'),
username: config.getOrThrow<string>('DATABASE_USER'),
password: config.getOrThrow<string>('DATABASE_PASSWORD'),
database: config.getOrThrow<string>('DATABASE_NAME'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
// Dev : synchronize auto (itération rapide). Prod : pas de synchronize,
// le schéma est appliqué par les migrations exécutées au démarrage.
synchronize: config.get('NODE_ENV') !== 'production',
migrationsRun: config.get('NODE_ENV') === 'production',
logging: config.get('NODE_ENV') === 'development',
}),
}),

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { validateEnv } from './env.validation';
const base = {
DATABASE_HOST: 'localhost',
DATABASE_PORT: '5432',
DATABASE_USER: 'datacat',
DATABASE_PASSWORD: 'datacat',
DATABASE_NAME: 'datacat',
};
describe('validateEnv', () => {
it('accepte une config valide, coerce le port et applique les défauts', () => {
const cfg = validateEnv({ ...base });
expect(cfg.DATABASE_PORT).toBe(5432);
expect(typeof cfg.DATABASE_PORT).toBe('number');
expect(cfg.NODE_ENV).toBe('development');
expect(cfg.PORT).toBe(3000);
});
it('rejette une variable requise manquante (message explicite)', () => {
const { DATABASE_HOST: _omit, ...rest } = base;
expect(() => validateEnv(rest)).toThrow(/DATABASE_HOST/);
});
it('rejette un port hors plage', () => {
expect(() => validateEnv({ ...base, DATABASE_PORT: '70000' })).toThrow();
});
it('rejette un NODE_ENV inconnu', () => {
expect(() => validateEnv({ ...base, NODE_ENV: 'staging' })).toThrow();
});
});

View File

@@ -0,0 +1,76 @@
import { plainToInstance, Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
Min,
validateSync,
} from 'class-validator';
export enum NodeEnv {
Development = 'development',
Production = 'production',
Test = 'test',
}
/**
* Schéma des variables d'environnement. Validé au démarrage via `validateEnv`
* (branché sur `ConfigModule.forRoot({ validate })`) : l'app refuse de démarrer
* si une variable requise manque ou est mal typée, plutôt que d'utiliser
* silencieusement des valeurs par défaut (ex. credentials DB).
*/
export class EnvironmentVariables {
@IsEnum(NodeEnv)
@IsOptional()
NODE_ENV: NodeEnv = NodeEnv.Development;
@IsString()
@IsNotEmpty()
DATABASE_HOST!: string;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(65535)
DATABASE_PORT!: number;
@IsString()
@IsNotEmpty()
DATABASE_USER!: string;
@IsString()
@IsNotEmpty()
DATABASE_PASSWORD!: string;
@IsString()
@IsNotEmpty()
DATABASE_NAME!: string;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(65535)
@IsOptional()
PORT = 3000;
@IsString()
@IsOptional()
DOCS_OUTPUT_DIR?: string;
}
export function validateEnv(config: Record<string, unknown>): EnvironmentVariables {
const validated = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validated, { skipMissingProperties: false });
if (errors.length > 0) {
const details = errors
.map((e) => Object.values(e.constraints ?? {}).join(', '))
.join('\n - ');
throw new Error(`Configuration d'environnement invalide:\n - ${details}`);
}
return validated;
}

20
back/src/data-source.ts Normal file
View File

@@ -0,0 +1,20 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
/**
* DataSource dédié à la CLI TypeORM (migrations). Lit l'environnement
* directement (les mêmes DATABASE_* que l'app). Utilisé par les scripts
* migration:generate / migration:run / migration:revert.
*
* NB : la config runtime de l'app vit dans app.module.ts (TypeOrmModule).
*/
export default new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST ?? 'localhost',
port: Number(process.env.DATABASE_PORT ?? 5432),
username: process.env.DATABASE_USER ?? 'datacat',
password: process.env.DATABASE_PASSWORD ?? 'datacat',
database: process.env.DATABASE_NAME ?? 'datacat',
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
});

View File

@@ -1,5 +1,5 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, RequestMethod } from '@nestjs/common';
import { Logger, ValidationPipe, RequestMethod } from '@nestjs/common';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
@@ -16,7 +16,7 @@ async function bootstrap() {
const port = process.env.PORT ?? 3000;
await app.listen(port);
console.log(`Datacat API running on port ${port}`);
Logger.log(`Datacat API running on port ${port}`, 'Bootstrap');
}
bootstrap();

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialSchema1782543826810 implements MigrationInterface {
name = 'InitialSchema1782543826810'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "categories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "description" text, "parent_id" character varying(36), "order_index" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_24dbc6126a28ff948da33e97d3b" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "api_entries" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "convention" character varying(30) NOT NULL DEFAULT 'CONSULTER', "name" character varying(255) NOT NULL DEFAULT '', "description" text, "type" character varying(10) NOT NULL, "provider" character varying(255) NOT NULL DEFAULT '', "version_major" integer NOT NULL DEFAULT '0', "version_minor" integer NOT NULL DEFAULT '0', "version_patch" integer NOT NULL DEFAULT '0', "yaml_content" text NOT NULL, "html_path" character varying(500), "status" character varying(10) NOT NULL DEFAULT 'PENDING', "error_message" text, "category_id" character varying(36), "is_current" boolean NOT NULL DEFAULT true, "contact_functional_name" character varying(255) NOT NULL DEFAULT '', "contact_functional_entity" character varying(255) NOT NULL DEFAULT '', "contact_functional_email" character varying(255) NOT NULL DEFAULT '', "contact_technical_name" character varying(255) NOT NULL DEFAULT '', "contact_technical_entity" character varying(255) NOT NULL DEFAULT '', "contact_technical_email" character varying(255) NOT NULL DEFAULT '', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_d7b668541d2539a0c212b198d32" PRIMARY KEY ("id"))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "api_entries"`);
await queryRunner.query(`DROP TABLE "categories"`);
}
}

View File

@@ -45,6 +45,9 @@ export class ApiEntryEntity {
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
categoryId!: string | null;
@Column({ type: 'boolean', default: true, name: 'is_current' })
isCurrent!: boolean;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
contactFunctionalName!: string;

View File

@@ -13,6 +13,7 @@ export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
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);

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { ApisService } from './apis.service';
import { ApiType } from '@datacat/shared';
@@ -154,3 +155,199 @@ describe('ApisService.parseSearchTokens()', () => {
});
});
});
// ─── 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() };
const mockDataSource = {
transaction: vi.fn(async (cb: (m: unknown) => unknown) =>
cb({ getRepository: () => mockRepo }),
),
};
let service: ApisService;
beforeEach(() => {
vi.clearAllMocks();
service = new ApisService(mockRepo as any, mockDataSource 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,6 +1,6 @@
import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, Inject, forwardRef, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DataSource, 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';
@@ -17,14 +17,39 @@ interface InternalUpdateFields {
@Injectable()
export class ApisService {
private readonly logger = new Logger(ApisService.name);
constructor(
@InjectRepository(ApiEntryEntity)
private readonly repo: Repository<ApiEntryEntity>,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => DocsGenerationService))
private readonly docsService: DocsGenerationService,
) {}
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({
convention: dto.convention,
name: dto.name,
@@ -35,8 +60,9 @@ export class ApisService {
description: dto.description ?? null,
type: dto.type,
yamlContent: dto.yamlContent,
status: 'PENDING',
status: (dto.yamlContent ? 'PENDING' : 'NO_YAML') as ApiStatus,
categoryId: dto.categoryId ?? null,
isCurrent,
contactFunctionalName: dto.contactFunctionalName,
contactFunctionalEntity: dto.contactFunctionalEntity,
contactFunctionalEmail: dto.contactFunctionalEmail,
@@ -188,10 +214,26 @@ export class ApisService {
return entities.map(toApiEntryListItem);
}
async setCurrent(id: string): Promise<ApiEntry> {
const entry = await this.findOneOrThrow(id);
// Transaction : éviter une fenêtre où aucun (ou deux) sibling n'est courant.
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(ApiEntryEntity);
await repo.update(
{ name: entry.name, convention: entry.convention },
{ isCurrent: false },
);
await 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 });
this.docsService.generate(id).catch(console.error);
this.docsService
.generate(id)
.catch((err) => this.logger.error(`Génération docs échouée pour ${id}`, err));
return this.findOneOrThrow(id);
}
}
@@ -222,6 +264,7 @@ function toApiEntry(entity: ApiEntryEntity): ApiEntry {
status: entity.status,
errorMessage: entity.errorMessage,
categoryId: entity.categoryId,
isCurrent: entity.isCurrent,
contactFunctionalName: entity.contactFunctionalName,
contactFunctionalEntity: entity.contactFunctionalEntity,
contactFunctionalEmail: entity.contactFunctionalEmail,

View File

@@ -1,17 +1,19 @@
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator';
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail, MaxLength } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiType, ApiConvention } from '@datacat/shared';
import { ApiType, ApiConvention, API_CONVENTIONS, API_TYPES } from '@datacat/shared';
export class CreateApiEntryDto {
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
@IsIn([...API_CONVENTIONS])
convention!: ApiConvention;
@IsString()
@IsNotEmpty()
@MaxLength(255)
name!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
provider!: string;
@IsInt()
@@ -33,7 +35,7 @@ export class CreateApiEntryDto {
@IsOptional()
description?: string;
@IsIn(['ASYNCAPI', 'OPENAPI'])
@IsIn([...API_TYPES])
type!: ApiType;
@IsString()
@@ -46,23 +48,29 @@ export class CreateApiEntryDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
contactFunctionalName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
contactFunctionalEntity!: string;
@IsEmail()
@MaxLength(255)
contactFunctionalEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
contactTechnicalName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
contactTechnicalEntity!: string;
@IsEmail()
@MaxLength(255)
contactTechnicalEmail!: string;
}

View File

@@ -1,9 +1,9 @@
import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiConvention } from '@datacat/shared';
import { ApiConvention, API_CONVENTIONS } from '@datacat/shared';
export class UpdateApiEntryDto {
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
@IsIn([...API_CONVENTIONS])
@IsOptional()
convention?: ApiConvention;

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

@@ -98,11 +98,51 @@ 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);
/**
* Comptages pour TOUTES les catégories en 2 requêtes (au lieu d'un N+1
* récursif par enfant) : la hiérarchie et les APIs directes sont chargées en
* une fois, puis les compteurs récursifs sont calculés en mémoire.
*/
private async computeCategoryCounts(): Promise<{
subcategoryCount: Map<string, number>;
recursiveApiCount: Map<string, number>;
}> {
const categories = await this.categoryRepo.find({ select: ['id', 'parentId'] });
const directRows = await this.apiRepo
.createQueryBuilder('api')
.select('api.categoryId', 'categoryId')
.addSelect('COUNT(*)', 'count')
.where('api.categoryId IS NOT NULL')
.groupBy('api.categoryId')
.getRawMany<{ categoryId: string; count: string }>();
const directApiCount = new Map(directRows.map((r) => [r.categoryId, Number(r.count)]));
const childrenOf = new Map<string, string[]>();
for (const c of categories) {
if (c.parentId) {
const siblings = childrenOf.get(c.parentId) ?? [];
siblings.push(c.id);
childrenOf.set(c.parentId, siblings);
}
}
const recursiveApiCount = new Map<string, number>();
const computeRecursive = (id: string): number => {
const cached = recursiveApiCount.get(id);
if (cached !== undefined) return cached;
let total = directApiCount.get(id) ?? 0;
for (const childId of childrenOf.get(id) ?? []) total += computeRecursive(childId);
recursiveApiCount.set(id, total);
return total;
};
const subcategoryCount = new Map<string, number>();
for (const c of categories) {
subcategoryCount.set(c.id, (childrenOf.get(c.id) ?? []).length);
computeRecursive(c.id);
}
return { subcategoryCount, recursiveApiCount };
}
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
@@ -132,13 +172,12 @@ export class CategoriesService {
order: { orderIndex: 'ASC', name: 'ASC' },
});
const subcategories: CategoryWithCounts[] = await Promise.all(
directChildren.map(async (child) => {
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
const apiCount = await this.countApisRecursively(child.id);
return { ...toCategory(child), subcategoryCount, apiCount };
}),
);
const { subcategoryCount, recursiveApiCount } = await this.computeCategoryCounts();
const subcategories: CategoryWithCounts[] = directChildren.map((child) => ({
...toCategory(child),
subcategoryCount: subcategoryCount.get(child.id) ?? 0,
apiCount: recursiveApiCount.get(child.id) ?? 0,
}));
/* 4. APIs directement dans cette catégorie */
const apiEntities = await this.apiRepo.find({

View File

@@ -1,4 +1,4 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import * as fs from 'fs/promises';
@@ -587,6 +587,8 @@ components:
@Injectable()
export class SeederService implements OnApplicationBootstrap {
private readonly logger = new Logger(SeederService.name);
constructor(
@InjectRepository(ApiEntryEntity)
private readonly apiRepo: Repository<ApiEntryEntity>,
@@ -605,10 +607,41 @@ export class SeederService implements OnApplicationBootstrap {
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({});
@@ -617,7 +650,7 @@ export class SeederService implements OnApplicationBootstrap {
const docsDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
await fs.rm(docsDir, { recursive: true, force: true });
await fs.mkdir(docsDir, { recursive: true });
console.log('[Seeder] Reset complet — données supprimées, docs nettoyées.');
this.logger.log('Reset complet — données supprimées, docs nettoyées.');
}
private async seed(): Promise<void> {
@@ -697,6 +730,7 @@ export class SeederService implements OnApplicationBootstrap {
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',
@@ -775,6 +809,7 @@ export class SeederService implements OnApplicationBootstrap {
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',
@@ -795,6 +830,7 @@ export class SeederService implements OnApplicationBootstrap {
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',
@@ -830,11 +866,13 @@ export class SeederService implements OnApplicationBootstrap {
}),
]);
console.log('[Seeder] 8 catégories et 7 APIs de démo créées — génération docs en cours...');
this.logger.log('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) {
this.docsService.generate(entry.id).catch(console.error);
this.docsService
.generate(entry.id)
.catch((err) => this.logger.error(`Génération docs échouée pour ${entry.id}`, err));
}
}
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { CreateApiUploadDto } from './create-api-upload.dto';
function validate(payload: Record<string, unknown>) {
const dto = plainToInstance(CreateApiUploadDto, payload, { enableImplicitConversion: true });
return validateSync(dto, { whitelist: true });
}
describe('CreateApiUploadDto', () => {
it('accepte un payload minimal (name seul requis)', () => {
expect(validate({ name: 'Mon API' })).toHaveLength(0);
});
it('rejette un name manquant', () => {
expect(validate({}).some((e) => e.property === 'name')).toBe(true);
});
it('accepte des emails de contact vides (@ValidateIf)', () => {
expect(
validate({ name: 'X', contactFunctionalEmail: '', contactTechnicalEmail: '' }),
).toHaveLength(0);
});
it('rejette un email renseigné mais invalide', () => {
expect(
validate({ name: 'X', contactFunctionalEmail: 'pas-un-email' }).some(
(e) => e.property === 'contactFunctionalEmail',
),
).toBe(true);
});
it('accepte un email valide', () => {
expect(validate({ name: 'X', contactFunctionalEmail: 'a@b.fr' })).toHaveLength(0);
});
it('rejette une convention hors énumération', () => {
expect(
validate({ name: 'X', convention: 'FOO' }).some((e) => e.property === 'convention'),
).toBe(true);
});
it('rejette un name trop long (>255)', () => {
expect(
validate({ name: 'a'.repeat(256) }).some((e) => e.property === 'name'),
).toBe(true);
});
});

View File

@@ -0,0 +1,96 @@
import { Type } from 'class-transformer';
import {
IsEmail,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
import { ApiConvention, ApiType, API_CONVENTIONS, API_TYPES } from '@datacat/shared';
/**
* Corps du multipart POST /uploads. Les valeurs arrivent en `string` (form-data)
* et sont transformées par le ValidationPipe global (transform:true).
*
* Strictness alignée sur le comportement existant pour ne pas casser le front :
* seul `name` est réellement requis ; provider/contacts peuvent être vides ; les
* emails ne sont validés que s'ils sont renseignés (@ValidateIf).
*/
export class CreateApiUploadDto {
@IsIn([...API_CONVENTIONS])
@IsOptional()
convention?: ApiConvention;
@IsString()
@IsNotEmpty()
@MaxLength(255)
name!: string;
@IsString()
@IsOptional()
@MaxLength(255)
provider?: string;
@Type(() => Number)
@IsInt()
@Min(0)
@IsOptional()
versionMajor?: number;
@Type(() => Number)
@IsInt()
@Min(0)
@IsOptional()
versionMinor?: number;
@Type(() => Number)
@IsInt()
@Min(0)
@IsOptional()
versionPatch?: number;
@IsString()
@IsOptional()
description?: string;
@IsIn([...API_TYPES])
@IsOptional()
type?: ApiType;
@IsUUID()
@IsOptional()
categoryId?: string;
@IsString()
@IsOptional()
@MaxLength(255)
contactFunctionalName?: string;
@IsString()
@IsOptional()
@MaxLength(255)
contactFunctionalEntity?: string;
@ValidateIf((o: CreateApiUploadDto) => !!o.contactFunctionalEmail)
@IsEmail()
contactFunctionalEmail?: string;
@IsString()
@IsOptional()
@MaxLength(255)
contactTechnicalName?: string;
@IsString()
@IsOptional()
@MaxLength(255)
contactTechnicalEntity?: string;
@ValidateIf((o: CreateApiUploadDto) => !!o.contactTechnicalEmail)
@IsEmail()
contactTechnicalEmail?: string;
}

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

@@ -9,7 +9,6 @@ import {
} 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';
@@ -29,6 +28,7 @@ const execEnv = {
import { ApisService } from '../apis/apis.service';
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
import { ApiType } from '@datacat/shared';
import { CreateApiUploadDto } from './dto/create-api-upload.dto';
@Controller('uploads')
export class UploadsController {
@@ -69,11 +69,13 @@ export class UploadsController {
}
/* 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'),
}));
const fileContents = await Promise.all(
files.map(async (file) => ({
name: file.originalname,
path: file.path,
content: await fsPromises.readFile(file.path, 'utf-8'),
})),
);
/* Nettoyage immédiat des fichiers temporaires */
for (const file of files) {
@@ -133,18 +135,22 @@ export class UploadsController {
try {
if (detectedType === 'ASYNCAPI') {
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, timeout: 30000 });
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, shell: true, timeout: 30000 });
} else {
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, timeout: 30000 });
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, shell: true, timeout: 30000 });
}
} catch (cliError) {
const err = cliError as { stdout?: string; stderr?: string };
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 }],
};
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);
@@ -152,22 +158,7 @@ export class UploadsController {
/* Extraction des métadonnées depuis le fichier principal */
const mainContent = fileContents.find((f) => f.name === mainFileName)!.content;
const parsed = yaml.load(mainContent) 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;
/* Décomposer la version en major.minor.patch */
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];
}
const { name, description, versionMajor, versionMinor, versionPatch } = this.extractMetadata(mainContent);
return { valid: true, type: detectedType, mainFile: mainFileName, name, versionMajor, versionMinor, versionPatch, description, errors: [] };
}
@@ -186,41 +177,21 @@ export class UploadsController {
)
async upload(
@UploadedFiles() files: Express.Multer.File[],
@Body()
body: {
convention: string;
name: string;
provider: string;
versionMajor: string;
versionMinor: string;
versionPatch: string;
description?: string;
type?: ApiType;
categoryId?: string;
contactFunctionalName: string;
contactFunctionalEntity: string;
contactFunctionalEmail: string;
contactTechnicalName: string;
contactTechnicalEntity: string;
contactTechnicalEmail: string;
},
@Body() body: CreateApiUploadDto,
) {
if (!files || files.length === 0) throw new BadRequestException('YAML file 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 */
/* Crée le répertoire si absent (mkdir recursive est idempotent) */
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
await fsPromises.mkdir(uploadDir, { recursive: true });
let yamlContent: string;
let detectedType: ApiType | null = null;
if (files.length === 1) {
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');
yamlContent = await fsPromises.readFile(files[0].path, 'utf-8');
/* Validation syntaxique YAML */
this.validateYamlSyntax(yamlContent, files[0].originalname);
@@ -228,7 +199,7 @@ export class UploadsController {
detectedType = this.detectTypeFromContent(yamlContent);
} else {
/* Cas multi-fichiers : détecter le fichier principal et bundler */
const { mainFile, type } = this.detectMainFile(files);
const { mainFile, type } = await this.detectMainFile(files);
detectedType = type;
this.logger.log(`Multi-file upload: main=${mainFile.originalname}, type=${type}, total=${files.length} files`);
@@ -242,30 +213,61 @@ export class UploadsController {
}
const entry = await this.apisService.create({
convention: (body.convention as 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE') ?? 'CONSULTER',
convention: body.convention ?? '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,
provider: body.provider ?? '',
versionMajor: body.versionMajor ?? 0,
versionMinor: body.versionMinor ?? 0,
versionPatch: body.versionPatch ?? 0,
description: body.description,
type: apiType,
yamlContent,
categoryId: body.categoryId || undefined,
contactFunctionalName: body.contactFunctionalName,
contactFunctionalEntity: body.contactFunctionalEntity,
contactFunctionalEmail: body.contactFunctionalEmail,
contactTechnicalName: body.contactTechnicalName,
contactTechnicalEntity: body.contactTechnicalEntity,
contactTechnicalEmail: body.contactTechnicalEmail,
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((err) => this.logger.error(`Génération docs échouée pour ${entry.id}`, err));
}
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 {
@@ -288,9 +290,11 @@ export class UploadsController {
return null;
}
private detectMainFile(files: Express.Multer.File[]): { mainFile: Express.Multer.File; type: ApiType } {
private async detectMainFile(
files: Express.Multer.File[],
): Promise<{ mainFile: Express.Multer.File; type: ApiType }> {
for (const file of files) {
const content = fs.readFileSync(file.path, 'utf-8');
const content = await fsPromises.readFile(file.path, 'utf-8');
const type = this.detectTypeFromContent(content);
if (type) {
return { mainFile: file, type };

3
back/vitest-setup.ts Normal file
View File

@@ -0,0 +1,3 @@
// Requis pour que les décorateurs class-validator / class-transformer
// (Reflect.getMetadata) fonctionnent dans les tests, comme dans l'app (main.ts).
import 'reflect-metadata';

View File

@@ -4,6 +4,20 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./vitest-setup.ts'],
include: ['src/**/*.spec.ts'],
coverage: {
provider: 'v8',
reportsDirectory: './coverage',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: [
'src/**/*.spec.ts',
'src/**/*.module.ts',
'src/**/*.entity.ts',
'src/main.ts',
'src/**/*.dto.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

@@ -57,7 +57,13 @@
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
],
"outputHashing": "all"
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"development": {
"optimization": false,

View File

@@ -0,0 +1,38 @@
// @ts-check
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
module.exports = tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', '.angular/**', 'coverage/**'],
},
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{ type: 'attribute', prefix: 'app', style: 'camelCase' },
],
'@angular-eslint/component-selector': [
'error',
{ type: 'element', prefix: 'app', style: 'kebab-case' },
],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
{
files: ['**/*.html'],
extends: [
...angular.configs.templateRecommended,
...angular.configs.templateAccessibility,
],
rules: {},
},
);

View File

@@ -6,8 +6,10 @@
"start": "ng serve",
"build": "ng build",
"build:prod": "ng build --configuration production",
"test": "ng test --watch=false --browsers=ChromeHeadless",
"lint": "ng lint"
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@angular/animations": "^19.0.0",
@@ -18,6 +20,8 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@datacat/shared": "workspace:*",
"@gouvfr/dsfr": "^1.13.0",
"rxjs": "^7.8.0",
"tslib": "^2.8.0",
"zone.js": "^0.15.0"
@@ -26,9 +30,14 @@
"@angular-devkit/build-angular": "^19.0.0",
"@angular/cli": "^19.0.0",
"@angular/compiler-cli": "^19.0.0",
"@eslint/js": "^9.39.4",
"@types/node": "^22.0.0",
"@gouvfr/dsfr": "^1.13.0",
"angular-eslint": "^19.8.1",
"eslint": "^9.39.4",
"jsdom": "^29.1.1",
"sass": "^1.83.0",
"typescript": "~5.8.3",
"sass": "^1.83.0"
"typescript-eslint": "^8.62.0",
"vitest": "^3.2.4"
}
}

View File

@@ -1,12 +1,13 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { errorInterceptor } from './core/error.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClient(withInterceptors([errorInterceptor])),
],
};

View File

@@ -1,12 +1,14 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery } from '@datacat/shared';
import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery, ApiType } from '@datacat/shared';
import { environment } from '../../environments/environment';
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
private baseUrl = '/api/apis';
private baseUrl = `${environment.apiBaseUrl}/apis`;
private uploadsUrl = `${environment.apiBaseUrl}/uploads`;
list(query?: ApiListQuery): Observable<ApiListResponse> {
const params: Record<string, string> = {};
@@ -49,35 +51,32 @@ export class ApiService {
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`, {});
}
upload(formData: FormData): Observable<ApiEntry> {
return this.http.post<ApiEntry>('/api/uploads', formData);
return this.http.post<ApiEntry>(this.uploadsUrl, 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);
validate(formData: FormData): Observable<ValidateResponse> {
return this.http.post<ValidateResponse>(`${this.uploadsUrl}/validate`, formData);
}
}
/** Réponse de l'endpoint de validation d'upload (POST /uploads/validate). */
export interface ValidateResponse {
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 }>;
}

View File

@@ -6,11 +6,12 @@ import {
CategoryListResponse,
CategoryBrowseResponse,
} from '@datacat/shared';
import { environment } from '../../environments/environment';
@Injectable({ providedIn: 'root' })
export class CategoryService {
private http = inject(HttpClient);
private baseUrl = '/api/categories';
private baseUrl = `${environment.apiBaseUrl}/categories`;
list(search?: string): Observable<CategoryListResponse> {
const params: Record<string, string> = {};

View File

@@ -0,0 +1,18 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { ErrorService } from './error.service';
import { catchError, throwError } from 'rxjs';
/**
* Interceptor HTTP fonctionnel : journalise et normalise les erreurs réseau en
* un message lisible (via ErrorService), puis relaie l'erreur aux appelants.
*/
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const errorService = inject(ErrorService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
errorService.log(req.method, req.url, error);
return throwError(() => error);
}),
);
};

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorService } from './error.service';
describe('ErrorService.toMessage', () => {
const svc = new ErrorService();
it('retourne un body string', () => {
const err = new HttpErrorResponse({ error: 'Boom', status: 500 });
expect(svc.toMessage(err)).toBe('Boom');
});
it('extrait body.message', () => {
const err = new HttpErrorResponse({ error: { message: 'Champ invalide' }, status: 400 });
expect(svc.toMessage(err)).toBe('Champ invalide');
});
it('joint un body.message tableau', () => {
const err = new HttpErrorResponse({ error: { message: ['a', 'b'] }, status: 400 });
expect(svc.toMessage(err)).toBe('a, b');
});
it('status 0 -> serveur injoignable', () => {
const err = new HttpErrorResponse({ status: 0 });
expect(svc.toMessage(err)).toContain('injoignable');
});
it('Error standard -> son message', () => {
expect(svc.toMessage(new Error('oops'))).toBe('oops');
});
it('valeur inconnue -> fallback', () => {
expect(svc.toMessage(null)).toBe('Une erreur est survenue.');
expect(svc.toMessage(undefined, 'custom')).toBe('custom');
});
});

View File

@@ -0,0 +1,32 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
/**
* Centralise la traduction d'une erreur HTTP en message lisible et son
* journal. Les composants appellent `toMessage(err)` plutôt que de
* réimplémenter chacun leur extraction de message.
*/
@Injectable({ providedIn: 'root' })
export class ErrorService {
/** Extrait un message lisible depuis une erreur HTTP (ou autre). */
toMessage(error: unknown, fallback = 'Une erreur est survenue.'): string {
if (error instanceof HttpErrorResponse) {
const body = error.error;
if (typeof body === 'string' && body.trim()) return body;
if (body && typeof body === 'object') {
const message = (body as { message?: unknown }).message;
if (typeof message === 'string' && message) return message;
if (Array.isArray(message) && message.length) return message.join(', ');
}
if (error.status === 0) return 'Serveur injoignable. Vérifiez votre connexion.';
return error.message || fallback;
}
if (error instanceof Error && error.message) return error.message;
return fallback;
}
/** Journalise une erreur réseau (appelé par l'interceptor). */
log(method: string, url: string, error: HttpErrorResponse): void {
console.error(`[HTTP ${error.status}] ${method} ${url}${this.toMessage(error)}`);
}
}

View File

@@ -0,0 +1,369 @@
<div class="fr-container fr-mt-4w fr-mb-4w">
<app-breadcrumb [items]="breadcrumbItems()" />
@if (loading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (error()) {
<div class="fr-alert fr-alert--error fr-mb-4w">
<p class="fr-alert__title">Erreur</p>
<p>{{ error() }}</p>
</div>
}
@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-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>
} @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>
}
</select>
}
</div>
<!-- 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>
<!-- 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 (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>

View File

@@ -3,411 +3,26 @@ import {
signal,
computed,
inject,
DestroyRef,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 { CategoryService } from '../../core/category.service';
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
@Component({
selector: 'app-api-detail',
standalone: true,
imports: [CommonModule, RouterLink, ReactiveFormsModule],
imports: [CommonModule, RouterLink, ReactiveFormsModule, BreadcrumbComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
<!-- Fil d'Ariane -->
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
<li>
<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...' }}
</a>
</li>
</ol>
</nav>
@if (loading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (error()) {
<div class="fr-alert fr-alert--error fr-mb-4w">
<p class="fr-alert__title">Erreur</p>
<p>{{ error() }}</p>
</div>
}
@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-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>
} @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>
}
</select>
}
</div>
<!-- Droite : bouton Actions + dropdown -->
<div style="position:relative;flex-shrink:0;">
<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;">
<a [routerLink]="['/catalog', entry.id, 'docs']"
(click)="actionsOpen.set(false)"
style="display:block;padding:0.75rem 1rem;text-decoration:none;color:#161616;border-bottom:1px solid #eee;"
[style.opacity]="entry.status !== 'GENERATED' ? '0.5' : '1'"
[style.pointer-events]="entry.status !== 'GENERATED' ? 'none' : 'auto'">
Voir la documentation
</a>
<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>
<!-- 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 (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>
`,
templateUrl: "./api-detail.component.html",
})
export class ApiDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
@@ -415,6 +30,7 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
private fb = inject(FormBuilder);
private destroyRef = inject(DestroyRef);
api = signal<ApiEntry | null>(null);
versions = signal<ApiEntryListItem[]>([]);
@@ -433,6 +49,15 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
return ['/browse'];
});
breadcrumbItems = computed<BreadcrumbItem[]>(() => {
const crumbs = this.categoryBreadcrumb();
const items: BreadcrumbItem[] = [{ label: 'Accueil', link: '/' }];
if (crumbs.length === 0) items.push({ label: 'Parcourir', link: '/browse' });
for (const crumb of crumbs) items.push({ label: crumb.name, link: ['/browse', crumb.id] });
items.push({ label: this.api()?.title ?? 'Chargement...' });
return items;
});
/* Onglets */
activeTab = signal<'info' | 'contacts'>('info');
editingInfo = signal(false);
@@ -461,26 +86,29 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
});
private pollInterval: ReturnType<typeof setInterval> | null = null;
private routeSub: Subscription | null = null;
private id = '';
ngOnInit() {
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();
});
this.categoryService
.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((res) => this.categories.set(res.items));
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.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();
}
@@ -497,29 +125,38 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
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),
});
this.apiService
.setCurrent(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
this.settingCurrent.set(false);
this.apiService
.getVersions(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
},
error: () => this.settingCurrent.set(false),
});
}
onRegenerate() {
this.regenerating.set(true);
this.apiService.regenerate(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.regenerating.set(false);
this.startPollIfPending();
},
error: () => this.regenerating.set(false),
});
this.apiService
.regenerate(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
this.regenerating.set(false);
this.startPollIfPending();
},
error: () => this.regenerating.set(false),
});
}
startEditInfo() {
@@ -567,10 +204,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
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),
});
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
error: () => this.savingInfo.set(false),
});
}
submitContacts() {
@@ -586,56 +225,81 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
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),
});
})
.pipe(takeUntilDestroyed(this.destroyRef))
.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);
this.apiService.delete(this.id).subscribe({
next: () => this.router.navigate(['/catalog']),
error: () => this.deleting.set(false),
});
/* 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)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
if (redirectTarget) {
this.router.navigate(['/catalog', redirectTarget.id]);
} else {
this.router.navigate(this.backUrl());
}
},
error: () => this.deleting.set(false),
});
}
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({
next: (entry) => {
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');
this.loading.set(false);
},
});
this.apiService
.get(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
this.loading.set(false);
this.startPollIfPending();
this.apiService
.getVersions(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
if (entry.categoryId) {
this.categoryService
.browse(entry.categoryId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
error: () => {},
});
}
},
error: (err) => {
this.error.set(err.message ?? 'Erreur lors du chargement');
this.loading.set(false);
},
});
}
private startPollIfPending() {
@@ -646,12 +310,15 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
}
private pollStatus() {
this.apiService.get(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
if (entry.status !== 'PENDING') this.clearPoll();
},
});
this.apiService
.get(this.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.api.set(entry);
if (entry.status !== 'PENDING') this.clearPoll();
},
});
}
private clearPoll() {

View File

@@ -17,32 +17,16 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem
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';
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
@Component({
selector: 'app-browse',
standalone: true,
imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent],
imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent, BreadcrumbComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
<!-- Fil d'Ariane -->
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
<li>
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
</li>
@for (crumb of ancestors(); track crumb.id) {
<li>
<a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a>
</li>
}
@if (browseData()?.category) {
<li>
<a class="fr-breadcrumb__link" aria-current="page">{{ browseData()!.category!.name }}</a>
</li>
}
</ol>
</nav>
<app-breadcrumb [items]="breadcrumbItems()" />
<!-- En-tête -->
@if (categoryId()) {
@@ -309,6 +293,16 @@ export class BrowseComponent implements OnInit {
return d.breadcrumb.slice(0, -1);
});
breadcrumbItems = computed<BreadcrumbItem[]>(() => {
const items: BreadcrumbItem[] = [{ label: 'Accueil', link: '/' }];
for (const crumb of this.ancestors()) {
items.push({ label: crumb.name, link: ['/browse', crumb.id] });
}
const current = this.browseData()?.category;
if (current) items.push({ label: current.name });
return items;
});
backUrl = computed<string[]>(() => {
const anc = this.ancestors();
if (anc.length > 0) return ['/browse', anc[anc.length - 1].id];
@@ -327,16 +321,19 @@ export class BrowseComponent implements OnInit {
loadBrowse() {
this.loading.set(true);
this.error.set(null);
this.categoryService.browse(this.categoryId()).subscribe({
next: (data) => {
this.browseData.set(data);
this.loading.set(false);
},
error: () => {
this.error.set('Erreur lors du chargement des données.');
this.loading.set(false);
},
});
this.categoryService
.browse(this.categoryId())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data) => {
this.browseData.set(data);
this.loading.set(false);
},
error: () => {
this.error.set('Erreur lors du chargement des données.');
this.loading.set(false);
},
});
}
openCreate() {
@@ -381,20 +378,17 @@ export class BrowseComponent implements OnInit {
loadSearch() {
this.searchLoading.set(true);
const raw = this.searchQuery();
const q = raw.trim().toLowerCase();
forkJoin({
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
categories: this.categoryService.list(),
}).subscribe({
categories: this.categoryService.list(raw || undefined),
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: ({ apis, categories }) => {
this.searchResults.set(apis.items);
this.searchTotal.set(apis.total);
this.searchCategoryResults.set(
categories.items.filter(
(c) => c.name.toLowerCase().includes(q) || (c.description ?? '').toLowerCase().includes(q),
),
);
this.searchCategoryResults.set(categories.items);
this.searchLoading.set(false);
},
error: () => this.searchLoading.set(false),

View File

@@ -4,9 +4,11 @@ import {
computed,
effect,
inject,
DestroyRef,
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
@@ -154,6 +156,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared';
})
export class CatalogComponent implements OnInit {
private apiService = inject(ApiService);
private destroyRef = inject(DestroyRef);
apis = signal<ApiEntryListItem[]>([]);
loading = signal(false);
@@ -173,9 +176,9 @@ export class CatalogComponent implements OnInit {
private searchTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
/* Rechargement si page change */
/* Rechargement si page change (lecture du signal = dépendance de l'effet) */
effect(() => {
const _ = this.page();
this.page();
this.loadApis();
});
}
@@ -208,6 +211,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`;
}
@@ -220,6 +224,7 @@ export class CatalogComponent implements OnInit {
page: this.page(),
limit: this.limit,
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res) => {
this.apis.set(res.items);

View File

@@ -0,0 +1,472 @@
<div class="fr-container fr-mt-4w fr-mb-4w">
<app-breadcrumb [items]="breadcrumbItems" />
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
<!-- 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 -->
@if (step() === 1) {
<div class="fr-upload-group fr-mb-4w">
<label class="fr-label" for="file-input">
Fichier(s) YAML
<span class="fr-hint-text">
Formats acceptés : .yaml, .yml — Sélectionnez plusieurs fichiers si votre spec utilise des $ref
</span>
</label>
<input
id="file-input"
class="fr-upload"
type="file"
accept=".yaml,.yml"
multiple
(change)="onFileChange($event)"
/>
</div>
@if (fileError()) {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p>{{ fileError() }}</p>
</div>
}
@if (selectedFiles().length > 0) {
<div class="fr-mb-3w">
@for (file of selectedFiles(); track file.name) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-1w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
<span>
<strong>{{ file.name }}</strong>
({{ (file.size / 1024).toFixed(1) }} Ko)
</span>
@if (file.name === detectedMainFilename()) {
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
} @else if (selectedFiles().length > 1) {
<span class="fr-badge fr-badge--blue-cumulus fr-badge--sm">Secondaire</span>
}
</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">
<button
class="fr-btn"
(click)="goToStep2()"
[disabled]="!canProceedFromStep1()"
>
Suivant
</button>
<a routerLink="/catalog" class="fr-btn fr-btn--secondary">Annuler</a>
</div>
}
<!-- É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>
<!-- 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
</label>
<select id="category-input" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ categoryLabel(cat) }}</option>
}
</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
</label>
<textarea
id="description-input"
class="fr-input"
formControlName="description"
rows="3"
placeholder="Description de l'API..."
></textarea>
</div>
<!-- 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>
@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]="versionAlreadyExists()">Suivant</button>
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
Retour
</button>
</div>
</form>
}
<!-- É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">
<h2 class="fr-h5 fr-mb-3w">Récapitulatif</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
<dd>
@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) {
<div style="display:flex;align-items:center;gap:0.5rem;">
{{ f.name }}
@if (f.name === detectedMainFilename()) {
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
}
</div>
}
}
</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Type</dt>
<dd>{{ displayType() }}</dd>
</div>
<div class="fr-col-12">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ previewTitle() }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Version</dt>
<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">
<dt class="fr-text--bold">Catégorie</dt>
<dd>{{ selectedCategoryName() }}</dd>
</div>
}
@if (metaForm.value.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<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>
</div>
@if (uploadError()) {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Erreur lors de l'import</p>
<p>{{ uploadError() }}</p>
</div>
}
<div class="fr-btns-group fr-btns-group--inline-sm">
<button
class="fr-btn"
(click)="onSubmit()"
[disabled]="uploading()"
>
{{ uploading() ? 'Import en cours...' : 'Importer' }}
</button>
<button class="fr-btn fr-btn--secondary" (click)="step.set(3)" [disabled]="uploading()">
Retour
</button>
</div>
}
</div>

View File

@@ -3,437 +3,25 @@ import {
signal,
computed,
inject,
DestroyRef,
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service';
import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
import { ApiEntry, ApiEntryListItem, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
@Component({
selector: 'app-upload',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterLink],
imports: [CommonModule, ReactiveFormsModule, RouterLink, BreadcrumbComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
<!-- Fil d'Ariane -->
<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></li>
<li><a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a></li>
<li><a class="fr-breadcrumb__link" aria-current="page">Importer une API</a></li>
</ol>
</nav>
<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>
</div>
<!-- Étape 1 : Fichier(s) YAML -->
@if (step() === 1) {
<div class="fr-upload-group fr-mb-4w">
<label class="fr-label" for="file-input">
Fichier(s) YAML
<span class="fr-hint-text">
Formats acceptés : .yaml, .yml — Sélectionnez plusieurs fichiers si votre spec utilise des $ref
</span>
</label>
<input
id="file-input"
class="fr-upload"
type="file"
accept=".yaml,.yml"
multiple
(change)="onFileChange($event)"
/>
</div>
@if (fileError()) {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p>{{ fileError() }}</p>
</div>
}
@if (selectedFiles().length > 0) {
<div class="fr-mb-3w">
@for (file of selectedFiles(); track file.name) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-1w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
<span>
<strong>{{ file.name }}</strong>
({{ (file.size / 1024).toFixed(1) }} Ko)
</span>
@if (file.name === detectedMainFilename()) {
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
} @else if (selectedFiles().length > 1) {
<span class="fr-badge fr-badge--blue-cumulus fr-badge--sm">Secondaire</span>
}
</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">
<button
class="fr-btn"
(click)="goToStep2()"
[disabled]="!canProceedFromStep1()"
>
Suivant
</button>
<a routerLink="/catalog" class="fr-btn fr-btn--secondary">Annuler</a>
</div>
}
<!-- Étape 2 : Métadonnées -->
@if (step() === 2) {
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
<!-- 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
</label>
<select id="category-input" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ categoryLabel(cat) }}</option>
}
</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
</label>
<textarea
id="description-input"
class="fr-input"
formControlName="description"
rows="3"
placeholder="Description de l'API..."
></textarea>
</div>
<!-- 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-3w">
<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>
<!-- 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" [disabled]="metaForm.invalid">Suivant</button>
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
Retour
</button>
</div>
</form>
}
<!-- Étape 3 : Confirmation -->
@if (step() === 3) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-3w">Récapitulatif</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
<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) {
{{ selectedFiles()[0].name }}
} @else {
@for (f of selectedFiles(); track f.name) {
<div style="display:flex;align-items:center;gap:0.5rem;">
{{ f.name }}
@if (f.name === detectedMainFilename()) {
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
}
</div>
}
}
</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Type</dt>
<dd>{{ metaForm.value.type }}</dd>
</div>
<div class="fr-col-12">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ previewTitle() }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Version</dt>
<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">
<dt class="fr-text--bold">Catégorie</dt>
<dd>{{ selectedCategoryName() }}</dd>
</div>
}
@if (metaForm.value.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<dd>{{ metaForm.value.description }}</dd>
</div>
}
</dl>
</div>
</div>
</div>
@if (uploadError()) {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Erreur lors de l'import</p>
<p>{{ uploadError() }}</p>
</div>
}
<div class="fr-btns-group fr-btns-group--inline-sm">
<button
class="fr-btn"
(click)="onSubmit()"
[disabled]="uploading()"
>
{{ uploading() ? 'Import en cours...' : 'Importer' }}
</button>
<button class="fr-btn fr-btn--secondary" (click)="step.set(2)" [disabled]="uploading()">
Retour
</button>
</div>
}
</div>
`,
templateUrl: "./upload.component.html",
})
export class UploadComponent implements OnInit {
private fb = inject(FormBuilder);
@@ -441,8 +29,16 @@ export class UploadComponent implements OnInit {
private categoryService = inject(CategoryService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private destroyRef = inject(DestroyRef);
step = signal(1);
readonly breadcrumbItems: BreadcrumbItem[] = [
{ label: 'Parcourir', link: '/browse' },
{ label: 'Catalogue', link: '/catalog' },
{ label: 'Importer une API' },
];
private readonly maxFileSize = 5 * 1024 * 1024; // 5 Mo
selectedFiles = signal<File[]>([]);
detectedType = signal<'ASYNCAPI' | 'OPENAPI' | null>(null);
detectedMainFilename = signal<string | null>(null);
@@ -453,13 +49,28 @@ export class UploadComponent implements OnInit {
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({
convention: ['CONSULTER', Validators.required],
name: ['', Validators.required],
@@ -479,20 +90,39 @@ export class UploadComponent implements OnInit {
});
/* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
protected readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
previewTitle = computed(() => {
const values = this.formValues();
const convention = (values.convention ?? 'CONSULTER') as ApiConvention;
const name = values.name ?? '';
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();
});
selectedCategoryName = computed(() => {
const id = this.formValues().categoryId;
if (!id) return '';
return this.categories().find((c) => c.id === id)?.name ?? '';
});
/* 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);
@@ -513,9 +143,7 @@ export class UploadComponent implements OnInit {
return !!(this.metaForm.get('versionPatch')?.invalid && this.metaForm.get('versionPatch')?.touched);
}
canProceedFromStep1 = computed(() => {
/* Sans fichier, on peut toujours passer à l'étape 2 */
if (this.selectedFiles().length === 0) return true;
/* Avec fichier : validation serveur doit être terminée et sans erreur */
if (this.selectedFiles().length === 0) return true; // fichier optionnel
if (this.validating()) return false;
return this.validationDone() && this.validationErrors().length === 0;
});
@@ -530,24 +158,38 @@ export class UploadComponent implements OnInit {
const preselectedCategoryId = params.get('categoryId');
const fromId = params.get('from');
this.categoryService.list().subscribe({
next: (res) => {
this.categories.set(res.items);
if (preselectedCategoryId) {
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
}
},
});
this.categoryService
.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
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
.get(fromId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (api) => this.prefillFromApi(api),
error: () => {},
});
this.apiService
.getVersions(fromId)
.pipe(takeUntilDestroyed(this.destroyRef))
.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,
@@ -564,6 +206,10 @@ export class UploadComponent implements OnInit {
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 {
@@ -574,11 +220,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);
this.metaForm.get('type')?.enable();
/* 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;
@@ -595,6 +245,15 @@ export class UploadComponent implements OnInit {
return;
}
const tooLarge = files.find((f) => f.size > this.maxFileSize);
if (tooLarge) {
this.fileError.set(
`Fichier trop volumineux (${tooLarge.name}). Taille max : ${this.maxFileSize / (1024 * 1024)} Mo.`,
);
this.selectedFiles.set([]);
return;
}
this.selectedFiles.set(files);
/* Détection côté client du fichier principal (pré-remplissage du type) */
@@ -609,6 +268,25 @@ export class UploadComponent implements OnInit {
this.step.set(2);
}
goToStep3() {
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);
}
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);
}
private runValidation(files: File[]): void {
this.validating.set(true);
this.validationDone.set(false);
@@ -618,25 +296,35 @@ export class UploadComponent implements OnInit {
formData.append('files', f);
}
this.apiService.validate(formData).subscribe({
next: (result) => {
this.validating.set(false);
this.validationDone.set(true);
this.apiService
.validate(formData)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
this.validating.set(false);
this.validationDone.set(true);
if (!result.valid) {
this.validationErrors.set(result.errors);
this.metaForm.get('type')?.enable();
/* 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);
this.metaForm.patchValue({ type: result.type });
this.metaForm.get('type')?.disable();
/* 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 */
if (result.name) this.metaForm.patchValue({ name: result.name });
/* 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 });
@@ -651,17 +339,15 @@ export class UploadComponent implements OnInit {
});
}
goToStep3() {
if (this.metaForm.invalid) {
this.metaForm.markAllAsTouched();
return;
}
this.step.set(3);
}
onSubmit() {
const files = this.selectedFiles();
if (files.length === 0 || this.metaForm.invalid) return;
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;
}
this.uploading.set(true);
this.uploadError.set(null);
@@ -691,16 +377,19 @@ export class UploadComponent implements OnInit {
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
this.apiService.upload(formData).subscribe({
next: (entry) => {
this.uploading.set(false);
this.router.navigate(['/catalog', entry.id]);
},
error: (err) => {
this.uploading.set(false);
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
},
});
this.apiService
.upload(formData)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (entry) => {
this.uploading.set(false);
this.router.navigate(['/catalog', entry.id]);
},
error: (err) => {
this.uploading.set(false);
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
},
});
}
private async detectMainFileClient(files: File[]): Promise<void> {
@@ -709,13 +398,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,37 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
/** Un maillon du fil d'Ariane. `link` absent => élément courant (aria-current). */
export interface BreadcrumbItem {
label: string;
link?: string | unknown[];
}
/**
* Fil d'Ariane DSFR générique (présentationnel). Chaque page construit sa liste
* d'items et la passe ; le dernier item sans `link` est marqué comme courant.
*/
@Component({
selector: 'app-breadcrumb',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
@for (item of items(); track $index) {
<li>
@if (item.link) {
<a class="fr-breadcrumb__link" [routerLink]="item.link">{{ item.label }}</a>
} @else {
<a class="fr-breadcrumb__link" aria-current="page">{{ item.label }}</a>
}
</li>
}
</ol>
</nav>
`,
})
export class BreadcrumbComponent {
items = input.required<BreadcrumbItem[]>();
}

View File

@@ -1,4 +1,5 @@
import { Component, signal, inject, ChangeDetectionStrategy, input, output } from '@angular/core';
import { Component, signal, inject, DestroyRef, ChangeDetectionStrategy, input, output } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { CategoryWithCounts } from '@datacat/shared';
@@ -49,6 +50,7 @@ import { CategoryWithCounts } from '@datacat/shared';
})
export class CategoryDeleteModalComponent {
private categoryService = inject(CategoryService);
private destroyRef = inject(DestroyRef);
category = input.required<CategoryWithCounts>();
confirmed = output<void>();
@@ -65,15 +67,18 @@ export class CategoryDeleteModalComponent {
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.');
},
});
this.categoryService
.delete(cat.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.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

@@ -3,11 +3,13 @@ import {
signal,
computed,
inject,
DestroyRef,
OnInit,
ChangeDetectionStrategy,
input,
output,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared';
@@ -79,6 +81,7 @@ import { Category } from '@datacat/shared';
})
export class CategoryFormModalComponent implements OnInit {
private categoryService = inject(CategoryService);
private destroyRef = inject(DestroyRef);
mode = input<'create' | 'edit'>('create');
category = input<Category | null>(null);
@@ -133,10 +136,13 @@ export class CategoryFormModalComponent implements OnInit {
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([]),
});
this.categoryService
.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res) => this.allCategories.set(res.items),
error: () => this.allCategories.set([]),
});
}
}
@@ -159,7 +165,7 @@ export class CategoryFormModalComponent implements OnInit {
? this.categoryService.create({ name, description, parentId: this.formParentId() ?? undefined })
: this.categoryService.update(this.editingCategoryId()!, { name, description });
obs.subscribe({
obs.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: () => {
this.formLoading.set(false);
this.saved.emit();

View File

@@ -0,0 +1,4 @@
export const environment = {
production: true,
apiBaseUrl: '/api',
};

View File

@@ -0,0 +1,5 @@
export const environment = {
production: false,
// Préfixe des appels API (proxifié vers le back en dev, cf. proxy.conf*.json).
apiBaseUrl: '/api',
};

View File

@@ -0,0 +1,3 @@
// Charge le compilateur Angular pour permettre le fallback JIT en test
// (sinon l'import de symboles @angular/* partiellement compilés échoue).
import '@angular/compiler';

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
// Vitest pour les tests de logique/services purs (cohérent avec le back).
// Les tests de composants avec TestBed nécessiteraient un plugin Angular dédié
// (ex. @analogjs/vitest-angular) — hors périmètre pour l'instant.
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./vitest-setup.ts'],
include: ['src/**/*.spec.ts'],
},
});

1003
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

31
shared/README.md Normal file
View File

@@ -0,0 +1,31 @@
# @datacat/shared
Types et constantes partagés entre le **back** (NestJS) et le **front** (Angular).
## Contenu
- `src/types/` — interfaces et types (`ApiEntry`, `Category`, réponses d'API…).
- `src/schemas/api-entry.schema.ts`**source de vérité** des énumérations
(`API_TYPES`, `API_STATUSES`, `API_CONVENTIONS`). Les types `ApiType` /
`ApiStatus` / `ApiConvention` en sont **dérivés** (`typeof X[number]`), et la
validation back (`@IsIn`) réutilise ces mêmes constantes. Ajouter une valeur
dans le tableau suffit donc à propager type + validation.
## Double build (piège récurrent)
Ce paquet est consommé de **deux façons différentes** :
| Consommateur | Mécanisme | Config |
|--------------|-----------|--------|
| **front** (Angular) | alias TypeScript vers la **source** | `tsconfig.json` (ES2022) + `paths` dans `front-public/tsconfig.json` |
| **back** (NestJS) | paquet **compilé** (`dist/`) en CommonJS | `tsconfig.build.json` (CommonJS) → `dist/` |
⚠️ Le back lit `dist/`, pas la source. Après toute modification de `shared`, il
faut **rebuild** avant que le back voie le changement :
```sh
pnpm --filter @datacat/shared build
# (le docker-compose le fait déjà : voir infra/docker-compose.yml, étape tsc)
```
Le front, lui, voit la source directement (pas de rebuild nécessaire).

View File

@@ -1,9 +1,17 @@
{
"name": "@datacat/shared",
"version": "0.1.0",
"description": "Types et constantes partagés entre le back (NestJS) et le front (Angular) de Datacat.",
"license": "UNLICENSED",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json"
}

View File

@@ -1,3 +1,7 @@
// Source de vérité unique pour les énumérations d'API.
// Les types (`ApiType`, `ApiStatus`, `ApiConvention`) sont dérivés de ces
// constantes dans `../types/api-entry.types.ts`, et la validation back
// (class-validator `@IsIn`) les réutilise — pas de duplication.
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const;
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR', 'NO_YAML'] as const;
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;

View File

@@ -1,8 +1,12 @@
export type ApiType = 'ASYNCAPI' | 'OPENAPI';
import { API_TYPES, API_STATUSES, API_CONVENTIONS } from '../schemas/api-entry.schema';
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR';
// Types dérivés des constantes (source de vérité dans api-entry.schema.ts) :
// ajouter une valeur dans le tableau suffit à mettre à jour le type.
export type ApiType = (typeof API_TYPES)[number];
export type ApiConvention = 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE';
export type ApiStatus = (typeof API_STATUSES)[number];
export type ApiConvention = (typeof API_CONVENTIONS)[number];
export const API_CONVENTION_LABELS: Record<ApiConvention, string> = {
CONSULTER: 'Consulter',
@@ -27,6 +31,7 @@ export interface ApiEntry {
status: ApiStatus;
errorMessage: string | null;
categoryId: string | null;
isCurrent: boolean;
contactFunctionalName: string;
contactFunctionalEntity: string;
contactFunctionalEmail: string;
@@ -48,6 +53,7 @@ export interface ApiEntryListItem {
provider: string;
status: ApiStatus;
categoryId: string | null;
isCurrent: boolean;
createdAt: string;
updatedAt: string;
}