Alba is a JSON serializer for Ruby, JRuby, and TruffleRuby.
Alba uses GitHub Discussions to openly discuss the project.
If you've already used Alba, please consider posting your thoughts and feelings on Feedback. The fact that you enjoy using Alba gives me energy to keep developing Alba!
If you have feature requests or interesting ideas, join us with Ideas. Let's make Alba even better, together!
If you want to know more about Alba, there's a screencast created by Sebastian from Hanami Mastery. It covers basic features of Alba and how to use it in Hanami.
Because it's fast, easy and feature rich!
Alba is faster than most of the alternatives. We have a benchmark.
Alba is easy to use because there are only a few methods to remember. It's also easy to understand due to clean and short codebase. Finally it's easy to extend since it provides some methods for override to change default behavior of Alba.
While Alba's core is simple, it provides additional features when you need them, For example, Alba provides a way to control circular associations, inferring resource classes, root key and associations and supports layouts.
Add this line to your application's Gemfile:
gem 'alba'
And then execute:
$ bundle install
Or install it yourself as:
$ gem install alba
Alba supports CRuby 2.5 and higher and latest JRuby and TruffleRuby.
You can find the documentation on RubyDoc.
- Conditional attributes and associations
- Selectable backend
- Key transformation
- Root key inference
- Error handling
- Nil handling
- Resource name inflection based on association name
- Circular associations control
- [Experimental] Types for validation and conversion
- Layout
- No runtime dependencies
Alba's configuration is fairly simple.
Backend is the actual part serializing an object into JSON. Alba supports these backends.
oj
, the fastest. Gem installation required (C extension).active_support
, mostly for Rails. Gem installation required.default
orjson
, with no external dependencies.
You can set a backend like this:
Alba.backend = :oj
You can also set JSON encoder directly with a Proc.
Alba.encoder = ->(object) { JSON.generate(object) }
You can consider setting a backend with Symbol as a shortcut to set encoder.
You can enable inference feature using enable_inference!
method.
Alba.enable_inference!(with: :active_support)
You can choose which inflector Alba uses for inference. Possible value for with
option are:
:active_support
forActiveSupport::Inflector
:dry
forDry::Inflector
- any object which responds to some methods (see below)
For the details, see Error handling section
You can define attributes with (yes) attributes
macro with attribute names. If your attribute need some calculations, you can use attribute
with block.
class User
attr_accessor :id, :name, :email, :created_at, :updated_at
def initialize(id, name, email)
@id = id
@name = name
@email = email
@created_at = Time.now
@updated_at = Time.now
end
end
class UserResource
include Alba::Resource
root_key :user
attributes :id, :name
attribute :name_with_email do |resource|
"#{resource.name}: #{resource.email}"
end
end
user = User.new(1, 'Masafumi OKURA', 'masafumi@example.com')
UserResource.new(user).serialize
# => "{\"user\":{\"id\":1,\"name\":\"Masafumi OKURA\",\"name_with_email\":\"Masafumi OKURA: masafumi@example.com\"}}"
You can define instance methods on resources so that you can use it as attribute name in attributes
.
# The serialization result is the same as above
class UserResource
include Alba::Resource
root_key :user, :users # Later is for plural
attributes :id, :name, :name_with_email
# Attribute methods must accept one argument for each serialized object
def name_with_email(user)
"#{user.name}: #{user.email}"
end
end
This even works with users collection.
user1 = User.new(1, 'Masafumi OKURA', 'masafumi@example.com')
user2 = User.new(2, 'Test User', 'test@example.com')
UserResource.new([user1, user2]).serialize
# => "{\"users\":[{\"id\":1,\"name\":\"Masafumi OKURA\",\"name_with_email\":\"Masafumi OKURA: masafumi@example.com\"},{\"id\":2,\"name\":\"Test User\",\"name_with_email\":\"Test User: test@example.com\"}]}"
class User
attr_reader :id, :created_at, :updated_at
attr_accessor :articles
def initialize(id)
@id = id
@created_at = Time.now
@updated_at = Time.now
@articles = []
end
end
class Article
attr_accessor :user_id, :title, :body
def initialize(user_id, title, body)
@user_id = user_id
@title = title
@body = body
end
end
class ArticleResource
include Alba::Resource
attributes :title
end
class UserResource
include Alba::Resource
attributes :id
many :articles, resource: ArticleResource
end
user = User.new(1)
article1 = Article.new(1, 'Hello World!', 'Hello World!!!')
user.articles << article1
article2 = Article.new(2, 'Super nice', 'Really nice!')
user.articles << article2
UserResource.new(user).serialize
# => '{"id":1,"articles":[{"title":"Hello World!"},{"title":"Super nice"}]}'
You can define associations inline if you don't need a class for association.
class ArticleResource
include Alba::Resource
attributes :title
end
class UserResource
include Alba::Resource
attributes :id
many :articles, resource: ArticleResource
end
# This class works the same as `UserResource`
class AnotherUserResource
include Alba::Resource
attributes :id
many :articles do
attributes :title
end
end
You can "filter" association using second proc argument. This proc takes association object, params
and initial object.
This feature is useful when you want to modify association, such as adding includes
or order
to ActiveRecord relations.
class User
attr_reader :id, :banned
attr_accessor :articles
def initialize(id, banned = false)
@id = id
@banned = banned
@articles = []
end
end
class Article
attr_accessor :id, :title, :body
def initialize(id, title, body)
@id = id
@title = title
@body = body
end
end
class ArticleResource
include Alba::Resource
attributes :title
end
class UserResource
include Alba::Resource
attributes :id
# Second proc works as a filter
many :articles,
proc { |articles, params, user|
filter = params[:filter] || :odd?
articles.select {|a| a.id.send(filter) && !user.banned }
},
resource: ArticleResource
end
user = User.new(1)
article1 = Article.new(1, 'Hello World!', 'Hello World!!!')
user.articles << article1
article2 = Article.new(2, 'Super nice', 'Really nice!')
user.articles << article2
UserResource.new(user).serialize
# => '{"id":1,"articles":[{"title":"Hello World!"}]}'
UserResource.new(user, params: {filter: :even?}).serialize
# => '{"id":1,"articles":[{"title":"Super nice"}]}'
You can change a key for association with key
option.
class UserResource
include Alba::Resource
attributes :id
many :articles,
key: 'my_articles', # Set key here
resource: ArticleResource
end
UserResource.new(user).serialize
# => '{"id":1,"my_articles":[{"title":"Hello World!"}]}'
Alba.serialize
method is a shortcut to define everything inline.
Alba.serialize(user, root_key: :foo) do
attributes :id
many :articles do
attributes :title, :body
end
end
# => '{"foo":{"id":1,"articles":[{"title":"Hello World!","body":"Hello World!!!"},{"title":"Super nice","body":"Really nice!"}]}}'
Alba.serialize
can be used when you don't know what kind of object you serialize. For example:
Alba.serialize(something)
# => Same as `FooResource.new(something).serialize` when `something` is an instance of `Foo`.
Although this might be useful sometimes, it's generally recommended to define a class for Resource.
You can filter out certain attributes by overriding attributes
instance method. This is useful when you want to customize existing resource with inheritance.
You can access raw attributes via super
call. It returns a Hash whose keys are the name of the attribute and whose values are the body. Usually you need only keys to filter out, like below.
class Foo
attr_accessor :id, :name, :body
def initialize(id, name, body)
@id = id
@name = name
@body = body
end
end
class GenericFooResource
include Alba::Resource
attributes :id, :name, :body
end
class RestrictedFooResource < GenericFooResource
def attributes
super.select { |key, _| key.to_sym == :name }
end
end
RestrictedFooResource.new(foo).serialize
# => '{"name":"my foo"}'
If you want to use transform_keys
DSL and you already have active_support
installed, key transformation will work out of the box, using ActiveSupport::Inflector
. If active_support
is not around, you have 2 possibilities:
- install it
- use a custom inflector
With transform_keys
DSL, you can transform attribute keys.
class User
attr_reader :id, :first_name, :last_name
def initialize(id, first_name, last_name)
@id = id
@first_name = first_name
@last_name = last_name
end
end
class UserResource
include Alba::Resource
attributes :id, :first_name, :last_name
transform_keys :lower_camel
end
user = User.new(1, 'Masafumi', 'Okura')
UserResourceCamel.new(user).serialize
# => '{"id":1,"firstName":"Masafumi","lastName":"Okura"}'
Possible values for transform_keys
argument are:
:camel
for CamelCase:lower_camel
for lowerCamelCase:dash
for dash-case:snake
for snake_case:none
for not transforming keys
You can also transform root key when:
Alba.enable_inference!
is calledroot_key!
is called in Resource classroot
option oftransform_keys
is set to true
Alba.enable_inference!(with: :active_support) # with :dry also works
class BankAccount
attr_reader :account_number
def initialize(account_number)
@account_number = account_number
end
end
class BankAccountResource
include Alba::Resource
root_key!
attributes :account_number
transform_keys :dash, root: true
end
bank_account = BankAccount.new(123_456_789)
BankAccountResource.new(bank_account).serialize
# => '{"bank-account":{"account-number":123456789}}'
This behavior to transform root key will become default at version 2.
Supported transformation types are :camel, :lower_camel and :dash.
A custom inflector can be plugged in as follows.
module CustomInflector
module_function
def camelize(string)
end
def camelize_lower(string)
end
def dasherize(string)
end
def underscore(string)
end
def classify(string)
end
end
Alba.enable_inference!(with: CustomInflector)
You can filter attributes by overriding Alba::Resource#converter
method, but it's a bit tricky.
class User
attr_accessor :id, :name, :email, :created_at, :updated_at
def initialize(id, name, email)
@id = id
@name = name
@email = email
end
end
class UserResource
include Alba::Resource
attributes :id, :name, :email
private
# Here using `Proc#>>` method to compose a proc from `super`
def converter
super >> proc { |hash| hash.compact }
end
end
user = User.new(1, nil, nil)
UserResource.new(user).serialize # => '{"id":1}'
The key part is the use of Proc#>>
since Alba::Resource#converter
returns a Proc
which contains the basic logic and it's impossible to change its behavior by just overriding the method.
It's not recommended to swap the whole conversion logic. It's recommended to always call super
when you override converter
.
Filtering attributes with overriding convert
works well for simple cases. However, It's cumbersome when we want to filter various attributes based on different conditions for keys.
In these cases, conditional attributes works well. We can pass if
option to attributes
, attribute
, one
and many
. Below is an example for the same effect as filtering attributes section.
class User
attr_accessor :id, :name, :email, :created_at, :updated_at
def initialize(id, name, email)
@id = id
@name = name
@email = email
end
end
class UserResource
include Alba::Resource
attributes :id, :name, :email, if: proc { |user, attribute| !attribute.nil? }
end
user = User.new(1, nil, nil)
UserResource.new(user).serialize # => '{"id":1}'
Alba doesn't support default value for attributes, but it's easy to set a default value.
class FooResource
attribute :bar do |foo|
foo.bar || 'default bar'
end
end
We believe this is clearer than using some (not implemented yet) DSL such as default
because there are some conditions where default values should be applied (nil
, blank?
, empty?
etc.)
After Alba.enable_inference!
called, Alba tries to infer root key and association resource name.
Alba.enable_inference!(with: :active_support) # with :dry also works
class User
attr_reader :id
attr_accessor :articles
def initialize(id)
@id = id
@articles = []
end
end
class Article
attr_accessor :id, :title
def initialize(id, title)
@id = id
@title = title
end
end
class ArticleResource
include Alba::Resource
attributes :title
end
class UserResource
include Alba::Resource
key!
attributes :id
many :articles
end
user = User.new(1)
user.articles << Article.new(1, 'The title')
UserResource.new(user).serialize # => '{"user":{"id":1,"articles":[{"title":"The title"}]}}'
UserResource.new([user]).serialize # => '{"users":[{"id":1,"articles":[{"title":"The title"}]}]}'
This resource automatically sets its root key to either "users" or "user", depending on the given object is collection or not.
Also, you don't have to specify which resource class to use with many
. Alba infers it from association name.
You can set error handler globally or per resource using on_error
.
class User
attr_accessor :id, :name
def initialize(id, name, email)
@id = id
@name = name
@email = email
end
def email
raise RuntimeError, 'Error!'
end
end
class UserResource
include Alba::Resource
attributes :id, :name, :email
on_error :ignore
end
user = User.new(1, 'Test', 'email@example.com')
UserResource.new(user).serialize # => '{"id":1,"name":"Test"}'
This way you can exclude an entry when fetching an attribute gives an exception.
There are four possible arguments on_error
method accepts.
:raise
re-raises an error. This is the default behavior.:ignore
ignores the entry with the error.:nullify
sets the attribute with the error tonil
.- Block gives you more control over what to be returned.
The block receives five arguments, error
, object
, key
, attribute
and resource class
and must return a two-element array. Below is an example.
class ExampleResource
include Alba::Resource
on_error do |error, object, key, attribute, resource_class|
if resource_class == MyResource
['error_fallback', object.error_fallback]
else
[key, error.message]
end
end
end
Sometimes we want to convert nil
to different values such as empty string. Alba provides a flexible way to handle nil
.
class User
attr_reader :id, :name, :age
def initialize(id, name = nil, age = nil)
@id = id
@name = name
@age = age
end
end
class UserResource
include Alba::Resource
on_nil { '' }
root_key :user, :users
attributes :id, :name, :age
end
UserResource.new(User.new(1)).serialize
# => '{"user":{"id":1,"name":"","age":""}}'
You can get various information via block parameters.
class UserResource
include Alba::Resource
on_nil do |object, key|
if key == age
20
else
"User#{object.id}"
end
end
root_key :user, :users
attributes :id, :name, :age
end
UserResource.new(User.new(1)).serialize
# => '{"user":{"id":1,"name":"User1","age":20}}'
You can set a metadata with meta
DSL or meta
option.
class UserResource
include Alba::Resource
root_key :user, :users
attributes :id, :name
meta do
if object.is_a?(Enumerable)
{size: object.size}
else
{foo: :bar}
end
end
end
user = User.new(1, 'Masafumi OKURA', 'masafumi@example.com')
UserResource.new([user]).serialize
# => '{"users":[{"id":1,"name":"Masafumi OKURA"}],"meta":{"size":1}}'
# You can merge metadata with `meta` option
UserResource.new([user]).serialize(meta: {foo: :bar})
# => '{"users":[{"id":1,"name":"Masafumi OKURA"}],"meta":{"size":1,"foo":"bar"}}'
# You can set metadata with `meta` option alone
class UserResourceWithoutMeta
include Alba::Resource
root_key :user, :users
attributes :id, :name
end
UserResource.new([user]).serialize(meta: {foo: :bar})
# => '{"users":[{"id":1,"name":"Masafumi OKURA"}],"meta":{"foo":"bar"}}'
You can use object
method to access the underlying object and params
to access the params in meta
block.
Note that setting root key is required when setting a metadata.
Note that this feature works correctly since version 1.3. In previous versions it doesn't work as expected.
You can control circular associations with within
option. within
option is a nested Hash such as {book: {authors: books}}
. In this example, Alba serializes a book's authors' books. This means you can reference BookResource
from AuthorResource
and vice versa. This is really powerful when you have a complex data structure and serialize certain parts of it.
For more details, please refer to test code
You can validate and convert input with types.
class User
attr_reader :id, :name, :age, :bio, :admin, :created_at
def initialize(id, name, age, bio = '', admin = false) # rubocop:disable Style/OptionalBooleanParameter
@id = id
@name = name
@age = age
@admin = admin
@bio = bio
@created_at = Time.new(2020, 10, 10)
end
end
class UserResource
include Alba::Resource
attributes :name, id: [String, true], age: [Integer, true], bio: String, admin: [:Boolean, true], created_at: [String, ->(object) { object.strftime('%F') }]
end
user = User.new(1, 'Masafumi OKURA', '32', 'Ruby dev')
UserResource.new(user).serialize
# => '{"name":"Masafumi OKURA","id":"1","age":32,"bio":"Ruby dev","admin":false,"created_at":"2020-10-10"}'
Notice that id
and created_at
are converted to String and age
is converted to Integer.
If type is not correct and auto conversion is disabled (default), TypeError
occurs.
user = User.new(1, 'Masafumi OKURA', '32', nil) # bio is nil and auto conversion is disabled for bio
UserResource.new(user).serialize
# => TypeError, 'Attribute bio is expected to be String but actually nil.'
Note that this feature is experimental and interfaces are subject to change.
Sometimes we'd like to serialize JSON into a template. In other words, we need some structure OUTSIDE OF serialized JSON. IN HTML world, we call it a "layout".
Alba supports serializing JSON in a layout. You need a file for layout and then to specify file with layout
method.
{
"header": "my_header",
"body": <%= serialized_json %>
}
class FooResource
include Alba::Resource
layout file: 'my_layout.json.erb'
end
Note that layout files are treated as json
and erb
and evaluated in a context of the resource, meaning
- A layout file must be a valid JSON
- You must write
<%= serialized_json %>
in a layout to put serialized JSON string into a layout - You can access
params
in a layout so that you can add virtually any objects to a layout- When you access
params
, it's usually a Hash. You can useencode
method in a layout to convertparams
Hash into a JSON with the backend you use
- When you access
- You can also access
object
, the underlying object for the resource
In case you don't want to have a file for layout, Alba lets you define and apply layouts inline:
class FooResource
include Alba::Resource
layout inline: proc do
{
header: 'my header',
body: serializable_hash
}
end
end
In the example above, we specify a Proc which returns a Hash as an inline layout. In the Proc we can use serializable_hash
method to access a Hash right before serialization.
You can also use a Proc which returns String, not a Hash, for an inline layout.
class FooResource
include Alba::Resource
layout inline: proc do
%({
"header": "my header",
"body": #{serialized_json}
})
end
end
It looks similar to file layout but you must use string interpolation for method calls since it's not an ERB.
Also note that we use percentage notation here to use double quotes. Using single quotes in inline string layout causes the error which might be resolved in other ways.
Currently, Alba doesn't support caching, primarily due to the behavior of ActiveRecord::Relation
's cache. See the issue.
Sometimes we have shared behaviors across resources. In such cases we can have a module for common logic.
In attribute
block we can call instance method so we can improve the code below:
class FooResource
include Alba::Resource
# other attributes
attribute :created_at do |foo|
foo.created_at.strftime('%m/%d/%Y')
end
attribute :updated_at do |foo|
foo.updated_at.strftime('%m/%d/%Y')
end
end
class BarResource
include Alba::Resource
# other attributes
attribute :created_at do |bar|
bar.created_at.strftime('%m/%d/%Y')
end
attribute :updated_at do |bar|
bar.updated_at.strftime('%m/%d/%Y')
end
end
to:
module SharedLogic
def format_time(time)
time.strftime('%m/%d/%Y')
end
end
class FooResource
include Alba::Resource
include SharedLogic
# other attributes
attribute :created_at do |foo|
format_time(foo.created_at)
end
attribute :updated_at do |foo|
format_time(foo.updated_at)
end
end
class BarResource
include Alba::Resource
include SharedLogic
# other attributes
attribute :created_at do |bar|
format_time(bar.created_at)
end
attribute :updated_at do |bar|
format_time(bar.updated_at)
end
end
We can even add our own DSL to serialize attributes for readability and removing code duplications.
To do so, we need to extend
our module. Let's see how we can achieve the same goal with this approach.
module AlbaExtension
# Here attrs are an Array of Symbol
def formatted_time_attributes(*attrs)
attrs.each do |attr|
attribute attr do |object|
time = object.send(attr)
time.strftime('%m/%d/%Y')
end
end
end
end
class FooResource
include Alba::Resource
extend AlbaExtension
# other attributes
formatted_time_attributes :created_at, :updated_at
end
class BarResource
include Alba::Resource
extend AlbaExtension
# other attributes
formatted_time_attributes :created_at, :updated_at
end
In this way we have shorter and cleaner code. Note that we need to use send
or public_send
in attribute
block to get attribute data.
When you use Alba in Rails, you can create an initializer file with the line below for compatibility with Rails JSON encoder.
Alba.backend = :active_support
# or
Alba.backend = :oj_rails
The name "Alba" comes from "albatross", a kind of birds. In Japanese, this bird is called "Aho-dori", which means "stupid bird". I find it funny because in fact albatrosses fly really fast. I hope Alba looks stupid but in fact it does its job quick.
There are great pioneers in Ruby's ecosystem which does basically the same thing as Alba does. To name a few:
- ActiveModelSerializers a.k.a AMS, the most famous implementation of JSON serializer for Ruby
- Blueprinter shares some concepts with Alba
After checking out the repo, run bin/setup
to install dependencies. Then, run rake test
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/okuramasafumi/alba. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the Alba project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.