| name | api-versioning |
| description | Implements RESTful API design with versioning and request specs. Use when building APIs, adding API endpoints, versioning APIs, or when user mentions REST, JSON API, or API design. WHEN NOT: Internal-only endpoints, HTML views, Turbo Stream responses, or APIs without external consumers. |
| paths | app/controllers/api/**/*.rb, spec/requests/api/**/*.rb |
API Versioning for Rails
Overview
Well-structured APIs need versioning for backwards compatibility and clear organization.
Versioning Strategies
| Strategy | URL Example | Header Example |
|---|
| URL Path | /api/v1/users | - |
| Query Param | /api/users?version=1 | - |
| Header | /api/users | Accept: application/vnd.api+json; version=1 |
| Accept Header | /api/users | Accept: application/vnd.myapp.v1+json |
Recommended: URL Path versioning (most common, easiest to understand)
Quick Setup
Routes
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users, only: [:index, :show, :create, :update, :destroy]
resources :posts, only: [:index, :show, :create]
end
namespace :v2 do
resources :users, only: [:index, :show, :create, :update, :destroy]
end
end
end
Directory Structure
app/controllers/
├── api/
│ ├── base_controller.rb # Shared API logic
│ ├── v1/
│ │ ├── base_controller.rb # V1 base
│ │ ├── users_controller.rb
│ │ └── posts_controller.rb
│ └── v2/
│ ├── base_controller.rb # V2 base
│ └── users_controller.rb
Base Controller
module Api
class BaseController < ApplicationController
skip_before_action :verify_authenticity_token
respond_to :json
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def not_found(exception)
render json: { error: exception.message }, status: :not_found
end
def unprocessable_entity(exception)
render json: { errors: exception.record.errors }, status: :unprocessable_entity
end
def bad_request(exception)
render json: { error: exception.message }, status:
Version Base Controller
module Api
module V1
class BaseController < Api::BaseController
end
end
end
Resource Controller
module Api
module V1
class UsersController < BaseController
before_action :set_user, only: [:show, :update, :destroy]
def index
@users = User.page(params[:page]).per(25)
render json: {
data: @users,
meta: pagination_meta(@users)
}
end
def show
render json: { data: @user }
end
def create
@user = User.create!(user_params)
render json: { data: @user }, status: :created
end
def update
@user.update!(user_params)
render json: { data: @user }
end
def destroy
@user.destroy
head
= .find(params[])
params.().permit(, )
()
{
collection.current_page,
collection.total_pages,
collection.total_count
}
Response Format
Standard JSON Response
{
"data": {
"id": 1,
"type": "user",
"attributes": {
"name": "John Doe",
"email": "john@example.com",
"created_at": "2024-01-15T10:30:00Z"
}
}
}
Collection Response
{
"data": [
{ "id": 1, "type": "user", "attributes": { ... } },
{ "id": 2, "type": "user", "attributes": { ... } }
],
"meta": {
"current_page": 1,
"total_pages": 10,
"total_count": 100
}
}
Error Response
{
"error": "Record not found",
"code": "not_found"
}
{
"errors": {
"email": ["has already been taken"],
"name": ["can't be blank"]
}
}
Testing APIs
Request Spec Template
require 'rails_helper'
RSpec.describe 'Api::V1::Users', type: :request do
let(:headers) { { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } }
describe 'GET /api/v1/users' do
let!(:users) { create_list(:user, 3) }
it 'returns all users' do
get '/api/v1/users', headers: headers
expect(response).to have_http_status(:ok)
expect(json_response['data'].size).to eq(3)
end
it 'returns paginated results' do
get '/api/v1/users', params: { page: 1 }, headers: headers
expect(json_response['meta']).to include('current_page', 'total_pages')
end
end
describe 'GET /api/v1/users/:id' do
let(:user) { create(:user) }
it 'returns the user' do
get "/api/v1/users/#{user.id}", headers: headers
expect(response).to have_http_status(:ok)
expect(json_response[][]).to eq(user.id)
context
it
get , headers
expect(response).to have_http_status()
describe
let() { { { , } } }
it
expect {
post , valid_params.to_json, headers
}.to change(, ).by()
expect(response).to have_http_status()
context
let() { { { , } } }
it
post , invalid_params.to_json, headers
expect(response).to have_http_status()
expect(json_response[]).to be_present
.parse(response.body)
API Authentication
Token-Based Auth
module Api
class BaseController < ApplicationController
before_action :authenticate_api_user!
private
def authenticate_api_user!
token = request.headers['Authorization']&.split(' ')&.last
@current_api_user = User.find_by(api_token: token)
render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_api_user
end
def current_api_user
@current_api_user
end
end
end
JWT Authentication
def authenticate_api_user!
token = request.headers['Authorization']&.split(' ')&.last
return unauthorized unless token
payload = JWT.decode(token, Rails.application.secret_key_base).first
@current_api_user = User.find(payload['user_id'])
rescue JWT::DecodeError
unauthorized
end
def unauthorized
render json: { error: 'Unauthorized' }, status: :unauthorized
end
Workflow Checklist
API Implementation:
- [ ] Define routes in namespace
- [ ] Create base controller with error handling
- [ ] Create version-specific base controller
- [ ] Create resource controller
- [ ] Add authentication (if needed)
- [ ] Write request specs
- [ ] Document API endpoints