In this chapter, we are going to go back to Alexandria and analyze why the API we built is not RESTful. We will also discuss why I decided to show you how to build a non-RESTful API first. Finally, we will update Alexandria with a custom media type and hypermedia. We will also check how we could implement stateless authentication.
First, let’s answer this question:
Why didn’t I show you how to build RESTful APIs right away?
Well, that’s based on the fact that 99% of the APIs out there are not RESTful and you will probably end up working on one of them. It’s important to understand HTTP APIs and their limitations, since you probably won’t be able to rewrite everything from scratch. You might not even want to if your scenario doesn’t benefit from the REST constraints.
Let’s go through each constraint one more time and see if Alexandria supports them.
Not much to say here; any applications running on the Web tends to respect this constraint. That includes Alexandria.
Alexandria is clearly not stateless due to the authentication scheme we implemented. We’ve already discussed this facet and why we’ve decided to do that.
Alexandria is alright the way it is now, and I personally believe that going stateless simply gives too much responsibility to the clients.
One way to fix this would be to simply use one of the HTTP authentication scheme, but that’s probably not the best option. If you want to look into stateless authentication, I recommend checking out JSON Web Tokens.
Any request coming out of Alexandria includes the headers defining if the response can be cached or not. This constraint is respected.
The Uniform Interface constraint is actually composed of four sub-constraints.
Resources are identified using clean URIs in Alexandria. This constraint is respected.
Resources are manipulated through the representations we send back to the client. Using the identifiers contained in the representations, clients are able to modify or delete representations.
Using HTTP headers correctly, our requests are self-descriptive and clients are able to understand the representation using the embedded metadata.
This is another constraint we violated. We did so because most APIs today don’t respect it, and you should know how those work. However, this is something we are going to fix in the next section.
Nothing much to say here, Alexandria doesn’t prevent the use of proxies or intermediate caches. Constraint respected!
Sadly, we don’t have Code-On-Demand. Since it’s an optional constraint, we don’t need to care too much about it.
One of the recommendations given by Fielding for web APIs is that you should not document your API and instead focus on the documentation of your application media type. That’s where you can define the semantics of your application representations instead of explaining which endpoints can be used. This should actually be discovered by using your RESTful API.
A REST API should spend almost all of its descriptive effort in defining the media type(s) used for representing resources and driving application state, or in defining extended relation names and/or hypertext-enabled markup for existing standard media types. Any effort spent describing what methods to use on what URIs of interest should be entirely defined within the scope of the processing rules for a media type (and, in most cases, already defined by existing media types). Failure here implies that out-of-band information is driving interaction instead of hypertext.
—Roy T. Fielding
Let’s implement a custom media type. We are going to call it application/vnd.alexandria.v1+json
. This should remind you of the media type used by GitHub.
Our application currently works properly. Since we want to add a new feature, let’s create a new branch.
git checkout -b hypermedia
We will be working on this branch until the end of this chapter. Note that we won’t deploy the hypermedia version of Alexandria. It would require fixing all the tests that are going to break by adding our custom media type in the headers of each request.
If you’ve bought one of the bigger packages, you have the updated version of the tests.
We only want to use the application/vnd.alexandria.v1+json
media type from now on. This means we need to change the way we receive data from the client and the way we send data back. If we were writing documentation for this media type, we would explain how the representations are formatted. We would also define what format the client should use to send data to the server.
We are not going to write tests in this chapter. We will simply use curl
to ensure that our implementation works as expected.
First, let’s change the representations and only allow the client to request our custom media type. To check which media type the client wants, we need to extract that information from the Accept
header. The easiest way to do this, and sort the media types by preference, is to the the rack-accept gem.
Add it to your Gemfile
…
# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '2.5.0'
gem 'rails', '5.2.0'
gem 'pg'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'carrierwave'
gem 'carrierwave-base64'
gem 'pg_search'
gem 'kaminari'
gem 'bcrypt', '~> 3.1.7'
gem 'pundit'
gem 'money-rails', '1.11.0'
gem 'stripe'
gem 'oj'
gem 'rack-cors', :require => 'rack/cors'
gem 'rack-accept'
# Hidden code
and get it installed.
bundle install
Next, we need to tell Rails that we will be using a new custom media type. We are also going to add a new renderer to make it easier to format our representations.
Open the config/initializers/mime_types.rb
file and insert the following code into it.
# config/initializers/mime_types.rb
Mime::Type.register 'application/vnd.alexandria.v1+json', :alexandria_json_v1
# If we need to do some versioning
# Mime::Type.register "application/vnd.alexandria.v2+json", :alexandria_json_v2
# Or use a different base format
# Mime::Type.register "application/vnd.alexandria.v1+xml", :alexandria_json_v1
ActionController::Renderers.add :alexandria_json_v1 do |obj, options|
# We set the content type here
self.content_type = Mime::Type.lookup('application/vnd.alexandria.v1+json')
self.response_body = obj
end
This code will let us write the following in our controllers:
render alexandria_json_v1: data.to_json
Next, let’s move all our serializers into a new module, called V1
, to match our custom media type version.
mkdir app/serializers/alexandria/v1 && \
mv app/serializers/alexandria/serializer.rb \
app/serializers/alexandria/v1/serializer.rb &&
mv app/serializers/alexandria/collection_serializer.rb \
app/serializers/alexandria/v1/collection_serializer.rb &&
mv app/serializers/alexandria/entity_serializer.rb \
app/serializers/alexandria/v1/entity_serializer.rb
In order to use a different media type in the future, like one of the hypermedia formats we will discover in the next chapter, we can simply add a new module in serializers/
since we already encapsulated our custom serializers in the Alexandria
module. We could add a new module for each format we want to support: app/serializers/json_api
, app/serializers/hal
, etc.
Let’s update all our serializers with our new modules.
# app/serializers/alexandria/serializer.rb
module Alexandria
module V1
class Serializer
def initialize(data:, params:, actions:, options: {})
@data = data
@params = params
@actions = actions
@options = options
@serializer = if @data.is_a?(ActiveRecord::Relation)
collection_serializer
else
entity_serializer
end
end
def to_json
# We skip caching if the presenter is not configured for it
return data unless @serializer.cache?
Rails.cache.fetch("#{@serializer.key}/json", { raw: true }) do
data
end
end
private
def data
{ data: @serializer.serialize }.to_json
end
def collection_serializer
CollectionSerializer.new(@data, @params, @actions)
end
def entity_serializer
presenter_klass = "#{@data.class}Presenter".constantize
presenter = presenter_klass.new(@data, @params, @options)
EntitySerializer.new(presenter, @actions)
end
end
end
end
# app/serializers/alexandria/collection_serializer.rb
module Alexandria
module V1
class CollectionSerializer
def initialize(collection, params, actions)
@collection = collection
@params = params
@actions = actions
end
def serialize
return @collection unless @collection.any?
return build_data
end
def key
# We hash the key using SHA1 to reduce its size
@key ||= Digest::SHA1.hexdigest(build_key)
end
def cache?
presenter_class.cached?
end
private
def build_data
@collection.map do |entity|
presenter = presenter_class.new(entity, @params)
EntitySerializer.new(presenter, @actions).serialize
end
end
def presenter_class
@presenter_class ||= "#{@collection.model}Presenter".constantize
end
# Building the key is complex. We need to take into account all
# the parameters the client can send.
def build_key
last = @collection.unscoped.order('updated_at DESC').first
presenter = presenter_class.new(last, @params)
updated_at = last.try(:updated_at).try(:to_datetime)
cache_key = "collection/#{last.class}/#{updated_at}"
[:sort, :dir, :page, :per, :q].each do |param|
cache_key << "/#{param}:#{@params[param]}" if @params[param]
end
if presenter.validated_fields.present?
cache_key << "/fields:#{presenter.validated_fields}"
end
if presenter.validated_embeds.present?
cache_key << "/embeds:#{presenter.validated_embeds}"
end
cache_key
end
end
end
end
# app/serializers/alexandria/entity_serializer.rb
module Alexandria
module V1
class EntitySerializer
def initialize(presenter, actions)
@presenter = presenter
@entity = @presenter.object
@actions = actions
end
def serialize
return @presenter.build(@actions)
end
def key
@key ||= Digest::SHA1.hexdigest(build_key)
end
def cache?
@presenter.class.cached?
end
private
def build_key
updated_at = @entity.updated_at.to_datetime
cache_key = "model/#{@entity.class}/#{@entity.id}/#{updated_at}"
if @presenter.validated_fields.present?
cache_key << "/fields:#{@presenter.validated_fields}"
end
if @presenter.validated_embeds.present?
cache_key << "/embeds:#{@presenter.validated_embeds}"
end
cache_key
end
end
end
end
Our serializers are ready; now, we can change the way serialization works at the controller level. First, we are going to remove the serialize
method from the application
controller and move it to a new concern.
touch app/controllers/concerns/serialization.rb
# app/controllers/concerns/serialization.rb
module Serialization
extend ActiveSupport::Concern
def serialize(data, options = {})
{ json: json(data, options) }
end
private
def json(data, options)
Alexandria::V1::Serializer.new(data: data,
params: params,
actions: [:fields, :embeds],
options: options).to_json
end
end
Let’s include this new module in the ApplicationController
class. Don’t forget to remove the serialize
method from there as well.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include Authentication
include Authorization
include Serialization
rescue_from QueryBuilderError, with: :builder_error
rescue_from RepresentationBuilderError, with: :builder_error
rescue_from ActiveRecord::RecordNotFound, with: :resource_not_found
protected
def builder_error(error)
render status: 400, json: {
error: {
type: error.class,
message: error.message,
invalid_params: error.invalid_params
}
}
end
def unprocessable_entity!(resource, errors = nil)
render status: :unprocessable_entity, json: {
error: {
message: "Invalid parameters for resource #{resource.class}.",
invalid_params: errors || resource.errors
}
}
end
def orchestrate_query(scope, actions = :all)
QueryOrchestrator.new(scope: scope,
params: params,
request: request,
response: response,
actions: actions).run
end
# Removed the serialize method
def resource_not_found
render(status: 404)
end
end
Next, we need to extract the media types that the client accepts from the Accept
header. We also want to compare those media types with the ones we support to get the best candidate to generate the representation.
That’s the responsibility of the find_acceptable
method below. The supported media types are contained in the ACCEPTED_MEDIA_TYPES
constant.
Note that we only support one media type:
application/vnd.alexandria.v1+json
If the client uses wildcards, we will also return that media type.
# app/controllers/concerns/serialization.rb
module Serialization
extend ActiveSupport::Concern
ACCEPTED_MEDIA_TYPES = {
'*/*' => :alexandria_json_v1,
'application/*' => :alexandria_json_v1,
'application/vnd.alexandria.v1+json' => :alexandria_json_v1
}
def serialize # Hidden Code
private
def json # Hidden Code
def accepted_media_type
@accepted_media_type ||= find_acceptable
end
def find_acceptable
accept_header = request.headers['HTTP_ACCEPT']
accept = Rack::Accept::MediaType.new(accept_header).qvalues
accept.each do |media_type, q|
return media_type if ACCEPTED_MEDIA_TYPES[media_type]
end
nil
end
end
We can now use a before_action
filter to ensure that at least one of the formats accepted by the client is also supported by the server. If that’s not the case, we need to return a 406 Unacceptable Error
.
# app/controllers/concerns/serialization.rb
module Serialization
extend ActiveSupport::Concern
ACCEPTED_MEDIA_TYPES # Hidden Code
included do
before_action :acceptable?
end
def serialize # Hidden Code
def acceptable?
unacceptable! unless accepted_media_type
end
private
def json # Hidden Code
def accepted_media_type # Hidden Code
def find_acceptable # Hidden Code
def unacceptable!
accept = request.headers['HTTP_ACCEPT']
render status: 406, json: {
message: "No acceptable media type in Accept header: #{accept}",
acceptable_media_types: ACCEPTED_MEDIA_TYPES.keys
}.to_json
end
end
Finally, we need to change the way we render our JSON
documents. From now on, we don’t want to use the json
renderer anymore; instead, we should use the renderer and the serializer that match the media type accepted by the client.
For that, we have to change the serialize
method to use the renderer returned by the ACCEPTED_MEDIA_TYPES
constant. To use the correct serializer, we call a method named like the renderer (alexandria_json_v1
).
If we had more serialization options, we should extract that logic somewhere else - but since we only support one format, we can get away with it by only adding a method.
# app/controllers/concerns/serialization.rb
module Serialization
extend ActiveSupport::Concern
ACCEPTED_MEDIA_TYPES # Hidden Code
included do
before_action :acceptable?
end
def serialize(data, options = {})
{ renderer => send(renderer, data, options) }
end
def acceptable? # Hidden Code
private
def alexandria_json_v1(data, options)
Alexandria::V1::Serializer.new(data: data,
params: params,
actions: [:fields, :embeds],
options: options).to_json
end
def renderer
@render ||= ACCEPTED_MEDIA_TYPES[accepted_media_type]
end
def accepted_media_type # Hidden Code
def find_acceptable # Hidden Code
def unacceptable!
accept = request.headers['HTTP_ACCEPT']
render status: 406, alexandria_json_v1: {
message: "No acceptable media type in Accept header: #{accept}",
acceptable_media_types: ACCEPTED_MEDIA_TYPES.keys
}.to_json
end
end
Here is the complete Serialization
module for reference.
# app/controllers/concerns/serialization.rb
module Serialization
extend ActiveSupport::Concern
ACCEPTED_MEDIA_TYPES = {
'*/*' => :alexandria_json_v1,
'application/*' => :alexandria_json_v1,
'application/vnd.alexandria.v1+json' => :alexandria_json_v1
}
included do
before_action :acceptable?
end
def serialize(data, options = {})
{ renderer => send(renderer, data, options) }
end
def acceptable?
unacceptable! unless accepted_media_type
end
private
def alexandria_json_v1(data, options)
Alexandria::V1::Serializer.new(data: data,
params: params,
actions: [:fields, :embeds],
options: options).to_json
end
# If we had a v2
# def alexandria_json_v2(data)
# Alexandria::V2::Serializer.new(data: data,
# params: params,
# actions: [:fields, :embeds],
# options: options).to_json
# end
def renderer
@render ||= ACCEPTED_MEDIA_TYPES[accepted_media_type]
end
def accepted_media_type
@accepted_media_type ||= find_acceptable
end
def find_acceptable
accept_header = request.headers['HTTP_ACCEPT']
accept = Rack::Accept::MediaType.new(accept_header).qvalues
accept.each do |media_type, q|
return media_type if ACCEPTED_MEDIA_TYPES[media_type]
end
nil
end
def unacceptable!
accept = request.headers['HTTP_ACCEPT']
render status: 406, alexandria_json_v1: {
message: "No acceptable media type in Accept header: #{accept}",
acceptable_media_types: ACCEPTED_MEDIA_TYPES.keys
}.to_json
end
end
Let’s write some tests to ensure that the serialization is working as expected. We should write tests for the serializers, but we are just going to skip it (I think you already know how to write tests by now). We still need to make sure our implementation is working, though, so let’s write a few request tests.
touch spec/requests/serialization_spec.rb
# spec/requests/serialization_spec.rb
require 'rails_helper'
RSpec.describe 'Serialization', type: :request do
let(:api_key) { ApiKey.create }
let(:headers) do
{
'HTTP_AUTHORIZATION' =>
"Alexandria-Token api_key=#{api_key.id}:#{api_key.key}",
'HTTP_ACCEPT' => accept
}
end
let(:valid_media_type) { 'application/vnd.alexandria.v1+json; charset=utf-8' }
context 'with "Accept: application/xml, */*"' do
let(:accept) { 'application/xml, */*' }
it 'returns 200 OK' do
get '/api/books', headers: headers
expect(response.status).to eq 200
end
it 'returns "application/vnd.alexandria.v1+json"' do
get '/api/books', headers: headers
expect(response.headers['Content-Type']).to eq valid_media_type
end
end
context 'with "Accept: application/vnd.alexandria.v1+json"' do
let(:accept) { 'application/vnd.alexandria.v1+json' }
it 'returns 200 OK' do
get '/api/books', headers: headers
expect(response.status).to eq 200
end
it 'returns "application/vnd.alexandria.v1+json"' do
get '/api/books', headers: headers
expect(response.headers['Content-Type']).to eq valid_media_type
end
it 'returns valid JSON' do
get '/api/books', headers: headers
expect { JSON.parse(response.body) }.to_not raise_exception
end
end
context 'with "Accept: application/json"' do
let(:accept) { 'application/json' }
it 'returns 406 Unacceptable' do
get '/api/books', headers: headers
expect(response.status).to eq 406
end
it 'returns "application/vnd.alexandria.v1+json"' do
get '/api/books', headers: headers
expect(response.headers['Content-Type']).to eq valid_media_type
end
end
end
Run the tests.
rspec spec/requests/serialization_spec.rb
Success (GREEN)
...
Serialization
with "Accept: application/xml, */*"
returns 200 OK
returns "application/vnd.alexandria.v1+json"
with "Accept: application/vnd.alexandria.v1+json"
returns 200 OK
returns "application/vnd.alexandria.v1+json"
returns valid JSON
with "Accept: application/json"
returns 406 Unacceptable
returns "application/vnd.alexandria.v1+json"
Finished in 0.51037 seconds (files took 2.86 seconds to load)
7 examples, 0 failures
Looks good!
We are now letting the client know that the vnd.alexandria.v1+json
media type should be used.
The serialization part is done, and we can control versioning at the media type level. Now, we need a way to tell the client when the submitted data for POST
and PATCH
requests are not in the correct format. In order to support multiple media types, like JSON API (which we will study in the next chapter), it’s a good idea to encapsulate the data parsing into specialized classes. Those classes will be responsible for receiving data in format XYZ and normalize it in a way that our controllers can understand.
Let’s create our first parser! Just like the serializers, parsers are interfaces that allow the client and the server to communicate using various formats. Parsers are for the data received by the server while serializers are for the data sent by the server.
Add a parsers
and parsers/alexandria/
folders. We also need a default_parser.rb
file.
mkdir -p app/parsers/alexandria/v1 && \
touch app/parsers/alexandria/v1/default_parser.rb
The default parser is not going to do much. The controllers in Alexandria currently expect data formatted in a specific way, and we are not going to change that. Therefore, this parser will simply replace what Rails does to generate the params
object.
We also want a 400 Bad Request
to be returned to the client if the request cannot be parsed - this is why we enclosed the parsing in a begin rescue
block.
# app/parsers/alexandria/v1/default_parser.rb
module Alexandria
module V1
class DefaultParser
def initialize(request)
@request = request
end
def parse(params)
body = @request.body.read
params = params.merge(JSON.parse(body)).to_unsafe_hash unless body.blank?
ActionController::Parameters.new(params)
end
end
end
end
We are going to create one more module, named RequestParsing
, to ensure that data sent by the client can be read by the server.
First, create a new file.
touch app/controllers/concerns/request_parsing.rb
Here is the minimum code for this module. We define which media types are supported for input data with the SUPPORTED_MEDIA_TYPES
constant. We are also defining two methods, parser
and content_type
. The content_type
method only extracts the content type specified by the client while parser
uses that content type to get the matching parser class from SUPPORTED_MEDIA_TYPES
.
# app/controllers/concerns/request_parsing.rb
module RequestParsing
extend ActiveSupport::Concern
SUPPORTED_MEDIA_TYPES = {
'application/vnd.alexandria.v1+json' => Alexandria::V1::DefaultParser
}
private
def parser
@parser ||= SUPPORTED_MEDIA_TYPES[content_type].new(request, params)
end
def content_type
@content_type ||= request.headers['Content-Type']
end
end
We also want to ensure that the data is parsed when the client makes a POST
or PATCH
request since only those two methods come with a body. For that, we use a before_action
filter.
We also rescue from the ActionController::BadRequest
error (which can be raised by a parser) and return 400 Bad Request
to the client.
Finally, we override the params
method to use our parser.
# app/controllers/concerns/request_parsing.rb
module RequestParsing
extend ActiveSupport::Concern
SUPPORTED_MEDIA_TYPES = {
'application/vnd.alexandria.v1+json' => Alexandria::V1::DefaultParser
}
included do
rescue_from JSON::ParserError, with: :bad_request
before_action :supported_media_type?, only: [:create, :update]
end
def params
@params ||= @parse_body ? parser.parse(super) : super
end
def supported_media_type?
unless SUPPORTED_MEDIA_TYPES.keys.include?(content_type)
unsupported_media_type!
end
@parse_body = true
end
private
def parser
@parser ||= SUPPORTED_MEDIA_TYPES[content_type].new(request)
end
def content_type
@content_type ||= request.headers['Content-Type']
end
def bad_request
render status: :bad_request, alexandria_json_v1: {
message: 'Malformed Alexandria JSON document.'
}
end
def unsupported_media_type!
render status: :unsupported_media_type, alexandria_json_v1: {
message: "Unsupported Media Type in Content-Type header: #{content_type}",
supported_media_types: SUPPORTED_MEDIA_TYPES.keys
}
end
end
Finally, let’s add this new concern to the application controller.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include Authentication
include Authorization
include Serialization
include RequestParsing
# Hidden Code
protected
def builder_error # Hidden Code
def unprocessable_entity! # Hidden Code
def orchestrate_query # Hidden Code
def resource_not_found # Hidden Code
end
Now we need to add some tests.
touch spec/requests/request_parsing_spec.rb
# spec/requests/request_parsing_spec.rb
require 'rails_helper'
RSpec.describe 'Request Parsing', type: :request do
let(:john) { create(:admin) }
let(:api_key) { ApiKey.create }
let(:api_key_str) { "#{api_key.id}:#{api_key.key}" }
let(:access_token) { AccessToken.create(user: john, api_key: api_key) }
let(:token_str) { "#{john.id}:#{access_token.generate_token}" }
let(:headers) do
{
'HTTP_AUTHORIZATION' =>
"Alexandria-Token api_key=#{api_key_str}, access_token=#{token_str}",
'CONTENT_TYPE' => content_type
}
end
let(:author) { create(:michael_hartl) }
let(:params) do
{
data: attributes_for(:ruby_on_rails_tutorial,
author_id: author.id)
}.to_json
end
before { post '/api/books', headers: headers, params: params }
context 'with "Content-Type: application/vnd.alexandria.v1+json"' do
let(:content_type) { 'application/vnd.alexandria.v1+json' }
context 'with valid JSON' do
it 'returns 201 Created' do
expect(response.status).to eq 201
end
end
context 'with invalid JSON' do
let(:params) { '{ "data": { title: "Rails Tutorial"' }
it 'returns 400 Bad Request' do
expect(response.status).to eq 400
end
end
end
context 'with "Content-Type: application/json"' do
let(:content_type) { 'application/json' }
it 'returns 415 Unsupported Media Type' do
expect(response.status).to eq 415
end
it 'returns "application/vnd.alexandria.v1+json"' do
expect(response.headers['Content-Type']).to eq(
'application/vnd.alexandria.v1+json; charset=utf-8'
)
end
end
end
Run the tests.
rspec spec/requests/request_parsing_spec.rb
Success (GREEN)
...
Request Parsing
with "Content-Type: application/vnd.alexandria.v1+json"
with valid JSON
returns 201 Created
with invalid JSON
returns 400 Bad Request
with "Content-Type: application/json"
returns 415 Unsupported Media Type
returns "application/vnd.alexandria.v1+json"
Finished in 0.89863 seconds (files took 2.94 seconds to load)
4 examples, 0 failures
In the next chapter, we will take a look at a few hypermedia formats but for now, I want to show you how to define your own custom media type. This will be based on the simple format we have in Alexandria, but will also include some hypermedia properties.
JSON, unlike HTML, has no built-in support for hyperlinks. While HTML has links and forms as hypermedia controls, JSON has nothing - which explains the amount of standards being developed on top of it to fill this hole. In the next chapter, we will take a look at the following standards/specifications:
For now, we are simply going to add hyperlinks to our JSON
representation - and our approach will be as simple as possible. In the wild, I would recommend using one of the hypermedia formats we will learn about soon. For Alexandria, however, we will do it from scratch because that’s how we built the whole thing.
Note that we are only going to add the hypermedia properties to books
, authors
and publishers
. This is only to give you an idea of how hypermedia can be added to a web API.
We are going to add a new property to all our representations named links
. In there, we will have links pointing to the current entity (with self
) and the related resources. For each link, we will have a class
, a type
, a url
and a set of methods
.
{
"id": 1,
"title": "Ruby Under a Microscope",
"subtitle": "An Illustrated Guide to Ruby Internals",
"isbn_10": "1593275617",
"isbn_13": "9781593275617",
"description": "Ruby Under a Microscope is a cool book!",
"released_on": "2013-09-01",
"publisher_id": 1,
"author_id": 1,
"created_at": "2016-06-18T11:18:43.094Z",
"updated_at": "2016-06-18T11:18:43.094Z",
"cover": "http://localhost:3000/uploads/book/cover/default/cover.jpg",
"price_cents": 299,
"price_currency": "USD",
"links": {
"publisher": {
"class": "publishers",
"type": "entity",
"url": "http://localhost:3000/api/publishers/1",
"methods": ["GET", "PATCH", "DELETE"]
},
"author": {
"class": "authors",
"type": "entity",
"url": "http://localhost:3000/api/authors/1",
"methods": ["GET", "PATCH", "DELETE"]
},
"self": {
"class": "books",
"type": "entity",
"url": "http://localhost:3000/api/books/1",
"methods": ["GET", "PATCH", "DELETE"]
}
}
}
We could also use a more compact format as presented below, but we will go with the first one for our simple implementation.
{
"id": 1,
"title": "Ruby Under a Microscope",
"subtitle": "An Illustrated Guide to Ruby Internals",
"isbn_10": "1593275617",
"isbn_13": "9781593275617",
"description": "Ruby Under a Microscope is a cool book!",
"released_on": "2013-09-01",
"publisher_id": 1,
"author_id": 1,
"created_at": "2016-06-18T11:18:43.094Z",
"updated_at": "2016-06-18T11:18:43.094Z",
"cover": "http://localhost:3000/uploads/book/cover/default/cover.jpg",
"price_cents": 299,
"price_currency": "USD",
"links": {
"self": { "class": "book", "id": "1" },
"publisher": { "class": "publisher", "id": "1" },
"author": { "class": "author", "id": "1" }
},
"hypermedia": {
"classes": {
"publisher": {
"collection": {
"uri": "http://localhost:3000/api/publishers",
"methods": ["GET", "POST"] },
"entity": {
"uri_template": "http://localhost:3000/api/publishers/{id}",
"methods": ["GET", "PATCH", "DELETE"] }
},
"author": {
"collection": {
"uri": "http://localhost:3000/api/authors",
"methods": ["GET", "POST"] },
"entity": {
"uri_template": "http://localhost:3000/api/authors/{id}",
"methods": ["GET", "PATCH", "DELETE"] }
},
"book": {
"collection": {
"uri": "http://localhost:3000/api/books",
"methods": ["GET", "POST"] },
"entity": {
"uri_template": "http://localhost:3000/api/books/{id}",
"methods": ["GET", "PATCH", "DELETE"] }
}
}
}
}
For the authors
, who can have many books
, we are going to generate a URL using our filtering system. It would be cleaner to have…
http://localhost:3000/api/authors/1/books
but that would require changing a lot of things. It’s much simpler to use…
http://localhost:3000/api/books?q[author_id_eq]=1
and it has the same result.
{
"data": {
"id": 1,
"given_name": "Pat",
"family_name": "Shaughnessy",
"created_at": "2016-06-18T11:18:42.957Z",
"updated_at": "2016-06-18T11:18:42.957Z",
"links": {
"books": {
"class": "books",
"type": "collection",
"uri": "http://localhost:3000/api/books?q[author_id_eq]=1",
"methods": ["GET", "POST"]
},
"self": {
"class": "authors",
"type": "entity",
"uri": "http://localhost:3000/api/authors/1",
"methods": ["GET","PATCH","DELETE"]
}
}
}
}
Let’s get started! First, we need a class that will generate the hypermedia hash for our collections and entities.
Create a new file in the representation_builders
folder.
touch app/representation_builders/hypermedia_template.rb
# app/representation_builders/hypermedia_template.rb
class HypermediaTemplate
# We need this module to use Rails route helpers
include Rails.application.routes.url_helpers
def initialize
end
# Used for a list of entities. Will generate a URL like
# http://localhost:3000/api/books for example.
# The foreign_key and owner_id are only used to add ownership
# to collections, i.e. http://localhost:3000/api/books?q[author_id_eq]=1
def collection(presenter_class, owner_id = nil, foreign_key = nil)
suffix = foreign_key ? "?q[#{foreign_key}_eq]=#{owner_id}" : ''
{
class: presenter_class.model_name,
type: 'collection',
href: uri(presenter_class: presenter_class, suffix: suffix),
methods: presenter_class.collection_methods
}
end
# Used for a list of entities. Will generate a URL like
# http://localhost:3000/api/books for example.
def entity(presenter_class, id)
{
class: presenter_class.model_name,
type: 'entity',
href: id ? uri(presenter_class: presenter_class, id: id) : nil,
methods: id ? presenter_class.entity_methods : []
}
end
private
def uri(presenter_class:, id: nil, suffix: nil)
"#{root_url}api/#{presenter_class.model_name}".tap do |uri|
uri << "/#{id}" if id
uri << suffix if suffix
end
end
end
After that, we need a representation builder that will take care of adding the hypermedia data to the presenter data. This class will be called HypermediaTemplate
and we will use it to get the actual formatting done.
Note that we are making hypermedia optional since the code we will implement won’t work for some of our resources (for example, the search).
touch app/representation_builders/hypermedia_builder.rb
# app/representation_builders/hypermedia_builder.rb
class HypermediaBuilder
def initialize(presenter)
@presenter = presenter
@template = HypermediaTemplate.new
end
def build
# Just like caching, we make hypermedia optional
# It has to be activated in the presenter
if @presenter.class.hypermedia?
@presenter.data[:links] ||= {}
add_hypermedia_for_relationships
add_hypermedia
end
@presenter
end
private
# Add the 'self' link
def add_hypermedia
links = @template.entity(@presenter.class, @presenter.object.id)
@presenter.data[:links][:self] = links
end
# Add the related entities links
def add_hypermedia_for_relationships
@presenter.class.relations.each do |relationship|
# Get the presenter class for the related entity
presenter = "#{relationship.singularize.capitalize}Presenter".constantize
# Reflect on the associations to get the one matching the related
# entity name
association = @presenter.object.class.reflections[relationship]
foreign_key = association.foreign_key
# We check if the data on the other side of the relation is a collection
# and call the appropriate method on HypermediaTemplate instance
@presenter.data[:links][relationship] = if association.collection?
@template.collection(presenter, @presenter.object.id, foreign_key)
else # or not
@template.entity(presenter, @presenter.object.send(foreign_key))
end
end
end
end
Now we need to update the BasePresenter
class. We have used some methods in the hypermedia builder that we expect to be defined in our presenters - read through the comments to see what we added.
# app/presenters/base_presenter.rb
class BasePresenter
# Hidden Code
class << self
# Hidden Code
def cached # Hidden Code
def cached? # Hidden Code
# We need a way to enable hypermedia
# in specific presenters
def hypermedia
@hypermedia = true
end
def hypermedia?
@hypermedia
end
# Extract the model name from the presenter name
def model_name
@model_name ||= self.to_s.demodulize.underscore
.split('_').first.pluralize
end
# Default actions available for collection resources
# Can be overridden in children presenters
def collection_methods
['GET', 'POST']
end
# Default actions available for single resources
# Can be overridden in children presenters
def entity_methods
['GET', 'PATCH', 'DELETE']
end
end
attr_accessor :object, :params, :data
def initialize # Hidden Code
def as_json # Hidden Code
def build # Hidden Code
def validated_fields # Hidden Code
def validated_embeds # Hidden Code
def fields # Hidden Code
def embeds # Hidden Code
# Like `fields` and `embeds`, we need a method if we want to use
# the building system
def hypermedia
@hypermedia ||= HypermediaBuilder.new(self).build
end
private
def field_picker # Hidden Code
def embed_picker # Hidden Code
end
With the hypermedia representation builder, links will be added to each individual entity. We still need to define links at the root level for collections. To do that, we are going to add a new method in the collection serializer that will use the HypermediaTemplate
class to build a collection template. Since we want it at the root level, it will actually happen in the Serializer
class. Still, we need to have a way for the serializer to check if hypermedia should be added.
# app/serializers/alexandria/collection_serializer.rb
module Alexandria
module V1
class CollectionSerializer
def initialize # Hidden Code
def serialize # Hidden Code
def links
HypermediaTemplate.new.collection(presenter_class)
end
def key # Hidden Code
def cache? # Hidden Code
private
def build_data # Hidden Code
def presenter_class # Hidden Code
def build_key # Hidden Code
end
end
end
To add hypermedia only for collections, we can just check if the current serializer instance respond to the links
method. Since only the CollectionSerializer
class implements that method, the links
property will only be added for collections.
# app/serializers/alexandria/serializer.rb
module Alexandria
module V1
class Serializer
def initialize # Hidden Code
def to_json # Hidden Code
private
def data
json_hash = { data: @serializer.serialize }
json_hash[:links] = @serializer.links if @serializer.respond_to?(:links)
json_hash.to_json
end
def collection_serializer # Hidden Code
def entity_serializer # Hidden Code
end
end
end
We now need to tell our serializer to use hypermedia
. Add the hypermedia
action to the alexandria_json_v1
method in the Serialization
module.
# app/controllers/concerns/serialization.rb
module Serialization
# Hidden Code
def serialize # Hidden Code
def acceptable? # Hidden Code
private
def alexandria_json_v1(data, options)
Alexandria::V1::Serializer.new(data: data,
params: params,
actions: [:fields, :embeds, :hypermedia],
options: options).to_json
end
def renderer # Hidden Code
def accepted_media_type # Hidden Code
def find_acceptable # Hidden Code
def unacceptable! # Hidden Code
end
Finally, let’s enable hypermedia in the presenters for books
, publishers
and authors
.
# app/presenters/book_presenter.rb
class BookPresenter < BasePresenter
cached
hypermedia
# Hidden Code
end
# app/presenters/book_presenter.rb
class AuthorPresenter < BasePresenter
cached
hypermedia
# Hidden Code
end
# app/presenters/book_presenter.rb
class PublisherPresenter < BasePresenter
cached
hypermedia
# Hidden Code
end
To finish our very simple hypermedia implementation, we need to create an entry point. From that entry point, clients should be able to proceed to other resources simply by following links.
Let’s create a new controller for that.
touch app/controllers/home_controller.rb
# app/controllers/home_controller.rb
class HomeController < ApplicationController
before_action :skip_authorization
def index
render alexandria_json_v1: data.to_json
end
private
def data
template = HypermediaTemplate.new
presenters = [BookPresenter, AuthorPresenter, PublisherPresenter]
array = presenters.each_with_object([]) do |presenter, tmp_array|
tmp_array << template.collection(presenter)
end
{ data: array }
end
end
To route requests to this controller, we need to add some configuration in the routes.rb
file. We want the root path (/
) and the api path (/api
) to point to the index
action we just created.
# config/routes.rb
Rails.application.routes.draw do
scope :api do
# Hidden Code
get '/', to: 'home#index'
end
root to: 'home#index'
end
Let’s give it a try with curl
. We are going to start from the homepage and only follow links available in the representation.
First, start the server.
rails s
Send a request to the homepage.
curl -H "Authorization: Alexandria-Token api_key=1:my_api_key" \
-H "Accept: application/vnd.alexandria.v1+json" \
http://localhost:3000
Output
{
"data":[
{
"class":"books",
"type":"collection",
"href":"http://localhost:3000/api/books",
"methods":["GET","POST"]
},{
"class":"authors",
"type":"collection",
"href":"http://localhost:3000/api/authors",
"methods":["GET","POST"]
},{
"class":"publishers",
"type":"collection",
"href":"http://localhost:3000/api/publishers",
"methods":["GET","POST"]
}
]
}
We got back three resources that we can access. We know that we can send GET
and POST
requests, but we don’t know how to format the data for the POST
ones. Unfortunately, our simple media type doesn’t handle that case.
Let’s get the books by using the href
property from the representation above.
curl -H "Authorization: Alexandria-Token api_key=1:my_api_key" \
-H "Accept: application/vnd.alexandria.v1+json" \
http://localhost:3000/api/books
Output
{
"data":[
{
"id":1,
"title":"Ruby Under a Microscope",
"subtitle":"An Illustrated Guide to Ruby Internals",
"isbn_10":"1593275617",
"isbn_13":"9781593275617",
"description":"Ruby Under a Microscope is a cool book!",
"released_on":"2013-09-01",
"publisher_id":1,
"author_id":1,
"created_at":"2016-06-18T11:18:43.094Z",
"updated_at":"2016-06-18T11:18:43.094Z",
"cover":"http://localhost:3000/uploads/book/cover/default/cover.jpg",
"price_cents":299,
"price_currency":"USD",
"links": {
"publisher": {
"class":"publishers",
"type":"entity",
"href":"http://localhost:3000/api/publishers/1",
"methods":["GET","PATCH","DELETE"]
},
"author": {
"class":"authors",
"type":"entity",
"href":"http://localhost:3000/api/authors/1",
"methods":["GET","PATCH","DELETE"]
},
"self": {
"class":"books",
"type":"entity",
"href":"http://localhost:3000/api/books/1",
"methods":["GET","PATCH","DELETE"]
}
}
},
{ ... }
]
}
We received a list of books, and for each one of them we can access their resource directly or ask for a related resource (author
, book
). Let’s get more details about the author of the first book.
curl -H "Authorization: Alexandria-Token api_key=1:my_api_key" \
-H "Accept: application/vnd.alexandria.v1+json" \
http://localhost:3000/api/authors/1
Output
{
"data":{
"id":1,
"given_name":"Pat",
"family_name":"Shaughnessy",
"created_at":"2016-06-18T11:18:42.957Z",
"updated_at":"2016-06-18T11:18:42.957Z",
"links":{
"books":{
"class":"books",
"type":"collection",
"href":"http://localhost:3000/api/books?q[author_id_eq]=1",
"methods":["GET","POST"]
},
"self":{
"class":"authors",
"type":"entity",
"href":"http://localhost:3000/api/authors/1",
"methods":["GET","PATCH","DELETE"]
}
}
}
}
Great! Now if we wanted, we could get all the books written by Pat.
Alexandria could now be considered partially hypermedia; clients can head to the home page and navigate to /books
, /authors
and /publishers
, and from each one of those resources, they can access the individual items and their relations.
Let’s push the changes.
git status
Output
On branch hypermedia
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: Gemfile
modified: Gemfile.lock
modified: app/controllers/application_controller.rb
modified: app/presenters/author_presenter.rb
modified: app/presenters/base_presenter.rb
modified: app/presenters/book_presenter.rb
modified: app/presenters/publisher_presenter.rb
deleted: app/serializers/alexandria/collection_serializer.rb
deleted: app/serializers/alexandria/entity_serializer.rb
deleted: app/serializers/alexandria/serializer.rb
modified: config/initializers/mime_types.rb
modified: config/routes.rb
Untracked files:
(use "git add <file>..." to include in what will be committed)
app/controllers/concerns/request_parsing.rb
app/controllers/concerns/serialization.rb
app/controllers/home_controller.rb
app/parsers/
app/representation_builders/hypermedia_builder.rb
app/representation_builders/hypermedia_template.rb
app/serializers/alexandria/v1/
spec/requests/request_parsing_spec.rb
spec/requests/serialization_spec.rb
no changes added to commit (use "git add" and/or "git commit -a")
Stage them.
git add .
Commit the changes.
git commit -m "Implement Hypermedia"
Push to GitHub.
git push origin hypermedia
The hypermedia format we implemented was custom-made, and a simple one at that. We could have made it much better, but we don’t have to (nor should we) reinvent the wheel. A better solution is to use one of the options presented in the next chapter.
In the real world, we would have to document our media type. If you’d like to know how the documentation for a media type looks like, I recommend checking out the JSON API specification as it’s easy to read and really makes you understand how your API should be documented. Note that we will learn more about this specification in the next chapter anyway.