Agent Skills: API Patterns

>-

UncategorizedID: thibautbaissac/rails_ai_agents/api-patterns

Install this agent skill to your local

pnpm dlx add-skill https://github.com/ThibautBaissac/rails_ai_agents/tree/HEAD/.claude_37signals/skills/api-patterns

Skill Files

Browse the full folder contents for api-patterns.

Download Skill

Loading file tree…

.claude_37signals/skills/api-patterns/SKILL.md

Skill Metadata

Name
api-patterns
Description
>-

API Patterns

Philosophy: Same Controllers, Different Formats

  • One controller serves both HTML (web) and JSON (API)
  • Use respond_to blocks for format-specific responses
  • RESTful routes only (no GraphQL, no custom endpoints unless necessary)
  • Jbuilder for JSON templates (like ERB for HTML)
  • HTTP status codes for success/errors
  • Token-based authentication for API (not OAuth unless required)

Project Knowledge

Stack: Jbuilder for JSON views, RESTful routes, token-based API auth, same controllers for HTML and JSON, HTTP caching with ETags for API.

Multi-tenancy: API uses same account scoping (/accounts/:account_id/...), token scoped to account.

Commands:

# Generate API token model
rails generate model ApiToken user:references account:references \
  token:string last_used_at:datetime

# Test API endpoints
curl -H "Authorization: Bearer TOKEN" \
     -H "Accept: application/json" \
     http://localhost:3000/boards

Pattern 1: Respond To Blocks

class BoardsController < ApplicationController
  before_action :set_board, only: [:show, :edit, :update, :destroy]

  def index
    @boards = Current.account.boards.includes(:creator).order(created_at: :desc)

    respond_to do |format|
      format.html  # renders index.html.erb
      format.json  # renders index.json.jbuilder
    end
  end

  def show
    respond_to do |format|
      format.html
      format.json
    end
  end

  def create
    @board = Current.account.boards.build(board_params)
    @board.creator = Current.user

    respond_to do |format|
      if @board.save
        format.html { redirect_to @board, notice: "Board created" }
        format.json { render :show, status: :created, location: @board }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @board.errors, status: :unprocessable_entity }
      end
    end
  end

  def update
    respond_to do |format|
      if @board.update(board_params)
        format.html { redirect_to @board, notice: "Board updated" }
        format.json { render :show, status: :ok }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @board.errors, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @board.destroy

    respond_to do |format|
      format.html { redirect_to boards_path, notice: "Board deleted" }
      format.json { head :no_content }
    end
  end

  private

  def set_board
    @board = Current.account.boards.find(params[:id])
  end

  def board_params
    params.require(:board).permit(:name, :description)
  end
end

Pattern 2: Jbuilder Templates

See @references/jbuilder-templates.md for full details.

# app/views/boards/index.json.jbuilder
json.array! @boards do |board|
  json.extract! board, :id, :name, :description, :created_at, :updated_at
  json.creator do
    json.extract! board.creator, :id, :name, :email
  end
  json.url board_url(board, format: :json)
end

# app/views/boards/show.json.jbuilder
json.extract! @board, :id, :name, :description, :created_at, :updated_at
json.creator do
  json.extract! @board.creator, :id, :name, :email
end
json.cards @board.cards, partial: "cards/card", as: :card
json.url board_url(@board, format: :json)

# app/views/cards/_card.json.jbuilder
json.extract! card, :id, :title, :description, :created_at, :updated_at
json.creator do
  json.extract! card.creator, :id, :name
end
json.url board_card_url(card.board, card, format: :json)

Pattern 3: API Token Authentication

See @references/api-auth.md for full details.

# app/models/api_token.rb
class ApiToken < ApplicationRecord
  belongs_to :user
  belongs_to :account

  has_secure_token :token, length: 32

  validates :name, presence: true
  validates :token, presence: true, uniqueness: true

  scope :active, -> { where(active: true) }

  def use!
    touch(:last_used_at)
  end
end

# app/controllers/concerns/api_authenticatable.rb
module ApiAuthenticatable
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_from_token, if: :api_request?
  end

  private

  def api_request?
    request.format.json?
  end

  def authenticate_from_token
    token = request.headers["Authorization"]
      &.match(/Bearer (.+)/)&.captures&.first

    if token
      @api_token = ApiToken.active.find_by(token: token)
      if @api_token
        @api_token.use!
        Current.user = @api_token.user
        Current.account = @api_token.account
      else
        render json: { error: "Unauthorized" }, status: :unauthorized
      end
    else
      render json: { error: "Unauthorized" }, status: :unauthorized
    end
  end
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include ApiAuthenticatable

  skip_before_action :verify_authenticity_token, if: :api_request?
  before_action :authenticate_user!, unless: :api_request?
end

Pattern 4: Error Handling

module ApiErrorHandling
  extend ActiveSupport::Concern

  included do
    rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
    rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity
    rescue_from ActionController::ParameterMissing, with: :render_bad_request
  end

  private

  def render_not_found(exception)
    respond_to do |format|
      format.html { raise exception }
      format.json { render json: { error: "Not found" }, status: :not_found }
    end
  end

  def render_unprocessable_entity(exception)
    respond_to do |format|
      format.html { raise exception }
      format.json do
        render json: {
          error: "Validation failed",
          details: exception.record.errors.as_json
        }, status: :unprocessable_entity
      end
    end
  end

  def render_bad_request(exception)
    respond_to do |format|
      format.html { raise exception }
      format.json do
        render json: { error: "Bad request", message: exception.message },
               status: :bad_request
      end
    end
  end
end

Pattern 5: HTTP Caching for API

def index
  @boards = Current.account.boards.includes(:creator).order(created_at: :desc)

  respond_to do |format|
    format.html
    format.json do
      if stale?(@boards)
        render :index
      end
    end
  end
end

def show
  @board = Current.account.boards.find(params[:id])

  respond_to do |format|
    format.html
    format.json do
      if stale?(@board)
        render :show
      end
    end
  end
end

Pattern 6: Pagination

See @references/api-versioning.md for versioning details.

def index
  @boards = Current.account.boards.includes(:creator)
    .order(created_at: :desc)
    .page(params[:page])
    .per(params[:per_page] || 25)

  respond_to do |format|
    format.html
    format.json do
      response.headers["X-Total-Count"] = @boards.total_count.to_s
      response.headers["X-Page"] = @boards.current_page.to_s
      response.headers["X-Per-Page"] = @boards.limit_value.to_s
      render :index
    end
  end
end

# app/views/boards/index.json.jbuilder
json.boards @boards do |board|
  json.extract! board, :id, :name, :description, :created_at
  json.url board_url(board, format: :json)
end

json.pagination do
  json.current_page @boards.current_page
  json.per_page @boards.limit_value
  json.total_pages @boards.total_pages
  json.total_count @boards.total_count
  json.next_page boards_url(page: @boards.next_page, format: :json) if @boards.next_page
  json.prev_page boards_url(page: @boards.prev_page, format: :json) if @boards.prev_page
end

Cursor-based alternative:

def index
  @boards = Current.account.boards.order(created_at: :desc)
  @boards = @boards.where("created_at < ?", Time.zone.parse(params[:before])) if params[:before]
  @boards = @boards.limit(params[:limit] || 25)

  respond_to do |format|
    format.html
    format.json
  end
end

Pattern 7: Batch Operations

class Cards::BatchController < ApplicationController
  before_action :set_board

  def update
    results, errors = [], []

    batch_params[:cards].each do |card_params|
      card = @board.cards.find(card_params[:id])
      if card.update(card_params.except(:id))
        results << card
      else
        errors << { id: card.id, errors: card.errors }
      end
    end

    respond_to do |format|
      format.json do
        if errors.empty?
          render json: { success: true, cards: results }, status: :ok
        else
          render json: { success: false, errors: errors }, status: :unprocessable_entity
        end
      end
    end
  end
end

Boundaries

Always

  • Use same controllers for HTML and JSON (respond_to blocks)
  • Use Jbuilder for JSON views (not inline JSON in controllers)
  • Return proper HTTP status codes (201, 404, 422, etc.)
  • Implement token-based authentication for API
  • Include resource URLs in JSON responses
  • Scope all API requests to Current.account
  • Use ETags for HTTP caching
  • Test both HTML and JSON responses

Ask First

  • Whether to version API (most apps don't need it initially)
  • Pagination strategy (page-based vs cursor-based)
  • Whether to support batch operations
  • Rate limiting requirements

Never

  • Use GraphQL (stick to REST)
  • Create separate API controllers when respond_to works
  • Use Active Model Serializers (use Jbuilder)
  • Inline JSON in controllers (use views)
  • Skip authentication for API endpoints
  • Return HTML errors for JSON requests
  • Use session-based auth for API (use tokens)