28
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 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.
I chose the left one for two reasons:
- It allows the person using the crate to type
use mtgsdk
instead ofuse 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.
If you want to see for yourself, fork/download the repository and type
cargo doc --open
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;
.
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)
}
}
}
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.
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.
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:
- It has to be in an async block;
- I could not return anything (so no
await?
, because it returns the error).
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".
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 withfilter()
- Called
game_format()
implemented insideWhere
, which returned the sameWhere
- Called
type_field()
from theWhere
returned bygame_format()
- Called
cmc()
from theWhere
returned bytype_field()
- Called
rarity()
from theWhere
returned bycmc()
- Called
all()
from theWhere
returned byrarity()
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.
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 mention:
- The transform tool helps quite a bit;
- In case
#[serde(rename_all = "camelCase")]
is not sufficiently self-explanatory, it will allow a struct field likerelease_date
to receive data from a field that the API callsreleaseDate
; - 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 meSome
) - Using
#[serde(default)]
, which I used for the mandatory fields because in these cases there's no doubt that the API sent them.
- 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
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)
};
}
This might be helpful if you want to be truthful to what The Book calls integration testing.
See ya 🙃
Cover image by Wayne Low
28