Implement RESTful API versioning with namespace-based routing, versioned controllers, authentication, and deprecation strategies
/api/v1/ is the most common, most debuggable, and easiest to route. Use Accept header versioning only when URL aesthetics or hypermedia constraints demand it.Api::BaseController for consistent error handling, authentication, and response format.{ data: ..., meta: ... }{ error: ..., errors: ... }Deprecation response headers and documentation warnings for at least two release cycles before sunsetting a version.| Strategy | URL Example | Header Example | Tradeoff |
|---|---|---|---|
| URL Path | /api/v1/users | — | Simple, visible, easy caching |
| Accept Header | /api/users | Accept: application/vnd.myapp.v1+json | Clean URLs, harder to test |
| Query Param | /api/users?version=1 | — | Fragile, not recommended |
Recommendation: Start with URL path versioning. Only move to Accept header versioning if you have a strong reason.
# app/controllers/api/base_controller.rb
module Api
class BaseController < ApplicationController
before_action :set_api_version
private
def set_api_version
accept = request.headers["Accept"] || ""
match = accept.match(/application\/vnd\.myapp\.v(\d+)\+json/)
@api_version = match ? match[1].to_i : 1
end
end
end
# config/routes.rb
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
app/controllers/
api/
base_controller.rb # Shared API logic (auth, error handling)
v1/
base_controller.rb # V1-specific config
users_controller.rb
posts_controller.rb
v2/
base_controller.rb # V2-specific config
users_controller.rb
# app/controllers/api/base_controller.rb
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: :bad_request
end
end
end
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < Api::BaseController
# V1-specific configuration
end
end
end
# app/controllers/api/v1/users_controller.rb
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.as_json(only: [:id, :name, :email, :created_at]),
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 :no_content
end
private
def set_user
@user = User.find(params[:id])
end
def user_params
params.require(:user).permit(:name, :email)
end
def pagination_meta(collection)
{
current_page: collection.current_page,
total_pages: collection.total_pages,
total_count: collection.total_count
}
end
end
end
end
Stick to two shapes — success and error — across all versions:
// Single resource
{ "data": { "id": 1, "type": "user", "attributes": { "name": "John", "email": "[email protected]" } } }
// Collection with pagination
{ "data": [{ "id": 1, "type": "user", "attributes": { "name": "John" } }],
"meta": { "current_page": 1, "total_pages": 10, "total_count": 100 } }
// Error responses
{ "error": "Record not found", "code": "not_found" }
{ "errors": { "email": ["has already been taken"], "name": ["can't be blank"] } }
# app/controllers/api/base_controller.rb
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
# Using the jwt gem
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
def authenticate_api_user!
api_key = request.headers["X-API-Key"]
@current_api_user = User.find_by(api_key: api_key)
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_api_user
end
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < Api::BaseController
before_action :add_deprecation_header
private
def add_deprecation_header
response.set_header("Deprecation", "true")
response.set_header("Sunset", "2025-06-01")
response.set_header("Link", "</api/v2>; rel=\"successor-version\"")
end
end
end
end
Deprecation headers, update docs, notify consumers410 Gone for old endpoints# spec/requests/api/v1/users_spec.rb
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 "POST /api/v1/users" do
it "creates a user" do
expect {
post "/api/v1/users",
params: { user: { name: "Test", email: "[email protected]" } }.to_json,
headers: headers
}.to change(User, :count).by(1)
expect(response).to have_http_status(:created)
end
it "returns validation errors for invalid params" do
post "/api/v1/users",
params: { user: { name: "", email: "" } }.to_json, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response["errors"]).to be_present
end
end
def json_response = JSON.parse(response.body)
end
skip_before_action :verify_authenticity_token or requests from non-browser clients will fail with 422.Deprecation/Sunset headers and consumer notification, you will break integrations with no warning.namespace :api do; namespace :v1 do patternApi::BaseController inherits from ApplicationController with JSON error handlingApi::V1::BaseController{ data: ... } or { error: ... } JSONbefore_actionbullet or log)Deprecation and Sunset headersrake routes | grep api shows expected versioned paths