API Mode
Building JSON APIs with Rails API mode, serialization, versioning, authentication, and rate limiting.
You are an expert in building JSON APIs with Rails API mode, covering serialization, authentication, versioning, error handling, and performance.
## Key Points
- Use consistent response envelopes (`{ data: ..., meta: ... }` for success, `{ error: ... }` for failures).
- Version your API from day one using URL namespacing (`/api/v1/`).
- Return appropriate HTTP status codes: 200, 201, 204 for success; 400, 401, 403, 404, 422, 429 for errors.
- Use `includes` and `select` to avoid N+1 queries and over-fetching columns in list endpoints.
- Paginate all list endpoints. Return pagination metadata in the response body or headers.
- Use `status: :no_content` (204) for successful DELETE responses with no body.
- Keep serialization logic out of controllers. Use dedicated serializer classes or gems like `alba` or `blueprinter`.
- Document your API with OpenAPI/Swagger using `rswag` or similar tools.
- **Exposing internal errors**: Never render raw exception messages in production. Use rescue handlers to return safe error messages.
- **Missing authentication on endpoints**: Audit routes to ensure all sensitive endpoints require authentication.
- **No rate limiting**: Unthrottled APIs are vulnerable to abuse. Add rate limiting early.
- **Inconsistent response formats**: Mixing bare arrays, objects, and envelopes confuses API consumers. Pick a convention and enforce it.skilldb get ruby-rails-skills/API ModeFull skill: 351 linesRails API Mode — Ruby on Rails
You are an expert in building JSON APIs with Rails API mode, covering serialization, authentication, versioning, error handling, and performance.
Overview
Rails API mode (rails new myapp --api) strips out browser-specific middleware (cookies, sessions, flash, views) to create a lean backend for JSON APIs. It inherits from ActionController::API instead of ActionController::Base, producing faster responses with a smaller middleware stack.
Core Philosophy
A Rails API should be a contract between your server and its consumers. Every endpoint, response shape, and error format is a promise that clients depend on. Stability, consistency, and predictability matter more than cleverness. Versioning your API from the start acknowledges that contracts evolve, and gives you room to iterate without breaking existing integrations.
Rails API mode strips away the browser-centric middleware to focus purely on data exchange, but the Rails philosophy of convention over configuration still applies. Consistent response envelopes, standard HTTP status codes, and predictable URL structures reduce the cognitive load for anyone consuming your API. When a consumer can guess how your API behaves before reading the docs, you have succeeded.
Security in an API context means defense in depth: authentication verifies identity, authorization controls access, rate limiting prevents abuse, and input validation rejects malformed data before it reaches your business logic. Each layer is necessary because no single layer is sufficient on its own.
Anti-Patterns
-
Leaking Internal Structure: Returning raw ActiveRecord attributes (including
created_at,updated_at, internal IDs, or sensitive fields) directly as JSON without a serializer. This couples your API response to your database schema and can expose data you never intended to share. -
Version Procrastination: Skipping API versioning because "we only have one client right now." Adding versioning retroactively is painful and risks breaking existing consumers. Namespace from the start with
/api/v1/. -
Inconsistent Error Formats: Returning validation errors as an array in one endpoint, a string in another, and a hash in a third. Consumers need a single, predictable error shape to build reliable error handling.
-
Authentication Afterthought: Adding authentication piecemeal to individual controllers rather than enforcing it globally in a base controller with explicit
skip_before_actionfor public endpoints. This inverts the security model and makes it easy to accidentally expose private data. -
Pagination as Optional: Returning unbounded collections from list endpoints because the dataset is "small for now." Every list endpoint should paginate by default, with a sensible maximum page size.
Core Concepts
API Controller Base
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate_request
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def authenticate_request
authenticate_or_request_with_http_token do |token|
@current_user = User.find_by(api_token: token)
end
end
def current_user
@current_user
end
def not_found(exception)
render json: { error: "Not found", message: 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: "Bad request", message: exception.message }, status: :bad_request
end
end
end
end
Resource Controller
# app/controllers/api/v1/posts_controller.rb
module Api
module V1
class PostsController < BaseController
before_action :set_post, only: [:show, :update, :destroy]
def index
posts = Post.published
.includes(:user, :tags)
.order(created_at: :desc)
.page(params[:page])
.per(params[:per_page] || 25)
render json: {
data: posts.map { |p| PostSerializer.new(p).as_json },
meta: pagination_meta(posts)
}
end
def show
render json: PostSerializer.new(@post, detail: true).as_json
end
def create
post = current_user.posts.create!(post_params)
render json: PostSerializer.new(post).as_json, status: :created
end
def update
@post.update!(post_params)
render json: PostSerializer.new(@post).as_json
end
def destroy
@post.destroy!
head :no_content
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :body, :published, tag_ids: [])
end
def pagination_meta(collection)
{
current_page: collection.current_page,
total_pages: collection.total_pages,
total_count: collection.total_count,
per_page: collection.limit_value
}
end
end
end
end
Routing with Versioning
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :posts, only: [:index, :show, :create, :update, :destroy] do
resources :comments, only: [:index, :create]
end
resources :users, only: [:show, :update]
resource :session, only: [:create, :destroy]
end
end
end
Implementation Patterns
Serializers
# app/serializers/post_serializer.rb
class PostSerializer
def initialize(post, detail: false)
@post = post
@detail = detail
end
def as_json(*)
data = {
id: @post.id,
title: @post.title,
summary: @post.body.truncate(200),
published: @post.published,
author: {
id: @post.user.id,
name: @post.user.full_name
},
tags: @post.tags.map { |t| { id: t.id, name: t.name } },
created_at: @post.created_at.iso8601,
updated_at: @post.updated_at.iso8601
}
if @detail
data[:body] = @post.body
data[:comments_count] = @post.comments.count
end
data
end
end
JWT Authentication
# app/services/jwt_service.rb
class JwtService
SECRET = Rails.application.credentials.jwt_secret_key
ALGORITHM = "HS256"
def self.encode(payload, exp: 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET, ALGORITHM)
end
def self.decode(token)
decoded = JWT.decode(token, SECRET, true, algorithm: ALGORITHM)
HashWithIndifferentAccess.new(decoded.first)
rescue JWT::DecodeError, JWT::ExpiredSignature => e
nil
end
end
# app/controllers/api/v1/sessions_controller.rb
module Api
module V1
class SessionsController < BaseController
skip_before_action :authenticate_request, only: :create
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JwtService.encode(user_id: user.id)
render json: { token: token, user: UserSerializer.new(user).as_json }
else
render json: { error: "Invalid credentials" }, status: :unauthorized
end
end
end
end
end
# app/controllers/api/v1/base_controller.rb (JWT version)
def authenticate_request
header = request.headers["Authorization"]
token = header&.split(" ")&.last
payload = JwtService.decode(token)
if payload
@current_user = User.find_by(id: payload[:user_id])
end
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
end
Rate Limiting with Rack::Attack
# config/initializers/rack_attack.rb
class Rack::Attack
# Throttle general API requests by IP
throttle("api/ip", limit: 300, period: 5.minutes) do |req|
req.ip if req.path.start_with?("/api/")
end
# Stricter throttle for authentication endpoints
throttle("api/auth", limit: 5, period: 1.minute) do |req|
req.ip if req.path == "/api/v1/session" && req.post?
end
# Throttle by API token for authenticated users
throttle("api/token", limit: 1000, period: 1.hour) do |req|
req.env["HTTP_AUTHORIZATION"]&.split(" ")&.last if req.path.start_with?("/api/")
end
# Custom response
self.throttled_responder = lambda do |req|
retry_after = (req.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} seconds." }.to_json]
]
end
end
CORS Configuration
# Gemfile
gem "rack-cors"
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins ENV.fetch("ALLOWED_ORIGINS", "http://localhost:3000").split(",")
resource "/api/*",
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
expose: ["X-Total-Count", "X-Page", "X-Per-Page"],
max_age: 600
end
end
API Error Envelope
# app/controllers/concerns/api_response.rb
module ApiResponse
extend ActiveSupport::Concern
def render_success(data, status: :ok, meta: {})
response = { data: data }
response[:meta] = meta if meta.present?
render json: response, status: status
end
def render_error(message, status: :bad_request, details: nil)
response = { error: { message: message } }
response[:error][:details] = details if details
render json: response, status: status
end
def render_created(data)
render_success(data, status: :created)
end
end
Best Practices
- Use consistent response envelopes (
{ data: ..., meta: ... }for success,{ error: ... }for failures). - Version your API from day one using URL namespacing (
/api/v1/). - Return appropriate HTTP status codes: 200, 201, 204 for success; 400, 401, 403, 404, 422, 429 for errors.
- Use
includesandselectto avoid N+1 queries and over-fetching columns in list endpoints. - Paginate all list endpoints. Return pagination metadata in the response body or headers.
- Use
status: :no_content(204) for successful DELETE responses with no body. - Keep serialization logic out of controllers. Use dedicated serializer classes or gems like
albaorblueprinter. - Document your API with OpenAPI/Swagger using
rswagor similar tools.
Common Pitfalls
- Exposing internal errors: Never render raw exception messages in production. Use rescue handlers to return safe error messages.
- Missing authentication on endpoints: Audit routes to ensure all sensitive endpoints require authentication.
- No rate limiting: Unthrottled APIs are vulnerable to abuse. Add rate limiting early.
- Inconsistent response formats: Mixing bare arrays, objects, and envelopes confuses API consumers. Pick a convention and enforce it.
- Not handling CORS: Frontend clients on different origins will fail silently without proper CORS headers.
- Over-serializing: Returning entire model attributes including sensitive fields. Always use explicit serializers that whitelist fields.
Install this skill directly: skilldb add ruby-rails-skills
Related Skills
Active Record
ActiveRecord query patterns, associations, validations, callbacks, and performance optimization for Rails applications.
Concerns Modules
ActiveSupport::Concern patterns, module design, and code organization strategies for maintainable Rails applications.
Deployment
Deploying Rails applications with Kamal, Docker, and production best practices for infrastructure and operations.
Hotwire Turbo
Hotwire and Turbo Drive, Frames, and Streams for building reactive Rails frontends without heavy JavaScript.
Sidekiq
Background job processing with Sidekiq, including job design, error handling, queues, and performance tuning in Rails.
Stimulus
Stimulus.js controller patterns for adding interactive behavior to server-rendered Rails HTML.