Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Quick Start: A Runnable Client in One Sitting

This chapter goes from an empty project to a compiling, querying client in one file. It is deliberately the shortest path — one endpoint against the framework’s BaseConfig, no config or error type of your own — so the whole shape is visible at once. Scaffolding a Client Crate onward builds the same thing properly; here every corner that can be cut is cut.

The endpoint below is the same first request the capi_rs crate documentation and README teach, with a real transport in the client seat.

Prerequisites

  • Rust edition 2021, MSRV 1.88.
  • No serde knowledge: response types describe themselves through the framework’s own wire derives.

Step 1: Add dependencies

cargo new parcel_quickstart && cd parcel_quickstart
[dependencies]
capi_rs = { version = "0.5", features = ["async"] }
capi_wire_model = { version = "0.5", features = ["derive"] }
capic_reqwest = { version = "0.5", features = ["async"] }
capiw_serde_json = "0.5"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Four framework crates. capi_rs is the facade: it re-exports the Capi framework workspace and nothing else, so the wire model is a dependency of its own — the derives it exports generate code that names ::capi_wire_model directly. capic_reqwest is the transport, and capiw_serde_json the JSON codec. std is a default feature everywhere and needs no mention; async is the one feature this build turns on, and it is turned on for both crates that carry the axis, the facade and the transport. The compilation target — not a feature — picks the platform, which Features and Targets explains.

Step 2: Write the client

One file. It fetches a shipment from an imaginary parcel API.

use capi_rs::core::context::ApiContext;
use capi_rs::prelude::*;
use capi_wire_model::{WireDecode, WireModel};

// 1. The endpoint is a plain struct. `#[capi]` gives it a constructor
//    and setters; `ApiEndpoint` turns its fields into the request plan.
#[capi]
#[derive(Debug, Clone, ApiEndpoint)]
#[endpoint(path_template = "/v1/shipments/{shipment_id}")]
struct GetShipment {
    #[endpoint(location = "path")]
    pub shipment_id: String,
}

// 2. The response type describes itself to the wire model — no serde.
#[derive(Debug, WireModel, WireDecode)]
struct Shipment {
    id: String,
    status: String,
}

// 3. The Endpoint impl names the method, the URL, and the decoder.
impl Endpoint for GetShipment {
    type ApiConfig = BaseConfig;
    type Output = Shipment;
    type Error = CodecError;
    type Decoder = BodyDecoder;
    type Requires = Http;

    fn method(&self) -> Method {
        Method::GET
    }
    fn url(&self, config: &BaseConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path(self.path_string())
    }
    fn auth(&self) -> Auth {
        Auth::NONE
    }
}

// 4. `BodyDecoder` hands the response body to this one function.
impl DecodeBody for GetShipment {
    fn decode_body(
        body: Vec<u8>,
        _head: ResponseHead,
        _config: &BaseConfig,
        _context: ApiContext,
    ) -> Result<Shipment, ResponseError<CodecError>> {
        capiw_serde_json::codec().decode_from_slice(&body).map_err(Into::into)
    }
}

// 5. Pair a config with a client, then query.
#[tokio::main]
async fn main() -> Result<(), QueryError> {
    let config = BaseConfig::new(UrlBuilder::https("api.parcel.example"));
    let api = BaseInterface::new(config, capic_reqwest::ReqwestClient::default());

    let shipment = api.query(GetShipment::new("shp_123")).await?;
    println!("{}: {}", shipment.id, shipment.status);
    Ok(())
}

What just happened

Each numbered piece maps onto the pipeline:

  • The endpoint (GetShipment) is the letter: it states the method and URL and nothing about how the request travels. #[capi] generated GetShipment::new(shipment_id) — the field is neither Option, bool, nor Vec, so it is a constructor parameter — and ApiEndpoint generated path_string(), which fills the template from the field marked location = "path". type Requires = Http declares that the endpoint needs plain HTTP; a WebSocket endpoint would say WebSocket, and a client that cannot perform the exchange is refused at the query call site. headers, query_string, body, and the rest are provided methods this endpoint did not need to override.
  • auth() is overridden on purpose. The default is Auth::DEFAULT, a declaration that the request needs the config’s default credential, and BaseConfig installs no credential store — so the seal that places credentials would refuse the request before it is sent. Auth::NONE says the endpoint carries no credential. A real client’s config holds a store, and its endpoints leave the default in place; that is the authentication part of the book.
  • The decoder turns the response body back into a Shipment. BodyDecoder buffers the body and hands it to decode_body whatever the status; here a malformed body or a 404 both surface as a Decoding error, because nothing has told the framework what the service’s error bodies look like. A real client checks the status first and decodes an error envelope on the failure path — the status ladder — and the framework’s ResponseError carries the result either way.
  • decode_from_slice is the codec call. Naming capiw_serde_json::codec() inline keeps the example to one file; a real client stores a Codec<Json> on its own config and reads it back through config.json_lib(), so the JSON engine stays swappable — the config chapter.
  • BaseInterface::new(config, client) is where the two swappable choices meet: the config (base URL — and normally the codecs and the credential store) and the transport. Swap ReqwestClient for capic_ureq::UreqClient and the same endpoint runs blocking; the endpoint code does not change.
  • api.query(...) is the one funnel. It returns Result<Shipment, QueryError>; ? bubbles up any failure the framework owns — transport, decode, an unexpected status — as a QueryError.

Real endpoints add one derive to step 1: #[derive(WireModel, ApiEndpoint)]. WireModel is what lets #[wire(...)] attributes shape a field’s wire name and presence, and what gives the endpoint its redacted twin for fixtures and logs. The endpoint contract shows the full form.

What’s next

ChapterWhat you’ll learn
The Five Ideas and the Request LifecycleThe mental model the rest of the book assumes
The Wire ModelWhat Codec<Json> and the wire derives are
Scaffolding a Client CrateTurning this one file into a real crate with its own config
Consuming and Patching a ClientUsing — and fixing — a client someone else shipped