← Back to Blog

Building a Native Mobile API on an Existing Rails Application

Antarr Byrd
Antarr Byrd
2026-03-02
Ruby on RailsAPIAndroid
Share

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.

Share