From rails-cto
How to build RESTful JSON APIs in a Rails 8 application following OpenAPI standards. Use when creating, modifying, or debugging API endpoints, serializers, authentication, documentation, or any code under the Api:: namespace. Also use when the user mentions "api", "endpoint", "api key", "serializer", "openapi", "swagger", "api documentation", "cors", "rate limit", "pagination cursor", "api versioning", or asks about exposing data to external consumers. Proactively apply these rules whenever touching API controllers, routes, or serializers, even if the user doesn't explicitly ask.
How this skill is triggered — by the user, by Claude, or both
Slash command
/rails-cto:rails-cto-apiThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This project exposes a JSON API under the `Api::V1::` namespace for external consumers. All API code follows OpenAPI 3.x standards so that documentation stays accurate, clients can be auto-generated, and the API behaves predictably for third-party integrators.
This project exposes a JSON API under the Api::V1:: namespace for external consumers. All API code follows OpenAPI 3.x standards so that documentation stays accurate, clients can be auto-generated, and the API behaves predictably for third-party integrators.
app/
├── controllers/
│ └── api/
│ └── v1/
│ ├── base_controller.rb # Auth, error handling, pagination
│ ├── posts_controller.rb
│ └── ...
├── serializers/
│ └── api/
│ └── v1/
│ ├── post_serializer.rb
│ └── ...
└── services/ # Reuse existing service objects
└── posts/
├── create.rb
└── ...
config/
├── initializers/
│ ├── cors.rb # Rack::Cors configuration
│ └── rate_limit.rb # Rack::Attack configuration
└── routes.rb # API namespace and versioned routes
API controllers are a thin interface to existing service objects — they handle authentication, serialization, and HTTP semantics, but delegate business logic to the same services the web app uses. This avoids duplicating logic across two interfaces.
All API endpoints live under a URL-based version prefix. This makes the version visible in every request and easy to reason about for external consumers.
# config/routes.rb
namespace :api do
namespace :v1 do
resources :posts, only: [:index, :show, :create, :update, :destroy]
resources :collections, only: [:index, :show]
# Add resources as needed
end
end
This produces routes like /api/v1/posts, /api/v1/posts/:id, etc.
When introducing a breaking change in the future, create Api::V2:: alongside V1 rather than modifying V1. Non-breaking additions (new fields, new endpoints) can go into the existing version.
Every API controller inherits from a base controller that centralizes authentication, error handling, and response helpers.
# frozen_string_literal: true
# Base controller for all API V1 endpoints. Handles authentication,
# standard error responses, and shared pagination logic so individual
# controllers stay focused on their resource.
class Api::V1::BaseController < ActionController::API
before_action :authenticate_api_key
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
rescue_from ActionController::ParameterMissing, with: :bad_request
private
# Authenticates requests via the X-Api-Key header.
# API keys are stored on the Account model.
def authenticate_api_key
api_key = request.headers["X-Api-Key"]
@current_account = Account.find_by(api_key: api_key)
render_error(status: 401, message: "Invalid or missing API key") unless @current_account
end
attr_reader :current_account
# --- Error response helpers ---
def render_error(status:, message:, details: [])
render json: {
error: {
status: Rack::Utils.status_code(status),
message: message,
details: Array(details)
}
}, status: status
end
def not_found(exception)
render_error(status: 404, message: "Resource not found")
end
def unprocessable_entity(exception)
render_error(
status: 422,
message: "Validation failed",
details: exception.record.errors.full_messages
)
end
def bad_request(exception)
render_error(status: 400, message: exception.message)
end
# --- Pagination helpers ---
def paginate(scope, default_limit: 25, max_limit: 100)
limit = [(params[:limit] || default_limit).to_i, max_limit].min
after_cursor = params[:after]
records = if after_cursor
scope.where("id > ?", decode_cursor(after_cursor))
else
scope
end
records = records.order(id: :asc).limit(limit + 1).to_a
has_next = records.size > limit
records = records.first(limit)
{
records: records,
meta: {
next_cursor: has_next ? encode_cursor(records.last.id) : nil,
has_more: has_next,
limit: limit
}
}
end
def encode_cursor(id)
Base64.urlsafe_encode64(id.to_s)
end
def decode_cursor(cursor)
Base64.urlsafe_decode64(cursor).to_i
rescue ArgumentError
0
end
end
API consumers authenticate with an API key passed in the X-Api-Key header:
GET /api/v1/posts
X-Api-Key: sk_live_abc123def456
Every request without a valid key receives a 401 Unauthorized response. The API key maps to an Account, so all queries are automatically scoped to the correct tenant.
Store API keys on the Account model. Generate them with SecureRandom.hex(32) and prefix them for easy identification (e.g., sk_live_ for production, sk_test_ for sandbox).
API controllers follow the same RESTful conventions as web controllers — standard 7 actions only, thin orchestration, delegate to service objects for complex logic.
# frozen_string_literal: true
# Exposes posts to external API consumers.
# Delegates business logic to existing service objects.
class Api::V1::PostsController < Api::V1::BaseController
before_action :set_post, only: [:show, :update, :destroy]
# GET /api/v1/posts
def index
result = paginate(current_account.posts)
render json: {
data: Api::V1::PostSerializer.new(result[:records]).as_json,
meta: result[:meta]
}
end
# GET /api/v1/posts/:id
def show
render json: { data: Api::V1::PostSerializer.new(@post).as_json }
end
# POST /api/v1/posts
def create
result = Posts::Create.call(
params: post_params,
account: current_account
)
if result.success?
render json: { data: Api::V1::PostSerializer.new(result.post).as_json },
status: :created
else
render_error(status: 422, message: "Validation failed", details: result.errors.message_list)
end
end
# PATCH /api/v1/posts/:id
def update
result = Posts::Update.run(
post: @post,
params: post_params.to_h
)
if result.success?
render json: { data: Api::V1::PostSerializer.new(@post.reload).as_json }
else
render_error(status: 422, message: "Validation failed", details: result.errors.message_list)
end
end
# DELETE /api/v1/posts/:id
def destroy
@post.discard!
head :no_content
end
private
def set_post
@post = current_account.posts.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :url, :description, :collection_id)
end
end
Key points:
current_account — never use unscoped Post.findPosts::Create, Posts::Update, etc.)201 Created for successful creation, 204 No Content for deletiondata key for consistencyUse a serializer gem (alba or blueprinter) to control exactly which fields are exposed. Never return raw ActiveRecord objects — external consumers should see a stable, documented contract.
# frozen_string_literal: true
# Serializes posts for the V1 API. Controls which fields
# are visible to external consumers — adding fields here is a
# public contract change.
class Api::V1::PostSerializer
attr_reader :resource
def initialize(resource)
@resource = resource
end
def as_json
if resource.respond_to?(:map)
resource.map { |record| serialize(record) }
else
serialize(resource)
end
end
private
def serialize(record)
{
id: record.id,
title: record.title,
url: record.url,
description: record.description,
host: record.host,
created_at: record.created_at.iso8601,
updated_at: record.updated_at.iso8601
}
end
end
Guidelines:
iso8601 for timestamps — external consumers need a predictable formatdiscarded_at or embedding_vectorcollection: { id: 1, title: "..." }) rather than flatteningAll successful responses wrap data in a data key. List endpoints also include a meta key for pagination.
{
"data": {
"id": 1,
"title": "Example Post",
"url": "https://example.com",
"created_at": "2026-03-18T12:00:00Z",
"updated_at": "2026-03-18T12:00:00Z"
}
}
{
"data": [
{ "id": 1, "title": "First" },
{ "id": 2, "title": "Second" }
],
"meta": {
"next_cursor": "MjU",
"has_more": true,
"limit": 25
}
}
{
"error": {
"status": 422,
"message": "Validation failed",
"details": ["Title can't be blank", "URL is not valid"]
}
}
Use cursor-based pagination for all list endpoints. Cursor-based pagination performs better than page/offset for large datasets because it doesn't degrade as the offset grows, and it handles records being added or removed between requests.
Clients pass two parameters:
limit — how many records to return (default 25, max 100)after — cursor from a previous response's meta.next_cursorGET /api/v1/posts?limit=10
GET /api/v1/posts?limit=10&after=MjU
The base controller's paginate helper handles this. See the base controller section above for the implementation.
Use rack-attack to protect API endpoints from abuse. Return standard rate limit headers so consumers can self-throttle.
# config/initializers/rate_limit.rb
Rack::Attack.throttle("api/v1", limit: 100, period: 60) do |req|
if req.path.start_with?("/api/v1")
req.env["HTTP_X_API_KEY"]
end
end
Rack::Attack.throttled_responder = lambda do |_env|
now = Time.current
match_data = _env["rack.attack.match_data"]
headers = {
"Content-Type" => "application/json",
"X-RateLimit-Limit" => match_data[:limit].to_s,
"X-RateLimit-Remaining" => "0",
"X-RateLimit-Reset" => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601
}
body = {
error: {
status: 429,
message: "Rate limit exceeded. Try again later.",
details: []
}
}
[429, headers, [body.to_json]]
end
Include rate limit headers on every response via a before_action or middleware so consumers always know their remaining quota.
Configure rack-cors to allow external consumers to call the API from browser-based clients.
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins "*" # Restrict to specific domains in production
resource "/api/*",
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
expose: ["X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset"],
max_age: 600
end
end
In production, replace origins "*" with specific allowed domains. Expose rate limit headers so browser-based consumers can read them.
Maintain the OpenAPI spec as a YAML file and use committee to validate that API responses match the spec in your Minitest integration tests. When tests pass, the spec is accurate.
Add to Gemfile:
group :test do
gem "committee", require: false
gem "committee-rails", require: false
end
Then configure committee in your test helper:
# test/support/api_test_helper.rb
module ApiTestHelper
extend ActiveSupport::Concern
included do
include Committee::Rails::Test::Methods
def committee_options
@committee_options ||= {
schema_path: Rails.root.join("docs", "openapi", "v1.yaml").to_s,
prefix: "/api/v1",
check_header: false
}
end
end
def api_headers(account: nil)
headers = { "Content-Type" => "application/json" }
headers["X-Api-Key"] = account&.api_key if account
headers
end
end
# test/controllers/api/v1/posts_controller_test.rb
require "test_helper"
class Api::V1::PostsControllerTest < ActionDispatch::IntegrationTest
include ApiTestHelper
setup do
@account = accounts(:one)
@post = posts(:one)
end
# --- Authentication ---
test "returns 401 without an API key" do
get api_v1_posts_url, headers: api_headers
assert_response :unauthorized
assert_schema_conform
end
# --- Index ---
test "lists posts for the authenticated account" do
get api_v1_posts_url, headers: api_headers(account: @account)
assert_response :ok
assert_schema_conform
json = JSON.parse(response.body)
assert json.key?("data")
assert json.key?("meta")
end
test "paginates with cursor" do
get api_v1_posts_url,
params: { limit: 1 },
headers: api_headers(account: @account)
assert_response :ok
assert_schema_conform
json = JSON.parse(response.body)
assert_equal 1, json["data"].size
end
# --- Show ---
test "returns a single post" do
get api_v1_post_url(@post), headers: api_headers(account: @account)
assert_response :ok
assert_schema_conform
end
test "returns 404 for a post from another account" do
other_post = posts(:other_account)
get api_v1_post_url(other_post), headers: api_headers(account: @account)
assert_response :not_found
assert_schema_conform
end
# --- Create ---
test "creates a post" do
assert_difference("Post.count") do
post api_v1_posts_url,
params: { post: { url: "https://example.com", title: "New" } }.to_json,
headers: api_headers(account: @account)
end
assert_response :created
assert_schema_conform
end
test "returns 422 with invalid params" do
post api_v1_posts_url,
params: { post: { url: "" } }.to_json,
headers: api_headers(account: @account)
assert_response :unprocessable_entity
assert_schema_conform
end
# --- Update ---
test "updates a post" do
patch api_v1_post_url(@post),
params: { post: { title: "Updated" } }.to_json,
headers: api_headers(account: @account)
assert_response :ok
assert_schema_conform
end
# --- Destroy ---
test "deletes a post" do
delete api_v1_post_url(@post), headers: api_headers(account: @account)
assert_response :no_content
end
end
The key line is assert_schema_conform — committee checks that the response status, content type, and body all match what the OpenAPI spec declares. If your spec says a field is a string but you return an integer, the test fails.
Store the spec at docs/openapi/v1.yaml. This is a hand-maintained file — you own the contract.
# docs/openapi/v1.yaml
openapi: "3.0.3"
info:
title: API V1
version: "1.0"
servers:
- url: /api/v1
paths:
/posts:
get:
summary: List posts
tags: [Posts]
security: [{ api_key: [] }]
parameters:
- name: limit
in: query
schema: { type: integer }
description: Number of records to return (max 100)
- name: after
in: query
schema: { type: string }
description: Cursor for the next page
responses:
"200":
description: Posts retrieved
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Post" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
"401": { $ref: "#/components/responses/Unauthorized" }
post:
summary: Create a post
tags: [Posts]
security: [{ api_key: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
post:
type: object
properties:
title: { type: string }
url: { type: string, format: uri }
description: { type: string }
collection_id: { type: integer }
required: [url]
responses:
"201":
description: Post created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Post" }
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/ValidationFailed" }
/posts/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
get:
summary: Get a post
tags: [Posts]
security: [{ api_key: [] }]
responses:
"200":
description: Post retrieved
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Post" }
"401": { $ref: "#/components/responses/Unauthorized" }
"404": { $ref: "#/components/responses/NotFound" }
patch:
summary: Update a post
tags: [Posts]
security: [{ api_key: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
post:
type: object
properties:
title: { type: string }
url: { type: string, format: uri }
description: { type: string }
collection_id: { type: integer }
responses:
"200":
description: Post updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Post" }
"401": { $ref: "#/components/responses/Unauthorized" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/ValidationFailed" }
delete:
summary: Delete a post
tags: [Posts]
security: [{ api_key: [] }]
responses:
"204":
description: Post deleted
"401": { $ref: "#/components/responses/Unauthorized" }
"404": { $ref: "#/components/responses/NotFound" }
components:
schemas:
Post:
type: object
properties:
id: { type: integer }
title: { type: string }
url: { type: string, format: uri }
description: { type: string, nullable: true }
host: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
PaginationMeta:
type: object
properties:
next_cursor: { type: string, nullable: true }
has_more: { type: boolean }
limit: { type: integer }
Error:
type: object
properties:
error:
type: object
properties:
status: { type: integer }
message: { type: string }
details:
type: array
items: { type: string }
responses:
Unauthorized:
description: Invalid or missing API key
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
NotFound:
description: Resource not found
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
ValidationFailed:
description: Validation failed
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
securitySchemes:
api_key:
type: apiKey
name: X-Api-Key
in: header
Use these consistently across all endpoints:
| Status | When |
|---|---|
200 OK | Successful GET, PATCH |
201 Created | Successful POST |
204 No Content | Successful DELETE |
400 Bad Request | Malformed request or missing required params |
401 Unauthorized | Missing or invalid API key |
404 Not Found | Resource doesn't exist or isn't accessible |
422 Unprocessable Entity | Validation failures |
429 Too Many Requests | Rate limit exceeded |
500 Internal Server Error | Unexpected server error |
namespace :api / namespace :v1 in config/routes.rbApi::V1::BaseControllerapp/serializers/api/v1/ — only expose fields consumers needcurrent_accountassert_schema_conform to validate against the OpenAPI specdocs/openapi/v1.yaml with the new endpointGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.
npx claudepluginhub mattsears/rails-cto --plugin rails-cto