APIs supporting snake_case and camelCase with Rails
by Yuva, Co-founder
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.