Serega Cheatsheet
Table of Contents
Your First Serializer
class UserSerializer < Serega attribute :name attribute :email end user = OpenStruct.new(name: "Felonious Gru", email: "gru@example.com") UserSerializer.to_h(user) # => {name: "Felonious Gru", email: "gru@example.com"}
Each attribute :name calls object.name and adds it to the hash.
Attribute Options
default β calls method of the same name
class UserSerializer < Serega attribute :first_name end UserSerializer.to_h(OpenStruct.new(first_name: "Felonious")) # => {first_name: "Felonious"}
method: β call a different method
class UserSerializer < Serega attribute :name, method: :full_name end UserSerializer.to_h(OpenStruct.new(full_name: "Felonious Gru")) # => {name: "Felonious Gru"}
block β nested serializer
A block defines a nested serializer for the attribute value. Requires a base serializer: the base_serializer: option or config.base_serializer (see Relations, Configuration).
class UserSerializer < Serega config.base_serializer = Serega attribute :statistics, method: :itself do attribute :likes_count attribute :comments_count end end UserSerializer.to_h(OpenStruct.new(likes_count: 10, comments_count: 3)) # => {statistics: {likes_count: 10, comments_count: 3}}
value: β callable
Signatures: (obj), (obj, ctx:), (obj, batches:), (obj, ctx:, batches:), (obj, context) (see Context, Batch Loading).
class UserSerializer < Serega attribute :quote, value: proc { |user, ctx| "#{user.first_name}: #{ctx[:line]}" } end UserSerializer.to_h(OpenStruct.new(first_name: "Felonious"), context: {line: "Light bulb!"}) # => {quote: "Felonious: Light bulb!"}
delegate: β object.to.method
class UserSerializer < Serega attribute :city, delegate: {to: :address, allow_nil: true} end UserSerializer.to_h(OpenStruct.new(address: nil)) # => {city: nil} UserSerializer.to_h(OpenStruct.new(address: OpenStruct.new(city: "Suburbia"))) # => {city: "Suburbia"}
delegate: { to: :address, method: :town } calls object.address.town. delegate: { to: :address, method: :town, allow_nil: true } calls object.address&.town.
const: β fixed value
class UserSerializer < Serega attribute :role, const: "admin" end UserSerializer.to_h(OpenStruct.new) # => {role: "admin"}
default: β replace nil
class UserSerializer < Serega attribute :nickname, default: "anonymous" end UserSerializer.to_h(OpenStruct.new(nickname: nil)) # => {nickname: "anonymous"} UserSerializer.to_h(OpenStruct.new(nickname: "Vector")) # => {nickname: "Vector"}
hide: β skip unless explicitly requested (see Field Selection)
class UserSerializer < Serega attribute :email attribute :phone, hide: true end UserSerializer.to_h(OpenStruct.new(email: "gru@example.com", phone: "555-1234")) # => {email: "gru@example.com"} UserSerializer.to_h(OpenStruct.new(email: "gru@example.com", phone: "555-1234"), with: :phone) # => {email: "gru@example.com", phone: "555-1234"}
β οΈ Attribute names must match [a-zA-Z0-9_-~] (URL-safe). Disable with config.check_attribute_name = false.
Serialize
class UserSerializer < Serega attribute :name end user = OpenStruct.new(name: "Felonious Gru") UserSerializer.to_h(user) # => {name: "Felonious Gru"} UserSerializer.to_h([user]) # auto array detection => [{name: "Felonious Gru"}] UserSerializer.to_data(user) # Ruby Data object => #<data name="Felonious Gru"> UserSerializer.to_struct(user) # Ruby Struct object => #<struct name="Felonious Gru"> UserSerializer.to_json(user) # JSON String => "{\"name\":\"Felonious Gru\"}" UserSerializer.new(only: [:name]).to_h(user) # reuse β serialization plan built once
To serialize ids or other references, see Prepare Initial Objects.
Field Selection β :only :except :with
class UserSerializer < Serega attribute :name attribute :email attribute :phone, hide: true end user = OpenStruct.new(name: "Felonious", email: "gru@example.com", phone: "555-1234") UserSerializer.to_h(user, only: [:name]) # => {name: "Felonious"} UserSerializer.to_h(user, except: [:email]) # => {name: "Felonious"} UserSerializer.to_h(user, with: [:phone]) # => {name: "Felonious", email: "gru@example.com", phone: "555-1234"}
# Nested UserSerializer.to_h(user, only: [:name, {posts: [:title]}]) UserSerializer.to_h(user, only: "name,posts(title)") # one String, e.g. from GET ?fields=...
# Combined: :except wins, :only and :with both add fields UserSerializer.to_h(user, only: [:name, :email], except: [:email]) # => {name: "Felonious"} UserSerializer.to_h(user, only: [:name], with: [:phone]) # => {name: "Felonious", phone: "555-1234"}
Each relation can use its own option, see Combining options.
# Unknown attribute UserSerializer.to_h(user, only: [:address]) # => raises Serega::AttributeNotExist UserSerializer.to_h(user, only: [:address], check_initiate_params: false) # => {}
Context
class UserSerializer < Serega attribute :email, value: proc { |user, ctx| user.email if ctx[:current_user] == user } end user = OpenStruct.new(email: "gru@example.com") UserSerializer.to_h(user, context: {current_user: user}) # => {email: "gru@example.com"} UserSerializer.to_h(user, context: {current_user: nil}) # => {email: nil}
Inside presenter methods context is accessed via __ctx__ (see Presenter).
Conditional Attributes β :if :unless
| Option | Sees | Decides before |
|---|---|---|
if: |
(object, ctx) |
computing value |
unless: |
(object, ctx) |
computing value |
if_value: |
(value, ctx) |
after value built |
unless_value: |
(value, ctx) |
after value built |
class UserSerializer < Serega attribute :email, if: proc { |user, ctx| ctx[:current_user] == user } attribute :nickname, unless_value: :empty? end user = OpenStruct.new(email: "gru@example.com", nickname: "") UserSerializer.to_h(user, context: {current_user: nil}) # => {} UserSerializer.to_h(user, context: {current_user: user}) # => {email: "gru@example.com"}
Symbol short form: if: :active? β calls object.active?.
β οΈ if_value/unless_value cannot be combined with :serializer or a block β use if/unless there.
For unconditional hiding, prefer hide: true β see Attribute Options and Field Selection.
Formatters β format:
class AppSerializer < Serega formatter :iso8601, [:iso8601, 3], allow_nil: true # value&.iso8601(3) formatter :yes_no, ->(value) { value ? "yes" : "no" } formatter(:money) { |value, ctx| "$#{value / (10**ctx[:digits])}" } end class UserSerializer < AppSerializer attribute :balance, format: :money attribute :active, format: :yes_no attribute :role, format: [:to_s] # inline: value.to_s attribute :score, format: proc { |v| "#{v}%" } # inline end UserSerializer.to_h(OpenStruct.new(balance: 100_000, active: true, role: :admin, score: 87), context: {digits: 2}) # => {balance: "$1000", active: "yes", role: "admin", score: "87%"}
Relations
class CommentSerializer < Serega attribute :body end class PostSerializer < Serega attribute :title attribute :comments, serializer: CommentSerializer end post = OpenStruct.new(title: "How to Steal the Moon", comments: [OpenStruct.new(body: "Great post!")]) PostSerializer.to_h(post) # => {title: "How to Steal the Moon", comments: [{body: "Great post!"}]}
# Cyclic references / lazy attribute :comments, serializer: "CommentSerializer" attribute :comments, serializer: -> { CommentSerializer } attribute :comments, serializer: CommentSerializer, many: true # force collection
# Inline nested serializer via block. It inherits from `base_serializer:` # option / `config.base_serializer` (attributes of the base, if any, are # serialized too). attribute :comments, base_serializer: AppSerializer do attribute :body end
DB preloads for :serializer attributes β see Preloads. Force many: on every relation β see Required many:.
Batch Loading (N+1)
class UserSerializer < Serega batch :comments_count, ->(users) { users.to_h { |u| [u.id, u.id * 10] } } attribute :comments_count, batch: true end UserSerializer.to_h([OpenStruct.new(id: 1), OpenStruct.new(id: 2)]) # => [{comments_count: 10}, {comments_count: 20}]
Other forms:
attribute :comments_count, batch: :comments_count # named loader by name (same as batch: true above) attribute :comments_count, batch: {use: :comments_count, id: :uuid} # hash form, only for sub-options attribute :comments_count, batch: {use: ->(users) { Comment.where(user: users).group(:user_id).count }} # inline loader # Multiple loaders β note the keyword is `batches:` (plural) attribute :total_likes, batch: {use: [:fb_likes, :tw_likes]}, value: proc { |user, batches:| batches[:fb_likes][user.id] + batches[:tw_likes][user.id] }
β οΈ Value-proc keyword is batches: (plural). config.batch_id_option = :id is the default ID method.
Pairs well with :preload to avoid N+1 inside loaders (see Preloads).
Preloads
class PostSerializer < Serega attribute :verified_comments, preload: :comments, value: proc { |post| post.comments.select(&:verified?) } end
preload: value semantics
The value is passed to the preload handler exactly as written β :activerecord_preloads hands it straight to ActiveRecord::Associations::Preloader.
| Value | Effect |
|---|---|
:assoc |
preloads the :assoc association onto the objects to serialize |
[:a, :b] / {a: :b} / custom |
passed through as-is to the preload handler |
nil |
preloads nothing (also blocks auto_preload) |
false |
preloads nothing (also blocks auto_preload) |
true |
invalid β raises when the attribute is defined |
class AddressSerializer < Serega attribute :country, preload: :country end
class UserSerializer < Serega attribute :address, serializer: AddressSerializer, preload: :address end
:address is preloaded onto the users, and :country onto the addresses.
How preloads actually work
The loading is done by a handler registered with preload_with, called once per preloaded attribute with the gathered objects and that attributeβs :preload value:
class AppSerializer < Serega preload_with { |objects, preloads| MyORM.preload(objects, preloads) } end
plugin :activerecord_preloads registers one for you (built on ActiveRecord::Associations::Preloader), so enabling it loads every declared association once, automatically β no N+1. For another ORM β or for plain non-ORM objects β register your own handler: it can load data from any source and attach it to the objects by your own rules. Declaring :preload with no registered handler raises an error.
The handler runs once per preloaded attribute β the same :preload value on several attributes calls it once for each, so a custom handler should check whether the data is already loaded before loading it again.
Because the :preload value is yours to choose (not necessarily an association name), it doubles as a discriminator β give attributes different :preload values and branch on preloads in the handler:
attribute :owner, serializer: UserSerializer, preload: :owner attribute :author, serializer: UserSerializer, preload: :author preload_with do |objects, preloads| case preloads when :owner then load_owners(objects) when :author then load_authors(objects) else MyORM.preload(objects, preloads) end end
config.auto_preload saves you from writing preload: by hand β when enabled, attributes with :serializer or :delegate get a :preload inferred from that optionβs target.
Serializing Hash Records (hash_access:)
# objects should implement #[] and #fetch β Hash already does class UserSerializer < Serega # `true` is a shorthand for config.hash_access.default_mode (:symbol by default) attribute :name, hash_access: true # record[:name] attribute :role, hash_access: :string # record["role"] end UserSerializer.to_h({:name => "Kate", "role" => "admin"}) # => {name: "Kate", role: "admin"}
# allow_missing_key: nil instead of raising KeyError class UserSerializer < Serega attribute :middle_name, hash_access: {allow_missing_key: true} attribute :status, hash_access: {allow_missing_key: true}, default: "active" end UserSerializer.to_h({}) # => {middle_name: nil, status: "active"}
# config.hash_access.default_mode / default_allow_missing_key set the defaults for every attribute class UserSerializer < Serega config.hash_access.default_mode = :string config.hash_access.default_allow_missing_key = true attribute :middle_name, hash_access: true end UserSerializer.to_h({}) # => {middle_name: nil}
# delegate: configures hash access per step β # to_hash_access: for the intermediate (:to) read, hash_access: for the final read class UserSerializer < Serega attribute :city, delegate: {to: :address, hash_access: :symbol} # record.address[:city] end UserSerializer.to_h(OpenStruct.new(address: {city: "Paris"})) # => {city: "Paris"}
class UserSerializer < Serega attribute :city, delegate: {to: :address, to_hash_access: :symbol, hash_access: :symbol} # record[:address][:city] end UserSerializer.to_h({address: {city: "Paris"}}) # => {city: "Paris"}
Sharing Setup via Inheritance
A child serializer inherits attributes, config, and plugins from its parent. Define a base serializer once, then put shared setup there.
class AppSerializer < Serega config.check_initiate_params = false # shared by every subclass config.base_serializer = self # nested serializers from attribute blocks inherit AppSerializer end class UserSerializer < AppSerializer attribute :name end class PostSerializer < AppSerializer attribute :title end
Both UserSerializer and PostSerializer now skip unknown fields β no need to configure them twice.
Set defaults in the base class β see Configuration.
Prepare Initial Objects
class UserSerializer < Serega # OccamsRecord β faster, low-memory, read-only records prepare_initial_objects { |user_ids| OccamsRecord.query(User.where(id: user_ids)).run } attribute :first_name end UserSerializer.to_h(["17"]) # => [{first_name: "Ann"}]
Other forms:
# context as a second positional argument prepare_initial_objects { |ids, ctx| OccamsRecord.query(User.where(id: ids, account: ctx[:account])).run } # context as a :ctx keyword argument prepare_initial_objects { |ids, ctx:| OccamsRecord.query(User.where(id: ids, account: ctx[:account])).run } # any callable value prepare_initial_objects UsersLoader
β οΈ Runs only for the objects given to the serializer, not for objects of nested serializers. Runs before :many is detected, so it may turn a single object into a collection.
Configuration
config.auto_preload β auto-add :preload from :delegate / :serializer
class UserSerializer < Serega config.auto_preload = true attribute :city, delegate: {to: :address, method: :city} end # `:address` is preloaded automatically (from the :delegate option)
Default false. true is sugar for both flags on. Pass a Hash to opt in to just one:
class UserSerializer < Serega config.auto_preload = {has_serializer_option: true, has_delegate_option: false} attribute :city, delegate: {to: :address, method: :city} # no auto-preload attribute :posts, serializer: PostSerializer # auto-preloaded end
See Preloads.
config.hide_by_default β hide attrs unless explicitly requested
class UserSerializer < Serega config.hide_by_default = :auto # hides attributes with preloads or batch loaders attribute :name attribute :stats, preload: :stats, value: proc { |u| u.stats } end UserSerializer.to_h(user) # => {name: "Felonious"} (stats skipped) UserSerializer.to_h(user, with: :stats) # => {name: "Felonious", stats: 3}
Accepts false (default), true (hide everything) or :auto.
See Field Selection.
config.delegate_default_allow_nil β default :allow_nil for every :delegate
class UserSerializer < Serega config.delegate_default_allow_nil = true attribute :city, delegate: {to: :address} end UserSerializer.to_h(OpenStruct.new(address: nil)) # => {city: nil}
Default false β without this, a nil target raises NoMethodError.
config.batch_id_option β default method for batch IDs
class UserSerializer < Serega config.batch_id_option = :uuid batch :likes, ->(users) { users.to_h { |u| [u.uuid, 99] } } attribute :likes, batch: true # uses :uuid instead of :id end UserSerializer.to_h([OpenStruct.new(uuid: "abc")]) # => [{likes: 99}]
Default :id. See Batch Loading.
config.base_serializer β parent for block-defined nested serializers
class AppSerializer < Serega config.base_serializer = self # usually a settings-only serializer end class UserSerializer < AppSerializer attribute :statistics, method: :itself do # inherits from AppSerializer attribute :likes_count end end
No default β an attribute block raises an error when no base serializer is chosen. Can be set per attribute with the base_serializer: option (it wins over config). Attributes of the base, if any, are serialized too.
config.check_initiate_params β validate :only / :except / :with
class UserSerializer < Serega config.check_initiate_params = false attribute :name end UserSerializer.to_h(user, only: [:nope]) # => {} (silently skipped) # default `true` would raise Serega::AttributeNotExist
Common pattern: false in production, true in dev/test.
config.check_attribute_name β URL-safe attribute names
class UserSerializer < Serega config.check_attribute_name = false attribute :"weird.name" # would otherwise raise end
Default true β names must match [a-zA-Z0-9_-~].
config.depth_limit β deepest nesting of fields
class CommentSerializer < Serega config.depth_limit = 2 attribute :text attribute :replies, serializer: -> { CommentSerializer }, many: true, hide: true end CommentSerializer.new(with: :replies) # depth 2 CommentSerializer.new(with: {replies: :replies}) # => raises Serega::DepthLimitError (#details has the path)
Default nil β no limit. The root serializerβs limit applies. Checked at .new β instantiate before business logic to fail fast on malicious ?with= queries.
config.max_cached_plans_per_serializer_count β plan cache
Caches prepared serialization plans by :only / :except / :with signature so repeated requests with the same modifiers skip rebuilding. Default 20 (0 disables it); the plan without modifiers is always cached. No effect on output β purely a performance tuning knob.
Presenter
class UserSerializer < Serega attribute :full_name attribute :role presenter do # methods of the wrapped object are available directly def full_name "#{first_name} #{last_name}" end def role (id == __ctx__[:current_user_id]) ? :self : :other # context via __ctx__ end end end UserSerializer.to_h(OpenStruct.new(first_name: "Felonious", last_name: "Gru", id: 1), context: {current_user_id: 1}) # => {full_name: "Felonious Gru", role: :self}
__getobj__ returns the wrapped object. __ctx__ exposes the serialization context (see Context).
ActiveRecord Preloads
class AppSerializer < Serega plugin :activerecord_preloads end UserSerializer.to_h(user) # AR Preloader runs declared preloads automatically
Needs preloads declared on attributes β see Preloads.
Required many:
class AppSerializer < Serega config.require_many = true config.base_serializer = self # for relations defined with a block end class PostSerializer < AppSerializer attribute :author, serializer: -> { UserSerializer } end # => raises Serega::SeregaError: Attribute option :many [Boolean] must be provided # Fix: attribute :author, serializer: -> { UserSerializer }, many: false attribute :comments, serializer: -> { CommentSerializer }, many: true # Same for relations defined with a block: attribute :comments, many: true do attribute :text end
Root Key and Metadata
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 :root, root: :users # change for all plugin :root, root_one: :user, root_many: :users # different keys per shape plugin :root, root: nil # no wrapping by default # Per-side override: config.root.one = nil config.root.many = nil
class UserSerializer < Serega plugin :root plugin :metadata meta_attribute(:version, const: "1.2.3") end UserSerializer.to_h(nil) # => {data: nil, version: "1.2.3"}
class UserSerializer < Serega plugin :root plugin :context_metadata # default key :meta β merges into top level end UserSerializer.to_h(nil, meta: {page: 1}) # => {data: nil, page: 1}
Combined (root_one/root_many + metadata + context_metadata):
ResponseSerializer.to_h(user, meta: {page: 1}) # => {user: {first_name: "Felonious"}, show: "Despicable Me", page: 1} ResponseSerializer.to_h([walter, lucy], meta: {page: 1}) # => {users: [{first_name: "Felonious"}, {first_name: "Lucy"}], show: "Despicable Me", page: 1}
π‘ Recommendation: instead of mixing
:root,:metadataand:context_metadata, build an envelope serializer with plain:dataand:metaattributes β then every response field plays by the same rules (field selection, formatters,if/unless):class ResponseSerializer < AppSerializer attribute :data, serializer: -> { ... } attribute :meta # via :value or a nested serializer block end
Errors
| Error | When |
|---|---|
Serega::SeregaError |
base class for everything |
Serega::AttributeNotExist |
unknown attr in [Field Selection][field-selection] β #serializer, #attributes |
Serega::DepthLimitError |
[config.depth_limit][depth-limit] breach β #details |