Building a Native Mobile API on an Existing Rails Application
I needed to add a REST API to an existing civic engagement Rails app to serve a native Android client. The web app already uses Devise for auth, Active Storage for avatars, and polymorphic associations for upvotes and follows, and I did not want to touch any of the existing web controllers. Here is how I structured the API layer to keep the two clean.
API namespace
Instead of adding respond_to :json blocks to existing controllers, I put the mobile API under its own namespace. That keeps it fully 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.
Base controller
Every API controller inherits from a base controller. Mobile clients do not use CSRF tokens, so I skip that check. A before_action confirms the user is signed in via Devise or returns a JSON 401.
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
SessionsController validates credentials and signs the user in with Devise's sign_in. The Android client stores the session cookie and sends it on 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
In production you also want to handle unconfirmed and locked accounts. I left those out here for brevity.
Serializing for mobile
I skipped the serializer gems — no active_model_serializers, no jsonapi-serializer. Each controller has private methods that return plain hashes, so each endpoint can 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
List endpoints have to know whether the current user has upvoted or followed each item. Doing that per-item is N+1. I collect the IDs up front and use 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
That replaces 40 queries with 2.
Idempotent actions
Mobile clients retry on flaky networks. A user might tap "upvote" twice before the first response comes back. I use find_or_create_by! and rescue RecordNotUnique so the endpoint returns the same response no matter 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
Same pattern for follows, petition signatures, and votes.
Testing
Each controller has a matching RSpec file. I check that every endpoint returns 401 when unauthenticated, then verify the response shape 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 shipped
The API covers 17 controllers: sessions, feed, bill detail, search, politicians, discussions, questions, petitions, conversations, messages, notifications, elections, ballots, settings, and users. The native client has the full feature set of the web app.
The shape of the work is straightforward. A dedicated namespace keeps the API separate from the web app. A base controller handles auth. Private serializer methods give each endpoint control over its response. Batched lookups kill the N+1s. Idempotent mutations handle retries from a mobile network.