Tutorial: Building a Bookshelf API Client
Build a type-safe Rust HTTP client from a Paradox specification using gloo-net. You will define types and endpoints in .dox, generate Rust types and an async client module, and call the API from a WASM component.
What You Will Build
An async client with three functions:
| Function | Method | Description |
|---|---|---|
|
|
List all books |
|
|
Create a book |
|
|
Get a book by ID |
Prerequisites
-
The
paradoxCLI on yourPATH -
paradox-gloo-netinstalled (cargo install paradox-gloo-net) -
A Rust project with
gloo-net,serde, andserde_jsonas dependencies:
[dependencies]
gloo-net = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Step 1: Write the Specification
Create bookshelf.dox:
import std
type Book
title: Text
author: Text
year: Integer
type CreateBookRequest
title: Text
author: Text
year: Integer
valid CreateBookRequest
title != "" | Title is required
author != "" | Author is required
year > 0 | Year must be positive
listBooks: URI
http://localhost/api/books GET (Unit. [Book])
createBook: URI
http://localhost/api/books POST (CreateBookRequest. Book)
getBook: URI
http://localhost/api/books/${Integer} GET (Unit. Book)
Each URI constant declares an endpoint with its HTTP method and input/output types.
Step 2: Generate Rust Types
paradox generate --rust
This produces .dox/dox/mod.rs with your structs and validation:
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Book {
pub title: String,
pub author: String,
pub year: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct CreateBookRequest {
pub title: String,
pub author: String,
pub year: i64,
}
impl CreateBookRequest {
pub fn validate(&self) -> Result<(), String> {
let mut errors: Vec<String> = Vec::new();
if self.title.as_str() == "" { errors.push("Title is required".to_string()); }
if self.author.as_str() == "" { errors.push("Author is required".to_string()); }
if self.year <= 0 { errors.push("Year must be positive".to_string()); }
if errors.is_empty() { Ok(()) } else { Err(errors.join("; ")) }
}
}
The validation rules from your .dox spec are compiled into CreateBookRequest::validate.
Step 3: Generate the Client
paradox-gloo-net --uri listBooks --uri createBook --uri getBook
This generates .dox/client.rs:
//! Generated by paradox-gloo-net — do not edit
use gloo_net::http::Request;
use crate::{
Book,
CreateBookRequest
};
const API_BASE: &str = "";
pub async fn list_books() -> Result<Vec<Book>, String> {
let url = format!("{}/api/books", API_BASE);
let response = Request::get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.ok() {
return Err(format!("HTTP error: {}", response.status()));
}
response
.json::<Vec<Book>>()
.await
.map_err(|e| e.to_string())
}
pub async fn create_book(input: &CreateBookRequest) -> Result<Book, String> {
let url = format!("{}/api/books", API_BASE);
let response = Request::post(&url)
.header("Content-Type", "application/json")
.body(serde_json::to_string(input).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if !response.ok() {
return Err(format!("HTTP error: {}", response.status()));
}
response
.json::<Book>()
.await
.map_err(|e| e.to_string())
}
pub async fn get_book(integer: i64) -> Result<Book, String> {
let url = format!("{}/api/books/{}", API_BASE, integer);
let response = Request::get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.ok() {
return Err(format!("HTTP error: {}", response.status()));
}
response
.json::<Book>()
.await
.map_err(|e| e.to_string())
}
Each URI becomes an async fn that returns Result<T, String>. Endpoint names are converted from camelCase to snake_case (listBooks becomes list_books). Path parameters like ${Integer} become function arguments (integer: i64).
Step 4: Use the Client
mod client;
use client::{list_books, create_book, get_book};
use crate::CreateBookRequest;
async fn example() -> Result<(), String> {
// Create a book
let new_book = CreateBookRequest {
title: "Dune".into(),
author: "Frank Herbert".into(),
year: 1965,
};
let created = create_book(&new_book).await?;
// Get it back
let book = get_book(1).await?;
// List all books
let all_books = list_books().await?;
Ok(())
}
You can validate on the client side before sending:
let req = CreateBookRequest {
title: "".into(),
author: "Nobody".into(),
year: 2024,
};
if let Err(msg) = req.validate() {
// "Title is required"
}
Step 5: Test It
With a running bookshelf server (see the Express tutorial or Servant tutorial):
#[cfg(test)]
mod tests {
use super::client::*;
use crate::CreateBookRequest;
#[wasm_bindgen_test::wasm_bindgen_test]
async fn test_create_and_list() {
let req = CreateBookRequest {
title: "Dune".into(),
author: "Frank Herbert".into(),
year: 1965,
};
let book = create_book(&req).await.unwrap();
assert_eq!(book.title, "Dune");
let books = list_books().await.unwrap();
assert!(!books.is_empty());
}
}
Next Steps
-
Reference — Full flag reference, type mapping, and response handling
-
URI Types — URI literal syntax and structure
-
Rust Code Generation — Details on generated types and validation
-
Express Tutorial — Build the server this client talks to
-
Servant Tutorial — The same server in Haskell