REST API Wrapper with Rust

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 par…


This content originally appeared on DEV Community and was authored by Roger Torres

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 CI (I haven't done it yet).

The reason why I chose the MTG API (besides being a MTG nerd) is because it is a very simple API (it only have 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; but 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 passing a URL as you would do in a curl.

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 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)
        }
    }
}

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 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. What was different 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);

...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());

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() }
}

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
    }
}

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),
    }
}

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>,
}

Few things I want to comment:

  1. The transform tool helps quite a bit;
  2. In case #[serde(rename_all = "camelCase")] is not sufficiently self-explanatory, it will allow a 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, so in these cases, there's no doubt 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"] }

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)
    };
}

See ya ?

Cover image by Wayne Low


This content originally appeared on DEV Community and was authored by Roger Torres


Print Share Comment Cite Upload Translate Updates
APA

Roger Torres | Sciencx (2021-07-05T16:38:25+00:00) REST API Wrapper with Rust. Retrieved from https://www.scien.cx/2021/07/05/rest-api-wrapper-with-rust/

MLA
" » REST API Wrapper with Rust." Roger Torres | Sciencx - Monday July 5, 2021, https://www.scien.cx/2021/07/05/rest-api-wrapper-with-rust/
HARVARD
Roger Torres | Sciencx Monday July 5, 2021 » REST API Wrapper with Rust., viewed ,<https://www.scien.cx/2021/07/05/rest-api-wrapper-with-rust/>
VANCOUVER
Roger Torres | Sciencx - » REST API Wrapper with Rust. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2021/07/05/rest-api-wrapper-with-rust/
CHICAGO
" » REST API Wrapper with Rust." Roger Torres | Sciencx - Accessed . https://www.scien.cx/2021/07/05/rest-api-wrapper-with-rust/
IEEE
" » REST API Wrapper with Rust." Roger Torres | Sciencx [Online]. Available: https://www.scien.cx/2021/07/05/rest-api-wrapper-with-rust/. [Accessed: ]
rf:citation
» REST API Wrapper with Rust | Roger Torres | Sciencx | https://www.scien.cx/2021/07/05/rest-api-wrapper-with-rust/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.