SimpleAMS
"Simple things should be simple and complex things should be possible." Alan Kay.
If we want to interact with modern APIs we should start building modern, flexible libraries that help developers to build such APIs. Modern Ruby serializers, as I always wanted them to be.
You can find the core ideas, the reasoning behind the architecture, use cases and examples here.
Table of contents
- Installation
- Usage
- Development
- Contributing
Installation
Add this line to your application's Gemfile:
gem 'simple_ams'
And then execute:
$ bundle
Or install it yourself as:
$ gem install simple_ams
Usage
The gem's interface has been inspired by ActiveModel Serializers 0.9.2, 0.10.stable, jsonapi-rb and Ember Data. However, it has been built for POROs, has zero dependencies and does not relate to Rails in any case other than some nostalgia for the (advanced at that time) pre-0.10 ActiveModel Serialiers.
You can find the core ideas, the reasoning behind the architecture, use cases and examples here.
Simple case
You will rarely need all the advanced options. Usually you will have something like that:
class UserSerializer
include SimpleAMS::DSL
#specify the adapter we want to use
adapter SimpleAMS::Adapters::JSONAPI
#specify the attributes we want to serialize from the given object
attributes :id, :name, :email, :created_at, :role
#specify the type of the resource
type :user
#specify the name of the collection
collection :users
#specify a relation. Here microposts serves as both a name of the collection
#and the name of the method used to retrieve the values of the collection
#from the given object
has_many :microposts
end
Rendering a resource
Then you can just feed your serializer with data:
SimpleAMS::Renderer.new(user).to_json
to_json
first calls as_json
, which creates a ruby Hash and then to_json
is called
on top of that hash.
If you want to filter the available options (defined by the serializer) when you
instantiate the serializer, Renderer
accepts an options hash. In there you can
throw pretty much the same DSL:
SimpleAMS::Renderer.new(user, {
serializer: UserSerializer, fields: [:id, :name, :email], includes: []
}).to_json
Here we say that we only want 3 specific fields, and no relations at all.
Rendering a collection
Rendering a collection is pretty similar, meaning that it reuses the same serializer class, and accepts the same runtime options. The only difference is that you need to call a different class.
SimpleAMS::Renderer::Collection.new(users, {
serializer: UserSerializer, fields: [:id, :email, :name], includes: []
}).to_json
Serializer DSL
The serializer is a very robust, yet simple, with a hash-based internal representation.
fields directive
Fields specify the attributes that the serializer will hold. The values of each attribute is taken by the to-be serialized object, unless the serializer has a method of the same name.
fields :id, :name, :email, :created_at, :role
Using attributes
is also valid, itโs just an alias after all:
attributes :id, :name, :email, :created_at, :role
Of course, any field can be overridden by defining a method of the same name inside the serializer. In there, you can have access to a method called object which holds the actual resource to be serialized:
def name
"#{object.first_name} #{object.last_name}"
end
Relations (has_many/has_one/belongs_to)
These directives allows you to append relations in a resource.
has_one
is just an alias of belongs_to
since there is no real difference in
APIs (although internally and in adapters, SimpleAMS knows if you specified the
relation usingbelongs_to
or has_one
, making it future proof in case API specs
decide to support each one in a different way).
has_many :microposts
Again, it can be overridden by defining a method of the same name:
def microposts
Post.where(user_id: object.id).order(:created_at, :desc).limit(10)
end
relations are recursive
The relations directives can take the same options as the rendering.
#overriding the serializer
has_many :microposts, serializer: CustomPostsSerializer
#overriding the serializer and fields that should be included
has_many :microposts, serializer: CustomPostsSerializer, fields: [:content]
#overriding the serializer, fields and relations that should be included
has_many :microposts, serializer: CustomPostsSerializer, fields: [:content],
includes: []
#overriding the serializer, fields, relations and links
has_many :microposts, serializer: CustomPostsSerializer, fields: [:content],
includes: [], links: [:self]
When overriding from the relations directives (or when rendering in general) you are able to override any directive defined in the serializer to acquire a subset but never a superset.
embedded content (again recursive)
Sometimes, an annoying spec might define parts of a relation in the main body, while parts of the relation somewhere else. For instance, JSON:API does that by having some links in the main body and the rest in the included section. Thatโs also possible if you pass a block in the relation directive:
has_many :microposts, serializer: MicropostsSerializer, fields: [:content] do
#these goes to a class named `Embedded`, attached to the relation
link :self, ->(obj){ "/api/v1/users/#{obj.id}/relationships/microposts" }
link :related, ->(obj){ ["/api/v1/users/1", rel: :user] }
end
Inside that block, you can pass any parameter the original DSL supports and will be stored in an Embedded class under MicropostsSerializer. Btw SimpleAMS is smart enough (one of the very few cases that acts like that) to figure out that if a lambda returns something thatโs not an array, then this must be the value, while options are just empty.
relation name/type
Sometimes, we want to detach the relationโs name from the type. In the previous
example microposts
is the relation name (whatever that means), while the type
is defined by the MicropostsSerializer
, unless we override it, which can be
done either in the relation serializer itself, or when we use the relation from
the parent serializer:
has_many :microposts, serializer: MicropostsSerializer, fields: [:content], type: :feed do
link :self, ->(obj){ "/api/v1/users/#{obj.id}/relationships/microposts" }
link :related, ->(obj){ ["/api/v1/users/1", rel: :user] }
end
Internally SimpleAMS, differentiates type from name, and usually type is something thatโs semantically stronger (like a relation type) than name. You can even inject the name of the relation using the name option:
has_many :microposts, serializer: MicropostsSerializer, fields: [:content], type: :feed, name: :posts do
link :self, ->(obj){ "/api/v1/users/#{obj.id}/relationships/microposts" }
link :related, ->(obj){ ["/api/v1/users/1", rel: :user] }
end
As I said, the name, which is usually the name of the attribute that includes the relation in the JSON format, doesnโt really have any semantic meaning in most specs. At least I havenโt seen any spec to depend on the root attribute name of the relation. Instead itโs the type thatโs important, because type is what the web linking RFC defines.
value-hashmap type of directives
These are directives like adapter. They take a value, and optionally a hashmap,
which are options to be passed down straight to the adapter, hence they are adapter specific.
Such options are primary_id
, type
and adapter
For instance, for adapter it could be:
adapter SimpleAMS::Adapters::JSONAPI, root: true
Of course, since we are talking about Ruby here, it would be a huge restriction
to not allow dynamic value/hashmap combination. Basically any such directive
can accept a lambda (generally anything that responds to call
) and should
return an array where the first part is the value and (optionally) the second part is the
options. There is an argument that is passed down to the function/lambda, and
thatโs the actual object. For instance, to support polymorphic resources you
can have the type dynamic:
type ->(obj, s){ obj.employee? ? [:employee, {}] : [:user, {}]}
One of the very few times that SimpleAMS acts smart is inside the lambda, that if you have only a value (not an Array), it will take that as the value, while the options will be taken by the second argument, after the lambda. So the above is equivalent with:
type ->(obj, s){ obj.employee? ? :employee : :user}, {}
Note: you shouldn't use that in case of adapter, as that's the definition of UB :P
adapter
Specifies the adapter to be used. The adapter
method is the only one that does
not support lambda as it's value, as that would be the definition of undefined
behavior. If you want to support polymorphic collections, you should use the type
instead in combination with the serializer
.
#without options
adapter SimpleAMS::Adapters::JSONAPI
#with adapter-specific options
adapter SimpleAMS::Adapters::JSONAPI, {root: false}
Note that you can even specify your own adapter. Usually you will want to inherit
from an existing adapter (like SimpleAMS::Adapters::AMS
), but that's not a
requirement. All you need is to duck type to 2 methods:
initialize(document, options = {})
be able to accept 2 arguments when your adapter is instantiated. The first one is a document, while the second one is the adapter-specific options (like the{root: false}
.as_json
method returns that returns the hash representation of the serialized result
The conversion of a Hash into raw JSON string is out of the scope of this library. But you will probably want to use the fastest implementation possible like oj.
primary_id
Specifies the primary_id
to be used. There are many API specs that handle the
identifier of a resource in a different way than the rest of the attributes.
JSON:API is one of those.
#without options
primary_id :id
#with adapter-specific options
adapter :id, {external: true}
#dynamic
adapter ->(obj, s) { [obj.class.primary_key, {}]}
type
Specifies the type
to be used. There are many API specs that handle the
type of a resource in a different way than the rest of the attributes.
JSON:API is one of those.
#without options
type :user
#with adapter-specific options
type :user, {polymorphic: false}
#dynamic
type ->(obj, s){ obj.employee? ? [:employee, {}] : [:user, {}]}
name-value-hashmap type of directives
These are similar to the above, only that they also have an actual value, which
is converted to a representation through the adapter.
Such options are link
, meta
, form
and the most generic directive generic
.
For instance, think about a links. According to RFC 8288, a link has
- a link context,
- a link relation type,
- a link target, and
- optionally, target attributes
Now, if we wanted to translate that to our serializers, a link could look like:
link :feed, '/api/v1/me/feed', {style: :compact}
Here obviously the link context is the serializer itself, the link relation is
the feed, and the value is /api/v1/me/feed
. Now you might say, feed should be
the name of the link which is different from the relation type.
The relation type could be microposts
.
And actually, thatโs the case for JSONAPI v1.1.
In that case, the feed should be treated barely as a name (whatever that means)
and relation type will be put inside the link options like:
link :feed, '/api/v1/me/feed', {rel: :microposts, style: :compact}
Note however that this needs to be supported by the adapter you are using.
Similar to the case of value-hash directives, it is possible to have dynamic value and options:
#values can be dynamic through lambdas
#lambdas take arguments the object to be serialized and the instantiated serializer
link :feed, ->(obj, s) { [s.api_v1_user_feed_path(user_id: obj.id), {rel: :feed} }
#if the value inside the lambda is single (no array), the options will be taken from
#the second argument, after the lambda. So the above is equivelent to:
link :feed, ->(obj, s) { s.api_v1_user_feed_path(user_id: obj.id) }, rel: :feed
link
Specifies a link to be used. There are many API specs that handle the links of a resource in a special way (JSON:API is one of those). You can specify multiple links, as long as each link name is unique.
#specifying a link with without options
link :feed, "/api/v1/feed"
#specifying a link with options
link :feed, "/api/v1/feed", {rel: :feed, compact: true}
#values can be dynamic through lambdas
#lambdas take arguments the object to be serialized and the instantiated serializer
link :feed, ->(obj, s) { [s.api_v1_user_feed_path(user_id: obj.id), {rel: :feed, compact: true}] }
#if the value inside the lambda is single (no array), the options will be taken from
#the second argument, after the lambda. So the above is equivelent to:
link :feed, ->(obj, s) { s.api_v1_user_feed_path(user_id: obj.id) }, rel: :feed, compact: true
meta
Specifies a meta to be used. There are many API specs that handle the metas of a resource in a special way (JSON:API is one of those). You can specify multiple metas, as long as each link name is unique.
#specifying a meta with without options
meta :total_count, 1
#specifying a meta with options
meta :total_count, 1, {compact: true}
#values can be dynamic through lambdas
#lambdas take arguments the object to be serialized and the instantiated serializer
#in this case an object is apparently a collection/array
meta :total_count, ->(obj, s) { [obj.count, {compact: true}] }
#if the value inside the lambda is single (no array), the options will be taken from
#the second argument, after the lambda. So the above is equivelent to:
meta :total_count, ->(obj, s) { obj.count }, {compact: true}
form
Specifies a form to be used. Unfortunately, there are very few API specs that handle forms (the Ion hypermedia type is one of those). You can specify multiple forms, as long as each link name is unique.
#specifying a form with without options
form :upload, {method: :get, url: "/api/v1/submit"}
#specifying a form with options
form :upload, {method: :get, url: "/api/v1/submit"}, compact: true
#values can be dynamic through lambdas
#lambdas take arguments the object to be serialized and the instantiated serializer
form :upload, ->(obj, s) { [obj.class.upload_form_options, {compact: true}] }
#if the value inside the lambda is single (no array), the options will be taken from
#the second argument, after the lambda. So the above is equivelent to:
form :upload, ->(obj, s) { obj.class.upload_form_options }, {compact: true}
generic
Specifies a generic to be used. A generic is just a placeholder for extensions that are unknown to SimpleAMS (but maybe they make a lot of sense to you ^_^)
#specifying a generic with without options
generic :pagination, :extended
#specifying a form with options
generic :pagination, :extended, compact: false
#values can be dynamic through lambdas
#lambdas take arguments the object to be serialized and the instantiated serializer
generic :pagination, ->(obj, s) { [obj.class.pagination_type, {compact: false}] }
#if the value inside the lambda is single (no array), the options will be taken from
#the second argument, after the lambda. So the above is equivelent to:
generic :pagination, ->(obj, s) { obj.class.pagination_type }, {compact: false}
group of links/metas/forms/generics
Each of the aforementioned options comes with a plural form as well.
For instance, if we want to specify multiple links
at the same time:
links {
self: ['/api/v1/me', {rel: :user}]
feed: ['/api/v1/me/feed', {rel: :feed}]
}
or if we want to specify multiple metas
:
metas {
total_count: ->(obj){ obj.count }
pages: ->(obj){ obj.pages_count }
}
Same goes for forms
and generics
.
collection directive
SimpleAMS has a unique ability to allow you specify different options when you are rendering a collection. In its most simple use case it specified the plural name of the resource, used when rendering a collection:
collection :users
Itโs needed, if your adapter serializes the collection using a root element. But it can do much more than that: it allows you to define directives on the collection level. For instance, if you want to have a link that should be applied only to the collection level and not to each resource of the collection, then you need to define it inside the collectionโs block:
collection :users do
link :self, "/api/v1/users"
end
Or if we also want to have the total count of the collection, that should go in there actually:
collection :users do
link :self, "/api/v1/users"
meta :count, ->(collection, s) { collection.count }
end
Again, inside that block you can define using the regular DSL, whatever you would define in the resource level. Itโs just yet another level of recursion since, the same things that I show you here can be applied in the collection level inside the block. For instance, in theory (and if the adapter supports it), you can specify relations that apply only to the collection level:
class UserSerializer
include SimpleAMS::DSL
adapter SimpleAMS::Adapters::JSONAPI
attributes :id, :name, :email, :created_at, :role
type :user
collection :users do
link :self, "/api/v1/users"
meta :count, ->(collection, s) { collection.count }
has_one :s3_uploader #whatever that means :P
end
has_many :microposts
end
Rendering DSL
When rendering a resource, it should be straightforward:
SimpleAMS::Renderer.new(user, { serializer: UserSerializer }).to_json
All you need is to specify a serializer. In the example above, the resulted resource is a reflection of what is defined inside the serializer. However, the serializer acts as a filtering mechanism, meaning that you can override anything the serializer defines, given that the result creates a subset and not a superset (any superset options will be ignored).
For instance, you can override the type during rendering:
SimpleAMS::Renderer.new(user, {
serializer: UserSerializer, type: :person
}).to_json
or you can override the relations, and specify that you donโt want to include any relation defined in the serializer:
SimpleAMS::Renderer.new(user, {
serializer: UserSerializer, includes: []
}).to_json
or specify exactly what fields you want:
SimpleAMS::Renderer.new(user, {
serializer: UserSerializer, fields: [:id, :email, :name, :created_at]
}).to_json
or even specify the links subset that you want:
SimpleAMS::Renderer.new(user, {
serializer: UserSerializer, fields: [:id, :email, :name, :created_at],
links: [:self, :comments, :posts]
}).to_json
and the list goes on.. basically the rendering DSL is identical
includes vs relations
There might be some confusion between includes
and relations
, so to clear things up:
includes
: specifies which relations you want to include, out of the available relations.relations
: specifies the available relations, so it's not just an array of symbols, but rather full relation objects which are generated through the dsl. The raw representation of relations is an array of objects where each object is[relation_type, name, options, embedded_options]
. Hererelation_type
is the type of the relation (has_many
,belongs_to
etc),name
is the name of the relation (like users),options
, any relation options, andembedded_options
relevant to embedded options.
So when rendering, if you don't want any relations at all, the correct way is to
specify includes: []
. In practice you can use relations: []
as well, but that
will mean that the serializer has no relations at all (takes precedence over
includes
). But that's not the correct way to do it. For instance, thing about
another scenario: you want to specify only one relation, the feed
relation.
With includes
you would have includes: [:feed]
. With relations, you would
have to specify the relation at runtime (
relations: [[:has_one, :feed, {serializer: FeedSerializer, fields: [:id, :content]}, {}]]
) and then also specify that you only want that: includes: [:feed]
.
In general, there is no reason why you should use relations
at rendering time,
instead you should leave that to the serializer, and only specify the includes
.
Btw you might have noticed includes
only as a rendering option, but SimpleAMS
DSL is used all over the place, and actually it's a serializer option as well
(just that it's not very useful ^_^).
Rendering collections
Rendering a collection is similar, only that you need to call
SimpleAMS::Renderer::Collection
instead of just SimpleAMS::Renderer
:
SimpleAMS::Renderer::Collection.new(users, {
serializer: UserSerializer, fields: [:id, :email, :name, :created_at],
links: [:self, :comments, :posts]
}).to_json
Note that even with collection, by default everything goes to the resource. If you need to specify options for the collection itself, you need to use the collection key. For instance, having some metas inside the collection:
SimpleAMS::Renderer::Collection.new(users, {
serializer: UserSerializer, fields: [:id, :email, :name, :created_at],
links: [:self, :comments, :posts],
collection: {
metas: [:total_count]
}
}).to_json
Rendering options with values
If you want to specify the actual values when rendering the resource, rather than taking into account the serializer, you can inject a hashmap:
SimpleAMS::Renderer::Collection.new(users, {
serializer: UserSerializer, fields: [:id, :email, :name, :created_at],
links: [:self, :comments, :posts],
collection: {
metas: {
total_count: users.count,
}
}).to_json
Of course, you can also pass a lambda there, but not sure whatโs the point since the lambda parameter is the resource that you already try to render so itโs not going to give you anything more (and will be slower actually).
Exposing methods inside the serializer, like helpers
When rendering you can expose a couple of objects in the serializer:
SimpleAMS::Renderer::Collection.new(users, {
serializer: UserSerializer, fields: [:id, :email, :name, :created_at],
#exposing helpers that will be available inside the serializer
expose: {
#a class
current_user: User.first
#or a module
helpers: CommonHelpers
},
}).to_json
The expose attribute is also available through DSL, although usually thatโs not very useful. Just wanted to mentions that there is actually parity on everything, since everything has been built on the same building blocks :)
Extended DSL show off
Here is an extended example of the DSL. It's not a real use case of course, but shows what's possible with SimpleAMS and its powerful DSL.
{
#the primary id of the record(s), used mostly by the underlying adapter (like JSONAPI)
primary_id: :id,
#the type of the record, used mostly by the underlying adapter (like JSONAPI)
type: :user,
#which relations should be included
includes: [:posts, videos: [:comments]],
#which fields for each relation should be included
fields: [:id, :name, posts: [:id, :text], videos: [:id, :title, comments: [:id, :text]]] #overrides includes when association is specified
relations: [
[:belongs_to, :company, {
serializer: CompanySerializer,
fields: Company.column_names.map(&:to_sym)
}
],
[:has_many, :followers, {
serializer: UserSerializer,
fields: User.column_names.map(&:to_sym)
}
],
]
#the serializer that should be used
#makes sense to use it when initializing the Renderer
serializer: UserSerializer,
#can also be a lambda, in case of polymorphic records, ideal for ArrayRenderer
serializer: ->(obj, s){ obj.employee? ? EmployeeSerializer : UserSerializer }
#specifying the underlying adapter. This cannot be a lambda in case of ArrayRenderer,
#but can take some useful options that are passed down straight to the adapter class.
adapter: SimpleAMS::Adapters::AMS, root: true
#the links data
links: {
#can be a simple string
root: '/api/v1'
#a string with some options (relation and target attributes as defined by RFC8288
#however, you can also pass adapter-specific attributes
posts: "/api/v1/posts/", rel: :posts,
#it can also be a lambda that takes the resource to be rendered as a param
#when the lambda is called, it should return the array structure above
self: ->(obj, s) { ["/api/v1/users/#{obj.id}", rel: :user] }
},
#the meta data, same as the links data (available in adapters even for single records)
metas: {
type: ->(obj, s){ obj.employee? ? :employee : :user}
#meta can take arbitrary options as well
authorization: :oauth, type: :bearer_token
},
#the form data, same as the links/metas data (available in adapters even for single records)
forms: {
update: ->(obj, s){ User::UpdateForm.for(obj)}
follow: ->(obj, s){ User::FollowForm.for(obj)}
},
#collection parameters, used only in ArrayRenderer
collection: {
links: {
root: '/api/v1'
},
metas: {
pages: ->(obj, s) { [obj.pages, collection: true]},
current_page: ->(obj, s) { [obj.current_page, collection: true] },
previous_page: ->(obj, s) { [obj.previous_page, collection: true] },
next_page: ->(obj, s) { [obj.next_page, collection: true] },
max_per_page: 50,
},
#creating a resource goes in the collection route (users/), hence inside collection options ;)
forms: {
create: ->(obj){ User::CreateForm.for(obj)}
},
}
#exposing helpers that will be available inside the seriralizer
expose: {
#a class
current_user: User.first
#or a module
helpers: CommonHelpers
},
}
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
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.
Contributing
But reports are very welcome at https://github.com/vasilakisfil/SimpleAMS. Please add as much info as you can (serializer and Renderer input) so that we can easily track down the bug.
Pull requests are also very welcome on GitHub at https://github.com/vasilakisfil/SimpleAMS. However, to keep the code's sanity (AMS I am looking to you), I will be very picky on the code style and design, to match (my) existing code characteristics. Because at the end of the day, it's gonna be me who will maintain this thing.