Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :articles, only: [:index, :show, :create, :update, :destroy]
resources :sessions, only: [:create]
end
end
end
module Api
module V1
class BaseController < ActionController::API
respond_to :json
before_action :authenticate_request!
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
rescue_from ActionController::ParameterMissing, with: :bad_request
attr_reader :current_user
private
def authenticate_request!
token = request.headers["Authorization"].to_s.remove("Bearer ").strip
@current_user = User.find_by(api_token: token) if token.present?
return if @current_user
render_error("Invalid or missing API token", status: :unauthorized)
end
def render_success(data, status: :ok, meta: {})
payload = { data: data }
payload[:meta] = meta if meta.present?
render json: payload, status: status
end
def render_error(message, status:, details: nil)
body = { error: { message: message, code: status } }
body[:error][:details] = details if details
render json: body, status: status
end
def not_found(exception)
render_error(exception.message, status: :not_found)
end
def unprocessable_entity(exception)
render_error("Validation failed", status: :unprocessable_entity,
details: exception.record.errors.full_messages)
end
def bad_request(exception)
render_error(exception.message, status: :bad_request)
end
end
end
end
module Api
module V1
class ArticlesController < BaseController
MAX_PER_PAGE = 100
def index
page = params.fetch(:page, 1).to_i
per_page = [params.fetch(:per_page, 25).to_i, MAX_PER_PAGE].min
scope = Article.published.order(published_at: :desc)
articles = scope.offset((page - 1) * per_page).limit(per_page)
render_success(
articles.map { |a| serialize(a) },
meta: { page: page, per_page: per_page, total: scope.count }
)
end
def show
article = Article.find(params[:id])
render_success(serialize(article))
end
def create
article = current_user.articles.create!(article_params)
render_success(serialize(article), status: :created)
end
def update
article = current_user.articles.find(params[:id])
article.update!(article_params)
render_success(serialize(article))
end
def destroy
current_user.articles.find(params[:id]).destroy!
head :no_content
end
private
def article_params
params.require(:article).permit(:title, :body, :published_at)
end
def serialize(article)
{
id: article.id,
title: article.title,
body: article.body,
author_id: article.user_id,
published_at: article.published_at&.iso8601
}
end
end
end
end
A versioned API namespace keeps breaking changes contained: clients pin to /api/v1, and a future /api/v2 can evolve independently without disturbing existing consumers. The pattern shown here centralizes all cross-cutting concerns — authentication, error translation, and response shaping — into a single base controller so that every endpoint in the version inherits consistent behavior.
In routes.rb, the routes are nested under namespace :api with defaults: { format: :json }, then a further namespace :v1. This produces module-scoped controllers like Api::V1::ArticlesController and URL paths like /api/v1/articles. Forcing the JSON format at the namespace level means the API never tries to render HTML or accidentally serves an ERB template.
Api::V1::BaseController is the heart of the design. It inherits from ActionController::API rather than ActionController::Base, dropping view rendering, cookies, and CSRF middleware that a token API does not need. It declares respond_to :json, authenticates every request through a before_action that reads a bearer token, and wires up rescue_from handlers. The rescue_from blocks translate common exceptions — ActiveRecord::RecordNotFound, RecordInvalid, and ActionController::ParameterMissing — into structured JSON with the right HTTP status, so controllers can call find or save! and trust that failures become clean 404 or 422 responses. The render_error and render_success helpers standardize the envelope every endpoint returns.
Api::V1::ArticlesController then reads like plain business logic. Because the base handles auth and errors, show simply calls find, and create uses create! with strong parameters; the bang methods raise, and the base controller catches. index demonstrates lightweight pagination with page and per_page params, capping page size to protect the database.
The main trade-off is inheritance coupling: every controller depends on the base, so shared behavior must stay genuinely universal or be split into concerns. Namespacing by version also duplicates some code across v1 and v2 over time, which teams usually accept as the price of stable, independently deployable API contracts. This structure scales well because new endpoints inherit correctness by default.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.