// WowDb.java : 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 (Java 11+ : java.net.http), avec un petit lecteur JSON intégré. // Release 58 du site (01/10/2026). Licence : MIT. Données du jeu © Blizzard Entertainment. // // WowDb wowdb = new WowDb("bot-de-ma-guilde/1.0 (https://ma-guilde.example)"); // Map lame = wowdb.retrieve("classic", "items", 5191); // for (Map dague : wowdb.iterate("/classic/items", Map.of("type", "dagger", "rogue", "true"))) { // System.out.println(dague.get("name")); // } import java.io.IOException; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; public class WowDb { public static final String VERSION = "1.0.0"; public static final String BASE_URL = "https://api.wowdb.assemblee-defias.fr/v1"; /** Erreur de l'API (corps { error: { type, code, message, param, doc_url } }) ou du réseau. */ public static class WowDbException extends RuntimeException { public final int status; public final String type, code, param, docUrl, requestId; public WowDbException(String message, int status, String type, String code, String param, String docUrl, String requestId) { super(message); this.status = status; this.type = type; this.code = code; this.param = param; this.docUrl = docUrl; this.requestId = requestId; } } private final String baseUrl; private final String userAgent; private final int maxRetries; private final HttpClient http; public WowDb(String userAgent) { this(userAgent, BASE_URL, 2); } public WowDb(String userAgent, String baseUrl, int maxRetries) { this.userAgent = userAgent == null || userAgent.isEmpty() ? "WowDb.java/" + VERSION : userAgent; this.baseUrl = baseUrl.replaceAll("/+$", ""); this.maxRetries = maxRetries; this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); } /** Requête GET brute : chemin relatif à /v1 (« /classic/items/5191 ») ; renvoie le JSON (Map, List, String, Double, Long, Boolean ou null). */ public Object request(String path, Map params) { StringBuilder url = new StringBuilder(baseUrl).append(path.startsWith("/") ? path : "/" + path); if (params != null && !params.isEmpty()) { char sep = '?'; for (Map.Entry e : params.entrySet()) { if (e.getValue() == null) continue; url.append(sep).append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)).append('=').append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)); sep = '&'; } } HttpRequest req = HttpRequest.newBuilder(URI.create(url.toString())) .header("User-Agent", userAgent).header("Accept", "application/json").timeout(Duration.ofSeconds(30)).GET().build(); for (int essai = 0; ; essai++) { HttpResponse res; try { res = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); } catch (IOException e) { throw new WowDbException("Requête impossible : " + e.getMessage(), 0, "api_error", "network_error", null, null, null); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new WowDbException("Requête interrompue.", 0, "api_error", "network_error", null, null, null); } if (res.statusCode() == 429 && essai < maxRetries) { long attente = Math.max(1, res.headers().firstValue("Retry-After").map(s -> { try { return Long.parseLong(s.trim()); } catch (NumberFormatException e) { return 1L; } }).orElse(1L)); try { Thread.sleep(attente * 1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } continue; } Object corps; try { corps = Json.parse(res.body()); } catch (RuntimeException e) { corps = null; } if (res.statusCode() >= 400 || corps == null) { Map err = erreurDe(corps); Object message = err.get("message"); throw new WowDbException(message != null ? message.toString() : "Erreur HTTP " + res.statusCode(), res.statusCode(), String.valueOf(err.getOrDefault("type", "api_error")), String.valueOf(err.getOrDefault("code", "http_error")), (String) err.get("param"), (String) err.get("doc_url"), res.headers().firstValue("X-Request-Id").orElse(null)); } return corps; } } @SuppressWarnings("unchecked") private static Map erreurDe(Object corps) { if (corps instanceof Map && ((Map) corps).get("error") instanceof Map) { return (Map) ((Map) corps).get("error"); } return new LinkedHashMap<>(); } @SuppressWarnings("unchecked") private Map objet(String path, Map params) { return (Map) request(path, params); } /** Une page d'une liste : object, data, has_more, next_cursor, url. */ public Map list(String path, Map params) { return objet(path, params); } /** Tous les éléments d'une liste, page après page (pages de 100), à parcourir avec for. */ public Iterable> iterate(String path, Map params) { return () -> new Iterator<>() { private final Map q = new LinkedHashMap<>(params == null ? Map.of() : params); private List page = new ArrayList<>(); private int i = 0; private boolean fin = false; { q.putIfAbsent("limit", "100"); } @SuppressWarnings("unchecked") private void charger() { while (i >= page.size() && !fin) { Map p = list(path, q); page = (List) p.get("data"); i = 0; Object cursor = p.get("next_cursor"); if (cursor == null) fin = true; else q.put("cursor", cursor.toString()); } } @Override public boolean hasNext() { charger(); return i < page.size(); } @Override @SuppressWarnings("unchecked") public Map next() { if (!hasNext()) throw new NoSuchElementException(); return (Map) page.get(i++); } }; } /** Fiche d'une ressource : retrieve("classic", "items", 5191). */ public Map retrieve(String version, String resource, int id) { return objet("/" + version + "/" + resource + "/" + id, null); } /** Recherche plein texte dans toute la base. */ public Map search(String q, Map params) { Map p = new LinkedHashMap<>(params == null ? Map.of() : params); p.put("q", q); return objet("/search", p); } public Map stats() { return objet("/stats", null); } /** Petit lecteur JSON (RFC 8259) : objets en LinkedHashMap, tableaux en ArrayList, nombres en Long ou Double. */ public static final class Json { private final String s; private int i; private Json(String s) { this.s = s; } public static Object parse(String texte) { Json j = new Json(texte); j.blancs(); Object v = j.valeur(); j.blancs(); if (j.i != j.s.length()) throw new IllegalArgumentException("JSON : texte en trop à la position " + j.i); return v; } private void blancs() { while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++; } private Object valeur() { if (i >= s.length()) throw new IllegalArgumentException("JSON : fin inattendue"); char c = s.charAt(i); if (c == '{') return objet(); if (c == '[') return tableau(); if (c == '"') return chaine(); if (s.startsWith("true", i)) { i += 4; return Boolean.TRUE; } if (s.startsWith("false", i)) { i += 5; return Boolean.FALSE; } if (s.startsWith("null", i)) { i += 4; return null; } return nombre(); } private Map objet() { Map m = new LinkedHashMap<>(); i++; blancs(); if (s.charAt(i) == '}') { i++; return m; } while (true) { blancs(); String cle = chaine(); blancs(); if (s.charAt(i++) != ':') throw new IllegalArgumentException("JSON : « : » attendu à la position " + (i - 1)); blancs(); m.put(cle, valeur()); blancs(); char c = s.charAt(i++); if (c == '}') return m; if (c != ',') throw new IllegalArgumentException("JSON : « , » ou « } » attendu à la position " + (i - 1)); } } private List tableau() { List l = new ArrayList<>(); i++; blancs(); if (s.charAt(i) == ']') { i++; return l; } while (true) { blancs(); l.add(valeur()); blancs(); char c = s.charAt(i++); if (c == ']') return l; if (c != ',') throw new IllegalArgumentException("JSON : « , » ou « ] » attendu à la position " + (i - 1)); } } private String chaine() { if (s.charAt(i) != '"') throw new IllegalArgumentException("JSON : chaîne attendue à la position " + i); StringBuilder b = new StringBuilder(); i++; while (true) { char c = s.charAt(i++); if (c == '"') return b.toString(); if (c != '\\') { b.append(c); continue; } char e = s.charAt(i++); switch (e) { case 'n': b.append('\n'); break; case 't': b.append('\t'); break; case 'r': b.append('\r'); break; case 'b': b.append('\b'); break; case 'f': b.append('\f'); break; case 'u': b.append((char) Integer.parseInt(s.substring(i, i + 4), 16)); i += 4; break; default: b.append(e); } } } private Object nombre() { int debut = i; while (i < s.length() && "+-0123456789.eE".indexOf(s.charAt(i)) >= 0) i++; String n = s.substring(debut, i); if (n.isEmpty()) throw new IllegalArgumentException("JSON : valeur inattendue à la position " + debut); if (n.contains(".") || n.contains("e") || n.contains("E")) return Double.parseDouble(n); return Long.parseLong(n); } } }