Deserialization of complex parameters into a hash that an AR model can take.
Lets you have a reverse ActiveModel::Sereializer-like interface that allows for easy create and update without having to write heavy controllers.
Let's say we have a API create endpoint that takes json that looks something like
{
"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"
}
}
that goes into a flat DishReview model that looks like
t.belongs_to :restaurant
t.belongs_to :user
# field name different from API
t.string :name
t.string :description
t.string :taste
t.string :color
t.string :texture
t.string :smell
what do we do?
Normally, we'd have some params we permit, do some parsing and feed those into DishReview.new
, like
class DishReviewController < BaseController
def create
review_params = get_review_params(params)
@review = ProfessionalReview.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)
@@ratings ||= ["overall", "trusthworthy", "responsive", "knowledgeable", "communication"]
@@ratings.include? rating
end
end
and that's fine, but kind of annoying, and you have to do this for every action. It makes the controllers heavy, hard to parse, fragile, and really do things that are no longer controller-y.
So what we have here is a wrapper that lets us get away from polluting the controller with all of this parsing and lets us build deserializers that look very much like our serializers.
Deserializer acts and looks pretty mich identical to ActiveModel::Serializer. It has attributes, attribute, and the has_one association. It does not currently support has_many, as that's an odd thing for a write endpoint to support, but can easily be added.
MyDeserializer.from_params(params)
created the json that your AR model will then consume.
@review = DishReview.new( MyApi::V1::DishReviewDeserailzer.from_params(params) )
If you're using strong params, this lets you avoid having multiple definitions in fragile arrays. Just call MyDeserailzer.permitted_params
and you'll have the full array of keys you expect params to have.
To define a deserializer, you inherit from Deserializer::Base
and define it in much the same way you would an ActiveModel::Serializer
.
This is straight 1:1 mapping from params to the model, so
class PostDeserializer < Deserializer::Base
attributes :title,
:body
end
with params {"title" => "lorem", "body" => "ipsum"}
, will give you a hash of {title: "lorem", body: "ipsum"}
.
attribute
is the singular version of attributes
, but like ActiveModel::Serializer
it can take a :key
class PostDeserializer < Deserializer::Base
attribute :title
attribute :body, key: :text
end
It is symmetric with ActiveModel::Serializer
, so that :text is what it will get in params, but :body is what it will insert into the result.
For example with params of {"title" => "lorem", "text" => "ipsum"}
this desrerializer will produce {title: "lorem", body: "ipsum"}
.
NOTE: This is the only association currently supported by Deserializer
.
has_one
expects the param and its deserializer. So for params {"ratings" => {"taste" => "bad", "smell" => "good"}}
class DishDeserializer < Deserializer::Base
# probably other stuff
has_one :ratings, deserializer: RatingsDeserializer
end
class RatingsDeserializer < Deserializer::Base
attributes :taste,
:smell
end
you would get {ratings: {taste: "bad", smell: "good"}}
So let's say in the example above, your internal representation of ratings inside Dish
is actually called scores
, you can do
class DishDeserializer < Deserializer::Base
has_one :ratings, deserializer: RatingsDeserializer
def ratings
:scores
end
end
which will give you {scores: {taste: "bad", smell: "good"}}
or, if you want to deserialize ratings
into your dish
object, you can use object
class DishDeserializer < Deserializer::Base
has_one :ratings, deserializer: RatingsDeserializer
def ratings
object
end
end
which will give you {taste: "bad", smell: "good"}
or you can deserialize into another subobject by doing
class DishDeserializer < Deserializer::Base
has_one :colors, deserializer: ColorsDeserializer
has_one :ratings, deserializer: RatingsDeserializer
def colors
object[:ratings]
end
end
which, given params
{
"ratings" =>
{
"taste" => "bad",
"smell" => "good"
},
"colors" =>
{
"color" => "red"
}
}
, will give you {ratings: {taste: "bad", smell: "good", color: "red"}}
So the example above will combine all of those to look like
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
end
where RatingsDeserializer looks like
module MyApi
module V1
class RatingsDeserializer < Deserializer::Base
attributes :taste,
:color,
:texture,
:smell
end
end
end
All of this allows your controller to be so very small, like
class DishReviewsController < YourApiController::Base
def create
@review = DishReview.new( MyApi::V1::DishReviewDeserailzer.from_params(params) )
if @review.save
# return review
else
# return sad errors splody
end
end
# rest of RUD
end