Skip to content

Deserialization of complex API params into objects that can be consumed by an AR model

License

Notifications You must be signed in to change notification settings

gaorlov/deserializer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

37 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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      :smell

Solution (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
end

What'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
end

RatingsDeserializer:

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:

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
end

What'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
end

If we serialize with

class PostSerializer < ActiveModel::Serializer
  attribute :type

  def type
    object.post_type.symbolic_name
  end
end

Then, 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
end

Given 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
end

instead 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
end

which 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
end

instead 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
end

which 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      :state

And 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
end

And 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

  1. Fork it ( https://github.com/gaorlov/deserializer/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

About

Deserialization of complex API params into objects that can be consumed by an AR model

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages