Building a Native Mobile API on an Existing Rails Application
Ruby on Rails is a great tool for building web applications but it can also serve as the backend for native mobile applications. I recently needed to build a REST API for a native Android app on top of an existing Rails application for civic engagement. The web app already uses Devise for authentication, Active Storage for avatars, and polymorphic associations for upvotes and follows. In this post I will walk you through how I structured the API layer to serve the mobile client without modifying the existing web controllers.
Setting Up the API Namespace
Rather than adding respond_to :json blocks to the existing web controllers, I created a dedicated namespace. This keeps the mobile API completely separate from the web app's Turbo Stream responses and Devise redirects.
config/routes.rb
namespace :api do
namespace :native do
post 'session', to: 'sessions#create'
delete 'session', to: 'sessions#destroy'
get 'feed', to: 'feed#index'
# additional routes for bills, politicians, discussions, etc.
end
end
All native endpoints live under /api/native/ with their own controllers and serialization logic.
The Base Controller
The first step is to create a base controller that all API controllers will inherit from. Since mobile clients don't use CSRF tokens, we skip that verification. Authentication checks that the user is signed in via Devise and returns a JSON error if not.
app/controllers/api/native/base_controller.rb
module Api
module Native
class BaseController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :require_native_login
private
def require_native_login
return if user_signed_in?
render json: { error: 'Authentication required' }, status: :unauthorized
end
end
end
end
Session Authentication
The next step is to handle login. The SessionsController validates credentials and signs the user in using Devise's sign_in method. The Android client stores the session cookie and sends it with subsequent requests.
app/controllers/api/native/sessions_controller.rb
module Api
module Native
class SessionsController < ApplicationController
skip_before_action :verify_authenticity_token
def create
user = User.find_by(email: params[:email].to_s.strip.downcase)
if user.nil? || !user.valid_password?(params[:password].to_s)
render json: { error: 'Invalid email or password' }, status: :unauthorized
return
end
sign_in(:user, user)
render json: { user: { id: user.id, email: user.email, username: user.username } }
end
def destroy
sign_out(current_user) if user_signed_in?
head :no_content
end
end
end
end
You should also check for unconfirmed and locked accounts. I omitted those checks here for brevity.
Serializing Data for Mobile
I chose not to use a serializer gem like active_model_serializers or jsonapi-serializer. Instead each controller has private methods that return plain hashes. This lets each endpoint return exactly what the screen needs.
app/controllers/api/native/feed_controller.rb
def serialize_bill(bill, upvoted_ids, followed_ids)
decorator = bill.decorate
{
id: bill.slug,
identifier: bill.identifier,
title: bill.title,
summary: bill.summary,
chamber_label: decorator.chamber_label,
status: bill.latest_action_description,
upvotes_count: bill.upvotes_count,
comments_count: bill.comments_count,
upvoted: upvoted_ids.include?(bill.id),
following: followed_ids.include?(bill.id)
}
end
Batching User State Lookups
One challenge with list endpoints is checking whether the current user has upvoted or followed each item. Doing this per-item creates N+1 query problems. I batch these lookups by collecting all the IDs up front and using a Set for O(1) lookups.
app/controllers/api/native/feed_controller.rb
def index
bills = recommendations_bills(page)
bill_ids = bills.map(&:id)
upvoted_ids = current_user.upvotes
.where(upvotable_type: 'OpenStates::Bill', upvotable_id: bill_ids)
.pluck(:upvotable_id).to_set
followed_ids = current_user.followings
.where(followable_type: 'OpenStates::Bill', followable_id: bill_ids)
.pluck(:followable_id).to_set
render json: {
items: bills.map { |bill| serialize_bill(bill, upvoted_ids, followed_ids) },
meta: { current_page: bills.current_page, total_pages: bills.total_pages, has_next: !bills.last_page? }
}
end
This replaces what would be 40 queries with 2.
Handling Idempotent Actions
Mobile clients often retry requests due to network issues. A user might tap "upvote" twice before the first response comes back. To handle this I use find_or_create_by! and rescue RecordNotUnique so the endpoint always returns the same response regardless of how many times it is called.
app/controllers/api/native/bills_controller.rb
def upvote
current_user.upvotes.find_or_create_by!(upvotable: @bill)
@bill.reload
render json: { upvotes_count: @bill.upvotes_count, upvoted: true }
rescue ActiveRecord::RecordNotUnique
@bill.reload
render json: { upvotes_count: @bill.upvotes_count, upvoted: true }
end
I use this same pattern for follows, petition signatures, and votes across the API.
Testing
Each controller has a corresponding RSpec file. I test that every endpoint returns 401 when unauthenticated and verify the expected response structure when authenticated.
spec/controllers/api/native/bills_controller_spec.rb
RSpec.describe Api::Native::BillsController do
let(:user) { create(:user) }
let(:bill) { create(:open_states_bill) }
describe 'POST #upvote' do
context 'when unauthenticated' do
it 'returns 401' do
post :upvote, params: { id: bill.slug }, format: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated' do
before { sign_in user }
it 'creates an upvote and returns updated count' do
expect {
post :upvote, params: { id: bill.slug }, format: :json
}.to change { bill.reload.upvotes_count }.by(1)
json = JSON.parse(response.body)
expect(json['upvoted']).to be true
end
end
end
end
What I Shipped
The API covers 17 controllers in total: sessions, feed, bill detail, search, politicians, discussions, questions, petitions, conversations, messages, notifications, elections, ballots, settings, and users. The full feature set of the web app is available to the native client.
The approach is straightforward. A dedicated namespace keeps the API separate from the web app. A base controller handles authentication. Private serializer methods give each endpoint control over its response shape. Batched lookups prevent N+1 queries. And idempotent mutations handle the realities of mobile networking.
Please share any feedback to improve this or other postings.