Skip to content

Latest commit

 

History

History
1112 lines (962 loc) · 36.1 KB

README.md

File metadata and controls

1112 lines (962 loc) · 36.1 KB

HALPresenter

Gem Version

HALPresenter is a DSL for creating serializers conforming to JSON HAL. This DSL is highly influenced by ActiveModelSerializers. Check out this benchmark for a comparison to other serializers.
So, generating some json from an object, whats the big deal? Well if your API is not driven by hypermedia and your payloads most of the time just looks the same, then this might be overkill. But if you do have dynamic payloads (e.g the payload attributes and links depend on the context) then this gem greatly simplifies serialization and puts all the serialization logic in one place. This documentation might be a bit long and dull, but skim through it and check out the examples. I think you'll get the hang of it.

Installation

gem install hal_presenter

With Gemfile:

gem 'hal_presenter'

And then execute:

$ bundle

Intro

Lets start with an example. Say you have your typical blog and you want to serialize post resources. Posts have some text, an author and possibly some comments. Only the author of the post may edit or delete it. A serializer could then be written as:

class PostSerializer
  extend HALPresenter
  model Post
  
  attribute :text
  attribute :characters do
    resource.text.size
  end
  
  link :self do
    "/posts/#{resource.id}"
  end
  
  link :author do
    "/users/#{resource.author.id}"
  end
  
  link :edit do
    "/posts/#{resource.id}/edit" if resource.author.id == options[:current_user]
  end
  
  link :delete do
    "/posts/#{resource.id}" if resource.author.id == options[:current_user]
  end
  
  embed :recent_comments
end

Instances of Post can now be serialized with HALPresenter.to_hal(post, current_user: some_user) which will produce the following (assuming the current user is the author of the post. Else the edit/delete links would not be present):

{
  "text": "some very important stuff",
  "characters": 25,
    "_links": {
    "self": {
      "href": "/posts/5"
    },
    "author": {
      "href": "/users/8"
    },
    "edit": {
      "href": "/posts/5/edit"
    },
    "delete": {
      "href": "/posts/5"
    }
  },
  "_embedded": {
    "recent_comments": {
      "count": 2,
      "_links": {
        "self": {
          "href": "/posts/5/recent_comments"
        }
      },
      "_embedded": {
        "comments": [
          {
            "comment": "lorem ipsum",
            "_links": {
              "self": {
                "href": "/posts/5/comment/1"
              }
            }
          },
          {
            "comment": "dolor sit",
            "_links": {
              "self": {
                "href": "/posts/5/comment/2"
              }
            }
          }
        ]
      }
    }
  }
}

Note: In the output above, recent_comments is a collection and collections have their items embedded. Since this collection is embedded in the post resource that's why we get two levels of embedded resources.

Defining a Serializer

Serializers are defined by extending HALPresenter in the begining of the class declaration. This will add the following class methods:

::model

The model class method is used to register the resource Class that this serializer handles. (There's no Rails magic that automagically maps models to serializers.)

class PostSerializer
  extend HALPresenter
  model Post
end

This make it possible to serialize Post instances with HALPresenter.to_hal. HALPresenter will then lookup the right presenter and delegate the serialization to it (which in the case above would be PostSerializer).

post = Post.new(*args)
HALPresenter.to_hal(post)

If a model does not have it's own presenter but one of its superclasses does, then that will be used.

class SubPost < Post; end
sub_post = SubPost.new(*args)
HALPresenter.to_hal(sub_post) # will lookup PostSerializer since there isn't a specific one for SubPost

Using the model class method is not required for serialization. The serializer can also be called directly.

PostSerializer.to_hal(post)

The serializer class may also be specified as an option, using the :presenter key.

HALPresenter.to_hal(post, {presenter: PostSerializer})

Even though the model class method is optional, it is very useful if the serializer should be selected dynamically and when the serializer is used for deserialization.

::attribute

The attribute class method specifies an attribute (property) to be serialized. The first argument, name, is required and specifies the name of the attribute. When ::attribute is called with only one argument, the resources being serialized are expected to respond to that argument and the returned value is what ends up in the payload.

class PostSerializer
  extend HALPresenter
  attribute :title
end

post = OpenStruct.new(title: "hello")
PostSerializer.to_hal(post)   # => {"title": "hello"}

If ::attribute is called with two arguments, then the second arguments is what ends up in the payload.

class PostSerializer
  extend HALPresenter
  attribute :title, "world"
end

post = OpenStruct.new(title: "ignored")
PostSerializer.to_hal(post)   # => {"title": "world"}

When a block is passed to ::attribute, then the return value of that block is what ends up in the payload.

class PostSerializer
  extend HALPresenter
  attribute :title do
    resource.title.upcase
  end
end

post = OpenStruct.new(title: "hello")
PostSerializer.to_hal(post)   # => {"title": "HELLO"}

Notice that the object being serialized (post in the above example) is accessible inside the block by the resource method.
The keyword argument :embed_depth may be specified to set a max allowed nesting depth for the corresponding attribute to be serialized. See embed_depth.

::link

The link class method specifies a link to be added to the _links property. The first argument, rel, is required. ::link must be called with either a second argument (value) or a block.

class PostSerializer
  extend HALPresenter
  link :self, '/posts/1'
end

PostSerializer.to_hal   # => {"_links": {"self": {"href": "/posts/1"}}}

When a block is passed to ::link, the block must evaluate to either nil, a String containing the href or a hash with symbol keys.

class PostSerializer
  extend HALPresenter

  link :self do
    "/posts/#{resource.id}"
  end
end

post = OpenStruct.new(id: 5)
PostSerializer.to_hal(post)   # => {"_links": {"self": {"href": "/posts/5"}}}

When the block returns a hash the following keys will be used :href, :type, :deprecation, :profile, :title and :templated.

class PostSerializer
  extend HALPresenter

  link :other do
    {
      href: "/posts/#{resource.id}",
      title: resource.title,
      type: resource.type,
    }
  end
end

post = OpenStruct.new(id: 5, title: 'Foo', type: 'bar')
PostSerializer.to_hal(post)   # => {
                              #      "_links": {
                              #        "other": {
                              #          "href": "/posts/5",
                              #          "title": "Foo",
                              #          "type": "bar"
                              #        }
                              #      }
                              #    }

Multiple links can have the same relation. If so, they will be serialized as an array.

class PostSerializer
  extend HALPresenter

  link :item, "/foo"

  link :item do
    "/bar"
  end
end

PostSerializer.to_hal   # =>
                        # {
                        #   "_links": {
                        #     "item": [
                        #       {
                        #         "href": "/foo"
                        #       },
                        #       {
                        #         "href": "/bar"
                        #       }
                        #     ]
                        #   }
                        # }

The following options may be given to ::link:

  • embed_depth - sets a max allowed nesting depth for the corresponding link to be serialized. See embed_depth.
  • curie - prepends a curie to the rel.
  • title - a string used for labelling the link (e.g. in a user interface).
  • type - the media type of the resource returned after following this link.
  • deprecation - a URL providing information about the deprecation of this link.
  • profile - a URI that hints about the profile of the target resource.

::curie

The curie class method specifies a curie to be added to the curies list. The first argument, rel, is required. ::curie must be called with either a second argument (value) or a block.

class PostSerializer
  extend HALPresenter

  curie :doc, '/api/docs/{rel}'
  link :'doc:user', '/users/5'
end

PostSerializer.to_hal   # => {"_links":{"doc:user":{"href":"/users/5"},"curies":[{"name":"doc","href":"/api/docs/{rel}","templated":true}]}}

When a block is passed to ::curie, the return value of that block is what ends up as the href of the curie.

class PostSerializer
  extend HALPresenter

  curie :doc do
  '/api/docs/{rel}'
  end
  link :'doc:user', '/users/5'
end

post = OpenStruct.new(id: 5)
PostSerializer.to_hal(post)   # => {"_links":{"doc:user":{"href":"/users/5"},"curies":[{"name":"doc","href":"/api/docs/{rel}","templated":true}]}}

The keyword argument :embed_depth may be specified to set a max allowed nesting depth for the corresponding curie to be serialized. See embed_depth.
When a resource is embedded in another resource all curies are added to the root resource. This ensures that each curie only appear once in the output. Note that curies may get renamed if there are conflicts between them. See example in ::embed for more details.

::profile

The profile class method is used to specify a mediatype profile that the serializer is conforming to. This is optional and does not change anything about how things get serialized. This method exist only to specify that a given serializer will produce content with semantic meaning described in the specified mediatype profile.
The profile is retrieved with the class method semantic_profile.

class PostSerializer
  extend HALPresenter
  profile "foobar"
end

PostSerializer.semantic_profile     # => "foobar"

# Or with a block

class PostSerializer
  extend HALPresenter
  profile do
    "foo#{options[:foo]}"
  end
end

PostSerializer.semantic_profile(foo: 'bar')     # => "foobar"

::policy

The policy class method is used to register a policy class that should be used during serialization. The purpose of using a policy class is to specify rules about which properties should be serialized (depending on the context). E.g hide some attributes and/or links if current_user is not an admin. If a policy is specified, then by default no properties will be serialized unless the policy explicitly allows them to be serialized.
Using polices is not required, but its a nice way to structure rules about what should be shown and what actions (links) are possible to perform on a resource. The latter is usually tightly coupled with authorization in controllers. This means we can create polices with a bunch of rules and use the same policy in both serialization and in controllers. This plays nicely with gems like Pundit. Instances of the class registered with this method needs to respond to the following methods:

  • initialize(current_user, resource, options = {})
  • attribute?(name)
  • link?(rel)
  • embed?(name)

Additional methods will be needed for authorization in controller. Such as create?, update? etc when using Pundit. A policy instance will be instantiated with the resource being serialized and the option :current_user passed to ::to_hal. For each attribute being serialized a call to policy_instance.attribute?(name) will be made. If that call returns true then the attribute will be serialized. Else it will not end up in the serialized payload. Same goes for links and embedded resources. Curies are ignored by policies and always serialized. Using the following Policy would discard everything except the title attribute, the self link and only embedded resources if current_user is an admin user.

class SomePolicy
  attr_reader :current_user, :resource, :options

  def initialize(current_user, resource, options = {})
    @current_user = current_user
    @resource = resource
    @options = options
  end

  def attribute?(name)
    name.to_s == 'title'
  end

  def link?(rel)
    rel == :self
  end

  def embed?(_name)
    return false unless current_user
    current_user.admin?
  end
end

This gem includes a DSL that simplifies creating policies. See HALPresenter::Policy::DSL.

::namespace

Multiple links and embedded resources may be grouped together inside a curie namespace. This is done by wrapping them inside a block passed to ::namespace.

class DogSerializer
  extend HALPresenter
  attribute :name
  link :self do
    "/dogs/#{resource.name}"
  end
end

class PetSerializer
  extend HALPresenter
  namespace :foo do
    link :owner do
      "/users/#{resource.owner_id}"
    end
    embed :animal, presenter_class: DogSerializer
  end
  curie :foo do
    '/some_link/{rel}'
  end
end

dog = OpenStruct.new(id: 3, name: 'Milo')
pet = OpenStruct.new(owner_id: 5, animal: dog)

PetSerializer.to_hal(pet)   # => {"_links":{"foo:owner":{"href":"/users/5"},"curies":[{"name":"foo","href":"/some_link/{rel}","templated":true}]},"_embedded":{"foo:animal":{"name":"Milo","_links":{"self":{"href":"/dogs/Milo"}}}}}

::embed

The embed class method specifies a nested resource to be embedded. The first argument, name, is required. When ::embed is called with only one argument, the resource being serialized is expected to respond to the value of that argument and the returned value is what ends up in the payload. The keyword argument presenter_class specifies the serializer to be used for serializing the embedded resource.

class UserSerializer
  extend HALPresenter
  attribute :name
end

class PostSerializer
  extend HALPresenter
  embed :author, presenter_class: UserSerializer
end

user = OpenStruct.new(name: "bengt")
post = OpenStruct.new(title: "hello", author: user)
PostSerializer.to_hal(post)   # => {"_embedded":{"author":{"name":"bengt"}}}

If ::embed is called with two arguments, then the second arguments is embedded.

class UserSerializer
  extend HALPresenter
  attribute :name
end

class PostSerializer
  extend HALPresenter
  embed :author, OpenStruct.new(name: "bengt"), presenter_class: UserSerializer
end

post = OpenStruct.new(title: "hello")
PostSerializer.to_hal(post)   # => {"_embedded":{"author":{"name":"bengt"}}}

When a block is passed to ::embed, then the return value of that block is embedded.

class UserSerializer
  extend HALPresenter
  attribute :name
end

class PostSerializer
  extend HALPresenter
  embed :author, presenter_class: UserSerializer do
    OpenStruct.new(name: "bengt")
  end
end

post = OpenStruct.new(title: "hello")
PostSerializer.to_hal(post)   # => {"_embedded":{"author":{"name":"bengt"}}}

The keyword argument :embed_depth may be specified to set a max allowed nesting depth for the corresponding resource to be embedded. See embed_depth.
If the resource to be embedded has a registered Serializer then presenter_class is not needed.

class User
  def name; "bengt"; end
end

class UserSerializer
  extend HALPresenter
  model User
  attribute :name
end

class PostSerializer
  extend HALPresenter
  embed :author
end

post = OpenStruct.new(title: "hello", author: User.new)
PostSerializer.to_hal(post)   # => {"_embedded":{"author":{"name":"bengt"}}}

If the embedded resource has a curie then it will be added to the root resource rather than the embedded resource. If there is a curie name conflict between the root resource and any embedded resources, one or many of them will get renamed.

class FooSerializer
  extend HALPresenter
  attribute :info
  link :foo, curie: :doc do
    '/foo'
  end
  curie :doc do
    '/doc/{rel}'
  end
end

class BarSerializer
  extend HALPresenter
  attribute :info
  link :bar, curie: :doc do
    '/bar'
  end
  curie :doc do
    '/conflicting/{rel}'
  end
end

class PostSerializer
  extend HALPresenter
  curie :doc do
    '/doc/{rel}'
  end
  embed :foo, presenter_class: FooSerializer
  embed :bar, presenter_class: BarSerializer
end

post = OpenStruct.new(
  foo: OpenStruct.new(info: "doc means the same thing for foo"),
  bar: OpenStruct.new(info: "doc conflicts with Foo and Post. It must be renamed!")
)
PostSerializer.to_hal(post)   # =>
                              # {
                              #   "_links": {
                              #     "curies": [
                              #       {
                              #         "name": "doc",
                              #         "href": "/doc/{rel}",
                              #         "templated": true
                              #       },
                              #       {
                              #         "name": "doc0",
                              #         "href": "/conflicting/{rel}",
                              #         "templated": true
                              #       }
                              #     ]
                              #   },
                              #   "_embedded": {
                              #     "foo": {
                              #       "info": "doc means the same thing for foo",
                              #       "_links": {
                              #         "doc:foo": {
                              #           "href": "/foo"
                              #         }
                              #       }
                              #     },
                              #     "bar": {
                              #       "info": "doc conflicts with Foo and Post. It must be renamed!",
                              #       "_links": {
                              #         "doc0:bar": {
                              #           "href": "/bar"
                              #         }
                              #       }
                              #     }
                              #   }
                              # }

Note that the curie in FooSerializer has the same href as the curie in PostSerializer, thus it remains unaffected. The curie in BarSerializer however has another href and needs to be renamed. The new name is doc0. Any rels (links and embedded) refering to the renamed curie will be updated with the new curie name (e.g. doc0:bar).

collection

The collection class method is used to make a serializer capable of serializing an array of resources. Serializing collections may of course be done with separate serializer, but should we want to use the same serializer class for both then ::collection will make that work. The method takes a required keyword paramter named :of, which will be used as the key in the corresponding _embedded property. Each entry in the array given to ::to_collection will then be serialized with this serializer.

class PostSerializer
  extend HALPresenter
  attribute :id
  attribute :title
  collection of: 'posts'
end

list = (1..2).map do |i|
  OpenStruct.new(id: i, title: "hello#{i}")
end

PostSerializer.to_collection(list)   # => {"_embedded":{"posts":[{"id":1,"title":"hello1"},{"id":2,"title":"hello2"}]}}

The collection class method takes an optional block. The purpose of this block is to be able to set attributes, links and embedded resources on the serialized collection.

class PostSerializer
  extend HALPresenter
  attribute :id
  attribute :title
  collection of: 'posts' do
    attribute(:number_of_posts) { resources.count }
    link :self do
      format "/posts%s", (options[:page] && "?page=#{options[:page]}")
    end
    link :next do
      "/posts?page=#{options[:next]}" if options[:next]
    end
  end
end

list = (1..2).map do |i|
  OpenStruct.new(id: i, title: "hello#{i}")
end

PostSerializer.to_collection(list, page: 1, next: 2)   # => {"number_of_posts":2,"_links":{"self":{"href":"/posts?page=1"},"next":{"href":"/posts?page=2"}},"_embedded":{"posts":[{"id":1,"title":"hello1"},{"id":2,"title":"hello2"}]}}"

The response above with some newlines.

{
  "number_of_posts": 2,
  "_links": {
    "self": {
      "href": "/posts?page=1"
    },
    "next": {
      "href": "/posts?page=2"
    }
  },
  "_embedded": {
    "posts": [
      {
        "id": 1,
        "title": "hello1"
      },
      {
        "id": 2,
        "title": "hello2"
      }
    ]
  }
}

Note: the block given to the :number_of_posts attribute is using the method resources. This is just and alias for resource which looks better inside collections.

Keyword argument :embed_depth passed to ::attribute, ::link, ::curie and ::embed

The :embed_depth keyword arguments specifies for which levels of embedding the correponding property should be serialized.

  • nil: The property it is always serialized.
  • 0: The property is only serialized when the resource is not embedded.
  • 1: The property is serialized when it's embedded at most 1 level deep.
  • etc..

Consider the following payload representing a post resource:

{
  "id": 1,
  "message": "lorem ipsum..",
  "_links": {
    "self": {
      "href": "/posts/1"
    },
  },
  "_embedded": {
    "comments": [
      {
        "id": 1,
        "comment": "hello1"
        "_embedded": {
          "user": {
            "id": 2,
            "name": "foo",
            "_links": {
              "self": {
                "href": "/users/2"
              }
            }
          }
        }
      },
      {
        "id": 2,
        "comment": "hello2"
        "_embedded": {
          "user": {
            "id": 3,
            "name": "foo",
            "_links": {
              "self": {
                "href": "/users/3"
              }
            }
          }
        }
      }
    ]
  }
}

Here the post attributes id, message as well as the self link and the embedded comments all have a depth of 0. The properties of each embedded comment (attributes id, comment and embedded user) have a depth of 1. The properties of the user resources (embedded in the comments, which in turn are embedded in the post resource) have a depth of 2.
When a collection is serialized (using ::to_collection) the embedded resources will have the same depth as attributes and links on collection (e.g. depth = 0 unless the collection itself is embedded).
The purpose of specifying embed_depth is to be able skip serializing properties when embeddeed.
For example, when you serialize a collection of resources, perhaps you would like for each resource in that collection to only serialize a few properties, making it kind of like a "preview" of each resource.

blocks passed to ::attribute, ::link, ::curie, ::embed and ::collection

Blocks passes to ::attribute, ::link, ::curie and ::embed have access to the resource being serialized throught the resource method. These blocks also have access to an optional options hash that can be passed to ::to_hal.

class PostSerializer
  extend HALPresenter
  attribute :title do
    "#{resource.id} -- #{resource.title} -- #{options[:extra]}"
  end
end

post = OpenStruct.new(id: 5, title: "hello")
PostSerializer.to_hal(post, extra: 'world')   # => {"title": "5 -- hello -- world"}

These blocks also have access to the scope where the block was created (e.g. the Serializer class)

class PostSerializer
  extend HALPresenter
  def self.prefix; "-->"; end
  attribute :title do
    "#{prefix} #{resource.title}"
  end
end

post = OpenStruct.new(id: 5, title: "hello")
PostSerializer.to_hal(post)   # => {"title": "--> hello"}

Note: this does not mean that self inside the block is the serializer class. The access to the serializer class methods is done by delegation.
If the block passed to ::attribute evaluates to nil then the serialized value will be null. If the block passed to ::link, ::curie or ::embed evaluates to nil, then the corresponding property will not be serialized.

class PostSerializer
  extend HALPresenter
  attribute :title
  attribute :foo { nil }
  link :self { "/posts/#{resource.id}" }
  link :edit do
    "/posts/#{resource.id}" if resource.author_id == options[:current_user].id
  end
end

user = OpenStruct.new(id: 5)
post = OpenStruct.new(id: 1, title: "hello", author_id: 2)
PostSerializer.to_hal(post, current_user: user)   # => {"title":"hello","foo":null,"_links":{"self":{"href":"/posts/1"}}}

user = OpenStruct.new(id: 2)
PostSerializer.to_hal(post, current_user: user)   # => "{"title":"hello","foo":null,"_links":{"self":{"href":"/posts/1"},"edit":{"href":"/posts/1"}}}"

::to_hal

See examples in ::attribute, ::link, ::curie and ::embed.

::to_collection

See examples in ::collection

::post_serialize

The ::post_serialize class method can used to run a hook after each serialization. This method must be called with a block taking one parameter (which will be a Hash of the serialized result). This can be convenient when dynamic properties needs to be added to the serialized payload. As an example say that you have a Form class and a FormSerializer that should be used to serialize different kinds of forms.

  class Field
    attr_reader :name, :type, :value

    def initialize(name, params = {})
      @name = name
      @type = params[:type]
      @has_value = params.key? :value
      @value = params[:value]
    end

    def has_value?
      @has_value
    end
  end

  class Form
    attr_accessor :resource, :name, :title, :href, :method, :type, :self_link, :fields

    def initialize(params = {})
      @name = params[:name]
      @title = params[:title]
      @method = params[:method] || :post
      @type = params[:type] || 'application/json'
      @fields = (params[:fields] || {}).map { |name, args| Field.new(name, args) }
    end
  end
  
  class FormSerializer
    extend HALPresenter

    model Form
    profile 'shaf-form'

    attribute :method do
      (resource&.method || 'POST').to_s.upcase
    end

    attribute :name do
      resource&.name
    end

    attribute :title do
      resource&.title
    end

    attribute :href do
      resource&.href
    end

    attribute :type do
      resource&.type
    end

    link :self do
      resource&.self_link
    end

    link :profile,
      "https://gist.githubusercontent.com/sammyhenningsson/39c8aafeaf60192b082762cbf3e08d57/raw/shaf-form.md"

    post_serialize do |hash|
      fields = resource&.fields
      break if fields.nil? || fields.empty?
      hash[:fields] = fields.map do |field|
        { name: field.name, type: field.type }.tap do |f|
          f[:value] = field.value if field.has_value?
        end
      end
    end
  end

Now this setup can be used to serialize different kinds of forms with a single serializer.

common_fields = {
  email: { type: "string"},
  password: { type: "string"}
}

create_form = Form.new(name: 'create-user', title: 'Create User', fields: common_fields.merge({username: { type: "string"}}))
create_form.href = '/users'

edit_form = Form.new(name: 'edit-user', title: 'Update User', method: :put, fields: common_fields)
edit_form.href = '/users/5/edit'

FormSerializer.to_hal(create_form)
FormSerializer.to_hal(edit_form)

This would give the following:

{
    "href": "/users",
    "method": "POST",
    "name": "create-user",
    "title": "Create User",
    "type": "application/json",
    "fields": [
        {
            "name": "email",
            "type": "string"
        },
        {
            "name": "password",
            "type": "string"
        },
        {
            "name": "username",
            "type": "string"
        }
    ]
}

{
    "href": "/users/5/edit",
    "method": "PUT",
    "name": "edit-user",
    "title": "Update User",
    "type": "application/json",
    "fields": [
        {
            "name": "email",
            "type": "string"
        },
        {
            "name": "password",
            "type": "string"
        }
    ]
}

from_hal

The class method from_hal is used to deserialize a payload into a model instance. If there are links in the payload they will be discarded. Fields in the payload that does not have a corresponding attribute or embed in the serializer will be ignored.

class User
  attr_accessor :name
end

class Post
  attr_accessor :title, :author
end

class UserSerializer
  extend HALPresenter
  model User
  attribute :name
  link :self, '/user'
end

class PostSerializer
  extend HALPresenter
  model Post
  attribute :title
  link :self, '/post'
  embed :author, presenter_class: UserSerializer
end

user = User.new
user.name = "bengt"

post = Post.new
post.title= "hello"
post.author = user

payload = PostSerializer.to_hal(post)   # => {"title":"hello","_links":{"self":{"href":"/post"}},"_embedded":{"author":{"name":"bengt","_links":{"self":{"href":"/user"}}}}}"

post = PostSerializer.from_hal(payload)
post.title                               # => "hello"
post.author.name                         # => "bengt"

Instances are created by calling ::new on the class registered by ::model without any arguments. Then each attribute is set with #attribute_name= (e.g. post.title = 'hello') Thus, all models used for deserialization must respond to attribute_name= for all attributes used in the serializer.
If the model can't be created without arguments (or if the instance already exit), then the instance can be passed to ::from_hal.

class User
  attr_accessor :name

  def initialize(name)
    @name = name
  end
end

class Post
  attr_accessor :title, :author

  def initialize(title, author)
    @title = title
    @author = author
  end
end

class UserSerializer
  extend HALPresenter
  model User
  attribute :name
  link :self, '/user'
end

class PostSerializer
  extend HALPresenter
  model Post
  attribute :title
  link :self, '/post'
  embed :author, presenter_class: UserSerializer
end

payload = JSON.generate(
  {
    "title": "hello",
    "_embedded": {
      "author": {
        "name": "bengt"
      }
    }
  }
)

user = User.new('will_be_overwritten')
post = Post.new('will_be_overwritten', user)

post = PostSerializer.from_hal(payload, post)
post.title                               # => "hello"
post.author.name                         # => "bengt"

Collections can be deserialized into an array as long as the serializer has a collection. In this case the model instance cannot be passed as an argument so it must be possbile to create new instances with ModelName.new (whithout any arguments).

class User
  attr_accessor :name
end

class UserSerializer
  extend HALPresenter
  model User
  attribute :name
  link :self, '/user'
  collection of: 'users'
end

users =  (1..5).map do |i|
  {
    name: "user#{i}",
    foo: "ignored"
  }
end

payload = JSON.generate(
  {
    _embedded: {
      users: users
    }
  }
)

users = UserSerializer.from_hal(payload)
users.class                              # => Array
users.first.class                        # => User
users.map(&:name)                        # => ["user1", "user2", "user3", "user4", "user5"]                         

Inheritance

Serializers may use inheritance and should work just as expected

class BaseSerializer
  extend HALPresenter

  attribute :base, "will_be_overwritten"
  attribute :title

  link :self do
    resource_path(resource.id)
  end
end

class PostSerializer < BaseSerializer
  attribute :base, "child_attribute"
  
  def self.resource_path(id)
    "/posts/#{id}"
  end
end

post = OpenStruct.new(id: 5, title: 'hello')

PostSerializer.to_hal(post)   # => {"title": "hello", "base": "child_attribute", "_links": {"self": {"href": "/posts/5"}}}

Config

HALPresenter.base_href

This module method can be used to specify a base url that will get prepended to links hrefs.

HALPresenter.base_href = 'https://localhost:3000/'

class PostSerializer
  extend HALPresenter
  link :self, '/posts/1'
end

PostSerializer.to_hal   # => {"_links": {"self": {"href": "https://localhost:3000/posts/1"}}}

HALPresenter.paginate

Setting HALPresenter.paginate = true will add next/prev links for collections when possible. Requirements for this is:

  • The resource being serialized is a paginated collection (Kaminari, will_paginate and Sequel are supported)
  • The serializer being used has a collection block which declares a self link

Policy DSL

HALPresenter includes a DSL for creating polices. By including HALPresenter::Policy::DSL into your policy class you get the following class methods:

  • ::attribute(*names, &block)
  • ::link(*rels, &block)
  • ::embed(*names, &block)

These methods all work the same way and creates one or more rules for each name argument (rel for links). If no block is given then the corresponding attribute/link/embedded resource will always be serialized. If the block evaluates to true then the attribute/link/embedded resource will be serialized. Otherwise it will not be serialized. The block has access to the current user, the resource that is being serialized, as well as any options passed to ::to_hal from the methods current_user, resource resp. options.

class UserPolicy
  include HALPresenter::Policy::DSL

  attribute :first_name, :last_name

  attribute :email do
    # show name and email attributes if user is logged in
    !current_user.nil?
  end
  
  attribute :ssn do
    # Only show ssn if the resource belongs to current_user
    current_user && resource.user.id == current_user.id
  end
  
  link :self
  
  link :edit do
    edit?
  end
  
  embed :posts do
    current_user && !current_user.posts.empty?
  end
  
  def edit?
    current_user && resource.user.id == current_user.id
  end

Notice the instance method #edit? which is typically used by Pundit. That method is called from the block belonging to the rule for the edit link. This means that we can use the same policy class both for serialization and for authorization (and have all the rules in one place). This is great since we should only provide links to actions that are possible (authorized) and we don't want to sync this between controller code and serialization code.

Policies can be inherited, so probably it makes sense to have a base policy that other policies can inherit from. Like:

class BasePolicy
  include HALPresenter::Policy::DSL

  def authenticated?
    !!current_user
  end
end

class CommentPolicy < BasePolicy
  link :post

  link :comment do
    authenticated?
  end
end

Sometimes policies may depend on other policies. For thoses cases we can delegate to another policy. Like:

class CommentPolicy < BasePolicy
  def read?
    delegate_to PostPolicy, :read?, resource: resource.post
  end
end

Besides #delegate_to(policy_class, method, resource: nil, args: nil, **opts), there is also:

  • #delegate_attribute(policy_class, attr, **opts)
  • #delegate_link(policy_class, rel, **opts)
  • #delegate_embed(policy_class, rel, **opts)