agentleFS
mickzijdelmickzijdel/rails-toolkit/skills/rails-api/SKILL.md

rails-api

Skill2 starsChanged 8 days agoMITView on GitHub

Use when building a JSON API with Rails or adding an API namespace to an existing app — Api::BaseController setup, namespace versioning, serialization, CORS, pagination, standardized JSON errors, and request-spec patterns.

The file

We split this skill into the rules it gives an agent

10 rules. About 0 are common, 3 are distinctive, and 7 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use json.partial! for shared representations.

    line 72
  2. Very specific:

    Add rack-cors to Gemfile:

    line 99
  3. Very specific:

    Keep origins locked down in production — never origins "*" on an authenticated API.

    line 120
  4. Very specific:

    Use pagy — leaner than Kaminari/will_paginate for pure JSON APIs:

    line 124
  5. Very specific:

    Always paginate collections — never return unbounded Model.all to an API client.

    line 150
  6. Very specific:

    Use Rails' symbolic HTTP status names (:not_found, :unauthorized, :forbidden, :unprocessable_entity) rather than integers — intent is …

    line 220
  7. Very specific:

    Prefer bearer tokens (Authorization: Bearer <token>) over cookie sessions for cross-origin clients.

    line 226
  8. Distinctive:

    If the API is consumed only by your own Hotwire frontend on the same origin, session-based auth (CSRF-protected) is simpler and more secure …

    line 227
  9. Distinctive:

    Test the HTTP contract (status, shape, pagination meta) — not serializer internals.

    line 269
  10. Distinctive:

    Before shipping an API endpoint:

    line 273

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

Apply when: building a JSON API with Rails, adding an API namespace to an existing Rails app, or evaluating API design choices.


1. API-only mode

For standalone APIs, generate with --api to strip unnecessary middleware (views, assets, sessions, cookies):

rails new my_api --api

ApplicationController inherits from ActionController::API instead of ActionController::Base. Renders nothing for unknown formats (no HTML fallback).

For an API namespace added to an existing full-stack app, keep ApplicationController < ActionController::Base and use a dedicated base for API controllers:

# app/controllers/api/base_controller.rb
module Api
  class BaseController < ActionController::API
    include ActionController::MimeResponds

    before_action :require_api_authentication

    rescue_from ActiveRecord::RecordNotFound, with: :not_found
    rescue_from ActionController::ParameterMissing, with: :unprocessable_entity
  end
end

2. Versioning

Namespace by version so you can introduce v2 without breaking v1 clients:

# config/routes.rb
namespace :api do
  namespace :v1 do
    resources :articles, only: [:index, :show, :create, :update, :destroy]
    resources :users, only: [:show, :create]
  end
end

Controllers live at app/controllers/api/v1/ and inherit from Api::BaseController. URL versioning (the above) is simpler and visible in logs; use Accept header versioning only if you have a specific reason.

3. Serialization

Jbuilder (default, no extra gem)

Good for: straightforward response shapes, few endpoints, templates close to AR models.

# app/views/api/v1/articles/show.json.jbuilder
json.id @article.id
json.title @article.title
json.body @article.body
json.author do
  json.id @article.author.id
  json.name @article.author.name
end
json.created_at @article.created_at.iso8601

Use json.partial! for shared representations. Add json.cache! cache_key, expires_in: 1.hour do for high-traffic endpoints.

Blueprinter (explicit, fast)

Good for: many endpoints, consistent shape across controllers, easy unit testing.

# app/blueprints/article_blueprint.rb
class ArticleBlueprint < Blueprinter::Base
  identifier :id
  fields :title, :body, :created_at

  view :with_author do
    association :author, blueprint: UserBlueprint
  end
end
# controller
render json: ArticleBlueprint.render(@article, view: :with_author)

Pick one serialization approach per app — mixing Jbuilder and Blueprinter adds confusion without benefit.

4. CORS

Add rack-cors to Gemfile:

gem "rack-cors"

Configure in config/application.rb (or an initializer):

config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins ENV.fetch("CORS_ORIGINS", "http://localhost:3000").split(",")
    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      expose: ["X-Request-Id"],
      max_age: 600
  end
end

Keep origins locked down in production — never origins "*" on an authenticated API. Use an env var so staging/prod origins can differ without code changes.

5. Pagination

Use pagy — leaner than Kaminari/will_paginate for pure JSON APIs:

gem "pagy"
# app/controllers/api/base_controller.rb
include Pagy::Backend

private

def pagy_metadata_for(pagy)
  { count: pagy.count, page: pagy.page, pages: pagy.pages, per_page: pagy.limit }
end
# controller action
pagy, @articles = pagy(Article.published, limit: params[:per_page] || 25)
render json: {
  data: ArticleBlueprint.render_as_hash(@articles),
  meta: pagy_metadata_for(pagy)
}

Always paginate collections — never return unbounded Model.all to an API client.

Keyset (cursor) pagination for large or append-heavy collections

Offset pagination (pagy, LIMIT/OFFSET) is fine for admin lists, but it degrades on large datasets — the database still scans and discards every skipped row, so deep pages get slower — and it's unstable while the collection changes: a row inserted or deleted between requests shifts the window, so the client skips or repeats records. For big feeds, infinite scroll, and public list endpoints, paginate by a cursor on the ordered key instead:

# controller action — /api/v1/articles?after=<cursor>&per_page=25
def index
  limit = (params[:per_page] || 25).to_i.clamp(1, 100)
  scope = Article.published.order(created_at: :desc, id: :desc)

  if (cursor = decode_cursor(params[:after]))
    created_at, id = cursor
    # row-value comparison walks straight to the next page — no offset scan
    scope = scope.where("(created_at, id) < (?, ?)", created_at, id)
  end

  articles = scope.limit(limit + 1).to_a   # fetch one extra to detect "more"
  has_more = articles.size > limit
  articles = articles.first(limit)

  render json: {
    data: ArticleBlueprint.render_as_hash(articles),
    meta: { has_more:, next_cursor: (encode_cursor(articles.last) if has_more) }
  }
end

private

def encode_cursor(record)
  Base64.urlsafe_encode64([record.created_at.iso8601(6), record.id].to_json)
end

def decode_cursor(value)
  return if value.blank?
  JSON.parse(Base64.urlsafe_decode64(value))
rescue ArgumentError, JSON::ParserError
  nil # treat a malformed cursor as "start from the beginning"
end

Key points:

  • Order by a unique, stable tuple (created_at, id) so the cursor is unambiguous when timestamps collide — never order by created_at alone.
  • Return an opaque next_cursor (encode the tuple) plus a has_more flag instead of total page counts; computing count on a huge table is its own slow query.
  • A (created_at, id) composite index makes the row-value comparison an index range scan. See [[rails-database-performance]] for the index.
  • Trade-off: no "jump to page N" and no total count. Keep offset pagination where the UI needs page numbers.

6. Standardized JSON errors

Consistent error shape across all endpoints makes client error handling predictable:

# app/controllers/api/base_controller.rb
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
rescue_from ActionController::ParameterMissing, with: :unprocessable_entity

private

def not_found(e)
  render json: { error: "not_found", message: e.message }, status: :not_found
end

def unprocessable_entity(e)
  errors = e.respond_to?(:record) ? e.record.errors.as_json : { base: [e.message] }
  render json: { error: "unprocessable_entity", errors: errors }, status: :unprocessable_entity
end

Use Rails' symbolic HTTP status names (:not_found, :unauthorized, :forbidden, :unprocessable_entity) rather than integers — intent is readable in code.

7. Authentication

Token-based and session-based auth patterns belong in rails-security. For APIs specifically:

  • Prefer bearer tokens (Authorization: Bearer <token>) over cookie sessions for cross-origin clients.
  • If the API is consumed only by your own Hotwire frontend on the same origin, session-based auth (CSRF-protected) is simpler and more secure than rolling your own tokens.
  • authenticate_or_request_with_http_token is built into Rails — reach for it before adding Devise Token Auth or similar gems.

8. Testing

Request specs (RSpec)

# spec/requests/api/v1/articles_spec.rb
RSpec.describe "GET /api/v1/articles", type: :request do
  let!(:articles) { create_list(:article, 3, :published) }

  it "returns paginated articles" do
    get "/api/v1/articles", headers: { "Authorization" => "Bearer #{token}" }
    expect(response).to have_http_status(:ok)
    body = response.parsed_body
    expect(body["data"].length).to eq(3)
    expect(body["meta"]["count"]).to eq(3)
  end

  it "returns 401 without auth" do
    get "/api/v1/articles"
    expect(response).to have_http_status(:unauthorized)
  end
end

Minitest integration tests

# test/integration/api/v1/articles_test.rb
class Api::V1::ArticlesTest < ActionDispatch::IntegrationTest
  def test_index_returns_paginated_articles
    get api_v1_articles_path, headers: { "Authorization" => "Bearer #{tokens(:alice)}" }
    assert_response :ok
    body = response.parsed_body
    assert_kind_of Array, body["data"]
    assert body["meta"]["count"] >= 0
  end
end

Test the HTTP contract (status, shape, pagination meta) — not serializer internals. Serializer unit tests belong separately if you use Blueprinter.

API checklist

Before shipping an API endpoint:

  • Route is namespaced and versioned (api/v1/)
  • Controller inherits from Api::BaseController
  • Collections are paginated; shape includes meta.count/meta.pages
  • CORS origins locked to known values via env var (not "*")
  • rescue_from covers RecordNotFound, RecordInvalid, ParameterMissing
  • Authentication gate via before_action :require_api_authentication
  • JSON shape is stable: no Ruby class names, no nil fields leaking internal state
  • Request spec or integration test covers happy path and at least one error path

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.