DEV Community

loading...
Cover image for REST API Wrapper with Rust

REST API Wrapper with Rust

Roger Torres Paes (he/him/ele)
I'm a Brazilian dev who writes technical texts in ordinary language.
Updated on ・7 min read

TL;DR: This is a post for beginners where I show:

  • How I structured the files in the project for better documentation: every file in the same folder;
  • How I coded a "query builder" to handle different calls without repeating code: using a generic parameter;
  • How I coded test cases within the documentation: nothing special, just remarks on async tests;
  • How I created High Order Functions (HOF) to emulate that behaviour we have in Rust's Option and Iterator, for example: created a struct with functions that return the struct itself;
  • How I used serde to deserialize the Json: nothing special, just some extra remarks;
  • How I tested it as if it were a crate without exporting it to crates.io: just add the lib.rs in the dependencies of the new project.

I build a wrapper for the Magic: The Gathering API (an SDK, by their own terms). And I did so because I wanted a personal project that offered the following possibilities:

  • Use the reqwest crate;
  • Code something for other coders (e.g.: allowing them to use HOFs, such as cards.filter().types("creature").colors("red").name("dragon")...);
  • To document it as a crate, including tests in the documentation;
  • Implement Github Actions (Edit: I did this later, you can find it here).

The reason why I chose the Magic: The Gathering (MTG) API (besides being a MTG nerd) is because it is a very simple API (it has only GET methods).

This is not a tutorial, I will just highlight the interesting choices this endeavour led me to. Also, this is for beginners; I highly doubt I will say anything new to someone who had carefully read The Book and Rust by example and toyed with async a little bit (although we never know).

The result can be found here.


The project structure

I had two choices:
Project structure

I chose the left one for two reasons:

  • It allows the person using the crate to type use mtgsdk instead of use mtgsdk::mtgsdk
  • This way the documentation shows everything on the first page. Had I went for the option on the right, the docs would only show the module mtgsdk, which I found is not how the cool kids do it.

How it is (left option):
Option on the left

If you want to see for yourself, fork/download the repository and type cargo doc --open

How it would be (right option):
Option on the right

Maybe the first image and Rust by example is enough to show how each way of doing this is carried out; however, for the sake of clarity, I will say this: If you want the left one, all you have to do is to declare the mods in your lib.rs. Otherwise, you have to create a folder with your single module name, create a mod.rs file in it and use the mod and pub mod inside it, declaring only the folder name within lib.rs (in this case, lib.rs would only have pub mod mtgsdk;.


Query builder

As I said, this API only has GET methods, and there's not much to talk about how reqwest handles it, for it is pretty much just passing a URL as you would do in a curl.

I am not saying that this is all that reqwest does; it is not. I am saying that for this API we don't actually need anything else that accessing the URL and parsing the Json (more on this later).

However, instead of repeating the reqwest::get(url) inside every module, I created a query builder that receives an url and returns a Result<T, StatusCode> where T is a struct containing the data for the various calls (cards, formats, etc.).

Besides allowing me to maintain the usage of reqwest in a single spot, it also allowed me to handle the errors and just send StatusCode, so the developer using this crate would easily handle the errors. Here is the code with some additional comments.

async fn build<T>(url: String) -> Result<T, StatusCode>
where
    //This is a requirement for Serde; I will talk about it below.
    T: DeserializeOwned,
{
    let response = reqwest::get(url).await;

    // I am using match instead of "if let" or "?" 
    // to make what's happening here crystal clear
    match &response {
        Ok(r) => {
            if r.status() != StatusCode::OK {
                return Err(r.status());
            }
        }
        Err(e) => {
            if e.is_status() {
                return Err(e.status().unwrap());
            } else {
                return Err(StatusCode::BAD_REQUEST);
            }
        }
    }

    // This is where de magic (and most problems) occur.
    // Again, more on this later.
    let content = response.unwrap().json::<T>().await;

    match content {
        Ok(s) => Ok(s),
        Err(e) => {
            println!("{:?}", e);
            Err(StatusCode::BAD_REQUEST)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pretty straightforward: the functions calling the build() function will tell which type T corresponds to, a type that will be a struct with the Deserialize trait so that reqwest's json() can do the heavy lifting for us.


Documentation tests

The documentation section in the Rust Book is pretty good. Besides reading that, I only checked some examples of how documentation is managed in the crates I use.

What I want to highlight is the insertion of tests within the docs:
Documentation test

That this test will be executed is something that The Book talks about, so I will not stress about it. What was specific for me is that I was testing async calls, which required two minor tweaks:

High Order Functions

I will not lecture about HOF, let alone explain anything about functional programming. The reason I ended up with this is because, instead of something like this Builder Pattern (this is from another wrapper for the same API)...

let mut get_cards_request = api.cards().all_filtered(
    CardFilter::builder()
        .game_format(GameFormat::Standard)
        .cardtypes_or(&[CardType::Instant, CardType::Sorcery])
        .converted_mana_cost(2)
        .rarities(&[CardRarity::Rare, CardRarity::MythicRare])
        .build(),
    );

let mut cards: Vec<CardDetail> = Vec::new();
loop {
    let response = get_cards_request.next_page().await?
    let cards = response.content;
    if cards.is_empty() {
        break;
    }
    filtered_cards.extend(cards);
}
println!("Filtered Cards: {:?}", filtered_cards);
Enter fullscreen mode Exit fullscreen mode

...I wanted something like this:

let response = cards::filter()
    .game_format("standard")
    .type_field("instant|sorcery")
    .cmc(2)
    .rarity("rare|mythic")
    .all()
    .await;

println!("Filtered cards: {:?}", response.unwrap());
Enter fullscreen mode Exit fullscreen mode

Why? Because as a developer I love how Option and Iterator, as well as crates such as warp, implement this, giving Rust its "functional flavour".

How to do it

The function filter() returns a struct called Where that has a vector where I keep all the filters that are going to be added.

pub struct Where<'a> {
    query: Vec<(&'a str, String)>,
}

pub fn filter<'a>() -> Where<'a> {
    Where { query: Vec::new() }
}
Enter fullscreen mode Exit fullscreen mode

So, when I do something like response = mtgsdk::card::filter(), the variable response is a Where struct, and that allows me to call any function implemented inside Where, e.g.:

impl<'a> Where<'a> {
    pub fn game_format(mut self, input: &'a str) -> Self {
        self.query.push(("gameFormat", String::from(input)));
        self
    }
}
Enter fullscreen mode Exit fullscreen mode

So basically, when I called filter() and then added the functions game_format(), type_field(), cmc() and rarity() I was doing this:

  • Created a Where struct with filter()
  • Called game_format() implemented inside Where, which returned the same Where
  • Called type_field() from the Where returned by game_format()
  • Called cmc() from the Where returned by type_field()
  • Called rarity() from the Where returned by cmc()
  • Called all() from the Where returned by rarity() which finally returned the vector of cards:
pub async fn all(mut self) -> Result<Vec<Card>, StatusCode> {
    let val = self.query.remove(0);
    let mut filter = format!("?{}={}", val.0, val.1);

    for (k, v) in self.query.into_iter() {
        filter = format!("{}&{}={}", filter, k, v);
    }

    let cards: Result<RootAll, StatusCode> = query_builder::filter("cards", &filter).await;

    match cards {
        Ok(t) => Ok(t.cards),
        Err(e) => Err(e),
    }
}
Enter fullscreen mode Exit fullscreen mode

And that's it.


Deserialize Json

As promised.

#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Set {
    pub code: String,
    pub name: String,
    #[serde(rename = "type")]
    pub type_field: String,
    #[serde(default)]
    pub booster: Vec<Booster>,
    pub release_date: String,
    pub block: Option<String>,
    pub online_only: Option<bool>,
    pub gatherer_code: Option<String>,
    pub old_code: Option<String>,
    pub magic_cards_info_code: Option<String>,
    pub border: Option<String>,
    pub expansion: Option<String>,
    pub mkm_name: Option<String>,
    pub mkm_id: Option<u32>,
}
Enter fullscreen mode Exit fullscreen mode

Few things I want to mention:

  1. The transform tool helps quite a bit;
  2. In case #[serde(rename_all = "camelCase")] is not sufficiently self-explanatory, it will allow a struct field like release_date to receive data from a field that the API calls releaseDate;
  3. There are two ways to handle optional fields:
    • Using Option, which I preferred because in this case the developer using the crate will have absolute certainty if the field wasn't (the Option will be None) sent or if it was just empty (it will me Some)
    • Using #[serde(default)], which I used for the mandatory fields because in these cases there's no doubt that the API sent them.

Testing "as if" it was a crate

I wanted to import it in a new project, but I didn't wanted to send it to crates.io. How to do it? Like this:

In the new project's Cargo.toml I added this:

[dependencies]
mtgsdk = { path = "../mtgsdk" }
tokio = { version = "1", features = ["full"] }
Enter fullscreen mode Exit fullscreen mode

And that's all. In my main.rs I just used it as if it was a crate.

use mtgsdk::cards;

#[tokio::main]
async fn main() {
    let result = cards::find(46012).await;

    if let Ok(card) = result{
        println!("{}", card.name)
    };
}
Enter fullscreen mode Exit fullscreen mode

This might be helpful if you want to be truthful to what The Book calls integration testing.


See ya 🙃

Cover image by Wayne Low

Discussion (2)

Collapse
jeklah profile image
Jeklah

Nice article. Am just teaching myself rust at the moment, but am a large magic fan so this made it easier to mentally visualise. Thanks!

Collapse
rogertorres profile image
Roger Torres Paes (he/him/ele) Author

Glad to hear that, Jeklah!