Phase 3 : CRUD des livres, statuts de lecture et identite SSOwat
API - CRUD complet sur /api/livres : liste filtrable (format, statut) et cherchable (titre, auteur), consultation, creation, modification, changement de statut isole, suppression. - Aucune lecture ne filtre sur AjoutePar : la bibliotheque est commune au foyer. Le critere n'existe meme pas dans CritereLivres, pour qu'on ne puisse pas s'en servir par inadvertance. Deux tests verrouillent l'invariant, dont un par reflexion sur le type des criteres. - AjoutePar est renseigne par le serveur a la creation, jamais par le client, et n'est pas reecrit a l'edition (pas plus que DateAjout). - Identite lue dans les en-tetes SSOwat (YNH_USER, YNH_USER_EMAIL, YNH_USER_FULLNAME), avec repli sur un utilisateur simule configure en developpement uniquement. Sans en-tete ni simulation, l'identite reste inconnue plutot qu'inventee. Expose par GET /api/moi. - Migration appliquee au demarrage : pas d'etape manuelle a l'installation. Interface Blazor, pensee mobile d'abord - Catalogue unique (physiques et numeriques melanges, distingues par le format) avec recherche, filtres et changement de statut directement dans la liste. - Ajout par ISBN : quand la cascade rend plusieurs notices, un ecran de choix les presente en mettant en avant editeur et annee, seuls elements qui les departagent. Le formulaire reste ensuite entierement modifiable. - Ajout manuel, edition, suppression avec confirmation explicite. - Couvertures : l'URL OpenLibrary n'est jamais validee par un appel prealable (502 intermittents mesures en phase 2) ; un attribut onerror masque l'image cassee et laisse apparaitre un substitut. Decisions prises la ou CLAUDE.md etait muet - Tri alphabetique par titre. - Un ISBN saisi doit etre valide, mais reste facultatif. - Ecran de choix affiche seulement a partir de deux candidats. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
using MaBibli.Api.Services.Catalogue;
|
||||
using MaBibli.Api.Services.Identite;
|
||||
using MaBibli.Shared.Catalogue;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
|
||||
namespace MaBibli.Api.Endpoints;
|
||||
|
||||
public static class LivresEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// CRUD du catalogue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Aucune lecture ne filtre sur <c>AjoutePar</c></b> : la bibliothèque est commune.
|
||||
/// Ce champ n'est même pas acceptable en paramètre de requête, précisément pour qu'on ne
|
||||
/// puisse pas s'en servir comme d'un cloisonnement par inadvertance.
|
||||
/// </remarks>
|
||||
public static IEndpointRouteBuilder MapLivresEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var groupe = routes.MapGroup("/api/livres").WithTags("Livres");
|
||||
|
||||
groupe.MapGet("/", async (
|
||||
string? recherche,
|
||||
Format? format,
|
||||
Statut? statut,
|
||||
IServiceCatalogue service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var criteres = new CritereLivres
|
||||
{
|
||||
Recherche = recherche,
|
||||
Format = format,
|
||||
Statut = statut,
|
||||
};
|
||||
|
||||
return Results.Ok(await service.ListerAsync(criteres, ct));
|
||||
})
|
||||
.WithName("ListerLivres")
|
||||
.WithSummary("Liste le catalogue, filtrable par format et statut, cherchable sur titre et auteur.")
|
||||
.WithDescription(
|
||||
"Catalogue unique : physiques et numériques cohabitent, distingués par leur format. "
|
||||
+ "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant.")
|
||||
.Produces<IReadOnlyList<LivreDto>>();
|
||||
|
||||
groupe.MapGet("/{id:int}", async (int id, IServiceCatalogue service, CancellationToken ct) =>
|
||||
{
|
||||
var livre = await service.ObtenirAsync(id, ct);
|
||||
return livre is null ? Results.NotFound() : Results.Ok(livre);
|
||||
})
|
||||
.WithName("ObtenirLivre")
|
||||
.Produces<LivreDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPost("/", async (
|
||||
EnregistrementLivre saisie,
|
||||
IServiceCatalogue service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// AjoutePar vient du serveur, pas de la charge utile.
|
||||
var utilisateur = utilisateurs.Obtenir();
|
||||
var resultat = await service.CreerAsync(saisie, utilisateur.Identifiant, ct);
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Created($"/api/livres/{resultat.Livre!.Id}", resultat.Livre);
|
||||
})
|
||||
.WithName("CreerLivre")
|
||||
.WithSummary("Ajoute un livre. « AjoutePar » est déterminé par le serveur.")
|
||||
.Produces<LivreDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
groupe.MapPut("/{id:int}", async (
|
||||
int id,
|
||||
EnregistrementLivre saisie,
|
||||
IServiceCatalogue service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.ModifierAsync(id, saisie, ct);
|
||||
|
||||
if (resultat.EstIntrouvable)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Ok(resultat.Livre);
|
||||
})
|
||||
.WithName("ModifierLivre")
|
||||
.WithSummary("Modifie un livre. « AjoutePar » et « DateAjout » restent inchangés.")
|
||||
.Produces<LivreDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPatch("/{id:int}/statut", async (
|
||||
int id,
|
||||
ChangementStatut changement,
|
||||
IServiceCatalogue service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var livre = await service.ChangerStatutAsync(id, changement.Statut, ct);
|
||||
return livre is null ? Results.NotFound() : Results.Ok(livre);
|
||||
})
|
||||
.WithName("ChangerStatutLivre")
|
||||
.WithSummary("Change le seul statut de lecture — l'action la plus fréquente.")
|
||||
.Produces<LivreDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapDelete("/{id:int}", async (int id, IServiceCatalogue service, CancellationToken ct) =>
|
||||
{
|
||||
var supprime = await service.SupprimerAsync(id, ct);
|
||||
return supprime ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("SupprimerLivre")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/// <summary>Expose l'identité de la personne connectée, pour affichage côté client.</summary>
|
||||
public static IEndpointRouteBuilder MapIdentiteEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapGet("/api/moi", (IFournisseurUtilisateur utilisateurs) =>
|
||||
Results.Ok(utilisateurs.Obtenir()))
|
||||
.WithName("UtilisateurCourant")
|
||||
.WithTags("Identité")
|
||||
.WithSummary("Identité lue dans les en-têtes SSOwat, ou utilisateur simulé en développement.")
|
||||
.Produces<UtilisateurCourant>();
|
||||
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user