agentleFS
mattsearsmattsears/rails-cto/skills/rails-cto-api/SKILL.md

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

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
  1. Distinctive:

    All API endpoints live under a URL-based version prefix.

    line 48
  2. Very specific:

    When introducing a breaking change in the future, create Api::V2:: alongside V1 rather than modifying V1.

    line 65
  3. Distinctive:

    Every API controller inherits from a base controller that centralizes authentication, error handling, and response helpers.

    line 69
  4. Very specific:

    Every request without a valid key receives a 401 Unauthorized response.

    line 173
  5. Very specific:

    Always scope queries through current_account — never use unscoped Post.find

    line 254
  6. Very specific:

    Use a serializer gem (alba or blueprinter) to control exactly which fields are exposed.

    line 261
  7. Very specific:

    Always use iso8601 for timestamps — external consumers need a predictable format

    line 301
  8. Very specific:

    Only expose fields that external consumers need — not internal columns like discarded_at or embedding_vector

    line 302
  9. Distinctive:

    When adding a new field, consider it a public contract — removing it later is a breaking change

    line 303
  10. Very specific:

    All successful responses wrap data in a data key.

    line 308
  11. Distinctive:

    Use cursor-based pagination for all list endpoints.

    line 354
  12. Very specific:

    Use rack-attack to protect API endpoints from abuse.

    line 369
  13. Distinctive:

    Use these consistently across all endpoints:

    line 768
  14. Distinctive:

    Check CORS: if the endpoint will be called from browsers

    line 793

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 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.

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
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.

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
end

Authentication

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.

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
end

Key points:

  • Always scope queries through current_account — never use unscoped Post.find
  • Reuse existing service objects (Posts::Create, Posts::Update, etc.)
  • Return 201 Created for successful creation, 204 No Content for deletion
  • Wrap response data in a data key 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
end

Guidelines:

  • Always use iso8601 for timestamps — external consumers need a predictable format
  • Only expose fields that external consumers need — not internal columns like discarded_at or embedding_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's meta.next_cursor
GET /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.

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]]
end

Include 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
end

In 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
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

Writing 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
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.

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: header

HTTP Status Codes

Use these consistently across all endpoints:

StatusWhen
200 OKSuccessful GET, PATCH
201 CreatedSuccessful POST
204 No ContentSuccessful DELETE
400 Bad RequestMalformed request or missing required params
401 UnauthorizedMissing or invalid API key
404 Not FoundResource doesn't exist or isn't accessible
422 Unprocessable EntityValidation failures
429 Too Many RequestsRate limit exceeded
500 Internal Server ErrorUnexpected server error

Checklist for New API Endpoints

  1. Add route under namespace :api / namespace :v1 in config/routes.rb
  2. Create controller inheriting from Api::V1::BaseController
  3. Reuse existing service objects — don't duplicate business logic
  4. Create a serializer in app/serializers/api/v1/ — only expose fields consumers need
  5. Scope all queries through current_account
  6. Write integration tests with assert_schema_conform to validate against the OpenAPI spec
  7. Update the OpenAPI spec at docs/openapi/v1.yaml with the new endpoint
  8. Test error cases — 401, 404, 422 responses
  9. Verify cursor pagination works for list endpoints
  10. Check CORS if the endpoint will be called from browsers

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.