Files
builazoo/web/js/api-client.js
Nicolas Cantu e031c9a1d2 Initial commit
**Motivations:**
- Initialisation du versionning git pour le projet

**Root causes:**
- N/A (Nouveau projet)

**Correctifs:**
- N/A

**Evolutions:**
- Structure initiale du projet
- Ajout du .gitignore

**Pages affectées:**
- Tous les fichiers
2026-03-03 22:24:17 +01:00

259 lines
8.4 KiB
JavaScript

/**
* API client: signed requests (Ed25519), load/save zoo state and world zoos.
*/
import { getOrCreateKeyPair, signMessage } from "./auth-client.js";
/**
* @returns {string} API base URL (from ?api=, window, or localStorage)
*/
export function getApiBase() {
if (typeof window === "undefined") return "";
let base = "";
if (window.BUILAZOO_API_URL !== undefined && window.BUILAZOO_API_URL !== null && window.BUILAZOO_API_URL !== "") {
base = String(window.BUILAZOO_API_URL);
} else {
try {
const stored = localStorage.getItem("builazoo_api_url");
if (stored && stored.trim() !== "") base = stored.trim();
} catch (_) {
// ignore localStorage access errors
}
}
return base ? base.replace(/\/+$/, "") : "";
}
/**
* Persist API base URL so it is used on next load (getApiBase reads it from localStorage).
* @param {string} url
*/
export function setApiBaseUrl(url) {
const raw = url ? String(url).trim() : "";
const trimmed = raw ? raw.replace(/\/+$/, "") : "";
if (typeof window !== "undefined") window.BUILAZOO_API_URL = trimmed || undefined;
try {
if (trimmed) localStorage.setItem("builazoo_api_url", trimmed);
else localStorage.removeItem("builazoo_api_url");
} catch (_) {
// ignore localStorage errors
}
}
/**
* @param {string} body
* @returns {Promise<string>} hex
*/
async function hashBody(body) {
const enc = new TextEncoder().encode(body || "");
const buf = await crypto.subtle.digest("SHA-256", enc);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* @param {string} method
* @param {string} path
* @param {string} [body]
* @returns {Promise<Response>}
*/
export async function signedFetch(method, path, body) {
const base = getApiBase();
if (!base) throw new Error("No API base URL");
const url = base + path;
const timestamp = new Date().toISOString();
const bodyStr = body !== null && body !== undefined ? (typeof body === "string" ? body : JSON.stringify(body)) : "";
const bodyHash = await hashBody(bodyStr);
const message = `${method}\n${path}\n${timestamp}\n${bodyHash}`;
const keys = await getOrCreateKeyPair();
if (!keys) throw new Error("No keypair");
const signature = await signMessage(keys.privateKey, message);
const headers = {
"Content-Type": "application/json",
"X-Public-Key": keys.publicKeyBase64,
"X-Signature": signature,
"X-Timestamp": timestamp,
};
const init = { method, headers };
if (bodyStr) init.body = bodyStr;
return fetch(url, init);
}
/**
* @returns {Promise<{ worldZoos: Array<{ id: string, name: string, x: number, y: number, animalWeights: object }>, mapWidth: number, mapHeight: number }>}
*/
export async function loadZoos() {
const base = getApiBase();
if (!base) return { worldZoos: [], mapWidth: 100, mapHeight: 100 };
const res = await fetch(base + "/api/zoos");
if (!res.ok) throw new Error(`loadZoos ${res.status}`);
const data = await res.json();
return {
worldZoos: data.worldZoos || [],
mapWidth: data.mapWidth ?? 100,
mapHeight: data.mapHeight ?? 100,
};
}
/**
* Load current user's zoo. Distinguishes 401 (account unknown → register) from 404 (no zoo → create zoo).
* @returns {Promise<{ ok: true, data: { zooId: string, name: string, x: number, y: number, game_state: object | null } } | { status: 401 } | { status: 404 }>}
*/
export async function loadMyZoo() {
const base = getApiBase();
if (!base) return { status: 404 };
const res = await signedFetch("GET", "/api/zoos/me");
if (res.status === 401) return { status: 401 };
if (res.status === 404) return { status: 404 };
if (!res.ok) throw new Error(`loadMyZoo ${res.status}`);
const data = await res.json();
return { ok: true, data };
}
/**
* @param {object} gameState
* @returns {Promise<void>}
*/
export async function saveMyZoo(gameState) {
const base = getApiBase();
if (!base) return;
const res = await signedFetch("PATCH", "/api/zoos/me", { game_state: gameState });
if (!res.ok) throw new Error(`saveMyZoo ${res.status}`);
}
/**
* @param {string} pseudo
* @returns {Promise<{ id: string, pseudo: string }>}
*/
export async function register(pseudo) {
const base = getApiBase();
if (!base) throw new Error("No API base URL");
const keys = await getOrCreateKeyPair();
if (!keys) throw new Error("No keypair");
const res = await fetch(base + "/api/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Public-Key": keys.publicKeyBase64,
},
body: JSON.stringify({ pseudo: pseudo.trim() }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `register ${res.status}`);
}
return res.json();
}
/**
* @param {string} name
* @param {object} gameState
* @returns {Promise<{ zooId: string, name: string, x: number, y: number }>}
*/
export async function createMyZoo(name, gameState) {
const res = await signedFetch("POST", "/api/zoos/me", { name, game_state: gameState });
if (!res.ok) throw new Error(`createMyZoo ${res.status}`);
return res.json();
}
/**
* Fetch sales: with auth returns { asSeller, asBuyerUndelivered, active }; without auth only { active }.
* @returns {Promise<{ asSeller?: Array<object>, asBuyerUndelivered?: Array<object>, active: Array<object> }>}
*/
export async function getSales() {
const base = getApiBase();
if (!base) return { active: [] };
const res = await signedFetch("GET", "/api/sales");
if (!res.ok) throw new Error(`getSales ${res.status}`);
return res.json();
}
/**
* Map a server sale listing (asSeller item) to client SaleListing shape.
* @param {{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, end_at: string, status?: string, best_bid_amount?: number, best_bidder_zoo_id?: string, sold_at?: string, validated_at?: string | null, reproduction_score_at_sale?: number }} s
* @returns {import("./types.js").SaleListing}
*/
export function mapServerListingToClient(s) {
return {
id: s.id,
zooId: s.seller_zoo_id,
animalId: s.animal_id,
isBaby: s.is_baby,
price: s.initial_price,
endAt: Math.floor(new Date(s.end_at).getTime() / 1000),
status: s.status,
bestBidAmount: s.best_bid_amount,
bestBidderZooId: s.best_bidder_zoo_id,
validatedAt: s.validated_at ? Math.floor(new Date(s.validated_at).getTime() / 1000) : null,
reproductionScoreAtSale: s.reproduction_score_at_sale,
serverId: s.id,
};
}
/**
* Create a sale listing. Body: animalId, isBaby, price, endAt, reproductionScoreAtSale?.
* @param {{ animalId: string, isBaby: boolean, price: number, endAt: string, reproductionScoreAtSale?: number }} payload
* @returns {Promise<{ id: string }>}
*/
export async function createSale(payload) {
const res = await signedFetch("POST", "/api/sales", payload);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `createSale ${res.status}`);
}
return res.json();
}
/**
* Place a bid on a listing.
* @param {string} listingId
* @param {number} amount
* @returns {Promise<void>}
*/
export async function placeBid(listingId, amount) {
const res = await signedFetch("POST", `/api/sales/${listingId}/bid`, { amount });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `placeBid ${res.status}`);
}
}
/**
* Seller accepts the best bid.
* @param {string} listingId
* @returns {Promise<void>}
*/
export async function acceptSale(listingId) {
const res = await signedFetch("POST", `/api/sales/${listingId}/accept`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `acceptSale ${res.status}`);
}
}
/**
* Seller rejects the current best bid.
* @param {string} listingId
* @returns {Promise<void>}
*/
export async function rejectSale(listingId) {
const res = await signedFetch("POST", `/api/sales/${listingId}/reject`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `rejectSale ${res.status}`);
}
}
/**
* Buyer marks a won listing as delivered (after adding baby/animal to zoo).
* @param {string} listingId
* @returns {Promise<void>}
*/
export async function deliverSale(listingId) {
const res = await signedFetch("POST", `/api/sales/${listingId}/deliver`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `deliverSale ${res.status}`);
}
}