theomn.com

Writing a Web API Client in Rust (Part 2)

Published on

Continuing on from the previous post in the series (Part 1), this post will focus on making async HTTP requests, and JSON parsing with serde.

Much of this post will be spent introducing concepts and building foundational tech that will be put to use in the next post.

If you want to skip all the rationale and explanation, you can head over to GitHub and check out marvel-explorer which is where I prototyped a bunch of the code we'll be talking about.

Before we finally get down to business, know that a tremendous ergonomic enhancing language feature known as impl Trait, introduced in Rust 1.26 along with many other great changes, has a huge impact on the topic of returning futures.

Returning futures from functions was made a little ugly by some aspects of the type system, prior to impl Trait.

I'll be showing an example of how this would be done prior to 1.26, and then show how the code would be refactored to leverage impl Trait instead.

There are also significant changes to async strategies in Rust coming to the language (later this year?) which will likely impact the design of the crates we'll employ, but those changes are further out, potentially warranting a follow-up post when the new features are stable.

In addition to being aware of the Rust version being targeted, it's worth remembering that many of the crates being used here are pre-1.0, meaning their APIs can be highly volatile with each release.

I'd like to draw attention to the versions of the futures, hyper, and tokio crates.

futures = "0.1.0"
hyper = "0.11.25"
tokio-core = "0.1.17"

The futures crate has a newer release available (0.2), which is not compatible with the 0.1 API currently targeted by this specific version of hyper.

See the dependencies section of the Cargo.toml in the marvel-explorer repo for a full listing.

Building our API Client 

extern crate futures;
extern crate hyper;
extern crate hyper_tls;
extern crate serde;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;
extern crate tokio_core;

use std::cell::RefCell;
use std::io;

use futures::{Future, Stream};
use hyper::Client;
use hyper_tls::HttpsConnector;
use hyper::client::HttpConnector;
use serde_json::Value as JsValue;
use tokio_core::reactor::Core;

/// type alias for a custom hyper client, configured for HTTPS
/// instead of HTTP.
type HttpsClient = Client<HttpsConnector<HttpConnector>, hyper::Body>;

/// The top level interface for interacting with the remote service.
pub struct MarvelClient {
    /// The `UriMaker` we built in Part 1 of the series.
    uri_maker: UriMaker,
    /// tokio "core" to run our requests in.
    core: RefCell<Core>,
    /// hyper http client to build requests with.
    http: HttpsClient,
}

The above code shows the external crates we're going to be depending on for this next chunk of work, as well as the shape of the MarvelClient struct we'll be building on top of.

Since this struct has a handful of moving pieces, next up we will build a new() method to handle the initialization for us.

Initializing the Client 

impl MarvelClient {
    pub fn new(key: String, secret: String) -> MarvelClient {
        let uri_maker = UriMaker::new(
            key,
            secret,
            "https://gateway.marvel.com:443/v1/public/".to_owned(),
        );

        let core = Core::new().unwrap();

        let http = {
            let handle = core.handle();
            let connector = HttpsConnector::new(4, &handle).unwrap();

            Client::configure()
                .connector(connector)
                .build(&handle)
        };

        MarvelClient {
            uri_maker,
            core: RefCell::new(core),
            http,
        }
    }
    // ... snip ...
}

When creating a new client instance, we'll ask the caller to supply the public and private keys, then the new() method takes care of the rest.

Inside, we pass the keys along to a new UriMaker (the struct we defined in the previous post), as well as supplying the prefix for the URIs we will build.

Although the Marvel Comics API seems to offer access over both HTTP and HTTPS, generally speaking, HTTPS should always be preferred. Hyper works with HTTP out of the box, but to get HTTPS working, we actually need the help of another crate.

To enable hyper to make HTTPS requests, hyper-tls is one such option, though seemingly there are others. hyper-tls provides an HttpsConnector struct which we can hand off to the hyper Client via its builder interface. Since most of the pieces required for the construction of the Client are only needed by the client itself, we can build the whole thing within a block to hide the internals (the connector, and core handle) from prying eyes.

The core is a piece of the tokio landscape, and worth noting. The core is essentially an "event loop" which may be a term you recognize from the async APIs provided by other languages. The core will start up a background thread which will monitor the status of additional "tasks" (threads) submitted to the core for execution.

The futures crate pairs with tokio-core, and provides the primitives for building the "tasks" we submit to the core. Building individual futures for execution in the core is the simplest case, but the API allows for the more advanced usage of chaining futures together as a graph of tasks.

Next, we'll add a method that will run a basic HTTP request (as a future) and parse the response body as JSON. Since this method will return a futures::Future, we will be able to chain it to extract more meaningful values from the resulting JSON response.

A Brief Intro to Serde 

When working with JSON data in Rust, a popular library to use is serde.

serde, named for a combination of ser(ialize) and de(serialize), is a data transformation library with various supported formats (provided by additional crates).

In the initial code sample of this post, you can see 3 crates being used related to serde:

extern crate serde;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;

The serde crate is the core library which provides traits and types that the various format-providing crates are built on top of.

The serde_derive crate is used for the auto-generation the Serialize and Deserialize traits based on a given struct. This is a nice convenience since the appropriate JSON types corresponding to many primitive types in Rust are well known.

Finally, the serde_json crate adds in the format-specific logic to allow serde to parse and produce JSON data.

Parsing JSON with Serde 

Given a piece of JSON:

{
  "id": 1009664,
  "name": "Thor",
  "description": "As the Norse God of thunder and ligh..."
}

we can define a struct representation, which by using the derive annotation will have an auto-generated implementation for the Deserialize trait.

#[derive(Debug, Deserialize)]
pub struct Character {
    pub id: i32,
    pub name: String,
    pub description: String,
}

So long as the types listed for each field are compatible with the types found for the corresponding keys in the JSON object, we can parse this JSON and unpack it into an instance of the struct. This is normally done by using one of the many serde_json::from_* methods.

In our case, the data we receive from our HTTP requests will be a slice of bytes. In Rust this is represented as &[u8], so we can use serde_json::from_slice() to parse these responses.

#[test]
fn test_parse_bytes() {
    // .as_bytes() converts `str` to `&[u8]`
    let raw_json: &[u8] = r##"{
      "id": 1009664,
      "name": "Thor",
      "description": "As the Norse God of thunder and ligh..."
    }"##.as_bytes();
    
    // the type ascription here tells serde what kind of 
    // struct to unpack the data into.
    let character: Character = 
        serde_json::from_slice(raw_json).unwrap();
        
    assert_eq!(character.id, 1009664);
    assert_eq!(character.name.as_str(), "Thor");
    assert_eq!(
        character.description.as_str(), 
        "As the Norse God of thunder and ligh..."
    );
}
[view in playground][test-parse-bytes] In the above example the type hint of `Character` tells `serde` how to collect the data it's trying to parse. If the raw JSON does not conform to the shape, the `Result` return from `serde_json::from_slice()` will be a `serde_json::Error`.

Since we're going to be making different requests resulting in differently shaped JSON structures, it might be nice to not specialize right away.

For this, serde has an intermediate representation of the parsed data structure known as serde_json::Value. This generic Value allows for indexing, so you can do some basic traversal through the JSON data to retrieve a single value rather than building many intermediate structs to get down to a specific branch of the tree. You can also take unpack them into a struct with serde_json::from_value() just as you would when parsing string or byte JSON data.

Doing the initial JSON parsing (as a Value), then specializing later, can be especially helpful if you want to have overlapping "views" of the same data. For the purpose of our project, doing an initial Value parse will help us to fail fast if the response body is not even valid JSON and give us a nice generic type we can use in our signatures for the futures we'll return when making HTTP requests to the Web API.

#[test]
fn test_parse_bytes_as_value() {
    let raw_json: &[u8] = r##"{
      "id": 1009664,
      "name": "Thor",
      "description": "As the Norse God of thunder and ligh..."
    }"##.as_bytes();
    
    // serde can produce an intermediate representation 
    // as a `serde_json::Value`.
    let value: serde_json::Value =
        serde_json::from_slice(raw_json).unwrap();
        
    assert_eq!(value["id"], 1009664_i32);
    assert_eq!(value["name"], "Thor");
    assert_eq!(
        value["description"], 
        "As the Norse God of thunder and ligh..."
    );
}
[view in playground][test-parse-bytes-value]

Next, we'll put all this to use by fetching some JSON from via HTTP with hyper.

A Brief Intro to Futures 

We'll be using hyper to make our HTTP requests. hyper uses another crate called futures to allow for async execution of these requests.

futures in Rust are not that different from using promises in JavaScript, or using futures in Scala. The main idea is you have a task that executes in a non-blocking fashion.

Futures can succeed or fail, just like a Result. As such they are defined with two associated types. In the next code sample, you'll see the future type of the fn get_json() method defined as Future<Item = JsValue, Error = io::Error>, meaning if the future succeeds we'll end up with a serde_json::Value, or otherwise we'll get a std::io::Error.

hyper expects the types of Error for futures to conform to some specific kinds, and their docs hint towards using std::io::Error as a convenient path to working with those expectations. This is because they handle the conversion between std::io::Error and their own Error types. In light of this, we can use a small helper function like the following:

use std::io;

fn to_io_error<E>(err: E) -> io::Error
where
    E: Into<Box<std::error::Error + Send + Sync>>,
{
    // We can create a new IO Error with an ErrorKind of "other", then
    // pass in the actual error as data inside the wrapper type.
    io::Error::new(io::ErrorKind::Other, err)
}

This helper will allow us to effectively wrap any Error type within a std::io::Error, thus conforming to hyper's error handling demands.

In addition to succeeding and failing, futures can be combined in interesting ways. You can chain futures together using the and_then() combinator meaning each step in the chain will execute after the previous has completed.

Additionally, you can execute several futures in parallel and then operate on the respective returns when they are all complete. In this way, we can define complex graphs of async execution, all working towards producing a final result.

Making Requests 

Since hyper uses futures for making HTTP requests, and we'll ultimately be making requests to various API endpoints, and expecting JSON responses in each case, I opted to structure my client code around the idea of doing this initial step in one function, returning a future of a JSON value. Other functions can then be written to use this and transform the value by chaining as needed.

impl MarvelClient {
    // ... snip ... continued from the previous code sample

    /// Given a uri to access, this generates a future json 
    /// value (to be executed by a core later).
    fn get_json(
        &self,
        uri: hyper::Uri
    ) -> Box<Future<Item = JsValue, Error = io::Error>> {
        let f = self.http
            .get(uri)
            .and_then(|res| {
                res.body().concat2().and_then(move |body| {
                    let value: JsValue = serde_json::from_slice(&body)
                        // Wrap the `serde_json::Error` in a 
                        // `std::io::Error` (when needed).
                        .map_err(to_io_error)?;
                    Ok(value)
                })
            })
            .map_err(to_io_error);
        Box::new(f)
    }
    // ... snip ...
}

The above code sample defines our new private function, get_json(), which accepts a hyper::Uri to make a request to, then builds that request as a future.

It is worth noting that calling this function will not cause any network connections to be made. The future does not start execution until scheduled in a tokio core. The return of this function is simply planned work to be executed later.

As described in the tokio docs on returning futures, we are returning a Box of our future type, which is what I'd consider the most straightforward approach in Rust versions prior to 1.26.

For Rust 1.26 and above, impl Trait offers a simplified syntax which does not require us to put the future in a Box thus making it faster, and more memory efficient.

You can see the more simple, and more efficient version in the impl-trait branch of the the marvel-explorer repo (diff).

Wrapping Up 

In this post, we created a new struct, MarvelClient, which will be the way our binaries will ultimately interact with the Marvel Comics API.

It's got a foundational method for fetching data from various API endpoints, get_json(), but we don't yet have anything to call it.

In the next post, we'll call get_json() from some new methods, chaining futures together to compute a result.