Tutorial
Basic setup
To demonstrate how this can simplify API wrapping, in this tutorial we'll implement a client for the Deck of Cards API. For demonstration purposes, we'll just wrap part of the API.
Looking at the API documentation, we can see that
- The base URL is
https://deckofcardsapi.com/api/
- Decks can be created at the
deck/new
API (optionally shuffled) - Operations on a deck occur at
deck/<id>/operation
endpoints
That's it! To get started we'll create an blank package and call it "DeckAPI" (I did this in my temp directory).
pkg> generate DeckAPI
Generating project DeckAPI:
DeckAPI/Project.toml
DeckAPI/src/DeckAPI.jl
Then, we activate the project and add JSON3
and RestClient
as dependencies.
pkg> activate DeckAPI
(DeckAPI) pkg> add RestClient JSON3
Updating `/tmp/DeckAPI/Project.toml`
[e1389577] + RestClient v0.1.0
[0f8b85d8] + JSON3 v1.14.1
Updating `/tmp/DeckAPI/Manifest.toml`
[...] ...
Then we can navigate to DeckAPI/src/DeckAPI.jl
, get rid of the default greet() = print("Hello World!")
, and add
using RestClient, JSON3
Now we're ready to get started implementing the API! To start with, we'll want to create a RequestConfig
to hold the context in which we're calling the API. This just holds the base URL, a request lock, API access key (optional), and timeout value (optional). The Deck of Cards API is simple enough that we can set a single global RequestConfig
, but in a more complex case we might define a utility function to create a RequestConfig
based on user-provided parameters.
const DECK_CONFIG = RequestConfig("https://deckofcardsapi.com/api")
For use with @endpoint
-generated functions later, we'll also declare this to be the global request config for our package.
RestClient.globalconfig(::Val{DeckAPI}) = DECK_CONFIG
If we didn't care about creating the DECK_CONFIG
variable, this could be simplified to just @globalconfig RequestConfig(...)
.
That's all the setup needed, next we'll define types for the JSON structures that Desk of Cards can return.
Defining API types
Reading the documentation in order, we come across a few types we'll want to implement. First, there's the new deck object
{
"success": true,
"deck_id": "3p40paa87x90",
"shuffled": true,
"remaining": 52
}
Thanks to the @jsondef
macro, this is merely a matter of
@jsondef struct Deck
# success::Bool # I don't think we care about this?
id."deck_id"::String
# shuffled::Bool # We should know this anyway?
remaining::Int
end
We can also draw cards, which look like this
@jsondef struct Card
code::String
# image # Not needed by us (at the moment)
value::String
suit::String
end
Cards are given as a list in a certain response form, which gives us an opportunity to define a ListResponse
subtype.
@jsondef struct CardsResponse <: ListResponse{Card}
deck."deck_id"::String
remaining::Int
cards::Vector{Card}
end
Since we've subtyped ListResponse
, this will automagically be turned into a List
holding cards
.
Adding endpoints
In RestClient
, the bundle of information required to make a request to an endpoint is represented with a dedicated struct, subtyping AbstractEndpoint
.
The endpoint can then be implemented through the various endpoint interface functions (pagename
, headers
, parameters
, parameters
, responsetype
, and postprocess
). We'll start by implementing appropriate methods for the deck creation endpoint, then show how this can be simplified using @endpoint
shorthand.
The new deck endpoint
When creating a deck, the number of included 52-card decks and whether it should be shuffled can be specified. So, we create a struct that holds these two pieces of information.
struct NewDeckEndpoint <: AbstractEndpoint
count::Int
shuffle::Bool
end
This endpoint is located at either deck/new
or desk/new/shuffle
depending on whether it should be shuffled.
RestClient.pagename(new::NewDeckEndpoint) = "deck/new" * ifelse(new.shuffle, "/shuffle", "")
The deck count is specified using the deck_count
parameter, and so we should add a parameters
method too.
RestClient.parameters(new::NewDeckEndpoint) = ["deck_count" => string(new.count)]
After performing this request, we expect a JSON representation of a Deck
to be returned. Since we used @jsondef
to create the type, JSON3 will know how to parse it and we need only declare that we expect a Deck
response.
RestClient.responsetype(::NewDeckEndpoint) = Deck
At this point we can call the endpoint, and create a function for someone using DeckAPI to call.
new(count::Int = 1; shuffled::Bool = false) =
perform(Request{:get}(DECK_CONFIG, NewDeckEndpoint(count, shuffled)))
Next we'll show how these steps can be performed by a single @endpoint
statement.
Shuffle endpoint
For the shuffle endpoint, we'll use some of the capabilities of @endpoint
to simplify the declaration of the endpoint location, parameters, and response type.
@endpoint struct ShuffleEndpoint
"deck/{deck.id}/shuffle?{remaining}" -> Deck
deck::Deck
remaining::Bool
end
This sets the response type to Deck
, the location based on the deck id, and ?{remaining}
will expand to ["remaining" => string(self.remaining)]
.
Now we just need to define a function to access this endpoint.
shuffle(deck::Deck, remaining::Bool=false) =
perform(Request{:get}(DECK_CONFIG, ShuffleEndpoint(deck, remaining)))
Draw endpoint
The draw endpoint has a similar form to shuffle. You specify a target deck with deck/{deck.id}
, but then end with shuffle
and specify the number of cards that should be drawn.
The step of defining an accessor method can also be performed by the @endpoint
macro, so long as the appropriate globalconfig
method is defined.
@endpoint draw(deck::Deck, count::Int) -> "deck/{deck.id}/draw?{count}" -> CardsResponse
A DrawEndpoint
struct will automatically be created, and since CardsResponse
is ListResponse
, the DrawEndpoint
struct will subtype ListEndpoint
. Due to this subtyping, the CardsResponse
will automatically be restructured into a List{Card}
by the generic list postprocess
method.
Card return endpoint
The Deck of Cards API also allows for drawn cards to be returned to the deck. This is slightly more complicated than the other endpoints because you can optionally specify whether specific cards should be returned instead of everything.
We will account for this by leaving the endpoint parameters out of our @endpoint
construction, and then separately defining a parameters
method.
@endpoint putback(deck::Deck, cards::Union{Nothing, Vector{Card}}) ->
"deck/{deck.id}/return" -> Deck
function RestClient.parameters(pb::PutbackEndpoint)
if isnothing(pb.cards)
Pair{String, String}[]
else
["cards" => join(map(c -> c.code, pb.cards), ",")]
end
end
Since we can end up with a List{Card}
from the draw endpoint, and a List
contains the original request information (including the deck field of the draw endpoint), we can also provide a putback
method that operates on a List{Card}
for convenience.
putback(cardlist::List{Card}) = putback(cardlist.request.endpoint.deck, cardlist.items)
Demonstration
By starting Julia with the environment variable JULIA_DEBUG=RestClient
set, we will see information on the requests sent and responses received. This helps us verify that everything is behaving as expected, and debug any failures or unexpected results.
julia> deck = DeckAPI.new()
┌ Debug: GET https://deckofcardsapi.com/api/deck/new?deck_count=1
└ @ RestClient
┌ Debug: 200 80 bytes (saved to /tmp/api-get.dump) from https://deckofcardsapi.com/api/deck/new?deck_count=1
└ @ RestClient
DeckAPI.Deck(id="01n3ezer3rly", remaining=52)
julia> cards = DeckAPI.draw(deck, 5)
┌ Debug: GET https://deckofcardsapi.com/api/deck/01n3ezer3rly/draw?count=5
└ @ RestClient
┌ Debug: 200 1.181 KiB (saved to /tmp/api-get.dump) from https://deckofcardsapi.com/api/deck/01n3ezer3rly/draw?count=5
└ @ RestClient
RestClient.List{DeckAPI.Card} holding 5 items:
• Card(code="AS", value="ACE", suit="SPADES")
• Card(code="2S", value="2", suit="SPADES")
• Card(code="3S", value="3", suit="SPADES")
• Card(code="4S", value="4", suit="SPADES")
• Card(code="5S", value="5", suit="SPADES")
julia> DeckAPI.putback(cards)
┌ Debug: GET https://deckofcardsapi.com/api/deck/01n3ezer3rly/return?cards=AS%2C2S%2C3S%2C4S%2C5S
└ @ RestClient
┌ Debug: 200 61 bytes (saved to /tmp/api-get.dump) from https://deckofcardsapi.com/api/deck/01n3ezer3rly/return?cards=AS%2C2S%2C3S%2C4S%2C5S
└ @ RestClient
DeckAPI.Deck(id="01n3ezer3rly", remaining=52)
julia> DeckAPI.shuffle(deck)
┌ Debug: GET https://deckofcardsapi.com/api/deck/01n3ezer3rly/shuffle?remaining=false
└ @ RestClient
┌ Debug: 200 79 bytes (saved to /tmp/api-get.dump) from https://deckofcardsapi.com/api/deck/01n3ezer3rly/shuffle?remaining=false
└ @ RestClient
DeckAPI.Deck(id="01n3ezer3rly", remaining=52)
julia> cards = DeckAPI.draw(deck, 5)
┌ Debug: GET https://deckofcardsapi.com/api/deck/01n3ezer3rly/draw?count=5
└ @ RestClient
┌ Debug: 200 1.183 KiB (saved to /tmp/api-get.dump) from https://deckofcardsapi.com/api/deck/01n3ezer3rly/draw?count=5
└ @ RestClient
RestClient.List{DeckAPI.Card} holding 5 items:
• Card(code="3C", value="3", suit="CLUBS")
• Card(code="QC", value="QUEEN", suit="CLUBS")
• Card(code="4S", value="4", suit="SPADES")
• Card(code="2D", value="2", suit="DIAMONDS")
• Card(code="3S", value="3", suit="SPADES")