Organizing an API client library around reqwest::Client

I'm trying to create an ergonomic API client for an API that has a large number of endpoints.

I looked through several articles and API clients on crates.io and there were several ways I thought of organising the code, some were too complex for this use case and I ended up with issues like:

  • long method names if all methods were implemented on a single client and all in the same module/file
  • needing to pass the client to every request if breaking into separate modules
  • unfriendly user syntax like (Endpoint as UserEndpoint).get_by_id("12345") if organising with traits.
  • ...

I jotted down this notation, where a client is created with the base server and reqwest details, and each endpoint is a member of the client struct with it's own methods, so something like this..

let client = Client::new("server.org", "user_key");
let user = client.users.get_by_id("12345");

The following code works but I can't shake the feeling that my use of the reqwest::Client is weird, particularly because the reqwest::Client has an internal Arc and can be cloned directly. In the code, the main Client should have the single copy of the reqwest::Client and each endpoint has a reference to it. Each method on the endpoint clones it's own reference when calling a method.

Is this a good way to implement this? Can I use a reqwest::Client, clone it into the endpoints, and clone the clone on each method? Do I even need to clone the client again on the endpoint method level? Does anyone see any problems with this implementation or have any recommendations?

pub struct Client {
    api_base: url::Url,
    client: Arc<reqwest::Client>,
    pub users: UsersEndpoint,
}

impl Client {
    pub fn new(domain: &str, key: &str) -> Result<Client> {
        let api_base = Url::parse(format!("https://{domain}/api/").as_str())?;

        let mut headers = HeaderMap::new();
        headers.insert("x-key-header", HeaderValue::from_str(key)?);

        // set default client operation
        let client = Arc::new(reqwest::Client::builder().default_headers(headers).build()?);

        // register endpoints
        let users = UsersEndpoint:new(&api_base, client.clone())?;

        Ok(Client { api_base, client, users, })
    }
}

pub struct UsersEndpoint {
    endpoint: url::Url,
    client: Arc<reqwest::Client>,
}

impl UsersEndpoint {
    pub fn new(client: Arc<reqwest::Client>, api_base: &Url) -> Result<Self> {
        Ok(ApplicantEndpoint { endpoint: api_base.join("users/")?, client, })
    }

    pub async fn get_by_id(&self, id: &str) -> Result<String> {
        let client = self.client.clone();
        let endpoint = self.endpoint.join(id)?;

        Ok(client.get(endpoint).send().await?.text().await?)
    }
}
2 Likes

I would personally just have the "namespace structs" like UsersEndpoint wrap a reference to your Client struct rather than storing them all on Client. There may be some use-cases that wouldn't work for, but from what you've shown so far I think it would be fine for you.

The wrapper structs essentially become a completely compile-time abstraction, which is nice.

Code
use anyhow::Result;
use reqwest::{Url, header::{HeaderMap, HeaderValue}};
use std::sync::Arc;

pub struct Client {
    api_base: url::Url,
    client: Arc<reqwest::Client>,
}

impl Client {
    pub fn new(domain: &str, key: &str) -> Result<Client> {
        let api_base = Url::parse(format!("https://{domain}/api/").as_str())?;

        let mut headers = HeaderMap::new();
        headers.insert("x-key-header", HeaderValue::from_str(key)?);

        // set default client operation
        let client = Arc::new(reqwest::Client::builder().default_headers(headers).build()?);

        Ok(Client { api_base, client,})
    }
    
    pub fn users(&self) -> UsersEndpoint {
        UsersEndpoint(self)
    }
}

pub struct UsersEndpoint<'c>(&'c Client);

impl<'c> UsersEndpoint<'c> {
    fn endpoint(&self) -> Result<Url> {
        Ok(self.0.api_base.join("users/")?)
    }

    pub async fn get_by_id(&self, id: &str) -> Result<String> {
        let endpoint = self.endpoint()?.join(id)?;

        Ok(self.0.client.get(endpoint).send().await?.text().await?)
    }
}

// Make sure the lifetimes work when using it
async fn check() {
    let client = Client::new("example.com", "notakey").unwrap();
    
    client.users().get_by_id("100").await.unwrap();
}

Playground

One catch is that every API call will be creating that intermediary URL, but that's something that could be worked around if it was genuinely causing problems

I like that! it actually does what I was originally trying to achive. I tried it as a struct property rather than a tuple struct and ended up doing it this way trying to fight the borrow checker :rofl: I like the solution.

I was trying to get away from using a method to access the functions, but trying to deal with the lifetimes with the struct property puts lifetimes all over the code and I still can't get around the borrow checker.

One catch is that every API call will be creating that intermediary URL, but that's something that could be worked around if it was genuinely causing problems

That's not an issue and is better since most API methods have their own additions to the base anyway so I can move it into each method to build it's own endpoint and use the request to do whatever they need.

Code

It's a good solution because I don't need the Arc anymore, reqwest::Client has an internal Arc. If I am using the API in Tokio service for instance I will need to find a way to clone the internal reqwest::Client before I pass it to tokio::spawn(). I will probably need to implement clone for the client.

The next thing I want to do is error handling. Was going to first use thiserror, and then checkout anyhow if I needed it so thanks for saving me the time!!

1 Like

Yeah you can't have them as fields of Client because then Client would contain references to itself, which is not currently possible in safe Rust. Methods are definitely the way to go for this style where lifetimes get involved

I just used anyhow to make what you had build, if you're aiming to make a library you should probably eventually move to an actual error type, but anyhow/eyre is certainly convenient for getting started!

So.. my lib is way bigger than this example and split up into several modules. Just wanted to highlight in the solution that the fields need public visibility and that should be limited (in this case to the crate). It stumped me because I haven't required scoped access to fields in my other projects so far.

mod client {

    use anyhow::Result;
    use reqwest::{Url, header::{HeaderMap, HeaderValue}};
    use super::endpoint;

    pub struct Client {
        pub(crate) api_base: Url,
        pub(crate) client: reqwest::Client,
    }
    
    impl Client {
        pub fn new(domain: &str, key: &str) -> Result<Client> {
            let api_base = Url::parse(format!("https://{domain}/api/").as_str())?;
    
            // common api headers
            let mut headers = HeaderMap::new();
            headers.insert("x-key-header", HeaderValue::from_str(key)?);
    
            // common client settings for REST endpoint reqwests
            let client = reqwest::Client::builder().default_headers(headers).build()?;
    
            Ok(Client { api_base, client,})
        }
        
        pub fn users(&self) -> endpoint::UsersEndpoint {
            endpoint::UsersEndpoint(self)
        }
    }
}

mod endpoint {
    use anyhow::Result;
    use super::client;

    pub struct UsersEndpoint<'c>(pub(crate) &'c client::Client);
    
    impl<'c> UsersEndpoint<'c> {
    
        pub async fn get_by_id(&self, id: &str) -> Result<String> {
            let endpoint = self.0.api_base.join(format!("users/{id}").as_str())?;
            Ok(self.0.client.get(endpoint).send().await?.text().await?)
        }
    }
}

#[tokio::main]
async fn main() {
    use client::Client;
    
    let client = Client::new("example.com", "notakey").unwrap();
    println!("{}", client.users().get_by_id("100").await.unwrap());
}

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.