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