API Development Patterns
Complete patterns and best practices for building production-grade REST APIs in Rails 7.x/8.x.
RESTful API Conventions
Resource-Oriented Design
Core Principles:
-
Resources are nouns (not verbs): /users , /posts , not /get_user
-
Use HTTP methods for actions: GET (read), POST (create), PATCH/PUT (update), DELETE (destroy)
-
Nest resources for relationships, but limit nesting to 1-2 levels
-
Use plural resource names: /users not /user
Standard Resource Routes:
config/routes.rb
Rails.application.routes.draw do namespace :api do namespace :v1 do resources :posts do resources :comments, only: [:index, :create] # Nested but limited member do post :publish post :archive end collection do get :trending end end
# Flat route for comments by ID (better than deep nesting)
resources :comments, only: [:show, :update, :destroy]
end
end end
HTTP Methods & Status Codes
Standard API Actions:
Method Action Success Status Body
GET Index/List 200 OK Resource array + pagination
GET Show 200 OK Single resource
POST Create 201 Created Created resource
PATCH/PUT Update 200 OK Updated resource
DELETE Destroy 204 No Content Empty
Error Status Codes:
Code Meaning When to Use
400 Bad Request Invalid JSON, malformed request
401 Unauthorized Missing or invalid authentication
403 Forbidden Authenticated but not authorized
404 Not Found Resource doesn't exist
422 Unprocessable Entity Validation errors
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Unexpected server error
503 Service Unavailable Maintenance mode, overloaded
Controller Example:
app/controllers/api/v1/posts_controller.rb
module Api module V1 class PostsController < Api::BaseController before_action :authenticate_api_user! before_action :set_post, only: [:show, :update, :destroy]
def index
@posts = Post.published
.page(params[:page])
.per(params[:per_page] || 25)
render json: PostBlueprint.render(@posts, root: :posts), status: :ok
end
def show
render json: PostBlueprint.render(@post), status: :ok
end
def create
@post = Current.user.posts.build(post_params)
if @post.save
render json: PostBlueprint.render(@post), status: :created, location: api_v1_post_url(@post)
else
render json: { errors: @post.errors }, status: :unprocessable_entity
end
end
def update
if @post.update(post_params)
render json: PostBlueprint.render(@post), status: :ok
else
render json: { errors: @post.errors }, status: :unprocessable_entity
end
end
def destroy
@post.destroy
head :no_content
end
private
def set_post
@post = Post.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Post not found" }, status: :not_found
end
def post_params
params.require(:post).permit(:title, :body, :published_at, tag_ids: [])
end
end
end end
Serialization Patterns
Blueprinter (Recommended)
Installation:
Gemfile
gem 'blueprinter' gem 'oj' # Fast JSON parser
Basic Blueprint:
app/blueprints/post_blueprint.rb
class PostBlueprint < Blueprinter::Base identifier :id
fields :title, :body, :published_at, :created_at
field :slug do |post| post.title.parameterize end
association :author, blueprint: UserBlueprint, view: :compact
association :comments, blueprint: CommentBlueprint do |post, options| post.comments.limit(options[:comment_limit] || 10) end
view :compact do fields :id, :title, :slug end
view :extended do include_view :default fields :view_count, :like_count association :tags, blueprint: TagBlueprint end end
Using Views:
Compact view for lists
PostBlueprint.render(@posts, view: :compact, root: :posts)
Extended view for show
PostBlueprint.render(@post, view: :extended)
Pass options to associations
PostBlueprint.render(@post, comment_limit: 5)
JSONAPI::Serializer (Alternative)
For JSON:API Specification Compliance:
Gemfile
gem 'jsonapi-serializer'
app/serializers/post_serializer.rb
class PostSerializer include JSONAPI::Serializer
attributes :title, :body, :published_at
belongs_to :author, serializer: UserSerializer has_many :comments, serializer: CommentSerializer
attribute :slug do |post| post.title.parameterize end
link :self do |post| Rails.application.routes.url_helpers.api_v1_post_url(post) end end
Usage
PostSerializer.new(@posts, include: [:author, :comments]).serializable_hash
Alba (Lightweight Alternative)
Gemfile
gem 'alba'
app/serializers/post_serializer.rb
class PostSerializer include Alba::Resource
attributes :id, :title, :body, :published_at
one :author, resource: UserSerializer many :comments, resource: CommentSerializer
attribute :slug do |post| post.title.parameterize end end
Usage
PostSerializer.new(@posts).serialize
Authentication
JWT (JSON Web Tokens)
Installation:
Gemfile
gem 'jwt' gem 'bcrypt' # For password hashing
JWT Service:
app/services/json_web_token_service.rb
class JsonWebTokenService SECRET_KEY = Rails.application.credentials.secret_key_base ALGORITHM = 'HS256'
def self.encode(payload, expiration = 24.hours.from_now) payload[:exp] = expiration.to_i JWT.encode(payload, SECRET_KEY, ALGORITHM) end
def self.decode(token) decoded = JWT.decode(token, SECRET_KEY, true, algorithm: ALGORITHM)[0] HashWithIndifferentAccess.new(decoded) rescue JWT::DecodeError, JWT::ExpiredSignature => e nil end end
Authentication Controller:
app/controllers/api/v1/authentication_controller.rb
module Api module V1 class AuthenticationController < Api::BaseController skip_before_action :authenticate_api_user!, only: [:create]
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebTokenService.encode(user_id: user.id)
render json: {
token: token,
user: UserBlueprint.render_as_hash(user)
}, status: :ok
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
def destroy
# Implement token revocation (requires Redis/database storage)
head :no_content
end
end
end end
Base Controller with JWT Authentication:
app/controllers/api/base_controller.rb
module Api class BaseController < ActionController::API before_action :authenticate_api_user!
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def authenticate_api_user!
token = request.headers['Authorization']&.split(' ')&.last
return render_unauthorized unless token
decoded_token = JsonWebTokenService.decode(token)
return render_unauthorized unless decoded_token
@current_user = User.find_by(id: decoded_token[:user_id])
return render_unauthorized unless @current_user
# Store in Current for easy access
Current.user = @current_user
rescue
render_unauthorized
end
def current_user
@current_user
end
def render_unauthorized
render json: { error: 'Unauthorized' }, status: :unauthorized
end
def not_found
render json: { error: 'Resource not found' }, status: :not_found
end
def bad_request
render json: { error: 'Bad request' }, status: :bad_request
end
end end
API Keys (Alternative)
For Service-to-Service Authentication:
Migration
create_table :api_keys do |t| t.references :user, null: false, foreign_key: true t.string :key, null: false, index: { unique: true } t.string :name # e.g., "Production Server", "Mobile App" t.datetime :last_used_at t.datetime :expires_at t.timestamps end
app/models/api_key.rb
class ApiKey < ApplicationRecord belongs_to :user
before_create :generate_key
scope :active, -> { where('expires_at IS NULL OR expires_at > ?', Time.current) }
def self.authenticate(key) active.find_by(key: key)&.tap do |api_key| api_key.update_column(:last_used_at, Time.current) end end
private
def generate_key self.key = SecureRandom.base58(32) end end
Authentication in controller
def authenticate_api_key! key = request.headers['X-API-Key'] || params[:api_key] return render_unauthorized unless key
@api_key = ApiKey.authenticate(key) return render_unauthorized unless @api_key
@current_user = @api_key.user Current.user = @current_user end
Authorization
Pundit for APIs
Gemfile
gem 'pundit'
app/controllers/api/base_controller.rb
module Api class BaseController < ActionController::API include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :forbidden
private
def forbidden
render json: { error: 'Forbidden' }, status: :forbidden
end
end end
app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy def index? true end
def show? record.published? || record.author == user end
def create? user.present? end
def update? record.author == user end
def destroy? record.author == user || user.admin? end end
In controller
def show @post = Post.find(params[:id]) authorize @post render json: PostBlueprint.render(@post) end
Versioning Strategies
URL Versioning (Recommended)
Routes:
config/routes.rb
Rails.application.routes.draw do namespace :api do namespace :v1 do resources :posts end
namespace :v2 do
resources :posts
end
end end
Pros: Simple, clear, cache-friendly Cons: URLs change between versions
Header Versioning
config/routes.rb
namespace :api, defaults: { format: :json } do scope module: :v1, constraints: ApiVersion.new('v1', default: true) do resources :posts end
scope module: :v2, constraints: ApiVersion.new('v2') do resources :posts end end
lib/api_version.rb
class ApiVersion def initialize(version, default = false) @version = version @default = default end
def matches?(request) @default || check_headers(request.headers) end
private
def check_headers(headers) accept = headers['Accept'] accept&.include?("application/vnd.myapp.#{@version}+json") end end
Usage:
Accept: application/vnd.myapp.v2+json
Pagination
Kaminari
Gemfile
gem 'kaminari'
Controller
def index @posts = Post.published .page(params[:page]) .per(params[:per_page] || 25)
render json: { posts: PostBlueprint.render_as_hash(@posts, view: :compact), meta: pagination_meta(@posts) } end
private
def pagination_meta(collection) { current_page: collection.current_page, next_page: collection.next_page, prev_page: collection.prev_page, total_pages: collection.total_pages, total_count: collection.total_count, per_page: collection.limit_value } end
pagy (Faster Alternative)
Gemfile
gem 'pagy'
app/controllers/api/base_controller.rb
include Pagy::Backend
def index @pagy, @posts = pagy(Post.published, items: params[:per_page] || 25)
render json: { posts: PostBlueprint.render_as_hash(@posts), meta: pagy_metadata(@pagy) } end
private
def pagy_metadata(pagy_object) { current_page: pagy_object.page, next_page: pagy_object.next, prev_page: pagy_object.prev, total_pages: pagy_object.pages, total_count: pagy_object.count, per_page: pagy_object.items } end
Rate Limiting
Rack::Attack
Gemfile
gem 'rack-attack'
config/initializers/rack_attack.rb
class Rack::Attack
Throttle all requests by IP
throttle('req/ip', limit: 300, period: 5.minutes) do |req| req.ip if req.path.start_with?('/api/') end
Throttle API requests by authentication token
throttle('api/token', limit: 1000, period: 1.hour) do |req| req.env['HTTP_AUTHORIZATION']&.split(' ')&.last if req.path.start_with?('/api/') end
Throttle login attempts
throttle('logins/email', limit: 5, period: 20.minutes) do |req| if req.path == '/api/v1/login' && req.post? req.params['email'].to_s.downcase.gsub(/\s+/, "") end end
Block specific IPs
blocklist('block bad IPs') do |req| # Read from Redis or database Redis.current.sismember('blocked_ips', req.ip) end
Custom response for throttled requests
self.throttled_responder = lambda do |env| retry_after = env['rack.attack.match_data'][:period] [ 429, { 'Content-Type' => 'application/json', 'Retry-After' => retry_after.to_s }, [{ error: 'Rate limit exceeded', retry_after: retry_after }.to_json] ] end end
config/application.rb
config.middleware.use Rack::Attack
Error Handling
Standardized Error Format
app/controllers/api/base_controller.rb
module Api class BaseController < 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 render_error(message, status, details = {})
render json: {
error: {
message: message,
status: status,
**details
}
}, status: status
end
def bad_request(exception)
render_error('Bad request', :bad_request, details: exception.message)
end
def unauthorized
render_error('Unauthorized', :unauthorized)
end
def forbidden(exception)
render_error('Forbidden', :forbidden, details: exception.message)
end
def not_found(exception)
render_error('Resource not found', :not_found, resource: exception.model)
end
def unprocessable_entity(exception)
render json: {
error: {
message: 'Validation failed',
status: 422,
errors: exception.record.errors.as_json
}
}, status: :unprocessable_entity
end
def internal_server_error(exception)
Rails.logger.error(exception.message)
Rails.logger.error(exception.backtrace.join("\n"))
# Report to error tracking service (Sentry, Rollbar, etc.)
ErrorTrackingService.report(exception) if defined?(ErrorTrackingService)
render_error('Internal server error', :internal_server_error)
end
end end
Validation Errors Format
app/models/post.rb
class Post < ApplicationRecord validates :title, presence: true, length: { minimum: 5, maximum: 100 } validates :body, presence: true end
Response for validation errors (422):
{ "error": { "message": "Validation failed", "status": 422, "errors": { "title": ["can't be blank", "is too short (minimum is 5 characters)"], "body": ["can't be blank"] } } }
CORS Configuration
Gemfile
gem 'rack-cors'
config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins 'https://example.com', 'https://app.example.com'
resource '/api/*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true,
max_age: 86400
end
Development
if Rails.env.development? allow do origins 'http://localhost:3000', 'http://localhost:3001' resource '*', headers: :any, methods: :any end end end
API Documentation
rswag (OpenAPI/Swagger)
Installation:
Gemfile
gem 'rswag'
Run installer
rails g rswag:install
Request Spec:
spec/requests/api/v1/posts_spec.rb
require 'swagger_helper'
RSpec.describe 'API V1 Posts', type: :request do path '/api/v1/posts' do get 'Retrieves posts' do tags 'Posts' produces 'application/json' parameter name: :page, in: :query, type: :integer, required: false parameter name: :per_page, in: :query, type: :integer, required: false
response '200', 'posts found' do
schema type: :object,
properties: {
posts: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
title: { type: :string },
body: { type: :string },
published_at: { type: :string, format: 'date-time' }
},
required: ['id', 'title']
}
},
meta: {
type: :object,
properties: {
current_page: { type: :integer },
total_pages: { type: :integer },
total_count: { type: :integer }
}
}
}
run_test!
end
end
post 'Creates a post' do
tags 'Posts'
consumes 'application/json'
produces 'application/json'
parameter name: :post, in: :body, schema: {
type: :object,
properties: {
title: { type: :string },
body: { type: :string }
},
required: ['title', 'body']
}
response '201', 'post created' do
let(:post) { { title: 'Test Post', body: 'Test body' } }
run_test!
end
response '422', 'invalid request' do
let(:post) { { title: '' } }
run_test!
end
end
end end
Generate Swagger Docs:
rake rswag:specs:swaggerize
Access at: http://localhost:3000/api-docs
Performance Optimization
Caching
Controller with caching
def index @posts = Rails.cache.fetch(['posts', 'index', params[:page]], expires_in: 5.minutes) do Post.published .includes(:author, :tags) .page(params[:page]) .per(25) .to_a end
render json: PostBlueprint.render(@posts) end
ETags for conditional requests
def show @post = Post.find(params[:id])
if stale?(@post) render json: PostBlueprint.render(@post) end end
N+1 Query Prevention
Always use includes/eager_load for associations
def index @posts = Post.published .includes(:author, :tags, comments: :user) .page(params[:page])
render json: PostBlueprint.render(@posts) end
Bullet Gem (Development)
Gemfile
group :development do gem 'bullet' end
config/environments/development.rb
config.after_initialize do Bullet.enable = true Bullet.alert = true Bullet.bullet_logger = true Bullet.console = true Bullet.rails_logger = true Bullet.add_footer = false # API doesn't need HTML footer end
Testing
Request Specs
spec/requests/api/v1/posts_spec.rb
require 'rails_helper'
RSpec.describe 'API V1 Posts', type: :request do let(:user) { create(:user) } let(:token) { JsonWebTokenService.encode(user_id: user.id) } let(:auth_headers) { { 'Authorization' => "Bearer #{token}" } }
describe 'GET /api/v1/posts' do before do create_list(:post, 3, :published) create(:post, :draft) # Should not be included end
it 'returns published posts' do
get '/api/v1/posts', headers: auth_headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['posts'].size).to eq(3)
end
it 'paginates results' do
create_list(:post, 30, :published)
get '/api/v1/posts', params: { page: 2, per_page: 10 }, headers: auth_headers
json = JSON.parse(response.body)
expect(json['posts'].size).to eq(10)
expect(json['meta']['current_page']).to eq(2)
end
it 'returns 401 without authentication' do
get '/api/v1/posts'
expect(response).to have_http_status(:unauthorized)
end
end
describe 'POST /api/v1/posts' do let(:valid_params) do { post: { title: 'Test Post', body: 'Test body' } } end
it 'creates a post' do
expect {
post '/api/v1/posts', params: valid_params, headers: auth_headers
}.to change(Post, :count).by(1)
expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json['title']).to eq('Test Post')
expect(response.headers['Location']).to be_present
end
it 'returns validation errors' do
post '/api/v1/posts', params: { post: { title: '' } }, headers: auth_headers
expect(response).to have_http_status(:unprocessable_entity)
json = JSON.parse(response.body)
expect(json['error']['errors']).to have_key('title')
end
end
describe 'PATCH /api/v1/posts/:id' do let(:post_record) { create(:post, author: user) }
it 'updates the post' do
patch "/api/v1/posts/#{post_record.id}",
params: { post: { title: 'Updated' } },
headers: auth_headers
expect(response).to have_http_status(:ok)
expect(post_record.reload.title).to eq('Updated')
end
it 'returns 403 for unauthorized update' do
other_post = create(:post)
patch "/api/v1/posts/#{other_post.id}",
params: { post: { title: 'Hacked' } },
headers: auth_headers
expect(response).to have_http_status(:forbidden)
end
end end
Factory for API Testing
spec/factories/users.rb
FactoryBot.define do factory :user do email { Faker::Internet.email } password { 'password123' } password_confirmation { 'password123' } end end
spec/factories/posts.rb
FactoryBot.define do factory :post do title { Faker::Lorem.sentence } body { Faker::Lorem.paragraphs(number: 3).join("\n") } association :author, factory: :user
trait :published do
published_at { 1.day.ago }
end
trait :draft do
published_at { nil }
end
end end
Shared Examples for API Responses
spec/support/shared_examples/api_responses.rb
RSpec.shared_examples 'requires authentication' do it 'returns 401 without token' do make_request(headers: {}) expect(response).to have_http_status(:unauthorized) end
it 'returns 401 with invalid token' do make_request(headers: { 'Authorization' => 'Bearer invalid' }) expect(response).to have_http_status(:unauthorized) end end
RSpec.shared_examples 'paginates results' do it 'includes pagination metadata' do make_request
json = JSON.parse(response.body)
expect(json['meta']).to include(
'current_page',
'total_pages',
'total_count'
)
end end
Usage in specs
describe 'GET /api/v1/posts' do def make_request(headers: auth_headers) get '/api/v1/posts', headers: headers end
it_behaves_like 'requires authentication' it_behaves_like 'paginates results' end
Security Best Practices
Input Sanitization
Always use strong parameters
def post_params params.require(:post).permit(:title, :body, :published_at, tag_ids: []) end
SQL Injection Prevention
BAD - vulnerable to SQL injection
Post.where("title = '#{params[:title]}'")
GOOD - use parameterized queries
Post.where("title = ?", params[:title]) Post.where(title: params[:title])
Mass Assignment Protection
Models automatically protected with strong parameters
Never use:
Post.new(params[:post]) # BAD Post.create(params[:post]) # BAD
Always use:
Post.new(post_params) # GOOD Post.create(post_params) # GOOD
Sensitive Data Filtering
config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [ :password, :password_confirmation, :token, :api_key, :secret, :credit_card ]
Anti-Patterns to Avoid
❌ Don't Return ActiveRecord Objects Directly
BAD
def index render json: Post.all # Exposes all attributes end
GOOD
def index render json: PostBlueprint.render(Post.all) end
❌ Don't Use Sessions/Cookies in APIs
APIs should be stateless
Use JWT or API keys, not session-based authentication
❌ Don't Skip Authorization
BAD
def destroy @post = Post.find(params[:id]) @post.destroy end
GOOD
def destroy @post = Post.find(params[:id]) authorize @post # Pundit @post.destroy end
❌ Don't Ignore Rate Limiting
Always implement rate limiting for public APIs
Use Rack::Attack or similar
❌ Don't Return 200 for All Responses
Use appropriate status codes
200 OK, 201 Created, 204 No Content, 400 Bad Request, etc.
Summary Checklist
When building a new API endpoint:
-
Use RESTful resource naming and HTTP methods
-
Implement proper authentication (JWT/API keys)
-
Add authorization checks (Pundit)
-
Use serializers (Blueprinter) - never expose raw models
-
Return appropriate HTTP status codes
-
Implement pagination for list endpoints
-
Add rate limiting (Rack::Attack)
-
Configure CORS properly
-
Handle errors consistently
-
Write comprehensive request specs
-
Document with rswag/OpenAPI
-
Optimize queries (includes, caching)
-
Version your API (URL or header)
-
Filter sensitive parameters in logs
-
Use strong parameters for mass assignment protection
This skill provides the foundation for building production-ready REST APIs in Rails!