You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

175 lines
5.4 KiB
Rust

//! 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<T, ApiError>`.
//!
//! # 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<Quote>,
/// 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<ListResponse, ApiError> {
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<Quote, ApiError> {
fetch_json(&format!("/api/quotes/{id}")).await
}
/// Fetch a random quote from the database.
pub async fn get_random_quote() -> Result<Quote, ApiError> {
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<CreateResponse, ApiError> {
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<Quote, ApiError> {
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<T: for<'de> serde::Deserialize<'de>>(url: &str) -> Result<T, ApiError> {
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,
})
}
}