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_books()

GET

List all books

create_book(input)

POST

Create a book

get_book(integer)

GET

Get a book by ID

Prerequisites

  • The paradox CLI on your PATH

  • paradox-gloo-net installed (cargo install paradox-gloo-net)

  • A Rust project with gloo-net, serde, and serde_json as 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