Alt JSON API

Build Status

JSONApi serializer for Ruby objects. Inspired by Fast JSON API.

Features

  • Flexible mapping of attributes
  • Custom computed attributes
  • Custom ID-mapping
  • Manual type setting
  • Key and type transforms
  • Polymorphic associations
  • Sparse fieldsets support
  • Nested includes (arbitrary depth)

Installation

Add this line to your application's Gemfile:

gem 'oj'
gem 'jsonapi_serializer'

And then execute:

$ bundle

Usage

Record identification

Records in JSONApi are identified by type and id. Normally these attributes can be derived implicitly from serializer name and id by default is just object.id. But you can redefine this behavior by using following DSL-methods:

class MovieSerializer
  include JsonapiSerializer::Base
  # Without type hook, type of the record will be derived
  # from the serializer name. In this case it would be :movie
  type :film

  # Using id hook, you can define how id would be retrieved.
  # By default it will be taken from `id` attribute of the model.
  id { |object| object.slug }

  # You can also pass a symbol of attribute of the model that represents id
  id :slug
end

You can use public method MovieSerializer.new.id_hash(record) to get identification object (for example): {id: 'matrix', type: :film}

Types and keys transforms

JSON API spec does not define how exactly keys and types should be formatted. For example, if you have a model defined in BlockbusterMovie, it can be represented by blockbuster_movie, blockbuster-movie or blockbusterMovie, or anything else that makes sense for your application. JsonapiSerializer allows for customization of this behavior, but only globally so far. If you use Ruby on Rails, you can put these settings in config/initializers/jsonapi_serializer.rb:

# You can pass a symbol matching on of predefined transforms (:underscore, :dasherize or :camelize)
# or implement your own logic, using block.
# The following example will convert all attribute keys into dasherized-form:
JsonapiSerializer.set_key_transform :dasherize

# This will drop all non alphabet characters and upcase everything:
JsonapiSerializer.set_key_transform do |str|
  str.to_s.upcase.gsub(/[^A-Z]/, "").to_sym
end

# The same applies to type transform:
JsonapiSerializer.set_type_transform :camelize

# There is also still unresolved debate on how to treat namespaces in json-api.
# For example, you have `Library::AuthorSerializer` and corresponding model.
# Most of serializers would just drop namespace while trying to retrieve the type.
# We can either drop it too, or replace `::` with defined separator:
JsonapiSerializer.set_type_namespace_separator "-_-"

# Or if you want to ignore (drop) it:
JsonapiSerializer.set_type_namespace_separator :ignore

# The default option is "_"
# Bear in mind that only " " (space), "_" and "-" or any combination of these
# are allowed as per json-api spec, but in practice you can use other symbols if you
# make sure to escape them while using in urls.

Attributes configuration

Alt JSON API supports direct mapping of attributes, as well as remapping and custom attributes that are generated by lambda.

class MovieSerializer
  include JsonapiSerializer::Base
  # attributes accepts names of attributes
  # and/or pairs (hash) of attributes of serialized model
  # pointing to attributes of target model
  attributes :name, release_year: :year

  # attribute accepts a block with serializable record as parameter
  attribute :rating do |movie|
    "%.2g" % (movie.rating / 10.0)
  end

  # you can call class methods of serializer to split complex calculations
  attribute :comlex_attribute do |movie|
    do_heavy_calc(movie)
  end

  def self.do_heavy_calc(movie)
    #...
  end
end

In this example, serializer will access record.name and record.year to fill attributes name and release_year respectively. Rating block will convert 95 into 9.5 and make it string.

Relationships configuration

In order to define relationships, you can use belongs_to and has_many DSL methods. They accept options serializer and from. By default, serializer will try to guess relationship serializer by the name of relation. In case of :director relation, it would try to use DirectorSerializer and crash since it's not defined. Use serializer parameter to set serializer explicitly. Option from is used to point at the attribute of the model that actually returns the relation object(s) if it's different. You can also supply lambda, if the relation is accessible in some non-trivial way.

class MovieSerializer
  include JsonapiSerializer::Base
  belongs_to :director, serializer: PersonSerializer
  has_many :actors, from: :cast

  # or if you want to introduce some logic in building relationships
  has_many :male_actors, from: lambda { |movie| movie.cast.where(sex: "m") }
  has_many :female_actors, from: lambda { |movie| movie.cast.where(sex: "f") }
end

From the perspective of serializer, there is no distinction between belongs_to and has_one relations. Current implementation does not use ActiveRecord's specifics, such as object.{relation}_id or object.{relation}_ids to access relation ids, which means you will have to preload these relations to avoid DB-calls during serialization. This is done deliberately for two reasons:

  • Identifier of serialized object (id) can be remapped to another attribute, for example slug.
  • This library is intended to be ORM-agnostic, you can easily use it to serialize some graph structures.

Polymorphic models and relationships

There are two kinds of polymorphism that jsonapi_serializer supports. First is polymorphic model (STI models in ActiveRecord), where most attributes are shared, but children have different types. Ultimately it is still one kind of entity: think of Vehicle base class inherited by Car, Truck and Motorcycle. Second kind is polymorphic relationship, where one relationship can contain entirely different models. Let's say you have Post and Product, and both can have comments, hence from the perspective of individual comment it belongs to Commentable. Even though Post and Model can share some attributes, their serializers will be used mostly along from comments.

These types of serializers share most of the implementation and both rely on resolver, which is implicitly defined as a lambda, that applies JsonapiSerializer.type_transform to the record class name.

Polymorphic Models

To create a serializer for STI models:

class VehicleSerializer
  include JsonapiSerializer::Polymorphic
  attributes :name, :num_of_wheels
end

class CarSerializer < VehicleSerializer
  attributes :trunk_volume
end

class TruckSerializer < VehicleSerializer
  attributes :bed_size
end

class MotorcycleSerializer < VehicleSerializer
end

In this case common attributes will be inherited from VehicleSerializer and children will be registered automatically. Optionally you can add a resolver to the parent:

resolver do |model|
  case model
  when Motorcycle then :motorcycle
  when Truck then :truck
  when Car then :car
  end
end

But usually you don't need to, the implicit resolver does the same for you. To specify the rules of type transform and how the namespace is treated, read Types and keys transforms section.

Polymorphic Relationships

With polymorphic relationships we usually have several independent serializers for models that can appear in one relationships. In order to teach polymorphic serializer to use them, we just need to register these classes using polymorphic_for.

class CommentableSerializer
  include JsonapiSerializer::Polymorphic
  # Here we register standalone serializers
  # as targets for our polymorphic relationship.
  # Be aware that in this case attributes will have no effect.
  polymorphic_for PostSerializer, ProductSerializer

  # You can set up a resolver here as well!
end

class PostSerializer
  include JsonapiSerializer::Base
  attributes :title, :body
end

class ProductSerializer
  include JsonapiSerializer::Base
  attributes :sku, :name, :description
end

Then use it exactly the same as regular serializers. But keep in mind that you cannot randomly inherit serializer classes, an attempt to inherit regular serializer's class will cause an error.

Initialization and serialization

Once serializers are defined, you can instantiate them with several options. Currently supported options are: fields and include.

fields must be a hash, where keys represent record types and values are list of attributes and relationships of the corresponding type that will be present in serialized object. If some type is missing, that means all attributes and relationships defined in serializer will be serialized. In case of polymorphic serializer, you can supply shared fields under polymorphic type. There is a caveat, though: if you define a fieldset for a parent polymorphic class and omit fieldsets for subclasses it will be considered that you did not want any of attributes and relationships defined in subclass to be serialized. It works the same fashion for polymorphic relationships, so if you want only title from Post and name from Product, you can supply {commentable: ["title", "name"]} as a fields parameter for CommentableSerializer.

fields must have attributes as seen by API consumer. For example, if you have key_transform set to :camelize, then fields will be expected as {"movie" => ["movieTitle", "releaseYear"]}, you can use symbols or strings, they will be normallized on serializer instantiation.

include defines an arbitrary depth tree of included relationships in a similar way as ActiveRecord's includes. Bear in mind that fields has precedence, which means that if some relationship is missing in fields, it will not be included either.

options = {}

# We're omitting fieldset for ItemSerializer here,
# only attributes/relationships defined in CommentableSerializer
# will be serialized for Item objects
options[:fields] = {
  commentable: [:title],
  post: [:body]
}

# You can define arbitrary nesting here
options[:include] = [:tags, author: :some_authors_relation]

serializer = CommentableSerializer.new(options)

Then you can just reuse serializer's instance to serialize appropriate datasets, while supplying optional parameters, such as meta object.

serializer.serialazable_hash(movies, meta: meta)
# or
serializer.serialized_json(movies, meta: meta)

Utils

JsonapiSerializer provides some convenience methods for converting fields and include from query parameters into the form accepted by the serializer.

Fields converter

JsonapiSerializer.convert_fields({"articles" => "title,body", "people" => "name"})
# {articles: [:title, :body], people: [:name]}

Include converter

JsonapiSerializer.convert_include("author,comments.author,comments.theme")
# {author: {}, comments: {author: {}, theme: {}}}
end

Performance

By running bin/benchmark you can launch performance test locally, however numbers are fluctuating widely. The example output is as follows:

Base case

Adapters 10 hash/json (ms) 100 hash/json (ms) 1000 hash/json (ms) 10000 hash/json (ms)
JsonapiSerializerTest 0.39 / 1.17 1.32 / 1.75 11.26 / 16.55 118.13 / 179.28
FastJsonapiTest 0.16 / 0.19 1.12 / 1.60 10.71 / 16.02 104.76 / 160.39

With includes

Adapters 10 hash/json (ms) 100 hash/json (ms) 1000 hash/json (ms) 10000 hash/json (ms)
JsonapiSerializerTest 0.48 / 0.44 1.72 / 2.47 13.04 / 17.71 125.47 / 179.12
FastJsonapiTest 0.27 / 0.26 1.84 / 2.11 13.64 / 17.85 141.91 / 222.25

Performance tests do not include any advanced features, such as fieldsets, nested includes or polymorphic serializers, and were mostly intended to make sure that adding these features did not make serializer slower (or at least significantly slower), but there are models prepared to extend these tests. PRs are welcome.

Roadmap

  • Removing as many dependencies as possible. Opt-in JSON-library. Possibly removing dependency on active_support.
  • Creating jsonapi_serializer_rails to make rails integration simple
  • ...

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/jsonapi_serializer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.