home / skills / thibautbaissac / rails_ai_agents / api-versioning

api-versioning skill

/skills/api-versioning

This skill guides you to implement robust API versioning in Rails, improving backwards compatibility and clear endpoint organization.

npx playbooks add skill thibautbaissac/rails_ai_agents --skill api-versioning

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
7.6 KB
---
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.
allowed-tools: Read, Write, Edit, Bash
---

# 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

```ruby
# 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

    # v2 with changes
    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

```ruby
# app/controllers/api/base_controller.rb
module Api
  class BaseController < ApplicationController
    # Skip CSRF for API requests
    skip_before_action :verify_authenticity_token

    # Respond with JSON by default
    respond_to :json

    # Handle common errors
    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

```ruby
# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < Api::BaseController
      # V1-specific configuration
    end
  end
end
```

### Resource Controller

```ruby
# 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
```

## Response Format

### Standard JSON Response

```json
{
  "data": {
    "id": 1,
    "type": "user",
    "attributes": {
      "name": "John Doe",
      "email": "[email protected]",
      "created_at": "2024-01-15T10:30:00Z"
    }
  }
}
```

### Collection Response

```json
{
  "data": [
    { "id": 1, "type": "user", "attributes": { ... } },
    { "id": 2, "type": "user", "attributes": { ... } }
  ],
  "meta": {
    "current_page": 1,
    "total_pages": 10,
    "total_count": 100
  }
}
```

### Error Response

```json
{
  "error": "Record not found",
  "code": "not_found"
}

{
  "errors": {
    "email": ["has already been taken"],
    "name": ["can't be blank"]
  }
}
```

## Testing APIs

### Request Spec Template

```ruby
# 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 '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['data']['id']).to eq(user.id)
    end

    context 'when user not found' do
      it 'returns 404' do
        get '/api/v1/users/999999', headers: headers

        expect(response).to have_http_status(:not_found)
      end
    end
  end

  describe 'POST /api/v1/users' do
    let(:valid_params) { { user: { name: 'Test', email: '[email protected]' } } }

    it 'creates a user' do
      expect {
        post '/api/v1/users', params: valid_params.to_json, headers: headers
      }.to change(User, :count).by(1)

      expect(response).to have_http_status(:created)
    end

    context 'with invalid params' do
      let(:invalid_params) { { user: { name: '', email: '' } } }

      it 'returns validation errors' do
        post '/api/v1/users', params: invalid_params.to_json, headers: headers

        expect(response).to have_http_status(:unprocessable_entity)
        expect(json_response['errors']).to be_present
      end
    end
  end

  # Helper method
  def json_response
    JSON.parse(response.body)
  end
end
```

## API Authentication

### Token-Based Auth

```ruby
# 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
```

### JWT Authentication

```ruby
# Using 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
```

## 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
```

Overview

This skill implements RESTful API design with explicit versioning and request specs for Rails applications. It provides a clear directory layout, base controllers with error handling, standard JSON response formats, and test templates to follow TDD practices. Use it to add new endpoints, evolve API versions safely, and keep backward compatibility.

How this skill works

The skill scaffolds namespaced routes (e.g. /api/v1) and organizes controllers under versioned modules. It supplies a shared API base controller that centralizes JSON responses, error handlers, and optional token or JWT authentication, plus version-specific base controllers for per-version behavior. Request spec templates validate endpoints, pagination, error handling, and common edge cases.

When to use it

  • Building a new JSON REST API in Rails
  • Adding or changing API endpoints while preserving old behavior
  • Introducing a new API version with breaking changes
  • Writing request specs and following TDD for API endpoints
  • Adding authentication for API clients

Best practices

  • Prefer URL path versioning (easiest to understand and route) for public APIs
  • Keep a single Api::BaseController for shared concerns and per-version base controllers for differences
  • Return consistent JSON shapes: data, attributes, and meta for collections
  • Handle common exceptions globally (RecordNotFound, RecordInvalid, ParameterMissing) with proper HTTP status codes
  • Write request specs for happy paths and error scenarios, including pagination and auth

Example use cases

  • Create /api/v1/users and /api/v2/users to introduce a changed user representation without breaking v1 clients
  • Add token or JWT authentication to Api::BaseController and test unauthorized responses in request specs
  • Implement paginated list endpoints that return data plus meta with current_page and total_pages
  • Standardize error responses for validation and not found errors so clients can parse failures consistently
  • Use version-specific base controllers to introduce new behavior for v2 while sharing common logic in Api::BaseController

FAQ

Which versioning strategy should I choose?

Use URL path versioning for most Rails APIs: it’s explicit, easy to route, and straightforward for clients.

How do I keep backward compatibility when changing an endpoint?

Create a new version namespace (e.g. v2) with updated controllers and preserve the v1 controllers; migrate clients gradually and document changes.