Adapters-based API serializers with Hypermedia support for Ruby apps.
Ruby Logos Groff
Latest commit 090d383 Sep 14, 2016 @ismasan committed on GitHub Merge pull request #70 from tjmw/tw-remove-duplicate-license
Remove duplicated license file

README.md

Oat

Build Status Gem Version

Adapters-based API serializers with Hypermedia support for Ruby apps. Read the blog post for context and motivation.

What

Oat lets you design your API payloads succinctly while conforming to your media type of choice (hypermedia or not). The details of the media type are dealt with by pluggable adapters.

Oat ships with adapters for HAL, Siren and JsonAPI, and it's easy to write your own.

Serializers

A serializer describes one or more of your API's entities.

You extend from Oat::Serializer to define your own serializers.

require 'oat/adapters/hal'
class ProductSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  schema do
    type "product"
    link :self, href: product_url(item)

    properties do |props|
      props.title item.title
      props.price item.price
      props.description item.blurb
    end
  end

end

Then in your app (for example a Rails controller)

product = Product.find(params[:id])
render json: ProductSerializer.new(product)

Serializers require a single object as argument, which can be a model instance, a presenter or any other domain object.

The full serializer signature is item, context, adapter_class.

  • item a model or presenter instance. It is available in your serializer's schema as item.
  • context (optional) a context hash that is passed to the serializer and sub-serializers as the context variable. Useful if you need to pass request-specific data.
  • adapter_class (optional) A serializer's adapter can be configured at class-level or passed here to the initializer. Useful if you want to switch adapters based on request data. More on this below.

Defining Properties

There are a few different ways of defining properties on a serializer.

Properties can be added explicitly using property. In this case, you can map an arbitrary value to an arbitrary key:

require 'oat/adapters/hal'
class ProductSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  schema do
    type "product"
    link :self, href: product_url(item)

    property :title, item.title
    property :price, item.price
    property :description, item.blurb
    property :the_number_one, 1
  end
end

Similarly, properties can be added within a block using properties to be more concise or make the code more readable. Again, these will set arbitrary values for arbitrary keys:

require 'oat/adapters/hal'
class ProductSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  schema do
    type "product"
    link :self, href: product_url(item)

    properties do |p|
      p.title           item.title
      p.price           item.price
      p.description     item.blurb
      p.the_number_one  1
    end
  end
end

In many cases, you will want to simply map the properties of item to a property in the serializer. This can be easily done using map_properties. This method takes a list of method or attribute names to which item will respond. Note that you cannot assign arbitrary values and keys using map_properties - the serializer will simply add a key and call that method on item to assign the value.

require 'oat/adapters/hal'
class ProductSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  schema do
    type "product"
    link :self, href: product_url(item)

    map_properties :title, :price
    property :description, item.blurb
    property :the_number_one, 1
  end
end

Defining Links

Links to other resources can be added by using link with a name and an options hash. Most adapters expect just an href in the options hash, but some might support additional properties. Some adapters also suport passing templated: true in the options hash to indicate special treatment of a link template.

Adding meta-information

You can add meta-information about your JSON document via meta :property, "value". When using the JsonAPI adapter these properties are rendered in a top level "meta" node. When using the HAL or Siren adapters meta just acts as an alias to property, so the properties are rendered like normal properties.

Adapters

Using the included HAL adapter, the ProductSerializer above would render the following JSON:

{
    "_links": {
        "self": {"href": "http://example.com/products/1"}
    },
    "title": "Some product",
    "price": 1000,
    "description": "..."
}

You can easily swap adapters. The same ProductSerializer, this time using the Siren adapter:

adapter Oat::Adapters::Siren

... Renders this JSON:

{
    "class": ["product"],
    "links": [
        { "rel": [ "self" ], "href": "http://example.com/products/1" }
    ],
    "properties": {
        "title": "Some product",
        "price": 1000,
        "description": "..."
    }
}

At the moment Oat ships with adapters for HAL, Siren and JsonAPI, but it's easy to write your own.

Note: Oat adapters are not required by default. Your code should explicitly require the ones it needs:

# HAL
require 'oat/adapters/hal'
# Siren
require 'oat/adapters/siren'
# JsonAPI
require 'oat/adapters/json_api'

Switching adapters dynamically

Adapters can also be passed as an argument to serializer instances.

ProductSerializer.new(product, nil, Oat::Adapters::HAL)

That means that your app could switch adapters on run time depending, for example, on the request's Accept header or anything you need.

Note: a different library could be written to make adapter-switching auto-magical for different frameworks, for example using Responders in Rails. Also see Rails Integration.

Nested serializers

It's common for a media type to include "embedded" entities within a payload. For example an account entity may have many users. An Oat serializer can inline such relationships:

class AccountSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  schema do
    property :id, item.id
    property :status, item.status
    # user entities
    entities :users, item.users do |user, user_serializer|
      user_serializer.properties do |props|
        props.name user.name
        props.email user.email
      end
    end
  end
end

Another, more reusable option is to use a nested serializer. Instead of a block, you pass another serializer class that will handle serializing user entities.

class AccountSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  schema do
    property :id, item.id
    property :status, item.status
    # user entities
    entities :users, item.users, UserSerializer
  end
end

And the UserSerializer may look like this:

class UserSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  schema do
    property :name, item.name
    property :email, item.name
  end
end

In the user serializer, item refers to the user instance being wrapped by the serializer.

The bundled hypermedia adapters ship with an entities method to add arrays of entities, and an entity method to add a single entity.

# single entity
entity :child, item.child do |child, s|
  s.name child.name
  s.id child.id
end

# list of entities
entities :children, item.children do |child, s|
  s.name child.name
  s.id child.id
end

Both can be expressed using a separate serializer:

# single entity
entity :child, item.child, ChildSerializer

# list of entities
entities :children, item.children, ChildSerializer

The way sub-entities are rendered in the final payload is up to the adapter. In HAL the example above would be:

{
  ...,
  "_embedded": {
    "child": {"name": "child's name", "id": 1},
    "children": [
      {"name": "child 2 name", "id": 2},
      {"name": "child 3 name", "id": 3},
      ...
    ]
  }
}

Nested serializers when using the JsonAPI adapter

Collections are easy to express in HAL and Siren because they're no different from any other "entity". JsonAPI, however, doesn't work that way. In JsonAPI there's a distinction between "side-loaded" entities and the collection that is the subject of the resource. For this reason a collection method was added to the Oat DSL specifically for use with the JsonAPI adapter.

In the HAL and Siren adapters, collection is aliased to entities, but in the JsonAPI adapter, it sets the resource's main collection array as per the spec. entities keep the current behaviour of side-loading entities in the resource.

Subclassing

Serializers can be subclassed, for example if you want all your serializers to share the same adapter or add shared helper methods.

class MyAppSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  protected

  def format_price(price)
    Money.new(price, 'GBP').format
  end
end
class ProductSerializer < MyAppSerializer
  schema do
    property :title, item.title
    property :price, format_price(item.price)
  end
end

This is useful if you want your serializers to better express your app's domain. For example, a serializer for a social app:

class UserSerializer < SocialSerializer
  schema do
    name item.name
    email item.email
    # friend entities
    friends item.friends
  end
end

The superclass defines the methods name, email and friends, which in turn delegate to the adapter's setters.

class SocialSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL # or whatever

  # friendly setters
  protected

  def name(value)
    property :name, value
  end

  def email(value)
    property :email, value
  end

  def friends(objects)
    entities :friends, objects, FriendSerializer
  end
end

You can specify multiple schema blocks, including across class hierarchies. This allows us to append schema attributes or override previously defined attributes:

class ExtendedUserSerializer < UserSerializer
  schema do
    name item.full_name # name property will now by the user's full name
    property :dob, item.dob # additional date of birth attribute
  end
end

URLs

Hypermedia is all about the URLs linking your resources together. Oat adapters can have methods to declare links in your entity schema but it's up to your code/framework how to create those links. A simple stand-alone implementation could be:

class ProductSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  schema do
    link :self, href: product_url(item.id)
    ...
  end

  protected

  # helper URL method
  def product_url(id)
    "https://api.com/products/#{id}"
  end
end

In frameworks like Rails, you'll probably want to use the URL helpers created by the routes.rb file. Two options:

Pass a context hash to serializers

You can pass a context hash as second argument to serializers. This object will be passed to nested serializers too. For example, you can pass the controller instance itself.

# users_controller.rb

def show
  user = User.find(params[:id])
  render json: UserSerializer.new(user, controller: self)
end

Then, in the UserSerializer:

class ProductSerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  schema do
    # `context[:controller]` is the controller, which responds to URL helpers.
    link :self, href: context[:controller].product_url(item)
    ...
  end
end

The context hash is passed down to each nested serializer called by a parent. In some cases, you might want to include extra context information for one or more nested serializers. This can be done by passing options into your call to entity or entities.

class CategorySerializer < Oat::Serializer
  adapter Oat::Adapters::HAL

  schema do
    map_properties :id, :name

    # category entities
    # passing this option ensures that only direct children are embedded within
    # the parent serialized category
    entities :subcategories, item.subcategories, CategorySerializer, embedded: true if context[:embedded]
  end
end

The additional options are merged into the current context before being passed down to the nested serializer.

Mixin Rails' routing module

Alternatively, you can mix in Rails routing helpers directly into your serializers.

class MyAppParentSerializer < Oat::Serializer
  include ActionDispatch::Routing::UrlFor
  include Rails.application.routes.url_helpers
  def self.default_url_options
    Rails.application.routes.default_url_options
  end

  adapter Oat::Adapters::HAL
end

Then your serializer sub-classes can just use the URL helpers

class ProductSerializer < MyAppParentSerializer
  schema do
    # `product_url` is mixed in from Rails' routing system.
    link :self, href: product_url(item)
    ...
  end
end

However, since serializers don't have access to the current request, for this to work you must configure each environment's base host. In config/environments/production.rb:

config.after_initialize do
  Rails.application.routes.default_url_options[:host] = 'api.com'
end

NOTE: Rails URL helpers could be handled by a separate oat-rails gem.

Custom adapters.

An adapter's primary concern is to abstract away the details of specific media types.

Methods defined in an adapter are exposed as schema setters in your serializers. Ideally different adapters should expose the same methods so your serializers can switch adapters without loosing compatibility. For example all bundled adapters expose the following methods:

  • type The type of the entity. Renders as "class" in Siren, root node name in JsonAPI, not used in HAL.
  • link Add a link with rel and href. Renders inside "_links" in HAL, "links" in Siren and JsonAP.
  • property Add a property to the entity. Top level attributes in HAL and JsonAPI, "properties" node in Siren.
  • properties Yield a properties object to set many properties at once.
  • entity Add a single sub-entity. "_embedded" node in HAL, "entities" in Siren, "linked" in JsonAPI.
  • entities Add a collection of sub-entities.

You can define these in your own custom adapters if you're using your own media type or need to implement a different spec.

class CustomAdapter < Oat::Adapter

  def type(*types)
    data[:rel] = types
  end

  def property(name, value)
    data[:attr][name] = value
  end

  def entity(name, obj, serializer_class = nil, &block)
    data[:nested_documents] = serializer_from_block_or_class(obj, serializer_class, &block).to_hash
  end

  ... etc
end

An adapter class provides a data object (just a Hash) that stores your data in the structure you want. An adapter's public methods are exposed to your serializers.

Unconventional or domain specific adapters

Although adapters should in general comply with a common interface, you can still create your own domain-specific adapters if you need to.

Let's say you're working on a media-type specification specializing in describing social networks and want your payload definitions to express the concept of "friendship". You want your serializers to look like:

class UserSerializer < Oat::Serializer
  adapter SocialAdapter

  schema do
    name item.name
    email item.email

    # Friend entity
    friends item.friends do |friend, friend_serializer|
      friend_serializer.name friend.name
      friend_serializer.email friend.email
    end
  end
end

A custom media type could return JSON looking looking like this:

{
    "name": "Joe",
    "email": "joe@email.com",
    "friends": [
        {"name": "Jane", "email":"jane@email.com"},
        ...
    ]
}

The adapter for that would be:

class SocialAdapter < Oat::Adapter

  def name(value)
    data[:name] = value
  end

  def email(value)
    data[:email] = value
  end

  def friends(friend_list, serializer_class = nil, &block)
    data[:friends] = friend_list.map do |obj|
      serializer_from_block_or_class(obj, serializer_class, &block).to_hash
    end
  end
end

But you can easily write an adapter that turns your domain-specific serializers into HAL-compliant JSON.

class SocialHalAdapter < Oat::Adapters::HAL

  def name(value)
    property :name, value
  end

  def email(value)
    property :email, value
  end

  def friends(friend_list, serializer_class = nil, &block)
    entities :friends, friend_list, serializer_class, &block
  end
end

The result for the SocialHalAdapter is:

{
    "name": "Joe",
    "email": "joe@email.com",
    "_embedded": {
        "friends": [
            {"name": "Jane", "email":"jane@email.com"},
            ...
        ]
    }
}

You can take a look at the built-in Hypermedia adapters for guidance.

Rails Integration

The Rails responder functionality works out of the box with Oat when the requests specify JSON as their response format via a header Accept: application/json or query parameter format=json.

However, if you want to also support the mime type of your Hypermedia format of choice, it will require a little bit of code.

The example below uses Siren, but the same pattern can be used for HAL and JsonAPI.

Register the Siren mime-type and a responder:

# config/initializers/oat.rb
Mime::Type.register 'application/vnd.siren+json', :siren

ActionController::Renderers.add :siren do |resource, options|
  self.content_type ||= Mime[:siren]
  resource.to_siren
end

In your controller, add :siren to the respond_to:

class UsersController < ApplicationController
  respond_to :siren, :json

  def show
    user = User.find(params[:id])
    respond_with UserSerializer.new(user)
  end
end

Finally, add a to_siren method to your serializer:

class UserSerializer < Oat::Serializer
  adapter Oat::Adapters::Siren

  schema do
    property :name, item.name
    property :email, item.name
  end

  def to_siren
    to_json
  end
end

Now http requests that specify the Siren mime type will work as expected.

NOTE The key thing that makes this all work together is that the object passed to respond_with implements a to_FORMAT method, where FORMAT is the symbol used to register the mime type and responder (:siren). Without it, Rails will not invoke your responder block.

Installation

Add this line to your application's Gemfile:

gem 'oat'

And then execute:

$ bundle

Or install it yourself as:

$ gem install oat

TODO / contributions welcome

  • JsonAPI top-level meta
  • testing module that can be used for testing spec-compliance in user apps?

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request

Contributors

Many thanks to all contributors! https://github.com/ismasan/oat/graphs/contributors