Compare commits
27 Commits
c8b3e393ea
...
chore/qual
| Author | SHA1 | Date | |
|---|---|---|---|
| 787d8f32ee | |||
| d2d140640e | |||
| f64efda8a3 | |||
| 15e05cc46c | |||
| 5cdaa7d4a1 | |||
| f9df2dd1bb | |||
| c9fa0cd97b | |||
| cd43c88294 | |||
| 9151932eb6 | |||
| c38adf3a7c | |||
| 34a83233f2 | |||
| f7f706e1e1 | |||
| 38f6f6ab52 | |||
| 558bf763e7 | |||
| 1a8bd0b7b4 | |||
| b6a52ce554 | |||
| 3b9ccecd69 | |||
| 60ed177335 | |||
| 403850f21d | |||
| 2e9191e6a2 | |||
| 14e5821ec0 | |||
| 3836db1e1f | |||
| f326efb7b0 | |||
| 68c714cc97 | |||
| ac35250a06 | |||
| 007a5d3fc8 | |||
| 0976c33059 |
@@ -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/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/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
|
- [`documentation/CLAUDE.md`](documentation/CLAUDE.md) — guide d'implémentation interne
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"include": "modules/seeder/assets/**/*.yaml",
|
||||||
|
"outDir": "./dist/back/src",
|
||||||
|
"watchAssets": true
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"entryFile": "back/src/main"
|
"entryFile": "back/src/main"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,41 +9,47 @@
|
|||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||||
"test": "vitest run",
|
"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": {
|
"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",
|
"@asyncapi/bundler": "^1.0.1",
|
||||||
"js-yaml": "^4.1.0",
|
"@asyncapi/cli": "6.0.0",
|
||||||
"@asyncapi/generator": "3.0.1",
|
"@asyncapi/generator": "3.0.1",
|
||||||
"@asyncapi/html-template": "3.5.6",
|
"@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": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.0",
|
"@nestjs/testing": "^11.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.6",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,26 @@ import { UploadsModule } from './modules/uploads/uploads.module';
|
|||||||
import { DocsGenerationModule } from './modules/docs-generation/docs-generation.module';
|
import { DocsGenerationModule } from './modules/docs-generation/docs-generation.module';
|
||||||
import { CategoriesModule } from './modules/categories/categories.module';
|
import { CategoriesModule } from './modules/categories/categories.module';
|
||||||
import { SeederModule } from './modules/seeder/seeder.module';
|
import { SeederModule } from './modules/seeder/seeder.module';
|
||||||
|
import { validateEnv } from './config/env.validation';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: config.get('DATABASE_HOST', 'localhost'),
|
host: config.getOrThrow<string>('DATABASE_HOST'),
|
||||||
port: config.get<number>('DATABASE_PORT', 5432),
|
port: config.getOrThrow<number>('DATABASE_PORT'),
|
||||||
username: config.get('DATABASE_USER', 'datacat'),
|
username: config.getOrThrow<string>('DATABASE_USER'),
|
||||||
password: config.get('DATABASE_PASSWORD', 'datacat'),
|
password: config.getOrThrow<string>('DATABASE_PASSWORD'),
|
||||||
database: config.get('DATABASE_NAME', 'datacat'),
|
database: config.getOrThrow<string>('DATABASE_NAME'),
|
||||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
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',
|
synchronize: config.get('NODE_ENV') !== 'production',
|
||||||
|
migrationsRun: config.get('NODE_ENV') === 'production',
|
||||||
logging: config.get('NODE_ENV') === 'development',
|
logging: config.get('NODE_ENV') === 'development',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
33
back/src/config/env.validation.spec.ts
Normal file
33
back/src/config/env.validation.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
76
back/src/config/env.validation.ts
Normal file
76
back/src/config/env.validation.ts
Normal 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
20
back/src/data-source.ts
Normal 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'],
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe, RequestMethod } from '@nestjs/common';
|
import { Logger, ValidationPipe, RequestMethod } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const port = process.env.PORT ?? 3000;
|
const port = process.env.PORT ?? 3000;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`Datacat API running on port ${port}`);
|
Logger.log(`Datacat API running on port ${port}`, 'Bootstrap');
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
16
back/src/migrations/1782543826810-InitialSchema.ts
Normal file
16
back/src/migrations/1782543826810-InitialSchema.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ export class ApiEntryEntity {
|
|||||||
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
|
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
|
||||||
categoryId!: string | null;
|
categoryId!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true, name: 'is_current' })
|
||||||
|
isCurrent!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
|
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
|
||||||
contactFunctionalName!: string;
|
contactFunctionalName!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
|||||||
provider: entity.provider,
|
provider: entity.provider,
|
||||||
status: entity.status,
|
status: entity.status,
|
||||||
categoryId: entity.categoryId,
|
categoryId: entity.categoryId,
|
||||||
|
isCurrent: entity.isCurrent,
|
||||||
createdAt: entity.createdAt.toISOString(),
|
createdAt: entity.createdAt.toISOString(),
|
||||||
updatedAt: entity.updatedAt.toISOString(),
|
updatedAt: entity.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export class ApisController {
|
|||||||
return this.apisService.remove(id);
|
return this.apisService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/set-current')
|
||||||
|
setCurrent(@Param('id') id: string) {
|
||||||
|
return this.apisService.setCurrent(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/regenerate')
|
@Post(':id/regenerate')
|
||||||
regenerate(@Param('id') id: string) {
|
regenerate(@Param('id') id: string) {
|
||||||
return this.apisService.regenerate(id);
|
return this.apisService.regenerate(id);
|
||||||
|
|||||||
@@ -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 { ApisService } from './apis.service';
|
||||||
import { ApiType } from '@datacat/shared';
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { ApiEntryEntity } from './api-entry.entity';
|
import { ApiEntryEntity } from './api-entry.entity';
|
||||||
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
||||||
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
||||||
@@ -17,14 +17,39 @@ interface InternalUpdateFields {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApisService {
|
export class ApisService {
|
||||||
|
private readonly logger = new Logger(ApisService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ApiEntryEntity)
|
@InjectRepository(ApiEntryEntity)
|
||||||
private readonly repo: Repository<ApiEntryEntity>,
|
private readonly repo: Repository<ApiEntryEntity>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => DocsGenerationService))
|
@Inject(forwardRef(() => DocsGenerationService))
|
||||||
private readonly docsService: DocsGenerationService,
|
private readonly docsService: DocsGenerationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
|
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({
|
const entity = this.repo.create({
|
||||||
convention: dto.convention,
|
convention: dto.convention,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
@@ -35,8 +60,9 @@ export class ApisService {
|
|||||||
description: dto.description ?? null,
|
description: dto.description ?? null,
|
||||||
type: dto.type,
|
type: dto.type,
|
||||||
yamlContent: dto.yamlContent,
|
yamlContent: dto.yamlContent,
|
||||||
status: 'PENDING',
|
status: (dto.yamlContent ? 'PENDING' : 'NO_YAML') as ApiStatus,
|
||||||
categoryId: dto.categoryId ?? null,
|
categoryId: dto.categoryId ?? null,
|
||||||
|
isCurrent,
|
||||||
contactFunctionalName: dto.contactFunctionalName,
|
contactFunctionalName: dto.contactFunctionalName,
|
||||||
contactFunctionalEntity: dto.contactFunctionalEntity,
|
contactFunctionalEntity: dto.contactFunctionalEntity,
|
||||||
contactFunctionalEmail: dto.contactFunctionalEmail,
|
contactFunctionalEmail: dto.contactFunctionalEmail,
|
||||||
@@ -188,10 +214,26 @@ export class ApisService {
|
|||||||
return entities.map(toApiEntryListItem);
|
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> {
|
async regenerate(id: string): Promise<ApiEntry> {
|
||||||
await this.findOneOrThrow(id);
|
await this.findOneOrThrow(id);
|
||||||
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
|
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);
|
return this.findOneOrThrow(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +264,7 @@ function toApiEntry(entity: ApiEntryEntity): ApiEntry {
|
|||||||
status: entity.status,
|
status: entity.status,
|
||||||
errorMessage: entity.errorMessage,
|
errorMessage: entity.errorMessage,
|
||||||
categoryId: entity.categoryId,
|
categoryId: entity.categoryId,
|
||||||
|
isCurrent: entity.isCurrent,
|
||||||
contactFunctionalName: entity.contactFunctionalName,
|
contactFunctionalName: entity.contactFunctionalName,
|
||||||
contactFunctionalEntity: entity.contactFunctionalEntity,
|
contactFunctionalEntity: entity.contactFunctionalEntity,
|
||||||
contactFunctionalEmail: entity.contactFunctionalEmail,
|
contactFunctionalEmail: entity.contactFunctionalEmail,
|
||||||
|
|||||||
@@ -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 { Type } from 'class-transformer';
|
||||||
import { ApiType, ApiConvention } from '@datacat/shared';
|
import { ApiType, ApiConvention, API_CONVENTIONS, API_TYPES } from '@datacat/shared';
|
||||||
|
|
||||||
export class CreateApiEntryDto {
|
export class CreateApiEntryDto {
|
||||||
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
|
@IsIn([...API_CONVENTIONS])
|
||||||
convention!: ApiConvention;
|
convention!: ApiConvention;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
provider!: string;
|
provider!: string;
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -33,7 +35,7 @@ export class CreateApiEntryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsIn(['ASYNCAPI', 'OPENAPI'])
|
@IsIn([...API_TYPES])
|
||||||
type!: ApiType;
|
type!: ApiType;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -46,23 +48,29 @@ export class CreateApiEntryDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
contactFunctionalName!: string;
|
contactFunctionalName!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
contactFunctionalEntity!: string;
|
contactFunctionalEntity!: string;
|
||||||
|
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
|
@MaxLength(255)
|
||||||
contactFunctionalEmail!: string;
|
contactFunctionalEmail!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
contactTechnicalName!: string;
|
contactTechnicalName!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
contactTechnicalEntity!: string;
|
contactTechnicalEntity!: string;
|
||||||
|
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
|
@MaxLength(255)
|
||||||
contactTechnicalEmail!: string;
|
contactTechnicalEmail!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator';
|
import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiConvention } from '@datacat/shared';
|
import { ApiConvention, API_CONVENTIONS } from '@datacat/shared';
|
||||||
|
|
||||||
export class UpdateApiEntryDto {
|
export class UpdateApiEntryDto {
|
||||||
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
|
@IsIn([...API_CONVENTIONS])
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
convention?: ApiConvention;
|
convention?: ApiConvention;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
|
Query,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CategoriesService } from './categories.service';
|
import { CategoriesService } from './categories.service';
|
||||||
@@ -17,8 +18,8 @@ export class CategoriesController {
|
|||||||
constructor(private readonly categoriesService: CategoriesService) {}
|
constructor(private readonly categoriesService: CategoriesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll() {
|
findAll(@Query('search') search?: string) {
|
||||||
return this.categoriesService.findAll();
|
return this.categoriesService.findAll(search);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Déclarés AVANT :id pour éviter que NestJS interprète "browse" comme un UUID */
|
/* Déclarés AVANT :id pour éviter que NestJS interprète "browse" comme un UUID */
|
||||||
|
|||||||
@@ -98,11 +98,51 @@ export class CategoriesService {
|
|||||||
await this.categoryRepo.delete(id);
|
await this.categoryRepo.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countApisRecursively(categoryId: string): Promise<number> {
|
/**
|
||||||
const direct = await this.apiRepo.count({ where: { categoryId } });
|
* Comptages pour TOUTES les catégories en 2 requêtes (au lieu d'un N+1
|
||||||
const children = await this.categoryRepo.find({ where: { parentId: categoryId }, select: ['id'] });
|
* récursif par enfant) : la hiérarchie et les APIs directes sont chargées en
|
||||||
const childCounts = await Promise.all(children.map((c) => this.countApisRecursively(c.id)));
|
* une fois, puis les compteurs récursifs sont calculés en mémoire.
|
||||||
return direct + childCounts.reduce((sum, n) => sum + n, 0);
|
*/
|
||||||
|
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> {
|
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
|
||||||
@@ -132,13 +172,12 @@ export class CategoriesService {
|
|||||||
order: { orderIndex: 'ASC', name: 'ASC' },
|
order: { orderIndex: 'ASC', name: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const subcategories: CategoryWithCounts[] = await Promise.all(
|
const { subcategoryCount, recursiveApiCount } = await this.computeCategoryCounts();
|
||||||
directChildren.map(async (child) => {
|
const subcategories: CategoryWithCounts[] = directChildren.map((child) => ({
|
||||||
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
|
...toCategory(child),
|
||||||
const apiCount = await this.countApisRecursively(child.id);
|
subcategoryCount: subcategoryCount.get(child.id) ?? 0,
|
||||||
return { ...toCategory(child), subcategoryCount, apiCount };
|
apiCount: recursiveApiCount.get(child.id) ?? 0,
|
||||||
}),
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
/* 4. APIs directement dans cette catégorie */
|
/* 4. APIs directement dans cette catégorie */
|
||||||
const apiEntities = await this.apiRepo.find({
|
const apiEntities = await this.apiRepo.find({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
@@ -587,6 +587,8 @@ components:
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SeederService implements OnApplicationBootstrap {
|
export class SeederService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(SeederService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ApiEntryEntity)
|
@InjectRepository(ApiEntryEntity)
|
||||||
private readonly apiRepo: Repository<ApiEntryEntity>,
|
private readonly apiRepo: Repository<ApiEntryEntity>,
|
||||||
@@ -605,10 +607,41 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
await this.reset();
|
await this.reset();
|
||||||
}
|
}
|
||||||
const count = await this.apiRepo.count();
|
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();
|
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> {
|
private async reset(): Promise<void> {
|
||||||
/* Supprimer toutes les entrées puis les catégories */
|
/* Supprimer toutes les entrées puis les catégories */
|
||||||
await this.apiRepo.delete({});
|
await this.apiRepo.delete({});
|
||||||
@@ -617,7 +650,7 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
const docsDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
|
const docsDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
|
||||||
await fs.rm(docsDir, { recursive: true, force: true });
|
await fs.rm(docsDir, { recursive: true, force: true });
|
||||||
await fs.mkdir(docsDir, { recursive: 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> {
|
private async seed(): Promise<void> {
|
||||||
@@ -697,6 +730,7 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
versionMajor: 3,
|
versionMajor: 3,
|
||||||
versionMinor: 0,
|
versionMinor: 0,
|
||||||
versionPatch: 4,
|
versionPatch: 4,
|
||||||
|
isCurrent: true,
|
||||||
description:
|
description:
|
||||||
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||||
type: 'OPENAPI',
|
type: 'OPENAPI',
|
||||||
@@ -775,6 +809,7 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
versionMajor: 3,
|
versionMajor: 3,
|
||||||
versionMinor: 0,
|
versionMinor: 0,
|
||||||
versionPatch: 3,
|
versionPatch: 3,
|
||||||
|
isCurrent: false,
|
||||||
description:
|
description:
|
||||||
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||||
type: 'OPENAPI',
|
type: 'OPENAPI',
|
||||||
@@ -795,6 +830,7 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
versionMajor: 3,
|
versionMajor: 3,
|
||||||
versionMinor: 0,
|
versionMinor: 0,
|
||||||
versionPatch: 2,
|
versionPatch: 2,
|
||||||
|
isCurrent: false,
|
||||||
description:
|
description:
|
||||||
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||||
type: 'OPENAPI',
|
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 */
|
/* Déclenche la génération de docs en fire-and-forget pour chaque API */
|
||||||
for (const entry of entries) {
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
back/src/modules/uploads/dto/create-api-upload.dto.spec.ts
Normal file
49
back/src/modules/uploads/dto/create-api-upload.dto.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
back/src/modules/uploads/dto/create-api-upload.dto.ts
Normal file
96
back/src/modules/uploads/dto/create-api-upload.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
93
back/src/modules/uploads/uploads.controller.spec.ts
Normal file
93
back/src/modules/uploads/uploads.controller.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as fsPromises from 'fs/promises';
|
import * as fsPromises from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -29,6 +28,7 @@ const execEnv = {
|
|||||||
import { ApisService } from '../apis/apis.service';
|
import { ApisService } from '../apis/apis.service';
|
||||||
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||||
import { ApiType } from '@datacat/shared';
|
import { ApiType } from '@datacat/shared';
|
||||||
|
import { CreateApiUploadDto } from './dto/create-api-upload.dto';
|
||||||
|
|
||||||
@Controller('uploads')
|
@Controller('uploads')
|
||||||
export class UploadsController {
|
export class UploadsController {
|
||||||
@@ -69,11 +69,13 @@ export class UploadsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Lire tous les contenus en mémoire avant toute suppression */
|
/* Lire tous les contenus en mémoire avant toute suppression */
|
||||||
const fileContents = files.map((file) => ({
|
const fileContents = await Promise.all(
|
||||||
name: file.originalname,
|
files.map(async (file) => ({
|
||||||
path: file.path,
|
name: file.originalname,
|
||||||
content: fs.readFileSync(file.path, 'utf-8'),
|
path: file.path,
|
||||||
}));
|
content: await fsPromises.readFile(file.path, 'utf-8'),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
/* Nettoyage immédiat des fichiers temporaires */
|
/* Nettoyage immédiat des fichiers temporaires */
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -133,18 +135,22 @@ export class UploadsController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (detectedType === 'ASYNCAPI') {
|
if (detectedType === 'ASYNCAPI') {
|
||||||
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, timeout: 30000 });
|
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, shell: true, timeout: 30000 });
|
||||||
} else {
|
} else {
|
||||||
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, timeout: 30000 });
|
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, shell: true, timeout: 30000 });
|
||||||
}
|
}
|
||||||
} catch (cliError) {
|
} catch (cliError) {
|
||||||
const err = cliError as { stdout?: string; stderr?: string };
|
const err = cliError as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
|
||||||
const output = (err.stdout || err.stderr || String(cliError)).trim();
|
/* CLI non installé (dev local sans Docker) → skip la validation complète */
|
||||||
return {
|
if (err.code !== 'ENOENT') {
|
||||||
valid: false, type: detectedType, mainFile: mainFileName,
|
const output = (err.stdout || err.stderr || String(cliError)).trim();
|
||||||
name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
|
return {
|
||||||
errors: [{ file: mainFileName, message: output }],
|
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 {
|
} finally {
|
||||||
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
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 */
|
/* Extraction des métadonnées depuis le fichier principal */
|
||||||
const mainContent = fileContents.find((f) => f.name === mainFileName)!.content;
|
const mainContent = fileContents.find((f) => f.name === mainFileName)!.content;
|
||||||
const parsed = yaml.load(mainContent) as Record<string, unknown>;
|
const { name, description, versionMajor, versionMinor, versionPatch } = this.extractMetadata(mainContent);
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true, type: detectedType, mainFile: mainFileName, name, versionMajor, versionMinor, versionPatch, description, errors: [] };
|
return { valid: true, type: detectedType, mainFile: mainFileName, name, versionMajor, versionMinor, versionPatch, description, errors: [] };
|
||||||
}
|
}
|
||||||
@@ -186,41 +177,21 @@ export class UploadsController {
|
|||||||
)
|
)
|
||||||
async upload(
|
async upload(
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Body()
|
@Body() body: CreateApiUploadDto,
|
||||||
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;
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
if (!files || files.length === 0) throw new BadRequestException('YAML file is required');
|
/* Crée le répertoire si absent (mkdir recursive est idempotent) */
|
||||||
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 */
|
|
||||||
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
|
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
|
||||||
if (!fs.existsSync(uploadDir)) {
|
await fsPromises.mkdir(uploadDir, { recursive: true });
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
let yamlContent: string;
|
let yamlContent: string;
|
||||||
let detectedType: ApiType | null = null;
|
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 */
|
/* 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 */
|
/* Validation syntaxique YAML */
|
||||||
this.validateYamlSyntax(yamlContent, files[0].originalname);
|
this.validateYamlSyntax(yamlContent, files[0].originalname);
|
||||||
@@ -228,7 +199,7 @@ export class UploadsController {
|
|||||||
detectedType = this.detectTypeFromContent(yamlContent);
|
detectedType = this.detectTypeFromContent(yamlContent);
|
||||||
} else {
|
} else {
|
||||||
/* Cas multi-fichiers : détecter le fichier principal et bundler */
|
/* 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;
|
detectedType = type;
|
||||||
|
|
||||||
this.logger.log(`Multi-file upload: main=${mainFile.originalname}, type=${type}, total=${files.length} files`);
|
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({
|
const entry = await this.apisService.create({
|
||||||
convention: (body.convention as 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE') ?? 'CONSULTER',
|
convention: body.convention ?? 'CONSULTER',
|
||||||
name: body.name,
|
name: body.name,
|
||||||
provider: body.provider,
|
provider: body.provider ?? '',
|
||||||
versionMajor: parseInt(body.versionMajor, 10) || 0,
|
versionMajor: body.versionMajor ?? 0,
|
||||||
versionMinor: parseInt(body.versionMinor, 10) || 0,
|
versionMinor: body.versionMinor ?? 0,
|
||||||
versionPatch: parseInt(body.versionPatch, 10) || 0,
|
versionPatch: body.versionPatch ?? 0,
|
||||||
description: body.description,
|
description: body.description,
|
||||||
type: apiType,
|
type: apiType,
|
||||||
yamlContent,
|
yamlContent,
|
||||||
categoryId: body.categoryId || undefined,
|
categoryId: body.categoryId || undefined,
|
||||||
contactFunctionalName: body.contactFunctionalName,
|
contactFunctionalName: body.contactFunctionalName ?? '',
|
||||||
contactFunctionalEntity: body.contactFunctionalEntity,
|
contactFunctionalEntity: body.contactFunctionalEntity ?? '',
|
||||||
contactFunctionalEmail: body.contactFunctionalEmail,
|
contactFunctionalEmail: body.contactFunctionalEmail ?? '',
|
||||||
contactTechnicalName: body.contactTechnicalName,
|
contactTechnicalName: body.contactTechnicalName ?? '',
|
||||||
contactTechnicalEntity: body.contactTechnicalEntity,
|
contactTechnicalEntity: body.contactTechnicalEntity ?? '',
|
||||||
contactTechnicalEmail: body.contactTechnicalEmail,
|
contactTechnicalEmail: body.contactTechnicalEmail ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Fire-and-forget : génération asynchrone */
|
/* Fire-and-forget : génération asynchrone (seulement si YAML fourni) */
|
||||||
this.docsService.generate(entry.id).catch(console.error);
|
if (yamlContent) {
|
||||||
|
this.docsService
|
||||||
|
.generate(entry.id)
|
||||||
|
.catch((err) => this.logger.error(`Génération docs échouée pour ${entry.id}`, err));
|
||||||
|
}
|
||||||
|
|
||||||
return entry;
|
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 */
|
/** Valide la syntaxe YAML et lève une BadRequestException si invalide */
|
||||||
private validateYamlSyntax(content: string, filename: string): void {
|
private validateYamlSyntax(content: string, filename: string): void {
|
||||||
try {
|
try {
|
||||||
@@ -288,9 +290,11 @@ export class UploadsController {
|
|||||||
return null;
|
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) {
|
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);
|
const type = this.detectTypeFromContent(content);
|
||||||
if (type) {
|
if (type) {
|
||||||
return { mainFile: file, type };
|
return { mainFile: file, type };
|
||||||
|
|||||||
3
back/vitest-setup.ts
Normal file
3
back/vitest-setup.ts
Normal 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';
|
||||||
@@ -4,6 +4,20 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
setupFiles: ['./vitest-setup.ts'],
|
||||||
include: ['src/**/*.spec.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',
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## 1. Purpose & Architecture
|
## 1. Purpose & Architecture
|
||||||
|
|
||||||
**Datacat** est un catalogue de données d'API. Il permet d'importer des fichiers YAML
|
**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/
|
datacat/
|
||||||
@@ -19,8 +19,8 @@ datacat/
|
|||||||
User uploads YAML → POST /api/uploads
|
User uploads YAML → POST /api/uploads
|
||||||
→ ApiEntry created (status: PENDING)
|
→ ApiEntry created (status: PENDING)
|
||||||
→ DocsGenerationService.generate()
|
→ DocsGenerationService.generate()
|
||||||
→ asyncapi generate fromFile input.yaml --output /docs-output/<id>/
|
→ AsyncAPI : API programmatique @asyncapi/generator (html-template local)
|
||||||
→ OR redocly build-docs input.yaml --output /docs-output/<id>/index.html
|
→ OpenAPI : redocly build-docs input.yaml --output /docs-output/<id>/index.html
|
||||||
→ ApiEntry updated (status: GENERATED, htmlPath: /docs-output/<id>/index.html)
|
→ ApiEntry updated (status: GENERATED, htmlPath: /docs-output/<id>/index.html)
|
||||||
→ GET /api/apis/:id/docs → sendFile(htmlPath)
|
→ GET /api/apis/:id/docs → sendFile(htmlPath)
|
||||||
```
|
```
|
||||||
@@ -31,17 +31,15 @@ User uploads YAML → POST /api/uploads
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Tout en Docker (recommandé)
|
# Tout en Docker (recommandé)
|
||||||
pnpm run dev # docker compose up
|
docker compose -f infra/docker-compose.yml up -d
|
||||||
pnpm run dev:build # docker compose up --build
|
docker compose -f infra/docker-compose.yml up -d --build # avec rebuild
|
||||||
|
|
||||||
# Local (sans Docker)
|
# 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
|
cd front-public && pnpm run start # Angular dev server — port 4200
|
||||||
|
|
||||||
# Install deps
|
# Install deps (depuis la racine workspace)
|
||||||
cd back && pnpm install
|
pnpm install
|
||||||
cd front-public && pnpm install
|
|
||||||
cd shared && pnpm install
|
|
||||||
|
|
||||||
# Build prod
|
# Build prod
|
||||||
cd back && pnpm run build
|
cd back && pnpm run build
|
||||||
@@ -49,11 +47,21 @@ cd front-public && pnpm run build:prod
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Proxy Angular → API
|
### Proxy Angular → API
|
||||||
Créer `front-public/proxy.conf.json` :
|
`front-public/proxy.conf.json` (développement local) :
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"/api": {
|
"/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
|
"secure": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,9 +79,10 @@ Créer `front-public/proxy.conf.json` :
|
|||||||
| TypeORM | ^0.3.20 |
|
| TypeORM | ^0.3.20 |
|
||||||
| PostgreSQL | 17 |
|
| PostgreSQL | 17 |
|
||||||
| Angular | ^19.0.0 |
|
| Angular | ^19.0.0 |
|
||||||
| TypeScript | ^5.7.0 |
|
| TypeScript | ~5.8.3 |
|
||||||
| @asyncapi/cli | latest (in Docker) |
|
| @asyncapi/cli | 6.0.0 (in Docker) |
|
||||||
| @redocly/cli | latest (in Docker) |
|
| @redocly/cli | latest (in Docker) |
|
||||||
|
| @gouvfr/dsfr | ^1.13.0 (npm devDep front-public) |
|
||||||
|
|
||||||
### Conventions TypeScript
|
### Conventions TypeScript
|
||||||
- Pas de `any` — utiliser les types de `@datacat/shared`
|
- Pas de `any` — utiliser les types de `@datacat/shared`
|
||||||
@@ -88,61 +97,110 @@ Créer `front-public/proxy.conf.json` :
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE api_entries (
|
CREATE TABLE api_entries (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
title VARCHAR(255) NOT NULL,
|
convention VARCHAR(30) NOT NULL DEFAULT 'CONSULTER'
|
||||||
version VARCHAR(50) NOT NULL,
|
CHECK (convention IN ('CONSULTER','ENREGISTRER','ETRE_NOTIFIE')),
|
||||||
description TEXT,
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
|
provider VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
yaml_content TEXT NOT NULL,
|
version_major INT NOT NULL DEFAULT 0,
|
||||||
html_path VARCHAR(500),
|
version_minor INT NOT NULL DEFAULT 0,
|
||||||
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
|
version_patch INT NOT NULL DEFAULT 0,
|
||||||
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
|
description TEXT,
|
||||||
error_message TEXT,
|
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
yaml_content TEXT NOT NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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`)
|
### Entity TypeORM (`back/src/modules/apis/api-entry.entity.ts`)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
import { ApiType, ApiStatus } from '@datacat/shared';
|
import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared';
|
||||||
|
|
||||||
@Entity('api_entries')
|
@Entity('api_entries')
|
||||||
export class ApiEntryEntity {
|
export class ApiEntryEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id!: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ type: 'varchar', length: 30, default: 'CONSULTER' })
|
||||||
title: string;
|
convention!: ApiConvention;
|
||||||
|
|
||||||
@Column()
|
@Column({ type: 'varchar', length: 255, default: '' })
|
||||||
version: string;
|
name!: string;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'text' })
|
@Column({ nullable: true, type: 'text' })
|
||||||
description: string | null;
|
description!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 10 })
|
@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' })
|
@Column({ type: 'text', name: 'yaml_content' })
|
||||||
yamlContent: string;
|
yamlContent!: string;
|
||||||
|
|
||||||
@Column({ nullable: true, name: 'html_path' })
|
@Column({ nullable: true, type: 'varchar', length: 500, name: 'html_path' })
|
||||||
htmlPath: string | null;
|
htmlPath!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 10, default: 'PENDING' })
|
@Column({ type: 'varchar', length: 10, default: 'PENDING' })
|
||||||
status: ApiStatus;
|
status!: ApiStatus;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'text', name: 'error_message' })
|
@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' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at' })
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
updatedAt: Date;
|
updatedAt!: Date;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -161,40 +219,60 @@ src/
|
|||||||
│ │ ├── apis.module.ts
|
│ │ ├── apis.module.ts
|
||||||
│ │ ├── apis.controller.ts # REST endpoints
|
│ │ ├── apis.controller.ts # REST endpoints
|
||||||
│ │ ├── apis.service.ts # CRUD logique
|
│ │ ├── 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/
|
||||||
│ │ ├── uploads.module.ts
|
│ │ ├── uploads.module.ts
|
||||||
│ │ └── uploads.controller.ts # POST /api/uploads (multipart)
|
│ │ └── uploads.controller.ts # POST /api/uploads + POST /api/uploads/validate
|
||||||
│ └── docs-generation/
|
│ ├── docs-generation/
|
||||||
│ ├── docs-generation.module.ts
|
│ │ ├── docs-generation.module.ts
|
||||||
│ └── docs-generation.service.ts # Appels AsyncAPI/Redocly CLI
|
│ │ └── docs-generation.service.ts # API programmatique @asyncapi/generator + redocly CLI
|
||||||
├── database/
|
│ ├── categories/
|
||||||
│ └── database.module.ts # TypeORM config (si séparé)
|
│ │ ├── 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/
|
└── common/
|
||||||
├── filters/
|
└── filters/
|
||||||
│ └── all-exceptions.filter.ts
|
└── all-exceptions.filter.ts
|
||||||
└── interceptors/
|
|
||||||
└── transform.interceptor.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### API REST
|
### API REST
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /health → { status: 'ok' }
|
GET /health → { status: 'ok' }
|
||||||
GET /api/apis → ApiListResponse (+ ?search=&type=&page=&limit=)
|
GET /api/apis → ApiListResponse (+ ?search=&type=&categoryId=&page=&limit=)
|
||||||
GET /api/apis/:id → ApiEntry
|
GET /api/apis/:id → ApiEntry
|
||||||
POST /api/uploads → ApiEntry (multipart: file + title + version + description + type)
|
PATCH /api/apis/:id → ApiEntry (body: UpdateApiEntryDto)
|
||||||
PATCH /api/apis/:id → ApiEntry (body: UpdateApiEntryDto)
|
DELETE /api/apis/:id → 204 No Content
|
||||||
DELETE /api/apis/:id → 204 No Content
|
POST /api/apis/:id/regenerate → ApiEntry (relance la génération de docs)
|
||||||
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/docs → sendFile(htmlPath) ou 404 si pas encore généré
|
GET /api/apis/:id/*path → assets 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)
|
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
|
### ApisController
|
||||||
|
|
||||||
```typescript
|
```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';
|
import { Response } from 'express';
|
||||||
|
|
||||||
@Controller('apis')
|
@Controller('apis')
|
||||||
@@ -237,7 +315,7 @@ export class ApisController {
|
|||||||
@Get(':id/yaml')
|
@Get(':id/yaml')
|
||||||
async getYaml(@Param('id') id: string, @Res() res: Response) {
|
async getYaml(@Param('id') id: string, @Res() res: Response) {
|
||||||
const entry = await this.apisService.findOneOrThrow(id);
|
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.setHeader('Content-Type', 'application/x-yaml');
|
||||||
res.send(entry.yamlContent);
|
res.send(entry.yamlContent);
|
||||||
}
|
}
|
||||||
@@ -247,8 +325,8 @@ export class ApisController {
|
|||||||
### UploadsController
|
### UploadsController
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Controller, Post, UseInterceptors, UploadedFile, Body } from '@nestjs/common';
|
import { Controller, Post, UseInterceptors, UploadedFiles, Body, BadRequestException } from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
|
|
||||||
@Controller('uploads')
|
@Controller('uploads')
|
||||||
@@ -258,22 +336,38 @@ export class UploadsController {
|
|||||||
private readonly docsService: DocsGenerationService,
|
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()
|
@Post()
|
||||||
@UseInterceptors(FileInterceptor('file', {
|
@UseInterceptors(FilesInterceptor('files', 20, { /* diskStorage + fileFilter yaml */ }))
|
||||||
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);
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
async upload(
|
async upload(
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Body() body: { title: string; version: string; description?: string; type: 'ASYNCAPI' | 'OPENAPI' },
|
@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');
|
// 1 fichier → lecture directe
|
||||||
const entry = await this.apisService.create({ ...body, yamlContent });
|
// N fichiers → détection fichier principal + bundling (redocly bundle / @asyncapi/bundler)
|
||||||
// Fire and forget — génération asynchrone
|
// Fire and forget — génération asynchrone
|
||||||
this.docsService.generate(entry.id).catch(console.error);
|
this.docsService.generate(entry.id).catch(console.error);
|
||||||
return entry;
|
return entry;
|
||||||
@@ -289,19 +383,19 @@ import { execFile } from 'child_process';
|
|||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
const AsyncApiGenerator = require('@asyncapi/generator');
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DocsGenerationService {
|
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> {
|
async generate(entryId: string): Promise<void> {
|
||||||
const entry = await this.apisService.findOneOrThrow(entryId);
|
const entry = await this.apisService.findOneOrThrow(entryId);
|
||||||
const entryDir = path.join(this.outputDir, entryId);
|
const entryDir = path.join(this.outputDir, entryId);
|
||||||
|
|
||||||
await fs.mkdir(entryDir, { recursive: true });
|
await fs.mkdir(entryDir, { recursive: true });
|
||||||
|
|
||||||
const yamlPath = path.join(entryDir, 'input.yaml');
|
const yamlPath = path.join(entryDir, 'input.yaml');
|
||||||
await fs.writeFile(yamlPath, entry.yamlContent, 'utf-8');
|
await fs.writeFile(yamlPath, entry.yamlContent, 'utf-8');
|
||||||
|
|
||||||
@@ -311,37 +405,32 @@ export class DocsGenerationService {
|
|||||||
} else {
|
} else {
|
||||||
await this.generateOpenApi(yamlPath, entryDir);
|
await this.generateOpenApi(yamlPath, entryDir);
|
||||||
}
|
}
|
||||||
|
await this.apisService.updateInternal(entryId, { htmlPath: path.join(entryDir, 'index.html'), status: 'GENERATED' });
|
||||||
const htmlPath = path.join(entryDir, 'index.html');
|
|
||||||
await this.apisService.update(entryId, { htmlPath, status: 'GENERATED' });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.apisService.update(entryId, {
|
await this.apisService.updateInternal(entryId, { status: 'ERROR', errorMessage: String(error) });
|
||||||
status: 'ERROR',
|
|
||||||
errorMessage: String(error),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
|
private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
|
||||||
// asyncapi generate fromFile <input> --output <dir>
|
// API programmatique — aucun appel réseau, template résolu localement
|
||||||
await execFileAsync('asyncapi', [
|
const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
|
||||||
'generate', 'fromFile',
|
const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
|
||||||
yamlPath,
|
await generator.generateFromFile(yamlPath);
|
||||||
'--output', outputDir,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
|
private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
|
||||||
// redocly build-docs <input> --output <file>
|
|
||||||
await execFileAsync('redocly', [
|
await execFileAsync('redocly', [
|
||||||
'build-docs',
|
'build-docs', yamlPath,
|
||||||
yamlPath,
|
|
||||||
'--output', path.join(outputDir, 'index.html'),
|
'--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
|
## 6. Conventions Angular 19
|
||||||
@@ -362,44 +451,31 @@ import { ActivatedRoute } from '@angular/router';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-catalog',
|
selector: 'app-browse',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="fr-container fr-mt-4w">
|
<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()) {
|
@if (loading()) {
|
||||||
<div class="fr-callout">Chargement...</div>
|
<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">
|
<div class="fr-card fr-mb-2w">
|
||||||
<h2>{{ api.title }}</h2>
|
<h2>{{ cat.name }}</h2>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class CatalogComponent implements OnInit {
|
export class BrowseComponent implements OnInit {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
apis = signal<ApiEntryListItem[]>([]);
|
subcategories = signal<CategoryWithCounts[]>([]);
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
|
|
||||||
ngOnInit() {
|
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -438,23 +514,13 @@ export class ApiService {
|
|||||||
|
|
||||||
## 7. DSFR (Système de Design de l'État)
|
## 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/
|
```bash
|
||||||
et placer les assets dans `front-public/src/assets/dsfr/`.
|
# Déjà dans front-public/package.json :
|
||||||
|
# "@gouvfr/dsfr": "^1.13.0" (devDependencies)
|
||||||
Structure attendue :
|
# 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
|
### 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-delete-bin-line` — supprimer
|
||||||
- `fr-icon-refresh-line` — régénérer
|
- `fr-icon-refresh-line` — régénérer
|
||||||
- `fr-icon-eye-line` — voir
|
- `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
|
```typescript
|
||||||
# Générer HTML depuis un fichier YAML AsyncAPI
|
// Utilise @asyncapi/generator directement (installé dans node_modules)
|
||||||
asyncapi generate fromFile ./api.yaml --output ./output-dir/
|
// Template @asyncapi/html-template pré-installé dans l'image Docker
|
||||||
|
const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
|
||||||
# Valider un fichier
|
const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
|
||||||
asyncapi validate ./api.yaml
|
await generator.generateFromFile(yamlPath);
|
||||||
|
|
||||||
# Format de sortie : ./output-dir/index.html
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Redocly CLI
|
### OpenAPI — CLI Redocly
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Générer HTML depuis un fichier OpenAPI YAML
|
# 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
|
# Valider un fichier OpenAPI
|
||||||
redocly lint ./api.yaml
|
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
|
### Notes importantes
|
||||||
- Les deux CLIs sont installés dans l'image Docker prod (`infra/Dockerfile.prod`, target `back`)
|
- `@asyncapi/generator` et `@asyncapi/html-template` sont installés dans l'image Docker prod et dev
|
||||||
- En dev, ils sont installés dans `infra/Dockerfile.back.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
|
- 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`
|
- 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
|
- `registry_password` : token Gitea avec accès packages
|
||||||
- `deploy_kubeconfig` : contenu du fichier `~/.kube/config`
|
- `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
|
## 11. Règles de code
|
||||||
@@ -637,7 +693,7 @@ git push -u origin main
|
|||||||
- **Langue** : code en anglais, commentaires en français
|
- **Langue** : code en anglais, commentaires en français
|
||||||
- **Commits** : Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`)
|
- **Commits** : Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`)
|
||||||
- **TypeScript** : pas de `any`, `strict: true`, types depuis `@datacat/shared`
|
- **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
|
- **Angular** : standalone components, signals, inject(), OnPush, lazy loading
|
||||||
- **Tests** : Vitest pour le backend, `ng test` pour le frontend
|
- **Tests** : Vitest pour le backend, `ng test` pour le frontend
|
||||||
- **Pas de `console.log`** en production — utiliser Pino (NestJS) ou le logger Angular
|
- **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
|
## 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
|
### `/catalog` — CatalogComponent
|
||||||
- Grille DSFR de cartes (`fr-card`)
|
- 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)
|
- Select DSFR pour filtrer par type (ASYNCAPI / OPENAPI)
|
||||||
- Pagination DSFR (`fr-pagination`)
|
- Pagination DSFR (`fr-pagination`)
|
||||||
- Lien vers `/upload` avec bouton `fr-btn fr-btn--icon-left fr-icon-upload-2-line`
|
|
||||||
|
|
||||||
### `/catalog/:id` — ApiDetailComponent
|
### `/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)
|
- Badge de statut : `fr-badge--success` (GENERATED), `fr-badge--warning` (PENDING), `fr-badge--error` (ERROR)
|
||||||
- Boutons :
|
- Boutons :
|
||||||
- "Voir la documentation" → `/catalog/:id/docs` (désactivé si status ≠ GENERATED)
|
- "Voir la documentation" → `/catalog/:id/docs` (désactivé si status ≠ GENERATED)
|
||||||
- "Télécharger YAML" → `/api/apis/:id/yaml`
|
- "Télécharger YAML" → `/api/apis/:id/yaml`
|
||||||
- "Régénérer" → POST `/api/apis/:id/regenerate`
|
- "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
|
### `/catalog/:id/docs` — DocViewerComponent
|
||||||
- `<iframe>` plein écran affichant `/api/apis/:id/docs`
|
- `<iframe>` plein écran affichant `/api/apis/:id/docs`
|
||||||
- Bouton "Retour" en haut
|
- Bouton "Retour" en haut
|
||||||
|
|
||||||
### `/upload` — UploadComponent
|
### `/upload` — UploadComponent
|
||||||
- Stepper DSFR 3 étapes : (1) Choisir le fichier, (2) Métadonnées, (3) Confirmation
|
- Stepper DSFR 3 étapes : (1) Choisir les fichiers, (2) Métadonnées, (3) Confirmation
|
||||||
- Étape 1 : Zone de drop DSFR (`fr-upload-group`) pour fichier `.yaml` / `.yml`
|
- Étape 1 : Zone de drop DSFR (`fr-upload-group`) pour 1 à 20 fichiers `.yaml` / `.yml`
|
||||||
- Étape 2 : Formulaire (titre, version, description, type select AsyncAPI/OpenAPI)
|
- 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"
|
- Étape 3 : Récap + bouton "Importer"
|
||||||
- POST multipart vers `/api/uploads`
|
- POST multipart vers `/api/uploads`
|
||||||
- Redirect vers `/catalog/:id` après succès
|
- 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
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Liste les entrées avec filtres optionnels et pagination.
|
|||||||
|
|
||||||
| Paramètre | Type | Défaut | Description |
|
| 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 |
|
| `type` | `ASYNCAPI` \| `OPENAPI` | — | Filtre par type |
|
||||||
| `categoryId` | UUID | — | Filtre par catégorie |
|
| `categoryId` | UUID | — | Filtre par catégorie |
|
||||||
| `page` | number | `1` | Page (min 1) |
|
| `page` | number | `1` | Page (min 1) |
|
||||||
@@ -41,10 +41,13 @@ Liste les entrées avec filtres optionnels et pagination.
|
|||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"title": "My API",
|
"title": "Consulter Petstore",
|
||||||
|
"name": "Petstore",
|
||||||
|
"convention": "CONSULTER",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "...",
|
"description": "...",
|
||||||
"type": "OPENAPI",
|
"type": "OPENAPI",
|
||||||
|
"provider": "Équipe Produit",
|
||||||
"status": "GENERATED",
|
"status": "GENERATED",
|
||||||
"categoryId": "uuid-or-null",
|
"categoryId": "uuid-or-null",
|
||||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
"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
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"title": "My API",
|
"title": "Consulter Petstore",
|
||||||
|
"name": "Petstore",
|
||||||
|
"convention": "CONSULTER",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"versionMajor": 1,
|
||||||
|
"versionMinor": 0,
|
||||||
|
"versionPatch": 0,
|
||||||
"description": "...",
|
"description": "...",
|
||||||
"type": "OPENAPI",
|
"type": "OPENAPI",
|
||||||
|
"provider": "Équipe Produit",
|
||||||
"yamlContent": "openapi: '3.0.0'\n...",
|
"yamlContent": "openapi: '3.0.0'\n...",
|
||||||
"htmlPath": "/app/docs-output/uuid/index.html",
|
"htmlPath": "/app/docs-output/uuid/index.html",
|
||||||
"status": "GENERATED",
|
"status": "GENERATED",
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
"categoryId": "uuid-or-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",
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
"updatedAt": "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)
|
**Body JSON** (tous les champs sont optionnels)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "New title",
|
"convention": "CONSULTER",
|
||||||
"version": "2.0.0",
|
"name": "Nouveau nom",
|
||||||
"description": "Updated description",
|
"provider": "Équipe X",
|
||||||
"categoryId": "uuid-or-null"
|
"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**
|
**Réponse 200**
|
||||||
- `Content-Type: application/x-yaml`
|
- `Content-Type: application/x-yaml`
|
||||||
- `Content-Disposition: attachment; filename="<title>.yaml"`
|
- `Content-Disposition: attachment; filename="<name>.yaml"`
|
||||||
- Corps : contenu YAML brut
|
- Corps : contenu YAML brut
|
||||||
|
|
||||||
**Réponse 404** — entrée introuvable
|
**Réponse 404** — entrée introuvable
|
||||||
@@ -155,6 +180,52 @@ Télécharge le fichier YAML source.
|
|||||||
|
|
||||||
## Upload (`/api/uploads`)
|
## 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`
|
### `POST /api/uploads`
|
||||||
|
|
||||||
Crée une nouvelle entrée à partir d'un ou plusieurs fichiers YAML.
|
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 |
|
| Champ | Type | Requis | Description |
|
||||||
|-------|------|--------|-------------|
|
|-------|------|--------|-------------|
|
||||||
| `files` | file (1 à 20) | Oui | Fichiers YAML (`.yaml` ou `.yml`) |
|
| `files` | file (1 à 20) | Oui | Fichiers YAML (`.yaml` ou `.yml`) |
|
||||||
| `title` | string | Oui | Titre de l'API |
|
| `convention` | `CONSULTER` \| `ENREGISTRER` \| `ETRE_NOTIFIE` | Oui | Convention d'échange |
|
||||||
| `version` | string | Oui | Version (ex: `1.0.0`) |
|
| `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 |
|
| `description` | string | Non | Description libre |
|
||||||
| `type` | `ASYNCAPI` \| `OPENAPI` | Non | Type (détecté automatiquement si absent) |
|
| `type` | `ASYNCAPI` \| `OPENAPI` | Non | Type (détecté automatiquement si absent) |
|
||||||
| `categoryId` | UUID | Non | Catégorie parente |
|
| `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**
|
**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`
|
- 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.
|
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 201** — `ApiEntry` créé avec `status: "PENDING"`
|
||||||
|
|
||||||
**Réponse 400**
|
**Réponse 400**
|
||||||
- Aucun fichier fourni
|
- Aucun fichier fourni
|
||||||
- `title` ou `version` manquant
|
- `name` ou `convention` manquant
|
||||||
- Fichier non YAML
|
- Fichier non YAML
|
||||||
- Type non détectable et non fourni
|
- Type non détectable et non fourni
|
||||||
- Aucun fichier principal trouvé dans un upload multi-fichiers
|
- 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": [
|
"apis": [
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"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`
|
### `DELETE /api/categories/:id`
|
||||||
|
|
||||||
Supprime une catégorie.
|
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 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
|
**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 |
|
| Code | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| 400 | Requête invalide (champ manquant, format incorrect) |
|
| 400 | Requête invalide (champ manquant, format incorrect, suppression non vide) |
|
||||||
| 404 | Ressource introuvable |
|
| 404 | Ressource introuvable |
|
||||||
| 500 | Erreur interne serveur |
|
| 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 |
|
| `PENDING` | Entrée créée, génération de doc en cours |
|
||||||
| `GENERATED` | Documentation HTML disponible |
|
| `GENERATED` | Documentation HTML disponible |
|
||||||
| `ERROR` | La génération a échoué (voir `errorMessage`) |
|
| `ERROR` | La génération a échoué (voir `errorMessage`) |
|
||||||
|
|
||||||
|
## Conventions d'échange
|
||||||
|
|
||||||
|
| Convention | Label affiché |
|
||||||
|
|------------|---------------|
|
||||||
|
| `CONSULTER` | Consulter |
|
||||||
|
| `ENREGISTRER` | Enregistrer |
|
||||||
|
| `ETRE_NOTIFIE` | Être notifié |
|
||||||
|
|||||||
@@ -83,22 +83,37 @@ Injecte des données de démonstration au démarrage (idempotent : ne re-seed qu
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE api_entries (
|
CREATE TABLE api_entries (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
title VARCHAR(255) NOT NULL,
|
convention VARCHAR(30) NOT NULL DEFAULT 'CONSULTER'
|
||||||
version VARCHAR(50) NOT NULL,
|
CHECK (convention IN ('CONSULTER','ENREGISTRER','ETRE_NOTIFIE')),
|
||||||
description TEXT,
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
|
provider VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
yaml_content TEXT NOT NULL,
|
version_major INT NOT NULL DEFAULT 0,
|
||||||
html_path VARCHAR(500),
|
version_minor INT NOT NULL DEFAULT 0,
|
||||||
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
|
version_patch INT NOT NULL DEFAULT 0,
|
||||||
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
|
description TEXT,
|
||||||
error_message TEXT,
|
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
|
||||||
category_id VARCHAR(36),
|
yaml_content TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
html_path VARCHAR(500),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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`
|
### Table `categories`
|
||||||
|
|
||||||
```sql
|
```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.
|
> 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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## 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.
|
- **Schéma DB** : TypeORM crée et synchronise automatiquement les tables au démarrage (mode `synchronize: true` hors production). Aucune migration manuelle nécessaire.
|
||||||
|
|||||||
@@ -57,7 +57,13 @@
|
|||||||
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
|
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
|
||||||
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
|
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
|
|||||||
38
front-public/eslint.config.js
Normal file
38
front-public/eslint.config.js
Normal 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: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"build:prod": "ng build --configuration production",
|
"build:prod": "ng build --configuration production",
|
||||||
"test": "ng test --watch=false --browsers=ChromeHeadless",
|
"test": "vitest run",
|
||||||
"lint": "ng lint"
|
"test:watch": "vitest",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^19.0.0",
|
"@angular/animations": "^19.0.0",
|
||||||
@@ -18,6 +20,8 @@
|
|||||||
"@angular/platform-browser": "^19.0.0",
|
"@angular/platform-browser": "^19.0.0",
|
||||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||||
"@angular/router": "^19.0.0",
|
"@angular/router": "^19.0.0",
|
||||||
|
"@datacat/shared": "workspace:*",
|
||||||
|
"@gouvfr/dsfr": "^1.13.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"tslib": "^2.8.0",
|
"tslib": "^2.8.0",
|
||||||
"zone.js": "^0.15.0"
|
"zone.js": "^0.15.0"
|
||||||
@@ -26,9 +30,14 @@
|
|||||||
"@angular-devkit/build-angular": "^19.0.0",
|
"@angular-devkit/build-angular": "^19.0.0",
|
||||||
"@angular/cli": "^19.0.0",
|
"@angular/cli": "^19.0.0",
|
||||||
"@angular/compiler-cli": "^19.0.0",
|
"@angular/compiler-cli": "^19.0.0",
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/node": "^22.0.0",
|
"@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",
|
"typescript": "~5.8.3",
|
||||||
"sass": "^1.83.0"
|
"typescript-eslint": "^8.62.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
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 { routes } from './app.routes';
|
||||||
|
import { errorInterceptor } from './core/error.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes, withComponentInputBinding()),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptors([errorInterceptor])),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ApiService {
|
export class ApiService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private baseUrl = '/api/apis';
|
private baseUrl = `${environment.apiBaseUrl}/apis`;
|
||||||
|
private uploadsUrl = `${environment.apiBaseUrl}/uploads`;
|
||||||
|
|
||||||
list(query?: ApiListQuery): Observable<ApiListResponse> {
|
list(query?: ApiListQuery): Observable<ApiListResponse> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
@@ -49,35 +51,32 @@ export class ApiService {
|
|||||||
return this.http.get<ApiEntryListItem[]>(`${this.baseUrl}/${id}/versions`);
|
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> {
|
regenerate(id: string): Observable<ApiEntry> {
|
||||||
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
|
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
upload(formData: FormData): Observable<ApiEntry> {
|
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<{
|
validate(formData: FormData): Observable<ValidateResponse> {
|
||||||
valid: boolean;
|
return this.http.post<ValidateResponse>(`${this.uploadsUrl}/validate`, formData);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 }>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import {
|
|||||||
CategoryListResponse,
|
CategoryListResponse,
|
||||||
CategoryBrowseResponse,
|
CategoryBrowseResponse,
|
||||||
} from '@datacat/shared';
|
} from '@datacat/shared';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CategoryService {
|
export class CategoryService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private baseUrl = '/api/categories';
|
private baseUrl = `${environment.apiBaseUrl}/categories`;
|
||||||
|
|
||||||
list(search?: string): Observable<CategoryListResponse> {
|
list(search?: string): Observable<CategoryListResponse> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|||||||
18
front-public/src/app/core/error.interceptor.ts
Normal file
18
front-public/src/app/core/error.interceptor.ts
Normal 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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
36
front-public/src/app/core/error.service.spec.ts
Normal file
36
front-public/src/app/core/error.service.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
32
front-public/src/app/core/error.service.ts
Normal file
32
front-public/src/app/core/error.service.ts
Normal 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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
369
front-public/src/app/pages/api-detail/api-detail.component.html
Normal file
369
front-public/src/app/pages/api-detail/api-detail.component.html
Normal 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>
|
||||||
@@ -3,411 +3,26 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
|
DestroyRef,
|
||||||
OnInit,
|
OnInit,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { Subscription } from 'rxjs';
|
|
||||||
import { ApiService } from '../../core/api.service';
|
import { ApiService } from '../../core/api.service';
|
||||||
import { CategoryService } from '../../core/category.service';
|
import { CategoryService } from '../../core/category.service';
|
||||||
|
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||||
import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
|
import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-api-detail',
|
selector: 'app-api-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, ReactiveFormsModule],
|
imports: [CommonModule, RouterLink, ReactiveFormsModule, BreadcrumbComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
templateUrl: "./api-detail.component.html",
|
||||||
<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>
|
|
||||||
`,
|
|
||||||
})
|
})
|
||||||
export class ApiDetailComponent implements OnInit, OnDestroy {
|
export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
@@ -415,6 +30,7 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
private categoryService = inject(CategoryService);
|
private categoryService = inject(CategoryService);
|
||||||
private fb = inject(FormBuilder);
|
private fb = inject(FormBuilder);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
api = signal<ApiEntry | null>(null);
|
api = signal<ApiEntry | null>(null);
|
||||||
versions = signal<ApiEntryListItem[]>([]);
|
versions = signal<ApiEntryListItem[]>([]);
|
||||||
@@ -433,6 +49,15 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
return ['/browse'];
|
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 */
|
/* Onglets */
|
||||||
activeTab = signal<'info' | 'contacts'>('info');
|
activeTab = signal<'info' | 'contacts'>('info');
|
||||||
editingInfo = signal(false);
|
editingInfo = signal(false);
|
||||||
@@ -461,26 +86,29 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private routeSub: Subscription | null = null;
|
|
||||||
private id = '';
|
private id = '';
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.categoryService.list().subscribe((res) => this.categories.set(res.items));
|
this.categoryService
|
||||||
this.routeSub = this.route.paramMap.subscribe((params) => {
|
.list()
|
||||||
this.id = params.get('id') ?? '';
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
this.api.set(null);
|
.subscribe((res) => this.categories.set(res.items));
|
||||||
this.versions.set([]);
|
this.route.paramMap
|
||||||
this.error.set(null);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
this.categoryBreadcrumb.set([]);
|
.subscribe((params) => {
|
||||||
this.editingInfo.set(false);
|
this.id = params.get('id') ?? '';
|
||||||
this.editingContacts.set(false);
|
this.api.set(null);
|
||||||
this.clearPoll();
|
this.versions.set([]);
|
||||||
this.loadApi();
|
this.error.set(null);
|
||||||
});
|
this.categoryBreadcrumb.set([]);
|
||||||
|
this.editingInfo.set(false);
|
||||||
|
this.editingContacts.set(false);
|
||||||
|
this.clearPoll();
|
||||||
|
this.loadApi();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.routeSub?.unsubscribe();
|
|
||||||
this.clearPoll();
|
this.clearPoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,29 +125,38 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
onSetCurrent() {
|
onSetCurrent() {
|
||||||
this.settingCurrent.set(true);
|
this.settingCurrent.set(true);
|
||||||
this.apiService.setCurrent(this.id).subscribe({
|
this.apiService
|
||||||
next: (entry) => {
|
.setCurrent(this.id)
|
||||||
this.api.set(entry);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
this.settingCurrent.set(false);
|
.subscribe({
|
||||||
this.apiService.getVersions(this.id).subscribe({
|
next: (entry) => {
|
||||||
next: (v) => this.versions.set(v),
|
this.api.set(entry);
|
||||||
error: () => {},
|
this.settingCurrent.set(false);
|
||||||
});
|
this.apiService
|
||||||
},
|
.getVersions(this.id)
|
||||||
error: () => this.settingCurrent.set(false),
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
});
|
.subscribe({
|
||||||
|
next: (v) => this.versions.set(v),
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => this.settingCurrent.set(false),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onRegenerate() {
|
onRegenerate() {
|
||||||
this.regenerating.set(true);
|
this.regenerating.set(true);
|
||||||
this.apiService.regenerate(this.id).subscribe({
|
this.apiService
|
||||||
next: (entry) => {
|
.regenerate(this.id)
|
||||||
this.api.set(entry);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
this.regenerating.set(false);
|
.subscribe({
|
||||||
this.startPollIfPending();
|
next: (entry) => {
|
||||||
},
|
this.api.set(entry);
|
||||||
error: () => this.regenerating.set(false),
|
this.regenerating.set(false);
|
||||||
});
|
this.startPollIfPending();
|
||||||
|
},
|
||||||
|
error: () => this.regenerating.set(false),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startEditInfo() {
|
startEditInfo() {
|
||||||
@@ -567,10 +204,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
versionPatch: raw.versionPatch ?? 0,
|
versionPatch: raw.versionPatch ?? 0,
|
||||||
categoryId: raw.categoryId || null,
|
categoryId: raw.categoryId || null,
|
||||||
description: raw.description || null,
|
description: raw.description || null,
|
||||||
}).subscribe({
|
})
|
||||||
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
error: () => this.savingInfo.set(false),
|
.subscribe({
|
||||||
});
|
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
|
||||||
|
error: () => this.savingInfo.set(false),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
submitContacts() {
|
submitContacts() {
|
||||||
@@ -586,56 +225,81 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
contactTechnicalName: raw.contactTechnicalName ?? '',
|
contactTechnicalName: raw.contactTechnicalName ?? '',
|
||||||
contactTechnicalEntity: raw.contactTechnicalEntity ?? '',
|
contactTechnicalEntity: raw.contactTechnicalEntity ?? '',
|
||||||
contactTechnicalEmail: raw.contactTechnicalEmail ?? '',
|
contactTechnicalEmail: raw.contactTechnicalEmail ?? '',
|
||||||
}).subscribe({
|
})
|
||||||
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
error: () => this.savingContacts.set(false),
|
.subscribe({
|
||||||
});
|
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
|
||||||
|
error: () => this.savingContacts.set(false),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onDelete() {
|
onDelete() {
|
||||||
if (!confirm('Supprimer cette API ?')) return;
|
if (!confirm('Supprimer cette API ?')) return;
|
||||||
this.deleting.set(true);
|
this.deleting.set(true);
|
||||||
this.apiService.delete(this.id).subscribe({
|
/* Calculer la cible de redirection avant la suppression */
|
||||||
next: () => this.router.navigate(['/catalog']),
|
const otherVersions = this.versions().filter((v) => v.id !== this.id);
|
||||||
error: () => this.deleting.set(false),
|
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 {
|
badgeClass(status: string): string {
|
||||||
if (status === 'GENERATED') return 'fr-badge fr-badge--success';
|
if (status === 'GENERATED') return 'fr-badge fr-badge--success';
|
||||||
if (status === 'ERROR') return 'fr-badge fr-badge--error';
|
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';
|
return 'fr-badge fr-badge--info';
|
||||||
}
|
}
|
||||||
|
|
||||||
statusLabel(status: string): string {
|
statusLabel(status: string): string {
|
||||||
if (status === 'GENERATED') return 'Générée';
|
if (status === 'GENERATED') return 'Générée';
|
||||||
if (status === 'ERROR') return 'Erreur';
|
if (status === 'ERROR') return 'Erreur';
|
||||||
|
if (status === 'NO_YAML') return 'Sans fichier';
|
||||||
return 'En attente';
|
return 'En attente';
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadApi() {
|
private loadApi() {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.apiService.get(this.id).subscribe({
|
this.apiService
|
||||||
next: (entry) => {
|
.get(this.id)
|
||||||
this.api.set(entry);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
this.loading.set(false);
|
.subscribe({
|
||||||
this.startPollIfPending();
|
next: (entry) => {
|
||||||
this.apiService.getVersions(this.id).subscribe({
|
this.api.set(entry);
|
||||||
next: (v) => this.versions.set(v),
|
this.loading.set(false);
|
||||||
error: () => {},
|
this.startPollIfPending();
|
||||||
});
|
this.apiService
|
||||||
if (entry.categoryId) {
|
.getVersions(this.id)
|
||||||
this.categoryService.browse(entry.categoryId).subscribe({
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
|
.subscribe({
|
||||||
error: () => {},
|
next: (v) => this.versions.set(v),
|
||||||
});
|
error: () => {},
|
||||||
}
|
});
|
||||||
},
|
if (entry.categoryId) {
|
||||||
error: (err) => {
|
this.categoryService
|
||||||
this.error.set(err.message ?? 'Erreur lors du chargement');
|
.browse(entry.categoryId)
|
||||||
this.loading.set(false);
|
.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() {
|
private startPollIfPending() {
|
||||||
@@ -646,12 +310,15 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private pollStatus() {
|
private pollStatus() {
|
||||||
this.apiService.get(this.id).subscribe({
|
this.apiService
|
||||||
next: (entry) => {
|
.get(this.id)
|
||||||
this.api.set(entry);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
if (entry.status !== 'PENDING') this.clearPoll();
|
.subscribe({
|
||||||
},
|
next: (entry) => {
|
||||||
});
|
this.api.set(entry);
|
||||||
|
if (entry.status !== 'PENDING') this.clearPoll();
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearPoll() {
|
private clearPoll() {
|
||||||
|
|||||||
@@ -17,32 +17,16 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem
|
|||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { CategoryFormModalComponent } from '../../shared/category-form-modal/category-form-modal.component';
|
import { CategoryFormModalComponent } from '../../shared/category-form-modal/category-form-modal.component';
|
||||||
import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal/category-delete-modal.component';
|
import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal/category-delete-modal.component';
|
||||||
|
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-browse',
|
selector: 'app-browse',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent],
|
imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent, BreadcrumbComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="fr-container fr-mt-4w fr-mb-4w">
|
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||||
<!-- Fil d'Ariane -->
|
<app-breadcrumb [items]="breadcrumbItems()" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- En-tête -->
|
<!-- En-tête -->
|
||||||
@if (categoryId()) {
|
@if (categoryId()) {
|
||||||
@@ -309,6 +293,16 @@ export class BrowseComponent implements OnInit {
|
|||||||
return d.breadcrumb.slice(0, -1);
|
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[]>(() => {
|
backUrl = computed<string[]>(() => {
|
||||||
const anc = this.ancestors();
|
const anc = this.ancestors();
|
||||||
if (anc.length > 0) return ['/browse', anc[anc.length - 1].id];
|
if (anc.length > 0) return ['/browse', anc[anc.length - 1].id];
|
||||||
@@ -327,16 +321,19 @@ export class BrowseComponent implements OnInit {
|
|||||||
loadBrowse() {
|
loadBrowse() {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.categoryService.browse(this.categoryId()).subscribe({
|
this.categoryService
|
||||||
next: (data) => {
|
.browse(this.categoryId())
|
||||||
this.browseData.set(data);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
this.loading.set(false);
|
.subscribe({
|
||||||
},
|
next: (data) => {
|
||||||
error: () => {
|
this.browseData.set(data);
|
||||||
this.error.set('Erreur lors du chargement des données.');
|
this.loading.set(false);
|
||||||
this.loading.set(false);
|
},
|
||||||
},
|
error: () => {
|
||||||
});
|
this.error.set('Erreur lors du chargement des données.');
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openCreate() {
|
openCreate() {
|
||||||
@@ -381,20 +378,17 @@ export class BrowseComponent implements OnInit {
|
|||||||
loadSearch() {
|
loadSearch() {
|
||||||
this.searchLoading.set(true);
|
this.searchLoading.set(true);
|
||||||
const raw = this.searchQuery();
|
const raw = this.searchQuery();
|
||||||
const q = raw.trim().toLowerCase();
|
|
||||||
|
|
||||||
forkJoin({
|
forkJoin({
|
||||||
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
|
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
|
||||||
categories: this.categoryService.list(),
|
categories: this.categoryService.list(raw || undefined),
|
||||||
}).subscribe({
|
})
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
next: ({ apis, categories }) => {
|
next: ({ apis, categories }) => {
|
||||||
this.searchResults.set(apis.items);
|
this.searchResults.set(apis.items);
|
||||||
this.searchTotal.set(apis.total);
|
this.searchTotal.set(apis.total);
|
||||||
this.searchCategoryResults.set(
|
this.searchCategoryResults.set(categories.items);
|
||||||
categories.items.filter(
|
|
||||||
(c) => c.name.toLowerCase().includes(q) || (c.description ?? '').toLowerCase().includes(q),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
this.searchLoading.set(false);
|
this.searchLoading.set(false);
|
||||||
},
|
},
|
||||||
error: () => this.searchLoading.set(false),
|
error: () => this.searchLoading.set(false),
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
|
DestroyRef,
|
||||||
OnInit,
|
OnInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -154,6 +156,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared';
|
|||||||
})
|
})
|
||||||
export class CatalogComponent implements OnInit {
|
export class CatalogComponent implements OnInit {
|
||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
apis = signal<ApiEntryListItem[]>([]);
|
apis = signal<ApiEntryListItem[]>([]);
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
@@ -173,9 +176,9 @@ export class CatalogComponent implements OnInit {
|
|||||||
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
/* Rechargement si page change */
|
/* Rechargement si page change (lecture du signal = dépendance de l'effet) */
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const _ = this.page();
|
this.page();
|
||||||
this.loadApis();
|
this.loadApis();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -208,6 +211,7 @@ export class CatalogComponent implements OnInit {
|
|||||||
const base = 'fr-badge';
|
const base = 'fr-badge';
|
||||||
if (status === 'GENERATED') return `${base} fr-badge--success`;
|
if (status === 'GENERATED') return `${base} fr-badge--success`;
|
||||||
if (status === 'ERROR') return `${base} fr-badge--error`;
|
if (status === 'ERROR') return `${base} fr-badge--error`;
|
||||||
|
if (status === 'NO_YAML') return `${base} fr-badge--warning`;
|
||||||
return `${base} fr-badge--info`;
|
return `${base} fr-badge--info`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +224,7 @@ export class CatalogComponent implements OnInit {
|
|||||||
page: this.page(),
|
page: this.page(),
|
||||||
limit: this.limit,
|
limit: this.limit,
|
||||||
})
|
})
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (res) => {
|
next: (res) => {
|
||||||
this.apis.set(res.items);
|
this.apis.set(res.items);
|
||||||
|
|||||||
472
front-public/src/app/pages/upload/upload.component.html
Normal file
472
front-public/src/app/pages/upload/upload.component.html
Normal 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>
|
||||||
@@ -3,437 +3,25 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
|
DestroyRef,
|
||||||
OnInit,
|
OnInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||||
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
|
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
|
||||||
import { ApiService } from '../../core/api.service';
|
import { ApiService } from '../../core/api.service';
|
||||||
import { CategoryService } from '../../core/category.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({
|
@Component({
|
||||||
selector: 'app-upload',
|
selector: 'app-upload',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, BreadcrumbComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
templateUrl: "./upload.component.html",
|
||||||
<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>
|
|
||||||
`,
|
|
||||||
})
|
})
|
||||||
export class UploadComponent implements OnInit {
|
export class UploadComponent implements OnInit {
|
||||||
private fb = inject(FormBuilder);
|
private fb = inject(FormBuilder);
|
||||||
@@ -441,8 +29,16 @@ export class UploadComponent implements OnInit {
|
|||||||
private categoryService = inject(CategoryService);
|
private categoryService = inject(CategoryService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
step = signal(1);
|
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[]>([]);
|
selectedFiles = signal<File[]>([]);
|
||||||
detectedType = signal<'ASYNCAPI' | 'OPENAPI' | null>(null);
|
detectedType = signal<'ASYNCAPI' | 'OPENAPI' | null>(null);
|
||||||
detectedMainFilename = signal<string | null>(null);
|
detectedMainFilename = signal<string | null>(null);
|
||||||
@@ -453,13 +49,28 @@ export class UploadComponent implements OnInit {
|
|||||||
validationDone = signal(false);
|
validationDone = signal(false);
|
||||||
validationErrors = signal<Array<{ file: string; message: string }>>([]);
|
validationErrors = signal<Array<{ file: string; message: string }>>([]);
|
||||||
categories = signal<Category[]>([]);
|
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 = [
|
readonly stepTitles = [
|
||||||
'Choisir le fichier',
|
'Choisir le fichier',
|
||||||
'Renseigner les métadonnées',
|
'Informations',
|
||||||
|
'Contacts',
|
||||||
"Confirmer l'import",
|
"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({
|
metaForm = this.fb.group({
|
||||||
convention: ['CONSULTER', Validators.required],
|
convention: ['CONSULTER', Validators.required],
|
||||||
name: ['', 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 */
|
/* 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(() => {
|
previewTitle = computed(() => {
|
||||||
const values = this.formValues();
|
const values = this.formValues();
|
||||||
const convention = (values.convention ?? 'CONSULTER') as ApiConvention;
|
const origin = this.fromApi();
|
||||||
const name = values.name ?? '';
|
/* 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();
|
return `${API_CONVENTION_LABELS[convention]} ${name}`.trim();
|
||||||
});
|
});
|
||||||
|
|
||||||
selectedCategoryName = computed(() => {
|
selectedCategoryName = computed(() => {
|
||||||
const id = this.formValues().categoryId;
|
const id = this.formValues().categoryId;
|
||||||
if (!id) return '';
|
if (!id) return '';
|
||||||
return this.categories().find((c) => c.id === id)?.name ?? '';
|
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) */
|
/* Méthodes de validation (pas de computed — la re-exécution est déclenchée par les événements DOM blur/input) */
|
||||||
conventionInvalid(): boolean {
|
conventionInvalid(): boolean {
|
||||||
return !!(this.metaForm.get('convention')?.invalid && this.metaForm.get('convention')?.touched);
|
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);
|
return !!(this.metaForm.get('versionPatch')?.invalid && this.metaForm.get('versionPatch')?.touched);
|
||||||
}
|
}
|
||||||
canProceedFromStep1 = computed(() => {
|
canProceedFromStep1 = computed(() => {
|
||||||
/* Sans fichier, on peut toujours passer à l'étape 2 */
|
if (this.selectedFiles().length === 0) return true; // fichier optionnel
|
||||||
if (this.selectedFiles().length === 0) return true;
|
|
||||||
/* Avec fichier : validation serveur doit être terminée et sans erreur */
|
|
||||||
if (this.validating()) return false;
|
if (this.validating()) return false;
|
||||||
return this.validationDone() && this.validationErrors().length === 0;
|
return this.validationDone() && this.validationErrors().length === 0;
|
||||||
});
|
});
|
||||||
@@ -530,24 +158,38 @@ export class UploadComponent implements OnInit {
|
|||||||
const preselectedCategoryId = params.get('categoryId');
|
const preselectedCategoryId = params.get('categoryId');
|
||||||
const fromId = params.get('from');
|
const fromId = params.get('from');
|
||||||
|
|
||||||
this.categoryService.list().subscribe({
|
this.categoryService
|
||||||
next: (res) => {
|
.list()
|
||||||
this.categories.set(res.items);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
if (preselectedCategoryId) {
|
.subscribe({
|
||||||
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
|
next: (res) => {
|
||||||
}
|
this.categories.set(res.items);
|
||||||
},
|
if (preselectedCategoryId) {
|
||||||
});
|
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (fromId) {
|
if (fromId) {
|
||||||
this.apiService.get(fromId).subscribe({
|
this.apiService
|
||||||
next: (api) => this.prefillFromApi(api),
|
.get(fromId)
|
||||||
error: () => {},
|
.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 {
|
private prefillFromApi(api: ApiEntry): void {
|
||||||
|
this.fromApi.set(api);
|
||||||
this.metaForm.patchValue({
|
this.metaForm.patchValue({
|
||||||
convention: api.convention,
|
convention: api.convention,
|
||||||
name: api.name,
|
name: api.name,
|
||||||
@@ -564,6 +206,10 @@ export class UploadComponent implements OnInit {
|
|||||||
contactTechnicalEntity: api.contactTechnicalEntity,
|
contactTechnicalEntity: api.contactTechnicalEntity,
|
||||||
contactTechnicalEmail: api.contactTechnicalEmail,
|
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 {
|
categoryLabel(cat: Category): string {
|
||||||
@@ -574,11 +220,15 @@ export class UploadComponent implements OnInit {
|
|||||||
|
|
||||||
onFileChange(event: Event) {
|
onFileChange(event: Event) {
|
||||||
this.fileError.set(null);
|
this.fileError.set(null);
|
||||||
|
this.uploadError.set(null);
|
||||||
this.detectedType.set(null);
|
this.detectedType.set(null);
|
||||||
this.detectedMainFilename.set(null);
|
this.detectedMainFilename.set(null);
|
||||||
this.validationErrors.set([]);
|
this.validationErrors.set([]);
|
||||||
this.validationDone.set(false);
|
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 input = event.target as HTMLInputElement;
|
||||||
const fileList = input.files;
|
const fileList = input.files;
|
||||||
@@ -595,6 +245,15 @@ export class UploadComponent implements OnInit {
|
|||||||
return;
|
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);
|
this.selectedFiles.set(files);
|
||||||
|
|
||||||
/* Détection côté client du fichier principal (pré-remplissage du type) */
|
/* 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);
|
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 {
|
private runValidation(files: File[]): void {
|
||||||
this.validating.set(true);
|
this.validating.set(true);
|
||||||
this.validationDone.set(false);
|
this.validationDone.set(false);
|
||||||
@@ -618,25 +296,35 @@ export class UploadComponent implements OnInit {
|
|||||||
formData.append('files', f);
|
formData.append('files', f);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.apiService.validate(formData).subscribe({
|
this.apiService
|
||||||
next: (result) => {
|
.validate(formData)
|
||||||
this.validating.set(false);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
this.validationDone.set(true);
|
.subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.validating.set(false);
|
||||||
|
this.validationDone.set(true);
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
this.validationErrors.set(result.errors);
|
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 {
|
} else {
|
||||||
this.validationErrors.set([]);
|
this.validationErrors.set([]);
|
||||||
if (result.type) {
|
if (result.type) {
|
||||||
this.detectedType.set(result.type);
|
this.detectedType.set(result.type);
|
||||||
this.metaForm.patchValue({ type: result.type });
|
/* Ne pas modifier le type s'il est verrouillé par fromApi */
|
||||||
this.metaForm.get('type')?.disable();
|
if (!this.fromApi()) {
|
||||||
|
this.metaForm.patchValue({ type: result.type });
|
||||||
|
this.metaForm.get('type')?.disable();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (result.mainFile) {
|
if (result.mainFile) {
|
||||||
this.detectedMainFilename.set(result.mainFile);
|
this.detectedMainFilename.set(result.mainFile);
|
||||||
}
|
}
|
||||||
/* Pré-remplissage du formulaire depuis les métadonnées du fichier */
|
/* 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.versionMajor !== null) this.metaForm.patchValue({ versionMajor: result.versionMajor });
|
||||||
if (result.versionMinor !== null) this.metaForm.patchValue({ versionMinor: result.versionMinor });
|
if (result.versionMinor !== null) this.metaForm.patchValue({ versionMinor: result.versionMinor });
|
||||||
if (result.versionPatch !== null) this.metaForm.patchValue({ versionPatch: result.versionPatch });
|
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() {
|
onSubmit() {
|
||||||
const files = this.selectedFiles();
|
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.uploading.set(true);
|
||||||
this.uploadError.set(null);
|
this.uploadError.set(null);
|
||||||
@@ -691,16 +377,19 @@ export class UploadComponent implements OnInit {
|
|||||||
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
|
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
|
||||||
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
|
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
|
||||||
|
|
||||||
this.apiService.upload(formData).subscribe({
|
this.apiService
|
||||||
next: (entry) => {
|
.upload(formData)
|
||||||
this.uploading.set(false);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
this.router.navigate(['/catalog', entry.id]);
|
.subscribe({
|
||||||
},
|
next: (entry) => {
|
||||||
error: (err) => {
|
this.uploading.set(false);
|
||||||
this.uploading.set(false);
|
this.router.navigate(['/catalog', entry.id]);
|
||||||
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
|
},
|
||||||
},
|
error: (err) => {
|
||||||
});
|
this.uploading.set(false);
|
||||||
|
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async detectMainFileClient(files: File[]): Promise<void> {
|
private async detectMainFileClient(files: File[]): Promise<void> {
|
||||||
@@ -709,13 +398,13 @@ export class UploadComponent implements OnInit {
|
|||||||
if (/^openapi\s*:/m.test(content)) {
|
if (/^openapi\s*:/m.test(content)) {
|
||||||
this.detectedType.set('OPENAPI');
|
this.detectedType.set('OPENAPI');
|
||||||
this.detectedMainFilename.set(file.name);
|
this.detectedMainFilename.set(file.name);
|
||||||
this.metaForm.patchValue({ type: 'OPENAPI' });
|
if (!this.fromApi()) this.metaForm.patchValue({ type: 'OPENAPI' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (/^asyncapi\s*:/m.test(content)) {
|
if (/^asyncapi\s*:/m.test(content)) {
|
||||||
this.detectedType.set('ASYNCAPI');
|
this.detectedType.set('ASYNCAPI');
|
||||||
this.detectedMainFilename.set(file.name);
|
this.detectedMainFilename.set(file.name);
|
||||||
this.metaForm.patchValue({ type: 'ASYNCAPI' });
|
if (!this.fromApi()) this.metaForm.patchValue({ type: 'ASYNCAPI' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>();
|
||||||
|
}
|
||||||
@@ -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 { CommonModule } from '@angular/common';
|
||||||
import { CategoryService } from '../../core/category.service';
|
import { CategoryService } from '../../core/category.service';
|
||||||
import { CategoryWithCounts } from '@datacat/shared';
|
import { CategoryWithCounts } from '@datacat/shared';
|
||||||
@@ -49,6 +50,7 @@ import { CategoryWithCounts } from '@datacat/shared';
|
|||||||
})
|
})
|
||||||
export class CategoryDeleteModalComponent {
|
export class CategoryDeleteModalComponent {
|
||||||
private categoryService = inject(CategoryService);
|
private categoryService = inject(CategoryService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
category = input.required<CategoryWithCounts>();
|
category = input.required<CategoryWithCounts>();
|
||||||
confirmed = output<void>();
|
confirmed = output<void>();
|
||||||
@@ -65,15 +67,18 @@ export class CategoryDeleteModalComponent {
|
|||||||
const cat = this.category();
|
const cat = this.category();
|
||||||
this.deleteLoading.set(true);
|
this.deleteLoading.set(true);
|
||||||
this.deleteError.set(null);
|
this.deleteError.set(null);
|
||||||
this.categoryService.delete(cat.id).subscribe({
|
this.categoryService
|
||||||
next: () => {
|
.delete(cat.id)
|
||||||
this.deleteLoading.set(false);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
this.confirmed.emit();
|
.subscribe({
|
||||||
},
|
next: () => {
|
||||||
error: (err: { error?: { message?: string } }) => {
|
this.deleteLoading.set(false);
|
||||||
this.deleteLoading.set(false);
|
this.confirmed.emit();
|
||||||
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
|
},
|
||||||
},
|
error: (err: { error?: { message?: string } }) => {
|
||||||
});
|
this.deleteLoading.set(false);
|
||||||
|
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
|
DestroyRef,
|
||||||
OnInit,
|
OnInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CategoryService } from '../../core/category.service';
|
import { CategoryService } from '../../core/category.service';
|
||||||
import { Category } from '@datacat/shared';
|
import { Category } from '@datacat/shared';
|
||||||
@@ -79,6 +81,7 @@ import { Category } from '@datacat/shared';
|
|||||||
})
|
})
|
||||||
export class CategoryFormModalComponent implements OnInit {
|
export class CategoryFormModalComponent implements OnInit {
|
||||||
private categoryService = inject(CategoryService);
|
private categoryService = inject(CategoryService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
mode = input<'create' | 'edit'>('create');
|
mode = input<'create' | 'edit'>('create');
|
||||||
category = input<Category | null>(null);
|
category = input<Category | null>(null);
|
||||||
@@ -133,10 +136,13 @@ export class CategoryFormModalComponent implements OnInit {
|
|||||||
this.formDescription.set(cat.description ?? '');
|
this.formDescription.set(cat.description ?? '');
|
||||||
} else {
|
} else {
|
||||||
this.formParentId.set(this.parentId());
|
this.formParentId.set(this.parentId());
|
||||||
this.categoryService.list().subscribe({
|
this.categoryService
|
||||||
next: (res) => this.allCategories.set(res.items),
|
.list()
|
||||||
error: () => this.allCategories.set([]),
|
.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.create({ name, description, parentId: this.formParentId() ?? undefined })
|
||||||
: this.categoryService.update(this.editingCategoryId()!, { name, description });
|
: this.categoryService.update(this.editingCategoryId()!, { name, description });
|
||||||
|
|
||||||
obs.subscribe({
|
obs.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.formLoading.set(false);
|
this.formLoading.set(false);
|
||||||
this.saved.emit();
|
this.saved.emit();
|
||||||
|
|||||||
4
front-public/src/environments/environment.prod.ts
Normal file
4
front-public/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiBaseUrl: '/api',
|
||||||
|
};
|
||||||
5
front-public/src/environments/environment.ts
Normal file
5
front-public/src/environments/environment.ts
Normal 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',
|
||||||
|
};
|
||||||
3
front-public/vitest-setup.ts
Normal file
3
front-public/vitest-setup.ts
Normal 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';
|
||||||
13
front-public/vitest.config.ts
Normal file
13
front-public/vitest.config.ts
Normal 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
1003
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
31
shared/README.md
Normal file
31
shared/README.md
Normal 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).
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@datacat/shared",
|
"name": "@datacat/shared",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"description": "Types et constantes partagés entre le back (NestJS) et le front (Angular) de Datacat.",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project tsconfig.build.json"
|
"build": "tsc --project tsconfig.build.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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;
|
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;
|
||||||
|
|||||||
@@ -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> = {
|
export const API_CONVENTION_LABELS: Record<ApiConvention, string> = {
|
||||||
CONSULTER: 'Consulter',
|
CONSULTER: 'Consulter',
|
||||||
@@ -27,6 +31,7 @@ export interface ApiEntry {
|
|||||||
status: ApiStatus;
|
status: ApiStatus;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
|
isCurrent: boolean;
|
||||||
contactFunctionalName: string;
|
contactFunctionalName: string;
|
||||||
contactFunctionalEntity: string;
|
contactFunctionalEntity: string;
|
||||||
contactFunctionalEmail: string;
|
contactFunctionalEmail: string;
|
||||||
@@ -48,6 +53,7 @@ export interface ApiEntryListItem {
|
|||||||
provider: string;
|
provider: string;
|
||||||
status: ApiStatus;
|
status: ApiStatus;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
|
isCurrent: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user