Skip to content

The cleanest way to declare your Rails API view layer. Period.

Notifications You must be signed in to change notification settings

FidMe/active_mappers

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

85 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ActiveMappers

Build Status Gem Version

If you have ever done Rails API development, you must have considered using a layer to abstract and centralize your JSON objects construction.

There are multiple solutions out on the market, here is a quick overview of each :

Solution Pros Cons
JBuilder Simple, easy, integrates with the default View layer Very slow, dedicated to JSON
Active Model Serializers Simple, easy to declare Can be hard to customize, slow, project is abandoned
fast_json_api As simple as AMS, fast Hard to customize, JSONAPI standard is required
ActiveMappers Blazing fast, Easy to declare/customize, works with any format output (JSON, Hash) Limited number of options (for now)

Installation

Add this line to your application's Gemfile:

gem 'active_mappers'

Execute

$ bundle install

Then, depending on your usage you may want to create an app/mappers folder in your Rails application.

You will put all your mappers inside of it.

Basic usage

UserMapper.with(user)
# =>
# {
#   user: {
#     id: '123',
#     email: '[email protected]',
#     profile: {
#       first_name: 'Michael',
#       last_name: 'Villeneuve',
#     }
#   }
# }

Setup (optional)

You may want to customize some parts of ActiveMappers behavior.

If you want to, create an initializer in your project :

# config/initializers/active_mappers.rb
ActiveMappers::Setup.configure do |config|
  config.camelcase_keys = false
  config.ignored_namespaces = [:admin, :back_office]
end

Here is the list of configurable options

Option Type Default Description
camelcase_keys boolean true Should keys name be camelcase. Fallback to snake when set to false
ignored_namespaces Array [] Namespaces to ignore when generating json root key name
root_keys_transformer Proc See below Custom way to change a mapper class name into a JSON root key

Root Keys Transformer

A root key transform is used to transform the mapper class name into a JSON root key name.

For example this mapper class :

class User::ProfileInformationMapper < ActiveMappers::Base
end

Will automatically resolve to the following json :

{
  "user/profileInformation": {}
}

To customize this behavior you can do the following :

config.root_keys_transformer = proc do |key|
  # Return any key transform based on the key which is the class name of your mapper

  # The below line transforms User::ProfileInformationMapper to user/profile_informations
  key.gsub('Mapper', '').tableize
end

API

Declaring your attributes

Most basic usage, just declare the attributes you want to display.

class UserMapper < ActiveMappers::Base # You must extend ActiveMappers::Base in order to use the DSL
  attributes :id, :email
end

Delegating your attributes

Say you have a model with the following structure :

{
  email: '[email protected]',
  profile: {
    first_name: 'Michael',
  }
}

And you want to generate this structure :

{
  email: '[email protected]',
  first_name: 'Michael',
  last_name: 'Villeneuve',
}

To implement this, you must use the delegate feature :

class UserMapper < ActiveMappers::Base
  delegate :first_name, :last_name, to: :profile
end

Declaring relationship

You can declare any type of relationship (has_one, belongs_to, has_many, etc) and the mapper that matches it will automatically be fetched and used.

For example if a User has a belongs_to relationship with an Account you can write :

class UserMapper < ActiveMappers::Base
  attributes :email
  relation :account # Will automatically resolve to AccountMapper
end

class AccountMapper < ActiveMappers::Base
  attributes :first_name, :last_name
end

It will generate something like

{
  user: {
    email: '[email protected]',
    account: {
      first_name: 'Michael',
      last_name: 'Villeneuve'
    }
  }
}

It also works with namespaced resources.

If you need you can specify more options :

class UserMapper < ActiveMappers::Base
  relation :account, AccountMapper, scope: :admin
end

Declaring polymorphic relationships

Consider the following polymorphic relation :

class Post
  belongs_to :author, polymorphic: true
end

class AdminUser
  has_many :posts, class_name: 'Post', as: :author
end

class NormalUser
  has_many :posts, class_name: 'Post', as: :author
end

In order to use the author polymorphic attribute in your PostMapper you need to declare the following :

class PostMapper < ActiveMappers::Base
  polymorphic :author
end

And of course, you must implement the associated mappers :

class AdminUserMapper
  attributes :id, :name
end

class NormalUserMapper
  attributes :id, :name
end

Then, based of the XXX_type column, the mapper will automatically resolve to either AdminUserMapper or NormalUserMapper

Rendering a collection of different classes

Say you want to render many resources with a single Mapper

collection = Bird.all + Fish.all + Insect.all

render json: AnimalMapper.with(collection)

class AnimalMapper < ActiveMappers::Base
  acts_as_polymorphic
end

class BirdMapper < ActiveMappers::Base
  attributes :name, :wings_count
end

class FishMapper < ActiveMappers::Base
  attributes :name, :fins_count
end

class InsectMapper < ActiveMappers::Base
  attributes :name, :has_venom
end

Will generate the following :

{
  animals: [
    { name: 'Michael', wings_count: 2 },
    { name: 'Emeric', fins_count: 1 },
    { name: 'Arthur', has_venom: true },
  ]
}

Again, just like the above polymorphic declaration, the mapper will automatically resolve to the corresponding one.

Custom Attributes

If you need to implement custom attributes you can always use the each statement.

class UserMapper < ActiveMappers::Base
  attributes :email, :id

  each do |user|
    {
      custom_attribute: "Hi, I'm a custom attribute",
      another_custom_attribute: Time.now
    }
  end
end

Will generate the following:

{
  user: {
    id: '12345',
    email: '[email protected]',
    custom_attribute: "Hi, I'm a custom attribute",
    another_custom_attribute: "2018-09-26 17:49:59 +0200"
  }
}

You can declare any number of each in a single mapper. Actually, each is used to implement every above features.

Context

In most cases declaring your mapper with your resource should be enough. However, sometimes you may need to give your mapper a context in order to allow it to resolve a method or something else.

At fidme we found a use case where we need to send an instance of a user along with a mapper in order to call a model method with user as params.

To solve this use case we use the context option, for instance :

class RewardMapper < ActiveMappers::Base
  each do |reward, context|
    { user_reward: reward.of_user(context) }
  end
end

RewardMapper.with(reward, context: user)

Scope

ActiveMappers does not yet support inheritance. However we provide an even better alternative named scope.

Whenever you feel the need to declare more or less attributes based on who called the mapper, you may want to consider using scope.

A very usual use case would be to have a different way to map a resource depending on wether you are an administrator or not. Instead of declaring a whole new mapper just to add/remove attributes, you can do the following :

class UserMapper < ActiveMappers::Base
  attributes :pseudo

  scope :admin
    attributes :id
  end

  scope :owner
    attributes :email
  end
end

# This declaration gives you 3 ways to call the mapper

# By an administrator
UserMapper.with(User.first, scope: :admin)
# => { pseudo: 'michael33', id: '1234' }

# By anyone
UserMapper.with(User.first)
# => { pseudo: 'michael33' }

# Or by the corresponding user that will gain access to personal informations
UserMapper.with(User.first, scope: :owner)
# => { pseudo: 'michael33', email: '[email protected]' }

Using a mapper

Even though there are many ways to declare a mapper, there is only one way to use it

UserMapper.with(User.first)

# Or use it with a collection
UserMapper.with(User.all)

In a Rails controller :

def index
  render json: UserMapper.with(User.all)
end

JSON Root

You can choose to use ActiveMappers with or without a JSON root.

By default, root will be enabled, meaning a UserMapper, will generate a JSON prefixed by :

{
  user: {}
}

Custom Root

If you want to customize the root name, you can use

UserMapper.with(user, root: :hello)

which will generate :

{
  hello: {}
}

Rootless

If you do not want to set any root, use :

UserMapper.with(user, rootless: true)

Adding your own features to Active Mapper DSL

If you want to add specific features to the DSL you can reopen ::ActiveMappers::Base class and add your own methods. The most convenient way to do that is in your Active Mapper initializer following this pattern:

ActiveMappers::Setup.configure do |config|
  ...
end

module ActiveMappers
  class Base
    include Rails.application.routes.url_helpers

    def self.my_capitalize_dsl_feature(*params)
      each do |resource|                              #your mapped resource(s)
        h = {}
        params.each do |param|
          h[param] = resource.try(param)&.capitalize  #your treatment
        end
        h                                             #the returned hash will be merged to the mapper result.
      end
    end
  end
end

and then:

class UserMapper < ActiveMappers::Base
  my_capitalize_dsl_feature :civility
end

Anything is missing ?

File an issue