// WowDb.cs : client officiel de l'API de WoWDB des Défias, version 1.0.0. // Documentation : https://docs.wowdb.assemblee-defias.fr // Un seul fichier, sans dépendance (.NET 6+ : HttpClient et System.Text.Json). Release 58 du site (01/10/2026). // Licence : MIT. Données du jeu © Blizzard Entertainment. // // var wowdb = new WowDb.Client("bot-de-ma-guilde/1.0 (https://ma-guilde.example)"); // var lame = await wowdb.RetrieveAsync("classic", "items", 5191); // await foreach (var dague in wowdb.IterateAsync("/classic/items", new() { ["type"] = "dagger", ["rogue"] = "true" })) // Console.WriteLine(dague.GetProperty("name").GetString()); #nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace WowDb { /// Erreur de l'API (corps { error: { type, code, message, param, doc_url } }) ou du réseau. public sealed class WowDbException : Exception { public int Status { get; } public string Type { get; } public string Code { get; } public string? Param { get; } public string? DocUrl { get; } public string? RequestId { get; } public WowDbException(string message, int status = 0, string type = "api_error", string code = "network_error", string? param = null, string? docUrl = null, string? requestId = null) : base(message) { Status = status; Type = type; Code = code; Param = param; DocUrl = docUrl; RequestId = requestId; } } /// Client de l'API de WoWDB des Défias (lecture seule, sans clé). public sealed class Client : IDisposable { public const string Version = "1.0.0"; public const string BaseUrl = "https://api.wowdb.assemblee-defias.fr/v1"; private readonly HttpClient _http; private readonly bool _proprietaire; private readonly string _base; public int MaxRetries { get; set; } = 2; public Client(string userAgent = "WowDb.cs/1.0.0", string baseUrl = BaseUrl, HttpClient? http = null) { _base = baseUrl.TrimEnd('/'); _proprietaire = http == null; _http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; _http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent); _http.DefaultRequestHeaders.Accept.ParseAdd("application/json"); } /// Requête GET brute : chemin relatif à /v1 (« /classic/items/5191 ») ; renvoie le JSON. public async Task GetAsync(string path, Dictionary? query = null, CancellationToken ct = default) { var url = _base + (path.StartsWith("/") ? path : "/" + path); if (query != null && query.Count > 0) url += "?" + string.Join("&", query.Where(p => p.Value != null).Select(p => Uri.EscapeDataString(p.Key) + "=" + Uri.EscapeDataString(p.Value))); for (var essai = 0; ; essai++) { HttpResponseMessage res; try { res = await _http.GetAsync(url, ct).ConfigureAwait(false); } catch (HttpRequestException e) { throw new WowDbException("Requête impossible : " + e.Message); } using (res) { if (res.StatusCode == (HttpStatusCode)429 && essai < MaxRetries) { var attente = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); await Task.Delay(attente < TimeSpan.FromSeconds(1) ? TimeSpan.FromSeconds(1) : attente, ct).ConfigureAwait(false); continue; } var texte = await res.Content.ReadAsStringAsync().ConfigureAwait(false); JsonElement? corps = null; try { using var doc = JsonDocument.Parse(texte); corps = doc.RootElement.Clone(); } catch (JsonException) { } var statut = (int)res.StatusCode; if (statut >= 400 || corps == null) { string? Lire(string champ) => corps is JsonElement c && c.ValueKind == JsonValueKind.Object && c.TryGetProperty("error", out var e) && e.TryGetProperty(champ, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; var requestId = res.Headers.TryGetValues("X-Request-Id", out var ids) ? ids.FirstOrDefault() : null; throw new WowDbException(Lire("message") ?? "Erreur HTTP " + statut, statut, Lire("type") ?? "api_error", Lire("code") ?? "http_error", Lire("param"), Lire("doc_url"), requestId); } return corps.Value; } } } /// Une page d'une liste : object, data, has_more, next_cursor, url. public Task ListAsync(string path, Dictionary? query = null, CancellationToken ct = default) => GetAsync(path, query, ct); /// Tous les éléments d'une liste, page après page (pages de 100). public async IAsyncEnumerable IterateAsync(string path, Dictionary? query = null, [EnumeratorCancellation] CancellationToken ct = default) { var q = query != null ? new Dictionary(query) : new Dictionary(); if (!q.ContainsKey("limit")) q["limit"] = "100"; while (true) { var page = await GetAsync(path, q, ct).ConfigureAwait(false); foreach (var element in page.GetProperty("data").EnumerateArray()) yield return element; var cursor = page.GetProperty("next_cursor"); if (cursor.ValueKind != JsonValueKind.String) yield break; q["cursor"] = cursor.GetString()!; } } /// Fiche d'une ressource : RetrieveAsync("classic", "items", 5191). public Task RetrieveAsync(string version, string resource, int id, CancellationToken ct = default) => GetAsync($"/{version}/{resource}/{id}", null, ct); /// Recherche plein texte dans toute la base. public Task SearchAsync(string q, Dictionary? query = null, CancellationToken ct = default) { var p = query != null ? new Dictionary(query) : new Dictionary(); p["q"] = q; return GetAsync("/search", p, ct); } public Task StatsAsync(CancellationToken ct = default) => GetAsync("/stats", null, ct); public void Dispose() { if (_proprietaire) _http.Dispose(); } } }