Files
mabibli/MaBibli.Api/Endpoints/LivresEndpoints.cs
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

260 lines
12 KiB
C#

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.
/// <para>
/// Le statut de lecture, lui, est <b>personnel</b> : il est toujours celui de l'appelant,
/// déduit des en-têtes SSOwat. Aucun point d'entrée n'accepte un nom d'utilisateur — on ne
/// consulte ni ne modifie la lecture de quelqu'un d'autre.
/// </para>
/// </remarks>
public static IEndpointRouteBuilder MapLivresEndpoints(this IEndpointRouteBuilder routes)
{
var groupe = routes.MapGroup("/api/livres").WithTags("Livres");
groupe.MapGet("/", async (
string? recherche,
Format? format,
TypeDocument? typeDocument,
Statut? statut,
int? auteurId,
bool? prete,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var criteres = new CritereLivres
{
Recherche = recherche,
Format = format,
TypeDocument = typeDocument,
Statut = statut,
AuteurId = auteurId,
Prete = prete,
};
var utilisateur = utilisateurs.Obtenir().Identifiant;
return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct));
})
.WithName("ListerLivres")
.WithSummary("Liste le catalogue, filtrable par format, type de document, statut, auteur et prêt en cours ; cherchable sur titre et auteurs.")
.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. "
+ "Le filtre « statut » porte en revanche sur le statut de lecture de l'appelant, "
+ "qui lui est propre. La recherche ignore la casse et les accents.")
.Produces<IReadOnlyList<LivreDto>>();
groupe.MapGet("/{id:int}", async (
int id,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var livre = await service.ObtenirAsync(id, utilisateurs.Obtenir().Identifiant, ct);
return livre is null ? Results.NotFound() : Results.Ok(livre);
})
.WithName("ObtenirLivre")
.Produces<LivreDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/", async (
EnregistrementLivre saisie,
bool? confirmerDoublon,
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, confirmerDoublon ?? false, ct);
if (resultat.Erreur is not null)
{
return Results.BadRequest(new { erreur = resultat.Erreur });
}
// 409 et non 400 : la saisie est valide, c'est le catalogue qui contient déjà
// quelque chose de semblable. Le même appel avec « confirmerDoublon » aboutira.
return resultat.Doublons is { } doublons
? Results.Conflict(doublons)
: Results.Created($"/api/livres/{resultat.Livre!.Id}", resultat.Livre);
})
.WithName("CreerLivre")
.WithSummary("Ajoute un livre. « AjoutePar » est déterminé par le serveur.")
.WithDescription(
"Répond 409 quand le catalogue contient déjà un livre de même ISBN, ou de même "
+ "titre et même auteur, en donnant les fiches en cause. Ce n'est pas un refus : "
+ "posséder deux exemplaires est légitime, et « confirmerDoublon=true » "
+ "enregistre la même saisie sans plus rien demander.")
.Produces<LivreDto>(StatusCodes.Status201Created)
.Produces<DoublonsLivre>(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
int id,
EnregistrementLivre saisie,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.ModifierAsync(
id, saisie, utilisateurs.Obtenir().Identifiant, 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 ; le statut posé est celui de l'appelant.")
.Produces<LivreDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPatch("/{id:int}/statut", async (
int id,
ChangementStatut changement,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var livre = await service.ChangerStatutAsync(
id, changement.Statut, utilisateurs.Obtenir().Identifiant, ct);
return livre is null ? Results.NotFound() : Results.Ok(livre);
})
.WithName("ChangerStatutLivre")
.WithSummary("Change le seul statut de lecture de l'appelant — l'action la plus fréquente.")
.WithDescription("Un statut nul remet le livre à « non commencé » : la ligne est supprimée.")
.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>
/// Consultation des auteurs et arbitrage des rapprochements ambigus.
/// </summary>
/// <remarks>
/// Les variantes sûres sont réunies automatiquement à l'écriture d'un livre et n'atteignent
/// jamais ces points d'entrée. Ce qui arrive ici est ce que la machine <b>refuse</b> de
/// trancher : un patronyme seul face à un nom complet, où seul l'utilisateur sait s'il
/// s'agit d'une même personne.
/// </remarks>
public static IEndpointRouteBuilder MapAuteursEndpoints(this IEndpointRouteBuilder routes)
{
var groupe = routes.MapGroup("/api/auteurs").WithTags("Auteurs");
groupe.MapGet("/", async (IServiceAuteurs service, CancellationToken ct) =>
Results.Ok(await service.ListerAsync(ct)))
.WithName("ListerAuteurs")
.WithSummary("Tous les auteurs du catalogue, avec leur nombre de livres.")
.Produces<IReadOnlyList<AuteurDto>>();
groupe.MapGet("/{id:int}", async (int id, IServiceAuteurs service, CancellationToken ct) =>
{
var auteur = await service.ObtenirAsync(id, ct);
return auteur is null ? Results.NotFound() : Results.Ok(auteur);
})
.WithName("ObtenirAuteur")
.Produces<AuteurDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/{id:int}", async (
int id,
RenommerAuteur demande,
bool? confirmerFusion,
IServiceAuteurs service,
CancellationToken ct) =>
{
var resultat = await service.RenommerAsync(
id, demande.Nom, confirmerFusion ?? false, ct);
if (resultat.Auteur is null && resultat.Collision is null)
{
return Results.NotFound();
}
return resultat.Collision is not null
? Results.Conflict(resultat)
: Results.Ok(resultat);
})
.WithName("RenommerAuteur")
.WithSummary("Renomme un auteur ou fusionne sa fiche avec une fiche existante.")
.Produces<ResultatRenommageAuteur>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
groupe.MapGet("/rapprochements", async (IServiceAuteurs service, CancellationToken ct) =>
Results.Ok(await service.SuggestionsAsync(ct)))
.WithName("ListerRapprochements")
.WithSummary("Rapprochements ambigus proposés, refus déjà écartés.")
.Produces<IReadOnlyList<SuggestionRapprochement>>();
groupe.MapPost("/rapprochements/accepter", async (
DecisionRapprochement decision, IServiceAuteurs service, CancellationToken ct) =>
{
var fusionne = await service.FusionnerAsync(decision.SourceId, decision.CibleId, ct);
return fusionne ? Results.NoContent() : Results.NotFound();
})
.WithName("AccepterRapprochement")
.WithSummary("Fusionne les deux auteurs : « source » disparaît au profit de « cible ».")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/rapprochements/refuser", async (
DecisionRapprochement decision, IServiceAuteurs service, CancellationToken ct) =>
{
var refuse = await service.RefuserAsync(decision.SourceId, decision.CibleId, ct);
return refuse ? Results.NoContent() : Results.NotFound();
})
.WithName("RefuserRapprochement")
.WithSummary("Mémorise que ces deux auteurs sont bien deux personnes : la suggestion ne reviendra plus.")
.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;
}
}