Introducing JSONAPI::Resources
It's been a long time coming, but the JSON:API spec is nearing a 1.0 release. Dan and I have been actively involved in helping to form this spec, which appeals to us because it is so ambitious and comprehensive. JSON:API goes beyond specifying a format for JSON payloads - it also specifies how data should be fetched and modified. By standardizing so many of the decisions around designing and building an API, JSON:API allows developers to focus on the design of their applications. As JSON:API catches on, we'll all benefit from the standardized tooling that develops for both clients and servers.
Our initial contribution to this tooling is JSONAPI::Resources, or "JR". JR is a gem that allows Rails apps to easily support the JSON:API spec. As you may have guessed from the name, JR is resource-centric. You define resources for your application and JR can automatically fulfill requests to fetch and modify them. JR not only handles the serialization of responses, but also provides controllers that support methods to interact with the resource. By declaring resource relationships you can allow related resources to be retrieved in compound documents, and by specifying resource attributes you can control how the resource is represented to the API client.
Until now, we've been using (and recommending and contributing toward) ActiveModel::Serializers, or "AMS", the Ruby library that has come closest to fulfilling the JSON:API spec. There are a couple reasons that we developed JR instead of continuing to work with AMS. First of all, AMS is not strictly focused on JSON:API, and has been out of compliance for some time. This is not a major hurdle and could be easily overcome by enabling a JSON:API mode in AMS, something which is already under discussion. The primary reason we developed JR is that AMS is focused on serializers and not resources. While serializers are just concerned with the representation of a model, resources can also act as a proxy to a backing model. In this way, JR can assist with fetching and modifying resources, and can therefore handle all aspects of JSON:API.
Components
Let's take a quick look at the major components of JSONAPI::Resources.
Resource
You can define the resources available through your API as subclasses of JSONAPI::Resource
. A resource definition looks very similar to the definition of a serializer in AMS. Resources form the core of JR and are used by many of its components.
Let's say we're building a blog. Here's a simple resource definition for posts:
1 2 3 4 5 6 7 |
require 'json/api/resource' class PostResource < JSONAPI::Resource attributes :id, :title, :body has_one :author end |
And here's a corresponding author:
1 2 3 4 5 |
class AuthorResource < JSONAPI::Resource attributes :id, :name has_many :posts end |
All declared attributes can, by default, be accessed through the API. There are also methods on a resource that you can implement to control which of the attributes are creatable, updateable, and fetchable. Any attribute on the underlying object that isn't declared in the resource definition will not be available through the resource.
Relationships support options to set different class names and keys from the resource names. In addition, a relationship can be treated "as a set" for create
and update
purposes.
In the following example, tags are defined with the acts_as_set
option. This allows a collection of tags to be set on a post that will replace all of its existing tags:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
class PostResource < JSONAPI::Resource attribute :id attribute :title attribute :body attribute :subject has_one :author, class_name: 'Person' has_one :section has_many :tags, acts_as_set: true has_many :comments def subject @object.title end def self.updateable(keys, context) super(keys - [:author, :subject]) end def self.createable(keys, context) super(keys - [:subject]) end filters :title, :author filter :id end |
Also note that the above resource has a computed attribute called subject
. This is simply the title of the post, and is not createable or updateable.
A resource also controls which filters its corresponding ResourceController
will support. The Resource
class provides basic search capabilities, but if you need more control you can implement a find
method on your resource.
ResourceSerializer
A ResourceSerializer
can serialize a resource into a JSON:API compliant hash, which can then be converted to JSON. ResourceSerializer
has a serialize
method that takes a resource instance to serialize, and optional fields
, includes
, and context
parameters.
For example:
1 2 |
post = Post.find(1) JSONAPI::ResourceSerializer.new.serialize(PostResource.new(post)) |
This returns a hash like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ posts: [{ id: 1, title: 'New post', body: 'A body!!!', links: { section: nil, author: 1, tags: [1,2,3], comments: [1,2] } }] } |
You can also provide some options and filter the fields
and include
related records. include
takes an array of related resources to serialize, and fields
takes a hash of resource types to an array of attributes. For example:
1 2 3 4 5 6 7 |
post = Post.find(1) JSONAPI::ResourceSerializer.new.serialize(PostResource.new(post), ['comments','author','comments.tags','author.posts'], {people: [:id, :email, :comments], posts: [:id, :title, :author], tags: [:name], comments: [:id, :body, :post]}) |
This outputs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
{ :posts=>[ {:id=>1, :title=>"New post", :links=>{:author=>1}} ], :linked=>{ :posts=>[ {:id=>2, :title=>"JR solves your serialization woes!", :links=>{:author=>1}} ], :people=>[ {:id=>1, :email=>"joe@xyz.fake", :links=>{:comments=>[1]}} ], :tags=>[ {:name=>"whiny"}, {:name=>"short"}, {:name=>"happy"} ], :comments=>[ {:id=>1, :body=>"what a dumb post", :links=>{:post=>1}}, {:id=>2, :body=>"i liked it", :links=>{:post=>1}} ] } } |
Note that multilevel includes can be specified with a dot notation, like 'author.posts'
. In addition, you can control the fields for each resource type.
ResourceController
The ResourceController
class provides index
, show
, create
, update
, delete
, show_association
, create_association
, and destroy_association
methods that function based on the resource definition matching the controller name. The easiest way to use ResourceController
is to derive your ApplicationController
from it, and in turn derive specific controllers from your ApplicationController
. There is no need for any methods to exist in your individual controllers unless you need to alter the default behavior.
1 2 3 4 5 |
class ApplicationController < JSONAPI::ResourceController end class ContactsController < ApplicationController end |
The ResourceController
translates the includes
and fields
parameters from the JSON:API specified style into the internal structures used by the ResourceSerializer
.
For example, the following request will get all the contacts with their included phone numbers:
1 |
http://localhost:3000/contacts?include=phone_numbers&fields[contacts]=name_first,name_last&fields[phone_numbers]=number |
The contact records will only contain the first and last names and the phone number will just contain the number.
Of course, you don't need to use ResourceController
if your needs are different.
Routing
JR has a couple of helper methods available to assist you with setting up routes.
jsonapi_resources
Like resources
in ActionDispatch, jsonapi_resources
provides resourceful routes mapping between HTTP verbs and URLs and controller actions. This will also setup mappings for relationship URLs for a resource's associations. For example:
1 2 3 4 5 6 |
require 'jsonapi/routing_ext' Peeps::Application.routes.draw do jsonapi_resources :contacts jsonapi_resources :phone_numbers end |
This generates the following routes:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
Prefix Verb URI Pattern Controller#Action contact_links_phone_numbers GET /contacts/:contact_id/links/phone_numbers(.:format) contacts#show_association {:association=>"phone_numbers"} POST /contacts/:contact_id/links/phone_numbers(.:format) contacts#create_association {:association=>"phone_numbers"} DELETE /contacts/:contact_id/links/phone_numbers/:keys(.:format) contacts#destroy_association {:association=>"phone_numbers"} contacts GET /contacts(.:format) contacts#index POST /contacts(.:format) contacts#create new_contact GET /contacts/new(.:format) contacts#new edit_contact GET /contacts/:id/edit(.:format) contacts#edit contact GET /contacts/:id(.:format) contacts#show PATCH /contacts/:id(.:format) contacts#update PUT /contacts/:id(.:format) contacts#update DELETE /contacts/:id(.:format) contacts#destroy phone_number_links_contact GET /phone_numbers/:phone_number_id/links/contact(.:format) phone_numbers#show_association {:association=>"contact"} POST /phone_numbers/:phone_number_id/links/contact(.:format) phone_numbers#create_association {:association=>"contact"} DELETE /phone_numbers/:phone_number_id/links/contact(.:format) phone_numbers#destroy_association {:association=>"contact"} phone_numbers GET /phone_numbers(.:format) phone_numbers#index POST /phone_numbers(.:format) phone_numbers#create new_phone_number GET /phone_numbers/new(.:format) phone_numbers#new edit_phone_number GET /phone_numbers/:id/edit(.:format) phone_numbers#edit phone_number GET /phone_numbers/:id(.:format) phone_numbers#show PATCH /phone_numbers/:id(.:format) phone_numbers#update PUT /phone_numbers/:id(.:format) phone_numbers#update DELETE /phone_numbers/:id(.:format) phone_numbers#destroy |
Use jsonapi_resource
for singleton resources that can be looked up without an id.
You can control the relationship routes by passing a block into jsonapi_resources
or jsonapi_resource
. An empty block
will not create any relationship routes.
You can add individual relationship routes with jsonapi_links
. For example:
1 2 3 4 |
jsonapi_resources :posts, except: [:destroy] do jsonapi_link :author, except: [:destroy] jsonapi_links :tags, only: [:show, :create] end |
This will create relationship routes for author
(show
and create
, but not destroy
) and for tags
(again show
and create
, but not destroy
).
Getting started
We've shared a simple contact manager app created with JR called Peeps, which you can pull down and play with (some curl
examples are provided). It also contains instructions for how to recreate the app if you want to see just what's involved. Hint - it's not much.
Status and Next Steps
So is JSONAPI::Resources complete? In a word, no. It is functional, but it is missing plenty of features. The biggest, as far as I see them, are pagination, sorting, and support for PATCH operations. Also not yet implemented are the top level meta
and links
objects. I plan to add these over time and would love community support to make and keep JR as compliant with JSON:API as possible.
In very basic testing I have found JSONAPI::Resources to be anywhere from 20% to 50% faster than the equivalent operation using version 0.8.1 of ActiveModel Serializers and 5% to 20% faster than the 0.9.0alpha master branch. Of course, there's probably some speed that can still be extracted from both projects.
I hope you find JSONAPI::Resources useful for building your own APIs. I'd appreciate your comments as well as your contributions.