.claude_37signals/skills/api-patterns/SKILL.md
Builds REST APIs using respond_to blocks with Jbuilder templates following the 37signals same-controllers-different-formats philosophy. Use when adding API endpoints, JSON responses, token authentication, pagination, or when user mentions API, JSON, REST, or Jbuilder. WHEN NOT: For HTML-only controllers (use crud-patterns), for webhook delivery (use event-tracking).
npx skillsauth add ThibautBaissac/rails_ai_agents api-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
respond_to blocks for format-specific responsesStack: 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
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
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)
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
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
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
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
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
respond_to blocks)Current.accountrespond_to worksdevelopment
Creates Turbo Streams, Turbo Frames, and morphing patterns for real-time UI updates. Use when adding real-time updates, partial page rendering, form submissions, or broadcasting. WHEN NOT: For Stimulus JavaScript controllers (see stimulus-patterns skill). For general view conventions (see rules/views.md).
testing
Writes Minitest tests with fixtures following 37signals conventions. Uses Minitest (not RSpec) and fixtures (not factories). Use when writing tests, adding test coverage, or creating fixtures. WHEN NOT: For RSpec or FactoryBot patterns (this project uses Minitest + fixtures exclusively). For test configuration/CI setup (see project docs).
tools
Builds focused, single-purpose Stimulus controllers for progressive enhancement. Use when adding JavaScript behavior, UI interactions, form enhancements, or building reusable client-side components. WHEN NOT: For Turbo Stream/Frame patterns (see turbo-patterns skill). For server-side view logic (see rules/views.md).
testing
Implements the state-as-records-not-booleans pattern for rich state tracking. Use when modeling state changes, replacing boolean flags with record-based state, or when user mentions state records, closures, publications, or toggling state. WHEN NOT: Technical flags like cached/processed (use booleans), concern extraction (use concern-patterns), general model work (use model-patterns).