Serega Ruby Serializer

Gem Version GitHub Actions

The Serega Ruby Serializer provides easy and powerful DSL to describe your objects and serialize them to Hash, ready for further conversion to JSON or other formats.


📌 Serega does not depend on any gem and works with any framework

It has some great features:

Installation

bundle add serega

Define serializers

Most apps should define base serializer with common plugins and settings to not repeat them in each serializer.

Serializers will inherit everything (plugins, config, attributes) from their superclasses.

class AppSerializer < Serega
  # plugin :one
  # plugin :two

  # config.one = :one
  # config.two = :two
end

class UserSerializer < AppSerializer
  # attribute :one
  # attribute :two
end

class CommentSerializer < AppSerializer
  # attribute :one
  # attribute :two
end

Adding attributes

⚠️ Attribute names are checked to include only "a-z", "A-Z", "0-9", "_", "-", "~" characters.

We allow ONLY these characters as we want to be able to use attribute names in URLs without escaping.

This rule can be turned off with simple config option: config.check_attribute_name = false

class UserSerializer < Serega
  # Regular attribute
  attribute :first_name

  # Option :method specifies the name used to read the attribute's value —
  # a method called on the serialized object by default, or a Hash key when
  # combined with :hash_access
  attribute :first_name, method: :old_first_name

  # Attribute blocks below require a base serializer for nested serializers.
  # See the "Defining a nested serializer with a block" section below.
  config.base_serializer = Serega

  # Method :itself is handy to serialize the same object with a nested set of
  # attributes. Note: with the :presenter plugin the serialized value will be
  # the Presenter instance itself; use `method: :__getobj__` to serialize the
  # original unwrapped object instead.
  attribute :statistics, method: :itself do
    attribute :likes_count
    attribute :comments_count
  end

  # Block defines a nested serializer for the attribute value.
  # See the "Defining a nested serializer with a block" section below.
  attribute :author do
    attribute :name
  end

  # Option :value can be used with a Proc or callable object to define
  # attribute value
  attribute :first_name, value: UserProfile.new # must have #call method
  attribute :first_name, value: proc { |user| user.profile&.first_name }

  # Option :delegate can be used to define attribute value.
  # Sub-option :allow_nil by default is false
  attribute :first_name, delegate: { to: :profile, allow_nil: true }

  # Option :delegate can be used with :method sub-option, so method chain here
  # is user.profile.fname
  attribute :first_name, delegate: { to: :profile, method: :fname }

  # Option :default can be used to replace possible nil values.
  attribute :first_name, default: ''
  attribute :is_active, default: false
  attribute :comments_count, default: 0

  # Option :const specifies attribute with a specific constant value
  attribute(:type, const: 'user')

  # Option :hide specifies attributes that should not be serialized by default
  attribute :tags, hide: true

  # Option :serializer specifies nested serializer for attribute
  # We can define the `:serializer` value as a Class, String, or Proc.
  # Use String or Proc if you have cross-references in serializers.
  attribute :posts, serializer: PostSerializer
  attribute :posts, serializer: "PostSerializer"
  attribute :posts, serializer: -> { PostSerializer }

  # Option `:many` specifies a has_many relationship. It is optional.
  # If not specified, it is defined during serialization by checking
  # `object.is_a?(Enumerable) && !object.is_a?(Hash) && !object.is_a?(Struct)`
  # Also the `:many` changes the default value from `nil` to `[]`.
  attribute :posts, serializer: PostSerializer, many: true

  # Option `:preload` allows to specify associations to preload to
  # attribute value
  attribute :email, preload: :emails, value: proc { |user| user.emails.find(&:verified?) }

  # Options `:if, :unless, :if_value and :unless_value` can be specified
  # when `:if` plugin is enabled. They hide the attribute key and value from the
  # response.
  # See more usage examples in the `:if` plugin section.
  attribute :email, if: proc { |user, ctx| user == ctx[:current_user] }
  attribute :email, if_value: :present?

  # Option `:format` can be specified when enabled `:formatters` plugin
  # It changes the attribute value
  attribute :created_at, format: :iso_time
  attribute :updated_at, format: :iso_time

  # Option `:format` also can be used as Proc
  attribute :created_at, format: proc { |time| time.strftime("%Y-%m-%d")}
end

Defining a nested serializer with a block

An attribute block defines an anonymous nested serializer. The attribute value is found as usual — by the attribute name or the :method, :value, :delegate, :batch option — and is serialized by this nested serializer.

The nested serializer is a regular subclass of an explicitly chosen base serializer — usually a settings-only serializer holding your plugins and configuration. Choose it with the base_serializer: attribute option or the config.base_serializer setting (attribute option wins; an error is raised when none is set):

class AppSerializer < Serega
  plugin :activerecord_preloads

  # Nested serializers defined with attribute blocks will inherit from
  # AppSerializer
  config.base_serializer = self
end

class UserSerializer < AppSerializer
  attribute :first_name

  # Serializes user.author with a nested serializer inherited from AppSerializer
  attribute :author do
    attribute :name
  end

  # Serializes the user itself, exposing only counters
  attribute :statistics, method: :itself, base_serializer: StatsBaseSerializer do
    attribute :likes_count
    attribute :comments_count
  end
end

UserSerializer.to_h(user)
# => {first_name: "Bruce", author: {name: "Bob"}, statistics: {likes_count: 10, comments_count: 3}}

The nested serializer inherits everything the base serializer has — plugins, config, attributes, batch loaders, the preload handler — through the regular inheritance mechanism. Any serializer can be used as a base: if it has attributes, they are serialized by the nested serializer too, together with the attributes defined in the block. Anything the base does not provide can be declared inside the block:

attribute :statistics, method: :itself do
  batch(:stats) { |users| Stats.for_users(users) } # => { user_id => stat }

  attribute :likes_count, batch: :stats, value: proc { |user, batches:| batches[:stats][user.id].likes }
end

Things to keep in mind:

  • The nested serializer is created at the moment the attribute is defined. Changes made to the base serializer later do not affect already defined nested serializers.
  • Errors raised while serializing nested attributes are reported with a readable serializer label, for example: (when serializing 'comments_count' attribute in UserSerializer.<statistics>).

Serializing

We can serialize objects using class method .call aliased as .to_h and same instance methods #call and its alias #to_h.

user = OpenStruct.new(username: 'serega')

class UserSerializer < Serega
  attribute :username
end

UserSerializer.to_h(user) # => {username: "serega"}
UserSerializer.to_h([user]) # => [{username: "serega"}]

Use .to_data / #to_data to get the same result as Ruby Data objects (immutable value objects, Ruby 3.2+). Nested serialized relations are also converted to Data objects.

result = UserSerializer.to_data(user)
result         # => #<data username="serega">
result.username # => "serega"

UserSerializer.to_data([user]) # => [#<data username="serega">]

If serialized fields are constant, then it's a good idea to initiate the serializer and reuse it. It will be a bit faster (the serialization plan will be prepared only once).

# Example with all fields
serializer = UserSerializer.new
serializer.to_h(user1)
serializer.to_h(user2)

# Example with custom fields
serializer = UserSerializer.new(only: [:username, :avatar])
serializer.to_h(user1)
serializer.to_h(user2)

Selecting Fields

By default, all attributes are serialized (except marked as hide: true).

We can provide modifiers to select serialized attributes:

  • only - lists specific attributes to serialize;
  • except - lists attributes to not serialize;
  • with - lists attributes to serialize additionally (By default all attributes are exposed and will be serialized, but some attributes can be hidden when they are defined with the hide: true option, more on this below. with modifier can be used to expose such attributes).

Modifiers can be provided as Hash, Array, String, Symbol, or their combinations.

With plugin string_modifiers we can provide modifiers as single String with attributes split by comma , and nested values inside brackets (), like: first_name,last_name,addresses(line1,line2). This can be very useful to accept the list of fields in GET requests.

When a non-existing attribute is provided, the Serega::AttributeNotExist error will be raised. This error can be muted with the check_initiate_params: false option.

class UserSerializer < Serega
  plugin :string_modifiers # to send all modifiers in one string

  attribute :username
  attribute :first_name
  attribute :last_name
  attribute :email, hide: true
  attribute :enemies, serializer: UserSerializer, hide: true
end

joker = OpenStruct.new(
  username: 'The Joker',
  first_name: 'jack',
  last_name: 'Oswald White',
  email: 'joker@mail.com',
  enemies: []
)

bruce = OpenStruct.new(
  username: 'Batman',
  first_name: 'Bruce',
  last_name: 'Wayne',
  email: 'bruce@wayneenterprises.com',
  enemies: []
)

joker.enemies << bruce
bruce.enemies << joker

# Default
UserSerializer.to_h(bruce)
# => {:username=>"Batman", :first_name=>"Bruce", :last_name=>"Wayne"}

# With `:only` modifier
fields = [:username, { enemies: [:username, :email] }]
fields_as_string = 'username,enemies(username,email)'

UserSerializer.to_h(bruce, only: fields)
UserSerializer.new(only: fields).to_h(bruce)
UserSerializer.new(only: fields_as_string).to_h(bruce)
# =>
# {
#   :username=>"Batman",
#   :enemies=>[{:username=>"The Joker", :email=>"joker@mail.com"}]
# }

# With `:except` modifier
fields = %i[first_name last_name]
fields_as_string = 'first_name,last_name'
UserSerializer.new(except: fields).to_h(bruce)
UserSerializer.to_h(bruce, except: fields)
UserSerializer.to_h(bruce, except: fields_as_string)
# => {:username=>"Batman"}

# With `:with` modifier
fields = %i[email enemies]
fields_as_string = 'email,enemies'
UserSerializer.new(with: fields).to_h(bruce)
UserSerializer.to_h(bruce, with: fields)
UserSerializer.to_h(bruce, with: fields_as_string)
# =>
# {
#   :username=>"Batman",
#   :first_name=>"Bruce",
#   :last_name=>"Wayne",
#   :email=>"bruce@wayneenterprises.com",
#   :enemies=>[
#     {:username=>"The Joker", :first_name=>"jack", :last_name=>"Oswald White"}
#   ]
# }

# With no existing attribute
fields = %i[first_name enemy]
fields_as_string = 'first_name,enemy'
UserSerializer.new(only: fields).to_h(bruce)
UserSerializer.to_h(bruce, only: fields)
UserSerializer.to_h(bruce, only: fields_as_string)
# => raises Serega::AttributeNotExist

# With no existing attribute and disabled validation
fields = %i[first_name enemy]
fields_as_string = 'first_name,enemy'
UserSerializer.new(only: fields, check_initiate_params: false).to_h(bruce)
UserSerializer.to_h(bruce, only: fields, check_initiate_params: false)
UserSerializer.to_h(bruce, only: fields_as_string, check_initiate_params: false)
# => {:first_name=>"Bruce"}

Using Context

Sometimes it can be required to use the context during serialization, like current_user or any.

class UserSerializer < Serega
  attribute :email, value: proc { |user, ctx|
    user.email if ctx[:current_user] == user
  }
end

user = OpenStruct.new(email: 'email@example.com')
UserSerializer.(user, context: {current_user: user})
# => {:email=>"email@example.com"}

UserSerializer.new.to_h(user, context: {current_user: user}) # same
# => {:email=>"email@example.com"}

Batch Loading

Serega includes built-in batch loading functionality to efficiently load data on demand and solve N+1 problems.

Defining Named Batch Loaders

Named loaders can be defined using the batch class method and reused across attributes:

class UserSerializer < Serega
  # Define named loaders
  batch :comments_count, ->(users) { Comment.where(user: users).group(:user_id).count }
  batch :comments_count, CommentsCountLoader # Example with callable class

  # Use a loader by its name
  attribute :comments_count, batch: :comments_count

  # Equivalent — when the attribute name matches the loader name
  attribute :comments_count, batch: true

  # Equivalent — the default value resolution spelled out
  attribute :comments_count, batch: :comments_count,
    value: proc { |user, batches:| batches[:comments_count][user.id] }

  # Hash form — needed only for sub-options, for example a custom `:id` method
  attribute :comments_count, batch: { use: :comments_count, id: :uuid }
end

Using Custom Loaders

Custom loaders can be provided directly using the :use option with any callable object:

class UserSerializer < Serega
  # Inline proc loader
  attribute :followers_count,
    batch: { use: ->(users) { Follow.where(user: users).group(:user_id).count } }

  # Callable class loader
  attribute :likes_count, batch: { use: LikesCountLoader }

  # Method reference
  attribute :views_count, batch: { use: ViewsCounter.method(:batch_load) }
end

Using Multiple Loaders in Same Attribute

Custom loaders can be provided directly using the :use option with any callable object:

class UserSerializer < Serega
  # Define named loaders
  batch :facebook_likes, FacebookLikesLoader
  batch :twitter_likes, TwitterLikesLoader

  # Summarize likes
  attribute :likes_count,
    batch: { use: [:facebook_likes, :twitter_likes] },
    value: proc { |user, batches:| batches[:facebook_likes][user.id] + batches[:twitter_likes][user.id] }
end

Configuration

Here are the default options. Other options can be added with plugins.

⚠️ Attributes are prepared at the moment they are defined. If a config option influences attributes (auto_preload, hide_by_default, batch_id_option, formatters, etc.), changing it affects only attributes defined after the change — including nested serializers defined with attribute blocks, which are created at their definition point. Configure the serializer before defining attributes.

class AppSerializer < Serega
  # With `activerecord_preloads` plugin it automatically adds `preload` option
  # to attributes with `:delegate` or `:serializer` option.
  # It helps to preload associations automatically, omitting N+1 requests.
  config.auto_preload = false

  # Hides attributes by default. Accepts:
  #   false         - nothing hidden (default)
  #   true          - all attributes hidden
  #   [:preload]    - attributes with :preload option hidden
  #   [:batch]      - attributes with :batch option hidden
  #   [:preload, :batch] - either option triggers hiding
  # Useful to avoid extra DB requests for attributes that were not requested.
  config.hide_by_default = false

  # Default method used on serialized object to resolve batch value
  # For example:
  #   attribute :counter, batch: CounterBatchLoader
  #   # Attribute values will be resolved as:
  #   proc { |object, batches:| batches[:counter][object.id] }
  config.batch_id_option = :id

  # Defaults for the `hash_access:` attribute option — `default_mode` is
  # what `hash_access: true` (and a Hash form omitting :mode) resolves to.
  # See "Serializing Hash records".
  config.hash_access.default_mode = :symbol # the default
  config.hash_access.default_allow_missing_key = false # the default

  # Parent class for nested serializers defined with attribute blocks.
  # Usually a settings-only serializer, e.g. `config.base_serializer = self`
  # in an application base serializer class. There is no default — an
  # attribute block raises an error when no base serializer is chosen (it can
  # also be provided per-attribute with the `base_serializer:` option).
  config.base_serializer = self

  # Disable/enable validation of modifiers (`:with, :except, :only`)
  # By default, this validation is enabled.
  # After disabling, all requested incorrect attributes will be skipped.
  config.check_initiate_params = false # default is true, enabled

  # Stores in memory prepared `plans` - list of serialized attributes.
  # Next time serialization happens with the same modifiers (`only, except, with`),
  # we will reuse already prepared `plans`.
  # This defines storage size (count of stored `plans` with different modifiers).
  config.max_cached_plans_per_serializer_count = 50 # default is 0, disabled
end

Preloads

Serega includes built-in preloads functionality that lets you declare :preload on attributes. It is used by the :activerecord_preloads plugin: enable it and every association you declare is loaded once during serialization, automatically, with no N+1.

Configuration options:

  • config.auto_preload - default false (use true or { has_delegate_option: true, has_serializer_option: true })
  • config.hide_by_default - default false (use :auto to hide attributes with preloads or batch loaders)

These options are extremely useful if you want to forget about finding preloads manually.

The :preload value is passed to the preload handler exactly as written — a Symbol, Array, Hash, or any custom value your ORM understands. Preloads can be disabled with preload: false (or preload: nil); preload: true is not a valid value. Automatically added preloads can be overwritten with the manually specified preload: :xxx option.

The actual loading is performed by a handler registered with preload_with. The :activerecord_preloads plugin registers one for you; to preload with another ORM — or to attach data to plain non-ORM objects by your own rules — register your own (see Custom preloading). Declaring :preload without a registered handler raises an error, so a preload never silently does nothing.

For some examples, please read the comments in the code below

class AppSerializer < Serega
  config.auto_preload = true
  config.hide_by_default = :auto
end

class UserSerializer < AppSerializer
  # No preloads
  attribute :username

  # `preload: :user_stats` added manually
  attribute :followers_count, preload: :user_stats,
    value: proc { |user| user.user_stats.followers_count }

  # `preload: :user_stats` added automatically, as
  # `auto_preload` includes `has_delegate_option: true`
  attribute :comments_count, delegate: { to: :user_stats }

  # `preload: :albums` added automatically as
  # `auto_preload` includes `has_serializer_option: true`
  attribute :albums, serializer: 'AlbumSerializer'
end

class AlbumSerializer < AppSerializer
  attribute :images_count, delegate: { to: :album_stats }
end

# With `hide_by_default = :auto`, attributes that declare a preload are hidden
# unless explicitly requested with `:with`. When requested, their associations
# are preloaded automatically. For example:
#
#   UserSerializer.to_h(users, with: [:followers_count, { albums: :images_count }])
#
# preloads `:user_stats` and `:albums` on the users, and `:album_stats` on the
# albums.

SPECIFIC CASE: Serializing the same object in association

For example, you show your current user as "user" and use the same user object to serialize "user_stats". UserStatSerializer relies on user fields and any other user associations. Specify preload: nil so that nothing is preloaded for this attribute on the "user" object — the associations declared in UserStatSerializer are still preloaded onto the same user object.

class AppSerializer < Serega
  config.auto_preload = true
  config.hide_by_default = :auto
end

class UserSerializer < AppSerializer
  attribute :username
  attribute :user_stats,
    serializer: 'UserStatSerializer',
    value: proc { |user| user },
    preload: nil
end

Custom preloading

The :activerecord_preloads plugin works out of the box, but the preload mechanism itself is not tied to ActiveRecord — or to ORMs at all. Register a handler with preload_with and it is called once per preloaded attribute with the gathered objects and that attribute's :preload value, before the attribute is serialized:

class UserSerializer < Serega
  attribute :name
  attribute :posts, serializer: PostSerializer, preload: :posts

  # `objects` is the gathered users; `preloads` is this attribute's
  # `:preload` value (`:posts`). `MyORM.preload` does the eager loading.
  preload_with { |objects, preloads| MyORM.preload(objects, preloads) }
end

The handler can be a block or any callable taking (objects, preloads). It is inherited by child serializers and can be overridden per subclass. The :preload value reaches the handler untouched, so you can pass whatever your handler expects — not necessarily something an ORM understands. The gathered objects can be plain Ruby objects too: the handler decides where the data comes from (another ORM, an HTTP API, a cache) and how to attach it to the objects, by whatever rules you define.

The handler runs once per preloaded attribute, not once per distinct :preload value. If the same :preload is declared on several attributes, the handler is invoked once for each of them over the same objects, so it should check whether the data is already loaded before loading it again. (The :activerecord_preloads handler relies on ActiveRecord::Associations::Preloader, which already skips associations that are loaded.)

Because you choose the :preload value, it doubles as a discriminator: give two attributes different :preload values and branch on preloads inside the handler — no need for the value to be a real association name.

class PostSerializer < Serega
  attribute :owner,  serializer: UserSerializer, preload: :owner
  attribute :author, serializer: UserSerializer, preload: :author

  # Both attributes load the same person, but from different sources.
  preload_with do |objects, preloads|
    case preloads
    when :owner  then load_owners(objects)   # e.g. from the DB
    when :author then load_authors(objects)  # e.g. from an external API
    else MyORM.preload(objects, preloads)
    end
  end
end

Serializing Hash records

(objects should implement #[], #fetch, and #key? - Hash already has them)

The hash_access: <mode> attribute option makes an attribute read its value from Hash keys instead of calling a method.

Allowed <mode>:

  • :symbol reads record
  • :string reads record
  • true shorthand for config.hash_access.default_mode (:symbol by default)

By default missing keys will raise KeyError.

config.hash_access.default_allow_missing_key = true will change KeyError to return nil value instead. config.hash_access.default_mode = <mode> changes what true (and a Hash form omitting :mode) resolves to.

class UserSerializer < Serega
  config.hash_access.default_mode = :symbol             # the default
  config.hash_access.default_allow_missing_key = true    # false by default

  attribute :name, hash_access: true     # reads record[:name] (config.hash_access.default_mode)
  attribute :name, hash_access: :symbol  # reads record[:name]
  attribute :name, hash_access: :string  # reads record["name"]

  # Use long form if you need to override `allow_missing_key`
  attribute :name, hash_access: { allow_missing_key: false }
  attribute :name, hash_access: { mode: :symbol, allow_missing_key: false }
  attribute :name, hash_access: { mode: :string, allow_missing_key: false }
end

Delegated attributes configure hash access per step:

  • to_hash_access configures reading <mode> for intermediate object
  • hash_access configures reading <mode> for final key
# reads `record.address.city` (no hash access)
attribute :city, delegate: { to: :address } # record.address.city

# reads record.address[:city]
attribute :city, delegate: { to: :address, hash_access: :symbol }

# reads record[:address].city
attribute :city, delegate: { to: :address, to_hash_access: :symbol }

# reads record[:address][:city]
attribute :city, delegate: { to: :address, to_hash_access: :symbol, hash_access: :symbol }

# reads record["address"] and if exists returns record["address"][:city]
attribute :city,
  delegate: {
    to: :address,
    allow_nil: true,
    to_hash_access: { mode: :string, allow_missing_key: true },
    hash_access: { mode: :symbol, allow_missing_key: false }
  }

Plugins

Plugin :activerecord_preloads

Automatically preloads associations to serialized objects.

Every association you declare with :preload is loaded once during serialization using ActiveRecord::Associations::Preloader, so there are no N+1 queries. The plugin does this by registering a preload_with handler; to use a different ORM, register your own instead.

class AppSerializer < Serega
  config.auto_preload = true
  plugin :activerecord_preloads
end

class AlbumSerializer < AppSerializer
  # no preloads
  attribute :title

  # preloads :downloads, as manually specified
  attribute :downloads_count, preload: :downloads, value: proc { |album| album.downloads.count }
end

class UserSerializer < AppSerializer
  # no preloads
  attribute :username

  # preloads :user_stats, as auto_preload is enabled for :delegate attributes
  attribute :comments_count, delegate: { to: :user_stats }

  # preloads :albums, as auto_preload is enabled for :serializer attributes
  attribute :albums, serializer: AlbumSerializer
end

UserSerializer.to_h(users)
# 1 query to load :user_stats for all users
# + 1 query to load :albums for all users
# + 1 query to load :downloads for all albums
# = 3 queries total, regardless of how many users/albums are serialized

Plugin :root

Adds a root key to serialized data.

Accepts options:

  • :root - specifies root for all responses
  • :root_one - specifies the root key for single object serialization only
  • :root_many - specifies the root key for multiple objects serialization only

Adds additional config options:

  • config.root.one
  • config.root.many
  • config.root.one=
  • config.root.many=

The default root is :data.

class UserSerializer < Serega
  plugin :root # default root is :data
end

class UserSerializer < Serega
  plugin :root, root: :users
end

class UserSerializer < Serega
  plugin :root, root_one: :user, root_many: :people
end

class UserSerializer < Serega
  plugin :root, root: nil # no root key by default
end

The root key can also be changed per serialization, or removed entirely by providing root: nil (it can still be added back per serialization).

class UserSerializer < Serega
  plugin :root
end

UserSerializer.to_h(nil)              # => {:data=>nil}
UserSerializer.to_h(nil, root: :user) # => {:user=>nil}
UserSerializer.to_h(nil, root: nil)   # => nil

Plugin :metadata

Depends on: :root plugin, that must be loaded first

Adds metadata to the serialized response via the class-level meta_attribute method, which accepts:

  • *path [Array of Symbols] - nested hash keys.

  • **options [Hash]

  • :const - describes metadata value (if it is constant)

  • :value - describes metadata value as any #callable instance

  • :hide_nil - does not show the metadata key if the value is nil. It is false by default

  • :hide_empty - does not show the metadata key if the value is nil or empty. It is false by default.

  • &block [Proc] - describes value for the current meta attribute

class AppSerializer < Serega
  plugin :root
  plugin :metadata

  meta_attribute(:version, const: '1.2.3')
  meta_attribute(:ab_tests, :names, value: ABTests.new.method(:names))
  meta_attribute(:meta, :paging, hide_nil: true) do |records, ctx|
    next unless records.respond_to?(:total_count)

    {
      page: records.page,
      per_page: records.per_page,
      total_count: records.total_count
    }
  end
end

AppSerializer.to_h(nil)
# => {:data=>nil, :version=>"1.2.3", :ab_tests=>{:names=> ... }}

Plugin :context_metadata

Depends on: :root plugin, that must be loaded first

Adds metadata supplied per serialization call (as opposed to :metadata, which is defined statically on the serializer).

Accepts option :context_metadata_key with the name of the root metadata keyword. By default, it has the :meta value.

The key can be changed in children serializers using this method: config.context_metadata.key=(value).

class UserSerializer < Serega
  plugin :root, root: :data
  plugin :context_metadata, context_metadata_key: :meta

  # Same:
  # plugin :context_metadata
  # config.context_metadata.key = :meta
end

UserSerializer.to_h(nil, meta: { version: '1.0.1' })
# => {:data=>nil, :version=>"1.0.1"}

Plugin :formatters

Defines named value formatters once and applies them to any attribute.

Use config.formatters.add() to register formatters. The :format attribute option then accepts a formatter name or a callable directly.

Formatters receive up to 2 parameters: the value and the context.

class AppSerializer < Serega
  plugin :formatters, formatters: {
    iso8601: ->(value) { value.iso8601 },
    on_off: ->(value) { value ? 'ON' : 'OFF' },
    money: ->(value) { value.round(2) },
    date: DateTypeFormatter # callable
  }
end

class UserSerializer < Serega
  # Additionally we can add formatters via config in subclasses
  config.formatters.add(
    iso8601: ->(value) { value.iso8601 },
    on_off: ->(value) { value ? 'ON' : 'OFF' },
    money: ->(value) { value.round(2) }
  )

  # Using predefined formatter
  attribute :commission, format: :money
  attribute :is_logged_in, format: :on_off
  attribute :created_at, format: :iso8601
  attribute :updated_at, format: :iso8601

  # Using `callable` formatter
  attribute :score_percent, format: PercentFormatter # callable class
  attribute :score_percent, format: proc { |percent| "#{percent.round(2)}%" }
end

Plugin :presenter

Moves computed attribute logic out of blocks and into a dedicated Presenter class, keeping serializers readable as schemas rather than bags of lambdas.

Without the plugin, computed attributes live as inline blocks:

class UserSerializer < Serega
  attribute :name, value: proc { |u| [u.first_name, u.last_name].compact.join(' ') }
  attribute :role, value: proc { |u, ctx| u == ctx[:current_user] ? :self : :other }
end

With the plugin, they move into a presenter do ... end block:

class UserSerializer < Serega
  plugin :presenter

  attribute :name
  attribute :role

  presenter do
    def name
      [first_name, last_name].compact.join(' ')
    end

    def role
      id == __ctx__[:current_user_id] ? :self : :other
    end
  end
end

The block is evaluated inside the serializer's own Presenter class, so multiple presenter blocks accumulate.

Presenter inherits from SimpleDelegator, so every method of the serialized object is available directly inside presenter methods. Any method not explicitly defined on Presenter is resolved via method_missing on the first call, which also defines a real delegator method — so all subsequent serializations call it directly, without going through method_missing again.

The original wrapped object is accessible via __getobj__ (standard SimpleDelegator API) when you need an unambiguous reference to it.

The serialization context is accessible via the private method __ctx__.

Objects are wrapped in the Presenter only when the serializer's Presenter class (or an inherited one) actually defines custom methods. Loading the plugin in a base serializer adds no overhead to serializers that don't customize their presenters — their attribute values, batch loaders and value callables keep receiving the raw objects.

Plugin :string_modifiers

Allows :only, :except and :with to be given as a single comma-separated string, with nested attributes in parentheses. Useful for accepting a field list straight from a query parameter.

Modifiers can still be provided the old way, as nested hashes or arrays.

PostSerializer.plugin :string_modifiers
PostSerializer.new(only: "id,user(id,username)").to_h(post)
PostSerializer.new(except: "user(username,email)").to_h(post)
PostSerializer.new(with: "user(email)").to_h(post)

PostSerializer.new(with: {user: %i[email, username]}).to_h(post)

Plugin :if

Adds :if, :unless, :if_value, :unless_value attribute options to conditionally remove attributes from the response.

:if/:unless receive the serialized object and context, and are checked before the attribute value is found. :if_value/:unless_value receive the already-found value and context, checked after. The latter two cannot be used with the :serializer option, since a relationship has no "serialized value" of its own — use :if/:unless instead.

See also a :hide option that is available without any plugins to hide attribute without conditions. Look at select serialized fields for :hide usage examples.

 class UserSerializer < Serega
   attribute :email, if: :active? # translates to `if user.active?`
   attribute :email, if: proc {|user| user.active?} # same
   attribute :email, if: proc {|user, ctx| user == ctx[:current_user]}
   attribute :email, if: CustomPolicy.method(:view_email?)

   attribute :email, unless: :hidden? # translates to `unless user.hidden?`
   attribute :email, unless: proc {|user| user.hidden?} # same
   attribute :email, unless: proc {|user, context| context[:show_emails]}
   attribute :email, unless: CustomPolicy.method(:hide_email?)

   attribute :email, if_value: :present? # if email.present?
   attribute :email, if_value: proc {|email| email.present?} # same
   attribute :email, if_value: proc {|email, ctx| ctx[:show_emails]}
   attribute :email, if_value: CustomPolicy.method(:view_email?)

   attribute :email, unless_value: :blank? # unless email.blank?
   attribute :email, unless_value: proc {|email| email.blank?} # same
   attribute :email, unless_value: proc {|email, context| context[:show_emails]}
   attribute :email, unless_value: CustomPolicy.method(:hide_email?)
 end

Plugin :camel_case

Without this plugin, responding with camelCased keys means specifying the attribute name and method directly for each attribute: attribute :firstName, method: first_name

This plugin camelCases every attribute name automatically, replacing _x with X throughout the string. The transformation runs once, when the attribute is defined, not on every serialization.

Provide a custom transformation when adding the plugin, for example plugin :camel_case, transform: ->(name) { name.camelize }

Skip camelCase for a single attribute with camel_case: false.

This plugin transforms only attribute keys, without affecting the root, metadata and context_metadata plugins keys.

If you wish to select serialized fields, you should provide them camelCased.

class AppSerializer < Serega
  plugin :camel_case
end

class UserSerializer < AppSerializer
  attribute :first_name
  attribute :last_name
  attribute :full_name, camel_case: false,
    value: proc { |user| [user.first_name, user.last_name].compact.join(" ") }
end

require "ostruct"
user = OpenStruct.new(first_name: "Bruce", last_name: "Wayne")
UserSerializer.to_h(user)
# => {firstName: "Bruce", lastName: "Wayne", full_name: "Bruce Wayne"}

UserSerializer.new(only: %i[firstName lastName]).to_h(user)
# => {firstName: "Bruce", lastName: "Wayne"}

Plugin :depth_limit

Helps to secure from malicious queries that serialize too much or from accidental serializing of objects with cyclic relations.

Depth limit is checked when constructing a serialization plan, that is when #new method is called, ex: SomeSerializer.new(with: params[:with]). It can be useful to instantiate serializer before any other business logic to get possible errors earlier.

Any class-level serialization methods also check the depth limit as they also instantiate serializer.

When the depth limit is exceeded Serega::DepthLimitError is raised. Depth limit error details can be found in the additional Serega::DepthLimitError#details method

The limit can be checked or changed with the next config options:

  • config.depth_limit.limit
  • config.depth_limit.limit=

There is no default limit, but it should be set when enabling the plugin.

class AppSerializer < Serega
  plugin :depth_limit, limit: 10 # set limit for all child classes
end

class UserSerializer < AppSerializer
  config.depth_limit.limit = 5 # overrides limit for UserSerializer
end

Plugin :explicit_many_option

Requires the :many option on every relationship attribute (an attribute with the :serializer option or a block defining a nested serializer), so it's always explicit whether it returns one object or many.

class BaseSerializer < Serega
  plugin :explicit_many_option
  config.base_serializer = self
end

class PostSerializer < BaseSerializer
  attribute :text

  attribute :user, many: false do
    attribute :name
  end

  attribute :comments, serializer: PostSerializer, many: true
end

Errors

  • The Serega::SeregaError is a base error raised by this gem.

  • The Serega::AttributeNotExist error is raised when validating attributes in :only, :except, :with modifiers. This error contains additional methods:

  • #serializer - shows current serializer

  • #attributes - lists not existing attributes

Release

To release a new version, read RELEASE.md.

Development

  • bundle install - install dependencies
  • bin/console - open irb console with loaded gems
  • bundle exec rspec - run tests
  • bundle exec rubocop - check code standards
  • yard stats --list-undoc --no-cache - view undocumented code
  • yard server --reload - view code documentation

Contributing

Bug reports, pull requests and improvements ideas are very welcome!

License

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