rails-api-controllers

SKILL.md

Rails API Controllers

Build production-ready RESTful JSON APIs with Rails. This skill covers API controller patterns, versioning, authentication, error handling, and best practices for modern API development.


API-Only Rails Setup

Generate API-Only App:

# New API-only Rails app (skips views, helpers, assets)
rails new my_api --api

# Or add to existing app
# config/application.rb
module MyApi
  class Application < Rails::Application
    config.api_only = true
  end
end

Base API Controller:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods

  # Global error handling
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
  rescue_from ActionController::ParameterMissing, with: :bad_request

  before_action :authenticate

  private

  def authenticate
    authenticate_token || render_unauthorized
  end

  def authenticate_token
    authenticate_with_http_token do |token, options|
      @current_user = User.find_by(api_token: token)
    end
  end

  def render_unauthorized
    render json: { error: 'Unauthorized' }, status: :unauthorized
  end

  def not_found(exception)
    render json: { error: exception.message }, status: :not_found
  end

  def unprocessable_entity(exception)
    render json: {
      error: 'Validation failed',
      details: exception.record.errors.full_messages
    }, status: :unprocessable_entity
  end

  def bad_request(exception)
    render json: { error: exception.message }, status: :bad_request
  end
end

Why: API-only mode removes unnecessary middleware and optimizes for JSON responses. Centralized error handling ensures consistent responses.


RESTful API Design

# app/controllers/api/v1/articles_controller.rb
module Api
  module V1
    class ArticlesController < ApplicationController
      before_action :set_article, only: [:show, :update, :destroy]

      # GET /api/v1/articles
      def index
        @articles = Article.published
                          .includes(:author)
                          .page(params[:page])
                          .per(params[:per_page] || 20)

        render json: @articles, status: :ok
      end

      # GET /api/v1/articles/:id
      def show
        render json: @article, status: :ok
      end

      # POST /api/v1/articles
      def create
        @article = Article.new(article_params)
        @article.author = current_user

        if @article.save
          render json: @article, status: :created, location: api_v1_article_url(@article)
        else
          render json: {
            error: 'Failed to create article',
            details: @article.errors.full_messages
          }, status: :unprocessable_entity
        end
      end

      # PATCH/PUT /api/v1/articles/:id
      def update
        if @article.update(article_params)
          render json: @article, status: :ok
        else
          render json: {
            error: 'Failed to update article',
            details: @article.errors.full_messages
          }, status: :unprocessable_entity
        end
      end

      # DELETE /api/v1/articles/:id
      def destroy
        @article.destroy
        head :no_content
      end

      private

      def set_article
        @article = Article.find(params[:id])
      end

      def article_params
        params.require(:article).permit(:title, :body, :published)
      end
    end
  end
end

Routes:

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :articles
    end
  end
end

Why: Follows REST conventions with proper status codes (200 OK, 201 Created, 204 No Content, 422 Unprocessable Entity). Namespace by version for future API changes.

Common Status Codes:

Code Symbol Usage
200 :ok Successful GET, PATCH, PUT
201 :created Successful POST (resource created)
204 :no_content Successful DELETE (no response body)
400 :bad_request Invalid request syntax, missing parameters
401 :unauthorized Missing or invalid authentication
403 :forbidden Authenticated but lacks permission
404 :not_found Resource doesn't exist
422 :unprocessable_entity Validation errors
429 :too_many_requests Rate limit exceeded
500 :internal_server_error Server error

Examples:

# Success responses
render json: @article, status: :ok                    # 200
render json: @article, status: :created               # 201
head :no_content                                       # 204

# Error responses
render json: { error: 'Bad request' }, status: :bad_request              # 400
render json: { error: 'Unauthorized' }, status: :unauthorized            # 401
render json: { error: 'Forbidden' }, status: :forbidden                  # 403
render json: { error: 'Not found' }, status: :not_found                  # 404
render json: { error: 'Validation failed' }, status: :unprocessable_entity  # 422

Why: Correct status codes help API clients handle responses appropriately and provide clear semantics about what happened.


API Versioning

Directory Structure:

app/controllers/
└── api/
    ├── v1/
    │   ├── articles_controller.rb
    │   └── users_controller.rb
    └── v2/
        ├── articles_controller.rb
        └── users_controller.rb

V1 Controller:

# app/controllers/api/v1/articles_controller.rb
module Api
  module V1
    class ArticlesController < ApplicationController
      def index
        @articles = Article.all
        render json: @articles
      end
    end
  end
end

V2 Controller (Breaking Changes):

# app/controllers/api/v2/articles_controller.rb
module Api
  module V2
    class ArticlesController < ApplicationController
      def index
        # V2 adds pagination and filtering
        @articles = Article
                    .where(status: params[:status]) if params[:status].present?
                    .page(params[:page])

        render json: {
          data: @articles,
          meta: {
            current_page: @articles.current_page,
            total_pages: @articles.total_pages,
            total_count: @articles.total_count
          }
        }
      end
    end
  end
end

Routes:

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :articles
    end

    namespace :v2 do
      resources :articles
    end
  end
end

Why: URL versioning is explicit, easy to test, and allows multiple versions to coexist. Clients can migrate at their own pace.

# ❌ WRONG - Breaking existing clients
class Api::ArticlesController < ApplicationController
  def index
    # Changed response structure without versioning
    render json: {
      articles: @articles,           # Was just array, now nested
      total: @articles.count          # New field
    }
  end
end
# ✅ CORRECT - New version for breaking changes
module Api
  module V1
    class ArticlesController < ApplicationController
      def index
        render json: @articles  # Keep V1 unchanged
      end
    end
  end

  module V2
    class ArticlesController < ApplicationController
      def index
        render json: {
          articles: @articles,
          total: @articles.count
        }
      end
    end
  end
end

Why bad: Breaking changes without versioning break existing API clients. Always version when changing response structure or behavior.


Authentication & Authorization

User Model:

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  has_secure_token :api_token

  # Regenerate token on password change
  after_update :regenerate_api_token, if: :saved_change_to_password_digest?

  private

  def regenerate_api_token
    regenerate_api_token
  end
end

Authentication Controller:

# app/controllers/api/v1/authentication_controller.rb
module Api
  module V1
    class AuthenticationController < ApplicationController
      skip_before_action :authenticate, only: [:create]

      # POST /api/v1/auth
      def create
        user = User.find_by(email: params[:email])

        if user&.authenticate(params[:password])
          render json: {
            token: user.api_token,
            user: {
              id: user.id,
              email: user.email,
              name: user.name
            }
          }, status: :ok
        else
          render json: { error: 'Invalid email or password' }, status: :unauthorized
        end
      end

      # DELETE /api/v1/auth
      def destroy
        current_user.regenerate_api_token
        head :no_content
      end
    end
  end
end

Using Token in Requests:

# Client sends token in Authorization header
curl -H "Authorization: Token YOUR_API_TOKEN" \
     https://api.example.com/api/v1/articles

Why: Token authentication is stateless (no sessions), works across domains, and is suitable for mobile/SPA clients.

Setup:

# Gemfile
gem 'jwt'

# lib/json_web_token.rb
class JsonWebToken
  SECRET_KEY = Rails.application.credentials.secret_key_base

  def self.encode(payload, exp = 24.hours.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET_KEY)
  end

  def self.decode(token)
    body = JWT.decode(token, SECRET_KEY)[0]
    HashWithIndifferentAccess.new(body)
  rescue JWT::DecodeError, JWT::ExpiredSignature
    nil
  end
end

Application Controller:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  before_action :authenticate_request

  private

  def authenticate_request
    header = request.headers['Authorization']
    token = header.split(' ').last if header
    decoded = JsonWebToken.decode(token)

    if decoded
      @current_user = User.find(decoded[:user_id])
    else
      render json: { error: 'Unauthorized' }, status: :unauthorized
    end
  rescue ActiveRecord::RecordNotFound
    render json: { error: 'Unauthorized' }, status: :unauthorized
  end

  attr_reader :current_user
end

Authentication Endpoint:

# app/controllers/api/v1/authentication_controller.rb
module Api
  module V1
    class AuthenticationController < ApplicationController
      skip_before_action :authenticate_request, only: [:create]

      def create
        user = User.find_by(email: params[:email])

        if user&.authenticate(params[:password])
          token = JsonWebToken.encode(user_id: user.id)
          render json: { token: token, user: user }, status: :ok
        else
          render json: { error: 'Invalid credentials' }, status: :unauthorized
        end
      end
    end
  end
end

Why: JWT is self-contained, stateless, and can include claims (user_id, roles, expiration). Widely supported by API clients.


Pagination, Filtering & Sorting

With Kaminari:

# Gemfile
gem 'kaminari'

# app/controllers/api/v1/articles_controller.rb
def index
  page = params[:page] || 1
  per_page = params[:per_page] || 20

  @articles = Article.page(page).per(per_page)

  render json: {
    data: @articles,
    meta: {
      current_page: @articles.current_page,
      next_page: @articles.next_page,
      prev_page: @articles.prev_page,
      total_pages: @articles.total_pages,
      total_count: @articles.total_count
    }
  }
end

With Pagy (Faster):

# Gemfile
gem 'pagy'

# app/controllers/application_controller.rb
include Pagy::Backend

# app/controllers/api/v1/articles_controller.rb
def index
  pagy, articles = pagy(Article.all, items: params[:per_page] || 20)

  render json: {
    data: articles,
    meta: {
      current_page: pagy.page,
      total_pages: pagy.pages,
      total_count: pagy.count,
      per_page: pagy.items
    }
  }
end

Why: Pagination prevents loading large datasets into memory. Include metadata so clients know how to fetch more pages.

# app/controllers/api/v1/articles_controller.rb
def index
  @articles = Article.all

  # Filtering
  @articles = @articles.where(status: params[:status]) if params[:status].present?
  @articles = @articles.where(category: params[:category]) if params[:category].present?
  @articles = @articles.where('created_at >= ?', params[:from_date]) if params[:from_date].present?

  # Searching
  @articles = @articles.where('title ILIKE ?', "%#{params[:q]}%") if params[:q].present?

  # Sorting
  sort_column = params[:sort_by] || 'created_at'
  sort_direction = params[:order] || 'desc'
  @articles = @articles.order("#{sort_column} #{sort_direction}")

  # Pagination
  @articles = @articles.page(params[:page]).per(params[:per_page] || 20)

  render json: {
    data: @articles,
    meta: pagination_meta(@articles)
  }
end

private

def pagination_meta(collection)
  {
    current_page: collection.current_page,
    total_pages: collection.total_pages,
    total_count: collection.total_count
  }
end

Example Requests:

# Filter by status
GET /api/v1/articles?status=published

# Search by title
GET /api/v1/articles?q=rails

# Sort by created_at descending
GET /api/v1/articles?sort_by=created_at&order=desc

# Combine filters, search, sort, and pagination
GET /api/v1/articles?status=published&q=rails&sort_by=title&order=asc&page=2&per_page=50

Why: Flexible filtering and sorting let clients fetch exactly what they need without loading unnecessary data.


CORS Configuration

Setup:

# Gemfile
gem 'rack-cors'

# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'example.com', 'localhost:3000'  # Whitelist specific origins

    resource '/api/*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      credentials: true,
      max_age: 86400  # Cache preflight for 24 hours
  end
end

Development (Allow All Origins):

# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    if Rails.env.development?
      origins '*'  # Allow all in development
    else
      origins ENV['ALLOWED_ORIGINS']&.split(',') || 'example.com'
    end

    resource '/api/*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

Why: CORS is required when frontend (SPA, mobile app) and API are on different domains. Whitelist specific origins in production for security.


Rate Limiting

With Rack::Attack:

# Gemfile
gem 'rack-attack'

# config/initializers/rack_attack.rb
class Rack::Attack
  # Throttle all requests by IP (60 requests per minute)
  throttle('req/ip', limit: 60, period: 1.minute) do |req|
    req.ip if req.path.start_with?('/api/')
  end

  # Throttle POST requests by IP (10 per minute)
  throttle('req/ip/post', limit: 10, period: 1.minute) do |req|
    req.ip if req.path.start_with?('/api/') && req.post?
  end

  # Throttle authenticated requests by user token
  throttle('req/token', limit: 100, period: 1.minute) do |req|
    if req.path.start_with?('/api/')
      token = req.env['HTTP_AUTHORIZATION']&.split(' ')&.last
      User.find_by(api_token: token)&.id if token
    end
  end

  # Custom response for throttled requests
  self.throttled_responder = lambda do |env|
    [
      429,
      { 'Content-Type' => 'application/json' },
      [{ error: 'Rate limit exceeded. Try again later.' }.to_json]
    ]
  end
end

# config/application.rb
config.middleware.use Rack::Attack

Why: Rate limiting prevents abuse, protects server resources, and ensures fair usage across all API clients.


Error Handling

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  rescue_from StandardError, with: :internal_server_error
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
  rescue_from ActionController::ParameterMissing, with: :bad_request
  rescue_from Pundit::NotAuthorizedError, with: :forbidden

  private

  def not_found(exception)
    render json: error_response(
      'Resource not found',
      exception.message
    ), status: :not_found
  end

  def unprocessable_entity(exception)
    render json: error_response(
      'Validation failed',
      exception.record.errors.full_messages
    ), status: :unprocessable_entity
  end

  def bad_request(exception)
    render json: error_response(
      'Bad request',
      exception.message
    ), status: :bad_request
  end

  def forbidden(exception)
    render json: error_response(
      'Forbidden',
      'You are not authorized to perform this action'
    ), status: :forbidden
  end

  def internal_server_error(exception)
    # Log error for debugging
    Rails.logger.error(exception.message)
    Rails.logger.error(exception.backtrace.join("\n"))

    render json: error_response(
      'Internal server error',
      Rails.env.production? ? 'Something went wrong' : exception.message
    ), status: :internal_server_error
  end

  def error_response(message, details = nil)
    response = { error: message }
    response[:details] = details if details.present?
    response
  end
end

Example Error Responses:

// 404 Not Found
{
  "error": "Resource not found",
  "details": "Couldn't find Article with 'id'=999"
}

// 422 Unprocessable Entity
{
  "error": "Validation failed",
  "details": [
    "Title can't be blank",
    "Body is too short (minimum is 10 characters)"
  ]
}

// 400 Bad Request
{
  "error": "Bad request",
  "details": "param is missing or the value is empty: article"
}

Why: Consistent error format makes it easy for clients to parse and display errors. Include details for debugging without exposing sensitive info.


Testing API Endpoints

# spec/requests/api/v1/articles_spec.rb
require 'rails_helper'

RSpec.describe 'Api::V1::Articles', type: :request do
  let(:user) { create(:user) }
  let(:headers) { { 'Authorization' => "Token #{user.api_token}" } }

  describe 'GET /api/v1/articles' do
    let!(:articles) { create_list(:article, 3, :published) }

    it 'returns all published articles' do
      get '/api/v1/articles', headers: headers

      expect(response).to have_http_status(:ok)
      expect(json_response['data'].size).to eq(3)
    end

    it 'filters by status' do
      draft = create(:article, status: :draft)

      get '/api/v1/articles', params: { status: 'draft' }, headers: headers

      expect(response).to have_http_status(:ok)
      expect(json_response['data'].size).to eq(1)
      expect(json_response['data'].first['id']).to eq(draft.id)
    end

    it 'paginates results' do
      create_list(:article, 25)

      get '/api/v1/articles', params: { page: 2, per_page: 10 }, headers: headers

      expect(response).to have_http_status(:ok)
      expect(json_response['data'].size).to eq(10)
      expect(json_response['meta']['current_page']).to eq(2)
    end
  end

  describe 'POST /api/v1/articles' do
    let(:valid_attributes) { { article: { title: 'Test', body: 'Content' } } }

    it 'creates a new article' do
      expect {
        post '/api/v1/articles', params: valid_attributes, headers: headers
      }.to change(Article, :count).by(1)

      expect(response).to have_http_status(:created)
      expect(json_response['title']).to eq('Test')
      expect(response.location).to be_present
    end

    it 'returns errors for invalid data' do
      post '/api/v1/articles', params: { article: { title: '' } }, headers: headers

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json_response['error']).to eq('Failed to create article')
      expect(json_response['details']).to include("Title can't be blank")
    end
  end

  describe 'DELETE /api/v1/articles/:id' do
    let!(:article) { create(:article) }

    it 'deletes the article' do
      expect {
        delete "/api/v1/articles/#{article.id}", headers: headers
      }.to change(Article, :count).by(-1)

      expect(response).to have_http_status(:no_content)
      expect(response.body).to be_empty
    end
  end

  describe 'authentication' do
    it 'returns 401 without token' do
      get '/api/v1/articles'

      expect(response).to have_http_status(:unauthorized)
      expect(json_response['error']).to eq('Unauthorized')
    end

    it 'returns 401 with invalid token' do
      get '/api/v1/articles', headers: { 'Authorization' => 'Token invalid' }

      expect(response).to have_http_status(:unauthorized)
    end
  end

  private

  def json_response
    JSON.parse(response.body)
  end
end

Why: Request specs test the full HTTP request/response cycle including routing, authentication, and JSON parsing. More realistic than controller specs.


# spec/support/request_helpers.rb
module RequestHelpers
  def json_response
    JSON.parse(response.body)
  end

  def auth_headers(user)
    { 'Authorization' => "Token #{user.api_token}" }
  end
end

RSpec.configure do |config|
  config.include RequestHelpers, type: :request
end

# spec/requests/api/v1/authentication_spec.rb
RSpec.describe 'Api::V1::Authentication', type: :request do
  describe 'POST /api/v1/auth' do
    let(:user) { create(:user, email: 'test@example.com', password: 'password') }

    it 'returns token with valid credentials' do
      post '/api/v1/auth', params: { email: 'test@example.com', password: 'password' }

      expect(response).to have_http_status(:ok)
      expect(json_response['token']).to be_present
      expect(json_response['user']['email']).to eq('test@example.com')
    end

    it 'returns error with invalid credentials' do
      post '/api/v1/auth', params: { email: 'test@example.com', password: 'wrong' }

      expect(response).to have_http_status(:unauthorized)
      expect(json_response['error']).to eq('Invalid email or password')
    end
  end
end

Official Documentation:

Gems & Libraries:

API Documentation:

Best Practices:

Weekly Installs
3
GitHub Stars
4
First Seen
Mar 1, 2026
Installed on
opencode3
gemini-cli3
github-copilot3
codex3
amp3
cline3