API design question (heterogenous list with associated types)

Hi,

I am writing a wrapper around a web API. It's possible to send multiple Requests using one HTTP call. How can I design a function to do that?

For now I have a trait Request as follows:

trait Request: serde::Serialize 
where
    <Self as Request>::Response: for<'a> serde::Deserialize<'a>
{
     type: Response;
}

Each request has to be tagged with a String when sending to the API endpoint and it will return a (more or less) map of tag to Response.

What signature could the function send_requests(requests) be?

For reference, the single request function signature is

fn send_request<T>(request: T) -> anyhow::Result<T::Response>
where
    T: Request

I'm not sure if I fully understand what you are trying to do. What does your HTTP call look like? Are these "requests" encoded in the body?

Yes, the requests get encoded in the HTTP body.

The encoding is (more or less):

[ {"a_type_of_request", {/* encoded TypeOfRequest */}, "a_tag"}, ...]

It will return

[ {"a_tag", {/* encoded TypeOfRequest::Response */}}, ... ]

I am in fact using async functions, so if there is a great api that uses futures I am open to this as well. Not knowing enough about async in Rust I am not sure if this is even feasible.

E.g.

let a = not_send_yet(ARequest{...});
let b = not_send_yet(AnotherRequest{...});
send_requests(vec![a,b]).await;
futures::join!(a,b);

What I do in such cases is using structs and enums to abstract over the API. In your case my types would look something like this, probably:

use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Request {
  #[serde(flatten)]
  inner: RequestInner,
  tag: String,
}

#[derive(Serialize)]
enum RequestInner {
  RequestType1(...),
  RequestType2(...),
}

#[derive(Deserialize)]
struct Response {
  #[serde(flatten)]
  inner: ResponseInner,
  tag: String
}

#[derive(Deserialize)]
enum ResponseInner {
  ResponseType1(...),
  ResponseType2(...),
}

Then you can construct a Vec<Request>, serialize it and send it off.

There are great HTTP clients available, for example reqwest.

Unfortunately that's not very typesafe. I'd prefer a different approach, e.g. using std::any. But I am a little lost on how to do that exactly.

The idea regarding async was maybe not as clear. I'll amend it with a different type I am sure I would need.

let requests_wrapper = ...
// this should save record that this request is to be sent
let a = requests_wrapper.register_request(ARequest{...});
let b = requests_wrapper.register_request(AnotherRequest{...});

// This will finally send the **one** HTTP request
requests_wrapper.send_requests().await;

futures::join!(a,b);

send_requests() would then "fill" the future. I don't know if that's possible.

What do you mean by that? The API you are calling is not very typesafe or the approach of using concrete types to interact with your API? If you refer to the latter, I'd disagree that using dynamic typing is more typesafe than using concrete types. Also, serde is designed to work with concrete types. Personally, I've never seen it being used with std::any::Any.

Your type could be a simple vector of requests. I was imagining something like this to send your HTTP request:

let requests: Vec<Request> = ...

reqwest::post("your endpoint")
  .json(&requests)
  .send()
  .await;

What you are describing would be some sort of lazy future. Maybe there is an implementation for that out there, but I don't know one and have never needed it. It would be far easier if send_requests() creates a single future for your HTTP request that resolves with your answers. No need for every operation you put into your HTTP body to be a future. They would all resolve with the response from the http request anyway, so no need to be extra complicated here.

If I understand you correctly, what you want is, given a tuple (R1, R2) of requests, you want the API to figure out that the return type must be (R1::Response, R2::Response). Is that correct?

This way you lose the information that for RequestType1 the response is going to be ResponseType1.
And every use of any ReponseType1 would have to runtime match on the ResponseInner.

And you are totally right that framing it as a tuple makes it more accessible :face_palm:.

I thought you can match your response to your request via the tag (or position in the array). That way, if response["tag"] matches the right response type for request["tag"], you'd be good. This can be implemented easily based on the types I described above:

impl ResponseInner {
  fn matches_request(&self, request: &RequestInner) -> bool {
     match (self, request) {
        (ResponseInner::ResponseType1(_), RequestInner::RequestType1(_)) => true,
        (ResponseInner::ResponseType2(_), RequestInner::RequestType2(_)) => true,
        _ => false,
    }
  }
}

I have whipped up a quick example showing how to build a compile-time cons list out of pairs using both type-level and value-level recursion: Playground

This is completely type-safe in that it statically infers a "list" (nested pairs) of response types from a "list" (nested pairs) of request types, sending the requests and receiving their responses in order.

The construction and destructuring of the nested request and response tuples is a bit ugly, though. You can use a macro for abstracting it away, e.g. like this.

1 Like

Thank you for your hlist implementation. I'll play around with it. Very appreciated.