// 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();
}
}
}