Design of a request-response API

I am trying to implement a request-response abstraction between two processes that can only exchange bytes with each other. I am aiming to have an API similar to the following:

#[derive(Serialize, Deserialize, Debug)]
struct TestReq {
    value: u32,
}

#[derive(Serialize, Deserialize, Debug)]
struct TestResp {
    value: u32
}

let remote = Remote::default();
let request = TestReq { value: 41 };
// response should be inferred to be of type TestResp
let response = remote.request(request).await;
assert!(response.value, 42)

So far I have defined the following traits and the definition of Remote as follows:

trait Request: Serialize + DeserializeOwned {
    type Response: Serialize + DeserializeOwned;
}

impl Request for TestReq {
    type Response = TestResp;
}

#[derive(Default)]
struct Remote (VecU8SendingInterface);

impl Remote {
    async fn request<R: Request>(&self, request: R) -> R::Response {
        let req_data = bincode::serialize(&request).unwrap();
        let resp_data = self.0.send(req_data).await;
        bincode::deserialize(resp_data).unwrap()
    } 
}

This seems good but it has a fundamental problem. The handling function on the other side has no type information to deserialize the bytes into the original request.

async fn handle_requests(recv: VecU8ReceivingInterface) {
    while let Some(req_data) = recv.next().await {
        // how to specify the type here
        let request = bincode::deserialize(&req_data).unwrap();
    }
}

As far as I can tell, the only way to solve this problem is to wrap all my requests in an enum, however, this is making the process of adding request-response pairs to my system somewhat verbose since in addition to defining struct ReqTest and struct RespTest, I also need to declare this for every pair:

impl Request for TestReq {
    type Response = TestResp;
}

and to manually maintain an enum with all the variants inside of it:

enum Requests {
    T1(TestReq),
    T2(TestReq2),
    ...
}

I was thinking of cleaning this up a bit by providing a proc macro that would allow one to write:

#[remote::request(response = TestResp)]
struct TestReq {
    value: u32,
}

struct TestResp {
    valuep1: u32
}

But I don't think I will be able to generate the Requests enum in that way since proc macros cannot hold state between invocations. I suppose this problem has been solved several times before, but I am not sure which crates I can look at for inspiration.

1 Like

and if I were to wrap everything in an enum I have a hunch I would have to expose that detail in my API, for example:

let request = Requests::T1(TestReq { value: 41 });
let response = remote.request(request).await;

Or I would have to write a implementation of From<TestReq> for Requests for each request type.

Well, if you want to handle dynamically-typed requests, the you'd have to use some dynamic typing construct; indeed an enum may be an appropriate solution. I don't think there's a way to avoid that.

An alternative design would be to send every type of request to its own "endpoint" – this is usually the way web APIs are designed, and it could work for you, too.

2 Likes

An alternative design would be to send every type of request to its own "endpoint" – this is usually the way web APIs are designed, and it could work for you, too.

In this case, I am working with postMessage and Web Workers, so there really only is one endpoint.

indeed an enum may be an appropriate solution. I don't think there's a way to avoid that.

I suspect that this is indeed the case, it would be nice if I could at least generate said enum using a macro, but I do not think that is possible without maintain some sort of state between invocations.

Why don't you generate the request types from the response enum, then?

Why don't you generate the request types from the response enum, then?

Could you elaborate on that approach? Do you perhaps mean something like?

enum Response {
    #[request(Req1Struct)]
    Resp1(Resp1Struct),

    #[request(Req2Struct)]
    Resp2(Resp2Struct)
}

There are RPC libraries out there which will generate all the boilerplate stuff for you, but I think they all will mainly require you to statically enumerate all possible message types. E.g. this one requires that your messages are all part of one trait. I'm not suggesting this library in particular, just that there is related work in the space which may or may not be appropriate for you.

In other words, the set of messages is closed. However your theoretical macro syntax implies you want the set of message types to be open. This makes your problem much harder, in general, there is no perfect solution to this problem.

But very few cases really need to handle the open implementation types case, almost always you can simply enumerate them. You should decide if you really need to handle an open set of message types.

However your theoretical macro syntax implies you want the set of message types to be open.

Sorry, my macro syntax was misleading, the set of message types is closed.

In that case, I think you can use the library I linked before. I haven't used it, but it seems popular. It's also a middleware-type library so you can use your own postMessage-based transport protocol, if the library doesn't support it. Presumably it doesn't, but it has this adaptor for creating transport protocols by serializing and deserializing messages using serde.

your case might look something like

#[tarpc::service]
trait Rpc {
    async fn test(input: TestReq) -> TestResp;
    // enumerate all message types here, e.g.
    async fn x(input : XReq) -> XResp;
    async fn y(input : YReq) -> YResp;
}

struct Server { ... };
impl Rpc for Server {
  // this actually runs on the server, do your computation here...
  // see docs for how to implement this
}

// create your custom transport protocol which just sends binary data
impl tokio::AsyncRead for VecU8SendingInterface {..}
impl tokio::AsyncWrite for VecU8SendingInterface {..}

// setup a protocol implementation for usage with tarpc
type MyProtocol<Item, SinkItem> =
  tarpc::serde_transport::Transport<
    VecU8SendingInterface, Item, SinkItem,
    tokio_serde::formats::Bincode
  >;
fn my_protocol<Item, SinkItem>() -> MyProtocol<Item, SinkItem> {
   tarpc::serde_transport::Transport::from((
       VecU8SendingInterface::new(),
       tokio_serde::formats::Bincode::default(),
   ))
}
1 Like

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.