FastJSONAPISerializer
Fast JSON-API Serializer is a fast, flexible and simple JSON-API serializer for crystal.
Refer to the full API documentation
Why use it? 😅
- Works with any ORM or plain Crystal objects.
- Offers a very flexible API.
- Did I mention it was fast?
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
- Add the dependency to your
shard.yml
:
dependencies:
fast-jsonapi-serializer:
github: mjeffrey18/fast-jsonapi-serializer
- 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
- Integer
- String
- UUID
- Nil
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:
belongs_to
has_many
has_one
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.
except
- array of fields which should be excludedincludes
- definition of relation that should be includedoptions
- options that will be passed to methods defined forif
attribute options and.meta(options)
meta
- meta attributes to be added under"meta"
key at root level, merged into default.meta
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
- Allow Proc based conditional attributes
- Allow Proc based conditional relationships
- Allow global key case-change option
- Allow links meta data
- Add safety checks for inputs and bad data
Contributing
- Fork it (https://github.com/mjeffrey18/fast-jsonapi-serializer/fork)
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - 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
- Marc Jeffrey - creator and maintainer