api_serializer
A declarative DSL for serializing Ruby objects to JSON with typed, versioned schemas. Works anywhere Ruby 3.3+ runs — no ActiveSupport, no Rails required.
Part of the another_api family of gems; most useful when paired with the full another_api Rails engine, but usable standalone.
- Installation
- Quickstart
- Core concepts
- Attributes
- Variants
- Associations
- Serializing
- Queryable attributes
- Error handling
- License
Installation
gem "api_serializer"
Ruby ≥ 3.3. Runtime dependency: literal.
Quickstart
class UserSchema < ApiSerializer::Schema
serializer :default do
attribute :id, Integer
attribute :name, String
attribute :role, String, default: "member"
virtual :slug, String do |user|
user.name.downcase.tr(" ", "-")
end
end
deserializer :create do
attribute :name, String
attribute :role, _Nilable(String)
end
end
user = User.new(id: 1, name: "Ada Lovelace", role: "admin")
UserSchema.serializer_for(:default).transform(user).as_json
# => { id: 1, name: "Ada Lovelace", role: "admin", slug: "ada-lovelace" }
UserSchema.deserializer_for(:create).transform(name: "Grace", role: nil).to_h
# => { name: "Grace", role: nil }
Core concepts
- Schema — a class that inherits from
ApiSerializer::Schemaand groups every way a single resource can be read from or written to the API. - Variant — a named, typed projection defined with
serializer :fooordeserializer :foo. One schema can have many. - Attribute — an entry in a variant declared with
attribute,virtual,compose,decompose,has_one, orhas_many. - Transformer — the object that takes input (a model, a hash, anything that responds to the declared attribute names or keys) and produces a typed output struct matching the variant.
Everything else in the gem — templates, fallback resolvers, queryable mappings, nested schemas — builds on those four.
Attributes
attribute
attribute :name, type, from: nil, to: nil, default: nil, transform: nil, queryable: nil, &coercer
The workhorse. Declares a typed attribute that the transformer will pluck from the input.
attribute :id, Integer
Types come from literal — use
String, Integer, Float, Time, Symbol, etc., plus literal
combinators (_Nilable(type), _Union(a, b), _Array(type), _Boolean,
_Any). A non-nil default: implicitly wraps the type in _Nilable.
from: / to: — source/target path on the input object. Supports
.-separated nested paths:
attribute :email, String, from: "contact.email"
# reads input.contact.email, input[:contact][:email], or a mix.
default: — fallback value when the source path is missing. Automatically
makes the attribute nilable.
transform: — a proc called with (value) or (value, context) to
transform the raw value:
attribute :created_at, Time, transform: ->(v) { Time.parse(v) }
attribute :tz_aware, Time, transform: ->(v, ctx) { v.in_time_zone(ctx[:tz]) }
block — the trailing block is a Literal coercer, run by the type system to convert / validate the final value:
attribute :status, Symbol do |value|
value.to_sym
end
queryable: — marks the attribute as filterable/sortable. See
Queryable attributes.
virtual
A computed value with no direct source on the input. Not filterable or sortable. Block receives the whole input object plus optional context.
virtual :full_name, String do |user|
"#{user.first_name} #{user.last_name}"
end
virtual :local_time, Time do |user, context|
user.created_at.in_time_zone(context[:tz])
end
compose
Combine several source paths into one output attribute via a block.
compose :name, String, from: %i[first_name last_name] do |first, last|
"#{first} #{last}"
end
compose :greeting, String, from: %i[name] do |name, context|
"#{(context[:locale] == :fr) ? "Bonjour" : "Hi"} #{name}"
end
The block arity determines how context is passed: arity from.size gets
just the values; arity from.size + 1 gets the values plus the context
hash.
decompose
The inverse of compose — one source, many target attributes. The block
returns an array matching the target names.
decompose %i[first_name last_name], String, from: :full_name do |full|
full.split(" ", 2)
end
Path values (with .) are not supported as decomposition targets.
Variants
serializer and deserializer
Variants are defined inside a Schema subclass. serializer and
deserializer are identical under the hood — the names are a convention
so that your "model → API" and "API → model" shapes are declared
separately.
class ArticleSchema < ApiSerializer::Schema
serializer :default do
attribute :id, Integer
attribute :title, String
attribute :body, String
end
serializer :minimal do
attribute :id, Integer
attribute :title, String
end
deserializer :create do
attribute :title, String
attribute :body, String
end
deserializer :update do
attribute :title, _Nilable(String)
attribute :body, _Nilable(String)
end
end
ArticleSchema.serializer_for(:default).transform(article).as_json
ArticleSchema.deserializer_for(:update).transform(params).to_h
Templates and inheritance
serializer_template and deserializer_template define abstract
variants that can't be used directly but can be inherited from.
base_template defines a template usable by both.
class PostSchema < ApiSerializer::Schema
base_template :id_base do
attribute :id, Integer
end
serializer_template :admin_base, inherits: :id_base do
attribute :created_at, Time
attribute :updated_at, Time
end
serializer :default, inherits: :id_base do
attribute :title, String
end
serializer :admin, inherits: :admin_base do
attribute :title, String
attribute :author_id, Integer
end
end
Abstract variants are only inheritable —
PostSchema.serializer_for(:admin_base) raises
VariantNotFoundError.
Composing templates
A variant can compose in one or more templates via composes::
serializer_template :timestamps do
attribute :created_at, Time
attribute :updated_at, Time
end
serializer_template :audit do
attribute :created_by_id, Integer
attribute :updated_by_id, Integer
end
serializer :admin, composes: %i[timestamps audit] do
attribute :id, Integer
attribute :title, String
end
Looking up variants at runtime
Called without a variant name, serializer / deserializer return a
VariantResolver — a late-binding handle that fetches the variant on
first use. Use this when referring to another schema whose variants may
not be defined yet at class-body evaluation:
class CommentSchema < ApiSerializer::Schema
serializer :default do
attribute :id, Integer
# UserSchema may be loaded after us — the resolver defers lookup.
has_one :author, UserSchema.serializer
end
end
Resolvers optionally take a mapping so the nested schema uses a different variant than the parent:
has_one :author, UserSchema.serializer(full: :minimal, admin: :full)
# ↑ when the parent is rendered
# as :full, render :author as
# :minimal.
To ask (without raising) whether a schema has a given variant, use
Schema.variant?(name, type:):
UserSchema.variant?(:admin) # => true / false
UserSchema.variant?(:create, type: :deserializer)
Associations
has_one and has_many
serializer :default do
attribute :id, Integer
has_one :author, UserSchema.serializer
has_many :comments, CommentSchema.serializer
end
Both accept from:, to:, default:, virtual:, queryable: and a
&coercer block, plus the serializer/resolver as the second positional
argument. (They do not accept attribute's transform: option —
associations transform via their own schema's pipeline.)
Nilable resolvers
Wrap the resolver in _Nilable(...) to allow nil:
has_one :author, _Nilable(UserSchema.serializer)
The parent will accept nil for the association and produce nil in
the output.
Variant fallback
When the parent is rendered as variant X, nested schemas are asked for
variant X too. If they don't define it, they fall back through:
:nested → :minimal → :id_only
Consumers commonly define :nested on every schema as a safe
light-payload fallback. When the resolver can't find any match
(including fallbacks) and the association is _Nilable, the field
renders as nil. Otherwise it raises VariantNotFoundError.
Serializing
From the schema
transformer = UserSchema.serializer_for(:default)
# => DataTransformer bound to the :default variant
transformer.transform(user)
# => a typed TargetDataStructure instance
serializer_for returns a DataTransformer — a stable, cacheable handle;
#transform(input) walks the schema and returns the target struct. If you
already hold a raw Variant (e.g. from Schema.fetch_variant), its
#serialize(input, context = {}) / #deserialize(input, context = {})
convenience methods wrap the same pipeline.
transform accepts an input and an optional context hash:
UserSchema.serializer_for(:default).transform(user, { locale: :fr })
The input can be any object whose attributes can be accessed by
symbolised name, or any Hash-like value indexed by symbol keys. Nested
paths (via from: "contact.email") are walked through the same
interface.
From the instance
For an object-oriented "serialise this instance" flow, wrap the input in
ApiSerializer::SerializationContextWrapper:
wrapper = ApiSerializer::SerializationContextWrapper.new(user, UserSchema, { locale: :fr })
wrapper.serialize(:default)
wrapper.deserialize(:create)
The another_api Rails engine ships a convenience
AnotherApi::Serializes mixin that looks up the schema class by
convention, e.g. user.serialization.serialize(:default) — see that
gem's README for details.
Rendering JSON
The transformer produces a typed struct
(ApiSerializer::TargetDataStructure). Call as_json (recursive, pure
Ruby, no ActiveSupport) to get a plain Hash of primitives suitable for
any JSON encoder:
output = UserSchema.serializer_for(:default).transform(user)
JSON.generate(output.as_json)
# or in a Rails controller:
render json: output.as_json
to_h gives you a shallow hash — use as_json when you have nested
schemas to avoid struct instances leaking into the output.
Context
Context is an arbitrary hash threaded through the whole transformation. Used by:
- attribute
transform:procs with arity 2 virtualblocks with arity 2composeblocks with arityfrom.size + 1- nested association serialization (so nested schemas see the same context as the parent)
current_variant_name is added automatically before nested schemas run,
so downstream resolvers know which variant was requested.
Queryable attributes
Mark an attribute queryable: to expose it to filter/sort mappings
consumed by
api_query_language:
attribute :email, String, queryable: true
attribute :name, String, queryable: {filter: true, sort: false}
attribute :status, String, queryable: {filter: true, allowed_values: %w[draft published]}
attribute :created_at, Time, queryable: {sort: true, filter: false, column: "articles.created_at"}
Options (see ApiSerializer::QueryableConfig):
| Key | Default | Meaning |
|---|---|---|
filter |
true |
Attribute can be filtered on |
sort |
true |
Attribute can be sorted on |
column |
nil |
Explicit DB column if different from the attribute name |
transform |
nil |
Proc applied to the filter value before it reaches the backend |
allowed_values |
nil |
If set, filter values must be one of these |
Retrieve the mappings with:
UserSchema.serializer_for(:default).filtering_mapped_attributes
# => { email: nil, name: nil, status: <QueryableConfig …>, … }
UserSchema.serializer_for(:default).sorting_mapped_attributes
Nested associations are expanded into dotted paths (author.name,
comments.created_at, etc.) up to five levels deep.
Error handling
All raised errors live in ApiSerializer::Errors:
| Error | Raised when |
|---|---|
VariantNotFoundError |
serializer_for(:unknown) or nested variant fallback exhausted |
VariantDefinitionError |
empty variant, invalid inherits:, or missing mixin template |
AttributeDefinitionError |
transform: / compose block arity doesn't match the sources |
DataTransformError |
input doesn't match the declared attribute types at transform time |
DataTransformError wraps Literal's TypeError / ArgumentError and
includes the offending schema class name in the message — pattern-match
on e.message only if you must; otherwise just rescue the class and
render a 400.
License
MIT. See LICENSE.txt at the repository root.