Collection+JSON with ActiveModel::Serializers

Posted on 29 Sep 2015 by Eric Oestrich

For work I'm starting on a new API and I decided to use Collection+JSON after using it at REST Fest. I enjoyed making a ruby client for it, so I figured I would see how it works further than a weekend.

I like to use ActiveModel::Serializers and this DSL has made generating Collection+JSON extremely simple. We are using ActiveModel::Serializers master branch on github. Not the gem currently on Rubygems so you may need to tweak some things to get it working on the older version.

Using The DSL

app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def index
    render :json => orders, :serializer => OrdersSerializer, :meta => {
      :href => orders_url(params.slice(:search, :page, :per)),
      :up => root_url,
      :search => params[:search] || "",
    }
  end

  def show
    render :json => [order], :serializer => OrdersSerializer, :meta => {
      :href => order_url(order.first),
      :up => orders_url,
      :search => "",
    }
  end
end

Here is the controller. Of note is show displaying an array of orders, since there is no single element in Collection+JSON. This was a lot nicer than I expected, it flowed very well.

app/serializers/order_serializer.rb
class OrderSerializer < ActiveModel::Serialier
  include CollectionJson

  attributes :name, :ordered_at

  href do
    meta(:href)
  end
end

Here is the OrderSerializer. It includes CollectionJson which adds the DSL. The important feature is href. I found that passing in the href from the controller is best. I pull the href out from the meta hash. I'm not super sure this is the right way to go, but it's worked well so far.

app/serializers/orders_serializer.rb
class OrdersSerializer < ActiveModel::Serializer
  include CollectionJson

  link "up" do
    meta(:up)
  end

  query "http://example.com/rels/order-search" do
    href orders_url
    data :search, :value => meta(:search), :prompt => "Order Name"
  end

  template do
    data "name", :value => "", :prompt => "Order Name"
  end

  href do
    meta(:href)
  end

  private

  def include_template?
    true
  end
end

Here we see some more of the DSL. Generating links, queries, and the template. You can see more about these in the code below.

config/initializers/serializers.rb
require 'collection_json'
ActiveModel::Serializer.config.adapter = :collection_json

class ActiveModel::Serializer::ArraySerializer
  include Rails.application.routes.url_helpers
  delegate :default_url_options, :to => "ActionController::Base"
end

class ActiveModel::Serializer
  include Rails.application.routes.url_helpers
  delegate :default_url_options, :to => "ActionController::Base"
end

Here we set the adapter to collection_json and include routes so the serializers can be hypermedia driven. I'm not super stoked about how many times we need to include routes, but I couldn't figure out a way around it.

The Details

Here are the two files that make the above happen:

app/serializers/collection_json.rb
module CollectionJson
  extend ActiveSupport::Concern

  module RoutesHelpers
    include Rails.application.routes.url_helpers
    delegate :default_url_options, :to => "ActionController::Base"
  end

  module CollectionObject
    def encoding(encoding)
      @encoding = encoding
    end

    def data(name, options = {})
      @data << options.merge({
        :name => name,
      })
    end

    def compile(metadata)
      reset_query
      @meta = metadata
      instance_eval(&@block)
      generate_json
    end

    private

    def meta(key)
      @meta && @meta[key]
    end
  end

  class Query
    include CollectionObject
    include RoutesHelpers

    def initialize(rel, &block)
      @rel = rel
      @block = block
    end

    def href(href)
      @href = href
    end

    private

    def generate_json
      json = {
        :rel => @rel,
        :href => CGI.unescape(@href),
        :data => @data,
      }
      json[:encoding] = @encoding if @encoding
      json
    end

    def reset_query
      @data = []
      @encoding = nil
      @href = nil
    end
  end

  class Template
    include CollectionObject
    include RoutesHelpers

    def initialize(&block)
      @block = block
    end

    private

    def generate_json
      json = { :data => @data }
      json[:encoding] = @encoding if @encoding
      json
    end

    def reset_query
      @data = []
      @encoding = nil
    end
  end

  class_methods do
    include RoutesHelpers

    def link(rel, &block)
      @links ||= []
      @links << {
        :rel => rel,
        :href => block,
      }
    end

    def links
      @links
    end

    def query(rel, &block)
      @queries ||= []
      @queries << Query.new(rel, &block)
    end

    def queries
      @queries
    end

    def template(&block)
      return @template unless block_given?
      @template = Template.new(&block)
    end

    def href(&block)
      define_method(:href) do
        instance_eval(&block)
      end
    end
  end

  def links
    return unless self.class.links.present?
    self.class.links.map do |link|
      link.merge({
        :href => instance_eval(&link[:href]),
      })
    end.select do |link|
      link[:href].present?
    end
  end

  def queries
    return unless self.class.queries.present?
    self.class.queries.map do |query|
      query.compile(@meta)
    end
  end

  def template
    if respond_to?(:include_template?, true) && include_template?
      self.class.template.compile(@meta) if self.class.template.present?
    end
  end

  def meta(key)
    @meta && @meta[key]
  end
end
lib/collection_json.rb
module ActiveModel
  class Serializer
    module Adapter
      class CollectionJson < Base
        def initialize(serializer, options = {})
          super
          @hash = {}
        end

        def serializable_hash(options = {})
          @hash[:version] = "1.0"
          @hash[:href] = serializer.href
          @hash[:links] = serializer.links if serializer.links.present?
          @hash[:queries] = serializer.queries if serializer.queries.present?
          @hash[:template] = serializer.template if serializer.template.present?

          add_items

          {
            collection: @hash,
          }
        end

        def as_json(options = nil)
          serializable_hash(options)
        end

        private

        def add_items
          if serializer.respond_to?(:each)
            serializer.map do |serializer|
              add_item(serializer)
            end
          else
            add_item(serializer) if serializer.object
          end
        end

        def add_item(serializer)
          @hash[:items] ||= []

          item = {}
          item[:href] = serializer.href
          item[:links] = serializer.links if serializer.links.present?

          serializer.attributes.each do |key, value|
            next unless value.present?
            item[:data] ||= []

            data = {
              name: key.to_s,
              value: value,
            }

            item[:data] << data
          end if serializer.attributes.present?

          @hash[:items] << item
        end
      end
    end
  end
end
comments powered by Disqus
Eric Oestrich
I am:
All posts
Creative Commons License
This site's content is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License unless otherwise specified. Code on this site is licensed under the MIT License unless otherwise specified.