fast-jsonapi-serializer

Version, currently 0.1.02 versions

github.com/mjeffrey18/fast-jsonapi-serializer

FastJSONAPISerializer is a fast, flexible and simple JSON-API serializer for crystal

9 stars
0 dependents
License: MIT

Nothing has been indexed for 0.1.0 yet. The tag is recorded, its shard.yml has not been read, so the manifest and dependency list below are empty because they are unknown rather than because they are absent.

Installation

# Add this to your shard.yml
dependencies:
  fast-jsonapi-serializer:
    github: mjeffrey18/fast-jsonapi-serializer
    version: ~> 0.1.0

Then run:

shards install

shard.yml

No shard.yml has been indexed for 0.1.0. You can read it on the repository.

Dependencies

Unknown: the shard.yml for this version has not been read yet.

README

This README is the one indexed from the repository at its latest ref, not from the tag for this version.

# FastJSONAPISerializer

![Build Status](https://github.com/mjeffrey18/fast-jsonapi-serializer/actions/workflows/ci.yml/badge.svg?branch=main) [![Docs](https://img.shields.io/badge/docs-available-brightgreen.svg)](https://mjeffrey18.github.io/fast-jsonapi-serializer/) [![GitHub release](https://img.shields.io/github/release/mjeffrey18/fast-jsonapi-serializer.svg)](https://github.com/mjeffrey18/fast-jsonapi-serializer/releases)


Fast JSON-API Serializer is a fast, flexible and simple [JSON-API](https://jsonapi.org) serializer for crystal.

Refer to the full API [documentation](https://mjeffrey18.github.io/fast-jsonapi-serializer/)

## 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

1. Add the dependency to your `shard.yml`:

```yaml
dependencies:
  fast-jsonapi-serializer:
    github: mjeffrey18/fast-jsonapi-serializer
```

2. Run `shards install`

## Setup

Require the shard in your project.

```crystal
require "fast-jsonapi-serializer"
```

## Usage

### Quick Introduction

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

```crystal
class Restaurant
  property name

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

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

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

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

#### Single Resource

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

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

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

#### Resource Collection

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

```

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

```json
{
  "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.

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

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

```json
{
  "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](https://jsonapi.org) standard, we always convert the id to a string.

Example without and id below;

```crystal
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):

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

### Attributes

The attributes API is very flexible.

**Single** `attribute`

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

**Multiple** `attributes`

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

**Mixed**

```crystal
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;

```crystal
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`

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

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

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

#### Conditional control of the attributes

**Attribute API**

```crystal
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

```crystal
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

```crystal
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):

```json
{
  "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:

```crystal
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

```crystal
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:

```crystal
# 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):

```json
{
  "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

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

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

```json
{
  "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

```crystal
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):

```json
{
  "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 excluded
- `includes` - definition of relation that should be included
- `options` - options that will be passed to methods defined for `if` attribute options and `.meta(options)`
- `meta` - meta attributes to be added under `"meta"` key at root level, merged into default `.meta`

Kitchen sink example:

```crystal
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.

```crystal
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

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](https://github.com/imdrasil/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](https://github.com/andersondanilo/jsonapi-serializer-cr)

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

## Contributors

- [Marc Jeffrey](https://github.com/mjeffrey18) - creator and maintainer