//! Typed API client for the quotesdb backend. //! //! Provides async functions that wrap all quotesdb API endpoints using //! `gloo::net::http::Request`. All functions return `Result`. //! //! # Example //! //! ```ignore //! let quote = api::get_random_quote().await?; //! ``` use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput}; use serde::Deserialize; /// Response type for `GET /api/quotes` (paginated list). #[derive(Debug, Clone, Deserialize)] pub struct ListResponse { /// The quotes on the current page. pub quotes: Vec, /// Total number of pages available. pub total_pages: u32, } /// Response type for `PUT /api/quotes` (create quote). /// /// Includes the newly created quote and its auth code (only returned once). #[derive(Debug, Clone, Deserialize)] pub struct CreateResponse { /// The created quote record. pub quote: Quote, /// The auth code required for future edits/deletes. Store this securely. pub auth_code: String, } /// Errors that can occur during API calls. #[derive(Debug, thiserror::Error, Clone)] pub enum ApiError { /// A network-level error (e.g., connection refused, timeout). #[error("network error: {0}")] Network(String), /// The server responded with an error status code. #[error("server error {status}: {message}")] Server { status: u16, message: String }, /// Failed to parse the response body as JSON. #[error("parse error: {0}")] Parse(String), } /// Fetch a paginated list of quotes. /// /// # Arguments /// - `page` — 1-indexed page number. /// - `author` — Optional author name filter (URL-encoded automatically). /// - `tag` — Optional tag filter (URL-encoded automatically). pub async fn list_quotes( page: u32, author: Option<&str>, tag: Option<&str>, ) -> Result { let mut url = format!("/api/quotes?page={page}"); if let Some(a) = author { url.push_str(&format!("&author={}", js_sys::encode_uri_component(a))); } if let Some(t) = tag { url.push_str(&format!("&tag={}", js_sys::encode_uri_component(t))); } fetch_json(&url).await } /// Fetch a single quote by its ID. /// /// Returns `ApiError::Server { status: 404, .. }` if the quote does not exist. pub async fn get_quote(id: &str) -> Result { fetch_json(&format!("/api/quotes/{id}")).await } /// Fetch a random quote from the database. pub async fn get_random_quote() -> Result { fetch_json("/api/quotes/random").await } /// Create a new quote. /// /// Returns the created quote and its auth code on success (HTTP 201). pub async fn create_quote(input: &CreateQuoteInput) -> Result { let resp = gloo::net::http::Request::put("/api/quotes") .json(input) .map_err(|e| ApiError::Network(e.to_string()))? .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if resp.status() == 201 { resp.json() .await .map_err(|e| ApiError::Parse(e.to_string())) } else { let msg = resp.text().await.unwrap_or_default(); Err(ApiError::Server { status: resp.status(), message: msg, }) } } /// Update an existing quote. /// /// Requires the correct `auth_code` in the `X-Auth-Code` header. /// Returns `ApiError::Server { status: 403, .. }` on wrong auth code. pub async fn update_quote( id: &str, input: &UpdateQuoteInput, auth_code: &str, ) -> Result { let resp = gloo::net::http::Request::post(&format!("/api/quotes/{id}")) .header("X-Auth-Code", auth_code) .json(input) .map_err(|e| ApiError::Network(e.to_string()))? .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if resp.status() == 200 { resp.json() .await .map_err(|e| ApiError::Parse(e.to_string())) } else { let msg = resp.text().await.unwrap_or_default(); Err(ApiError::Server { status: resp.status(), message: msg, }) } } /// Delete a quote by ID. /// /// Requires the correct `auth_code` in the `X-Auth-Code` header. /// Returns `Ok(())` on HTTP 204. Returns `ApiError::Server { status: 403, .. }` on wrong auth code. pub async fn delete_quote(id: &str, auth_code: &str) -> Result<(), ApiError> { let resp = gloo::net::http::Request::delete(&format!("/api/quotes/{id}")) .header("X-Auth-Code", auth_code) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if resp.status() == 204 { Ok(()) } else { let msg = resp.text().await.unwrap_or_default(); Err(ApiError::Server { status: resp.status(), message: msg, }) } } /// Internal helper: GET a URL and deserialise the response body as JSON. /// /// Returns `ApiError` on non-2xx status or deserialisation failure. async fn fetch_json serde::Deserialize<'de>>(url: &str) -> Result { let resp = gloo::net::http::Request::get(url) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if resp.status() >= 200 && resp.status() < 300 { resp.json() .await .map_err(|e| ApiError::Parse(e.to_string())) } else { let msg = resp.text().await.unwrap_or_default(); Err(ApiError::Server { status: resp.status(), message: msg, }) } }