using System.Net; using System.Net.Http.Json; using System.Text.Json; using MaBibli.Shared.Catalogue; using MaBibli.Shared.Dtos; using MaBibli.Shared.Entites; namespace MaBibli.Client.Services; /// /// Accès à l'API depuis le client Blazor. /// /// /// Aucun cache : la phase 3 suppose le réseau disponible. Le cache hors-ligne est la phase 6. /// public sealed class ServiceLivresApi(HttpClient http) { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); public async Task> ListerAsync(CritereLivres criteres, CancellationToken ct = default) { var parametres = new List(); if (!string.IsNullOrWhiteSpace(criteres.Recherche)) { parametres.Add($"recherche={Uri.EscapeDataString(criteres.Recherche.Trim())}"); } if (criteres.Format is { } format) { parametres.Add($"format={format}"); } if (criteres.Statut is { } statut) { parametres.Add($"statut={statut}"); } if (criteres.AuteurId is { } auteurId) { parametres.Add($"auteurId={auteurId}"); } var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty); return await http.GetFromJsonAsync>(url, Json, ct) ?? []; } public async Task ObtenirAsync(int id, CancellationToken ct = default) { var reponse = await http.GetAsync($"api/livres/{id}", ct); return reponse.StatusCode == HttpStatusCode.NotFound ? null : await LireAsync(reponse, ct); } public async Task> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) { var reponse = await http.PostAsJsonAsync("api/livres", saisie, Json, ct); return await InterpreterAsync(reponse, ct); } public async Task> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) { var reponse = await http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct); return await InterpreterAsync(reponse, ct); } /// /// Change le statut de lecture de l'utilisateur courant ; null le remet à /// « non commencé ». Le serveur sait qui appelle : le client ne transmet jamais d'identité. /// public async Task> ChangerStatutAsync( int id, Statut? statut, CancellationToken ct = default) { var reponse = await http.PatchAsJsonAsync( $"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct); return await InterpreterAsync(reponse, ct); } public async Task> ListerAuteursAsync(CancellationToken ct = default) => await http.GetFromJsonAsync>("api/auteurs", Json, ct) ?? []; public async Task ObtenirAuteurAsync(int id, CancellationToken ct = default) { var reponse = await http.GetAsync($"api/auteurs/{id}", ct); return reponse.StatusCode == HttpStatusCode.NotFound ? null : await LireAsync(reponse, ct); } /// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul. public async Task> ListerRapprochementsAsync( CancellationToken ct = default) => await http.GetFromJsonAsync>("api/auteurs/rapprochements", Json, ct) ?? []; public Task AccepterRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) => DeciderAsync("accepter", sourceId, cibleId, ct); public Task RefuserRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) => DeciderAsync("refuser", sourceId, cibleId, ct); private async Task DeciderAsync(string action, int sourceId, int cibleId, CancellationToken ct) { var reponse = await http.PostAsJsonAsync( $"api/auteurs/rapprochements/{action}", new DecisionRapprochement { SourceId = sourceId, CibleId = cibleId }, Json, ct); return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct); } /// Les livres actuellement dehors, du prêt le plus ancien au plus récent. public async Task> ListerPretsEnCoursAsync(CancellationToken ct = default) => await http.GetFromJsonAsync>("api/prets/en-cours", Json, ct) ?? []; /// /// Historique complet des prêts d'un livre, du plus récent au plus ancien. /// /// Liste vide = jamais prêté ; null = livre inconnu. public async Task?> ListerPretsLivreAsync(int livreId, CancellationToken ct = default) { var reponse = await http.GetAsync($"api/livres/{livreId}/prets", ct); return reponse.StatusCode == HttpStatusCode.NotFound ? null : await LireAsync>(reponse, ct); } /// /// Prête un livre. est une date locale, convertie ici en /// UTC : tout ce que l'API stocke est en UTC, comme le reste des dates du projet. /// public async Task> PreterAsync( int livreId, string emprunteur, DateTime? datePret = null, CancellationToken ct = default) { var saisie = new EnregistrementPret { Emprunteur = emprunteur, DatePret = EnUtc(datePret), }; var reponse = await http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct); return await InterpreterAsync(reponse, ct); } /// Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre. public async Task> RendreAsync( int pretId, DateTime? dateRetour = null, CancellationToken ct = default) { var reponse = await http.PostAsJsonAsync( $"api/prets/{pretId}/retour", new RetourPret { DateRetour = EnUtc(dateRetour) }, Json, ct); return await InterpreterAsync(reponse, ct); } /// /// Convertit une date issue d'un <input type="date"> en instant UTC. /// /// /// Le navigateur produit une date sans fuseau (minuit, « Unspecified ») : l'envoyer telle /// quelle décalerait le prêt d'un jour pour la moitié du globe. On la déclare locale avant /// de la convertir, ce qui est bien ce que l'utilisateur voulait dire en la choisissant. /// private static DateTime? EnUtc(DateTime? date) => date is null ? null : DateTime.SpecifyKind(date.Value, DateTimeKind.Local).ToUniversalTime(); public async Task SupprimerAsync(int id, CancellationToken ct = default) { var reponse = await http.DeleteAsync($"api/livres/{id}", ct); return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct); } public async Task ChercherIsbnAsync(string isbn, CancellationToken ct = default) { var reponse = await http.GetAsync($"api/isbn/{Uri.EscapeDataString(isbn)}", ct); return reponse.IsSuccessStatusCode ? await LireAsync(reponse, ct) : null; } public async Task ObtenirUtilisateurAsync(CancellationToken ct = default) { try { return await http.GetFromJsonAsync("api/moi", Json, ct) ?? UtilisateurCourant.Anonyme; } catch (HttpRequestException) { return UtilisateurCourant.Anonyme; } } private static async Task LireAsync(HttpResponseMessage reponse, CancellationToken ct) { reponse.EnsureSuccessStatusCode(); return await reponse.Content.ReadFromJsonAsync(Json, ct); } private static async Task> InterpreterAsync(HttpResponseMessage reponse, CancellationToken ct) { if (reponse.IsSuccessStatusCode) { var valeur = await reponse.Content.ReadFromJsonAsync(Json, ct); return new ResultatApi(valeur, null); } return new ResultatApi(default, await MessageErreurAsync(reponse, ct)); } /// Récupère le message d'erreur de l'API pour l'afficher tel quel à l'utilisateur. private static async Task MessageErreurAsync(HttpResponseMessage reponse, CancellationToken ct) { try { var corps = await reponse.Content.ReadFromJsonAsync(Json, ct); if (!string.IsNullOrWhiteSpace(corps?.Erreur)) { return corps.Erreur; } } catch (Exception) { // Corps illisible ou vide : on retombe sur le code HTTP. } return reponse.StatusCode switch { HttpStatusCode.NotFound => "Ce livre n'existe plus.", _ => $"L'enregistrement a échoué ({(int)reponse.StatusCode}).", }; } // ───────────────────────────────────────────────────────────────────────── // Liste d'envies et bibliographie par auteur. // Bloc ajouté en fin de classe pour rester bien délimité. // ───────────────────────────────────────────────────────────────────────── /// /// La liste d'envies de l'utilisateur courant. /// /// /// Comme pour le statut de lecture, le client ne transmet aucune identité : le serveur sait /// qui appelle. Il n'existe volontairement pas de moyen de demander la liste d'un autre. /// public async Task> ListerSouhaitsAsync(CancellationToken ct = default) => await http.GetFromJsonAsync>("api/souhaits", Json, ct) ?? []; public async Task> AjouterSouhaitAsync( EnregistrementSouhait saisie, CancellationToken ct = default) { var reponse = await http.PostAsJsonAsync("api/souhaits", saisie, Json, ct); return await InterpreterAsync(reponse, ct); } public async Task SupprimerSouhaitAsync(int id, CancellationToken ct = default) { var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct); return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct); } /// /// Adresse de téléchargement d'un export. /// /// /// Le fichier n'est pas récupéré par ce service : un simple lien suffit, le serveur pose un /// Content-Disposition: attachment et le navigateur enregistre. Passer par /// obligerait à fabriquer une URL d'objet en JavaScript pour un /// résultat identique. /// /// ⚠️ Le lien doit porter l'attribut download : sans lui, le routeur Blazor /// intercepte le clic comme une navigation interne et affiche sa page « introuvable » au /// lieu de laisser le navigateur télécharger. /// /// public static string UrlExportSouhaits(FormatExportSouhaits format) => format == FormatExportSouhaits.Csv ? "api/souhaits/export.csv" : "api/souhaits/export.txt"; /// /// Bibliographie d'un auteur d'après la BnF. null si l'auteur n'existe pas. /// public async Task ObtenirBibliographieAsync( int auteurId, CancellationToken ct = default) { var reponse = await http.GetAsync($"api/auteurs/{auteurId}/bibliographie", ct); return reponse.StatusCode == HttpStatusCode.NotFound ? null : await LireAsync(reponse, ct); } private sealed record ErreurApi(string? Erreur); } /// Résultat d'un appel d'écriture : la valeur, ou un message d'erreur affichable. public readonly record struct ResultatApi(T? Valeur, string? Erreur) { public bool EstOk => Erreur is null; }