Use of Enum, duplicate function, implementing trait on external library

Hello all, I really appreciate the rust community - very friendly group. I'm a self taught developer / hobbyist and have spent the last month enjoying my introduction into rust. The attached code is a simple wrapper on top of attohttpc to interact with my broker's Web API. Attohttpc is a blocking client and simple for me to start with.

Code: my lib.rs on github
See how its used in the unit tests inside the file.

Questions.

  1. My use of enum's to limit the query parameters so they match the Web API. Is this Rust idiomatic?
  2. Did I implement the trait for the two specific outputs for Executor? Is it correct that I do the two implementations?

Thanks, and I'll keep reading and learning!

:wave:

I'm personally a big fan of using enums to constrain my public APIs. The more constraints, the easier it is to test all permutations of inputs. If that's a goal you're interested in achieving, I do have some suggestions. (And if not, feel free to skip to the end of this response!)

I'll use the gethistory method as an example. Even though it is documented that callers should use the History enum, there is nothing in this method that enforces that constraint. The test for this method shows that the History enum is expected to be used on the RequestBuilder return type. This is a flexible design, but perhaps it is too flexible?

The following example compiles, but will probably have problems at runtime because it sends invalid query parameters to the server:

initialize_client()
    .gethistory("TRP")
    .params(&[("foo", String::from("bar"))])
    .execute();

We can constrain this API by keeping the RequestBuider internal to it, and accept a slice of History values:

pub fn gethistory<T>(&self, symbol: &str, params: &[History]) -> T
where
    RequestBuilder: Execute<T>,
{
    let mut builder = self.client
        .get(format!("{}marketdata/{}/pricehistory", APIWWW, symbol));

    for param in params.iter().cloned() {
        let (key, value): (&str, String) = param.into();
        builder = builder.param(key, value);
    }

    builder.execute()
}

I had to derive Clone for History because we're not allowed to move out of &[T]. One workaround is to implement Into<(&'static str, String)> for &History<'a>. Another option would be providing a method like fn as_kv(&self) -> (&str, String) to avoid unnecessary cloning. It looks much better, too:

for param in params {
    let (key, value) = param.as_kv();
    builder = builder.param(key, value);
}

With that, the test can be rewritten:

#[test]
fn able_to_retrieve_history() {
    let resptxt: String = initialize_client()
        .gethistory("TRP", &[
            History::Period(1),
            History::PeriodType("month"),
            History::Frequency(1),
            History::FrequencyType("daily"),
        ]);
    println!("{:?}", resptxt);
    assert_eq!(resptxt.contains("\"candles\""), true);
}

And now the caller can't provide invalid inputs.

Caveat: Arguably the string and integer values are still unconstrained! It is possible to construct invalid values like History::PeriodType("foo"). These of course can be further constrained with additional enums and structs. Use a PeriodType enum in place of free-form strings. Make the integer types "private" by wrapping them in a tuple struct and providing a static constructor method that validates the numeric value. If you choose to.


Yep, those implementations for Execute<T> are fine! You can see how in the rewrite of gethistory() above, I constrained the return type be any T for which RequestBuilder implements Execute<T>. Nothing else had to change on those implementations, and this function will be able to output either String or serde_json::Value!

1 Like

parasyte, this is great! Really appreciate your time.

I'm excited to refactor the code based on your suggestions. I was going completely in the wrong direction trying to chain in a different manner and building my own builder pattern. I kept getting stuck as you mentioned in forcing the constraint of the enum to the specific API endpoint. Its a good thing i didn't get too far. This also helps me learn a bit with using generics in practice.

I like how you pushed the function towards execution instead of accepting more options instead of continuing with the builder chain.

One followup: why do we define the where clause with RequestBuilder: instead of just saying it needs to invoke the Execute trait?

I'll play around the enum a bit, but I like using the Into<> trait as it seems much cleaner. I see that your Into suggestion is for &History<'a> and not for History. This seemed odd at first to me as I didn't know I can implement the trait on a reference of History. Maybe if I play with this a bit, I'll understand why this is better. Thinking its because the underlying RequestBuilder.params is looking to borrow and like you said so we don't have to clone.

Great stuff.

This always trips me up.

Because the output type is generic (i.e. it needs to return more than one concrete type) it has to output a generic type T. The trouble is that the compiler cannot generate code for every possible type T. One obvious example is u32, because calling builder.execute() doesn't know how to produce a u32; Execute<u32> is not implemented for RequestBuilder.

The part that gets me is that I tend to think of generic constraints in terms of only applying to generic types like where T: Display. And this is untrue! (I still haven't found a way to unlearn this intuition.) It is possible to provide a generic constraint on a concrete type that must implement some specific trait like where Option<MyStruct>: Display. And that's what this where clause does.

AFAIK, there is no way to instruct any particular function to just "invoke a trait". The trait is already implemented for the RequestBuilder type. It's just that the compiler doesn't know ahead-of-time if a caller will try to retrieve something like u32 from it. With the constraint in place, the compiler is being instructed that:

the return type [can] be any T for which RequestBuilder implements Execute<T>.

And now it doesn't have to attempt to do any analysis on any other function to determine whether the code can be generated.

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.