diff --git a/back/src/modules/apis/apis.controller.ts b/back/src/modules/apis/apis.controller.ts index 66cc76f..77218b9 100644 --- a/back/src/modules/apis/apis.controller.ts +++ b/back/src/modules/apis/apis.controller.ts @@ -64,6 +64,11 @@ export class ApisController { res.send(entry.yamlContent); } + @Get(':id/versions') + getVersions(@Param('id') id: string) { + return this.apisService.findVersionsOf(id); + } + /** Sert les assets statiques (CSS, JS) générés par AsyncAPI CLI */ @Get(':id/*path') async getDocAsset(@Param('id') id: string, @Req() req: Request, @Res() res: Response) { diff --git a/back/src/modules/apis/apis.service.ts b/back/src/modules/apis/apis.service.ts index 31c1a2a..87d77d7 100644 --- a/back/src/modules/apis/apis.service.ts +++ b/back/src/modules/apis/apis.service.ts @@ -4,7 +4,7 @@ import { Repository } from 'typeorm'; import { ApiEntryEntity } from './api-entry.entity'; import { CreateApiEntryDto } from './dto/create-api-entry.dto'; import { UpdateApiEntryDto } from './dto/update-api-entry.dto'; -import { ApiListResponse, ApiListQuery, ApiEntry, ApiType, ApiConvention, API_CONVENTION_LABELS } 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'; @@ -107,6 +107,19 @@ export class ApisService { await this.repo.delete(id); } + async findVersionsOf(id: string): Promise { + 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 regenerate(id: string): Promise { await this.findOneOrThrow(id); await this.updateInternal(id, { status: 'PENDING', errorMessage: null }); diff --git a/back/src/modules/seeder/seeder.service.ts b/back/src/modules/seeder/seeder.service.ts index 00b39ca..a2de0eb 100644 --- a/back/src/modules/seeder/seeder.service.ts +++ b/back/src/modules/seeder/seeder.service.ts @@ -765,6 +765,46 @@ export class SeederService implements OnApplicationBootstrap { contactTechnicalEntity: 'API Commerce', contactTechnicalEmail: 'api-orders@example.com', }), + this.apiRepo.create({ + convention: 'CONSULTER', + name: 'Référentiel Produits', + versionMajor: 3, + versionMinor: 0, + versionPatch: 3, + 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, + 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', @@ -786,7 +826,7 @@ export class SeederService implements OnApplicationBootstrap { }), ]); - console.log('[Seeder] 8 catégories et 5 APIs de démo créées — génération docs en cours...'); + console.log('[Seeder] 8 catégories et 7 APIs de démo créées — génération docs en cours...'); /* Déclenche la génération de docs en fire-and-forget pour chaque API */ for (const entry of entries) { diff --git a/front-public/src/app/core/api.service.ts b/front-public/src/app/core/api.service.ts index 9a4a201..e10a60c 100644 --- a/front-public/src/app/core/api.service.ts +++ b/front-public/src/app/core/api.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { ApiEntry, ApiListResponse, ApiListQuery } from '@datacat/shared'; +import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery } from '@datacat/shared'; @Injectable({ providedIn: 'root' }) export class ApiService { @@ -45,6 +45,10 @@ export class ApiService { return this.http.delete(`${this.baseUrl}/${id}`); } + getVersions(id: string): Observable { + return this.http.get(`${this.baseUrl}/${id}/versions`); + } + regenerate(id: string): Observable { return this.http.post(`${this.baseUrl}/${id}/regenerate`, {}); } diff --git a/front-public/src/app/pages/api-detail/api-detail.component.ts b/front-public/src/app/pages/api-detail/api-detail.component.ts index f026961..e92543a 100644 --- a/front-public/src/app/pages/api-detail/api-detail.component.ts +++ b/front-public/src/app/pages/api-detail/api-detail.component.ts @@ -9,9 +9,10 @@ import { } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; import { ApiService } from '../../core/api.service'; import { CategoryService } from '../../core/category.service'; -import { ApiEntry, Category, ApiConvention } from '@datacat/shared'; +import { ApiEntry, ApiEntryListItem, Category, ApiConvention } from '@datacat/shared'; @Component({ selector: 'app-api-detail', @@ -63,6 +64,16 @@ import { ApiEntry, Category, ApiConvention } from '@datacat/shared'; v{{ entry.version }} {{ entry.status }} + @if (versions().length > 1) { +
+ + +
+ } @@ -155,6 +166,9 @@ import { ApiEntry, Category, ApiConvention } from '@datacat/shared'; + (null); + versions = signal([]); categoryBreadcrumb = signal([]); loading = signal(false); error = signal(null); @@ -388,17 +403,37 @@ export class ApiDetailComponent implements OnInit, OnDestroy { }); private pollInterval: ReturnType | null = null; + private routeSub: Subscription | null = null; private id = ''; ngOnInit() { - this.id = this.route.snapshot.paramMap.get('id') ?? ''; - 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.clearPoll(); + this.loadApi(); + }); } ngOnDestroy() { + this.routeSub?.unsubscribe(); this.clearPoll(); } + onVersionChange(event: Event): void { + const id = (event.target as HTMLSelectElement).value; + if (id && id !== this.api()?.id) { + this.router.navigate(['/catalog', id]); + } + } + + onNewVersion(): void { + this.router.navigate(['/upload'], { queryParams: { from: this.api()?.id } }); + } + onRegenerate() { this.regenerating.set(true); this.apiService.regenerate(this.id).subscribe({ @@ -494,6 +529,10 @@ export class ApiDetailComponent implements OnInit, OnDestroy { this.api.set(entry); this.loading.set(false); this.startPollIfPending(); + this.apiService.getVersions(this.id).subscribe({ + next: (v) => this.versions.set(v), + error: () => {}, + }); if (entry.categoryId) { this.categoryService.browse(entry.categoryId).subscribe({ next: (data) => this.categoryBreadcrumb.set(data.breadcrumb), diff --git a/front-public/src/app/pages/upload/upload.component.ts b/front-public/src/app/pages/upload/upload.component.ts index 9e66fc7..16fe2cc 100644 --- a/front-public/src/app/pages/upload/upload.component.ts +++ b/front-public/src/app/pages/upload/upload.component.ts @@ -12,7 +12,7 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router, RouterLink, ActivatedRoute } from '@angular/router'; import { ApiService } from '../../core/api.service'; import { CategoryService } from '../../core/category.service'; -import { Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared'; +import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared'; @Component({ selector: 'app-upload', @@ -526,7 +526,10 @@ export class UploadComponent implements OnInit { } ngOnInit() { - const preselectedCategoryId = this.route.snapshot.queryParamMap.get('categoryId'); + const params = this.route.snapshot.queryParamMap; + const preselectedCategoryId = params.get('categoryId'); + const fromId = params.get('from'); + this.categoryService.list().subscribe({ next: (res) => { this.categories.set(res.items); @@ -535,6 +538,32 @@ export class UploadComponent implements OnInit { } }, }); + + if (fromId) { + this.apiService.get(fromId).subscribe({ + next: (api) => this.prefillFromApi(api), + error: () => {}, + }); + } + } + + private prefillFromApi(api: ApiEntry): void { + 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, + }); } categoryLabel(cat: Category): string {