DEV Community

Sami Pietikäinen
Sami Pietikäinen

Posted on • Originally published at pagefault.blog

Implementing REST client with Rust: an introduction to traits and generics

After reading the Rust book and writing about my initial experiences, I wanted a small practical project to familiarize myself with the language. So, I decided to write a REST client library. This post shows, step-by-step, how my initial design was refactored to be more robust and easy-to-use. Traits and generics are key concepts in these refactorings. The intent is not to cover the internals of the library in detail and also the basic syntax of traits and generics is not covered here. The Rust Book chapter 10 is an excellent resource for the latter.

Restson

The library I implemented is called Restson and it is a client library for JSON encoded REST APIs. The main idea is to provide easy-to-use interface for API requests and also handle URL parsing as well as serialization and deserialization from Rust structs automatically. The library is implemented using Hyper, which is the de-factor HTTP library for Rust.

The Restson library is available in GitHub and in crates.io.

Design iterations

When I started writing the library I was new to Rust (I still am) and I had also never used Hyper before. So I did not end up with elegant design right away. Though, usually you don't end up with the final design right away even if you are familiar with the language. At least for me it usually takes some iterations and refactoring to improve and refine the initial ideas and structures.

Lets look at how the GET interface signature looked initially:

pub fn get(&mut self, url: String) -> Result<String, Error>
Enter fullscreen mode Exit fullscreen mode

So the function took the REST endpoint URL as a parameter, performed the GET request with Hyper library and then returned the request body as a string. From functionality point-of-view the function above is not wrong because it does get the job done. However, it is not ideal for the library user and there are also multiple disadvantages with this design.

  • The whole URL needs to be provided every time the interface is used:
    • URL literals scattered in the code
    • Changing the base URL requires modifying all the API calls
  • Client caller needs to handle URL parsing (which adds boilerplate and is error prone)
  • Client caller needs to manually deserialize the returned JSON in GET and serialize data to JSON in POST.

Lets look at how these disadvantages can be fixed by improving the library interface.

Automatic (de)serialization

Lets put the URL parsing aside for now, and look at the return value of the function first. For simple use cases it might be ok to just manipulate the JSON directly, but it is often better to deserialize it to struct. This allows the compiler to do proper type checks and also reduces dependencies to the exact JSON format. For instance, if some field name changes, only the (de)serialization code needs to be updated instead of all the places where the data is used.

To be able to return deserialized data, the get-function needs to know the type in which the JSON is deserialized to. However, these types are defined by the library user and cannot be fixed in the library code. This can be solved by making get a generic function:

pub fn get<T>(&mut self, url: String) -> Result<T, Error> where
    T: serde::de::DeserializeOwned
Enter fullscreen mode Exit fullscreen mode

Generic type T is introduced and instead of returning Result<String, Error> the function now returns Result<T, Error>. Now the function can return different user defined types that are deserialized from the JSON returned by the server. In Rust, type inference also makes generics convenient to use:

let data: MyType = client.get("url").unwrap();
Enter fullscreen mode Exit fullscreen mode

In the code above, the compiler is able to figure out that generic type T in get must be MyType because the return value is assigned to variable of that type (return value of unwrap to be exact). The generic type could also be explicitly annotated but it is unnecessary here. The call above is equivalent with the example below.

let data = client.get::<MyType>("url").unwrap();
Enter fullscreen mode Exit fullscreen mode

You might be wondering what is the where T: serde::de::DeserializeOwned part for. Well, the generic type Tcannot be any type. The get-function can only accept types that implement deserialization interface. In Restson library, the deserialization is implemented using Serde library, so type T needs to implement DeserializeOwnedtrait. This way the implementation of get can call serde_json::from_str function for that type.

The where clause sets a trait bound which makes sure that if T does not implement DeserializeOwned and thus does not have serde_json::from_str function available, the build won't pass. For the library user, implementing this trait is easy thanks to macros provided by Serde.

#[derive(Deserialize)]
struct MyType {
    field1: String,
    field2: String,
}
Enter fullscreen mode Exit fullscreen mode

By using a generic type for the return value, the library is now able to automatically deserialize the data to user defined types. Relatively minor change on the library that makes the interface more convenient to use.

URL handling

First obvious improvement to the URL handling is to give the base URL when the client instance is created. Then the individual API functions like get would only take the REST path as a parameter.

This is already better, but still error prone. It would still be easy to use wrong type of return value with an API path. This would mean that deserialization would always fail, and the error would not be caught during compilation. Furthermore, there would still be path string literals scattered in the code.

One approach to solve this would be to associate the API path with the type that is used with the library. That is, each type that is used in REST requests would be able to return the corresponding API path. This is accomplished by defining a trait for the REST path.

pub trait RestPath {
    fn get_path() -> String;
}
Enter fullscreen mode Exit fullscreen mode
pub fn get<T>(&mut self) -> Result<T, Error> where
    T: serde::de::DeserializeOwned + RestPath
Enter fullscreen mode Exit fullscreen mode

Now, each type that is used as the return type T, must implement both DeserializedOwned and RestPath traits. This is also enforced by the compiler, and if the traits have not been implemented the build will fail. The URL parameter can be removed from get altogether because the implementation can call T::get_path() to get the correct path and the base URL is provided when the client is instantiated. The trait implementation simply needs to return the correct path string:

impl RestPath for MyType {
    fn get_path() -> String { String::from("apipath") }
}
Enter fullscreen mode Exit fullscreen mode

Now the API path is only defined once and the interface can be used without even knowing what the actual path string is:

let data: MyType = client.get().unwrap();
Enter fullscreen mode Exit fullscreen mode

There is still one major weakness in this design. For instance, a path like api/devices/<ID>/status would be difficult to implement because it contains a parameter that should be easily changeable. However, the current get_path does not take any parameters.

Parametrized paths

To be able to return parametrized API paths, the get_path functions should take a parameter that is used to format the path string. For instance:

impl RestPath for MyType {
    fn get_path(id: u32) -> String { format!("api/devices/{}/status", id) }
}
Enter fullscreen mode Exit fullscreen mode

This parameter would also need to be forwarded from the interface functions such as get. The problem is, however, that this is library code and cannot know what types the library user would like to use as path parameters. One way to dodge this problem would be to pass, for instance, a vector of strings. Although this would work, it is not optimal. For one, it would require unnecessary string conversion code for the library user and also make error checking in the trait implementation harder. Better alternative is to make RestPath trait generic.

pub trait RestPath<U> {
    fn get_path(par: U) -> Result<String, Error>;
}
Enter fullscreen mode Exit fullscreen mode

Now the library user can use whatever parameter type to implement the trait. The return value is also changed to Result<String, Error> so that the trait implementation can indicate errors with the provided parameters. These changes also require changes to get interface to pass the parameters.

pub fn get<U, T>(&mut self, param: U) -> Result<T, Error> where
    T: serde::de::DeserializeOwned + RestPath<U>
Enter fullscreen mode Exit fullscreen mode

Another generic parameter U is introduced to the interface which is used in the trait bound RestPath<U>, and also taken in as param. When the library implementation calls get_path function, the parameter is passed to it T::get_path(params). If a RestPath implementation for type T with correct parameter type is not found, the compiler produces an error. The interface now looks pretty complex but it is actually pretty simple to use.

First the RestPath is implemented for user type:

impl RestPath<u32> for MyType {
    fn get_path(id: u32) -> Result<String,Error> { 
        Ok(format!("api/devices/{}/status", id)) 
    }
}
Enter fullscreen mode Exit fullscreen mode

Then the path parameter is simply given to the interface function which passes it to get_path.

// calls api/devices/1234/status and deserializes
// returned JSON to data variable
let data: MyType = client.get(1234).unwrap();
Enter fullscreen mode Exit fullscreen mode

The RestPath trait can also be implemented multiple times for the same type with different generic parameters. It is also possible to provide multiple path parameters by using, for instance, a tuple.

Conclusions

These refactorings improved the initial design which, although working, was not very convenient for the library user. It was also prone to errors that could not easily be detected during compilation. By using a combination of generics and traits the interface was kept simple to use but still a lot of common functionality (such as serialization) was encapsulated inside the library. User types were also allowed with the library which enables more error checking by the compiler (compared to passing things as, for instance, strings).

All in all the traits and generics are powerful tools to have especially when writing library, utility or other reusable code.

Top comments (0)