rails-cto-api
Skill15 starsChanged 7 months agoMITView on GitHub
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.
- Reads credentials
In this repository17 other files it gives its agents.
- claude/CLAUDE.mdCLAUDE.md
- rails-cto-architectSkill
- rails-cto-commitSkill
- rails-cto-engineerSkill
- rails-cto-erbSkill
- rails-cto-minitestSkill
- rails-cto-production-prSkill
- rails-cto-pull-requestSkill
- rails-cto-qaSkill
- rails-cto-restfulSkill
- rails-cto-securitySkill
- rails-ctoSkill
- rails-cto-static-analysisSkill
- rails-cto-stimulusSkill
- rails-cto-tailwindSkill
- rails-upgradeSkill
- rails-viewcomponentsSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
14 rules. About 0 are common, 6 are distinctive, and 8 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈6
Distinctive · estimate
This author's own choices. Worth a look.
8
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 48
All API endpoints live under a URL-based version prefix.
- Very specific: line 65
When introducing a breaking change in the future, create Api::V2:: alongside V1 rather than modifying V1.
- Distinctive: line 69
Every API controller inherits from a base controller that centralizes authentication, error handling, and response helpers.
- Very specific: line 173
Every request without a valid key receives a 401 Unauthorized response.
- Very specific: line 254
Always scope queries through current_account — never use unscoped Post.find
- Very specific: line 261
Use a serializer gem (alba or blueprinter) to control exactly which fields are exposed.
- Very specific: line 301
Always use iso8601 for timestamps — external consumers need a predictable format
- Very specific: line 302
Only expose fields that external consumers need — not internal columns like discarded_at or embedding_vector
- Distinctive: line 303
When adding a new field, consider it a public contract — removing it later is a breaking change
- Very specific: line 308
All successful responses wrap data in a data key.
- Distinctive: line 354
Use cursor-based pagination for all list endpoints.
- Very specific: line 369
Use rack-attack to protect API endpoints from abuse.
- Distinctive: line 768
Use these consistently across all endpoints:
- Distinctive: line 793
Check CORS: if the endpoint will be called from browsers
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Rails API Development
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.
Architecture Overview
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 routesAPI 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.
Versioning
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.
Routes
# 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
endThis 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.
Base Controller
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
endAuthentication
API consumers authenticate with an API key passed in the X-Api-Key header:
GET /api/v1/posts
X-Api-Key: sk_live_abc123def456Every 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.
Generating API keys
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).
Controller Pattern
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
endKey points:
- Always scope queries through
current_account— never use unscopedPost.find - Reuse existing service objects (
Posts::Create,Posts::Update, etc.) - Return
201 Createdfor successful creation,204 No Contentfor deletion - Wrap response data in a
datakey for consistency
Serialization
Use 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
endGuidelines:
- Always use
iso8601for timestamps — external consumers need a predictable format - Only expose fields that external consumers need — not internal columns like
discarded_atorembedding_vector - When adding a new field, consider it a public contract — removing it later is a breaking change
- Nest related resources under a key (e.g.,
collection: { id: 1, title: "..." }) rather than flattening
Response Format
All successful responses wrap data in a data key. List endpoints also include a meta key for pagination.
Single resource
{
"data": {
"id": 1,
"title": "Example Post",
"url": "https://example.com",
"created_at": "2026-03-18T12:00:00Z",
"updated_at": "2026-03-18T12:00:00Z"
}
}Collection with pagination
{
"data": [
{ "id": 1, "title": "First" },
{ "id": 2, "title": "Second" }
],
"meta": {
"next_cursor": "MjU",
"has_more": true,
"limit": 25
}
}Error
{
"error": {
"status": 422,
"message": "Validation failed",
"details": ["Title can't be blank", "URL is not valid"]
}
}Pagination
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'smeta.next_cursor
GET /api/v1/posts?limit=10
GET /api/v1/posts?limit=10&after=MjUThe base controller's paginate helper handles this. See the base controller section above for the implementation.
Rate Limiting
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]]
endInclude rate limit headers on every response via a before_action or middleware so consumers always know their remaining quota.
CORS
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
endIn production, replace origins "*" with specific allowed domains. Expose rate limit headers so browser-based consumers can read them.
OpenAPI Documentation
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.
Setup
Add to Gemfile:
group :test do
gem "committee", require: false
gem "committee-rails", require: false
endThen 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
endWriting integration tests with schema validation
# 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
endThe 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.
Maintaining the OpenAPI spec
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: headerHTTP Status Codes
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 |
Checklist for New API Endpoints
- Add route under
namespace :api / namespace :v1inconfig/routes.rb - Create controller inheriting from
Api::V1::BaseController - Reuse existing service objects — don't duplicate business logic
- Create a serializer in
app/serializers/api/v1/— only expose fields consumers need - Scope all queries through
current_account - Write integration tests with
assert_schema_conformto validate against the OpenAPI spec - Update the OpenAPI spec at
docs/openapi/v1.yamlwith the new endpoint - Test error cases — 401, 404, 422 responses
- Verify cursor pagination works for list endpoints
- Check CORS if the endpoint will be called from browsers
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

