Much ink and pixels have been spent discussing the virtues and flaws of Hypermedia for API design. Like with REST, the sheer amount of theory and jargon around the subject can make it hard to understand the potential benefits for you as an API developer, the cost of implementation and the consequences it would have on the way you build, and interact with REST APIs.
In this article I’ll show the outlines of what a Hypermedia API looks like, and how a generic Ruby client for such APIs can be designed following these patterns.
Our definition of Hypermedia will be this: Hypermedia for API design means adding links to your API responses.
Links
By link, I mean a link as you already know it from HTML documents:
<a href="http://server.com/foobar">Foo bar</a>
<link rel="stylesheet" href="/styles.css" />
So at the very least, a link is an address or pointer to a separate resource somewhere in the network.
Links encode actions
But, in our definition, this will also constitute a link:
<form action="/orders/place" method="PUT">
<input type="text" name="discount_code" value="" />
<select name="shipping">
<option value="1">Free shipping</option>
<option value="2">Expensive shipping</option>
</select>
</form>
So not just an address, but also the minimum information needed to instruct the client on how it should interact with the referenced resources. In the example above, a HTML form tells the client —the browser— that it must send a PUT request, and also defines a schema for the data expected by the server.
In other words, links or forms encapsulate actions that the client may take over resources, or ways in which it can change the state of a resource. These, in a nutshell, are the ideas behind HATEOAS, and they have been derived from the way the World Wide Web works as we know it and interact with every day.
An example
So how does any of this apply to REST APIs?
Let’s imagine a simple shopping cart API that shows us information about an order.
GET /orders/123
{
"updated_at": "2017-04-10T18:30Z",
"id": 123,
"status": "open",
"total": 1000
}
Now let’s say that you can place an open order by issuing a PUT
request
against a resource, something like
PUT /orders/123/completion
The server updates the state of the order and returns a new representation:
{
"updated_at": "2017-04-10T20:00Z",
"id": 123,
"status": "placed",
"total": 1000
}
Nothing new here. This is how most of us build REST/JSON APIs.
How do you know that you can place an open order? You’d check the relevant documentation, which would include information on the URL to use, the required request method, available parameters, etc.
If you wanted to use this API in Ruby, you’d grab any number of available HTTP client libraries and end up with something like this:
# get an order
order = Client.get("/orders/123")
# place the order
order = Client.put("/orders/123/completion")
After a while, manually concatenating URL paths gets tedious and error-prone, so as an API client author you’ll probably come up with something slightly more domain-specific:
order = Client.get_order(123)
order = Client.place_order(123)
This is what many Ruby API client libraries look like.
APIs and links
Now let’s re-implement the same example using a Hypermedia approach. State transitions —actions— can be encoded as links in the API responses themselves.
GET /orders/123
{
"_links": {
"place_order": {
"method": "put",
"href": "https://api.com/orders/123/completion"
}
},
"updated_at": "2017-04-10T18:30Z",
"id": 123,
"status": "open",
"total": 1000
}
I’m using an extension of the HAL specification to encode a “place_order” link, including its request method and target URL.
For one, this makes the order resource a little bit more informative to humans: not only does it tell you about the current state of an order, but what you can do with it.
The API client
As an API client implementor, you can leverage this simple convention and write a generic client that learns what actions are available from the API itself. This is particularly easy to do in Ruby.
Let’s start from the top. First thing is to implement a class that wraps any API resource and exposes its attributes and links. I’m calling it Entity.
class Entity
def initialize(data, http_client)
@data = data
@links = @data.fetch("_links", {})
@client = http_client
end
# ...
end
data
is the JSON data (a Hash) for the resource itself. http_client
for now
is anything that supports the basic HTTP operations (get
, post
, put
,
delete
, etc). It can be an instance of
Faraday or anything, really.
We then use the method_missing
and
respond_to_missing?
combo to delegate data access to any properties available in the resource data.
class Entity
# ...
def method_missing(method_name, *args, &block)
if @data.key?(method_name)
@data[method_name]
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
@data.key?(method_name)
end
end
With this, we can access regular properties.
order = Entity.new(json_data, http_client)
order.id # 123
order.status # "open"
What about links? We can delegate those to a Link
class that’ll handle making
the relevant HTTP request and returning the response.
class Entity
# ...
def method_missing(method_name, *args, &block)
if @data.key?(method_name) # if it's a regular property...
@data[method_name]
elsif @links.key?(method_name) # if it's a link...
Link.new(@links[method_name], @client).run(*args)
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
@data.key?(method_name) || @links.key?(method_name)
end
end
We instantiate a Link
passing the link data and the HTTP client instance, and
then we run it with any optional arguments.
This allows us to run those links like any regular method call. For example placing an order:
order = Entity.new(json_data, http_client)
# this will issue a PUT request under the hood
placed_order = order.place_order
The Link class
The link wrapper class delegates the actual HTTP request handling to the underlying HTTP client.
link = Link.new({
"href": "https://api.com/orders/123/completion",
"method": "put"
}, http_client)
We initialize it with the link data and the HTTP client.
class Link
attr_reader :request_method, :href
def initialize(attrs, http_client)
@request_method = attrs.fetch("method", :get).to_sym
@href = attrs.fetch("href")
@http_client = http_client
end
# ...
end
The run
method uses the HTTP client to issue the appropriate HTTP request and
wraps the response in a new Entity
.
class Link
# ...
# we'll assume that request bodies need to be JSON-encoded
def run(payload = {})
response = case request_method
when :get
@http_client.get(href, payload)
when :put
@http_client.put(href, JSON.dump(payload))
when :delete
# etc
end
# error handling on failed responses...
# wrap response body in new Entity
Entity.new(response.body, @http_client)
end
end
Because a Link
sends optional parameters to the relevant endpoint, we can pass
any data expected by the API. For example:
placed_order = order.place_order(
discount_code: "ABC"
)
Links encode capabilities
The presence or absence of links in a resource is meaningful information. For
example, an “open” order may include a place_order
link, but an order that has
already been placed shouldn’t let the client place it again.
We can add some syntax sugar to our Entity
class to better interrogate
resources for their supported links.
class Entity
# ...
def can?(link_name)
@links.key? link_name.to_s
end
end
So client code can decide what to do based not so much on the data in a resource, but on its capabilities.
if order.can?(:place_order)
# PUT https://api.com/orders/123/place
order = order.place_order
end
if order.can?(:start_order_fulfillment)
# POST https://api.com/orders/123/fulfillments
fulfillment = order.start_order_fulfillment(...)
end
This pattern has the side-effect of encouraging you to keep the business logic on the server side. The client app is driven by the API’s capabilities, presented dynamically to the client along with every resource.
For example, instead of hardcoding conditional logic to decide whether of not to show a button to place an order (based on the current state of the order, possibly), a client application can blindly render the relevant UI elements if the expected links are present in the current resource.
<% if order.can?(:place_order) %>
<button type="submit">Place this order</button>
<% end %>
A client written in this way can be kept pretty generic. New endpoints available in the API don’t need client implementors to release new versions of the client library. As long as the API presents a new link, the client can follow it. The release cycles of API and client are thus disentangled.
The API root
The API root is the entry point into a Hypermedia-enabled API, and the only URL a client needs to know about. Much as a website’s homepage, the root resource will most likely include a list of links to the main operations supported by your API.
GET /
{
"_links": {
"orders": {
"href": "https://...",
},
"create_order": {
"href": "https://...",
"method": "post"
}
}
}
We only need to wrap up our Entity
and Link
classes into a client that we
can initialize with a root URL.
class ApiClient
def initialize(root_url, http_client)
@root_url, @http_client = root_url, http_client
end
def root(url = @root_url)
response = @http_client.get(url)
Entity.new(response.body, @http_client)
end
end
Once on the root resource, a client can just follow available links by name. The actual URLs they point to may or may not be on the same host. As long as the responses conform to the same conventions, the client —and the users of your SDKs— don’t need to care.
api = ApiClient.new("https://api.com", SomeHttpClient)
root = api.root
# create order
order = root.create_order(line_items: [...])
# add items
order.add_line_item(id: "iphone", quantity: 2)
# place it
order = order.place_order
Workflows
As an API designer, I’ve found that this approach encourages me to not only think of individual endpoints but of entire workflows through the service. How do you start and place an order? How do you upload an image? By adding the right links in the right places I can help make it easier for my customers to accomplish particular tasks.
These workflows can form the core of the API’s documentation, too, by documenting the sequence of links to be run for each use case instead of just singular endpoints.
Pagination
A nice workflow to implement in this way is paginating over resources that represent lists of things.
GET /orders
{
"_links": {
"next": {
"href": "https://api.com/orders?page=2"
}
},
"total_items": 323,
"items": [
{"id": 123, "total": 100},
{"id": 234, "total": 50},
// ... etc
]
}
In this convention, a list resource will have an items
array of orders. If
there are more pages available, the resource can include a next
link.
We can extend Entity
to implement the Enumerable
interface for list
resources.
module EnumerableEntity
include Enumerable
def each(&block)
self.items.each &block
end
end
class Entity
def initialize(data, client)
# ...
self.extend EnumerableEntity if data.key?("items")
end
end
The client can now ask the entity whether it can be paginated.
page = root.orders
page.each do |order|
puts order.status
end
page = page.next if page.can?(:next)
We can take this one step further and implement an Enumerator that will consume the entire data set in a memory-efficient way.
module EnumerableEntity
# ...
def to_enum
# start with the first page
page = self
Enumerator.new do |yielder|
loop do
# iterate over items in the first page
page.each{|item| yielder.yield item }
# stop iteration if we've reached the last page
raise StopIteration unless page.can?(:next)
# navigate to the next page and iterate again
page = page.next
end
end
end
end
We can now treat potentially thousands of orders in a paginated API as a simple array.
all_orders = root.orders(sort: "total.desc").to_enum
all_orders.each{|o| ...}
all_orders.map(&:total)
all_orders.find_all{|o| o.total > 200 }
Non-hypermedia APIs
Even if your API doesn’t include links, a generic Ruby client like the one described here can be used to describe endpoints in your service with little extra work.
class ApiClient
# ...
def from_hash(data)
Entity.new(data, @http_client)
end
end
api = ApiClient.new(nil, SomeHttpClient)
orders_service = api.from_hash({
"_links": {
"orders": {"href": "...", "method": "get"},
"create_order": {"href": "...", "method": "post"}
}
})
order = orders_service.create_order(...)
order = order.place_order
# etc
New releases of such a client could consist of just a YAML file with the entire API definition.
Reference
- HAL a simple specification for Hypermedia API responses.
- BooticClient a Ruby client I wrote for a specific Hypermedia API, usable for any API that follows the conventions in this post.
- HyperClient another Hypermedia Ruby client. I haven’t used it much but it looks pretty mature.
- Presentation at the London Ruby User Group (video).