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 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
How it would be (right option):
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)
}
}
}
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:
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).
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 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.
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 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
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)
};
}
This might be helpful if you want to be truthful to what The Book calls integration testing.
See ya 🙃
Cover image by Wayne Low
Top comments (2)
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!
Glad to hear that, Jeklah!