Deserializer
Features
- Hash transformation and sanitization
- Deserialization of complex parameters into a hash that an AR model can take
- Avoid having multiple definitions in fragile arrays when using strong params
- Easy create and update from JSON without writing heavy controllers
- ActiveModel::Serializer-like interface and conventions
Problem
Let's say we have an API with an endpoint that takes this JSON:
{
"restaurant_id" : 13,
"user_id" : 6,
"dish_name" : "risotto con funghi",
"description" : "repulsive beyond belief",
"ratings" : {
"taste" : "terrible",
"color" : "horrendous",
"texture" : "vile",
"smell" : "delightful, somehow"
}
}But this goes into a flat DishReview model:
t.belongs_to :restaurant
t.belongs_to :user
t.string :name # field name different from API (dish_name)
t.string :description
t.string :taste
t.string :color
t.string :texture
t.string :smellSolution (No Deserializer)
Permit some params, do some parsing and feed that into DishReview.new:
class DishReviewController < BaseController
def create
review_params = get_review_params(params)
@review = DishReview.new(review_params)
if @review.save
# return review
else
# return sad errors splody
end
end
# rest of RUD
protected
def permitted_params
[
:restaurant_id,
:user_id
:dish_name,
:description,
:taste,
:color,
:texture,
:smell
]
end
def get_review_params(params)
review_params = params.require(:review)
review_params[:name] ||= review_params.delete(:dish_name)
ratings = review_params.delete(:ratings)
if (ratings.present?)
ratings.each{|rating, value| review_params[rating] = value if valid_rating?(rating) }
end
review_params.permit(permitted_params)
end
def valid_rating?(rating)
["taste", "color", "texture", "smell"].include? rating
end
endWhat's up with that?
- You have to do this for every action
- Controllers are obese, hard to parse and fragile
- Controllers are doing non-controller-y things
Solution (With Deserializer)
DishReviewDeserializer:
module MyApi
module V1
class DishReviewDeserializer < Deserializer::Base
attributes :restaurant_id
:user_id
:description
attribute :name, key: :dish_name
has_one :ratings, :deserializer => RatingsDeserializer
def ratings
object
end
end
end
endRatingsDeserializer:
module MyApi
module V1
class RatingsDeserializer < Deserializer::Base
attributes :taste,
:color,
:texture,
:smell
end
end
endAll of this allows your controller to be so very small:
class DishReviewsController < YourApiController::Base
def create
@review = DishReview.new( MyApi::V1::DishReviewDeserializer.from_params(params) )
if @review.save
# return review
else
# return sad errors splody
end
end
# RUD
endWhat's up with that?
- Un-pollutes controllers from all the parsing
- Builds deserializers that look like our serializers
Definition
Inherit from Deserializer::Base and define it in much the same way you would an ActiveModel::Serializer.
attributes
Use attributes for straight mapping from params to the model:
class PostDeserializer < Deserializer::Base
attributes :title,
:body
end# Example params
{
"title" => "lorem",
"body" => "ipsum"
}
# Resulting hash
{
title: "lorem",
body: "ipsum"
}attribute
Allows the following customizations for each attribute
:key
class PostDeserializer < Deserializer::Base
attribute :title, ignore_empty: true
attribute :body, key: :content
end:content here is what it will get in params while :body is what it will be inserted into the result.
# Example params
{
"title" => "lorem",
"content" => "ipsum"
}
# Resulting hash
{
title: "lorem",
body: "ipsum"
}:ignore_empty
While Deserializer's default is to pass all values through, this option will drop any key with false/nil/""/[]/{} values from the result.
# Example params
{
"title" => "",
"text" => nil
}
# Resulting hash
{}:convert_with
Allows deserializing and converting a value at the same time. For example:
class Post < ActiveRecord::Base
belongs_to :post_type # this is a domain table
endIf we serialize with
class PostSerializer < ActiveModel::Serializer
attribute :type
def type
object.post_type.symbolic_name
end
endThen, when we get a symbolic name from the controller but want to work with an id in the backend, we can:
class PostDeserializer < Deserializer::Base
attribute :title, ignore_empty: true
attribute :body
attribute :post_type_id, key: :type, convert_with: to_type_id
def to_type_id(value)
Type.find_by_symbolic_name.id
end
end# Example params
{
"title" => "lorem",
"body" => "ipsum",
"type" => "BLAGABLAG"
}
# Resulting hash
{
title: "lorem",
body: "ipsum",
post_type_id: 1
}has_one
has_one association expects a param and its deserializer:
class DishDeserializer < Deserializer::Base
# probably other stuff
has_one :ratings, deserializer: RatingsDeserializer
end
class RatingsDeserializer < Deserializer::Base
attributes :taste,
:smell
end# Example params
{
"ratings" => {
"taste" => "bad",
"smell" => "good"
}
}
# Resulting hash
{
ratings: {
taste: "bad",
smell: "good"
}
}Deserialize into a Different Name
In the example above, if ratings inside Dish is called scores in your ActiveRecord, you can:
class DishDeserializer < Deserializer::Base
has_one :ratings, deserializer: RatingsDeserializer
def ratings
:scores
end
end# Example params
{
"ratings" => {
"taste" => "bad",
"smell" => "good"
}
}
# Resulting hash
{
scores: {
taste: "bad",
smell: "good"
}
}Deserialize into Parent Object
To deserialize ratings into the dish object, you can use object:
class DishDeserializer < Deserializer::Base
has_one :ratings, deserializer: RatingsDeserializer
def ratings
object
end
end# Resulting hash
{
taste: "bad",
smell: "good"
}Deserialize into a Different Sub-object
class DishDeserializer < Deserializer::Base
has_one :colors, deserializer: ColorsDeserializer
has_one :ratings, deserializer: RatingsDeserializer
def colors
:ratings
end
endGiven params:
# Example params
{
"ratings" =>
{
"taste" => "bad",
"smell" => "good"
},
"colors" =>
{
"color" => "red"
}
}
# Resulting hash
{
ratings: {
taste: "bad",
smell: "good",
color: "red"
}
}key
You can deserialize a has_one association into a different key from what the json gives you. For example:
{
id: 6,
name: "mac & cheese",
alias:
{
id: 83,
name: "macaroni and cheese"
}
}but your model is
class Dish
has_one :alias
accepted_nested_attributes_for :alias
endinstead of renaming the hash in the controller, you can do
class DishDeserializer < Deserializer::Base
attributes :id,
:name
has_one :alias_attributes, deserializer: AliasDeserializer, key: :alias
endwhich would output
{
id: 6,
name: "mac & cheese",
alias_attributes:
{
id: 83,
name: "macaroni and cheese"
}
}has_many
has_many association expects a param and its deserializer:
class DishDeserializer < Deserializer::Base
# probably other stuff
has_many :ratings, deserializer: RatingsDeserializer
end
class RatingsDeserializer < Deserializer::Base
attributes :user_id,
:rating,
:comment
end# Example params
{
"ratings" => [
{ "user_id" => 6,
"rating" => 3,
"comment" => "not bad"
},
{ "user_id" => 25,
"rating" => 2,
"comment" => "gross"
}
]
}
# Resulting hash
{
ratings: [
{ user_id: 6,
rating: 3,
comment: "not bad"
},
{ user_id: 25,
rating: 2,
comment: "gross"
}
]
}key
You can deserialize a has_many association into a different key from what the json gives you. For example:
{
id: 6,
name: "mac & cheese",
aliases: [
{
id: 83,
name: "macaroni and cheese"
},
{
id: 86,
name: "cheesy pasta"
}
]
}but your model is
class Dish
has_many :aliases
accepted_nested_attributes_for :aliases
endinstead of renaming the hash in the controller, you can do
class DishDeserializer < Deserializer::Base
attributes :id,
:name
has_many :aliases_attributes, deserializer: AliasDeserializer, key: :aliases
endwhich would output
{
id: 6,
name: "mac & cheese",
aliases_attributes: [
{
id: 83,
name: "macaroni and cheese"
},
{
id: 86,
name: "cheesy pasta"
}
]
}nests
Sometimes you get a flat param list, but want it to be nested for updated_nested_attributes
If you have 2 models that look like
class RestaurantLocation
belongs_to :address
# t.string :name
end
# where Address is something like
t.string :line_1
t.string :line_2
t.string :city
t.string :stateAnd you want to update them at the same time, as they're closely tied, nests lets you define
class ResaturantLocationDeserializer < Deserializer::Base
attribute :name
nests :address, deserializer: AddressDeserializer
end
class AddressDeserializer
attributes :line_1,
:line_2,
:city,
:state
endAnd now you can take a single block of json
# Example params into restaurant_location endpoint
{
"name" => "Little Caesars: Et Two Brute",
"line_1" => "2 Brute St.",
"city" => "Seattle",
"state" => "WA"
}
# Resulting hash
{
name: "Little Caesars: Et Two Brute",
address: {
line_1: "2 Brute St",
city: "Seattle",
state: "WA"
}
}Functions
from_params
MyDeserializer.from_params(params) creates the JSON that your AR model will then consume.
@review = DishReview.new( MyApi::V1::DishReviewDeserializer.from_params(params) )permitted_params
Just call MyDeserializer.permitted_params and you'll have the full array of keys you expect params to have.
Installation
Add this line to your application's Gemfile:
gem 'deserializer'
And then execute:
$ bundle
Or install it yourself as:
$ gem install deserializer
Contributing
- Fork it ( https://github.com/gaorlov/deserializer/fork )
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request