Compare commits
13 Commits
c8b3e393ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/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
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "modules/seeder/assets/**/*.yaml",
|
||||
"outDir": "./dist/back/src",
|
||||
"watchAssets": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"entryFile": "back/src/main"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ export class ApiEntryEntity {
|
||||
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
|
||||
categoryId!: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true, name: 'is_current' })
|
||||
isCurrent!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
|
||||
contactFunctionalName!: string;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
||||
provider: entity.provider,
|
||||
status: entity.status,
|
||||
categoryId: entity.categoryId,
|
||||
isCurrent: entity.isCurrent,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -43,6 +43,11 @@ export class ApisController {
|
||||
return this.apisService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/set-current')
|
||||
setCurrent(@Param('id') id: string) {
|
||||
return this.apisService.setCurrent(id);
|
||||
}
|
||||
|
||||
@Post(':id/regenerate')
|
||||
regenerate(@Param('id') id: string) {
|
||||
return this.apisService.regenerate(id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { ApisService } from './apis.service';
|
||||
import { ApiType } from '@datacat/shared';
|
||||
|
||||
@@ -154,3 +155,193 @@ 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() };
|
||||
|
||||
let service: ApisService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new ApisService(mockRepo as any, mockDocsService as any);
|
||||
});
|
||||
|
||||
function makeEntity(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'uuid-1', name: 'Petstore', convention: 'CONSULTER',
|
||||
versionMajor: 1, versionMinor: 0, versionPatch: 0,
|
||||
type: 'OPENAPI', provider: 'DINUM', description: null,
|
||||
yamlContent: 'openapi: "3.0.0"', htmlPath: null,
|
||||
status: 'PENDING', errorMessage: null, categoryId: null,
|
||||
isCurrent: true,
|
||||
contactFunctionalName: '', contactFunctionalEntity: '', contactFunctionalEmail: '',
|
||||
contactTechnicalName: '', contactTechnicalEntity: '', contactTechnicalEmail: '',
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const baseDto = {
|
||||
name: 'Petstore', convention: 'CONSULTER' as const,
|
||||
versionMajor: 1, versionMinor: 0, versionPatch: 0,
|
||||
type: 'OPENAPI' as const, provider: 'DINUM',
|
||||
yamlContent: 'openapi: "3.0.0"',
|
||||
contactFunctionalName: 'Alice', contactFunctionalEntity: 'DINUM', contactFunctionalEmail: 'alice@dinum.fr',
|
||||
contactTechnicalName: 'Bob', contactTechnicalEntity: 'DINUM', contactTechnicalEmail: 'bob@dinum.fr',
|
||||
};
|
||||
|
||||
// ─── ApisService.create() ─────────────────────────────────────────────────────
|
||||
|
||||
describe('ApisService.create()', () => {
|
||||
it('crée l\'entrée et la retourne', async () => {
|
||||
const entity = makeEntity();
|
||||
mockRepo.findOne.mockResolvedValue(null);
|
||||
mockRepo.count.mockResolvedValue(0);
|
||||
mockRepo.create.mockReturnValue(entity);
|
||||
mockRepo.save.mockResolvedValue(entity);
|
||||
|
||||
const result = await service.create(baseDto as any);
|
||||
|
||||
expect(result.id).toBe('uuid-1');
|
||||
expect(result.title).toBe('Consulter Petstore');
|
||||
expect(result.version).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('lève ConflictException si doublon', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(makeEntity());
|
||||
|
||||
await expect(service.create(baseDto as any)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('isCurrent=true si première version', async () => {
|
||||
const entity = makeEntity({ isCurrent: true });
|
||||
mockRepo.findOne.mockResolvedValue(null);
|
||||
mockRepo.count.mockResolvedValue(0);
|
||||
mockRepo.create.mockReturnValue(entity);
|
||||
mockRepo.save.mockResolvedValue(entity);
|
||||
|
||||
await service.create(baseDto as any);
|
||||
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ isCurrent: true }));
|
||||
});
|
||||
|
||||
it('isCurrent=false si version ultérieure', async () => {
|
||||
const entity = makeEntity({ isCurrent: false });
|
||||
mockRepo.findOne.mockResolvedValue(null);
|
||||
mockRepo.count.mockResolvedValue(1);
|
||||
mockRepo.create.mockReturnValue(entity);
|
||||
mockRepo.save.mockResolvedValue(entity);
|
||||
|
||||
await service.create(baseDto as any);
|
||||
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ isCurrent: false }));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ApisService.findOneOrThrow() ────────────────────────────────────────────
|
||||
|
||||
describe('ApisService.findOneOrThrow()', () => {
|
||||
it('retourne l\'entrée mappée si trouvée', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(makeEntity());
|
||||
|
||||
const result = await service.findOneOrThrow('uuid-1');
|
||||
|
||||
expect(result.id).toBe('uuid-1');
|
||||
expect(result.title).toBe('Consulter Petstore');
|
||||
expect(result.version).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('lève NotFoundException si absente', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOneOrThrow('uuid-unknown')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ApisService.remove() ────────────────────────────────────────────────────
|
||||
|
||||
describe('ApisService.remove()', () => {
|
||||
it('supprime l\'entrée si elle existe', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(makeEntity());
|
||||
mockRepo.delete.mockResolvedValue({ affected: 1 });
|
||||
|
||||
await service.remove('uuid-1');
|
||||
|
||||
expect(mockRepo.delete).toHaveBeenCalledWith('uuid-1');
|
||||
});
|
||||
|
||||
it('lève NotFoundException si absente', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove('uuid-unknown')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ApisService.findVersionsOf() ────────────────────────────────────────────
|
||||
|
||||
describe('ApisService.findVersionsOf()', () => {
|
||||
it('retourne toutes les versions (même name+convention)', async () => {
|
||||
const entity1 = makeEntity({ id: 'uuid-1', versionMajor: 2 });
|
||||
const entity2 = makeEntity({ id: 'uuid-2', versionMajor: 1 });
|
||||
mockRepo.findOne.mockResolvedValue(entity1);
|
||||
mockRepo.find.mockResolvedValue([entity1, entity2]);
|
||||
|
||||
const result = await service.findVersionsOf('uuid-1');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('uuid-1');
|
||||
expect(result[1].id).toBe('uuid-2');
|
||||
});
|
||||
|
||||
it('lève NotFoundException si id inconnu', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findVersionsOf('uuid-unknown')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ApisService.setCurrent() ────────────────────────────────────────────────
|
||||
|
||||
describe('ApisService.setCurrent()', () => {
|
||||
it('met isCurrent=false sur les siblings puis true sur la cible', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(makeEntity());
|
||||
mockRepo.update.mockResolvedValue({ affected: 1 });
|
||||
|
||||
await service.setCurrent('uuid-1');
|
||||
|
||||
expect(mockRepo.update).toHaveBeenCalledWith(
|
||||
{ name: 'Petstore', convention: 'CONSULTER' },
|
||||
{ isCurrent: false },
|
||||
);
|
||||
expect(mockRepo.update).toHaveBeenCalledWith(
|
||||
{ id: 'uuid-1' },
|
||||
{ isCurrent: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ApisService.regenerate() ────────────────────────────────────────────────
|
||||
|
||||
describe('ApisService.regenerate()', () => {
|
||||
it('remet le statut à PENDING et appelle docsService.generate()', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(makeEntity());
|
||||
mockRepo.update.mockResolvedValue({ affected: 1 });
|
||||
mockDocsService.generate.mockResolvedValue(undefined);
|
||||
|
||||
await service.regenerate('uuid-1');
|
||||
|
||||
expect(mockRepo.update).toHaveBeenCalledWith('uuid-1', { status: 'PENDING', errorMessage: null });
|
||||
expect(mockDocsService.generate).toHaveBeenCalledWith('uuid-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ApiEntryEntity } from './api-entry.entity';
|
||||
@@ -25,6 +25,28 @@ export class ApisService {
|
||||
) {}
|
||||
|
||||
async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
|
||||
// Vérifier qu'il n'existe pas déjà une entrée avec le même tuple (name, convention, version)
|
||||
const duplicate = await this.repo.findOne({
|
||||
where: {
|
||||
name: dto.name,
|
||||
convention: dto.convention,
|
||||
versionMajor: dto.versionMajor,
|
||||
versionMinor: dto.versionMinor,
|
||||
versionPatch: dto.versionPatch,
|
||||
},
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new ConflictException(
|
||||
`La version ${dto.versionMajor}.${dto.versionMinor}.${dto.versionPatch} existe déjà pour l'API "${dto.name}".`,
|
||||
);
|
||||
}
|
||||
|
||||
// Première version si aucune entrée avec le même name + convention
|
||||
const existingCount = await this.repo.count({
|
||||
where: { name: dto.name, convention: dto.convention },
|
||||
});
|
||||
const isCurrent = existingCount === 0;
|
||||
|
||||
const entity = this.repo.create({
|
||||
convention: dto.convention,
|
||||
name: dto.name,
|
||||
@@ -35,8 +57,9 @@ export class ApisService {
|
||||
description: dto.description ?? null,
|
||||
type: dto.type,
|
||||
yamlContent: dto.yamlContent,
|
||||
status: 'PENDING',
|
||||
status: (dto.yamlContent ? 'PENDING' : 'NO_YAML') as ApiStatus,
|
||||
categoryId: dto.categoryId ?? null,
|
||||
isCurrent,
|
||||
contactFunctionalName: dto.contactFunctionalName,
|
||||
contactFunctionalEntity: dto.contactFunctionalEntity,
|
||||
contactFunctionalEmail: dto.contactFunctionalEmail,
|
||||
@@ -188,6 +211,18 @@ export class ApisService {
|
||||
return entities.map(toApiEntryListItem);
|
||||
}
|
||||
|
||||
async setCurrent(id: string): Promise<ApiEntry> {
|
||||
const entry = await this.findOneOrThrow(id);
|
||||
// Mettre tous les siblings à false
|
||||
await this.repo.update(
|
||||
{ name: entry.name, convention: entry.convention },
|
||||
{ isCurrent: false },
|
||||
);
|
||||
// Mettre cet entry à true
|
||||
await this.repo.update({ id }, { isCurrent: true });
|
||||
return this.findOneOrThrow(id);
|
||||
}
|
||||
|
||||
async regenerate(id: string): Promise<ApiEntry> {
|
||||
await this.findOneOrThrow(id);
|
||||
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
|
||||
@@ -222,6 +257,7 @@ function toApiEntry(entity: ApiEntryEntity): ApiEntry {
|
||||
status: entity.status,
|
||||
errorMessage: entity.errorMessage,
|
||||
categoryId: entity.categoryId,
|
||||
isCurrent: entity.isCurrent,
|
||||
contactFunctionalName: entity.contactFunctionalName,
|
||||
contactFunctionalEntity: entity.contactFunctionalEntity,
|
||||
contactFunctionalEmail: entity.contactFunctionalEmail,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
} from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
@@ -17,8 +18,8 @@ export class CategoriesController {
|
||||
constructor(private readonly categoriesService: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.categoriesService.findAll();
|
||||
findAll(@Query('search') search?: string) {
|
||||
return this.categoriesService.findAll(search);
|
||||
}
|
||||
|
||||
/* Déclarés AVANT :id pour éviter que NestJS interprète "browse" comme un UUID */
|
||||
|
||||
@@ -605,10 +605,41 @@ export class SeederService implements OnApplicationBootstrap {
|
||||
await this.reset();
|
||||
}
|
||||
const count = await this.apiRepo.count();
|
||||
if (count > 0) return; /* Idempotent — ne ré-exécute pas si des données existent */
|
||||
if (count > 0) {
|
||||
await this.fixIsCurrentFlags();
|
||||
return;
|
||||
}
|
||||
await this.seed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrige les flags is_current après une migration :
|
||||
* si toutes les versions d'un même groupe (name+convention) sont marquées courantes,
|
||||
* on ne garde que la plus haute version.
|
||||
*/
|
||||
private async fixIsCurrentFlags(): Promise<void> {
|
||||
await this.dataSource.query(`
|
||||
UPDATE api_entries SET is_current = false
|
||||
WHERE id IN (
|
||||
SELECT a.id
|
||||
FROM api_entries a
|
||||
WHERE a.is_current = true
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM api_entries b
|
||||
WHERE b.name = a.name AND b.convention = a.convention
|
||||
AND b.is_current = true
|
||||
AND b.id != a.id
|
||||
AND (
|
||||
b.version_major > a.version_major
|
||||
OR (b.version_major = a.version_major AND b.version_minor > a.version_minor)
|
||||
OR (b.version_major = a.version_major AND b.version_minor = a.version_minor
|
||||
AND b.version_patch > a.version_patch)
|
||||
)
|
||||
)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
private async reset(): Promise<void> {
|
||||
/* Supprimer toutes les entrées puis les catégories */
|
||||
await this.apiRepo.delete({});
|
||||
@@ -697,6 +728,7 @@ export class SeederService implements OnApplicationBootstrap {
|
||||
versionMajor: 3,
|
||||
versionMinor: 0,
|
||||
versionPatch: 4,
|
||||
isCurrent: true,
|
||||
description:
|
||||
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||
type: 'OPENAPI',
|
||||
@@ -775,6 +807,7 @@ export class SeederService implements OnApplicationBootstrap {
|
||||
versionMajor: 3,
|
||||
versionMinor: 0,
|
||||
versionPatch: 3,
|
||||
isCurrent: false,
|
||||
description:
|
||||
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||
type: 'OPENAPI',
|
||||
@@ -795,6 +828,7 @@ export class SeederService implements OnApplicationBootstrap {
|
||||
versionMajor: 3,
|
||||
versionMinor: 0,
|
||||
versionPatch: 2,
|
||||
isCurrent: false,
|
||||
description:
|
||||
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||
type: 'OPENAPI',
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -133,12 +133,14 @@ export class UploadsController {
|
||||
|
||||
try {
|
||||
if (detectedType === 'ASYNCAPI') {
|
||||
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, timeout: 30000 });
|
||||
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, shell: true, timeout: 30000 });
|
||||
} else {
|
||||
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, timeout: 30000 });
|
||||
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, shell: true, timeout: 30000 });
|
||||
}
|
||||
} catch (cliError) {
|
||||
const err = cliError as { stdout?: string; stderr?: string };
|
||||
const err = cliError as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
|
||||
/* CLI non installé (dev local sans Docker) → skip la validation complète */
|
||||
if (err.code !== 'ENOENT') {
|
||||
const output = (err.stdout || err.stderr || String(cliError)).trim();
|
||||
return {
|
||||
valid: false, type: detectedType, mainFile: mainFileName,
|
||||
@@ -146,28 +148,15 @@ export class UploadsController {
|
||||
errors: [{ file: mainFileName, message: output }],
|
||||
};
|
||||
}
|
||||
this.logger.warn(`CLI ${detectedType === 'ASYNCAPI' ? 'asyncapi' : 'redocly'} non trouvé — validation CLI ignorée`);
|
||||
}
|
||||
} finally {
|
||||
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
/* Extraction des métadonnées depuis le fichier principal */
|
||||
const mainContent = fileContents.find((f) => f.name === mainFileName)!.content;
|
||||
const parsed = yaml.load(mainContent) as Record<string, unknown>;
|
||||
const info = parsed['info'] as Record<string, unknown> | undefined;
|
||||
const name = typeof info?.['title'] === 'string' ? info['title'] : null;
|
||||
const description = typeof info?.['description'] === 'string' ? info['description'] : null;
|
||||
|
||||
/* Décomposer la version en major.minor.patch */
|
||||
let versionMajor: number | null = null;
|
||||
let versionMinor: number | null = null;
|
||||
let versionPatch: number | null = null;
|
||||
const versionStr = typeof info?.['version'] === 'string' ? info['version'] : null;
|
||||
if (versionStr) {
|
||||
const parts = versionStr.split('.').map((p) => parseInt(p, 10));
|
||||
if (parts.length >= 1 && !isNaN(parts[0])) versionMajor = parts[0];
|
||||
if (parts.length >= 2 && !isNaN(parts[1])) versionMinor = parts[1];
|
||||
if (parts.length >= 3 && !isNaN(parts[2])) versionPatch = parts[2];
|
||||
}
|
||||
const { name, description, versionMajor, versionMinor, versionPatch } = this.extractMetadata(mainContent);
|
||||
|
||||
return { valid: true, type: detectedType, mainFile: mainFileName, name, versionMajor, versionMinor, versionPatch, description, errors: [] };
|
||||
}
|
||||
@@ -205,7 +194,6 @@ export class UploadsController {
|
||||
contactTechnicalEmail: string;
|
||||
},
|
||||
) {
|
||||
if (!files || files.length === 0) throw new BadRequestException('YAML file is required');
|
||||
if (!body.name) throw new BadRequestException('name is required');
|
||||
if (!body.convention) throw new BadRequestException('convention is required');
|
||||
|
||||
@@ -218,7 +206,10 @@ export class UploadsController {
|
||||
let yamlContent: string;
|
||||
let detectedType: ApiType | null = null;
|
||||
|
||||
if (files.length === 1) {
|
||||
if (!files || files.length === 0) {
|
||||
/* Pas de fichier : entrée créée sans YAML (YAML pourra être ajouté plus tard) */
|
||||
yamlContent = '';
|
||||
} else if (files.length === 1) {
|
||||
/* Cas simple : un seul fichier */
|
||||
yamlContent = fs.readFileSync(files[0].path, 'utf-8');
|
||||
|
||||
@@ -260,12 +251,41 @@ export class UploadsController {
|
||||
contactTechnicalEmail: body.contactTechnicalEmail,
|
||||
});
|
||||
|
||||
/* Fire-and-forget : génération asynchrone */
|
||||
/* Fire-and-forget : génération asynchrone (seulement si YAML fourni) */
|
||||
if (yamlContent) {
|
||||
this.docsService.generate(entry.id).catch(console.error);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Extrait les métadonnées (titre, description, version) depuis le contenu YAML */
|
||||
private extractMetadata(yamlContent: string): {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
versionMajor: number | null;
|
||||
versionMinor: number | null;
|
||||
versionPatch: number | null;
|
||||
} {
|
||||
const parsed = yaml.load(yamlContent) as Record<string, unknown>;
|
||||
const info = parsed['info'] as Record<string, unknown> | undefined;
|
||||
const name = typeof info?.['title'] === 'string' ? info['title'] : null;
|
||||
const description = typeof info?.['description'] === 'string' ? info['description'] : null;
|
||||
|
||||
let versionMajor: number | null = null;
|
||||
let versionMinor: number | null = null;
|
||||
let versionPatch: number | null = null;
|
||||
const versionStr = typeof info?.['version'] === 'string' ? info['version'] : null;
|
||||
if (versionStr) {
|
||||
const parts = versionStr.split('.').map((p) => parseInt(p, 10));
|
||||
if (parts.length >= 1 && !isNaN(parts[0])) versionMajor = parts[0];
|
||||
if (parts.length >= 2 && !isNaN(parts[1])) versionMinor = parts[1];
|
||||
if (parts.length >= 3 && !isNaN(parts[2])) versionPatch = parts[2];
|
||||
}
|
||||
|
||||
return { name, description, versionMajor, versionMinor, versionPatch };
|
||||
}
|
||||
|
||||
/** Valide la syntaxe YAML et lève une BadRequestException si invalide */
|
||||
private validateYamlSyntax(content: string, filename: string): void {
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## 1. Purpose & Architecture
|
||||
|
||||
**Datacat** est un catalogue de données d'API. Il permet d'importer des fichiers YAML
|
||||
(AsyncAPI ou OpenAPI) et de les transformer en documentation HTML via des CLI spécialisées.
|
||||
(AsyncAPI ou OpenAPI) et de les transformer en documentation HTML.
|
||||
|
||||
```
|
||||
datacat/
|
||||
@@ -19,8 +19,8 @@ datacat/
|
||||
User uploads YAML → POST /api/uploads
|
||||
→ ApiEntry created (status: PENDING)
|
||||
→ DocsGenerationService.generate()
|
||||
→ asyncapi generate fromFile input.yaml --output /docs-output/<id>/
|
||||
→ OR redocly build-docs input.yaml --output /docs-output/<id>/index.html
|
||||
→ AsyncAPI : API programmatique @asyncapi/generator (html-template local)
|
||||
→ OpenAPI : redocly build-docs input.yaml --output /docs-output/<id>/index.html
|
||||
→ ApiEntry updated (status: GENERATED, htmlPath: /docs-output/<id>/index.html)
|
||||
→ GET /api/apis/:id/docs → sendFile(htmlPath)
|
||||
```
|
||||
@@ -31,17 +31,15 @@ User uploads YAML → POST /api/uploads
|
||||
|
||||
```bash
|
||||
# Tout en Docker (recommandé)
|
||||
pnpm run dev # docker compose up
|
||||
pnpm run dev:build # docker compose up --build
|
||||
docker compose -f infra/docker-compose.yml up -d
|
||||
docker compose -f infra/docker-compose.yml up -d --build # avec rebuild
|
||||
|
||||
# Local (sans Docker)
|
||||
cd back && pnpm run start:dev # NestJS watch mode — port 3000
|
||||
cd back && pnpm run start:dev # NestJS watch mode — port 3000 interne / 3010 hôte
|
||||
cd front-public && pnpm run start # Angular dev server — port 4200
|
||||
|
||||
# Install deps
|
||||
cd back && pnpm install
|
||||
cd front-public && pnpm install
|
||||
cd shared && pnpm install
|
||||
# Install deps (depuis la racine workspace)
|
||||
pnpm install
|
||||
|
||||
# Build prod
|
||||
cd back && pnpm run build
|
||||
@@ -49,11 +47,21 @@ cd front-public && pnpm run build:prod
|
||||
```
|
||||
|
||||
### Proxy Angular → API
|
||||
Créer `front-public/proxy.conf.json` :
|
||||
`front-public/proxy.conf.json` (développement local) :
|
||||
```json
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"target": "http://localhost:3010",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`front-public/proxy.conf.docker.json` (dans Docker) :
|
||||
```json
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://backend:3000",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
@@ -71,9 +79,10 @@ Créer `front-public/proxy.conf.json` :
|
||||
| TypeORM | ^0.3.20 |
|
||||
| PostgreSQL | 17 |
|
||||
| Angular | ^19.0.0 |
|
||||
| TypeScript | ^5.7.0 |
|
||||
| @asyncapi/cli | latest (in Docker) |
|
||||
| TypeScript | ~5.8.3 |
|
||||
| @asyncapi/cli | 6.0.0 (in Docker) |
|
||||
| @redocly/cli | latest (in Docker) |
|
||||
| @gouvfr/dsfr | ^1.13.0 (npm devDep front-public) |
|
||||
|
||||
### Conventions TypeScript
|
||||
- Pas de `any` — utiliser les types de `@datacat/shared`
|
||||
@@ -89,8 +98,13 @@ Créer `front-public/proxy.conf.json` :
|
||||
```sql
|
||||
CREATE TABLE api_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL,
|
||||
convention VARCHAR(30) NOT NULL DEFAULT 'CONSULTER'
|
||||
CHECK (convention IN ('CONSULTER','ENREGISTRER','ETRE_NOTIFIE')),
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
provider VARCHAR(255) NOT NULL DEFAULT '',
|
||||
version_major INT NOT NULL DEFAULT 0,
|
||||
version_minor INT NOT NULL DEFAULT 0,
|
||||
version_patch INT NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
|
||||
yaml_content TEXT NOT NULL,
|
||||
@@ -98,51 +112,95 @@ CREATE TABLE api_entries (
|
||||
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
|
||||
error_message TEXT,
|
||||
category_id VARCHAR(36),
|
||||
contact_functional_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
contact_functional_entity VARCHAR(255) NOT NULL DEFAULT '',
|
||||
contact_functional_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
contact_technical_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
contact_technical_entity VARCHAR(255) NOT NULL DEFAULT '',
|
||||
contact_technical_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
> `title` et `version` sont des champs **calculés** (pas en DB) :
|
||||
> - `title` = `CONVENTION_LABEL + ' ' + name` (ex: `"Consulter Petstore"`)
|
||||
> - `version` = `"major.minor.patch"` (ex: `"1.2.0"`)
|
||||
|
||||
### Entity TypeORM (`back/src/modules/apis/api-entry.entity.ts`)
|
||||
|
||||
```typescript
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { ApiType, ApiStatus } from '@datacat/shared';
|
||||
import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared';
|
||||
|
||||
@Entity('api_entries')
|
||||
export class ApiEntryEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
id!: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
@Column({ type: 'varchar', length: 30, default: 'CONSULTER' })
|
||||
convention!: ApiConvention;
|
||||
|
||||
@Column()
|
||||
version: string;
|
||||
@Column({ type: 'varchar', length: 255, default: '' })
|
||||
name!: string;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
description: string | null;
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 10 })
|
||||
type: ApiType;
|
||||
type!: ApiType;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, default: '', name: 'provider' })
|
||||
provider!: string;
|
||||
|
||||
@Column({ type: 'int', default: 0, name: 'version_major' })
|
||||
versionMajor!: number;
|
||||
|
||||
@Column({ type: 'int', default: 0, name: 'version_minor' })
|
||||
versionMinor!: number;
|
||||
|
||||
@Column({ type: 'int', default: 0, name: 'version_patch' })
|
||||
versionPatch!: number;
|
||||
|
||||
@Column({ type: 'text', name: 'yaml_content' })
|
||||
yamlContent: string;
|
||||
yamlContent!: string;
|
||||
|
||||
@Column({ nullable: true, name: 'html_path' })
|
||||
htmlPath: string | null;
|
||||
@Column({ nullable: true, type: 'varchar', length: 500, name: 'html_path' })
|
||||
htmlPath!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, default: 'PENDING' })
|
||||
status: ApiStatus;
|
||||
status!: ApiStatus;
|
||||
|
||||
@Column({ nullable: true, type: 'text', name: 'error_message' })
|
||||
errorMessage: string | null;
|
||||
errorMessage!: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
|
||||
categoryId!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
|
||||
contactFunctionalName!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_entity' })
|
||||
contactFunctionalEntity!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_email' })
|
||||
contactFunctionalEmail!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_name' })
|
||||
contactTechnicalName!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_entity' })
|
||||
contactTechnicalEntity!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_email' })
|
||||
contactTechnicalEmail!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
updatedAt!: Date;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -161,40 +219,60 @@ src/
|
||||
│ │ ├── apis.module.ts
|
||||
│ │ ├── apis.controller.ts # REST endpoints
|
||||
│ │ ├── apis.service.ts # CRUD logique
|
||||
│ │ └── api-entry.entity.ts # TypeORM entity
|
||||
│ │ ├── api-entry.entity.ts # TypeORM entity
|
||||
│ │ ├── api-entry.mapper.ts # entity → DTO (calcul title/version)
|
||||
│ │ └── dto/
|
||||
│ │ ├── create-api-entry.dto.ts
|
||||
│ │ └── update-api-entry.dto.ts
|
||||
│ ├── uploads/
|
||||
│ │ ├── uploads.module.ts
|
||||
│ │ └── uploads.controller.ts # POST /api/uploads (multipart)
|
||||
│ └── docs-generation/
|
||||
│ ├── docs-generation.module.ts
|
||||
│ └── docs-generation.service.ts # Appels AsyncAPI/Redocly CLI
|
||||
├── database/
|
||||
│ └── database.module.ts # TypeORM config (si séparé)
|
||||
│ │ └── uploads.controller.ts # POST /api/uploads + POST /api/uploads/validate
|
||||
│ ├── docs-generation/
|
||||
│ │ ├── docs-generation.module.ts
|
||||
│ │ └── docs-generation.service.ts # API programmatique @asyncapi/generator + redocly CLI
|
||||
│ ├── categories/
|
||||
│ │ ├── categories.module.ts
|
||||
│ │ ├── categories.controller.ts
|
||||
│ │ ├── categories.service.ts
|
||||
│ │ └── category.entity.ts
|
||||
│ └── seeder/
|
||||
│ ├── seeder.module.ts
|
||||
│ └── seeder.service.ts # Données de démo (idempotent, OnApplicationBootstrap)
|
||||
├── health/
|
||||
│ ├── health.module.ts
|
||||
│ └── health.controller.ts
|
||||
└── common/
|
||||
├── filters/
|
||||
│ └── all-exceptions.filter.ts
|
||||
└── interceptors/
|
||||
└── transform.interceptor.ts
|
||||
└── filters/
|
||||
└── all-exceptions.filter.ts
|
||||
```
|
||||
|
||||
### API REST
|
||||
|
||||
```
|
||||
GET /health → { status: 'ok' }
|
||||
GET /api/apis → ApiListResponse (+ ?search=&type=&page=&limit=)
|
||||
GET /api/apis → ApiListResponse (+ ?search=&type=&categoryId=&page=&limit=)
|
||||
GET /api/apis/:id → ApiEntry
|
||||
POST /api/uploads → ApiEntry (multipart: file + title + version + description + type)
|
||||
PATCH /api/apis/:id → ApiEntry (body: UpdateApiEntryDto)
|
||||
DELETE /api/apis/:id → 204 No Content
|
||||
POST /api/apis/:id/regenerate → ApiEntry (relance la génération de docs)
|
||||
GET /api/apis/:id/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)
|
||||
POST /api/uploads → ApiEntry (multipart: files 1..20 + métadonnées)
|
||||
POST /api/uploads/validate → résultat de validation (sans sauvegarde)
|
||||
GET /api/categories → liste flat toutes catégories
|
||||
GET /api/categories/browse → navigation racine (sous-catégories + APIs sans catégorie)
|
||||
GET /api/categories/browse/:id → navigation dans une catégorie
|
||||
GET /api/categories/:id → détail catégorie
|
||||
POST /api/categories → créer catégorie
|
||||
PATCH /api/categories/:id → modifier catégorie
|
||||
DELETE /api/categories/:id → supprimer (400 si non vide)
|
||||
```
|
||||
|
||||
### ApisController
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, Patch, Delete, Post, Param, Body, Query, Res, NotFoundException } from '@nestjs/common';
|
||||
import { Controller, Get, Patch, Delete, Post, Param, Body, Query, Res, NotFoundException, HttpCode } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller('apis')
|
||||
@@ -237,7 +315,7 @@ export class ApisController {
|
||||
@Get(':id/yaml')
|
||||
async getYaml(@Param('id') id: string, @Res() res: Response) {
|
||||
const entry = await this.apisService.findOneOrThrow(id);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${entry.title}.yaml"`);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${entry.name}.yaml"`);
|
||||
res.setHeader('Content-Type', 'application/x-yaml');
|
||||
res.send(entry.yamlContent);
|
||||
}
|
||||
@@ -247,8 +325,8 @@ export class ApisController {
|
||||
### UploadsController
|
||||
|
||||
```typescript
|
||||
import { Controller, Post, UseInterceptors, UploadedFile, Body } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Controller, Post, UseInterceptors, UploadedFiles, Body, BadRequestException } from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
|
||||
@Controller('uploads')
|
||||
@@ -258,22 +336,38 @@ export class UploadsController {
|
||||
private readonly docsService: DocsGenerationService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
storage: diskStorage({ destination: '/tmp/uploads' }),
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.originalname.match(/\.(yaml|yml)$/)) {
|
||||
return cb(new Error('Only YAML files are allowed'), false);
|
||||
// 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 }
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}))
|
||||
|
||||
// POST /api/uploads — crée l'entrée + lance la génération
|
||||
@Post()
|
||||
@UseInterceptors(FilesInterceptor('files', 20, { /* diskStorage + fileFilter yaml */ }))
|
||||
async upload(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() body: { title: string; version: string; description?: string; type: 'ASYNCAPI' | 'OPENAPI' },
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Body() body: {
|
||||
convention: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
versionMajor: string; // string car multipart form-data
|
||||
versionMinor: string;
|
||||
versionPatch: string;
|
||||
description?: string;
|
||||
type?: 'ASYNCAPI' | 'OPENAPI'; // optionnel, détecté depuis le YAML
|
||||
categoryId?: string;
|
||||
contactFunctionalName: string;
|
||||
contactFunctionalEntity: string;
|
||||
contactFunctionalEmail: string;
|
||||
contactTechnicalName: string;
|
||||
contactTechnicalEntity: string;
|
||||
contactTechnicalEmail: string;
|
||||
},
|
||||
) {
|
||||
const yamlContent = fs.readFileSync(file.path, 'utf-8');
|
||||
const entry = await this.apisService.create({ ...body, yamlContent });
|
||||
// 1 fichier → lecture directe
|
||||
// N fichiers → détection fichier principal + bundling (redocly bundle / @asyncapi/bundler)
|
||||
// Fire and forget — génération asynchrone
|
||||
this.docsService.generate(entry.id).catch(console.error);
|
||||
return entry;
|
||||
@@ -289,19 +383,19 @@ import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
const AsyncApiGenerator = require('@asyncapi/generator');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@Injectable()
|
||||
export class DocsGenerationService {
|
||||
private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
|
||||
private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? './docs-output';
|
||||
|
||||
async generate(entryId: string): Promise<void> {
|
||||
const entry = await this.apisService.findOneOrThrow(entryId);
|
||||
const entryDir = path.join(this.outputDir, entryId);
|
||||
|
||||
await fs.mkdir(entryDir, { recursive: true });
|
||||
|
||||
const yamlPath = path.join(entryDir, 'input.yaml');
|
||||
await fs.writeFile(yamlPath, entry.yamlContent, 'utf-8');
|
||||
|
||||
@@ -311,37 +405,32 @@ export class DocsGenerationService {
|
||||
} else {
|
||||
await this.generateOpenApi(yamlPath, entryDir);
|
||||
}
|
||||
|
||||
const htmlPath = path.join(entryDir, 'index.html');
|
||||
await this.apisService.update(entryId, { htmlPath, status: 'GENERATED' });
|
||||
await this.apisService.updateInternal(entryId, { htmlPath: path.join(entryDir, 'index.html'), status: 'GENERATED' });
|
||||
} catch (error) {
|
||||
await this.apisService.update(entryId, {
|
||||
status: 'ERROR',
|
||||
errorMessage: String(error),
|
||||
});
|
||||
await this.apisService.updateInternal(entryId, { status: 'ERROR', errorMessage: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
|
||||
// asyncapi generate fromFile <input> --output <dir>
|
||||
await execFileAsync('asyncapi', [
|
||||
'generate', 'fromFile',
|
||||
yamlPath,
|
||||
'--output', outputDir,
|
||||
]);
|
||||
// API programmatique — aucun appel réseau, template résolu localement
|
||||
const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
|
||||
const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
|
||||
await generator.generateFromFile(yamlPath);
|
||||
}
|
||||
|
||||
private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
|
||||
// redocly build-docs <input> --output <file>
|
||||
await execFileAsync('redocly', [
|
||||
'build-docs',
|
||||
yamlPath,
|
||||
'build-docs', yamlPath,
|
||||
'--output', path.join(outputDir, 'index.html'),
|
||||
]);
|
||||
], { shell: true, env: execEnv });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note** : `ApisService` expose deux méthodes de mise à jour :
|
||||
> - `update(id, dto: UpdateApiEntryDto)` — pour le controller (champs éditables par l'utilisateur)
|
||||
> - `updateInternal(id, fields)` — pour `DocsGenerationService` (status / htmlPath / errorMessage)
|
||||
|
||||
---
|
||||
|
||||
## 6. Conventions Angular 19
|
||||
@@ -362,44 +451,31 @@ import { ActivatedRoute } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalog',
|
||||
selector: 'app-browse',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fr-container fr-mt-4w">
|
||||
<h1 class="fr-h1">Catalogue des APIs</h1>
|
||||
<h1 class="fr-h1">Parcourir les APIs</h1>
|
||||
@if (loading()) {
|
||||
<div class="fr-callout">Chargement...</div>
|
||||
}
|
||||
@for (api of apis(); track api.id) {
|
||||
@for (cat of subcategories(); track cat.id) {
|
||||
<div class="fr-card fr-mb-2w">
|
||||
<h2>{{ api.title }}</h2>
|
||||
<h2>{{ cat.name }}</h2>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class CatalogComponent implements OnInit {
|
||||
export class BrowseComponent implements OnInit {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
apis = signal<ApiEntryListItem[]>([]);
|
||||
subcategories = signal<CategoryWithCounts[]>([]);
|
||||
loading = signal(false);
|
||||
|
||||
ngOnInit() {
|
||||
this.loadApis();
|
||||
}
|
||||
|
||||
private loadApis() {
|
||||
this.loading.set(true);
|
||||
this.http.get<ApiListResponse>('/api/apis').subscribe({
|
||||
next: (res) => {
|
||||
this.apis.set(res.items);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
});
|
||||
}
|
||||
ngOnInit() { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -438,23 +514,13 @@ export class ApiService {
|
||||
|
||||
## 7. DSFR (Système de Design de l'État)
|
||||
|
||||
### Installation (assets statiques — pas de package npm)
|
||||
### Installation via npm
|
||||
|
||||
Télécharger la dernière version depuis https://www.systeme-de-design.gouv.fr/
|
||||
et placer les assets dans `front-public/src/assets/dsfr/`.
|
||||
|
||||
Structure attendue :
|
||||
```bash
|
||||
# Déjà dans front-public/package.json :
|
||||
# "@gouvfr/dsfr": "^1.13.0" (devDependencies)
|
||||
# Assets copiés automatiquement via angular.json (assets config)
|
||||
```
|
||||
src/assets/dsfr/
|
||||
├── dsfr.min.css
|
||||
├── dsfr.module.min.js
|
||||
├── dsfr.nomodule.min.js
|
||||
└── utility/
|
||||
└── icons/
|
||||
└── icons.min.css
|
||||
```
|
||||
|
||||
Déjà référencé dans `index.html`.
|
||||
|
||||
### Classes CSS essentielles
|
||||
|
||||
@@ -506,24 +572,24 @@ fr-mt-4w fr-mb-2w fr-ml-1w fr-p-3w fr-mx-auto
|
||||
- `fr-icon-delete-bin-line` — supprimer
|
||||
- `fr-icon-refresh-line` — régénérer
|
||||
- `fr-icon-eye-line` — voir
|
||||
- `fr-icon-folder-2-line` — dossier
|
||||
- `fr-icon-edit-line` — éditer
|
||||
|
||||
---
|
||||
|
||||
## 8. Commandes AsyncAPI CLI & Redocly CLI
|
||||
## 8. Génération de documentation
|
||||
|
||||
### AsyncAPI CLI
|
||||
### AsyncAPI — API programmatique (pas CLI)
|
||||
|
||||
```bash
|
||||
# Générer HTML depuis un fichier YAML AsyncAPI
|
||||
asyncapi generate fromFile ./api.yaml --output ./output-dir/
|
||||
|
||||
# Valider un fichier
|
||||
asyncapi validate ./api.yaml
|
||||
|
||||
# Format de sortie : ./output-dir/index.html
|
||||
```typescript
|
||||
// Utilise @asyncapi/generator directement (installé dans node_modules)
|
||||
// Template @asyncapi/html-template pré-installé dans l'image Docker
|
||||
const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
|
||||
const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
|
||||
await generator.generateFromFile(yamlPath);
|
||||
```
|
||||
|
||||
### Redocly CLI
|
||||
### OpenAPI — CLI Redocly
|
||||
|
||||
```bash
|
||||
# Générer HTML depuis un fichier OpenAPI YAML
|
||||
@@ -531,15 +597,17 @@ redocly build-docs ./api.yaml --output ./output-dir/index.html
|
||||
|
||||
# Valider un fichier OpenAPI
|
||||
redocly lint ./api.yaml
|
||||
|
||||
# Format de sortie : ./output-dir/index.html
|
||||
```
|
||||
|
||||
### Bundling multi-fichiers
|
||||
- **OpenAPI** : `redocly bundle <main> --output <bundled.yaml>`
|
||||
- **AsyncAPI** : API programmatique `@asyncapi/bundler`
|
||||
|
||||
### Notes importantes
|
||||
- Les deux CLIs sont installés dans l'image Docker prod (`infra/Dockerfile.prod`, target `back`)
|
||||
- En dev, ils sont installés dans `infra/Dockerfile.back.dev`
|
||||
- `@asyncapi/generator` et `@asyncapi/html-template` sont installés dans l'image Docker prod et dev
|
||||
- `@redocly/cli` est installé globalement dans les images Docker
|
||||
- La génération est **asynchrone** : l'upload crée l'entrée, puis la génération se fait en background
|
||||
- Le répertoire de sortie : `DOCS_OUTPUT_DIR=/app/docs-output` (PVC en prod)
|
||||
- Le répertoire de sortie : `DOCS_OUTPUT_DIR=/app/docs-output` (PVC en prod), `./docs-output` en dev
|
||||
- Chaque API a son propre sous-répertoire : `/app/docs-output/<uuid>/index.html`
|
||||
|
||||
---
|
||||
@@ -618,18 +686,6 @@ Dans `https://ci.chmod777.dev` → Settings → Secrets pour le dépôt `datacat
|
||||
- `registry_password` : token Gitea avec accès packages
|
||||
- `deploy_kubeconfig` : contenu du fichier `~/.kube/config`
|
||||
|
||||
### Dépôt Gitea
|
||||
Créer le dépôt `datacat` dans l'organisation `z3n` sur `https://git.chmod777.dev`.
|
||||
|
||||
```bash
|
||||
cd /home/flo/dev/datacat
|
||||
git init
|
||||
git remote add origin https://git.chmod777.dev/z3n/datacat.git
|
||||
git add .
|
||||
git commit -m "feat: initial scaffold"
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Règles de code
|
||||
@@ -637,7 +693,7 @@ git push -u origin main
|
||||
- **Langue** : code en anglais, commentaires en français
|
||||
- **Commits** : Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`)
|
||||
- **TypeScript** : pas de `any`, `strict: true`, types depuis `@datacat/shared`
|
||||
- **NestJS** : un module par feature (`ApisModule`, `UploadsModule`, `DocsGenerationModule`)
|
||||
- **NestJS** : un module par feature (`ApisModule`, `UploadsModule`, `DocsGenerationModule`, `CategoriesModule`)
|
||||
- **Angular** : standalone components, signals, inject(), OnPush, lazy loading
|
||||
- **Tests** : Vitest pour le backend, `ng test` pour le frontend
|
||||
- **Pas de `console.log`** en production — utiliser Pino (NestJS) ou le logger Angular
|
||||
@@ -646,30 +702,48 @@ git push -u origin main
|
||||
|
||||
## 12. Pages Angular — Détail d'implémentation
|
||||
|
||||
### `/browse` et `/browse/:id` — BrowseComponent
|
||||
- Navigation par dossiers DSFR (tuiles `fr-tile` ou cartes `fr-card`)
|
||||
- Fil d'Ariane DSFR (`fr-breadcrumb`) depuis le breadcrumb retourné par l'API
|
||||
- Boutons : créer dossier, éditer dossier (crayon), supprimer dossier
|
||||
- Modales partagées : `CategoryFormModalComponent` (créer/éditer), `CategoryDeleteModalComponent`
|
||||
- Recherche dans les APIs du dossier courant
|
||||
- `BrowseComponent` réutilisé pour `/browse` et `/browse/:id` — s'abonne à `paramMap` via `takeUntilDestroyed`
|
||||
|
||||
### `/catalog` — CatalogComponent
|
||||
- Grille DSFR de cartes (`fr-card`)
|
||||
- Champ de recherche (`fr-input`) filtrant titre + description
|
||||
- Champ de recherche (`fr-input`) filtrant `name`, `provider`, `description`, contacts
|
||||
- Select DSFR pour filtrer par type (ASYNCAPI / OPENAPI)
|
||||
- Pagination DSFR (`fr-pagination`)
|
||||
- Lien vers `/upload` avec bouton `fr-btn fr-btn--icon-left fr-icon-upload-2-line`
|
||||
|
||||
### `/catalog/:id` — ApiDetailComponent
|
||||
- Métadonnées : titre, version, description, type, statut (badge), dates
|
||||
- Fil d'Ariane aligné sur la hiérarchie des catégories
|
||||
- Titre affiché : `CONVENTION_LABEL + ' ' + name` (ex: `"Consulter Petstore"`)
|
||||
- Métadonnées : convention (badge), name, provider, version, description, type, statut, dates
|
||||
- Contacts métier (nom, entité, email) et technique (nom, entité, email)
|
||||
- Badge de statut : `fr-badge--success` (GENERATED), `fr-badge--warning` (PENDING), `fr-badge--error` (ERROR)
|
||||
- Boutons :
|
||||
- "Voir la documentation" → `/catalog/:id/docs` (désactivé si status ≠ GENERATED)
|
||||
- "Télécharger YAML" → `/api/apis/:id/yaml`
|
||||
- "Régénérer" → POST `/api/apis/:id/regenerate`
|
||||
- "Supprimer" → DELETE `/api/apis/:id` + redirect `/catalog`
|
||||
- "Modifier" → modale d'édition (tous les champs sauf yamlContent)
|
||||
- "Supprimer" → modale de confirmation → DELETE `/api/apis/:id` + redirect `/browse`
|
||||
|
||||
### `/catalog/:id/docs` — DocViewerComponent
|
||||
- `<iframe>` plein écran affichant `/api/apis/:id/docs`
|
||||
- Bouton "Retour" en haut
|
||||
|
||||
### `/upload` — UploadComponent
|
||||
- Stepper DSFR 3 étapes : (1) Choisir le fichier, (2) Métadonnées, (3) Confirmation
|
||||
- Étape 1 : Zone de drop DSFR (`fr-upload-group`) pour fichier `.yaml` / `.yml`
|
||||
- Étape 2 : Formulaire (titre, version, description, type select AsyncAPI/OpenAPI)
|
||||
- Stepper DSFR 3 étapes : (1) Choisir les fichiers, (2) Métadonnées, (3) Confirmation
|
||||
- Étape 1 : Zone de drop DSFR (`fr-upload-group`) pour 1 à 20 fichiers `.yaml` / `.yml`
|
||||
- Validation serveur via `POST /api/uploads/validate` dès la sélection
|
||||
- Pré-remplit les champs `name`, `versionMajor/Minor/Patch`, `description` depuis le YAML
|
||||
- Étape 2 : Formulaire — `convention` (select), `name`, `provider`, `versionMajor/Minor/Patch`,
|
||||
`description`, `categoryId` (optionnel), contacts métier + technique
|
||||
- Étape 3 : Récap + bouton "Importer"
|
||||
- POST multipart vers `/api/uploads`
|
||||
- Redirect vers `/catalog/:id` après succès
|
||||
|
||||
### Composants partagés
|
||||
- `CategoryFormModalComponent` — modale DSFR pour créer ou éditer une catégorie (nom, description, ordre)
|
||||
- `CategoryDeleteModalComponent` — modale DSFR de confirmation de suppression d'une catégorie
|
||||
|
||||
@@ -29,7 +29,7 @@ Liste les entrées avec filtres optionnels et pagination.
|
||||
|
||||
| Paramètre | Type | Défaut | Description |
|
||||
|-----------|------|--------|-------------|
|
||||
| `search` | string | — | Recherche dans le titre et la description (case-insensitive) |
|
||||
| `search` | string | — | Recherche dans `name`, `provider`, `description`, contacts (case-insensitive) |
|
||||
| `type` | `ASYNCAPI` \| `OPENAPI` | — | Filtre par type |
|
||||
| `categoryId` | UUID | — | Filtre par catégorie |
|
||||
| `page` | number | `1` | Page (min 1) |
|
||||
@@ -41,10 +41,13 @@ Liste les entrées avec filtres optionnels et pagination.
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "My API",
|
||||
"title": "Consulter Petstore",
|
||||
"name": "Petstore",
|
||||
"convention": "CONSULTER",
|
||||
"version": "1.0.0",
|
||||
"description": "...",
|
||||
"type": "OPENAPI",
|
||||
"provider": "Équipe Produit",
|
||||
"status": "GENERATED",
|
||||
"categoryId": "uuid-or-null",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -57,7 +60,7 @@ Liste les entrées avec filtres optionnels et pagination.
|
||||
}
|
||||
```
|
||||
|
||||
> `items` contient des `ApiEntryListItem` (sans `yamlContent` ni `htmlPath`).
|
||||
> `items` contient des `ApiEntryListItem` (sans `yamlContent`, `htmlPath`, contacts ni `versionMajor/Minor/Patch`).
|
||||
|
||||
---
|
||||
|
||||
@@ -69,15 +72,27 @@ Retourne le détail complet d'une entrée.
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "My API",
|
||||
"title": "Consulter Petstore",
|
||||
"name": "Petstore",
|
||||
"convention": "CONSULTER",
|
||||
"version": "1.0.0",
|
||||
"versionMajor": 1,
|
||||
"versionMinor": 0,
|
||||
"versionPatch": 0,
|
||||
"description": "...",
|
||||
"type": "OPENAPI",
|
||||
"provider": "Équipe Produit",
|
||||
"yamlContent": "openapi: '3.0.0'\n...",
|
||||
"htmlPath": "/app/docs-output/uuid/index.html",
|
||||
"status": "GENERATED",
|
||||
"errorMessage": null,
|
||||
"categoryId": "uuid-or-null",
|
||||
"contactFunctionalName": "Alice Martin",
|
||||
"contactFunctionalEntity": "DSI",
|
||||
"contactFunctionalEmail": "alice@example.com",
|
||||
"contactTechnicalName": "Bob Dupont",
|
||||
"contactTechnicalEntity": "Équipe Backend",
|
||||
"contactTechnicalEmail": "bob@example.com",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
@@ -94,10 +109,20 @@ Met à jour les métadonnées d'une entrée (champs éditables par l'utilisateur
|
||||
**Body JSON** (tous les champs sont optionnels)
|
||||
```json
|
||||
{
|
||||
"title": "New title",
|
||||
"version": "2.0.0",
|
||||
"description": "Updated description",
|
||||
"categoryId": "uuid-or-null"
|
||||
"convention": "CONSULTER",
|
||||
"name": "Nouveau nom",
|
||||
"provider": "Équipe X",
|
||||
"versionMajor": 2,
|
||||
"versionMinor": 0,
|
||||
"versionPatch": 0,
|
||||
"description": "Description mise à jour",
|
||||
"categoryId": "uuid-or-null",
|
||||
"contactFunctionalName": "...",
|
||||
"contactFunctionalEntity": "...",
|
||||
"contactFunctionalEmail": "...",
|
||||
"contactTechnicalName": "...",
|
||||
"contactTechnicalEntity": "...",
|
||||
"contactTechnicalEmail": "..."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -146,7 +171,7 @@ Télécharge le fichier YAML source.
|
||||
|
||||
**Réponse 200**
|
||||
- `Content-Type: application/x-yaml`
|
||||
- `Content-Disposition: attachment; filename="<title>.yaml"`
|
||||
- `Content-Disposition: attachment; filename="<name>.yaml"`
|
||||
- Corps : contenu YAML brut
|
||||
|
||||
**Réponse 404** — entrée introuvable
|
||||
@@ -155,6 +180,52 @@ Télécharge le fichier YAML source.
|
||||
|
||||
## Upload (`/api/uploads`)
|
||||
|
||||
### `POST /api/uploads/validate`
|
||||
|
||||
Valide 1 à 20 fichiers YAML sans créer d'entrée en base. Utilisé pour la validation en temps réel lors de l'upload.
|
||||
|
||||
**Content-Type** : `multipart/form-data`
|
||||
|
||||
**Champs du formulaire**
|
||||
|
||||
| Champ | Type | Requis | Description |
|
||||
|-------|------|--------|-------------|
|
||||
| `files` | file (1 à 20) | Oui | Fichiers YAML (`.yaml` ou `.yml`) |
|
||||
|
||||
**Réponse 200**
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"type": "OPENAPI",
|
||||
"mainFile": "petstore.yaml",
|
||||
"name": "Petstore",
|
||||
"versionMajor": 1,
|
||||
"versionMinor": 0,
|
||||
"versionPatch": 0,
|
||||
"description": "A sample API",
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
En cas d'erreur :
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"type": null,
|
||||
"mainFile": null,
|
||||
"name": null,
|
||||
"versionMajor": null,
|
||||
"versionMinor": null,
|
||||
"versionPatch": null,
|
||||
"description": null,
|
||||
"errors": [
|
||||
{ "file": "api.yaml", "message": "erreur de syntaxe YAML — unexpected token" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/uploads`
|
||||
|
||||
Crée une nouvelle entrée à partir d'un ou plusieurs fichiers YAML.
|
||||
@@ -167,11 +238,21 @@ Lance la génération de documentation en arrière-plan (fire-and-forget).
|
||||
| Champ | Type | Requis | Description |
|
||||
|-------|------|--------|-------------|
|
||||
| `files` | file (1 à 20) | Oui | Fichiers YAML (`.yaml` ou `.yml`) |
|
||||
| `title` | string | Oui | Titre de l'API |
|
||||
| `version` | string | Oui | Version (ex: `1.0.0`) |
|
||||
| `convention` | `CONSULTER` \| `ENREGISTRER` \| `ETRE_NOTIFIE` | Oui | Convention d'échange |
|
||||
| `name` | string | Oui | Nom de l'API |
|
||||
| `provider` | string | Oui | Fournisseur / équipe responsable |
|
||||
| `versionMajor` | string (number) | Non | Version majeure (défaut: 0) |
|
||||
| `versionMinor` | string (number) | Non | Version mineure (défaut: 0) |
|
||||
| `versionPatch` | string (number) | Non | Version patch (défaut: 0) |
|
||||
| `description` | string | Non | Description libre |
|
||||
| `type` | `ASYNCAPI` \| `OPENAPI` | Non | Type (détecté automatiquement si absent) |
|
||||
| `categoryId` | UUID | Non | Catégorie parente |
|
||||
| `contactFunctionalName` | string | Non | Contact métier — nom |
|
||||
| `contactFunctionalEntity` | string | Non | Contact métier — entité |
|
||||
| `contactFunctionalEmail` | string | Non | Contact métier — email |
|
||||
| `contactTechnicalName` | string | Non | Contact technique — nom |
|
||||
| `contactTechnicalEntity` | string | Non | Contact technique — entité |
|
||||
| `contactTechnicalEmail` | string | Non | Contact technique — email |
|
||||
|
||||
**Détection automatique du type**
|
||||
|
||||
@@ -180,13 +261,13 @@ Le type est détecté à partir du contenu YAML :
|
||||
- Présence de `asyncapi:` en début de ligne → `ASYNCAPI`
|
||||
|
||||
Si plusieurs fichiers sont fournis, le fichier contenant la clé racine est considéré comme le fichier principal.
|
||||
Les autres sont bundlés via `redocly bundle` (OpenAPI) ou `asyncapi bundle` (AsyncAPI).
|
||||
Les autres sont bundlés via `redocly bundle` (OpenAPI) ou `@asyncapi/bundler` (AsyncAPI).
|
||||
|
||||
**Réponse 201** — `ApiEntry` créé avec `status: "PENDING"`
|
||||
|
||||
**Réponse 400**
|
||||
- Aucun fichier fourni
|
||||
- `title` ou `version` manquant
|
||||
- `name` ou `convention` manquant
|
||||
- Fichier non YAML
|
||||
- Type non détectable et non fourni
|
||||
- Aucun fichier principal trouvé dans un upload multi-fichiers
|
||||
@@ -243,8 +324,14 @@ Navigation à la racine : retourne les catégories de premier niveau et les APIs
|
||||
"apis": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "API sans catégorie",
|
||||
...
|
||||
"title": "Consulter API sans catégorie",
|
||||
"name": "API sans catégorie",
|
||||
"convention": "CONSULTER",
|
||||
"version": "1.0.0",
|
||||
"provider": "...",
|
||||
"status": "GENERATED",
|
||||
"categoryId": null,
|
||||
"..."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -331,10 +418,11 @@ Met à jour une catégorie.
|
||||
### `DELETE /api/categories/:id`
|
||||
|
||||
Supprime une catégorie.
|
||||
Les APIs et sous-catégories enfants sont désassociées (`categoryId`/`parentId` mis à null).
|
||||
|
||||
**Réponse 204** — succès (pas de corps)
|
||||
|
||||
**Réponse 400** — le dossier contient des APIs ou des sous-dossiers (vérification applicative avant suppression)
|
||||
|
||||
**Réponse 404** — catégorie introuvable
|
||||
|
||||
---
|
||||
@@ -343,7 +431,7 @@ Les APIs et sous-catégories enfants sont désassociées (`categoryId`/`parentId
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| 400 | Requête invalide (champ manquant, format incorrect) |
|
||||
| 400 | Requête invalide (champ manquant, format incorrect, suppression non vide) |
|
||||
| 404 | Ressource introuvable |
|
||||
| 500 | Erreur interne serveur |
|
||||
|
||||
@@ -365,3 +453,11 @@ Les APIs et sous-catégories enfants sont désassociées (`categoryId`/`parentId
|
||||
| `PENDING` | Entrée créée, génération de doc en cours |
|
||||
| `GENERATED` | Documentation HTML disponible |
|
||||
| `ERROR` | La génération a échoué (voir `errorMessage`) |
|
||||
|
||||
## Conventions d'échange
|
||||
|
||||
| Convention | Label affiché |
|
||||
|------------|---------------|
|
||||
| `CONSULTER` | Consulter |
|
||||
| `ENREGISTRER` | Enregistrer |
|
||||
| `ETRE_NOTIFIE` | Être notifié |
|
||||
|
||||
@@ -84,8 +84,13 @@ Injecte des données de démonstration au démarrage (idempotent : ne re-seed qu
|
||||
```sql
|
||||
CREATE TABLE api_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL,
|
||||
convention VARCHAR(30) NOT NULL DEFAULT 'CONSULTER'
|
||||
CHECK (convention IN ('CONSULTER','ENREGISTRER','ETRE_NOTIFIE')),
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
provider VARCHAR(255) NOT NULL DEFAULT '',
|
||||
version_major INT NOT NULL DEFAULT 0,
|
||||
version_minor INT NOT NULL DEFAULT 0,
|
||||
version_patch INT NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
|
||||
yaml_content TEXT NOT NULL,
|
||||
@@ -94,11 +99,21 @@ CREATE TABLE api_entries (
|
||||
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
|
||||
error_message TEXT,
|
||||
category_id VARCHAR(36),
|
||||
contact_functional_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
contact_functional_entity VARCHAR(255) NOT NULL DEFAULT '',
|
||||
contact_functional_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
contact_technical_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
contact_technical_entity VARCHAR(255) NOT NULL DEFAULT '',
|
||||
contact_technical_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
> `title` et `version` sont des champs calculés (pas en DB) :
|
||||
> - `title` = `CONVENTION_LABEL + ' ' + name` (ex: `"Consulter Petstore"`)
|
||||
> - `version` = `"major.minor.patch"` (ex: `"1.0.0"`)
|
||||
|
||||
### Table `categories`
|
||||
|
||||
```sql
|
||||
@@ -114,7 +129,7 @@ CREATE TABLE categories (
|
||||
```
|
||||
|
||||
> Les relations `category_id` / `parent_id` sont des colonnes varchar sans contrainte de clé étrangère TypeORM.
|
||||
> La suppression d'une catégorie désassocie automatiquement ses APIs et sous-catégories enfants (logique applicative).
|
||||
> La suppression d'une catégorie est **rejetée avec 400** si le dossier contient des APIs ou des sous-dossiers (vérification applicative avant `DELETE`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -138,6 +138,65 @@ Des exemples sont disponibles dans `documentation/` :
|
||||
|
||||
---
|
||||
|
||||
## Fixtures & re-seed
|
||||
|
||||
Les données de démo (catégories + APIs) sont définies dans `back/src/modules/seeder/seeder.service.ts`.
|
||||
|
||||
### Structure des fixtures
|
||||
|
||||
```
|
||||
E-commerce (racine)
|
||||
└─ Produits
|
||||
└─ Commandes
|
||||
└─ Notifications
|
||||
Infrastructure (racine)
|
||||
└─ Événements
|
||||
Identité & Accès (racine)
|
||||
└─ Authentification
|
||||
```
|
||||
|
||||
5 APIs de démo y sont rattachées (Petstore, Streetlights Kafka, Account Service, Orders, Auth JWT).
|
||||
|
||||
### Modifier les fixtures
|
||||
|
||||
Éditer directement `back/src/modules/seeder/seeder.service.ts` :
|
||||
|
||||
- **Catégories** : bloc `categoryRepo.save(categoryRepo.create({...}))` dans la méthode `seed()`. Utiliser `parentId: null` pour une racine, ou `parentId: autreCategorie.id` pour un enfant.
|
||||
- **APIs** : tableau passé à `apiRepo.save([...])`. Chaque entrée référence une catégorie via `categoryId`.
|
||||
- **YAML** : les constantes en haut du fichier (`PETSTORE_YAML`, `ORDERS_YAML`, etc.) contiennent le YAML inliné de chaque API.
|
||||
|
||||
### Régénérer la base de données
|
||||
|
||||
Le seeder est **idempotent** : il ne s'exécute pas si la table `api_entries` contient déjà des lignes.
|
||||
|
||||
Pour forcer un re-seed complet (vide tout et recrée), utiliser la variable `FORCE_RESEED=true` :
|
||||
|
||||
**Avec Docker :**
|
||||
```bash
|
||||
docker compose -f infra/docker-compose.yml stop backend
|
||||
docker compose -f infra/docker-compose.yml run --rm -e FORCE_RESEED=true backend
|
||||
```
|
||||
|
||||
**Sans Docker (local) :**
|
||||
```bash
|
||||
cd back
|
||||
FORCE_RESEED=true pnpm run start:dev
|
||||
```
|
||||
|
||||
Ou via `back/.env` :
|
||||
```env
|
||||
FORCE_RESEED=true
|
||||
```
|
||||
> Penser à retirer `FORCE_RESEED=true` du `.env` après le redémarrage, sinon la base sera réinitialisée à chaque démarrage.
|
||||
|
||||
Le seeder supprime toutes les entrées `api_entries` et `categories`, nettoie le répertoire `docs-output`, puis recrée tout depuis `seed()`. Les logs confirment l'opération :
|
||||
```
|
||||
[Seeder] Reset complet — données supprimées, docs nettoyées.
|
||||
[Seeder] 8 catégories et 5 APIs de démo créées — génération docs en cours...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Schéma DB** : TypeORM crée et synchronise automatiquement les tables au démarrage (mode `synchronize: true` hors production). Aucune migration manuelle nécessaire.
|
||||
|
||||
@@ -49,6 +49,10 @@ export class ApiService {
|
||||
return this.http.get<ApiEntryListItem[]>(`${this.baseUrl}/${id}/versions`);
|
||||
}
|
||||
|
||||
setCurrent(id: string): Observable<ApiEntry> {
|
||||
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/set-current`, {});
|
||||
}
|
||||
|
||||
regenerate(id: string): Observable<ApiEntry> {
|
||||
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
|
||||
}
|
||||
|
||||
@@ -77,21 +77,21 @@ import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
<!-- Droite : bouton Actions + dropdown -->
|
||||
<div style="position:relative;flex-shrink:0;">
|
||||
<!-- 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;">
|
||||
<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
|
||||
@@ -118,6 +118,7 @@ import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation onglets -->
|
||||
<div style="display:flex;border-bottom:2px solid #e5e5e5;margin-bottom:1.5rem;">
|
||||
@@ -595,8 +596,17 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
onDelete() {
|
||||
if (!confirm('Supprimer cette API ?')) return;
|
||||
this.deleting.set(true);
|
||||
/* Calculer la cible de redirection avant la suppression */
|
||||
const otherVersions = this.versions().filter((v) => v.id !== this.id);
|
||||
const redirectTarget = otherVersions.find((v) => v.isCurrent) ?? otherVersions[0];
|
||||
this.apiService.delete(this.id).subscribe({
|
||||
next: () => this.router.navigate(['/catalog']),
|
||||
next: () => {
|
||||
if (redirectTarget) {
|
||||
this.router.navigate(['/catalog', redirectTarget.id]);
|
||||
} else {
|
||||
this.router.navigate(this.backUrl());
|
||||
}
|
||||
},
|
||||
error: () => this.deleting.set(false),
|
||||
});
|
||||
}
|
||||
@@ -604,12 +614,14 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
badgeClass(status: string): string {
|
||||
if (status === 'GENERATED') return 'fr-badge fr-badge--success';
|
||||
if (status === 'ERROR') return 'fr-badge fr-badge--error';
|
||||
if (status === 'NO_YAML') return 'fr-badge fr-badge--warning';
|
||||
return 'fr-badge fr-badge--info';
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
if (status === 'GENERATED') return 'Générée';
|
||||
if (status === 'ERROR') return 'Erreur';
|
||||
if (status === 'NO_YAML') return 'Sans fichier';
|
||||
return 'En attente';
|
||||
}
|
||||
|
||||
|
||||
@@ -381,20 +381,15 @@ export class BrowseComponent implements OnInit {
|
||||
loadSearch() {
|
||||
this.searchLoading.set(true);
|
||||
const raw = this.searchQuery();
|
||||
const q = raw.trim().toLowerCase();
|
||||
|
||||
forkJoin({
|
||||
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
|
||||
categories: this.categoryService.list(),
|
||||
categories: this.categoryService.list(raw || undefined),
|
||||
}).subscribe({
|
||||
next: ({ apis, categories }) => {
|
||||
this.searchResults.set(apis.items);
|
||||
this.searchTotal.set(apis.total);
|
||||
this.searchCategoryResults.set(
|
||||
categories.items.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || (c.description ?? '').toLowerCase().includes(q),
|
||||
),
|
||||
);
|
||||
this.searchCategoryResults.set(categories.items);
|
||||
this.searchLoading.set(false);
|
||||
},
|
||||
error: () => this.searchLoading.set(false),
|
||||
|
||||
@@ -208,6 +208,7 @@ export class CatalogComponent implements OnInit {
|
||||
const base = 'fr-badge';
|
||||
if (status === 'GENERATED') return `${base} fr-badge--success`;
|
||||
if (status === 'ERROR') return `${base} fr-badge--error`;
|
||||
if (status === 'NO_YAML') return `${base} fr-badge--warning`;
|
||||
return `${base} fr-badge--info`;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { CategoryService } from '../../core/category.service';
|
||||
import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
|
||||
import { ApiEntry, ApiEntryListItem, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload',
|
||||
@@ -32,17 +32,32 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
|
||||
<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>
|
||||
<!-- 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
|
||||
class="fr-stepper__steps"
|
||||
[attr.data-fr-current-step]="step()"
|
||||
data-fr-steps="3"
|
||||
></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 -->
|
||||
@@ -120,8 +135,18 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Étape 2 : Métadonnées -->
|
||||
<!-- Étape 2 : Informations -->
|
||||
@if (step() === 2) {
|
||||
<!-- Bannière nouvelle version -->
|
||||
@if (fromApi(); as origin) {
|
||||
<div class="fr-callout fr-callout--blue-cumulus fr-mb-3w">
|
||||
<p class="fr-callout__text">
|
||||
Nouvelle version de : <strong>{{ origin.title }}</strong> (v{{ origin.version }})
|
||||
— le nom et la convention sont verrouillés.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
|
||||
|
||||
<!-- Titre : convention + nom -->
|
||||
@@ -225,7 +250,7 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
|
||||
<!-- 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-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>
|
||||
@@ -270,6 +295,29 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
</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">
|
||||
@@ -347,16 +395,16 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
</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)">
|
||||
<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 3 : Confirmation -->
|
||||
@if (step() === 3) {
|
||||
<!-- Étape 4 : Confirmation -->
|
||||
@if (step() === 4) {
|
||||
<div class="fr-card fr-mb-4w">
|
||||
<div class="fr-card__body">
|
||||
<div class="fr-card__content">
|
||||
@@ -365,7 +413,9 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
|
||||
<dd>
|
||||
@if (selectedFiles().length === 1) {
|
||||
@if (selectedFiles().length === 0) {
|
||||
<span class="fr-badge fr-badge--warning fr-badge--sm">Aucun fichier</span>
|
||||
} @else if (selectedFiles().length === 1) {
|
||||
{{ selectedFiles()[0].name }}
|
||||
} @else {
|
||||
@for (f of selectedFiles(); track f.name) {
|
||||
@@ -381,7 +431,7 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Type</dt>
|
||||
<dd>{{ metaForm.value.type }}</dd>
|
||||
<dd>{{ displayType() }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12">
|
||||
<dt class="fr-text--bold">Titre</dt>
|
||||
@@ -407,6 +457,22 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
<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>
|
||||
@@ -427,7 +493,7 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
>
|
||||
{{ uploading() ? 'Import en cours...' : 'Importer' }}
|
||||
</button>
|
||||
<button class="fr-btn fr-btn--secondary" (click)="step.set(2)" [disabled]="uploading()">
|
||||
<button class="fr-btn fr-btn--secondary" (click)="step.set(3)" [disabled]="uploading()">
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
@@ -453,13 +519,28 @@ export class UploadComponent implements OnInit {
|
||||
validationDone = signal(false);
|
||||
validationErrors = signal<Array<{ file: string; message: string }>>([]);
|
||||
categories = signal<Category[]>([]);
|
||||
/* API d'origine lors d'une création de nouvelle version (from=<id>) */
|
||||
fromApi = signal<ApiEntry | null>(null);
|
||||
/* Versions existantes de l'API d'origine (pour détecter les doublons) */
|
||||
existingVersions = signal<ApiEntryListItem[]>([]);
|
||||
|
||||
readonly stepTitles = [
|
||||
'Choisir le fichier',
|
||||
'Renseigner les métadonnées',
|
||||
'Informations',
|
||||
'Contacts',
|
||||
"Confirmer l'import",
|
||||
];
|
||||
|
||||
private readonly INFO_FIELDS = [
|
||||
'convention', 'name', 'provider', 'type',
|
||||
'versionMajor', 'versionMinor', 'versionPatch',
|
||||
];
|
||||
|
||||
private readonly CONTACT_FIELDS = [
|
||||
'contactFunctionalName', 'contactFunctionalEntity', 'contactFunctionalEmail',
|
||||
'contactTechnicalName', 'contactTechnicalEntity', 'contactTechnicalEmail',
|
||||
];
|
||||
|
||||
metaForm = this.fb.group({
|
||||
convention: ['CONSULTER', Validators.required],
|
||||
name: ['', Validators.required],
|
||||
@@ -483,16 +564,35 @@ export class UploadComponent implements OnInit {
|
||||
|
||||
previewTitle = computed(() => {
|
||||
const values = this.formValues();
|
||||
const convention = (values.convention ?? 'CONSULTER') as ApiConvention;
|
||||
const name = values.name ?? '';
|
||||
const origin = this.fromApi();
|
||||
/* Quand convention/name sont désactivés, ils sont absents de valueChanges → fallback sur fromApi */
|
||||
const convention = (values.convention ?? origin?.convention ?? 'CONSULTER') as ApiConvention;
|
||||
const name = values.name ?? origin?.name ?? '';
|
||||
return `${API_CONVENTION_LABELS[convention]} ${name}`.trim();
|
||||
});
|
||||
|
||||
selectedCategoryName = computed(() => {
|
||||
const id = this.formValues().categoryId;
|
||||
if (!id) return '';
|
||||
return this.categories().find((c) => c.id === id)?.name ?? '';
|
||||
});
|
||||
|
||||
/* Vérifie si la version saisie existe déjà parmi les versions de l'API d'origine */
|
||||
versionAlreadyExists = computed(() => {
|
||||
const versions = this.existingVersions();
|
||||
if (versions.length === 0) return false;
|
||||
const values = this.formValues();
|
||||
const candidate = `${values.versionMajor ?? 0}.${values.versionMinor ?? 0}.${values.versionPatch ?? 0}`;
|
||||
return versions.some((v) => v.version === candidate);
|
||||
});
|
||||
|
||||
/* Type effectif : getRawValue() inclut les champs désactivés */
|
||||
displayType = computed(() => {
|
||||
const origin = this.fromApi();
|
||||
if (origin) return origin.type;
|
||||
return (this.formValues().type ?? null) as 'ASYNCAPI' | 'OPENAPI' | null;
|
||||
});
|
||||
|
||||
/* Méthodes de validation (pas de computed — la re-exécution est déclenchée par les événements DOM blur/input) */
|
||||
conventionInvalid(): boolean {
|
||||
return !!(this.metaForm.get('convention')?.invalid && this.metaForm.get('convention')?.touched);
|
||||
@@ -513,9 +613,7 @@ export class UploadComponent implements OnInit {
|
||||
return !!(this.metaForm.get('versionPatch')?.invalid && this.metaForm.get('versionPatch')?.touched);
|
||||
}
|
||||
canProceedFromStep1 = computed(() => {
|
||||
/* Sans fichier, on peut toujours passer à l'étape 2 */
|
||||
if (this.selectedFiles().length === 0) return true;
|
||||
/* Avec fichier : validation serveur doit être terminée et sans erreur */
|
||||
if (this.selectedFiles().length === 0) return true; // fichier optionnel
|
||||
if (this.validating()) return false;
|
||||
return this.validationDone() && this.validationErrors().length === 0;
|
||||
});
|
||||
@@ -544,10 +642,15 @@ export class UploadComponent implements OnInit {
|
||||
next: (api) => this.prefillFromApi(api),
|
||||
error: () => {},
|
||||
});
|
||||
this.apiService.getVersions(fromId).subscribe({
|
||||
next: (versions) => this.existingVersions.set(versions),
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private prefillFromApi(api: ApiEntry): void {
|
||||
this.fromApi.set(api);
|
||||
this.metaForm.patchValue({
|
||||
convention: api.convention,
|
||||
name: api.name,
|
||||
@@ -564,6 +667,10 @@ export class UploadComponent implements OnInit {
|
||||
contactTechnicalEntity: api.contactTechnicalEntity,
|
||||
contactTechnicalEmail: api.contactTechnicalEmail,
|
||||
});
|
||||
/* Verrouiller les champs qui définissent l'identité de l'API */
|
||||
this.metaForm.get('convention')?.disable();
|
||||
this.metaForm.get('name')?.disable();
|
||||
this.metaForm.get('type')?.disable();
|
||||
}
|
||||
|
||||
categoryLabel(cat: Category): string {
|
||||
@@ -574,11 +681,15 @@ export class UploadComponent implements OnInit {
|
||||
|
||||
onFileChange(event: Event) {
|
||||
this.fileError.set(null);
|
||||
this.uploadError.set(null);
|
||||
this.detectedType.set(null);
|
||||
this.detectedMainFilename.set(null);
|
||||
this.validationErrors.set([]);
|
||||
this.validationDone.set(false);
|
||||
/* Ne pas réactiver le type s'il est verrouillé par fromApi */
|
||||
if (!this.fromApi()) {
|
||||
this.metaForm.get('type')?.enable();
|
||||
}
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const fileList = input.files;
|
||||
@@ -609,6 +720,25 @@ export class UploadComponent implements OnInit {
|
||||
this.step.set(2);
|
||||
}
|
||||
|
||||
goToStep3() {
|
||||
const invalid = this.INFO_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
|
||||
if (invalid) {
|
||||
this.INFO_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched());
|
||||
return;
|
||||
}
|
||||
if (this.versionAlreadyExists()) return;
|
||||
this.step.set(3);
|
||||
}
|
||||
|
||||
goToStep4() {
|
||||
const invalid = this.CONTACT_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
|
||||
if (invalid) {
|
||||
this.CONTACT_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched());
|
||||
return;
|
||||
}
|
||||
this.step.set(4);
|
||||
}
|
||||
|
||||
private runValidation(files: File[]): void {
|
||||
this.validating.set(true);
|
||||
this.validationDone.set(false);
|
||||
@@ -624,19 +754,26 @@ export class UploadComponent implements OnInit {
|
||||
this.validationDone.set(true);
|
||||
if (!result.valid) {
|
||||
this.validationErrors.set(result.errors);
|
||||
/* Ne pas réactiver le type s'il est verrouillé par fromApi */
|
||||
if (!this.fromApi()) {
|
||||
this.metaForm.get('type')?.enable();
|
||||
}
|
||||
} else {
|
||||
this.validationErrors.set([]);
|
||||
if (result.type) {
|
||||
this.detectedType.set(result.type);
|
||||
/* Ne pas modifier le type s'il est verrouillé par fromApi */
|
||||
if (!this.fromApi()) {
|
||||
this.metaForm.patchValue({ type: result.type });
|
||||
this.metaForm.get('type')?.disable();
|
||||
}
|
||||
}
|
||||
if (result.mainFile) {
|
||||
this.detectedMainFilename.set(result.mainFile);
|
||||
}
|
||||
/* Pré-remplissage du formulaire depuis les métadonnées du fichier */
|
||||
if (result.name) this.metaForm.patchValue({ name: result.name });
|
||||
/* Ne pas écraser le nom s'il est verrouillé par fromApi */
|
||||
if (result.name && !this.fromApi()) this.metaForm.patchValue({ name: result.name });
|
||||
if (result.versionMajor !== null) this.metaForm.patchValue({ versionMajor: result.versionMajor });
|
||||
if (result.versionMinor !== null) this.metaForm.patchValue({ versionMinor: result.versionMinor });
|
||||
if (result.versionPatch !== null) this.metaForm.patchValue({ versionPatch: result.versionPatch });
|
||||
@@ -651,17 +788,15 @@ export class UploadComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
goToStep3() {
|
||||
if (this.metaForm.invalid) {
|
||||
this.metaForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.step.set(3);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
const files = this.selectedFiles();
|
||||
if (files.length === 0 || this.metaForm.invalid) return;
|
||||
this.metaForm.markAllAsTouched();
|
||||
if (this.metaForm.invalid) {
|
||||
/* Naviguer vers l'étape avec les erreurs pour les rendre visibles */
|
||||
const infoInvalid = this.INFO_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
|
||||
this.step.set(infoInvalid ? 2 : 3);
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading.set(true);
|
||||
this.uploadError.set(null);
|
||||
@@ -709,13 +844,13 @@ export class UploadComponent implements OnInit {
|
||||
if (/^openapi\s*:/m.test(content)) {
|
||||
this.detectedType.set('OPENAPI');
|
||||
this.detectedMainFilename.set(file.name);
|
||||
this.metaForm.patchValue({ type: 'OPENAPI' });
|
||||
if (!this.fromApi()) this.metaForm.patchValue({ type: 'OPENAPI' });
|
||||
return;
|
||||
}
|
||||
if (/^asyncapi\s*:/m.test(content)) {
|
||||
this.detectedType.set('ASYNCAPI');
|
||||
this.detectedMainFilename.set(file.name);
|
||||
this.metaForm.patchValue({ type: 'ASYNCAPI' });
|
||||
if (!this.fromApi()) this.metaForm.patchValue({ type: 'ASYNCAPI' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type ApiType = 'ASYNCAPI' | 'OPENAPI';
|
||||
|
||||
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR';
|
||||
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR' | 'NO_YAML';
|
||||
|
||||
export type ApiConvention = 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE';
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface ApiEntry {
|
||||
status: ApiStatus;
|
||||
errorMessage: string | null;
|
||||
categoryId: string | null;
|
||||
isCurrent: boolean;
|
||||
contactFunctionalName: string;
|
||||
contactFunctionalEntity: string;
|
||||
contactFunctionalEmail: string;
|
||||
@@ -48,6 +49,7 @@ export interface ApiEntryListItem {
|
||||
provider: string;
|
||||
status: ApiStatus;
|
||||
categoryId: string | null;
|
||||
isCurrent: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user