api-versioning
SKILL.md
API Versioning for Rails 8
Overview
Well-structured APIs need versioning for backwards compatibility and clear organization.
Recommended: URL Path versioning (/api/v1/users)
Quick Setup
Routes
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users, only: [:index, :show, :create, :update, :destroy]
resources :events, only: [:index, :show, :create]
end
end
end
Directory Structure
app/controllers/
├── api/
│ ├── base_controller.rb
│ ├── v1/
│ │ ├── base_controller.rb
│ │ ├── users_controller.rb
│ │ └── events_controller.rb
│ └── v2/
│ ├── base_controller.rb
│ └── users_controller.rb
Base Controller
# app/controllers/api/base_controller.rb
module Api
class BaseController < ApplicationController
skip_before_action :verify_authenticity_token
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
Version Base Controller
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < Api::BaseController
end
end
end
Resource Controller
# 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, 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
API Authentication
Bearer Token Auth
# 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 = Session.find_by(token: token)&.user
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_api_user
end
def current_api_user
@current_api_user
end
end
end
Response Format
// Success (single)
{ "data": { "id": 1, "name": "John", "email": "john@example.com" } }
// Success (collection)
{ "data": [...], "meta": { "current_page": 1, "total_pages": 10 } }
// Error
{ "error": "Record not found" }
// Validation errors
{ "errors": { "email": ["has already been taken"] } }
Testing APIs (Minitest)
Request Test Template
# test/controllers/api/v1/users_controller_test.rb
require "test_helper"
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
@headers = {
"Accept" => "application/json",
"Content-Type" => "application/json",
"Authorization" => "Bearer #{api_token_for(@user)}"
}
end
# -- index --
test "GET /api/v1/users returns all users" do
get "/api/v1/users", headers: @headers
assert_response :success
data = json_response["data"]
assert_kind_of Array, data
end
# -- show --
test "GET /api/v1/users/:id returns the user" do
get "/api/v1/users/#{@user.id}", headers: @headers
assert_response :success
assert_equal @user.id, json_response["data"]["id"]
end
test "GET /api/v1/users/:id returns 404 for missing user" do
get "/api/v1/users/999999", headers: @headers
assert_response :not_found
assert json_response["error"].present?
end
# -- create --
test "POST /api/v1/users creates a user" do
params = { user: { name: "New User", email: "new@example.com" } }
assert_difference("User.count", 1) do
post "/api/v1/users", params: params.to_json, headers: @headers
end
assert_response :created
end
test "POST /api/v1/users with invalid params returns errors" do
params = { user: { name: "", email: "" } }
assert_no_difference("User.count") do
post "/api/v1/users", params: params.to_json, headers: @headers
end
assert_response :unprocessable_entity
assert json_response["errors"].present?
end
# -- update --
test "PATCH /api/v1/users/:id updates the user" do
params = { user: { name: "Updated" } }
patch "/api/v1/users/#{@user.id}", params: params.to_json, headers: @headers
assert_response :success
assert_equal "Updated", @user.reload.name
end
# -- destroy --
test "DELETE /api/v1/users/:id destroys the user" do
assert_difference("User.count", -1) do
delete "/api/v1/users/#{@user.id}", headers: @headers
end
assert_response :no_content
end
# -- authentication --
test "returns 401 without token" do
get "/api/v1/users", headers: { "Accept" => "application/json" }
assert_response :unauthorized
end
private
def json_response
JSON.parse(response.body)
end
def api_token_for(user)
user.sessions.create!.token
end
end
Checklist
- Routes namespaced under
api/v1 - Base controller with error handling
- Authentication configured
- Standard response format
- Request tests written
- 404/422/401 error cases tested
- All tests GREEN
Weekly Installs
2
Repository
dchuk/rails_ai_agentsFirst Seen
7 days ago
Security Audits
Installed on
opencode2
gemini-cli2
antigravity2
claude-code2
windsurf2
codex2