API Development Patterns
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:
/usersnot/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!
More from kaakati/rails-enterprise-dev
flutter conventions & best practices
Dart 3.x and Flutter 3.x conventions, naming patterns, code organization, null safety, and async/await best practices
55getx state management patterns
GetX controllers, reactive state, dependency injection, bindings, navigation, and best practices
52tailadmin ui patterns
TailAdmin dashboard UI framework patterns and Tailwind CSS classes. ALWAYS use this skill when: (1) Building any dashboard or admin panel interface, (2) Creating data tables, cards, charts, or metrics displays, (3) Implementing forms, buttons, alerts, or modals, (4) Building navigation (sidebar, header, breadcrumbs), (5) Any UI work that should follow TailAdmin design. This skill REQUIRES fetching from the official GitHub repository to ensure accurate class usage - NEVER invent classes.
39mvvm-architecture
Expert MVVM decisions for iOS/tvOS: choosing between ViewModel patterns (state enum vs published properties vs Combine), service layer boundaries, dependency injection strategies, and testing approaches. Use when designing ViewModel architecture, debugging data flow issues, or deciding where business logic belongs. Trigger keywords: MVVM, ViewModel, ObservableObject, @StateObject, service layer, dependency injection, unit test, mock, architecture
36rails localization (i18n) - english & arabic
Comprehensive internationalization skill for Ruby on Rails applications with proper English and Arabic translations, RTL support, pluralization rules, date/time formatting, and culturally appropriate content adaptation.
34rspec testing patterns
Complete guide to testing Ruby on Rails applications with RSpec. Use this skill when writing unit tests, integration tests, system tests, or when setting up test infrastructure including factories, shared examples, and mocking strategies.
31