Lib api - typed functions vs "generic" impl types


i am trying to stabilize core of my tiny library for exchanging RPC messages with remote server. The "protocol" includes several (20-ish?) of various RPCs, many with different input and different structure of response data.

Currently, i have the public "api" around dispatching requests done in following manner (pseudo code):

use anyhow::Result;
pub fn request_edit_config(&mut self, params: EditConfigParams) -> Result<EditConfigResponse> { .. }
pub fn request_close_session(&mut self, params: CloseParams) -> Result<CloseSessionResponse> { .. }

I've been wondering, whether a better approach would be to use traits for both input, and replies, to have only single "request" public api like:

pub fn request(&mut self, request: impl RequestParams) -> Result<Box<dyn ResponseBehavior>

One drawback i see here is that in my understanding, i'd enforce lib users to explicitly type all the invocations for the target response structures -> with my current code all is easily inferred due to standalone request_ declarations.

Is there any "common" recommended pattern? Is Trait based public api ok or not that great?

Or maybe some "risk" of providing both explicit and generic api (sans redundancy)?

It is fine to use whatever language feature in your public API. I don't really understand why this would even be a concern. Traits and generics are often used in public and private interfaces alike – if nothing else, the Rust standard library shows you an example of that (generic types and functions are pervasive in std). Generics are one of the most valuable and powerful tools Rust offers, so if you need them, use them.

In particular, in the case of request-response architecture, I do think generics are the right choice. Using generics can decouple the concrete, low-level mechanics of sending an RPC/HTTP/whatever request and serializing/deserializing the response, from the high-level semantic intention of the request itself. That said, I wouldn't return a Box<dyn Response> because it loses type information – after all, the response is a single, concrete type for each request. So I would recommend using an associated type instead.

An example of the aforementioned architecture can be found in my ring_api crate.

Thanks for feedback, my idea that you quote - i have stated a bit incorrectly, i implied Box<dyn ...> as "Trait based"... I didnt realize that i can use (forgot about) associated type + turbofish for more elegant and typed approach! This is what i will do i think in the end.

Separate functions are the most straightforward option, and there's a lot to be said for that. But you could do something like:

trait Request: Serialize {
  type Response: Deserialize;

impl API {
  fn send<R: Request>(&mut self, request: R) -> R::Response;

But now your users can pass their own implementation of Request to send. Use the sealed trait pattern to prevent this

1 Like

thanks for pointers to both of you -> i believe i will switch to this in a few days when i pass my current "super excited toddler with new toys" coding phase... :slight_smile:

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.