Compare commits
29 Commits
a0677df5ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a8bd0b7b4 | |||
| b6a52ce554 | |||
| 3b9ccecd69 | |||
| 60ed177335 | |||
| 403850f21d | |||
| 2e9191e6a2 | |||
| 14e5821ec0 | |||
| 3836db1e1f | |||
| f326efb7b0 | |||
| 68c714cc97 | |||
| ac35250a06 | |||
| 007a5d3fc8 | |||
| 0976c33059 | |||
| c8b3e393ea | |||
| eb07ca085f | |||
| dbf5af81ca | |||
| 498c6577e4 | |||
| e1a3dbc85b | |||
| 1adb6820dd | |||
| e939d592f8 | |||
| b9d45c0c3c | |||
| e85696079c | |||
| ef3884c68d | |||
| abc20bc9e8 | |||
| af02cf6808 | |||
| ecb45e8248 | |||
| e54c21042f | |||
| 9a8f28bbce | |||
| a82c52c491 |
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@datacat/shared": "workspace:*",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
|||||||
3
back/redocly.yaml
Normal file
3
back/redocly.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Validation structurelle uniquement — pas de règles de style
|
||||||
|
extends:
|
||||||
|
- spec
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
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;
|
||||||
@@ -18,6 +18,18 @@ export class ApiEntryEntity {
|
|||||||
@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;
|
||||||
|
|
||||||
@@ -33,23 +45,26 @@ 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({ nullable: true, type: 'text', name: 'functional_doc' })
|
@Column({ type: 'boolean', default: true, name: 'is_current' })
|
||||||
functionalDoc!: string | null;
|
isCurrent!: boolean;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'text', name: 'technical_doc' })
|
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
|
||||||
technicalDoc!: string | null;
|
contactFunctionalName!: string;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'varchar', length: 500, name: 'external_doc_url' })
|
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_entity' })
|
||||||
externalDocUrl!: string | null;
|
contactFunctionalEntity!: string;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_functional' })
|
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_email' })
|
||||||
contactFunctional!: string | null;
|
contactFunctionalEmail!: string;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_technical' })
|
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_name' })
|
||||||
contactTechnical!: string | null;
|
contactTechnicalName!: string;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'text', name: 'access_rights' })
|
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_entity' })
|
||||||
accessRights!: string | null;
|
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;
|
||||||
|
|||||||
20
back/src/modules/apis/api-entry.mapper.ts
Normal file
20
back/src/modules/apis/api-entry.mapper.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApiEntryEntity } from './api-entry.entity';
|
||||||
|
import { ApiEntryListItem, API_CONVENTION_LABELS } from '@datacat/shared';
|
||||||
|
|
||||||
|
export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
title: `${API_CONVENTION_LABELS[entity.convention]} ${entity.name}`,
|
||||||
|
name: entity.name,
|
||||||
|
convention: entity.convention,
|
||||||
|
version: `${entity.versionMajor}.${entity.versionMinor}.${entity.versionPatch}`,
|
||||||
|
description: entity.description,
|
||||||
|
type: entity.type,
|
||||||
|
provider: entity.provider,
|
||||||
|
status: entity.status,
|
||||||
|
categoryId: entity.categoryId,
|
||||||
|
isCurrent: entity.isCurrent,
|
||||||
|
createdAt: entity.createdAt.toISOString(),
|
||||||
|
updatedAt: entity.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -64,6 +69,11 @@ export class ApisController {
|
|||||||
res.send(entry.yamlContent);
|
res.send(entry.yamlContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/versions')
|
||||||
|
getVersions(@Param('id') id: string) {
|
||||||
|
return this.apisService.findVersionsOf(id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Sert les assets statiques (CSS, JS) générés par AsyncAPI CLI */
|
/** Sert les assets statiques (CSS, JS) générés par AsyncAPI CLI */
|
||||||
@Get(':id/*path')
|
@Get(':id/*path')
|
||||||
async getDocAsset(@Param('id') id: string, @Req() req: Request, @Res() res: Response) {
|
async getDocAsset(@Param('id') id: string, @Req() req: Request, @Res() res: Response) {
|
||||||
|
|||||||
347
back/src/modules/apis/apis.service.spec.ts
Normal file
347
back/src/modules/apis/apis.service.spec.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ApisService } from './apis.service';
|
||||||
|
import { ApiType } from '@datacat/shared';
|
||||||
|
|
||||||
|
/** Accès à la méthode privée pour les tests unitaires */
|
||||||
|
function parse(raw: string) {
|
||||||
|
return (ApisService.prototype as any).parseSearchTokens.call({}, raw) as {
|
||||||
|
text: string;
|
||||||
|
type?: ApiType;
|
||||||
|
version?: string;
|
||||||
|
latestOnly: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ApisService.parseSearchTokens()', () => {
|
||||||
|
describe('chaîne vide', () => {
|
||||||
|
it('retourne text vide, pas de type, pas de version, latestOnly=false', () => {
|
||||||
|
expect(parse('')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gère les espaces seuls', () => {
|
||||||
|
expect(parse(' ')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('texte libre seul (sans version ni type)', () => {
|
||||||
|
it('"auth" → latestOnly=true automatique', () => {
|
||||||
|
expect(parse('auth')).toEqual({ text: 'auth', type: undefined, version: undefined, latestOnly: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"paiement commande" → texte libre multi-mots, latestOnly=true', () => {
|
||||||
|
expect(parse('paiement commande')).toEqual({
|
||||||
|
text: 'paiement commande',
|
||||||
|
type: undefined,
|
||||||
|
version: undefined,
|
||||||
|
latestOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('détection du type', () => {
|
||||||
|
it('"async" → ASYNCAPI', () => {
|
||||||
|
const r = parse('async');
|
||||||
|
expect(r.type).toBe('ASYNCAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"asyncapi" → ASYNCAPI', () => {
|
||||||
|
expect(parse('asyncapi').type).toBe('ASYNCAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"openapi" → OPENAPI', () => {
|
||||||
|
expect(parse('openapi').type).toBe('OPENAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"open" → OPENAPI', () => {
|
||||||
|
expect(parse('open').type).toBe('OPENAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('type seul → latestOnly=true (champ non vide, pas de version)', () => {
|
||||||
|
expect(parse('async').latestOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mots de type ne font pas partie du texte libre', () => {
|
||||||
|
expect(parse('auth async').text).toBe('auth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('détection de version', () => {
|
||||||
|
it('"v1" → version="1", latestOnly=false', () => {
|
||||||
|
expect(parse('v1')).toEqual({ text: '', type: undefined, version: '1', latestOnly: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"1.2" → version="1.2"', () => {
|
||||||
|
expect(parse('1.2').version).toBe('1.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"v1.2.3" → version="1.2.3" (préfixe v supprimé)', () => {
|
||||||
|
expect(parse('v1.2.3').version).toBe('1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"1.0.0" → version exacte', () => {
|
||||||
|
expect(parse('1.0.0').version).toBe('1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('version présente → latestOnly=false', () => {
|
||||||
|
expect(parse('auth v1').latestOnly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numéro de version ne fait pas partie du texte libre', () => {
|
||||||
|
expect(parse('auth v1').text).toBe('auth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mot-clé "latest"', () => {
|
||||||
|
it('"latest" seul → latestOnly=true, pas de texte', () => {
|
||||||
|
expect(parse('latest')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"latest" + type → latestOnly=true + type détecté', () => {
|
||||||
|
const r = parse('latest async');
|
||||||
|
expect(r.latestOnly).toBe(true);
|
||||||
|
expect(r.type).toBe('ASYNCAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"latest" + version → les deux flags actifs', () => {
|
||||||
|
const r = parse('latest v1');
|
||||||
|
expect(r.latestOnly).toBe(true);
|
||||||
|
expect(r.version).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combinaisons', () => {
|
||||||
|
it('"auth async" → texte=auth, type=ASYNCAPI, latestOnly=true', () => {
|
||||||
|
expect(parse('auth async')).toEqual({
|
||||||
|
text: 'auth',
|
||||||
|
type: 'ASYNCAPI',
|
||||||
|
version: undefined,
|
||||||
|
latestOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"auth async v1" → texte=auth, type=ASYNCAPI, version=1, latestOnly=false', () => {
|
||||||
|
expect(parse('auth async v1')).toEqual({
|
||||||
|
text: 'auth',
|
||||||
|
type: 'ASYNCAPI',
|
||||||
|
version: '1',
|
||||||
|
latestOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"auth 1.0.0" → texte=auth, version=1.0.0, latestOnly=false', () => {
|
||||||
|
expect(parse('auth 1.0.0')).toEqual({
|
||||||
|
text: 'auth',
|
||||||
|
type: undefined,
|
||||||
|
version: '1.0.0',
|
||||||
|
latestOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"openapi latest" → type=OPENAPI, latestOnly=true', () => {
|
||||||
|
expect(parse('openapi latest')).toEqual({
|
||||||
|
text: '',
|
||||||
|
type: 'OPENAPI',
|
||||||
|
version: undefined,
|
||||||
|
latestOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('insensible à la casse pour les mots-clés', () => {
|
||||||
|
const r = parse('ASYNC Auth LATEST');
|
||||||
|
expect(r.type).toBe('ASYNCAPI');
|
||||||
|
expect(r.latestOnly).toBe(true);
|
||||||
|
expect(r.text).toBe('Auth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Mocks partagés pour les tests unitaires de ApisService ───────────────────
|
||||||
|
|
||||||
|
const mockRepo = {
|
||||||
|
findOne: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
find: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockDocsService = { generate: vi.fn() };
|
||||||
|
|
||||||
|
let service: ApisService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new ApisService(mockRepo as any, mockDocsService as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeEntity(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'uuid-1', name: 'Petstore', convention: 'CONSULTER',
|
||||||
|
versionMajor: 1, versionMinor: 0, versionPatch: 0,
|
||||||
|
type: 'OPENAPI', provider: 'DINUM', description: null,
|
||||||
|
yamlContent: 'openapi: "3.0.0"', htmlPath: null,
|
||||||
|
status: 'PENDING', errorMessage: null, categoryId: null,
|
||||||
|
isCurrent: true,
|
||||||
|
contactFunctionalName: '', contactFunctionalEntity: '', contactFunctionalEmail: '',
|
||||||
|
contactTechnicalName: '', contactTechnicalEntity: '', contactTechnicalEmail: '',
|
||||||
|
createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDto = {
|
||||||
|
name: 'Petstore', convention: 'CONSULTER' as const,
|
||||||
|
versionMajor: 1, versionMinor: 0, versionPatch: 0,
|
||||||
|
type: 'OPENAPI' as const, provider: 'DINUM',
|
||||||
|
yamlContent: 'openapi: "3.0.0"',
|
||||||
|
contactFunctionalName: 'Alice', contactFunctionalEntity: 'DINUM', contactFunctionalEmail: 'alice@dinum.fr',
|
||||||
|
contactTechnicalName: 'Bob', contactTechnicalEntity: 'DINUM', contactTechnicalEmail: 'bob@dinum.fr',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── ApisService.create() ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ApisService.create()', () => {
|
||||||
|
it('crée l\'entrée et la retourne', async () => {
|
||||||
|
const entity = makeEntity();
|
||||||
|
mockRepo.findOne.mockResolvedValue(null);
|
||||||
|
mockRepo.count.mockResolvedValue(0);
|
||||||
|
mockRepo.create.mockReturnValue(entity);
|
||||||
|
mockRepo.save.mockResolvedValue(entity);
|
||||||
|
|
||||||
|
const result = await service.create(baseDto as any);
|
||||||
|
|
||||||
|
expect(result.id).toBe('uuid-1');
|
||||||
|
expect(result.title).toBe('Consulter Petstore');
|
||||||
|
expect(result.version).toBe('1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lève ConflictException si doublon', async () => {
|
||||||
|
mockRepo.findOne.mockResolvedValue(makeEntity());
|
||||||
|
|
||||||
|
await expect(service.create(baseDto as any)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isCurrent=true si première version', async () => {
|
||||||
|
const entity = makeEntity({ isCurrent: true });
|
||||||
|
mockRepo.findOne.mockResolvedValue(null);
|
||||||
|
mockRepo.count.mockResolvedValue(0);
|
||||||
|
mockRepo.create.mockReturnValue(entity);
|
||||||
|
mockRepo.save.mockResolvedValue(entity);
|
||||||
|
|
||||||
|
await service.create(baseDto as any);
|
||||||
|
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ isCurrent: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isCurrent=false si version ultérieure', async () => {
|
||||||
|
const entity = makeEntity({ isCurrent: false });
|
||||||
|
mockRepo.findOne.mockResolvedValue(null);
|
||||||
|
mockRepo.count.mockResolvedValue(1);
|
||||||
|
mockRepo.create.mockReturnValue(entity);
|
||||||
|
mockRepo.save.mockResolvedValue(entity);
|
||||||
|
|
||||||
|
await service.create(baseDto as any);
|
||||||
|
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ isCurrent: false }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ApisService.findOneOrThrow() ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ApisService.findOneOrThrow()', () => {
|
||||||
|
it('retourne l\'entrée mappée si trouvée', async () => {
|
||||||
|
mockRepo.findOne.mockResolvedValue(makeEntity());
|
||||||
|
|
||||||
|
const result = await service.findOneOrThrow('uuid-1');
|
||||||
|
|
||||||
|
expect(result.id).toBe('uuid-1');
|
||||||
|
expect(result.title).toBe('Consulter Petstore');
|
||||||
|
expect(result.version).toBe('1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lève NotFoundException si absente', async () => {
|
||||||
|
mockRepo.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.findOneOrThrow('uuid-unknown')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ApisService.remove() ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ApisService.remove()', () => {
|
||||||
|
it('supprime l\'entrée si elle existe', async () => {
|
||||||
|
mockRepo.findOne.mockResolvedValue(makeEntity());
|
||||||
|
mockRepo.delete.mockResolvedValue({ affected: 1 });
|
||||||
|
|
||||||
|
await service.remove('uuid-1');
|
||||||
|
|
||||||
|
expect(mockRepo.delete).toHaveBeenCalledWith('uuid-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lève NotFoundException si absente', async () => {
|
||||||
|
mockRepo.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.remove('uuid-unknown')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ApisService.findVersionsOf() ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ApisService.findVersionsOf()', () => {
|
||||||
|
it('retourne toutes les versions (même name+convention)', async () => {
|
||||||
|
const entity1 = makeEntity({ id: 'uuid-1', versionMajor: 2 });
|
||||||
|
const entity2 = makeEntity({ id: 'uuid-2', versionMajor: 1 });
|
||||||
|
mockRepo.findOne.mockResolvedValue(entity1);
|
||||||
|
mockRepo.find.mockResolvedValue([entity1, entity2]);
|
||||||
|
|
||||||
|
const result = await service.findVersionsOf('uuid-1');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe('uuid-1');
|
||||||
|
expect(result[1].id).toBe('uuid-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lève NotFoundException si id inconnu', async () => {
|
||||||
|
mockRepo.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.findVersionsOf('uuid-unknown')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ApisService.setCurrent() ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ApisService.setCurrent()', () => {
|
||||||
|
it('met isCurrent=false sur les siblings puis true sur la cible', async () => {
|
||||||
|
mockRepo.findOne.mockResolvedValue(makeEntity());
|
||||||
|
mockRepo.update.mockResolvedValue({ affected: 1 });
|
||||||
|
|
||||||
|
await service.setCurrent('uuid-1');
|
||||||
|
|
||||||
|
expect(mockRepo.update).toHaveBeenCalledWith(
|
||||||
|
{ name: 'Petstore', convention: 'CONSULTER' },
|
||||||
|
{ isCurrent: false },
|
||||||
|
);
|
||||||
|
expect(mockRepo.update).toHaveBeenCalledWith(
|
||||||
|
{ id: 'uuid-1' },
|
||||||
|
{ isCurrent: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ApisService.regenerate() ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ApisService.regenerate()', () => {
|
||||||
|
it('remet le statut à PENDING et appelle docsService.generate()', async () => {
|
||||||
|
mockRepo.findOne.mockResolvedValue(makeEntity());
|
||||||
|
mockRepo.update.mockResolvedValue({ affected: 1 });
|
||||||
|
mockDocsService.generate.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.regenerate('uuid-1');
|
||||||
|
|
||||||
|
expect(mockRepo.update).toHaveBeenCalledWith('uuid-1', { status: 'PENDING', errorMessage: null });
|
||||||
|
expect(mockDocsService.generate).toHaveBeenCalledWith('uuid-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { 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';
|
||||||
import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus } from '@datacat/shared';
|
import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus, ApiConvention, API_CONVENTION_LABELS } from '@datacat/shared';
|
||||||
|
import { toApiEntryListItem } from './api-entry.mapper';
|
||||||
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||||
|
|
||||||
/** Champs internes mis à jour par DocsGenerationService */
|
/** Champs internes mis à jour par DocsGenerationService */
|
||||||
interface InternalUpdateFields {
|
interface InternalUpdateFields {
|
||||||
title?: string;
|
|
||||||
version?: string;
|
|
||||||
description?: string;
|
|
||||||
htmlPath?: string | null;
|
htmlPath?: string | null;
|
||||||
status?: ApiStatus;
|
status?: ApiStatus;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
@@ -27,55 +25,154 @@ export class ApisService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
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({
|
||||||
title: dto.title,
|
convention: dto.convention,
|
||||||
version: dto.version,
|
name: dto.name,
|
||||||
|
provider: dto.provider,
|
||||||
|
versionMajor: dto.versionMajor,
|
||||||
|
versionMinor: dto.versionMinor,
|
||||||
|
versionPatch: dto.versionPatch,
|
||||||
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,
|
||||||
functionalDoc: dto.functionalDoc ?? null,
|
isCurrent,
|
||||||
technicalDoc: dto.technicalDoc ?? null,
|
contactFunctionalName: dto.contactFunctionalName,
|
||||||
externalDocUrl: dto.externalDocUrl ?? null,
|
contactFunctionalEntity: dto.contactFunctionalEntity,
|
||||||
contactFunctional: dto.contactFunctional ?? null,
|
contactFunctionalEmail: dto.contactFunctionalEmail,
|
||||||
contactTechnical: dto.contactTechnical ?? null,
|
contactTechnicalName: dto.contactTechnicalName,
|
||||||
accessRights: dto.accessRights ?? null,
|
contactTechnicalEntity: dto.contactTechnicalEntity,
|
||||||
|
contactTechnicalEmail: dto.contactTechnicalEmail,
|
||||||
});
|
});
|
||||||
const saved = await this.repo.save(entity);
|
const saved = await this.repo.save(entity);
|
||||||
return toApiEntry(saved);
|
return toApiEntry(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(query: ApiListQuery): Promise<ApiListResponse> {
|
async findAll(query: ApiListQuery): Promise<ApiListResponse> {
|
||||||
const page = Math.max(1, Number(query.page ?? 1));
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20)));
|
const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20)));
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const qb = this.repo.createQueryBuilder('api');
|
const qb = this.repo.createQueryBuilder('api');
|
||||||
|
|
||||||
if (query.type) {
|
let effectiveType = query.type as ApiType | undefined;
|
||||||
qb.andWhere('api.type = :type', { type: query.type as ApiType });
|
let version: string | undefined;
|
||||||
|
let latestOnly = false;
|
||||||
|
|
||||||
|
if (query.search?.trim()) {
|
||||||
|
const parsed = this.parseSearchTokens(query.search);
|
||||||
|
// Le type détecté dans la saisie override le select UI
|
||||||
|
if (parsed.type) effectiveType = parsed.type;
|
||||||
|
version = parsed.version;
|
||||||
|
latestOnly = parsed.latestOnly;
|
||||||
|
|
||||||
|
if (parsed.text) {
|
||||||
|
// Recherche fuzzy via pg_trgm + ILIKE pour les matches partiels courts
|
||||||
|
qb.andWhere(
|
||||||
|
`(
|
||||||
|
api.name ILIKE :likeText
|
||||||
|
OR api.provider ILIKE :likeText
|
||||||
|
OR COALESCE(api.description, '') ILIKE :likeText
|
||||||
|
OR api.contact_functional_name ILIKE :likeText
|
||||||
|
OR api.contact_technical_name ILIKE :likeText
|
||||||
|
OR word_similarity(:text, api.name) > 0.4
|
||||||
|
OR word_similarity(:text, api.provider) > 0.4
|
||||||
|
OR word_similarity(:text, COALESCE(api.description, '')) > 0.5
|
||||||
|
)`,
|
||||||
|
{ likeText: `%${parsed.text}%`, text: parsed.text },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (query.search) {
|
|
||||||
qb.andWhere(
|
if (effectiveType) {
|
||||||
'(LOWER(api.title) LIKE :search OR LOWER(api.description) LIKE :search)',
|
qb.andWhere('api.type = :type', { type: effectiveType });
|
||||||
{ search: `%${query.search.toLowerCase()}%` },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (query.categoryId) {
|
if (query.categoryId) {
|
||||||
qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId });
|
qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId });
|
||||||
}
|
}
|
||||||
|
|
||||||
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
|
// Filtre par version (partielle ou complète)
|
||||||
|
if (version) {
|
||||||
|
const parts = version.split('.').map(Number);
|
||||||
|
if (parts.length >= 1 && !isNaN(parts[0]))
|
||||||
|
qb.andWhere('api.version_major = :major', { major: parts[0] });
|
||||||
|
if (parts.length >= 2 && !isNaN(parts[1]))
|
||||||
|
qb.andWhere('api.version_minor = :minor', { minor: parts[1] });
|
||||||
|
if (parts.length >= 3 && !isNaN(parts[2]))
|
||||||
|
qb.andWhere('api.version_patch = :patch', { patch: parts[2] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest-only : exclure les entrées dont il existe une version plus récente du même API
|
||||||
|
if (latestOnly) {
|
||||||
|
qb.andWhere(`NOT EXISTS (
|
||||||
|
SELECT 1 FROM api_entries e2
|
||||||
|
WHERE e2.name = api.name AND e2.convention = api.convention
|
||||||
|
AND (
|
||||||
|
e2.version_major > api.version_major
|
||||||
|
OR (e2.version_major = api.version_major AND e2.version_minor > api.version_minor)
|
||||||
|
OR (e2.version_major = api.version_major AND e2.version_minor = api.version_minor
|
||||||
|
AND e2.version_patch > api.version_patch)
|
||||||
|
)
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
|
||||||
const [entities, total] = await qb.getManyAndCount();
|
const [entities, total] = await qb.getManyAndCount();
|
||||||
|
|
||||||
return {
|
return { items: entities.map(toApiEntryListItem), total, page, limit };
|
||||||
items: entities.map(toApiEntryListItem),
|
}
|
||||||
total,
|
|
||||||
page,
|
private parseSearchTokens(raw: string): {
|
||||||
limit,
|
text: string;
|
||||||
|
type?: ApiType;
|
||||||
|
version?: string;
|
||||||
|
latestOnly: boolean;
|
||||||
|
} {
|
||||||
|
const TYPE_MAP: Record<string, ApiType> = {
|
||||||
|
async: 'ASYNCAPI', asyncapi: 'ASYNCAPI',
|
||||||
|
openapi: 'OPENAPI', open: 'OPENAPI',
|
||||||
};
|
};
|
||||||
|
const VERSION_RE = /^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/i;
|
||||||
|
let type: ApiType | undefined;
|
||||||
|
let version: string | undefined;
|
||||||
|
let latestOnly = false;
|
||||||
|
const remaining: string[] = [];
|
||||||
|
|
||||||
|
for (const word of raw.trim().split(/\s+/).filter(Boolean)) {
|
||||||
|
const lower = word.toLowerCase();
|
||||||
|
if (lower === 'latest') { latestOnly = true; }
|
||||||
|
else if (TYPE_MAP[lower]) { type = TYPE_MAP[lower]; }
|
||||||
|
else if (VERSION_RE.test(word)) { version = word.replace(/^v/i, ''); }
|
||||||
|
else { remaining.push(word); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = remaining.join(' ');
|
||||||
|
// Pas de version explicite ET champ non vide → latest par défaut
|
||||||
|
if (!latestOnly && raw.trim().length > 0 && !version) latestOnly = true;
|
||||||
|
return { text, type, version, latestOnly };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOneOrThrow(id: string): Promise<ApiEntry> {
|
async findOneOrThrow(id: string): Promise<ApiEntry> {
|
||||||
@@ -101,6 +198,31 @@ export class ApisService {
|
|||||||
await this.repo.delete(id);
|
await this.repo.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findVersionsOf(id: string): Promise<ApiEntryListItem[]> {
|
||||||
|
const entry = await this.findOneOrThrow(id);
|
||||||
|
const entities = await this.repo.find({
|
||||||
|
where: { name: entry.name, convention: entry.convention },
|
||||||
|
order: {
|
||||||
|
versionMajor: 'DESC',
|
||||||
|
versionMinor: 'DESC',
|
||||||
|
versionPatch: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return entities.map(toApiEntryListItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCurrent(id: string): Promise<ApiEntry> {
|
||||||
|
const entry = await this.findOneOrThrow(id);
|
||||||
|
// Mettre tous les siblings à false
|
||||||
|
await this.repo.update(
|
||||||
|
{ name: entry.name, convention: entry.convention },
|
||||||
|
{ isCurrent: false },
|
||||||
|
);
|
||||||
|
// Mettre cet entry à true
|
||||||
|
await this.repo.update({ id }, { isCurrent: true });
|
||||||
|
return this.findOneOrThrow(id);
|
||||||
|
}
|
||||||
|
|
||||||
async regenerate(id: string): Promise<ApiEntry> {
|
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 });
|
||||||
@@ -109,39 +231,41 @@ export class ApisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTitle(convention: ApiConvention, name: string): string {
|
||||||
|
return `${API_CONVENTION_LABELS[convention]} ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVersion(major: number, minor: number, patch: number): string {
|
||||||
|
return `${major}.${minor}.${patch}`;
|
||||||
|
}
|
||||||
|
|
||||||
function toApiEntry(entity: ApiEntryEntity): ApiEntry {
|
function toApiEntry(entity: ApiEntryEntity): ApiEntry {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
title: entity.title,
|
title: buildTitle(entity.convention, entity.name),
|
||||||
version: entity.version,
|
name: entity.name,
|
||||||
|
convention: entity.convention,
|
||||||
|
version: buildVersion(entity.versionMajor, entity.versionMinor, entity.versionPatch),
|
||||||
|
versionMajor: entity.versionMajor,
|
||||||
|
versionMinor: entity.versionMinor,
|
||||||
|
versionPatch: entity.versionPatch,
|
||||||
description: entity.description,
|
description: entity.description,
|
||||||
type: entity.type,
|
type: entity.type,
|
||||||
|
provider: entity.provider,
|
||||||
yamlContent: entity.yamlContent,
|
yamlContent: entity.yamlContent,
|
||||||
htmlPath: entity.htmlPath,
|
htmlPath: entity.htmlPath,
|
||||||
status: entity.status,
|
status: entity.status,
|
||||||
errorMessage: entity.errorMessage,
|
errorMessage: entity.errorMessage,
|
||||||
categoryId: entity.categoryId,
|
categoryId: entity.categoryId,
|
||||||
functionalDoc: entity.functionalDoc,
|
isCurrent: entity.isCurrent,
|
||||||
technicalDoc: entity.technicalDoc,
|
contactFunctionalName: entity.contactFunctionalName,
|
||||||
externalDocUrl: entity.externalDocUrl,
|
contactFunctionalEntity: entity.contactFunctionalEntity,
|
||||||
contactFunctional: entity.contactFunctional,
|
contactFunctionalEmail: entity.contactFunctionalEmail,
|
||||||
contactTechnical: entity.contactTechnical,
|
contactTechnicalName: entity.contactTechnicalName,
|
||||||
accessRights: entity.accessRights,
|
contactTechnicalEntity: entity.contactTechnicalEntity,
|
||||||
|
contactTechnicalEmail: entity.contactTechnicalEmail,
|
||||||
createdAt: entity.createdAt.toISOString(),
|
createdAt: entity.createdAt.toISOString(),
|
||||||
updatedAt: entity.updatedAt.toISOString(),
|
updatedAt: entity.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
|
||||||
return {
|
|
||||||
id: entity.id,
|
|
||||||
title: entity.title,
|
|
||||||
version: entity.version,
|
|
||||||
description: entity.description,
|
|
||||||
type: entity.type,
|
|
||||||
status: entity.status,
|
|
||||||
categoryId: entity.categoryId,
|
|
||||||
createdAt: entity.createdAt.toISOString(),
|
|
||||||
updatedAt: entity.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +1,33 @@
|
|||||||
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID } from 'class-validator';
|
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator';
|
||||||
import { ApiType } from '@datacat/shared';
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiType, ApiConvention } from '@datacat/shared';
|
||||||
|
|
||||||
export class CreateApiEntryDto {
|
export class CreateApiEntryDto {
|
||||||
@IsString()
|
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
|
||||||
@IsNotEmpty()
|
convention!: ApiConvention;
|
||||||
title!: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
version!: string;
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
provider!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Type(() => Number)
|
||||||
|
versionMajor!: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Type(() => Number)
|
||||||
|
versionMinor!: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Type(() => Number)
|
||||||
|
versionPatch!: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -26,26 +45,24 @@ export class CreateApiEntryDto {
|
|||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsNotEmpty()
|
||||||
functionalDoc?: string;
|
contactFunctionalName!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsNotEmpty()
|
||||||
technicalDoc?: string;
|
contactFunctionalEntity!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
contactFunctionalEmail!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsNotEmpty()
|
||||||
externalDocUrl?: string;
|
contactTechnicalName!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsNotEmpty()
|
||||||
contactFunctional?: string;
|
contactTechnicalEntity!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEmail()
|
||||||
@IsOptional()
|
contactTechnicalEmail!: string;
|
||||||
contactTechnical?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
accessRights?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,41 @@
|
|||||||
import { IsString, IsOptional, IsUUID } from 'class-validator';
|
import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiConvention } from '@datacat/shared';
|
||||||
|
|
||||||
export class UpdateApiEntryDto {
|
export class UpdateApiEntryDto {
|
||||||
@IsString()
|
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
title?: string;
|
convention?: ApiConvention;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
version?: string;
|
name?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
description?: string;
|
provider?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsOptional()
|
||||||
|
versionMajor?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsOptional()
|
||||||
|
versionMinor?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsOptional()
|
||||||
|
versionPatch?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -19,25 +43,25 @@ export class UpdateApiEntryDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
functionalDoc?: string | null;
|
contactFunctionalName?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
technicalDoc?: string | null;
|
contactFunctionalEntity?: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
@IsOptional()
|
||||||
|
contactFunctionalEmail?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
externalDocUrl?: string | null;
|
contactTechnicalName?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
contactFunctional?: string | null;
|
contactTechnicalEntity?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEmail()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
contactTechnical?: string | null;
|
contactTechnicalEmail?: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
accessRights?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ import {
|
|||||||
CategoryWithCounts,
|
CategoryWithCounts,
|
||||||
CategoryBrowseResponse,
|
CategoryBrowseResponse,
|
||||||
CategoryListResponse,
|
CategoryListResponse,
|
||||||
ApiEntryListItem,
|
|
||||||
ApiType,
|
|
||||||
ApiStatus,
|
|
||||||
} from '@datacat/shared';
|
} from '@datacat/shared';
|
||||||
|
import { toApiEntryListItem } from '../apis/api-entry.mapper';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoriesService {
|
export class CategoriesService {
|
||||||
@@ -24,7 +22,26 @@ export class CategoriesService {
|
|||||||
private readonly apiRepo: Repository<ApiEntryEntity>,
|
private readonly apiRepo: Repository<ApiEntryEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<CategoryListResponse> {
|
async findAll(search?: string): Promise<CategoryListResponse> {
|
||||||
|
if (search?.trim()) {
|
||||||
|
const text = search.trim();
|
||||||
|
const entities = await this.categoryRepo
|
||||||
|
.createQueryBuilder('cat')
|
||||||
|
.where(
|
||||||
|
`(
|
||||||
|
cat.name ILIKE :likeText
|
||||||
|
OR COALESCE(cat.description, '') ILIKE :likeText
|
||||||
|
OR word_similarity(:text, cat.name) >= 0.4
|
||||||
|
OR word_similarity(:text, COALESCE(cat.description, '')) > 0.5
|
||||||
|
)`,
|
||||||
|
{ likeText: `%${text}%`, text },
|
||||||
|
)
|
||||||
|
.orderBy('cat.order_index', 'ASC')
|
||||||
|
.addOrderBy('cat.name', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
return { items: entities.map(toCategory) };
|
||||||
|
}
|
||||||
|
|
||||||
const entities = await this.categoryRepo.find({
|
const entities = await this.categoryRepo.find({
|
||||||
order: { orderIndex: 'ASC', name: 'ASC' },
|
order: { orderIndex: 'ASC', name: 'ASC' },
|
||||||
});
|
});
|
||||||
@@ -81,6 +98,13 @@ 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 } });
|
||||||
|
const children = await this.categoryRepo.find({ where: { parentId: categoryId }, select: ['id'] });
|
||||||
|
const childCounts = await Promise.all(children.map((c) => this.countApisRecursively(c.id)));
|
||||||
|
return direct + childCounts.reduce((sum, n) => sum + n, 0);
|
||||||
|
}
|
||||||
|
|
||||||
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
|
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
|
||||||
/* 1. Charge la catégorie courante */
|
/* 1. Charge la catégorie courante */
|
||||||
let currentCategory: CategoryEntity | null = null;
|
let currentCategory: CategoryEntity | null = null;
|
||||||
@@ -111,7 +135,7 @@ export class CategoriesService {
|
|||||||
const subcategories: CategoryWithCounts[] = await Promise.all(
|
const subcategories: CategoryWithCounts[] = await Promise.all(
|
||||||
directChildren.map(async (child) => {
|
directChildren.map(async (child) => {
|
||||||
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
|
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
|
||||||
const apiCount = await this.apiRepo.count({ where: { categoryId: child.id } });
|
const apiCount = await this.countApisRecursively(child.id);
|
||||||
return { ...toCategory(child), subcategoryCount, apiCount };
|
return { ...toCategory(child), subcategoryCount, apiCount };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -143,16 +167,3 @@ function toCategory(entity: CategoryEntity): Category {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
|
||||||
return {
|
|
||||||
id: entity.id,
|
|
||||||
title: entity.title,
|
|
||||||
version: entity.version,
|
|
||||||
description: entity.description,
|
|
||||||
type: entity.type as ApiType,
|
|
||||||
status: entity.status as ApiStatus,
|
|
||||||
categoryId: entity.categoryId,
|
|
||||||
createdAt: entity.createdAt.toISOString(),
|
|
||||||
updatedAt: entity.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export class DocsGenerationService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.apisService.updateInternal(entryId, {
|
await this.apisService.updateInternal(entryId, {
|
||||||
status: 'ERROR',
|
status: 'ERROR',
|
||||||
errorMessage: String(error),
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
||||||
import { CategoryEntity } from '../categories/category.entity';
|
import { CategoryEntity } from '../categories/category.entity';
|
||||||
@@ -593,18 +593,53 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
@InjectRepository(CategoryEntity)
|
@InjectRepository(CategoryEntity)
|
||||||
private readonly categoryRepo: Repository<CategoryEntity>,
|
private readonly categoryRepo: Repository<CategoryEntity>,
|
||||||
private readonly docsService: DocsGenerationService,
|
private readonly docsService: DocsGenerationService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onApplicationBootstrap(): Promise<void> {
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
// Activer l'extension pg_trgm pour la recherche fuzzy
|
||||||
|
await this.dataSource.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
|
||||||
|
|
||||||
const forceReseed = process.env.FORCE_RESEED === 'true';
|
const forceReseed = process.env.FORCE_RESEED === 'true';
|
||||||
if (forceReseed) {
|
if (forceReseed) {
|
||||||
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({});
|
||||||
@@ -688,85 +723,148 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
/* APIs de démo */
|
/* APIs de démo */
|
||||||
const entries = await this.apiRepo.save([
|
const entries = await this.apiRepo.save([
|
||||||
this.apiRepo.create({
|
this.apiRepo.create({
|
||||||
title: 'Swagger Petstore',
|
convention: 'CONSULTER',
|
||||||
version: '3.0.4',
|
name: 'Référentiel Produits',
|
||||||
|
versionMajor: 3,
|
||||||
|
versionMinor: 0,
|
||||||
|
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',
|
||||||
yamlContent: PETSTORE_YAML,
|
yamlContent: PETSTORE_YAML,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
categoryId: produits.id,
|
categoryId: produits.id,
|
||||||
functionalDoc: 'Cas d\'usage : gestion du catalogue produits. Permet de lister, créer et consulter des animaux de compagnie. Usage typique : intégration e-commerce, démo API REST.',
|
provider: 'DINUM',
|
||||||
technicalDoc: 'Authentification Bearer token requise. Pagination via paramètre `limit` (max 100). Réponses JSON selon schéma OpenAPI 3.0. Endpoint base : https://petstore.swagger.io/v3.',
|
contactFunctionalName: 'Alice Martin',
|
||||||
externalDocUrl: null,
|
contactFunctionalEntity: 'Équipe Produit',
|
||||||
contactFunctional: 'Équipe Produit',
|
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
|
||||||
contactTechnical: 'Équipe API',
|
contactTechnicalName: 'Bob Dupont',
|
||||||
accessRights: 'API publique — aucune habilitation requise.',
|
contactTechnicalEntity: 'Équipe API',
|
||||||
|
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
|
||||||
}),
|
}),
|
||||||
this.apiRepo.create({
|
this.apiRepo.create({
|
||||||
title: 'Streetlights Kafka API',
|
convention: 'ETRE_NOTIFIE',
|
||||||
version: '1.0.0',
|
name: 'Éclairage Urbain Kafka',
|
||||||
|
versionMajor: 1,
|
||||||
|
versionMinor: 0,
|
||||||
|
versionPatch: 0,
|
||||||
description:
|
description:
|
||||||
'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.',
|
'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.',
|
||||||
type: 'ASYNCAPI',
|
type: 'ASYNCAPI',
|
||||||
yamlContent: STREETLIGHTS_YAML,
|
yamlContent: STREETLIGHTS_YAML,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
categoryId: evenements.id,
|
categoryId: evenements.id,
|
||||||
functionalDoc: 'Gestion de l\'éclairage urbain IoT. Permet de recevoir les mesures de luminosité des lampadaires et d\'envoyer des commandes d\'allumage/extinction à distance.',
|
provider: 'Bureau Infrastructure',
|
||||||
technicalDoc: 'Broker Kafka sécurisé avec SCRAM-SHA-512. Topics : smartylighting.streetlights.1.0.*. Schémas de messages définis en AsyncAPI 3.0. Adresse broker : test.mykafkacluster.org:18092.',
|
contactFunctionalName: 'Claire Leroy',
|
||||||
externalDocUrl: null,
|
contactFunctionalEntity: 'Bureau Infrastructure',
|
||||||
contactFunctional: 'Bureau Infrastructure',
|
contactFunctionalEmail: 'claire.leroy@example.gouv.fr',
|
||||||
contactTechnical: 'ops@example.com',
|
contactTechnicalName: 'David Chen',
|
||||||
accessRights: 'Accès sur demande — habilitation DSI requise.',
|
contactTechnicalEntity: 'Ops',
|
||||||
|
contactTechnicalEmail: 'ops@example.com',
|
||||||
}),
|
}),
|
||||||
this.apiRepo.create({
|
this.apiRepo.create({
|
||||||
title: 'Account Service',
|
convention: 'ETRE_NOTIFIE',
|
||||||
version: '1.0.0',
|
name: 'Comptes Utilisateurs',
|
||||||
|
versionMajor: 1,
|
||||||
|
versionMinor: 0,
|
||||||
|
versionPatch: 0,
|
||||||
description: "Service de gestion des comptes utilisateurs avec publication d'événements.",
|
description: "Service de gestion des comptes utilisateurs avec publication d'événements.",
|
||||||
type: 'ASYNCAPI',
|
type: 'ASYNCAPI',
|
||||||
yamlContent: ACCOUNT_SERVICE_YAML,
|
yamlContent: ACCOUNT_SERVICE_YAML,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
categoryId: notifications.id,
|
categoryId: notifications.id,
|
||||||
functionalDoc: 'Cycle de vie du compte utilisateur. Publie des événements lors de l\'inscription et du changement de mot de passe. Utilisé par les services de notification et d\'audit.',
|
provider: 'Équipe Comptes',
|
||||||
technicalDoc: 'Consommer via broker AMQP ou Kafka. Canaux : user/signedup, user/passwordchanged. Payloads JSON avec horodatage ISO 8601. Spec AsyncAPI 3.0.',
|
contactFunctionalName: 'Emma Roux',
|
||||||
externalDocUrl: null,
|
contactFunctionalEntity: 'Équipe Comptes',
|
||||||
contactFunctional: 'Équipe Comptes',
|
contactFunctionalEmail: 'emma.roux@example.gouv.fr',
|
||||||
contactTechnical: 'dev-core@example.com',
|
contactTechnicalName: 'François Blanc',
|
||||||
accessRights: 'Interne uniquement — accès réservé aux services du SI.',
|
contactTechnicalEntity: 'Dev Core',
|
||||||
|
contactTechnicalEmail: 'dev-core@example.com',
|
||||||
}),
|
}),
|
||||||
this.apiRepo.create({
|
this.apiRepo.create({
|
||||||
title: 'Orders API',
|
convention: 'ENREGISTRER',
|
||||||
version: '1.0.0',
|
name: 'Commandes Clients',
|
||||||
|
versionMajor: 1,
|
||||||
|
versionMinor: 0,
|
||||||
|
versionPatch: 0,
|
||||||
description: 'API de gestion des commandes clients.',
|
description: 'API de gestion des commandes clients.',
|
||||||
type: 'OPENAPI',
|
type: 'OPENAPI',
|
||||||
yamlContent: ORDERS_YAML,
|
yamlContent: ORDERS_YAML,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
categoryId: commandes.id,
|
categoryId: commandes.id,
|
||||||
functionalDoc: 'Gestion des commandes clients : création, consultation, mise à jour du statut. Supporte les webhooks pour les transitions de statut (confirmed, shipped, delivered, cancelled).',
|
provider: 'Équipe Commerce',
|
||||||
technicalDoc: 'REST + JWT Bearer. Pagination via paramètres `page` et `limit`. Webhook de statut : POST vers URL configurée lors de la transition. Base URL : https://api.example.com/v1.',
|
contactFunctionalName: 'Gaëlle Moreau',
|
||||||
externalDocUrl: null,
|
contactFunctionalEntity: 'Équipe Commerce',
|
||||||
contactFunctional: 'Équipe Commerce',
|
contactFunctionalEmail: 'gaelle.moreau@example.gouv.fr',
|
||||||
contactTechnical: 'api-orders@example.com',
|
contactTechnicalName: 'Hugo Simon',
|
||||||
accessRights: 'Partenaires agréés uniquement — convention de partenariat requise.',
|
contactTechnicalEntity: 'API Commerce',
|
||||||
|
contactTechnicalEmail: 'api-orders@example.com',
|
||||||
}),
|
}),
|
||||||
this.apiRepo.create({
|
this.apiRepo.create({
|
||||||
title: 'Auth API',
|
convention: 'CONSULTER',
|
||||||
version: '1.0.0',
|
name: 'Référentiel Produits',
|
||||||
|
versionMajor: 3,
|
||||||
|
versionMinor: 0,
|
||||||
|
versionPatch: 3,
|
||||||
|
isCurrent: false,
|
||||||
|
description:
|
||||||
|
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||||
|
type: 'OPENAPI',
|
||||||
|
yamlContent: PETSTORE_YAML,
|
||||||
|
status: 'PENDING',
|
||||||
|
categoryId: produits.id,
|
||||||
|
provider: 'DINUM',
|
||||||
|
contactFunctionalName: 'Alice Martin',
|
||||||
|
contactFunctionalEntity: 'Équipe Produit',
|
||||||
|
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
|
||||||
|
contactTechnicalName: 'Bob Dupont',
|
||||||
|
contactTechnicalEntity: 'Équipe API',
|
||||||
|
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
|
||||||
|
}),
|
||||||
|
this.apiRepo.create({
|
||||||
|
convention: 'CONSULTER',
|
||||||
|
name: 'Référentiel Produits',
|
||||||
|
versionMajor: 3,
|
||||||
|
versionMinor: 0,
|
||||||
|
versionPatch: 2,
|
||||||
|
isCurrent: false,
|
||||||
|
description:
|
||||||
|
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||||
|
type: 'OPENAPI',
|
||||||
|
yamlContent: PETSTORE_YAML,
|
||||||
|
status: 'PENDING',
|
||||||
|
categoryId: produits.id,
|
||||||
|
provider: 'DINUM',
|
||||||
|
contactFunctionalName: 'Alice Martin',
|
||||||
|
contactFunctionalEntity: 'Équipe Produit',
|
||||||
|
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
|
||||||
|
contactTechnicalName: 'Bob Dupont',
|
||||||
|
contactTechnicalEntity: 'Équipe API',
|
||||||
|
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
|
||||||
|
}),
|
||||||
|
this.apiRepo.create({
|
||||||
|
convention: 'CONSULTER',
|
||||||
|
name: 'Authentification JWT',
|
||||||
|
versionMajor: 1,
|
||||||
|
versionMinor: 0,
|
||||||
|
versionPatch: 0,
|
||||||
description: "Service d'authentification et d'autorisation.",
|
description: "Service d'authentification et d'autorisation.",
|
||||||
type: 'OPENAPI',
|
type: 'OPENAPI',
|
||||||
yamlContent: AUTH_YAML,
|
yamlContent: AUTH_YAML,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
categoryId: auth.id,
|
categoryId: auth.id,
|
||||||
functionalDoc: 'SSO centralisé avec tokens JWT. Gère la connexion, le rafraîchissement de token et la déconnexion. Exposé à tous les services du SI comme fournisseur d\'identité.',
|
provider: 'Équipe Sécurité',
|
||||||
technicalDoc: 'Bearer JWT (RS256). Endpoint principal : POST /auth/login. Refresh via POST /auth/refresh. Durée access token : 15 min. Durée refresh token : 7 jours. Base URL : https://auth.example.com/v1.',
|
contactFunctionalName: 'Isabelle Petit',
|
||||||
externalDocUrl: null,
|
contactFunctionalEntity: 'Équipe Sécurité',
|
||||||
contactFunctional: 'Équipe Sécurité',
|
contactFunctionalEmail: 'isabelle.petit@example.gouv.fr',
|
||||||
contactTechnical: 'securite@example.com',
|
contactTechnicalName: 'Julien Bernard',
|
||||||
accessRights: 'Restreint — habilitation RSSI requise avant intégration.',
|
contactTechnicalEntity: 'Sécurité SI',
|
||||||
|
contactTechnicalEmail: 'securite@example.com',
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('[Seeder] 8 catégories et 5 APIs de démo créées — génération docs en cours...');
|
console.log('[Seeder] 8 catégories et 7 APIs de démo créées — génération docs en cours...');
|
||||||
|
|
||||||
/* Déclenche la génération de docs en fire-and-forget pour chaque API */
|
/* 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) {
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,128 @@ export class UploadsController {
|
|||||||
private readonly docsService: DocsGenerationService,
|
private readonly docsService: DocsGenerationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Post('validate')
|
||||||
|
@UseInterceptors(
|
||||||
|
FilesInterceptor('files', 20, {
|
||||||
|
storage: diskStorage({ destination: path.join(os.tmpdir(), 'datacat-uploads') }),
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (!file.originalname.match(/\.(yaml|yml)$/i)) {
|
||||||
|
return cb(new BadRequestException('Only YAML files are allowed'), false);
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async validate(
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
type: ApiType | null;
|
||||||
|
mainFile: string | null;
|
||||||
|
name: string | null;
|
||||||
|
versionMajor: number | null;
|
||||||
|
versionMinor: number | null;
|
||||||
|
versionPatch: number | null;
|
||||||
|
description: string | null;
|
||||||
|
errors: Array<{ file: string; message: string }>;
|
||||||
|
}> {
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors: [{ file: '', message: 'Aucun fichier fourni.' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lire tous les contenus en mémoire avant toute suppression */
|
||||||
|
const fileContents = files.map((file) => ({
|
||||||
|
name: file.originalname,
|
||||||
|
path: file.path,
|
||||||
|
content: fs.readFileSync(file.path, 'utf-8'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/* Nettoyage immédiat des fichiers temporaires */
|
||||||
|
for (const file of files) {
|
||||||
|
fsPromises.unlink(file.path).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: Array<{ file: string; message: string }> = [];
|
||||||
|
|
||||||
|
/* Validation syntaxique de chaque fichier */
|
||||||
|
for (const { name, content } of fileContents) {
|
||||||
|
try {
|
||||||
|
const parsed = yaml.load(content);
|
||||||
|
if (parsed === null || typeof parsed !== 'object') {
|
||||||
|
errors.push({ file: name, message: 'le fichier YAML est vide ou invalide' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.push({ file: name, message: `erreur de syntaxe YAML — ${msg}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Détection du fichier principal */
|
||||||
|
let mainFileName: string | null = null;
|
||||||
|
let detectedType: ApiType | null = null;
|
||||||
|
|
||||||
|
for (const { name, content } of fileContents) {
|
||||||
|
const type = this.detectTypeFromContent(content);
|
||||||
|
if (type) {
|
||||||
|
mainFileName = name;
|
||||||
|
detectedType = type;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainFileName) {
|
||||||
|
return {
|
||||||
|
valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
|
||||||
|
errors: [{ file: '', message: 'Aucun fichier principal trouvé (clé openapi: ou asyncapi: manquante).' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Validation complète via CLI (asyncapi validate / redocly lint) */
|
||||||
|
const tempDir = path.join(os.tmpdir(), 'datacat-validate', randomUUID());
|
||||||
|
try {
|
||||||
|
await fsPromises.mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
/* Écrire tous les fichiers dans le répertoire temp avec leurs noms d'origine (pour les $ref) */
|
||||||
|
for (const { name, content } of fileContents) {
|
||||||
|
await fsPromises.writeFile(path.join(tempDir, name), content, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainPath = path.join(tempDir, mainFileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (detectedType === 'ASYNCAPI') {
|
||||||
|
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, shell: true, timeout: 30000 });
|
||||||
|
} else {
|
||||||
|
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, shell: true, timeout: 30000 });
|
||||||
|
}
|
||||||
|
} catch (cliError) {
|
||||||
|
const err = cliError as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
|
||||||
|
/* CLI non installé (dev local sans Docker) → skip la validation complète */
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
const output = (err.stdout || err.stderr || String(cliError)).trim();
|
||||||
|
return {
|
||||||
|
valid: false, type: detectedType, mainFile: mainFileName,
|
||||||
|
name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
|
||||||
|
errors: [{ file: mainFileName, message: output }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.logger.warn(`CLI ${detectedType === 'ASYNCAPI' ? 'asyncapi' : 'redocly'} non trouvé — validation CLI ignorée`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extraction des métadonnées depuis le fichier principal */
|
||||||
|
const mainContent = fileContents.find((f) => f.name === mainFileName)!.content;
|
||||||
|
const { name, description, versionMajor, versionMinor, versionPatch } = this.extractMetadata(mainContent);
|
||||||
|
|
||||||
|
return { valid: true, type: detectedType, mainFile: mainFileName, name, versionMajor, versionMinor, versionPatch, description, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FilesInterceptor('files', 20, {
|
FilesInterceptor('files', 20, {
|
||||||
@@ -55,22 +177,25 @@ export class UploadsController {
|
|||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Body()
|
@Body()
|
||||||
body: {
|
body: {
|
||||||
title: string;
|
convention: string;
|
||||||
version: string;
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
versionMajor: string;
|
||||||
|
versionMinor: string;
|
||||||
|
versionPatch: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
type?: ApiType;
|
type?: ApiType;
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
functionalDoc?: string;
|
contactFunctionalName: string;
|
||||||
technicalDoc?: string;
|
contactFunctionalEntity: string;
|
||||||
externalDocUrl?: string;
|
contactFunctionalEmail: string;
|
||||||
contactFunctional?: string;
|
contactTechnicalName: string;
|
||||||
contactTechnical?: string;
|
contactTechnicalEntity: string;
|
||||||
accessRights?: string;
|
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.title) throw new BadRequestException('title is required');
|
if (!body.convention) throw new BadRequestException('convention is required');
|
||||||
if (!body.version) throw new BadRequestException('version is required');
|
|
||||||
|
|
||||||
/* Crée le répertoire si absent */
|
/* Crée le répertoire si absent */
|
||||||
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
|
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
|
||||||
@@ -81,7 +206,10 @@ export class UploadsController {
|
|||||||
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 = fs.readFileSync(files[0].path, 'utf-8');
|
||||||
|
|
||||||
@@ -105,26 +233,59 @@ export class UploadsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entry = await this.apisService.create({
|
const entry = await this.apisService.create({
|
||||||
title: body.title,
|
convention: (body.convention as 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE') ?? 'CONSULTER',
|
||||||
version: body.version,
|
name: body.name,
|
||||||
|
provider: body.provider,
|
||||||
|
versionMajor: parseInt(body.versionMajor, 10) || 0,
|
||||||
|
versionMinor: parseInt(body.versionMinor, 10) || 0,
|
||||||
|
versionPatch: parseInt(body.versionPatch, 10) || 0,
|
||||||
description: body.description,
|
description: body.description,
|
||||||
type: apiType,
|
type: apiType,
|
||||||
yamlContent,
|
yamlContent,
|
||||||
categoryId: body.categoryId || undefined,
|
categoryId: body.categoryId || undefined,
|
||||||
functionalDoc: body.functionalDoc || undefined,
|
contactFunctionalName: body.contactFunctionalName,
|
||||||
technicalDoc: body.technicalDoc || undefined,
|
contactFunctionalEntity: body.contactFunctionalEntity,
|
||||||
externalDocUrl: body.externalDocUrl || undefined,
|
contactFunctionalEmail: body.contactFunctionalEmail,
|
||||||
contactFunctional: body.contactFunctional || undefined,
|
contactTechnicalName: body.contactTechnicalName,
|
||||||
contactTechnical: body.contactTechnical || undefined,
|
contactTechnicalEntity: body.contactTechnicalEntity,
|
||||||
accessRights: body.accessRights || undefined,
|
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(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -180,7 +341,7 @@ export class UploadsController {
|
|||||||
|
|
||||||
if (type === 'OPENAPI') {
|
if (type === 'OPENAPI') {
|
||||||
/* redocly bundle : pas d'appel réseau, fonctionne en local */
|
/* redocly bundle : pas d'appel réseau, fonctionne en local */
|
||||||
await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath], { shell: true, env: execEnv });
|
await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath], { env: execEnv });
|
||||||
return await fsPromises.readFile(bundledPath, 'utf-8');
|
return await fsPromises.readFile(bundledPath, 'utf-8');
|
||||||
} else {
|
} else {
|
||||||
/* @asyncapi/bundler API programmatique : aucun appel réseau */
|
/* @asyncapi/bundler API programmatique : aucun appel réseau */
|
||||||
|
|||||||
@@ -10,10 +10,9 @@
|
|||||||
"target": "ES2021",
|
"target": "ES2021",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"rootDir": "../",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true
|
||||||
"paths": {
|
},
|
||||||
"@datacat/shared": ["../shared/src/index.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
9
back/vitest.config.ts
Normal file
9
back/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.spec.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.
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "node_modules/@gouvfr/dsfr/dist/utility",
|
"input": "node_modules/@gouvfr/dsfr/dist/utility",
|
||||||
"output": "assets/dsfr/utility"
|
"output": "assets/dsfr/utility"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/@gouvfr/dsfr/dist/icons",
|
||||||
|
"output": "assets/dsfr/icons"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/@gouvfr/dsfr/dist/icons",
|
||||||
|
"output": "assets/icons"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.scss"],
|
"styles": ["src/styles.scss"],
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterOutlet, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
imports: [RouterOutlet, RouterLink],
|
||||||
template: `
|
template: `
|
||||||
<header role="banner" class="fr-header">
|
<header role="banner" class="fr-header">
|
||||||
<div class="fr-header__body">
|
<div class="fr-header__body">
|
||||||
@@ -17,7 +17,7 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fr-header__service">
|
<div class="fr-header__service">
|
||||||
<a href="/" routerLink="/browse" title="Accueil — Datacat">
|
<a href="/" routerLink="/" title="Accueil — Datacat">
|
||||||
<p class="fr-header__service-title">Datacat</p>
|
<p class="fr-header__service-title">Datacat</p>
|
||||||
</a>
|
</a>
|
||||||
<p class="fr-header__service-tagline">Catalogue de données d'API</p>
|
<p class="fr-header__service-tagline">Catalogue de données d'API</p>
|
||||||
@@ -26,34 +26,6 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fr-header__menu">
|
|
||||||
<div class="fr-container">
|
|
||||||
<nav class="fr-nav" role="navigation" aria-label="Menu principal">
|
|
||||||
<ul class="fr-nav__list">
|
|
||||||
<li class="fr-nav__item">
|
|
||||||
<a
|
|
||||||
class="fr-nav__link"
|
|
||||||
routerLink="/browse"
|
|
||||||
routerLinkActive="fr-nav__link--active"
|
|
||||||
[routerLinkActiveOptions]="{ exact: false }"
|
|
||||||
>
|
|
||||||
Parcourir
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="fr-nav__item">
|
|
||||||
<a
|
|
||||||
class="fr-nav__link"
|
|
||||||
routerLink="/catalog"
|
|
||||||
routerLinkActive="fr-nav__link--active"
|
|
||||||
[routerLinkActiveOptions]="{ exact: false }"
|
|
||||||
>
|
|
||||||
Catalogue
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Routes } from '@angular/router';
|
|||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
redirectTo: 'browse',
|
loadComponent: () =>
|
||||||
pathMatch: 'full',
|
import('./pages/browse/browse.component').then((m) => m.BrowseComponent),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'browse',
|
path: 'browse',
|
||||||
@@ -18,8 +18,8 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'catalog',
|
path: 'catalog',
|
||||||
loadComponent: () =>
|
redirectTo: 'browse',
|
||||||
import('./pages/catalog/catalog.component').then((m) => m.CatalogComponent),
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'catalog/:id',
|
path: 'catalog/:id',
|
||||||
@@ -38,6 +38,6 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
redirectTo: 'browse',
|
redirectTo: '',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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, ApiListResponse, ApiListQuery } from '@datacat/shared';
|
import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery } from '@datacat/shared';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ApiService {
|
export class ApiService {
|
||||||
@@ -22,7 +22,22 @@ export class ApiService {
|
|||||||
return this.http.get<ApiEntry>(`${this.baseUrl}/${id}`);
|
return this.http.get<ApiEntry>(`${this.baseUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, data: { title?: string; version?: string; description?: string }): Observable<ApiEntry> {
|
update(id: string, data: {
|
||||||
|
convention?: string;
|
||||||
|
name?: string;
|
||||||
|
provider?: string;
|
||||||
|
versionMajor?: number;
|
||||||
|
versionMinor?: number;
|
||||||
|
versionPatch?: number;
|
||||||
|
description?: string | null;
|
||||||
|
categoryId?: string | null;
|
||||||
|
contactFunctionalName?: string;
|
||||||
|
contactFunctionalEntity?: string;
|
||||||
|
contactFunctionalEmail?: string;
|
||||||
|
contactTechnicalName?: string;
|
||||||
|
contactTechnicalEntity?: string;
|
||||||
|
contactTechnicalEmail?: string;
|
||||||
|
}): Observable<ApiEntry> {
|
||||||
return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data);
|
return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +45,14 @@ export class ApiService {
|
|||||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getVersions(id: string): Observable<ApiEntryListItem[]> {
|
||||||
|
return this.http.get<ApiEntryListItem[]>(`${this.baseUrl}/${id}/versions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrent(id: string): Observable<ApiEntry> {
|
||||||
|
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/set-current`, {});
|
||||||
|
}
|
||||||
|
|
||||||
regenerate(id: string): Observable<ApiEntry> {
|
regenerate(id: string): Observable<ApiEntry> {
|
||||||
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
|
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
|
||||||
}
|
}
|
||||||
@@ -37,4 +60,28 @@ export class ApiService {
|
|||||||
upload(formData: FormData): Observable<ApiEntry> {
|
upload(formData: FormData): Observable<ApiEntry> {
|
||||||
return this.http.post<ApiEntry>('/api/uploads', formData);
|
return this.http.post<ApiEntry>('/api/uploads', formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate(formData: FormData): Observable<{
|
||||||
|
valid: boolean;
|
||||||
|
type: 'ASYNCAPI' | 'OPENAPI' | null;
|
||||||
|
mainFile: string | null;
|
||||||
|
name: string | null;
|
||||||
|
versionMajor: number | null;
|
||||||
|
versionMinor: number | null;
|
||||||
|
versionPatch: number | null;
|
||||||
|
description: string | null;
|
||||||
|
errors: Array<{ file: string; message: string }>;
|
||||||
|
}> {
|
||||||
|
return this.http.post<{
|
||||||
|
valid: boolean;
|
||||||
|
type: 'ASYNCAPI' | 'OPENAPI' | null;
|
||||||
|
mainFile: string | null;
|
||||||
|
name: string | null;
|
||||||
|
versionMajor: number | null;
|
||||||
|
versionMinor: number | null;
|
||||||
|
versionPatch: number | null;
|
||||||
|
description: string | null;
|
||||||
|
errors: Array<{ file: string; message: string }>;
|
||||||
|
}>('/api/uploads/validate', formData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export class CategoryService {
|
|||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private baseUrl = '/api/categories';
|
private baseUrl = '/api/categories';
|
||||||
|
|
||||||
list(): Observable<CategoryListResponse> {
|
list(search?: string): Observable<CategoryListResponse> {
|
||||||
return this.http.get<CategoryListResponse>(this.baseUrl);
|
const params: Record<string, string> = {};
|
||||||
|
if (search?.trim()) params['search'] = search;
|
||||||
|
return this.http.get<CategoryListResponse>(this.baseUrl, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
browse(id: string | null): Observable<CategoryBrowseResponse> {
|
browse(id: string | null): Observable<CategoryBrowseResponse> {
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
signal,
|
signal,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
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 { ApiEntry } from '@datacat/shared';
|
import { CategoryService } from '../../core/category.service';
|
||||||
|
import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-api-detail',
|
selector: 'app-api-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink],
|
imports: [CommonModule, RouterLink, ReactiveFormsModule],
|
||||||
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">
|
||||||
@@ -22,8 +26,14 @@ import { ApiEntry } from '@datacat/shared';
|
|||||||
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
||||||
<ol class="fr-breadcrumb__list">
|
<ol class="fr-breadcrumb__list">
|
||||||
<li>
|
<li>
|
||||||
<a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a>
|
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<a class="fr-breadcrumb__link" aria-current="page">
|
<a class="fr-breadcrumb__link" aria-current="page">
|
||||||
{{ api()?.title ?? 'Chargement...' }}
|
{{ api()?.title ?? 'Chargement...' }}
|
||||||
@@ -46,178 +56,356 @@ import { ApiEntry } from '@datacat/shared';
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (api(); as entry) {
|
@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 -->
|
<!-- En-tête -->
|
||||||
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
<div class="fr-mb-3w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
|
||||||
<div class="fr-col">
|
<!-- Gauche : titre + version -->
|
||||||
<h1 class="fr-h2 fr-mb-1w">{{ entry.title }}</h1>
|
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;min-width:0;">
|
||||||
<div class="fr-tags-group">
|
<h1 class="fr-h2 fr-mb-0">{{ entry.title }}</h1>
|
||||||
<span class="fr-tag">{{ entry.type }}</span>
|
@if (versions().length <= 1) {
|
||||||
<span class="fr-tag">v{{ entry.version }}</span>
|
<span class="fr-tag">v{{ entry.version }}</span>
|
||||||
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
|
} @else {
|
||||||
</div>
|
<select class="fr-select" id="version-select" style="width:auto;" (change)="onVersionChange($event)">
|
||||||
</div>
|
@for (v of versions(); track v.id) {
|
||||||
</div>
|
<option [value]="v.id" [selected]="v.id === entry.id">
|
||||||
|
{{ v.version }}{{ v.isCurrent ? ' (courante)' : '' }}
|
||||||
<!-- Alerte statut PENDING -->
|
</option>
|
||||||
@if (entry.status === 'PENDING') {
|
|
||||||
<div class="fr-alert fr-alert--info fr-mb-3w">
|
|
||||||
<p class="fr-alert__title">Génération en cours</p>
|
|
||||||
<p>La documentation est en cours de génération. Cette page se rafraîchit automatiquement.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Alerte statut ERROR -->
|
|
||||||
@if (entry.status === 'ERROR') {
|
|
||||||
<div class="fr-alert fr-alert--error fr-mb-3w">
|
|
||||||
<p class="fr-alert__title">Erreur de génération</p>
|
|
||||||
<p>{{ entry.errorMessage }}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Métadonnées -->
|
|
||||||
<div class="fr-card fr-mb-4w">
|
|
||||||
<div class="fr-card__body">
|
|
||||||
<div class="fr-card__content">
|
|
||||||
<h2 class="fr-h5 fr-mb-2w">Informations</h2>
|
|
||||||
<dl class="fr-grid-row fr-grid-row--gutters">
|
|
||||||
<div class="fr-col-12 fr-col-md-4">
|
|
||||||
<dt class="fr-text--bold">Titre</dt>
|
|
||||||
<dd>{{ entry.title }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="fr-col-12 fr-col-md-4">
|
|
||||||
<dt class="fr-text--bold">Version</dt>
|
|
||||||
<dd>{{ entry.version }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="fr-col-12 fr-col-md-4">
|
|
||||||
<dt class="fr-text--bold">Type</dt>
|
|
||||||
<dd>{{ entry.type }}</dd>
|
|
||||||
</div>
|
|
||||||
@if (entry.description) {
|
|
||||||
<div class="fr-col-12">
|
|
||||||
<dt class="fr-text--bold">Description</dt>
|
|
||||||
<dd>{{ entry.description }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<div class="fr-col-12 fr-col-md-6">
|
</select>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Droite : bouton Voir doc + Actions dropdown -->
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;flex-shrink:0;">
|
||||||
<!-- Documentation fonctionnelle -->
|
<a class="fr-btn fr-btn--icon-left fr-icon-eye-line"
|
||||||
@if (entry.functionalDoc) {
|
[routerLink]="entry.status === 'GENERATED' ? ['/catalog', entry.id, 'docs'] : null"
|
||||||
<div class="fr-card fr-mb-4w">
|
[style.opacity]="entry.status !== 'GENERATED' ? '0.5' : '1'"
|
||||||
<div class="fr-card__body">
|
[style.pointer-events]="entry.status !== 'GENERATED' ? 'none' : 'auto'">
|
||||||
<div class="fr-card__content">
|
Voir la documentation
|
||||||
<h2 class="fr-h5 fr-mb-2w">Documentation fonctionnelle</h2>
|
|
||||||
<p style="white-space: pre-wrap">{{ entry.functionalDoc }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Documentation technique -->
|
|
||||||
@if (entry.technicalDoc) {
|
|
||||||
<div class="fr-card fr-mb-4w">
|
|
||||||
<div class="fr-card__body">
|
|
||||||
<div class="fr-card__content">
|
|
||||||
<h2 class="fr-h5 fr-mb-2w">Documentation technique</h2>
|
|
||||||
<p style="white-space: pre-wrap">{{ entry.technicalDoc }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Contacts -->
|
|
||||||
@if (entry.contactFunctional || entry.contactTechnical) {
|
|
||||||
<div class="fr-card fr-mb-4w">
|
|
||||||
<div class="fr-card__body">
|
|
||||||
<div class="fr-card__content">
|
|
||||||
<h2 class="fr-h5 fr-mb-2w">Contacts</h2>
|
|
||||||
<dl class="fr-grid-row fr-grid-row--gutters">
|
|
||||||
@if (entry.contactFunctional) {
|
|
||||||
<div class="fr-col-12 fr-col-md-6">
|
|
||||||
<dt class="fr-text--bold">Contact fonctionnel</dt>
|
|
||||||
<dd>{{ entry.contactFunctional }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (entry.contactTechnical) {
|
|
||||||
<div class="fr-col-12 fr-col-md-6">
|
|
||||||
<dt class="fr-text--bold">Contact technique</dt>
|
|
||||||
<dd>{{ entry.contactTechnical }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Droits d'accès -->
|
|
||||||
@if (entry.accessRights) {
|
|
||||||
<div class="fr-card fr-mb-4w">
|
|
||||||
<div class="fr-card__body">
|
|
||||||
<div class="fr-card__content">
|
|
||||||
<h2 class="fr-h5 fr-mb-2w">Droits d'accès</h2>
|
|
||||||
<p style="white-space: pre-wrap">{{ entry.accessRights }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Documentation externe -->
|
|
||||||
@if (entry.externalDocUrl) {
|
|
||||||
<div class="fr-mb-4w">
|
|
||||||
<a
|
|
||||||
[href]="entry.externalDocUrl"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="fr-btn fr-btn--secondary fr-icon-external-link-line fr-btn--icon-left"
|
|
||||||
>
|
|
||||||
Documentation externe
|
|
||||||
</a>
|
</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>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Navigation onglets -->
|
||||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
<div style="display:flex;border-bottom:2px solid #e5e5e5;margin-bottom:1.5rem;">
|
||||||
<a
|
<button (click)="activeTab.set('info')"
|
||||||
[routerLink]="['/catalog', entry.id, 'docs']"
|
[style.border-bottom]="activeTab()==='info' ? '2px solid #000091' : '2px solid transparent'"
|
||||||
class="fr-btn"
|
[style.color]="activeTab()==='info' ? '#000091' : '#3a3a3a'"
|
||||||
[class.fr-btn--disabled]="entry.status !== 'GENERATED'"
|
[style.font-weight]="activeTab()==='info' ? '700' : '400'"
|
||||||
[attr.aria-disabled]="entry.status !== 'GENERATED' ? 'true' : null"
|
style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
|
||||||
>
|
Informations
|
||||||
Voir la documentation
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
[href]="'/api/apis/' + entry.id + '/yaml'"
|
|
||||||
class="fr-btn fr-btn--secondary"
|
|
||||||
download
|
|
||||||
>
|
|
||||||
Télécharger YAML
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
class="fr-btn fr-btn--secondary"
|
|
||||||
(click)="onRegenerate()"
|
|
||||||
[disabled]="regenerating()"
|
|
||||||
>
|
|
||||||
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button (click)="activeTab.set('contacts')"
|
||||||
class="fr-btn fr-btn--secondary"
|
[style.border-bottom]="activeTab()==='contacts' ? '2px solid #000091' : '2px solid transparent'"
|
||||||
(click)="onDelete()"
|
[style.color]="activeTab()==='contacts' ? '#000091' : '#3a3a3a'"
|
||||||
[disabled]="deleting()"
|
[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;">
|
||||||
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
|
Contacts
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -226,25 +414,103 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
|
private categoryService = inject(CategoryService);
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
|
|
||||||
api = signal<ApiEntry | null>(null);
|
api = signal<ApiEntry | null>(null);
|
||||||
|
versions = signal<ApiEntryListItem[]>([]);
|
||||||
|
categoryBreadcrumb = signal<Category[]>([]);
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
regenerating = signal(false);
|
regenerating = signal(false);
|
||||||
deleting = signal(false);
|
deleting = signal(false);
|
||||||
|
settingCurrent = signal(false);
|
||||||
|
categories = signal<Category[]>([]);
|
||||||
|
actionsOpen = signal(false);
|
||||||
|
|
||||||
|
backUrl = computed<string[]>(() => {
|
||||||
|
const crumbs = this.categoryBreadcrumb();
|
||||||
|
if (crumbs.length > 0) return ['/browse', crumbs[crumbs.length - 1].id];
|
||||||
|
return ['/browse'];
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Onglets */
|
||||||
|
activeTab = signal<'info' | 'contacts'>('info');
|
||||||
|
editingInfo = signal(false);
|
||||||
|
editingContacts = signal(false);
|
||||||
|
savingInfo = signal(false);
|
||||||
|
savingContacts = signal(false);
|
||||||
|
|
||||||
|
infoForm = this.fb.group({
|
||||||
|
convention: ['CONSULTER', Validators.required],
|
||||||
|
name: ['', Validators.required],
|
||||||
|
provider: ['', Validators.required],
|
||||||
|
versionMajor: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
versionMinor: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
versionPatch: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
description: [''],
|
||||||
|
categoryId: [''],
|
||||||
|
});
|
||||||
|
|
||||||
|
contactForm = this.fb.group({
|
||||||
|
contactFunctionalName: ['', Validators.required],
|
||||||
|
contactFunctionalEntity: ['', Validators.required],
|
||||||
|
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
|
||||||
|
contactTechnicalName: ['', Validators.required],
|
||||||
|
contactTechnicalEntity: ['', Validators.required],
|
||||||
|
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
|
||||||
|
});
|
||||||
|
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private routeSub: Subscription | null = null;
|
||||||
private id = '';
|
private id = '';
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.id = this.route.snapshot.paramMap.get('id') ?? '';
|
this.categoryService.list().subscribe((res) => this.categories.set(res.items));
|
||||||
this.loadApi();
|
this.routeSub = this.route.paramMap.subscribe((params) => {
|
||||||
|
this.id = params.get('id') ?? '';
|
||||||
|
this.api.set(null);
|
||||||
|
this.versions.set([]);
|
||||||
|
this.error.set(null);
|
||||||
|
this.categoryBreadcrumb.set([]);
|
||||||
|
this.editingInfo.set(false);
|
||||||
|
this.editingContacts.set(false);
|
||||||
|
this.clearPoll();
|
||||||
|
this.loadApi();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
this.routeSub?.unsubscribe();
|
||||||
this.clearPoll();
|
this.clearPoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onVersionChange(event: Event): void {
|
||||||
|
const id = (event.target as HTMLSelectElement).value;
|
||||||
|
if (id && id !== this.api()?.id) {
|
||||||
|
this.router.navigate(['/catalog', id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNewVersion(): void {
|
||||||
|
this.router.navigate(['/upload'], { queryParams: { from: this.api()?.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
onSetCurrent() {
|
||||||
|
this.settingCurrent.set(true);
|
||||||
|
this.apiService.setCurrent(this.id).subscribe({
|
||||||
|
next: (entry) => {
|
||||||
|
this.api.set(entry);
|
||||||
|
this.settingCurrent.set(false);
|
||||||
|
this.apiService.getVersions(this.id).subscribe({
|
||||||
|
next: (v) => this.versions.set(v),
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => this.settingCurrent.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onRegenerate() {
|
onRegenerate() {
|
||||||
this.regenerating.set(true);
|
this.regenerating.set(true);
|
||||||
this.apiService.regenerate(this.id).subscribe({
|
this.apiService.regenerate(this.id).subscribe({
|
||||||
@@ -257,11 +523,90 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startEditInfo() {
|
||||||
|
const entry = this.api();
|
||||||
|
if (!entry) return;
|
||||||
|
this.infoForm.patchValue({
|
||||||
|
convention: entry.convention,
|
||||||
|
name: entry.name,
|
||||||
|
provider: entry.provider,
|
||||||
|
versionMajor: entry.versionMajor,
|
||||||
|
versionMinor: entry.versionMinor,
|
||||||
|
versionPatch: entry.versionPatch,
|
||||||
|
description: entry.description ?? '',
|
||||||
|
categoryId: entry.categoryId ?? '',
|
||||||
|
});
|
||||||
|
this.editingInfo.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
startEditContacts() {
|
||||||
|
const entry = this.api();
|
||||||
|
if (!entry) return;
|
||||||
|
this.contactForm.patchValue({
|
||||||
|
contactFunctionalName: entry.contactFunctionalName,
|
||||||
|
contactFunctionalEntity: entry.contactFunctionalEntity,
|
||||||
|
contactFunctionalEmail: entry.contactFunctionalEmail,
|
||||||
|
contactTechnicalName: entry.contactTechnicalName,
|
||||||
|
contactTechnicalEntity: entry.contactTechnicalEntity,
|
||||||
|
contactTechnicalEmail: entry.contactTechnicalEmail,
|
||||||
|
});
|
||||||
|
this.editingContacts.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitInfo() {
|
||||||
|
if (this.infoForm.invalid) { this.infoForm.markAllAsTouched(); return; }
|
||||||
|
const entry = this.api();
|
||||||
|
if (!entry) return;
|
||||||
|
this.savingInfo.set(true);
|
||||||
|
const raw = this.infoForm.getRawValue();
|
||||||
|
this.apiService.update(entry.id, {
|
||||||
|
convention: raw.convention ?? 'CONSULTER',
|
||||||
|
name: raw.name ?? '',
|
||||||
|
provider: raw.provider ?? '',
|
||||||
|
versionMajor: raw.versionMajor ?? 0,
|
||||||
|
versionMinor: raw.versionMinor ?? 0,
|
||||||
|
versionPatch: raw.versionPatch ?? 0,
|
||||||
|
categoryId: raw.categoryId || null,
|
||||||
|
description: raw.description || null,
|
||||||
|
}).subscribe({
|
||||||
|
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
|
||||||
|
error: () => this.savingInfo.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submitContacts() {
|
||||||
|
if (this.contactForm.invalid) { this.contactForm.markAllAsTouched(); return; }
|
||||||
|
const entry = this.api();
|
||||||
|
if (!entry) return;
|
||||||
|
this.savingContacts.set(true);
|
||||||
|
const raw = this.contactForm.getRawValue();
|
||||||
|
this.apiService.update(entry.id, {
|
||||||
|
contactFunctionalName: raw.contactFunctionalName ?? '',
|
||||||
|
contactFunctionalEntity: raw.contactFunctionalEntity ?? '',
|
||||||
|
contactFunctionalEmail: raw.contactFunctionalEmail ?? '',
|
||||||
|
contactTechnicalName: raw.contactTechnicalName ?? '',
|
||||||
|
contactTechnicalEntity: raw.contactTechnicalEntity ?? '',
|
||||||
|
contactTechnicalEmail: raw.contactTechnicalEmail ?? '',
|
||||||
|
}).subscribe({
|
||||||
|
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
|
||||||
|
error: () => this.savingContacts.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onDelete() {
|
onDelete() {
|
||||||
if (!confirm('Supprimer cette API ?')) return;
|
if (!confirm('Supprimer cette API ?')) return;
|
||||||
this.deleting.set(true);
|
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({
|
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),
|
error: () => this.deleting.set(false),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -269,9 +614,17 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
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 {
|
||||||
|
if (status === 'GENERATED') return 'Générée';
|
||||||
|
if (status === 'ERROR') return 'Erreur';
|
||||||
|
if (status === 'NO_YAML') return 'Sans fichier';
|
||||||
|
return 'En attente';
|
||||||
|
}
|
||||||
|
|
||||||
private loadApi() {
|
private loadApi() {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.apiService.get(this.id).subscribe({
|
this.apiService.get(this.id).subscribe({
|
||||||
@@ -279,6 +632,16 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.api.set(entry);
|
this.api.set(entry);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.startPollIfPending();
|
this.startPollIfPending();
|
||||||
|
this.apiService.getVersions(this.id).subscribe({
|
||||||
|
next: (v) => this.versions.set(v),
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
if (entry.categoryId) {
|
||||||
|
this.categoryService.browse(entry.categoryId).subscribe({
|
||||||
|
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.error.set(err.message ?? 'Erreur lors du chargement');
|
this.error.set(err.message ?? 'Erreur lors du chargement');
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { CategoryService } from '../../core/category.service';
|
import { CategoryService } from '../../core/category.service';
|
||||||
import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/shared';
|
import { ApiService } from '../../core/api.service';
|
||||||
|
import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem } from '@datacat/shared';
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
|
import { CategoryFormModalComponent } from '../../shared/category-form-modal/category-form-modal.component';
|
||||||
|
import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal/category-delete-modal.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-browse',
|
selector: 'app-browse',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink],
|
imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent],
|
||||||
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">
|
||||||
@@ -25,7 +29,7 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
|
|||||||
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
||||||
<ol class="fr-breadcrumb__list">
|
<ol class="fr-breadcrumb__list">
|
||||||
<li>
|
<li>
|
||||||
<a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a>
|
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
|
||||||
</li>
|
</li>
|
||||||
@for (crumb of ancestors(); track crumb.id) {
|
@for (crumb of ancestors(); track crumb.id) {
|
||||||
<li>
|
<li>
|
||||||
@@ -41,17 +45,44 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- En-tête -->
|
<!-- En-tête -->
|
||||||
|
@if (categoryId()) {
|
||||||
|
<a class="fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w" style="display:inline-flex;"
|
||||||
|
[routerLink]="backUrl()">Retour</a>
|
||||||
|
}
|
||||||
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
||||||
<div class="fr-col">
|
<div class="fr-col">
|
||||||
<h1 class="fr-h2 fr-mb-0">{{ browseData()?.category?.name ?? 'Catalogue' }}</h1>
|
<h1 class="fr-h2 fr-mb-0">
|
||||||
|
{{ browseData()?.category?.name ?? 'Catalogue' }}
|
||||||
|
@if (browseData()?.category) {
|
||||||
|
<button
|
||||||
|
title="Modifier ce dossier"
|
||||||
|
style="background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
|
||||||
|
(click)="openEdit(browseData()!.category!)">
|
||||||
|
<span class="fr-icon-edit-line" aria-hidden="true" style="font-size:0.875rem"></span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="fr-col-auto">
|
<div class="fr-col-auto" style="display:flex;gap:0.5rem">
|
||||||
|
<a class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-upload-2-line"
|
||||||
|
[routerLink]="['/upload']"
|
||||||
|
[queryParams]="categoryId() ? { categoryId: categoryId() } : {}">
|
||||||
|
Importer une API
|
||||||
|
</a>
|
||||||
<button class="fr-btn fr-btn--icon-left fr-icon-folder-2-fill" (click)="openCreate()">
|
<button class="fr-btn fr-btn--icon-left fr-icon-folder-2-fill" (click)="openCreate()">
|
||||||
Nouveau dossier
|
Nouveau dossier
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Barre de recherche -->
|
||||||
|
<div class="fr-search-bar fr-mb-3w">
|
||||||
|
<label class="fr-label" for="search-api">Rechercher</label>
|
||||||
|
<input id="search-api" class="fr-input" type="search" placeholder="Ex : auth async v1.0, openapi latest..."
|
||||||
|
[value]="searchQuery()"
|
||||||
|
(input)="onSearchChange($any($event.target).value)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="fr-callout fr-mb-4w">
|
<div class="fr-callout fr-mb-4w">
|
||||||
<p class="fr-callout__text">Chargement...</p>
|
<p class="fr-callout__text">Chargement...</p>
|
||||||
@@ -64,134 +95,187 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!loading() && browseData()) {
|
@if (!isSearchMode()) {
|
||||||
<!-- Sous-catégories -->
|
@if (!loading() && browseData()) {
|
||||||
@if (browseData()!.subcategories.length > 0) {
|
<!-- Sous-catégories -->
|
||||||
<h2 class="fr-h4 fr-mb-3w">Catégories</h2>
|
@if (browseData()!.subcategories.length > 0) {
|
||||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
||||||
@for (cat of browseData()!.subcategories; track cat.id) {
|
@for (cat of browseData()!.subcategories; track cat.id) {
|
||||||
<div class="fr-col-12 fr-col-md-4">
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
<div class="fr-tile fr-enlarge-link fr-tile--sm">
|
<div class="fr-tile fr-enlarge-link fr-tile--sm">
|
||||||
<div class="fr-tile__body">
|
<div class="fr-tile__body">
|
||||||
<div class="fr-tile__content">
|
<div class="fr-tile__content">
|
||||||
<h3 class="fr-tile__title">
|
<h3 class="fr-tile__title">
|
||||||
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
|
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
|
||||||
</h3>
|
<button title="Modifier"
|
||||||
@if (cat.description) {
|
style="position:relative;z-index:1;background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
|
||||||
<p class="fr-tile__desc">{{ cat.description }}</p>
|
(click)="openEdit(cat); $event.stopPropagation()">
|
||||||
}
|
<span class="fr-icon-edit-line" aria-hidden="true" style="font-size:0.75rem"></span>
|
||||||
<p class="fr-tile__detail">
|
|
||||||
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
|
|
||||||
</p>
|
|
||||||
<div style="position:relative;z-index:1;margin-top:0.5rem;display:flex;gap:0.25rem;order:5">
|
|
||||||
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-edit-line"
|
|
||||||
(click)="openEdit(cat); $event.stopPropagation()">
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
|
|
||||||
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-delete-bin-line"
|
|
||||||
(click)="deleteCategory(cat); $event.stopPropagation()">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
</button>
|
||||||
|
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
|
||||||
|
<button title="Supprimer"
|
||||||
|
style="position:relative;z-index:1;background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
|
||||||
|
(click)="openDelete(cat); $event.stopPropagation()">
|
||||||
|
<span class="fr-icon-delete-bin-line" aria-hidden="true" style="font-size:0.75rem"></span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
@if (cat.description) {
|
||||||
|
<p class="fr-tile__desc">{{ cat.description }}</p>
|
||||||
|
}
|
||||||
|
<p class="fr-tile__detail">
|
||||||
|
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- APIs -->
|
||||||
|
@if (browseData()!.apis.length > 0) {
|
||||||
|
<div class="fr-grid-row fr-grid-row--gutters">
|
||||||
|
@for (api of browseData()!.apis; track api.id) {
|
||||||
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
|
<div class="fr-tile fr-enlarge-link fr-tile--sm">
|
||||||
|
<div class="fr-tile__body">
|
||||||
|
<div class="fr-tile__content">
|
||||||
|
<h3 class="fr-tile__title">
|
||||||
|
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
|
||||||
|
</h3>
|
||||||
|
@if (api.description) {
|
||||||
|
<p class="fr-tile__desc">{{ api.description }}</p>
|
||||||
|
}
|
||||||
|
<p class="fr-tile__detail">
|
||||||
|
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
|
||||||
|
<span class="fr-badge fr-badge--sm">v{{ api.version }}</span>
|
||||||
|
@if (api.isCurrent) {
|
||||||
|
<span class="fr-badge fr-badge--success fr-badge--sm">Courante</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (browseData()!.subcategories.length === 0 && browseData()!.apis.length === 0) {
|
||||||
|
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
|
||||||
|
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<!-- Mode recherche -->
|
||||||
|
@if (searchLoading()) {
|
||||||
|
<div class="fr-callout fr-mb-4w">
|
||||||
|
<p class="fr-callout__text">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!searchLoading()) {
|
||||||
|
<!-- Dossiers -->
|
||||||
|
@if (searchCategoryResults().length > 0) {
|
||||||
|
@if (searchResults().length > 0) {
|
||||||
|
<p class="fr-text--sm fr-text--bold fr-mb-2w">Dossiers</p>
|
||||||
|
}
|
||||||
|
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
||||||
|
@for (cat of searchCategoryResults(); track cat.id) {
|
||||||
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
|
<div class="fr-tile fr-enlarge-link fr-tile--sm">
|
||||||
|
<div class="fr-tile__body">
|
||||||
|
<div class="fr-tile__content">
|
||||||
|
<h3 class="fr-tile__title">
|
||||||
|
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
|
||||||
|
</h3>
|
||||||
|
@if (cat.description) {
|
||||||
|
<p class="fr-tile__desc">{{ cat.description }}</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
|
||||||
|
|
||||||
<!-- APIs -->
|
<!-- APIs -->
|
||||||
@if (browseData()!.apis.length > 0) {
|
@if (searchResults().length > 0) {
|
||||||
<h2 class="fr-h4 fr-mb-3w">APIs</h2>
|
@if (searchCategoryResults().length > 0) {
|
||||||
<div class="fr-grid-row fr-grid-row--gutters">
|
<p class="fr-text--sm fr-text--bold fr-mb-2w">APIs</p>
|
||||||
@for (api of browseData()!.apis; track api.id) {
|
}
|
||||||
<div class="fr-col-12 fr-col-md-4">
|
<div class="fr-grid-row fr-grid-row--gutters">
|
||||||
<div class="fr-tile fr-enlarge-link fr-tile--sm">
|
@for (api of searchResults(); track api.id) {
|
||||||
<div class="fr-tile__body">
|
<div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
|
||||||
<div class="fr-tile__content">
|
<div class="fr-card fr-card--sm fr-enlarge-link">
|
||||||
<h3 class="fr-tile__title">
|
<div class="fr-card__body">
|
||||||
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
|
<div class="fr-card__content">
|
||||||
</h3>
|
<h3 class="fr-card__title">
|
||||||
@if (api.description) {
|
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
|
||||||
<p class="fr-tile__desc">{{ api.description }}</p>
|
</h3>
|
||||||
}
|
@if (api.description) {
|
||||||
<p class="fr-tile__detail">
|
<p class="fr-card__desc">{{ api.description }}</p>
|
||||||
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
|
}
|
||||||
|
<div class="fr-badges-group fr-mt-1w">
|
||||||
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
|
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
|
||||||
</p>
|
<span class="fr-badge fr-badge--sm">v{{ api.version }}</span>
|
||||||
|
@if (api.isCurrent) {
|
||||||
|
<span class="fr-badge fr-badge--success fr-badge--sm">Courante</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (searchTotalPages() > 1) {
|
||||||
|
<div class="fr-grid-row fr-grid-row--center fr-mt-4w">
|
||||||
|
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === 1"
|
||||||
|
(click)="setSearchPage(searchPage() - 1)">Précédent</button>
|
||||||
|
<span class="fr-mx-2w">Page {{ searchPage() }} / {{ searchTotalPages() }}</span>
|
||||||
|
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === searchTotalPages()"
|
||||||
|
(click)="setSearchPage(searchPage() + 1)">Suivant</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@if (browseData()!.subcategories.length === 0 && browseData()!.apis.length === 0) {
|
<!-- Aucun résultat -->
|
||||||
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
|
@if (searchCategoryResults().length === 0 && searchResults().length === 0) {
|
||||||
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
|
<p class="fr-text--lead">Aucun résultat pour « {{ searchQuery() }} »</p>
|
||||||
</div>
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Modale création / édition (overlay Angular, sans fr-modal pour éviter l'interférence du JS DSFR) -->
|
|
||||||
@if (showFormModal()) {
|
|
||||||
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
|
|
||||||
role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
|
||||||
<div style="background:white;width:calc(100% - 2rem);max-width:540px;margin:0 1rem 2rem">
|
|
||||||
<div class="fr-p-4w">
|
|
||||||
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
|
||||||
<div class="fr-col">
|
|
||||||
<h2 id="modal-title" class="fr-h3 fr-mb-0">
|
|
||||||
{{ formMode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="fr-col-auto">
|
|
||||||
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
|
|
||||||
(click)="closeModal()">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (formError()) {
|
|
||||||
<div class="fr-alert fr-alert--error fr-mb-2w">
|
|
||||||
<p>{{ formError() }}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="fr-input-group fr-mb-2w">
|
|
||||||
<label class="fr-label" for="form-name">
|
|
||||||
Nom <span class="fr-hint-text">Obligatoire</span>
|
|
||||||
</label>
|
|
||||||
<input id="form-name" class="fr-input" type="text"
|
|
||||||
[value]="formName()"
|
|
||||||
(input)="formName.set($any($event.target).value)" />
|
|
||||||
</div>
|
|
||||||
<div class="fr-input-group fr-mb-3w">
|
|
||||||
<label class="fr-label" for="form-desc">
|
|
||||||
Description <span class="fr-hint-text">Optionnel</span>
|
|
||||||
</label>
|
|
||||||
<textarea id="form-desc" class="fr-input" rows="3"
|
|
||||||
[value]="formDescription()"
|
|
||||||
(input)="formDescription.set($any($event.target).value)"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
|
|
||||||
<button class="fr-btn fr-btn--secondary" (click)="closeModal()">Annuler</button>
|
|
||||||
<button class="fr-btn" (click)="submitForm()" [disabled]="formLoading()">
|
|
||||||
{{ formMode() === 'create' ? 'Créer' : 'Enregistrer' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale de suppression -->
|
||||||
|
@if (deletingCategory()) {
|
||||||
|
<app-category-delete-modal
|
||||||
|
[category]="deletingCategory()!"
|
||||||
|
(confirmed)="onDeleteConfirmed()"
|
||||||
|
(cancelled)="deletingCategory.set(null)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Modale création / édition -->
|
||||||
|
@if (showFormModal()) {
|
||||||
|
<app-category-form-modal
|
||||||
|
[mode]="formMode()"
|
||||||
|
[category]="editingCategory()"
|
||||||
|
[parentId]="categoryId()"
|
||||||
|
(saved)="onFormSaved()"
|
||||||
|
(closed)="showFormModal.set(false)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class BrowseComponent implements OnInit {
|
export class BrowseComponent implements OnInit {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private categoryService = inject(CategoryService);
|
private categoryService = inject(CategoryService);
|
||||||
|
private apiService = inject(ApiService);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
browseData = signal<CategoryBrowseResponse | null>(null);
|
browseData = signal<CategoryBrowseResponse | null>(null);
|
||||||
@@ -199,22 +283,38 @@ export class BrowseComponent implements OnInit {
|
|||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
categoryId = signal<string | null>(null);
|
categoryId = signal<string | null>(null);
|
||||||
|
|
||||||
/* Signaux pour la modale */
|
/* Signaux recherche */
|
||||||
|
searchQuery = signal('');
|
||||||
|
searchResults = signal<ApiEntryListItem[]>([]);
|
||||||
|
searchCategoryResults = signal<Category[]>([]);
|
||||||
|
searchTotal = signal(0);
|
||||||
|
searchPage = signal(1);
|
||||||
|
searchLoading = signal(false);
|
||||||
|
readonly searchLimit = 12;
|
||||||
|
|
||||||
|
isSearchMode = computed(() => this.searchQuery().trim().length > 0);
|
||||||
|
searchTotalPages = computed(() => Math.ceil(this.searchTotal() / this.searchLimit));
|
||||||
|
|
||||||
|
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/* Signaux pour les modales */
|
||||||
showFormModal = signal(false);
|
showFormModal = signal(false);
|
||||||
formMode = signal<'create' | 'edit'>('create');
|
formMode = signal<'create' | 'edit'>('create');
|
||||||
editingCategoryId = signal<string | null>(null);
|
editingCategory = signal<Category | null>(null);
|
||||||
formName = signal('');
|
deletingCategory = signal<CategoryWithCounts | null>(null);
|
||||||
formDescription = signal('');
|
|
||||||
formError = signal<string | null>(null);
|
|
||||||
formLoading = signal(false);
|
|
||||||
|
|
||||||
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
|
|
||||||
ancestors = computed<Category[]>(() => {
|
ancestors = computed<Category[]>(() => {
|
||||||
const d = this.browseData();
|
const d = this.browseData();
|
||||||
if (!d || !d.category) return [];
|
if (!d || !d.category) return [];
|
||||||
return d.breadcrumb.slice(0, -1);
|
return d.breadcrumb.slice(0, -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
backUrl = computed<string[]>(() => {
|
||||||
|
const anc = this.ancestors();
|
||||||
|
if (anc.length > 0) return ['/browse', anc[anc.length - 1].id];
|
||||||
|
return ['/browse'];
|
||||||
|
});
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.route.paramMap
|
this.route.paramMap
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
@@ -241,65 +341,65 @@ export class BrowseComponent implements OnInit {
|
|||||||
|
|
||||||
openCreate() {
|
openCreate() {
|
||||||
this.formMode.set('create');
|
this.formMode.set('create');
|
||||||
this.editingCategoryId.set(null);
|
this.editingCategory.set(null);
|
||||||
this.formName.set('');
|
|
||||||
this.formDescription.set('');
|
|
||||||
this.formError.set(null);
|
|
||||||
this.showFormModal.set(true);
|
this.showFormModal.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
openEdit(cat: CategoryWithCounts) {
|
openEdit(cat: Category) {
|
||||||
this.formMode.set('edit');
|
this.formMode.set('edit');
|
||||||
this.editingCategoryId.set(cat.id);
|
this.editingCategory.set(cat);
|
||||||
this.formName.set(cat.name);
|
|
||||||
this.formDescription.set(cat.description ?? '');
|
|
||||||
this.formError.set(null);
|
|
||||||
this.showFormModal.set(true);
|
this.showFormModal.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeModal() {
|
openDelete(cat: CategoryWithCounts) {
|
||||||
|
this.deletingCategory.set(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFormSaved() {
|
||||||
this.showFormModal.set(false);
|
this.showFormModal.set(false);
|
||||||
|
this.loadBrowse();
|
||||||
}
|
}
|
||||||
|
|
||||||
submitForm() {
|
onDeleteConfirmed() {
|
||||||
if (!this.formName().trim()) {
|
this.deletingCategory.set(null);
|
||||||
this.formError.set('Le nom est obligatoire.');
|
this.loadBrowse();
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
onSearchChange(value: string) {
|
||||||
|
this.searchQuery.set(value);
|
||||||
|
this.searchPage.set(1);
|
||||||
|
if (this.searchDebounce) clearTimeout(this.searchDebounce);
|
||||||
|
if (value.trim()) {
|
||||||
|
this.searchLoading.set(true);
|
||||||
|
this.searchDebounce = setTimeout(() => this.loadSearch(), 400);
|
||||||
|
} else {
|
||||||
|
this.searchLoading.set(false);
|
||||||
|
this.searchCategoryResults.set([]);
|
||||||
}
|
}
|
||||||
this.formLoading.set(true);
|
}
|
||||||
const name = this.formName().trim();
|
|
||||||
const description = this.formDescription().trim() || undefined;
|
|
||||||
|
|
||||||
const obs =
|
loadSearch() {
|
||||||
this.formMode() === 'create'
|
this.searchLoading.set(true);
|
||||||
? this.categoryService.create({
|
const raw = this.searchQuery();
|
||||||
name,
|
|
||||||
description,
|
|
||||||
parentId: this.categoryId() ?? undefined,
|
|
||||||
})
|
|
||||||
: this.categoryService.update(this.editingCategoryId()!, { name, description });
|
|
||||||
|
|
||||||
obs.subscribe({
|
forkJoin({
|
||||||
next: () => {
|
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
|
||||||
this.formLoading.set(false);
|
categories: this.categoryService.list(raw || undefined),
|
||||||
this.showFormModal.set(false);
|
}).subscribe({
|
||||||
this.loadBrowse();
|
next: ({ apis, categories }) => {
|
||||||
},
|
this.searchResults.set(apis.items);
|
||||||
error: (err: { error?: { message?: string } }) => {
|
this.searchTotal.set(apis.total);
|
||||||
this.formLoading.set(false);
|
this.searchCategoryResults.set(categories.items);
|
||||||
this.formError.set(err?.error?.message ?? 'Une erreur est survenue.');
|
this.searchLoading.set(false);
|
||||||
},
|
},
|
||||||
|
error: () => this.searchLoading.set(false),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteCategory(cat: CategoryWithCounts) {
|
setSearchPage(p: number) {
|
||||||
if (cat.apiCount > 0 || cat.subcategoryCount > 0) return;
|
if (p < 1 || p > this.searchTotalPages()) return;
|
||||||
if (!confirm(`Supprimer le dossier "${cat.name}" ?`)) return;
|
this.searchPage.set(p);
|
||||||
this.categoryService.delete(cat.id).subscribe({
|
this.loadSearch();
|
||||||
next: () => this.loadBrowse(),
|
|
||||||
error: (err: { error?: { message?: string } }) =>
|
|
||||||
alert(err?.error?.message ?? 'Erreur lors de la suppression.'),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
typeBadgeClass(type: string): string {
|
typeBadgeClass(type: string): string {
|
||||||
@@ -308,9 +408,4 @@ export class BrowseComponent implements OnInit {
|
|||||||
: 'fr-badge fr-badge--sm fr-badge--blue-ecume';
|
: 'fr-badge fr-badge--sm fr-badge--blue-ecume';
|
||||||
}
|
}
|
||||||
|
|
||||||
statusBadgeClass(status: string): string {
|
|
||||||
if (status === 'GENERATED') return 'fr-badge fr-badge--sm fr-badge--success';
|
|
||||||
if (status === 'ERROR') return 'fr-badge fr-badge--sm fr-badge--error';
|
|
||||||
return 'fr-badge fr-badge--sm fr-badge--info';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared';
|
|||||||
id="search-input"
|
id="search-input"
|
||||||
class="fr-input"
|
class="fr-input"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Rechercher par titre ou description..."
|
placeholder="Ex : auth async v1.0, openapi latest..."
|
||||||
[ngModel]="search()"
|
[ngModel]="search()"
|
||||||
(ngModelChange)="onSearchChange($event)"
|
(ngModelChange)="onSearchChange($event)"
|
||||||
/>
|
/>
|
||||||
@@ -208,6 +208,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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { 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 } 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 { Category } from '@datacat/shared';
|
import { ApiEntry, ApiEntryListItem, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-upload',
|
selector: 'app-upload',
|
||||||
@@ -31,17 +32,32 @@ import { Category } from '@datacat/shared';
|
|||||||
|
|
||||||
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
|
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
|
||||||
|
|
||||||
<!-- Stepper -->
|
<!-- Stepper numéroté -->
|
||||||
<div class="fr-stepper fr-mb-4w">
|
<div class="fr-mb-4w" style="display:flex;align-items:flex-start;">
|
||||||
<h2 class="fr-stepper__title">
|
@for (title of stepTitles; track $index) {
|
||||||
{{ stepTitles[step() - 1] }}
|
<div style="display:flex;flex-direction:column;align-items:center;flex:none;min-width:6rem;">
|
||||||
<span class="fr-stepper__state">Étape {{ step() }} sur 3</span>
|
<div
|
||||||
</h2>
|
[style.background]="step() > $index + 1 ? '#1f8d49' : step() === $index + 1 ? '#000091' : '#e5e5e5'"
|
||||||
<div
|
[style.color]="step() >= $index + 1 ? 'white' : '#666'"
|
||||||
class="fr-stepper__steps"
|
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;">
|
||||||
[attr.data-fr-current-step]="step()"
|
@if (step() > $index + 1) {
|
||||||
data-fr-steps="3"
|
<span class="fr-icon-check-line" aria-hidden="true" style="font-size:0.875rem"></span>
|
||||||
></div>
|
} @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>
|
</div>
|
||||||
|
|
||||||
<!-- Étape 1 : Fichier(s) YAML -->
|
<!-- Étape 1 : Fichier(s) YAML -->
|
||||||
@@ -85,6 +101,26 @@ import { Category } from '@datacat/shared';
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</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">
|
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||||
@@ -99,56 +135,64 @@ import { Category } from '@datacat/shared';
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Étape 2 : Métadonnées -->
|
<!-- Étape 2 : Informations -->
|
||||||
@if (step() === 2) {
|
@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>
|
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
|
||||||
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="titleInvalid()">
|
|
||||||
<label class="fr-label" for="title-input">
|
|
||||||
Titre <span class="fr-hint-text">Obligatoire</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="title-input"
|
|
||||||
class="fr-input"
|
|
||||||
[class.fr-input--error]="titleInvalid()"
|
|
||||||
formControlName="title"
|
|
||||||
type="text"
|
|
||||||
placeholder="Nom de l'API"
|
|
||||||
/>
|
|
||||||
@if (titleInvalid()) {
|
|
||||||
<p class="fr-error-text">Le titre est obligatoire.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="versionInvalid()">
|
<!-- Titre : convention + nom -->
|
||||||
<label class="fr-label" for="version-input">
|
<p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
|
||||||
Version <span class="fr-hint-text">Obligatoire</span>
|
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
|
||||||
</label>
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
<input
|
<div class="fr-select-group" [class.fr-select-group--error]="conventionInvalid()">
|
||||||
id="version-input"
|
<label class="fr-label" for="convention-input">Convention</label>
|
||||||
class="fr-input"
|
<select
|
||||||
[class.fr-input--error]="versionInvalid()"
|
id="convention-input"
|
||||||
formControlName="version"
|
class="fr-select"
|
||||||
type="text"
|
[class.fr-select--error]="conventionInvalid()"
|
||||||
placeholder="1.0.0"
|
formControlName="convention"
|
||||||
/>
|
>
|
||||||
@if (versionInvalid()) {
|
<option value="CONSULTER">Consulter</option>
|
||||||
<p class="fr-error-text">La version est obligatoire.</p>
|
<option value="ENREGISTRER">Enregistrer</option>
|
||||||
}
|
<option value="ETRE_NOTIFIE">Être notifié</option>
|
||||||
</div>
|
</select>
|
||||||
|
@if (conventionInvalid()) {
|
||||||
<div class="fr-select-group fr-mb-3w">
|
<p class="fr-error-text">La convention est obligatoire.</p>
|
||||||
<label class="fr-label" for="type-input">
|
}
|
||||||
Type <span class="fr-hint-text">Obligatoire</span>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
<select id="type-input" class="fr-select" formControlName="type">
|
<div class="fr-col-12 fr-col-md-8">
|
||||||
<option value="ASYNCAPI">AsyncAPI</option>
|
<div class="fr-input-group" [class.fr-input-group--error]="nameInvalid()">
|
||||||
<option value="OPENAPI">OpenAPI</option>
|
<label class="fr-label" for="name-input">Nom de l'API</label>
|
||||||
</select>
|
<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>
|
</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">
|
<div class="fr-select-group fr-mb-3w">
|
||||||
<label class="fr-label" for="category-input">
|
<label class="fr-label" for="category-input">
|
||||||
Catégorie <span class="fr-hint-text">Optionnel</span>
|
Catégorie
|
||||||
</label>
|
</label>
|
||||||
<select id="category-input" class="fr-select" formControlName="categoryId">
|
<select id="category-input" class="fr-select" formControlName="categoryId">
|
||||||
<option value="">— Sans catégorie —</option>
|
<option value="">— Sans catégorie —</option>
|
||||||
@@ -158,9 +202,42 @@ import { Category } from '@datacat/shared';
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="fr-input-group fr-mb-3w">
|
||||||
<label class="fr-label" for="description-input">
|
<label class="fr-label" for="description-input">
|
||||||
Description <span class="fr-hint-text">Optionnel</span>
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description-input"
|
id="description-input"
|
||||||
@@ -171,86 +248,65 @@ import { Category } from '@datacat/shared';
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fr-input-group fr-mb-3w">
|
<!-- Version X.Y.Z -->
|
||||||
<label class="fr-label" for="functional-doc-input">
|
<p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
|
||||||
Documentation fonctionnelle <span class="fr-hint-text">Optionnel</span>
|
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
|
||||||
</label>
|
<div class="fr-col-4">
|
||||||
<textarea
|
<div class="fr-input-group" [class.fr-input-group--error]="versionMajorInvalid()">
|
||||||
id="functional-doc-input"
|
<label class="fr-label" for="version-major-input">Majeure (X)</label>
|
||||||
class="fr-input"
|
<input
|
||||||
formControlName="functionalDoc"
|
id="version-major-input"
|
||||||
rows="4"
|
class="fr-input"
|
||||||
placeholder="Cas d'usage, contexte métier..."
|
[class.fr-input--error]="versionMajorInvalid()"
|
||||||
></textarea>
|
formControlName="versionMajor"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-4">
|
||||||
|
<div class="fr-input-group" [class.fr-input-group--error]="versionMinorInvalid()">
|
||||||
|
<label class="fr-label" for="version-minor-input">Mineure (Y)</label>
|
||||||
|
<input
|
||||||
|
id="version-minor-input"
|
||||||
|
class="fr-input"
|
||||||
|
[class.fr-input--error]="versionMinorInvalid()"
|
||||||
|
formControlName="versionMinor"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-4">
|
||||||
|
<div class="fr-input-group" [class.fr-input-group--error]="versionPatchInvalid()">
|
||||||
|
<label class="fr-label" for="version-patch-input">Correctif (Z)</label>
|
||||||
|
<input
|
||||||
|
id="version-patch-input"
|
||||||
|
class="fr-input"
|
||||||
|
[class.fr-input--error]="versionPatchInvalid()"
|
||||||
|
formControlName="versionPatch"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fr-input-group fr-mb-3w">
|
@if (versionAlreadyExists()) {
|
||||||
<label class="fr-label" for="technical-doc-input">
|
<div class="fr-alert fr-alert--warning fr-mb-3w">
|
||||||
Documentation technique <span class="fr-hint-text">Optionnel</span>
|
<p class="fr-alert__title">Version déjà existante</p>
|
||||||
</label>
|
<p>
|
||||||
<textarea
|
La version {{ formValues().versionMajor }}.{{ formValues().versionMinor }}.{{ formValues().versionPatch }}
|
||||||
id="technical-doc-input"
|
existe déjà pour cette API. Choisissez un numéro de version différent.
|
||||||
class="fr-input"
|
</p>
|
||||||
formControlName="technicalDoc"
|
</div>
|
||||||
rows="4"
|
}
|
||||||
placeholder="Guide d'intégration technique..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fr-input-group fr-mb-3w">
|
|
||||||
<label class="fr-label" for="external-doc-url-input">
|
|
||||||
URL documentation externe <span class="fr-hint-text">Optionnel</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="external-doc-url-input"
|
|
||||||
class="fr-input"
|
|
||||||
formControlName="externalDocUrl"
|
|
||||||
type="text"
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fr-input-group fr-mb-3w">
|
|
||||||
<label class="fr-label" for="contact-functional-input">
|
|
||||||
Contact fonctionnel <span class="fr-hint-text">Optionnel</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contact-functional-input"
|
|
||||||
class="fr-input"
|
|
||||||
formControlName="contactFunctional"
|
|
||||||
type="text"
|
|
||||||
placeholder="Nom, email, téléphone..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fr-input-group fr-mb-3w">
|
|
||||||
<label class="fr-label" for="contact-technical-input">
|
|
||||||
Contact technique <span class="fr-hint-text">Optionnel</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contact-technical-input"
|
|
||||||
class="fr-input"
|
|
||||||
formControlName="contactTechnical"
|
|
||||||
type="text"
|
|
||||||
placeholder="Nom, email, téléphone..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fr-input-group fr-mb-4w">
|
|
||||||
<label class="fr-label" for="access-rights-input">
|
|
||||||
Droits d'accès <span class="fr-hint-text">Optionnel</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="access-rights-input"
|
|
||||||
class="fr-input"
|
|
||||||
formControlName="accessRights"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Processus d'habilitation, conditions d'accès..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||||
<button type="submit" class="fr-btn" [disabled]="metaForm.invalid">Suivant</button>
|
<button type="submit" class="fr-btn" [disabled]="versionAlreadyExists()">Suivant</button>
|
||||||
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
|
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
|
||||||
Retour
|
Retour
|
||||||
</button>
|
</button>
|
||||||
@@ -258,8 +314,97 @@ import { Category } from '@datacat/shared';
|
|||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Étape 3 : Confirmation -->
|
<!-- Étape 3 : Contacts -->
|
||||||
@if (step() === 3) {
|
@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 fr-mb-4w">
|
||||||
<div class="fr-card__body">
|
<div class="fr-card__body">
|
||||||
<div class="fr-card__content">
|
<div class="fr-card__content">
|
||||||
@@ -268,7 +413,9 @@ import { Category } from '@datacat/shared';
|
|||||||
<div class="fr-col-12 fr-col-md-6">
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
|
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
|
||||||
<dd>
|
<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 }}
|
{{ selectedFiles()[0].name }}
|
||||||
} @else {
|
} @else {
|
||||||
@for (f of selectedFiles(); track f.name) {
|
@for (f of selectedFiles(); track f.name) {
|
||||||
@@ -284,15 +431,19 @@ import { Category } from '@datacat/shared';
|
|||||||
</div>
|
</div>
|
||||||
<div class="fr-col-12 fr-col-md-6">
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
<dt class="fr-text--bold">Type</dt>
|
<dt class="fr-text--bold">Type</dt>
|
||||||
<dd>{{ metaForm.value.type }}</dd>
|
<dd>{{ displayType() }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="fr-col-12 fr-col-md-6">
|
<div class="fr-col-12">
|
||||||
<dt class="fr-text--bold">Titre</dt>
|
<dt class="fr-text--bold">Titre</dt>
|
||||||
<dd>{{ metaForm.value.title }}</dd>
|
<dd>{{ previewTitle() }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="fr-col-12 fr-col-md-6">
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
<dt class="fr-text--bold">Version</dt>
|
<dt class="fr-text--bold">Version</dt>
|
||||||
<dd>{{ metaForm.value.version }}</dd>
|
<dd>{{ metaForm.value.versionMajor }}.{{ metaForm.value.versionMinor }}.{{ metaForm.value.versionPatch }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
|
<dt class="fr-text--bold">Fournisseur</dt>
|
||||||
|
<dd>{{ metaForm.value.provider }}</dd>
|
||||||
</div>
|
</div>
|
||||||
@if (metaForm.value.categoryId) {
|
@if (metaForm.value.categoryId) {
|
||||||
<div class="fr-col-12 fr-col-md-6">
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
@@ -306,6 +457,22 @@ import { Category } from '@datacat/shared';
|
|||||||
<dd>{{ metaForm.value.description }}</dd>
|
<dd>{{ metaForm.value.description }}</dd>
|
||||||
</div>
|
</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,7 +493,7 @@ import { Category } from '@datacat/shared';
|
|||||||
>
|
>
|
||||||
{{ uploading() ? 'Import en cours...' : 'Importer' }}
|
{{ uploading() ? 'Import en cours...' : 'Importer' }}
|
||||||
</button>
|
</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
|
Retour
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,6 +506,7 @@ export class UploadComponent implements OnInit {
|
|||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
private categoryService = inject(CategoryService);
|
private categoryService = inject(CategoryService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
step = signal(1);
|
step = signal(1);
|
||||||
selectedFiles = signal<File[]>([]);
|
selectedFiles = signal<File[]>([]);
|
||||||
@@ -347,50 +515,162 @@ export class UploadComponent implements OnInit {
|
|||||||
fileError = signal<string | null>(null);
|
fileError = signal<string | null>(null);
|
||||||
uploading = signal(false);
|
uploading = signal(false);
|
||||||
uploadError = signal<string | null>(null);
|
uploadError = signal<string | null>(null);
|
||||||
|
validating = signal(false);
|
||||||
|
validationDone = signal(false);
|
||||||
|
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({
|
||||||
title: ['', Validators.required],
|
convention: ['CONSULTER', Validators.required],
|
||||||
version: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
|
provider: ['', Validators.required],
|
||||||
|
versionMajor: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
versionMinor: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
versionPatch: [0, [Validators.required, Validators.min(0)]],
|
||||||
type: ['ASYNCAPI', Validators.required],
|
type: ['ASYNCAPI', Validators.required],
|
||||||
categoryId: [''],
|
categoryId: [''],
|
||||||
description: [''],
|
description: [''],
|
||||||
functionalDoc: [''],
|
contactFunctionalName: ['', Validators.required],
|
||||||
technicalDoc: [''],
|
contactFunctionalEntity: ['', Validators.required],
|
||||||
externalDocUrl: [''],
|
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
|
||||||
contactFunctional: [''],
|
contactTechnicalName: ['', Validators.required],
|
||||||
contactTechnical: [''],
|
contactTechnicalEntity: ['', Validators.required],
|
||||||
accessRights: [''],
|
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
|
||||||
|
private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
|
||||||
|
|
||||||
|
previewTitle = computed(() => {
|
||||||
|
const values = this.formValues();
|
||||||
|
const origin = this.fromApi();
|
||||||
|
/* Quand convention/name sont désactivés, ils sont absents de valueChanges → fallback sur fromApi */
|
||||||
|
const convention = (values.convention ?? origin?.convention ?? 'CONSULTER') as ApiConvention;
|
||||||
|
const name = values.name ?? origin?.name ?? '';
|
||||||
|
return `${API_CONVENTION_LABELS[convention]} ${name}`.trim();
|
||||||
});
|
});
|
||||||
|
|
||||||
titleInvalid = computed(
|
|
||||||
() => !!(this.metaForm.get('title')?.invalid && this.metaForm.get('title')?.touched),
|
|
||||||
);
|
|
||||||
versionInvalid = computed(
|
|
||||||
() => !!(this.metaForm.get('version')?.invalid && this.metaForm.get('version')?.touched),
|
|
||||||
);
|
|
||||||
selectedCategoryName = computed(() => {
|
selectedCategoryName = computed(() => {
|
||||||
const id = this.metaForm.value.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 ?? '';
|
||||||
});
|
});
|
||||||
canProceedFromStep1 = computed(() => {
|
|
||||||
if (this.selectedFiles().length === 0) return false;
|
/* Vérifie si la version saisie existe déjà parmi les versions de l'API d'origine */
|
||||||
if (this.selectedFiles().length === 1) return true;
|
versionAlreadyExists = computed(() => {
|
||||||
/* Plusieurs fichiers : un fichier principal doit être détecté */
|
const versions = this.existingVersions();
|
||||||
return this.detectedMainFilename() !== null;
|
if (versions.length === 0) return false;
|
||||||
|
const values = this.formValues();
|
||||||
|
const candidate = `${values.versionMajor ?? 0}.${values.versionMinor ?? 0}.${values.versionPatch ?? 0}`;
|
||||||
|
return versions.some((v) => v.version === candidate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Type effectif : getRawValue() inclut les champs désactivés */
|
||||||
|
displayType = computed(() => {
|
||||||
|
const origin = this.fromApi();
|
||||||
|
if (origin) return origin.type;
|
||||||
|
return (this.formValues().type ?? null) as 'ASYNCAPI' | 'OPENAPI' | null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Méthodes de validation (pas de computed — la re-exécution est déclenchée par les événements DOM blur/input) */
|
||||||
|
conventionInvalid(): boolean {
|
||||||
|
return !!(this.metaForm.get('convention')?.invalid && this.metaForm.get('convention')?.touched);
|
||||||
|
}
|
||||||
|
nameInvalid(): boolean {
|
||||||
|
return !!(this.metaForm.get('name')?.invalid && this.metaForm.get('name')?.touched);
|
||||||
|
}
|
||||||
|
providerInvalid(): boolean {
|
||||||
|
return !!(this.metaForm.get('provider')?.invalid && this.metaForm.get('provider')?.touched);
|
||||||
|
}
|
||||||
|
versionMajorInvalid(): boolean {
|
||||||
|
return !!(this.metaForm.get('versionMajor')?.invalid && this.metaForm.get('versionMajor')?.touched);
|
||||||
|
}
|
||||||
|
versionMinorInvalid(): boolean {
|
||||||
|
return !!(this.metaForm.get('versionMinor')?.invalid && this.metaForm.get('versionMinor')?.touched);
|
||||||
|
}
|
||||||
|
versionPatchInvalid(): boolean {
|
||||||
|
return !!(this.metaForm.get('versionPatch')?.invalid && this.metaForm.get('versionPatch')?.touched);
|
||||||
|
}
|
||||||
|
canProceedFromStep1 = computed(() => {
|
||||||
|
if (this.selectedFiles().length === 0) return true; // fichier optionnel
|
||||||
|
if (this.validating()) return false;
|
||||||
|
return this.validationDone() && this.validationErrors().length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldInvalid(name: string): boolean {
|
||||||
|
const ctrl = this.metaForm.get(name);
|
||||||
|
return !!(ctrl?.invalid && ctrl?.touched);
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
const params = this.route.snapshot.queryParamMap;
|
||||||
|
const preselectedCategoryId = params.get('categoryId');
|
||||||
|
const fromId = params.get('from');
|
||||||
|
|
||||||
this.categoryService.list().subscribe({
|
this.categoryService.list().subscribe({
|
||||||
next: (res) => this.categories.set(res.items),
|
next: (res) => {
|
||||||
|
this.categories.set(res.items);
|
||||||
|
if (preselectedCategoryId) {
|
||||||
|
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (fromId) {
|
||||||
|
this.apiService.get(fromId).subscribe({
|
||||||
|
next: (api) => this.prefillFromApi(api),
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
this.apiService.getVersions(fromId).subscribe({
|
||||||
|
next: (versions) => this.existingVersions.set(versions),
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private prefillFromApi(api: ApiEntry): void {
|
||||||
|
this.fromApi.set(api);
|
||||||
|
this.metaForm.patchValue({
|
||||||
|
convention: api.convention,
|
||||||
|
name: api.name,
|
||||||
|
provider: api.provider,
|
||||||
|
versionMajor: api.versionMajor,
|
||||||
|
versionMinor: api.versionMinor,
|
||||||
|
versionPatch: api.versionPatch + 1,
|
||||||
|
description: api.description ?? '',
|
||||||
|
categoryId: api.categoryId ?? '',
|
||||||
|
contactFunctionalName: api.contactFunctionalName,
|
||||||
|
contactFunctionalEntity: api.contactFunctionalEntity,
|
||||||
|
contactFunctionalEmail: api.contactFunctionalEmail,
|
||||||
|
contactTechnicalName: api.contactTechnicalName,
|
||||||
|
contactTechnicalEntity: api.contactTechnicalEntity,
|
||||||
|
contactTechnicalEmail: api.contactTechnicalEmail,
|
||||||
|
});
|
||||||
|
/* Verrouiller les champs qui définissent l'identité de l'API */
|
||||||
|
this.metaForm.get('convention')?.disable();
|
||||||
|
this.metaForm.get('name')?.disable();
|
||||||
|
this.metaForm.get('type')?.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryLabel(cat: Category): string {
|
categoryLabel(cat: Category): string {
|
||||||
@@ -401,8 +681,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.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 input = event.target as HTMLInputElement;
|
||||||
const fileList = input.files;
|
const fileList = input.files;
|
||||||
@@ -423,6 +710,9 @@ export class UploadComponent implements OnInit {
|
|||||||
|
|
||||||
/* Détection côté client du fichier principal (pré-remplissage du type) */
|
/* Détection côté client du fichier principal (pré-remplissage du type) */
|
||||||
void this.detectMainFileClient(files);
|
void this.detectMainFileClient(files);
|
||||||
|
|
||||||
|
/* Validation serveur immédiate */
|
||||||
|
this.runValidation(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
goToStep2() {
|
goToStep2() {
|
||||||
@@ -431,51 +721,110 @@ export class UploadComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
goToStep3() {
|
goToStep3() {
|
||||||
if (this.metaForm.invalid) {
|
const invalid = this.INFO_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
|
||||||
this.metaForm.markAllAsTouched();
|
if (invalid) {
|
||||||
|
this.INFO_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.versionAlreadyExists()) return;
|
||||||
this.step.set(3);
|
this.step.set(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit() {
|
goToStep4() {
|
||||||
const files = this.selectedFiles();
|
const invalid = this.CONTACT_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
|
||||||
if (files.length === 0 || this.metaForm.invalid) return;
|
if (invalid) {
|
||||||
|
this.CONTACT_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.step.set(4);
|
||||||
|
}
|
||||||
|
|
||||||
this.uploading.set(true);
|
private runValidation(files: File[]): void {
|
||||||
this.uploadError.set(null);
|
this.validating.set(true);
|
||||||
|
this.validationDone.set(false);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
formData.append('files', f);
|
formData.append('files', f);
|
||||||
}
|
}
|
||||||
formData.append('title', this.metaForm.value.title ?? '');
|
|
||||||
formData.append('version', this.metaForm.value.version ?? '');
|
this.apiService.validate(formData).subscribe({
|
||||||
formData.append('type', this.metaForm.value.type ?? 'ASYNCAPI');
|
next: (result) => {
|
||||||
if (this.metaForm.value.categoryId) {
|
this.validating.set(false);
|
||||||
formData.append('categoryId', this.metaForm.value.categoryId);
|
this.validationDone.set(true);
|
||||||
|
if (!result.valid) {
|
||||||
|
this.validationErrors.set(result.errors);
|
||||||
|
/* Ne pas réactiver le type s'il est verrouillé par fromApi */
|
||||||
|
if (!this.fromApi()) {
|
||||||
|
this.metaForm.get('type')?.enable();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.validationErrors.set([]);
|
||||||
|
if (result.type) {
|
||||||
|
this.detectedType.set(result.type);
|
||||||
|
/* Ne pas modifier le type s'il est verrouillé par fromApi */
|
||||||
|
if (!this.fromApi()) {
|
||||||
|
this.metaForm.patchValue({ type: result.type });
|
||||||
|
this.metaForm.get('type')?.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.mainFile) {
|
||||||
|
this.detectedMainFilename.set(result.mainFile);
|
||||||
|
}
|
||||||
|
/* Pré-remplissage du formulaire depuis les métadonnées du fichier */
|
||||||
|
/* Ne pas écraser le nom s'il est verrouillé par fromApi */
|
||||||
|
if (result.name && !this.fromApi()) this.metaForm.patchValue({ name: result.name });
|
||||||
|
if (result.versionMajor !== null) this.metaForm.patchValue({ versionMajor: result.versionMajor });
|
||||||
|
if (result.versionMinor !== null) this.metaForm.patchValue({ versionMinor: result.versionMinor });
|
||||||
|
if (result.versionPatch !== null) this.metaForm.patchValue({ versionPatch: result.versionPatch });
|
||||||
|
if (result.description) this.metaForm.patchValue({ description: result.description });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.validating.set(false);
|
||||||
|
this.validationDone.set(true);
|
||||||
|
this.validationErrors.set([{ file: '', message: err?.error?.message ?? 'Erreur de validation.' }]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
const files = this.selectedFiles();
|
||||||
|
this.metaForm.markAllAsTouched();
|
||||||
|
if (this.metaForm.invalid) {
|
||||||
|
/* Naviguer vers l'étape avec les erreurs pour les rendre visibles */
|
||||||
|
const infoInvalid = this.INFO_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
|
||||||
|
this.step.set(infoInvalid ? 2 : 3);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (this.metaForm.value.description) {
|
|
||||||
formData.append('description', this.metaForm.value.description);
|
this.uploading.set(true);
|
||||||
|
this.uploadError.set(null);
|
||||||
|
|
||||||
|
const raw = this.metaForm.getRawValue();
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const f of files) {
|
||||||
|
formData.append('files', f);
|
||||||
}
|
}
|
||||||
if (this.metaForm.value.functionalDoc) {
|
formData.append('convention', raw.convention ?? 'CONSULTER');
|
||||||
formData.append('functionalDoc', this.metaForm.value.functionalDoc);
|
formData.append('name', raw.name ?? '');
|
||||||
|
formData.append('provider', raw.provider ?? '');
|
||||||
|
formData.append('versionMajor', String(raw.versionMajor ?? 0));
|
||||||
|
formData.append('versionMinor', String(raw.versionMinor ?? 0));
|
||||||
|
formData.append('versionPatch', String(raw.versionPatch ?? 0));
|
||||||
|
formData.append('type', raw.type ?? 'ASYNCAPI');
|
||||||
|
if (raw.categoryId) {
|
||||||
|
formData.append('categoryId', raw.categoryId);
|
||||||
}
|
}
|
||||||
if (this.metaForm.value.technicalDoc) {
|
if (raw.description) {
|
||||||
formData.append('technicalDoc', this.metaForm.value.technicalDoc);
|
formData.append('description', raw.description);
|
||||||
}
|
|
||||||
if (this.metaForm.value.externalDocUrl) {
|
|
||||||
formData.append('externalDocUrl', this.metaForm.value.externalDocUrl);
|
|
||||||
}
|
|
||||||
if (this.metaForm.value.contactFunctional) {
|
|
||||||
formData.append('contactFunctional', this.metaForm.value.contactFunctional);
|
|
||||||
}
|
|
||||||
if (this.metaForm.value.contactTechnical) {
|
|
||||||
formData.append('contactTechnical', this.metaForm.value.contactTechnical);
|
|
||||||
}
|
|
||||||
if (this.metaForm.value.accessRights) {
|
|
||||||
formData.append('accessRights', this.metaForm.value.accessRights);
|
|
||||||
}
|
}
|
||||||
|
formData.append('contactFunctionalName', raw.contactFunctionalName ?? '');
|
||||||
|
formData.append('contactFunctionalEntity', raw.contactFunctionalEntity ?? '');
|
||||||
|
formData.append('contactFunctionalEmail', raw.contactFunctionalEmail ?? '');
|
||||||
|
formData.append('contactTechnicalName', raw.contactTechnicalName ?? '');
|
||||||
|
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
|
||||||
|
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
|
||||||
|
|
||||||
this.apiService.upload(formData).subscribe({
|
this.apiService.upload(formData).subscribe({
|
||||||
next: (entry) => {
|
next: (entry) => {
|
||||||
@@ -495,13 +844,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,79 @@
|
|||||||
|
import { Component, signal, inject, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CategoryService } from '../../core/category.service';
|
||||||
|
import { CategoryWithCounts } from '@datacat/shared';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-category-delete-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
|
||||||
|
role="dialog" aria-modal="true" aria-labelledby="delete-modal-title">
|
||||||
|
<div style="background:white;width:calc(100% - 2rem);max-width:480px;margin:0 1rem 2rem">
|
||||||
|
<div class="fr-p-4w">
|
||||||
|
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
||||||
|
<div class="fr-col">
|
||||||
|
<h2 id="delete-modal-title" class="fr-h3 fr-mb-0">Supprimer le dossier</h2>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-auto">
|
||||||
|
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
|
||||||
|
(click)="cancel()">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="fr-text--lead fr-mb-1w">
|
||||||
|
Êtes-vous sûr de vouloir supprimer le dossier
|
||||||
|
<strong>« {{ category().name }} »</strong> ?
|
||||||
|
</p>
|
||||||
|
<p class="fr-text--sm fr-text--mention-grey fr-mb-3w">
|
||||||
|
Cette action est irréversible.
|
||||||
|
</p>
|
||||||
|
@if (deleteError()) {
|
||||||
|
<div class="fr-alert fr-alert--error fr-mb-2w">
|
||||||
|
<p>{{ deleteError() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
|
||||||
|
<button class="fr-btn fr-btn--secondary" (click)="cancel()"
|
||||||
|
[disabled]="deleteLoading()">Annuler</button>
|
||||||
|
<button class="fr-btn fr-btn--error" (click)="confirmDelete()"
|
||||||
|
[disabled]="deleteLoading()">
|
||||||
|
{{ deleteLoading() ? 'Suppression...' : 'Supprimer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class CategoryDeleteModalComponent {
|
||||||
|
private categoryService = inject(CategoryService);
|
||||||
|
|
||||||
|
category = input.required<CategoryWithCounts>();
|
||||||
|
confirmed = output<void>();
|
||||||
|
cancelled = output<void>();
|
||||||
|
|
||||||
|
deleteLoading = signal(false);
|
||||||
|
deleteError = signal<string | null>(null);
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.cancelled.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete() {
|
||||||
|
const cat = this.category();
|
||||||
|
this.deleteLoading.set(true);
|
||||||
|
this.deleteError.set(null);
|
||||||
|
this.categoryService.delete(cat.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.deleteLoading.set(false);
|
||||||
|
this.confirmed.emit();
|
||||||
|
},
|
||||||
|
error: (err: { error?: { message?: string } }) => {
|
||||||
|
this.deleteLoading.set(false);
|
||||||
|
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CategoryService } from '../../core/category.service';
|
||||||
|
import { Category } from '@datacat/shared';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-category-form-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
|
||||||
|
role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
|
<div style="background:white;width:calc(100% - 2rem);max-width:540px;margin:0 1rem 2rem">
|
||||||
|
<div class="fr-p-4w">
|
||||||
|
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
||||||
|
<div class="fr-col">
|
||||||
|
<h2 id="modal-title" class="fr-h3 fr-mb-0">
|
||||||
|
{{ mode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-auto">
|
||||||
|
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
|
||||||
|
(click)="close()">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (formError()) {
|
||||||
|
<div class="fr-alert fr-alert--error fr-mb-2w">
|
||||||
|
<p>{{ formError() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (mode() === 'create') {
|
||||||
|
<div class="fr-select-group fr-mb-2w">
|
||||||
|
<label class="fr-label" for="form-parent">Emplacement</label>
|
||||||
|
<select id="form-parent" class="fr-select"
|
||||||
|
(change)="formParentId.set($any($event.target).value || null)">
|
||||||
|
<option value="" [selected]="!formParentId()"></option>
|
||||||
|
@for (opt of categoriesForSelect(); track opt.id) {
|
||||||
|
<option [value]="opt.id" [selected]="opt.id === formParentId()">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="formNameError()">
|
||||||
|
<label class="fr-label" for="form-name">
|
||||||
|
Nom <span style="color:var(--text-default-error)">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="form-name" class="fr-input" [class.fr-input--error]="formNameError()" type="text"
|
||||||
|
[value]="formName()"
|
||||||
|
(input)="formName.set($any($event.target).value); formNameError.set(false)"
|
||||||
|
(blur)="formNameError.set(!formName().trim())" />
|
||||||
|
@if (formNameError()) { <p class="fr-error-text">Le nom est obligatoire.</p> }
|
||||||
|
</div>
|
||||||
|
<div class="fr-input-group fr-mb-3w">
|
||||||
|
<label class="fr-label" for="form-desc">Description</label>
|
||||||
|
<textarea id="form-desc" class="fr-input" rows="3"
|
||||||
|
[value]="formDescription()"
|
||||||
|
(input)="formDescription.set($any($event.target).value)"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
|
||||||
|
<button class="fr-btn fr-btn--secondary" (click)="close()">Annuler</button>
|
||||||
|
<button class="fr-btn" (click)="submit()" [disabled]="formLoading()">
|
||||||
|
{{ mode() === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class CategoryFormModalComponent implements OnInit {
|
||||||
|
private categoryService = inject(CategoryService);
|
||||||
|
|
||||||
|
mode = input<'create' | 'edit'>('create');
|
||||||
|
category = input<Category | null>(null);
|
||||||
|
parentId = input<string | null>(null);
|
||||||
|
saved = output<void>();
|
||||||
|
closed = output<void>();
|
||||||
|
|
||||||
|
formName = signal('');
|
||||||
|
formDescription = signal('');
|
||||||
|
formParentId = signal<string | null>(null);
|
||||||
|
formError = signal<string | null>(null);
|
||||||
|
formNameError = signal(false);
|
||||||
|
formLoading = signal(false);
|
||||||
|
allCategories = signal<Category[]>([]);
|
||||||
|
editingCategoryId = signal<string | null>(null);
|
||||||
|
|
||||||
|
categoriesForSelect = computed<{ id: string; label: string }[]>(() => {
|
||||||
|
const cats = this.allCategories();
|
||||||
|
const map = new Map(cats.map((c) => [c.id, c]));
|
||||||
|
|
||||||
|
const getSortPath = (id: string, depth = 0): string => {
|
||||||
|
if (depth > 10) return '';
|
||||||
|
const cat = map.get(id);
|
||||||
|
if (!cat) return '';
|
||||||
|
if (!cat.parentId) return cat.name;
|
||||||
|
return `${getSortPath(cat.parentId, depth + 1)} / ${cat.name}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDepth = (id: string, depth = 0): number => {
|
||||||
|
if (depth > 10) return depth;
|
||||||
|
const cat = map.get(id);
|
||||||
|
if (!cat?.parentId) return depth;
|
||||||
|
return getDepth(cat.parentId, depth + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return cats
|
||||||
|
.map((c) => {
|
||||||
|
const depth = getDepth(c.id);
|
||||||
|
const indent = '\u00a0\u00a0'.repeat(depth);
|
||||||
|
const prefix = depth > 0 ? `${indent}└\u00a0` : '';
|
||||||
|
return { id: c.id, label: `${prefix}${c.name}`, _sort: getSortPath(c.id) };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a._sort.localeCompare(b._sort, 'fr'))
|
||||||
|
.map(({ id, label }) => ({ id, label }));
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const cat = this.category();
|
||||||
|
if (this.mode() === 'edit' && cat) {
|
||||||
|
this.editingCategoryId.set(cat.id);
|
||||||
|
this.formName.set(cat.name);
|
||||||
|
this.formDescription.set(cat.description ?? '');
|
||||||
|
} else {
|
||||||
|
this.formParentId.set(this.parentId());
|
||||||
|
this.categoryService.list().subscribe({
|
||||||
|
next: (res) => this.allCategories.set(res.items),
|
||||||
|
error: () => this.allCategories.set([]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (!this.formName().trim()) {
|
||||||
|
this.formNameError.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.formNameError.set(false);
|
||||||
|
this.formLoading.set(true);
|
||||||
|
const name = this.formName().trim();
|
||||||
|
const description = this.formDescription().trim() || undefined;
|
||||||
|
|
||||||
|
const obs =
|
||||||
|
this.mode() === 'create'
|
||||||
|
? this.categoryService.create({ name, description, parentId: this.formParentId() ?? undefined })
|
||||||
|
: this.categoryService.update(this.editingCategoryId()!, { name, description });
|
||||||
|
|
||||||
|
obs.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.formLoading.set(false);
|
||||||
|
this.saved.emit();
|
||||||
|
},
|
||||||
|
error: (err: { error?: { message?: string } }) => {
|
||||||
|
this.formLoading.set(false);
|
||||||
|
this.formError.set(err?.error?.message ?? 'Une erreur est survenue.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ services:
|
|||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
DOCS_OUTPUT_DIR: /app/docs-output
|
DOCS_OUTPUT_DIR: /app/docs-output
|
||||||
working_dir: /workspace/back
|
working_dir: /workspace/back
|
||||||
command: sh -c "cd /workspace && pnpm install && cd /workspace/back && pnpm run start:dev"
|
command: sh -c "cd /workspace && pnpm install && /workspace/back/node_modules/.bin/tsc --project /workspace/shared/tsconfig.build.json && cd /workspace/back && pnpm run start:dev"
|
||||||
networks:
|
networks:
|
||||||
- network
|
- network
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@@ -22,6 +22,9 @@ importers:
|
|||||||
'@asyncapi/html-template':
|
'@asyncapi/html-template':
|
||||||
specifier: 3.5.6
|
specifier: 3.5.6
|
||||||
version: 3.5.6(@noble/hashes@1.8.0)(encoding@0.1.13)(typescript@5.9.3)
|
version: 3.5.6(@noble/hashes@1.8.0)(encoding@0.1.13)(typescript@5.9.3)
|
||||||
|
'@datacat/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.1.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -10189,7 +10192,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
|
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
|
||||||
'@angular-devkit/build-webpack': 0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
'@angular-devkit/build-webpack': 0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(postcss@8.5.12))
|
||||||
'@angular-devkit/core': 19.2.26(chokidar@4.0.3)
|
'@angular-devkit/core': 19.2.26(chokidar@4.0.3)
|
||||||
'@angular/build': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(@angular/compiler@19.2.22)(@types/node@22.19.19)(chokidar@4.0.3)(jiti@1.21.7)(less@4.2.2)(postcss@8.5.12)(tailwindcss@3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(terser@5.39.0)(typescript@5.8.3)(yaml@2.9.0)
|
'@angular/build': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(@angular/compiler@19.2.22)(@types/node@22.19.19)(chokidar@4.0.3)(jiti@1.21.7)(less@4.2.2)(postcss@8.5.12)(tailwindcss@3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(terser@5.39.0)(typescript@5.8.3)(yaml@2.9.0)
|
||||||
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
|
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
|
||||||
@@ -10203,14 +10206,14 @@ snapshots:
|
|||||||
'@babel/preset-env': 7.26.9(@babel/core@7.26.10)
|
'@babel/preset-env': 7.26.9(@babel/core@7.26.10)
|
||||||
'@babel/runtime': 7.26.10
|
'@babel/runtime': 7.26.10
|
||||||
'@discoveryjs/json-ext': 0.6.3
|
'@discoveryjs/json-ext': 0.6.3
|
||||||
'@ngtools/webpack': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
'@ngtools/webpack': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))
|
||||||
'@vitejs/plugin-basic-ssl': 1.2.0(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0))
|
'@vitejs/plugin-basic-ssl': 1.2.0(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0))
|
||||||
ansi-colors: 4.1.3
|
ansi-colors: 4.1.3
|
||||||
autoprefixer: 10.4.20(postcss@8.5.12)
|
autoprefixer: 10.4.20(postcss@8.5.12)
|
||||||
babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.105.0(postcss@8.5.12))
|
||||||
browserslist: 4.28.2
|
browserslist: 4.28.2
|
||||||
copy-webpack-plugin: 12.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
copy-webpack-plugin: 12.0.2(webpack@5.105.0(postcss@8.5.12))
|
||||||
css-loader: 7.1.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
css-loader: 7.1.2(webpack@5.105.0(postcss@8.5.12))
|
||||||
esbuild-wasm: 0.28.0
|
esbuild-wasm: 0.28.0
|
||||||
fast-glob: 3.3.3
|
fast-glob: 3.3.3
|
||||||
http-proxy-middleware: 3.0.5
|
http-proxy-middleware: 3.0.5
|
||||||
@@ -10218,22 +10221,22 @@ snapshots:
|
|||||||
jsonc-parser: 3.3.1
|
jsonc-parser: 3.3.1
|
||||||
karma-source-map-support: 1.4.0
|
karma-source-map-support: 1.4.0
|
||||||
less: 4.2.2
|
less: 4.2.2
|
||||||
less-loader: 12.2.0(less@4.2.2)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
less-loader: 12.2.0(less@4.2.2)(webpack@5.105.0(postcss@8.5.12))
|
||||||
license-webpack-plugin: 4.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
license-webpack-plugin: 4.0.2(webpack@5.105.0(postcss@8.5.12))
|
||||||
loader-utils: 3.3.1
|
loader-utils: 3.3.1
|
||||||
mini-css-extract-plugin: 2.9.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
mini-css-extract-plugin: 2.9.2(webpack@5.105.0(postcss@8.5.12))
|
||||||
open: 10.1.0
|
open: 10.1.0
|
||||||
ora: 5.4.1
|
ora: 5.4.1
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
piscina: 4.8.0
|
piscina: 4.8.0
|
||||||
postcss: 8.5.12
|
postcss: 8.5.12
|
||||||
postcss-loader: 8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
postcss-loader: 8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))
|
||||||
resolve-url-loader: 5.0.0
|
resolve-url-loader: 5.0.0
|
||||||
rxjs: 7.8.1
|
rxjs: 7.8.1
|
||||||
sass: 1.85.0
|
sass: 1.85.0
|
||||||
sass-loader: 16.0.5(sass@1.85.0)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
sass-loader: 16.0.5(sass@1.85.0)(webpack@5.105.0(postcss@8.5.12))
|
||||||
semver: 7.7.1
|
semver: 7.7.1
|
||||||
source-map-loader: 5.0.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
source-map-loader: 5.0.0(webpack@5.105.0(postcss@8.5.12))
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
terser: 5.39.0
|
terser: 5.39.0
|
||||||
tree-kill: 1.2.2
|
tree-kill: 1.2.2
|
||||||
@@ -10243,7 +10246,7 @@ snapshots:
|
|||||||
webpack-dev-middleware: 7.4.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
|
webpack-dev-middleware: 7.4.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
|
||||||
webpack-dev-server: 5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
|
webpack-dev-server: 5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
|
||||||
webpack-merge: 6.0.1
|
webpack-merge: 6.0.1
|
||||||
webpack-subresource-integrity: 5.1.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
webpack-subresource-integrity: 5.1.0(webpack@5.105.0(postcss@8.5.12))
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
esbuild: 0.28.0
|
esbuild: 0.28.0
|
||||||
tailwindcss: 3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3))
|
tailwindcss: 3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3))
|
||||||
@@ -10277,7 +10280,7 @@ snapshots:
|
|||||||
- webpack-cli
|
- webpack-cli
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
'@angular-devkit/build-webpack@0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))':
|
'@angular-devkit/build-webpack@0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(postcss@8.5.12))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
|
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
|
||||||
rxjs: 7.8.1
|
rxjs: 7.8.1
|
||||||
@@ -13785,7 +13788,7 @@ snapshots:
|
|||||||
'@next/swc-win32-x64-msvc@14.2.33':
|
'@next/swc-win32-x64-msvc@14.2.33':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@ngtools/webpack@19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))':
|
'@ngtools/webpack@19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
|
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
@@ -16051,7 +16054,7 @@ snapshots:
|
|||||||
|
|
||||||
b4a@1.8.1: {}
|
b4a@1.8.1: {}
|
||||||
|
|
||||||
babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.105.0(postcss@8.5.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.26.10
|
'@babel/core': 7.26.10
|
||||||
find-cache-dir: 4.0.0
|
find-cache-dir: 4.0.0
|
||||||
@@ -16729,7 +16732,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-what: 3.14.1
|
is-what: 3.14.1
|
||||||
|
|
||||||
copy-webpack-plugin@12.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
copy-webpack-plugin@12.0.2(webpack@5.105.0(postcss@8.5.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.3
|
fast-glob: 3.3.3
|
||||||
glob-parent: 6.0.2
|
glob-parent: 6.0.2
|
||||||
@@ -16804,7 +16807,7 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
css-loader@7.1.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
css-loader@7.1.2(webpack@5.105.0(postcss@8.5.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
icss-utils: 5.1.0(postcss@8.5.12)
|
icss-utils: 5.1.0(postcss@8.5.12)
|
||||||
postcss: 8.5.12
|
postcss: 8.5.12
|
||||||
@@ -18750,7 +18753,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
readable-stream: 2.3.8
|
readable-stream: 2.3.8
|
||||||
|
|
||||||
less-loader@12.2.0(less@4.2.2)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
less-loader@12.2.0(less@4.2.2)(webpack@5.105.0(postcss@8.5.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
less: 4.2.2
|
less: 4.2.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -18776,7 +18779,7 @@ snapshots:
|
|||||||
|
|
||||||
libphonenumber-js@1.13.2: {}
|
libphonenumber-js@1.13.2: {}
|
||||||
|
|
||||||
license-webpack-plugin@4.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
license-webpack-plugin@4.0.2(webpack@5.105.0(postcss@8.5.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
webpack-sources: 3.4.1
|
webpack-sources: 3.4.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -19068,7 +19071,7 @@ snapshots:
|
|||||||
|
|
||||||
mimic-response@4.0.0: {}
|
mimic-response@4.0.0: {}
|
||||||
|
|
||||||
mini-css-extract-plugin@2.9.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
mini-css-extract-plugin@2.9.2(webpack@5.105.0(postcss@8.5.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
schema-utils: 4.3.3
|
schema-utils: 4.3.3
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
@@ -20167,7 +20170,7 @@ snapshots:
|
|||||||
postcss: 8.5.15
|
postcss: 8.5.15
|
||||||
ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3)
|
ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3)
|
||||||
|
|
||||||
postcss-loader@8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
postcss-loader@8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
cosmiconfig: 9.0.1(typescript@5.8.3)
|
cosmiconfig: 9.0.1(typescript@5.8.3)
|
||||||
jiti: 1.21.7
|
jiti: 1.21.7
|
||||||
@@ -20863,7 +20866,7 @@ snapshots:
|
|||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
sass-loader@16.0.5(sass@1.85.0)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
sass-loader@16.0.5(sass@1.85.0)(webpack@5.105.0(postcss@8.5.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
neo-async: 2.6.2
|
neo-async: 2.6.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -21241,7 +21244,7 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-loader@5.0.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
source-map-loader@5.0.0(webpack@5.105.0(postcss@8.5.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
@@ -22344,7 +22347,7 @@ snapshots:
|
|||||||
|
|
||||||
webpack-sources@3.4.1: {}
|
webpack-sources@3.4.1: {}
|
||||||
|
|
||||||
webpack-subresource-integrity@5.1.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
webpack-subresource-integrity@5.1.0(webpack@5.105.0(postcss@8.5.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
typed-assert: 1.0.9
|
typed-assert: 1.0.9
|
||||||
webpack: 5.105.0(esbuild@0.28.0)(postcss@8.5.12)
|
webpack: 5.105.0(esbuild@0.28.0)(postcss@8.5.12)
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"name": "@datacat/shared",
|
"name": "@datacat/shared",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "src/index.ts",
|
"main": "dist/index.js",
|
||||||
"types": "src/index.ts"
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,3 @@
|
|||||||
// Zod schemas for validation (used in backend)
|
|
||||||
// Install zod in back/ if needed: pnpm add zod
|
|
||||||
|
|
||||||
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
|
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
|
||||||
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const;
|
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const;
|
||||||
|
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;
|
||||||
export interface CreateApiEntryDto {
|
|
||||||
title: string;
|
|
||||||
version: string;
|
|
||||||
description?: string;
|
|
||||||
type: 'ASYNCAPI' | 'OPENAPI';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateApiEntryDto {
|
|
||||||
title?: string;
|
|
||||||
version?: string;
|
|
||||||
description?: string;
|
|
||||||
categoryId?: string | null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
export type ApiType = 'ASYNCAPI' | 'OPENAPI';
|
export type ApiType = 'ASYNCAPI' | 'OPENAPI';
|
||||||
|
|
||||||
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR';
|
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR' | 'NO_YAML';
|
||||||
|
|
||||||
|
export type ApiConvention = 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE';
|
||||||
|
|
||||||
|
export const API_CONVENTION_LABELS: Record<ApiConvention, string> = {
|
||||||
|
CONSULTER: 'Consulter',
|
||||||
|
ENREGISTRER: 'Enregistrer',
|
||||||
|
ETRE_NOTIFIE: 'Être notifié',
|
||||||
|
};
|
||||||
|
|
||||||
export interface ApiEntry {
|
export interface ApiEntry {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
name: string;
|
||||||
|
convention: ApiConvention;
|
||||||
version: string;
|
version: string;
|
||||||
|
versionMajor: number;
|
||||||
|
versionMinor: number;
|
||||||
|
versionPatch: number;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
type: ApiType;
|
type: ApiType;
|
||||||
|
provider: string;
|
||||||
yamlContent: string;
|
yamlContent: string;
|
||||||
htmlPath: string | null;
|
htmlPath: string | null;
|
||||||
status: ApiStatus;
|
status: ApiStatus;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
functionalDoc: string | null;
|
isCurrent: boolean;
|
||||||
technicalDoc: string | null;
|
contactFunctionalName: string;
|
||||||
externalDocUrl: string | null;
|
contactFunctionalEntity: string;
|
||||||
contactFunctional: string | null;
|
contactFunctionalEmail: string;
|
||||||
contactTechnical: string | null;
|
contactTechnicalName: string;
|
||||||
accessRights: string | null;
|
contactTechnicalEntity: string;
|
||||||
|
contactTechnicalEmail: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -26,11 +41,15 @@ export interface ApiEntry {
|
|||||||
export interface ApiEntryListItem {
|
export interface ApiEntryListItem {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
name: string;
|
||||||
|
convention: ApiConvention;
|
||||||
version: string;
|
version: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
type: ApiType;
|
type: ApiType;
|
||||||
|
provider: string;
|
||||||
status: ApiStatus;
|
status: ApiStatus;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
|
isCurrent: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
12
shared/tsconfig.build.json
Normal file
12
shared/tsconfig.build.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user