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.
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.
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:
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.
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:
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:
In the code above, the compiler is able to figure out that generic type
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.
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.
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.
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.
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.
Now, each type that is used as the return type
T, must implement both
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:
Now the API path is only defined once and the interface can be used without even knowing what the actual path string is:
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.
To be able to return parameterised API paths, the
get_path functions should take a parameter that is used to format the path string. For instance:
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.
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.
Another generic parameter
U is introduced to the interface which is used in the trait bound
RestPath, 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.
RestPath is implemented for user type:
Then the path parameter is simply given to the interface function which passes it to
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.
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.