rails-api-controllers
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:
- jwt - JSON Web Token implementation
- rack-cors - CORS middleware
- rack-attack - Rate limiting and throttling
- kaminari - Pagination
- pagy - Fast pagination
- pundit - Authorization
API Documentation:
- rswag - OpenAPI/Swagger docs for Rails APIs
- apipie-rails - API documentation tool
Best Practices: