#rails#api

APIs supporting snake_case and camelCase with Rails

Yuva's avatar

Yuva

Recently, for one of our clients, we encountered one interesting problem. Their stack is Rails + React, where Rails is for providing APIs, and React is for consuming those APIs. Frontend team prefers API payloads to be in camelCase. There are existing npm modules which can automatically convert snakecase to camelCase, but (un)fortunately front-end team is not using those.

We use AMS (active model serializers) to generate json responses. We are dealing with Rails 3, and app cannot be upgraded :). Latest version of AMS has out of box support for transformation. Say if we have to expose attributes of model Post, we have this code:

 
# ignore attributes (or schema), they are for demo'ing the usecase
class Post < ActiveModel::Serializer
  attributes :id, :title, :authorName, :totalComments
end

Using camelCase in ruby code looks ugly, but legacy code responds with camelCase for apis. One (obvious) solution is to request frontend team to use npm packages to convert snakecase to camelCase for GET requests, and camelCase to snakecase for all POST requests. Making that change would involve touching lots of components, which we didn't want to do right away. We looked for alternatives and figured out this approach.

Let me show you how the tests for the Post object described above look, so that you get undestand what the problem is:

describe PostsController do
  let(:post) { FactoryGirl.create(:post, title: "hello world", author_name: 'Jon') }
 
  describe 'GET show' do
    it "returns all details of a post" do
      get :show, id: post.id
      expect(response).to be_success
 
      expected_json = {
        "id" => post.id,
        "title" => "hello world",
        "authorName" => 'Jon',       # <-------- camelCase here!
        "totalComments" => 0         # <-------- camelCase here!
      }
 
      post_json = JSON.parse(response.body)
      expect(expected_json).to eq post_json
    end
  end
 
  describe 'PUT update' do
    it "updates post fields successfully" do
      #                                  params has camelCase here!
      put :update, id: post.id, title: "new title", authorName: "Ben"
      expect(response).to be_success
    end
  end
end

If you look at specs, generally we don't use camelCase in ruby to write specs. The code tries to map authorName to author_name, lots of copying going around. Let's go step-by-step in improving this situation:

Step 1: Make snakecase/camelCase configurable via urls

We modified all APIs to support a param called snakecase. If this query param is set, APIs are served in snakecase, otherwise they are served in camelCase. So, modified specs look like this:

describe PostsController do
  let(:post) { FactoryGirl.create(:post, title: "hello world", author_name: 'Jon') }
 
  describe 'GET show' do
    it "returns all details of a post" do
      get :show, id: post.id, snakecase: 1           # see, snakecase=1 here
      expect(response).to be_success
 
      expected_json = {
        "id" => post.id,
        "title" => "hello world",
        "author_name" => 'Jon',     # note we have snakecase here!
        "total_comments" => 0       # note we have snakecase here!
      }
 
      post_json = JSON.parse(response.body)
      expect(expected_json).to eq post_json
    end
  end
 
  describe 'PUT update' do
    it "updates post fields successfully" do
      #                          note: authorName is author_name, we have snakecase!
      put :update, id: post.id, title: "new title", author_name: "Ben",  snakecase: 1
      expect(response).to be_success
    end
  end
end

And AMS also looks sane, ie it looks like this:

 
# ignore attributes (or schema), they are for demo'ing the usecase
# note: we have snakecase here for author_name, and total_comments!
class Post < ActiveModel::Serializer
  attributes :id, :title, :author_name, :total_comments
end

Bit of faith restored for ruby developers. APIs typically look like this now:

  • for a GET request: GET /posts/10?snakecase=1
  • for a PUT request: PUT /posts/10?snakecase=1 (body contains payload)

But frontend still expects payload to be in camelCase. It's simple, avoid the query param snakecase=1 and we are all good.

Step 2: Implement snakecase/camelCase intelligence in controller params

Some problems can be solved with another level of indirection. We are going to play with controller level params, and provide one nice wrapper around it. We are not going to use params directly in our controllers, we are going to use a wrapper called api_params. Before, we jump into code, we have to support usecases like these for frontend:

  • filtering posts: GET /posts?authorName=Jon&page=2&perPage=10
  • updating a post: PUT /posts/10, body has: { title: 10, authorName: 'Ben' }

Note those camelCases in urls, and POST/PUT body. Now, onto code:

 
class ApisController < ApplicationController
  # yet to implement snakecase_params method.
  def api_params
    params[:snakecase].present? ? params : snakecase_params
  end
end
 
class PostsController < ApisController
  def index
    search_params = api_params.slice(:author_name, :page, :per_page)
    @posts = SearchPostsService.new(search_params).fetch
    render json: @posts, serializer: PostSerializer
  end
 
  def update
    @post = Post.find(api_params[:id])
    update_params = api_params.slice(:title, :author_name)
    UpdatePostService.new(@post, update_params).save
  end
end

Ignoring specifics of code, instead of using params in controller, we are using api_params. What this does is:

  • If snakecase=1, it means all the params are already in snakecase, and can be directly consumed by Ruby/Rails code.
  • If snakecase is not set (our frontend case), we assume that all the params are in camelCase, and they have to be converted to snakecase before Ruby/Rails code consumes it.

snakecase_params method is interesting, and simple. All it has to do is to perform a deep key transformation. Basecamp has already written some code to deep transform hashes. Code can be found in deep hash transform repo. We are going to re-use that code. Code looks like this:

 
module ApiConventionsHelper
  extend ActiveSupport::Concern
 
  class HashTransformer
    # Returns a new hash with all keys converted by the block operation.
    #  hash = { person: { name: 'Rob', age: '28' } }
    #  hash.deep_transform_keys { |key| key.to_s.upcase }
    #  # => {"PERSON"=>{"NAME"=>"Rob", "AGE"=>"28"}}
    def deep_transform_keys(hash, &block)
      result = {}
      hash.each do |k, v|
        result[yield(k)] = value.is_a?(Hash) ? deep_transform_keys(v, &block) : v
      end
      result
    end
 
    def snakecase_keys(hash)
      deep_transform_keys(hash) { |k| k.to_s.underscore.to_sym }
    end
  end
 
  def snakecase_params
    HashTransformer.new.snakecase_keys(params)
  end
end

Now, we can inject this concern into our ApisController, and boom, we have snakecase_params helper. Now, we have backend developers happy with using snakecase, and frontend developers are happy working with camelCase. What else is left? Yes, automatically transforming payload for GET requests.

Step 3: Teaching AMS to be aware of snakecase/camelCase

Remember, our AMS for Post is still using snakecase. Now based on url params, we have to transform attributes. Let's take a look at AMS for Post again:

 
class Post < ActiveModel::Serializer
  attributes :id, :title, :author_name, :total_comments
end

AMS makes it easy to play with attributes, again with another level of indirection.

First, we will use serializer options at controller level to tell AMS to transform payload to camelCase or snakecase.

 
class ApisController < ApplicationController
  def default_serializer_options
    { root: false, snakecase: params[:snakecase].present? }
  end
 
  def api_params
    params[:snakecase].present? ? params : snakecase_params
  end
end

All AMSes will now pickup default_serializer_options from controller. In these default options, we are appending snakecase option into AMS world. Now, how do we tell AMS to transform payload to camelCase or snakecase? Its simple: Use a base serializer, and derive all serializers from it.

 
class BaseSerializer < ActiveModel::Serializer
  # override attributes method.
  def attributes
    @options[:snakecase].present? ? super : camelize(super)
  end
end

We have to implement camelize now. We will extend ApiConventionsHelper and implementcamelize

 
module ApiConventionsHelper
  extend ActiveSupport::Concern
 
  class HashTransformer
    def deep_transform_keys(hash, &block)
      result = {}
      hash.each do |k, v|
        result[yield(k)] = value.is_a?(Hash) ? deep_transform_keys(v, &block) : v
      end
      result
    end
 
    def snakecase_keys(hash)
      deep_transform_keys(hash) { |k| k.to_s.underscore.to_sym }
    end
 
    def camelize_keys(hash)
      deep_transform_keys(hash) { |k| k.to_s.camelize(:lower) }
    end
  end
 
  def snakecase_params
    HashTransformer.new.snakecase_keys(params)
  end
 
  def camelize(hash)
    HashTransformer.new.camelize_keys(hash)
  end
end
 
# use ApiConventionsHelper and derive PostSerializer from this class
class BaseSerializer < ActiveModel::Serializer
  include ApiConventionsHelper
 
  def attributes
    @options[:snakecase].present? ? super : camelize(super)
  end
end
 
class Post < BaseSerializer
  attributes :id, :title, :author_name, :total_comments
end

Finally

With bit of conventions in place, and playing around controllers, and AMS, we are able to keep both backend developers and frontend developers happy. Interesting point to note here is, there is hardly any meta programming.

Thanks for reading! If you would like to get updates about subsequent blog posts Codemancers, do follows us on twitter: @codemancershq.