FastJSONAPISerializer

Build Status Docs GitHub release

Fast JSON-API Serializer is a fast, flexible and simple JSON-API serializer for crystal.

Refer to the full API documentation

Why use it? 😅

Benchmarks 🚀

Spoiler ~200% faster!

Compared to other JSON-API compliant alternatives. Sure, benchmarks are to be taken with a grain of salt...

See examples/benchmark.cr for the full benchmark setup.

(Kitchen Sink) With various relationships and all API features used -

FastJSONAPISerializer  66.54k ( 15.03µs) (± 2.25%)  22.2kB/op        fastest
    JSONApiSerializer  34.32k ( 29.14µs) (± 2.49%)  33.0kB/op   1.94× slower

Single object with 1 attribute

FastJSONAPISerializer 881.46k (  1.13µs) (± 1.98%)  1.47kB/op        fastest
    JSONApiSerializer 669.06k (  1.49µs) (± 2.65%)  1.44kB/op   1.32× slower

Installation

  1. Add the dependency to your shard.yml:
dependencies:
  fast-jsonapi-serializer:
    github: mjeffrey18/fast-jsonapi-serializer
  1. Run shards install

Setup

Require the shard in your project.

require "fast-jsonapi-serializer"

Usage

Quick Introduction

Considering a model/resource (ORM or plain crystal class)

class Restaurant
  property name

  def initialize(@name = "big burgers")
  end
end

Create a serializer which inherits from FastJSONAPISerializer::Base(YourResourceClass)

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attribute :name
end

Use the serialize API to to build a JSON-API compatible string

Single Resource

resource = Restaurant.new
RestaurantSerializer.new(resource).serialize

Example above produces this output (made readable for docs):

{
  "data": {
    "id": "1",
    "type": "restaurant",
    "attributes": {
      "name": "big burgers"
    }
  }
}

Resource Collection

resources = [Restaurant.new, Restaurant.new]
RestaurantSerializer.new(resources).serialize

Example above produces this output (made readable for docs):

{
  "data": [
    {
      "id": "1",
      "type": "restaurant",
      "attributes": {
        "name": "big burgers"
      }
    },
    {
      "id": "2",
      "type": "restaurant",
      "attributes": {
        "name": "big sandwiches"
      }
    }
  ]
}

Type

By default, the JSON-API type key will be the snake_case name of the resource class i.e. AdminUser -> "admin_user". You can override this behaviour by setting the type(String) macro.

class AdminUserSerializer < FastJSONAPISerializer::Base(AdminUser)
  type "user"
  attribute :name
end

Example above produces this output (made readable for docs):

{
  "data": {
    "id": "1",
    "type": "user",
    "attributes": {
      "name": "Joe"
    }
  }
}

ID

Your resource class should have an id instance method or getter to populate the JSON id field of the resource.

Supported ID's

If the resource does not respond to id the JSON id value will become null - giving a little more flexibility, although not advised or complaint with the JSON-API standard.

IMPORTANT - As per the JSON-API standard, we always convert the id to a string.

Example without and id below;

class Restaurant
  property name

  def initialize(@name = "big burgers")
  end
end

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attribute :name
end

RestaurantSerializer.new(Restaurant.new).serialize

Example above produces this output (made readable for docs):

{
  "data": {
    "id": null,
    "type": "restaurant",
    "attributes": {
      "name": "big burgers"
    }
  }
}

Attributes

The attributes API is very flexible.

Single attribute

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attribute :name
  attribute :street
end

Multiple attributes

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attributes :name, :street
end

Mixed

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attributes :name, :street
  attribute :post_code
end

Serializer methods

You can also list attributes which are on the serializer class;

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attribute :name
  attribute :custom_method_on_serializer

  def custom_method_on_serializer(_object, _options)
    123
  end

  def custom_method_on_serializer_two(object, options)
    if options[:show_full]
      object.full_data
    else
      object.data
    end
  end
end

Control the attribute JSON key name

Let's say you want to have different key name or case, you can pass this as a second argument attribute

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attribute :name, :FullName
end

Example above produces this output (made readable for docs):

{
  "data": {
    "id": "1",
    "type": "restaurant",
    "attributes": {
      "FullName": "big burgers"
    }
  }
}

Conditional control of the attributes

Attribute API

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attribute :name, :FullName, if: :should_show_name

  def should_show_name(object, _options)
    object.has_full_name?
  end
end

RestaurantSerializer.new(Restaurant.new).serialize

OR

Use the serialize(options: ...) API to control the attributes

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attribute :name, :FullName, if: :should_show_name

  def should_show_name(object, options)
    object.has_full_name? && options[:allow_name]
  end
end

RestaurantSerializer.new(Restaurant.new).serialize(
  options: {:allow_name => true}
)

Serialize API

We can have any number of attributes which can be excluded on demand.

Use the serialize(except: ...) API to control the attributes

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attribute :name, :address, :post_code
end

RestaurantSerializer.new(Restaurant.new).serialize(
  except: %i(name postcode)
)

Example above produces this output (made readable for docs):

{
  "data": {
    "id": "1",
    "type": "restaurant",
    "attributes": {
      "address": "somewhere cool"
    }
  }
}

Relations

The following relationships are supported:

Given a model which has various associations like follows:

class Restaurant
  property id : String,
    name : String,
    address : Nil | Address = nil,
    post_code : Nil | PostCode = nil,
    rooms : Array(Room) = [] of Room

  def initialize(@id, @name = "big burgers")
  end

  def tables
    [Table.new(1), Table.new(2), Table.new(3)]
  end
end

You can define the serializer relationships

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attribute :name

  belongs_to :address, AddressSerializer

  has_one :post_code, PostCodeSerializer

  has_many :rooms, RoomSerializer
  has_many :tables, TableSerializer, :Tables # here we can override the name (optional)
end

# Or if you prefer a more explicit approach

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  attribute :name

  belongs_to :address, serializer: AddressSerializer

  has_one :post_code, serializer: PostCodeSerializer

  has_many :rooms, serializer: RoomSerializer
  has_many :tables, serializer: TableSerializer, key: :Tables
end

Make sure to use the serialize(includes: ...) API to include the relations:

# build all associations
resource = Restaurant.new
resource.address = Address.new
resource.post_code = PostCode.new
room = Room.new(1)
room.tables = [Table.new(1), Table.new(2)]
resource.rooms = [room]

RestaurantSerializer.new(resource).serialize(
  includes: {
    :address   => [:address],
    :post_code => [:post_code],
    :tables    => {:room => [:room]}, # notice nested associations also
  }
)

IMPORTANT - Relationships do nothing unless requested via the serialize(includes: ...) API

Example above produces this output (made readable for docs):

{
  "data": {
    "id": "1",
    "type": "restaurant",
    "attributes": {
      "name": "big burgers"
    },
    "relationships": {
      "address": {
        "data": {
          "id": "101",
          "type": "address"
        }
      },
      "post_code": {
        "data": {
          "id": "101",
          "type": "post_code"
        }
      },
      "Tables": {
        "data": [
          {
            "id": "1",
            "type": "table"
          },
          {
            "id": "2",
            "type": "table"
          },
          {
            "id": "3",
            "type": "table"
          }
        ]
      }
    }
  },
  "included": [
    {
      "id": "101",
      "type": "address",
      "attributes": {
        "street": "some street"
      }
    },
    {
      "id": "101",
      "type": "post_code",
      "attributes": {
        "code": "code 24"
      }
    },
    {
      "id": "1",
      "type": "room",
      "attributes": {
        "name": "1-name"
      },
      "relationships": {}
    },
    {
      "id": "1",
      "type": "table",
      "attributes": {
        "number": 1
      },
      "relationships": {
        "room": {
          "data": {
            "id": "1",
            "type": "room"
          }
        }
      }
    },
    {
      "id": "2",
      "type": "room",
      "attributes": {
        "name": "2-name"
      },
      "relationships": {}
    },
    {
      "id": "2",
      "type": "table",
      "attributes": {
        "number": 2
      },
      "relationships": {
        "room": {
          "data": {
            "id": "2",
            "type": "room"
          }
        }
      }
    },
    {
      "id": "3",
      "type": "room",
      "attributes": {
        "name": "3-name"
      },
      "relationships": {}
    },
    {
      "id": "3",
      "type": "table",
      "attributes": {
        "number": 3
      },
      "relationships": {
        "room": {
          "data": {
            "id": "3",
            "type": "room"
          }
        }
      }
    }
  ]
}

Meta

You can add meta details to the JSON response payload.

Serialize API

Use the serialize(meta: ...) API to control the meta attributes

RestaurantSerializer.new(Restaurant.new).serialize(
  meta: {:page => 0, :limit => 50}
)

Example above produces this output (made readable for docs):

{
  "data": {
    "id": "1",
    "type": "restaurant",
    "attributes": {
      "name": "big burgers"
    }
  },
  "meta": {
    "page": 0,
    "limit": 50
  }
}

.meta class method

You can define default meta attributes as a class method on the serializer.

Using the serialize(meta: ...) API you can merge or override the default meta attributes

class RestaurantSerializer < FastJSONAPISerializer::Base(Restaurant)
  def self.meta(options)
    {
      :status => "ok"
    } of Symbol => FastJSONAPISerializer::MetaAny
  end
end

RestaurantSerializer.new(Restaurant.new).serialize(
  meta: {:page => 0, :limit => 50}
)

Note - FastJSONAPISerializer::MetaAny -> (JSON::Any::Type | Int32)

Example above produces this output (made readable for docs):

{
  "data": {
    "id": "1",
    "type": "restaurant",
    "attributes": {
      "name": "big burgers"
    }
  },
  "meta": {
    "status": "ok",
    "page": 0,
    "limit": 50
  }
}

Serialize API

We covered all the options in the previous examples but this shows all available options.

Kitchen sink example:

RestaurantSerializer.new(resource).serialize(
  except: %i(name),
  includes: {
    :address   => [:address],
    :post_code => [:post_code],
    :tables    => {:room => [:room]},
  },
  meta: {:page => 0, :limit => 50},
  options: {:show_rating => true}
)

Inheritance

You can DRY your serializers with inheritance - just add required attributes and/or associations in the subclasses.

class UserSerializer < Serializer::Base(User)
  attributes :name, :age
end

class FullUserSerializer < UserSerializer
  attributes :email, :created_at

  has_many :identities, IdentitySerializer
end

TODO

Contributing

  1. Fork it (https://github.com/mjeffrey18/fast-jsonapi-serializer/fork)
  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 a new Pull Request

Acknowledgements

This project was based on concepts gather from another amazing open source shard - serializer

Thank you so much for the inspiration!

--

I did use this shard as a bench comparison, but with good intentions. Big shout out to jsonapi-serializer-cr

This project is awesome and has helped me build projects, great work!

Contributors