rails-api — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited rails-api (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Apply when: building a JSON API with Rails, adding an API namespace to an existing Rails app, or evaluating API design choices.
For standalone APIs, generate with --api to strip unnecessary middleware (views, assets, sessions, cookies):
rails new my_api --apiApplicationController 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
endNamespace 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
endControllers 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.
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.iso8601Use json.partial! for shared representations. Add json.cache! cache_key, expires_in: 1.hour do for high-traffic endpoints.
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.
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
endKeep origins locked down in production — never origins "*" on an authenticated API. Use an env var so staging/prod origins can differ without code changes.
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.
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"
endKey points:
created_at, id) so the cursor is unambiguous when timestamps collide — never order by created_at alone.count on a huge table is its own slow query.(created_at, id) composite index makes the row-value comparison an index range scan. See [[rails-database-performance]] for the index.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
endUse Rails' symbolic HTTP status names (:not_found, :unauthorized, :forbidden, :unprocessable_entity) rather than integers — intent is readable in code.
Token-based and session-based auth patterns belong in rails-security. For APIs specifically:
Authorization: Bearer <token>) over cookie sessions for cross-origin clients.authenticate_or_request_with_http_token is built into Rails — reach for it before adding Devise Token Auth or similar gems.# 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# 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
endTest the HTTP contract (status, shape, pagination meta) — not serializer internals. Serializer unit tests belong separately if you use Blueprinter.
Before shipping an API endpoint:
api/v1/)Api::BaseControllermeta.count/meta.pages"*")rescue_from covers RecordNotFound, RecordInvalid, ParameterMissingbefore_action :require_api_authenticationnil fields leaking internal state~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.