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:
-
Rails Guides - API-Only Applications
-
Rails API 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:
-
REST API Tutorial
-
HTTP Status Codes