Why AMS Adapters, in Rails 5, are so important to a wonderful RESTful API
{
"data": {
"type": "Developer",
"id": "Tiago Freire",
"attributes": {
"nickname" : "Kuruma",
"background_knowledge": [ "C", "C++", "C#", "Java"],
"current_language:": "Ruby",
"interest": "Rust"
},
"relationships": {
"jobs": {
"data": { "type": "Software Boutique", "id": "Codeminer42" },
"links": {
"related": {
"href": "http://www.codeminer42.com"
}
}
},
"consultancy": {
"data": { "type": "Host and Cloud services", "id": "Locaweb" },
"links": {
"related": {
"href": "http://www.locaweb.com.br"
}
}
}
}
}
}
{
"data": {
"type": "Developer",
"id": "Bruno Bacarini",
"attributes": {
"nickname" : "bacarini",
"background_knowledge": ["Java"],
"current_language:": "Ruby"
},
"relationships": {
"jobs": [
{ "data": {
"type": "Host and Cloud services",
"id": "Locaweb"
},
"links": {
"related": {
"href": "http://www.locaweb.com.br"
}
}
},{
"data": {
"type": "e-recruitment",
"id": "VAGAS.com"
},
"links": {
"related": {
"href": "http://www.vagas.com.br"
}
}
}]
}
}
}
Developer Experience
JSON API
REST
API
AMS
Hypermedia
Adapters
Patterns
Documentation
REST
Representational State Transfer
The Hypertext Transfer Protocol (HTTP) is a stateless application-level protocol for distributed, collaborative, hypertext information systems.
Any information that can be named can be a resource: a document or image, a temporal service (e.g. "today's weather in Los Angeles"), a collection of other resources, a non-virtual object (e.g. a person), and so on. In other words, any concept that might be the target of an author's hypertext reference must fit within the definition of a resource.
Roy T. Fielding
[C] POST
[R] GET
[U] PUT / PATCH
[D] DELETE
Machines can follow links when they understand the data format and relationship types.
Roy T. Fielding
So, where are the links?
Collection+JSON
HAL
JSON-LD
SIREN
JSON-API
...
RAILS-API
RAILS 5
ActiveModel::
Serializer
#app/controllers
class PostsController < ApplicationController
def index
@posts = Post.all
render json: @posts
end
end
#app/serializers
class PostSerializer < ActiveModel::Serializer
attributes :id, :name, :date
has_many :comments
end
module ActionController
module Serialization
extend ActiveSupport::Concern
include ActionController::Renderers
...
[:_render_option_json, :_render_with_renderer_json].each do |renderer_method|
define_method renderer_method do |resource, options|
options.fetch(:context) { options[:context] = request }
serializable_resource = get_serializer(resource, options)
super(serializable_resource, options)
end
end
...
end
end
Adapters
#controller/action
render json: @posts, adapter: :json_api
#OR
#config/initializer
ActiveModel::Serializer.config.adapter = :json_api
Accept: application/vnd.example[.version].param[+json]
curl -v -H 'Accept:application/vnd.example.v1+json' localhost:3000
ActiveModel::Serializer::Adapter.register(:json_v1, Json)
ActiveModel::Serializer::Adapter.register(:json_v2, JsonApi)
module ActiveModel
class Serializer
class Adapter
....
def self.create(resource, options = {})
override = options.delete(:adapter)
klass = override ? adapter_class(override) : ActiveModel::Serializer.adapter
klass.new(resource, options)
end
...
end
end
end
module Example
class UsefulAdapter < ActiveModel::Serializer::Adapter
end
end
# Automatically register adapters when subclassing
def self.inherited(subclass)
ActiveModel::Serializer::Adapter.register(subclass.to_s.demodulize, subclass)
end
class MyAdapter; end
ActiveModel::Serializer::Adapter.register(:special_adapter, MyAdapter)
Relationships
Included
Pagination
Meta
#app/serializers
class PostSerializer < ActiveModel::Serializer
attributes :id, :title
has_many :comments
has_one :author
end
"data": [{
"type": "posts",
"id": "1",
"attributes": { "title": "JSON API paints my bikeshed!" },
"relationships": {
"author": {
"links": {
"self": "http://example.com/posts/1/relationships/author",
"related": "http://example.com/posts/1/author"
},
"data": { "type": "people", "id": "9" }
},
"comments": {
"links": {
"self": "http://example.com/posts/1/relationships/comments",
"related": "http://example.com/posts/1/comments"
},
"data": [{ "type": "comments", "id": "5" }]
}
},
"links": { "self": "http://example.com/posts/1" }
}],
class PostsController < ApplicationController
def show
@post = Post.find params[:id]
render json: @post, adapter: :json_api, include: ['comments', 'author']
end
end
"included": [{
"type": "people", "id": "9",
"attributes": {
"first-name": "Dan",
"last-name": "Gebhardt",
"twitter": "dgeb"
},
"links": { "self": "http://example.com/people/9" }
}, {
"type": "comments", "id": "5",
"attributes": { "body": "First!" },
"links": { "self": "http://example.com/comments/5" }
}],
(JsonAPI adapter)
class PostsController < ApplicationController
def show
@post = Post.find params[:id]
render json: @post, adapter: :json_api, include: [author: [:bio]]
end
end
class PostSerializer < ActiveModel::Serializer
attributes :id, :name
has_many :author
end
class AuthorSerializer < ActiveModel::Serializer
attributes :id, :name
has_one :bio
end
class BioSerializer < ActiveModel::Serializer
attributes :id, :title
belongs_to :author
end
"included": [
{
"id": "1",
"type": "author",
"attributes": {
"name": "bruno"
},
"relationships": {
"bio": {
"data": [
{
"id": "1",
"type": "bio"
}
]
}
}
},
{
"id": "1",
"type": "bio",
"attributes": {
"title": "foobar"
}
},
module ActiveModel
class Serializer
class Adapter
class JsonApi < Adapter
...
def add_links(options)
links = @hash.fetch(:links) { {} }
resources = serializer.instance_variable_get(:@resource)
if is_paginated?(resources)
@hash[:links] = add_pagination_links(links, resources, options)
end
end
def add_pagination_links(links, resources, options)
pagination_links = JsonApi::PaginationLinks.new(resources, options[:context])
.serializable_hash(options)
links.update(pagination_links)
end
def is_paginated?(resource)
resource.respond_to?(:current_page) &&
resource.respond_to?(:total_pages) &&
resource.respond_to?(:size)
end
...
end
end
end
end
"links": {
"self": "http://example.com/posts?page%5Bnumber%5D=3&page%5Bsize%5D=1",
"first": "http://example.com/posts?page%5Bnumber%5D=1&page%5Bsize%5D=1",
"prev": "http://example.com/posts?page%5Bnumber%5D=2&page%5Bsize%5D=1",
"next": "http://example.com/posts?page%5Bnumber%5D=4&page%5Bsize%5D=1",
"last": "http://example.com/posts?page%5Bnumber%5D=10&page%5Bsize%5D=1"
}
class PostsController < ApplicationController
def show
@post = Post.find params[:id]
render json: @post, adapter: :json_api, meta: { "total-pages" => 10 }
end
end
"meta": {
"total-pages": 10
}
{
"data": [{ "type": "posts", "id": "1",
"attributes": { "title": "JSON API paints my bikeshed!" },
"relationships": {
"author": {
"links": {
"self": "http://example.com/posts/1/relationships/author",
"related": "http://example.com/posts/1/author"
},
"data": { "type": "people", "id": "9" }
},
"comments": {
"links": {
"self": "http://example.com/posts/1/relationships/comments",
"related": "http://example.com/posts/1/comments"
},
"data": [{ "type": "comments", "id": "5" }]
}
},
"links": { "self": "http://example.com/posts/1" }
}],
"included": [{
"type": "people", "id": "9",
"attributes": {
"first-name": "Dan",
"last-name": "Gebhardt",
"twitter": "dgeb"
},
"links": { "self": "http://example.com/people/9" }
}, {
"type": "comments", "id": "5",
"attributes": { "body": "First!" },
"links": { "self": "http://example.com/comments/5" }
}],
"links": {
"self": "http://example.com/posts?page%5Bnumber%5D=3&page%5Bsize%5D=1",
"first": "http://example.com/posts?page%5Bnumber%5D=1&page%5Bsize%5D=1",
"prev": "http://example.com/posts?page%5Bnumber%5D=2&page%5Bsize%5D=1",
"next": "http://example.com/posts?page%5Bnumber%5D=4&page%5Bsize%5D=1",
"last": "http://example.com/posts?page%5Bnumber%5D=10&page%5Bsize%5D=1"
},
"meta": {
"total-pages": 10
}
}
"data": [{
"type": "posts",
"id": "1",
"attributes": { "title": "JSON API paints my bikeshed!" },
"relationships": {
"author": {
"links": {
"self": "http://example.com/posts/1/relationships/author",
"related": "http://example.com/posts/1/author"
},
"data": { "type": "people", "id": "9" }
},
"comments": {
"links": {
"self": "http://example.com/posts/1/relationships/comments",
"related": "http://example.com/posts/1/comments"
},
"data": [{ "type": "comments", "id": "5" }]
}
},
"links": { "self": "http://example.com/posts/1" }
}],
Deserialization (for both JSON-API and JSON)
Documentation
A better AC/Serializers integration API
Provide better naming for JSON Adapter
Add support to all conventions on JSON-API
Bring Filter back from older version
Russian Doll Cache (use #fetch_multi)
Start handle nested associations