Library design for interacting with a REST API

I am trying to write a library for interacting with a REST API from a government web service. The API has several endpoints, each with its own set of permissible parameters. The API is not fault tolerant, meaning that including an inappropriate parameter returns an error rather than being ignored. As such my goal is to leverage Rust's type safety to prevent those kinds of errors being possible.

I have an enum for the endpoints and then enums for each endpoint's possible parameters. My plan is to use a with_params() method while building the request URL that will take the parameters of any endpoint type. I think the way to achieve that is using an empty trait and have each parameter type implement that trait. Then the signature for with_params is fn with_params(self, params: Vec<T: Param>) -> URL. Does this make sense or is it the wrong tack?

1 Like

Why not just separate functions for different API calls?

The way I usually wrap a REST API in a typed interface is the following architecture:

  • Create traits Request and Response, where for example Request: Serialize and Response: for<'de> Deserialize<'de> or similar
    • Furthermore, Request has an associated type Resp: Response
  • There's a Client type that encapsulates any state of the client (e.g. an underlying "raw" HTTP client object, which needs state for cookies, etc.) with a send method, of which the signature is:
    • fn send<R: Request>(&self, req: R) -> Result<R::Resp, WhateverError>
    • (add a pinch of async to taste)
    • The implementation of this function is 3-step:
      1. Get the necessary parameters, method, request body, etc. from the Request
      2. Convert all this to something the HTTP API expects, and send it
      3. Parse the response and convert it back to a typed value using the Response protocol
  • Finally, I implement types conforming to Request and Response for each endpoint.

This way, everything boring and repetitive about doing the actual HTTP call is encoded in a single function, while the information minimally necessary for calling each endpoint is encoded purely as a stand-alone type.

It also makes the code extensible by downstream users, which is nice if you can't always immediately update your own code whenever the 3rd-party REST API changes.

A concrete example is my ring_api crate.

4 Likes

@alice, I think that would be most straight forward way, but that it would lead to repetition of code as most of the functions would be very similar.

That's an quite interesting design. Perhaps I should consider it in my back blaze crate.

@H2CO3, thanks for explaining your approach. If I understand correctly from looking at the src of your ring_api crate, rather than creating an enum of valid endpoints which the request method matches on, you have a struct for each valid endpoint that implements a Request trait. Is that right? This does seem more ergonomic.

Exactly, that's what I meant by this:

@H2CO3, when you define your Request trait you have the following:

impl<R: Request> Request for &R {
    type Body = R::Body;
    type Response = R::Response;

    const METHOD: Method = R::METHOD;

    fn endpoint(&self) -> Cow<str> {
        (**self).endpoint()
    }

    fn headers(&self) -> HeaderMap {
        (**self).headers()
    }

    fn body(&self) -> RequestBody<&Self::Body> {
        (**self).body()
    }
}

impl<R: Request> Request for &mut R {
    type Body = R::Body;
    type Response = R::Response;

    const METHOD: Method = R::METHOD;

    fn endpoint(&self) -> Cow<str> {
        (**self).endpoint()
    }

    fn headers(&self) -> HeaderMap {
        (**self).headers()
    }

    fn body(&self) -> RequestBody<&Self::Body> {
        (**self).body()
    }
}

Is that to provide a sort of auto-dereferencing for any type that implements Request when calling the methods defined by the trait? Is that a pretty common pattern to follow?

Something like that, yes. When it doesn't matter on the conceptual level that a value is passed by value, by reference, or using another "handle"-like generic type (e.g. Box), I find it valuable to provide this kind of blanket impl, for more convenient usage.

In the specific case of references, this also means that if I want to send the same request multiple times, or in general I just want to keep the request for further processing, I don't have to clone it, because passing a reference to it results in the same behavior as passing it by value.

(This could be solved by just taking a &R instead of an R in Client::send() in the first place, but I don't like that because then you have to pass a reference.)

1 Like

Thank you for your kindness in explaining these things. If I might ask another question, what is the reason for having endpoint() return Cow<str>? My guess is that it simplifies lifetime issues for &str values and is more efficient memory management than String values, but I'm not certain of that.

I don't think it simplifies lifetime issues, as Cow<str> is (in a loose sense) equivalent to a &str when it contains a Borrowed variant. It's really just there to allow returning a borrowed string (and avoid an allocation) when the path of the endpoint is always the same, but allow constructing a dynamic String when it might change based on the value of the request object.

Different specific question but same overall topic of library design:
Right now I'm implementing this project with reqwest as a dependency and using the reqwest client for making the actual HTTP calls similar to how the ring_api seems to work. How would one design a library like this that could use any backend client for HTTP calls? I'd like to keep the dependencies as lean as possible but also allow more freedom of implementation details if someone were to use this in a larger application for which they went with something other than reqwest.

In this case you'd want to abstract away the operations related to creating the client object and performing raw HTTP calls, e.g. by using a trait. I could imagine this:

trait RawHttpClient {
    fn send(&self, req: RawHttpRequest) -> RawHttpResponse;
}

struct RawHttpRequest {
    path: String,
    headers: HashMap<String, String>,
    body: Option<Vec<u8>>,
}

struct RawHttpResponse {
    headers: HashMap<String, String>,
    body: Option<Vec<u8>>,
}

struct Client<C> {
    raw_client: C
}

impl<C: RawHttpClient> Client {
    fn with_raw_client(raw_client: C) -> Self {
        Client { raw_client }
    }

    fn send<R: Request>(&self, req: R) -> Result<R::Response, Error> {
        let raw_req = RawHttpRequest {
            path: req.path().into_owned(),
            headers: …, // whatever fits
            body: serde_json::to_vec(&req)?.into(),
        };
        let raw_resp = self.raw_client.send(raw_req)?;
        let resp = serde_json::from_bytes(&raw_resp.body.expect("body"))?;
        Ok(resp)
    }
}

Then you'd implement RawHttpClient for request::Client and others that you want to support. 3rd-party crates would also be able to implement it. You could even implement this for your own types in order to wrap simpler HTTP clients of which the API is not object-oriented but say, purely procedural.

Note that the implementation above is a very rough sketch. I wouldn't recommend shoving everything into HashMap<String, String> or expect()ing an Option. I just wanted to show the idea with something very basic. You should refine this design according to the level of generality you need and maybe other requirements.