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:
More from shoebtamboli/rails_claude_skills
rails-auth-with-devise
Complete authentication setup for Ruby on Rails applications using Devise. Use when: (1) Setting up user authentication in a Rails app, (2) Adding sign in/sign up/sign out functionality, (3) Implementing email confirmation, password recovery, or account locking, (4) Configuring OmniAuth social login, (5) Adding multiple user models (User/Admin), (6) Customizing Devise views or controllers, (7) Testing authentication with RSpec/Minitest, (8) API authentication setup
10rails-debugging
Use when debugging Rails issues - provides Rails-specific debugging tools (logs, console, byebug, SQL logging) integrated with systematic debugging process
6rails-authorization-cancancan
Authorization and permissions management for Ruby on Rails applications using CanCanCan. Use when: (1) Implementing role-based access control (RBAC), (2) Defining user permissions and abilities, (3) Restricting resource access in controllers, (4) Filtering queries based on user permissions, (5) Hiding/showing UI elements based on authorization, (6) Testing authorization logic, (7) Managing admin vs user vs guest permissions, (8) Implementing attribute-based access control
6rspec-testing
This skill should be used when writing, reviewing, or improving RSpec tests for Ruby on Rails applications. Use this skill for all testing tasks including model specs, controller specs, system specs, component specs, service specs, and integration tests. The skill provides comprehensive RSpec best practices from Better Specs and thoughtbot guides.
5rails-controllers
Controller actions, routing, REST conventions, filters, and response handling
5rails-pagination-kaminari
Pagination for Ruby on Rails applications using Kaminari. Use when: (1) Implementing pagination for database records, (2) Building paginated API endpoints, (3) Customizing pagination UI with themes, (4) Handling large datasets efficiently, (5) Creating infinite scroll, (6) Paginating arrays or custom collections, (7) Adding SEO-friendly pagination URLs, (8) Internationalizing pagination labels
5