Skip to main content
Technology & EngineeringRuby Rails351 lines

API Mode

Building JSON APIs with Rails API mode, serialization, versioning, authentication, and rate limiting.

Quick Summary18 lines
You are an expert in building JSON APIs with Rails API mode, covering serialization, authentication, versioning, error handling, and performance.

## Key Points

- Use consistent response envelopes (`{ data: ..., meta: ... }` for success, `{ error: ... }` for failures).
- Version your API from day one using URL namespacing (`/api/v1/`).
- Return appropriate HTTP status codes: 200, 201, 204 for success; 400, 401, 403, 404, 422, 429 for errors.
- Use `includes` and `select` to avoid N+1 queries and over-fetching columns in list endpoints.
- Paginate all list endpoints. Return pagination metadata in the response body or headers.
- Use `status: :no_content` (204) for successful DELETE responses with no body.
- Keep serialization logic out of controllers. Use dedicated serializer classes or gems like `alba` or `blueprinter`.
- Document your API with OpenAPI/Swagger using `rswag` or similar tools.
- **Exposing internal errors**: Never render raw exception messages in production. Use rescue handlers to return safe error messages.
- **Missing authentication on endpoints**: Audit routes to ensure all sensitive endpoints require authentication.
- **No rate limiting**: Unthrottled APIs are vulnerable to abuse. Add rate limiting early.
- **Inconsistent response formats**: Mixing bare arrays, objects, and envelopes confuses API consumers. Pick a convention and enforce it.
skilldb get ruby-rails-skills/API ModeFull skill: 351 lines
Paste into your CLAUDE.md or agent config

Rails API Mode — Ruby on Rails

You are an expert in building JSON APIs with Rails API mode, covering serialization, authentication, versioning, error handling, and performance.

Overview

Rails API mode (rails new myapp --api) strips out browser-specific middleware (cookies, sessions, flash, views) to create a lean backend for JSON APIs. It inherits from ActionController::API instead of ActionController::Base, producing faster responses with a smaller middleware stack.

Core Philosophy

A Rails API should be a contract between your server and its consumers. Every endpoint, response shape, and error format is a promise that clients depend on. Stability, consistency, and predictability matter more than cleverness. Versioning your API from the start acknowledges that contracts evolve, and gives you room to iterate without breaking existing integrations.

Rails API mode strips away the browser-centric middleware to focus purely on data exchange, but the Rails philosophy of convention over configuration still applies. Consistent response envelopes, standard HTTP status codes, and predictable URL structures reduce the cognitive load for anyone consuming your API. When a consumer can guess how your API behaves before reading the docs, you have succeeded.

Security in an API context means defense in depth: authentication verifies identity, authorization controls access, rate limiting prevents abuse, and input validation rejects malformed data before it reaches your business logic. Each layer is necessary because no single layer is sufficient on its own.

Anti-Patterns

  • Leaking Internal Structure: Returning raw ActiveRecord attributes (including created_at, updated_at, internal IDs, or sensitive fields) directly as JSON without a serializer. This couples your API response to your database schema and can expose data you never intended to share.

  • Version Procrastination: Skipping API versioning because "we only have one client right now." Adding versioning retroactively is painful and risks breaking existing consumers. Namespace from the start with /api/v1/.

  • Inconsistent Error Formats: Returning validation errors as an array in one endpoint, a string in another, and a hash in a third. Consumers need a single, predictable error shape to build reliable error handling.

  • Authentication Afterthought: Adding authentication piecemeal to individual controllers rather than enforcing it globally in a base controller with explicit skip_before_action for public endpoints. This inverts the security model and makes it easy to accidentally expose private data.

  • Pagination as Optional: Returning unbounded collections from list endpoints because the dataset is "small for now." Every list endpoint should paginate by default, with a sensible maximum page size.

Core Concepts

API Controller Base

# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < ActionController::API
      include ActionController::HttpAuthentication::Token::ControllerMethods

      before_action :authenticate_request

      rescue_from ActiveRecord::RecordNotFound, with: :not_found
      rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
      rescue_from ActionController::ParameterMissing, with: :bad_request

      private

      def authenticate_request
        authenticate_or_request_with_http_token do |token|
          @current_user = User.find_by(api_token: token)
        end
      end

      def current_user
        @current_user
      end

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

      def unprocessable_entity(exception)
        render json: {
          error: "Validation failed",
          details: exception.record.errors.full_messages
        }, status: :unprocessable_entity
      end

      def bad_request(exception)
        render json: { error: "Bad request", message: exception.message }, status: :bad_request
      end
    end
  end
end

Resource Controller

# app/controllers/api/v1/posts_controller.rb
module Api
  module V1
    class PostsController < BaseController
      before_action :set_post, only: [:show, :update, :destroy]

      def index
        posts = Post.published
                    .includes(:user, :tags)
                    .order(created_at: :desc)
                    .page(params[:page])
                    .per(params[:per_page] || 25)

        render json: {
          data: posts.map { |p| PostSerializer.new(p).as_json },
          meta: pagination_meta(posts)
        }
      end

      def show
        render json: PostSerializer.new(@post, detail: true).as_json
      end

      def create
        post = current_user.posts.create!(post_params)
        render json: PostSerializer.new(post).as_json, status: :created
      end

      def update
        @post.update!(post_params)
        render json: PostSerializer.new(@post).as_json
      end

      def destroy
        @post.destroy!
        head :no_content
      end

      private

      def set_post
        @post = Post.find(params[:id])
      end

      def post_params
        params.require(:post).permit(:title, :body, :published, tag_ids: [])
      end

      def pagination_meta(collection)
        {
          current_page: collection.current_page,
          total_pages: collection.total_pages,
          total_count: collection.total_count,
          per_page: collection.limit_value
        }
      end
    end
  end
end

Routing with Versioning

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :posts, only: [:index, :show, :create, :update, :destroy] do
        resources :comments, only: [:index, :create]
      end
      resources :users, only: [:show, :update]
      resource :session, only: [:create, :destroy]
    end
  end
end

Implementation Patterns

Serializers

# app/serializers/post_serializer.rb
class PostSerializer
  def initialize(post, detail: false)
    @post = post
    @detail = detail
  end

  def as_json(*)
    data = {
      id: @post.id,
      title: @post.title,
      summary: @post.body.truncate(200),
      published: @post.published,
      author: {
        id: @post.user.id,
        name: @post.user.full_name
      },
      tags: @post.tags.map { |t| { id: t.id, name: t.name } },
      created_at: @post.created_at.iso8601,
      updated_at: @post.updated_at.iso8601
    }

    if @detail
      data[:body] = @post.body
      data[:comments_count] = @post.comments.count
    end

    data
  end
end

JWT Authentication

# app/services/jwt_service.rb
class JwtService
  SECRET = Rails.application.credentials.jwt_secret_key
  ALGORITHM = "HS256"

  def self.encode(payload, exp: 24.hours.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET, ALGORITHM)
  end

  def self.decode(token)
    decoded = JWT.decode(token, SECRET, true, algorithm: ALGORITHM)
    HashWithIndifferentAccess.new(decoded.first)
  rescue JWT::DecodeError, JWT::ExpiredSignature => e
    nil
  end
end

# app/controllers/api/v1/sessions_controller.rb
module Api
  module V1
    class SessionsController < BaseController
      skip_before_action :authenticate_request, only: :create

      def create
        user = User.find_by(email: params[:email])

        if user&.authenticate(params[:password])
          token = JwtService.encode(user_id: user.id)
          render json: { token: token, user: UserSerializer.new(user).as_json }
        else
          render json: { error: "Invalid credentials" }, status: :unauthorized
        end
      end
    end
  end
end

# app/controllers/api/v1/base_controller.rb (JWT version)
def authenticate_request
  header = request.headers["Authorization"]
  token = header&.split(" ")&.last
  payload = JwtService.decode(token)

  if payload
    @current_user = User.find_by(id: payload[:user_id])
  end

  render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
end

Rate Limiting with Rack::Attack

# config/initializers/rack_attack.rb
class Rack::Attack
  # Throttle general API requests by IP
  throttle("api/ip", limit: 300, period: 5.minutes) do |req|
    req.ip if req.path.start_with?("/api/")
  end

  # Stricter throttle for authentication endpoints
  throttle("api/auth", limit: 5, period: 1.minute) do |req|
    req.ip if req.path == "/api/v1/session" && req.post?
  end

  # Throttle by API token for authenticated users
  throttle("api/token", limit: 1000, period: 1.hour) do |req|
    req.env["HTTP_AUTHORIZATION"]&.split(" ")&.last if req.path.start_with?("/api/")
  end

  # Custom response
  self.throttled_responder = lambda do |req|
    retry_after = (req.env["rack.attack.match_data"] || {})[:period]
    [
      429,
      { "Content-Type" => "application/json", "Retry-After" => retry_after.to_s },
      [{ error: "Rate limit exceeded. Retry after #{retry_after} seconds." }.to_json]
    ]
  end
end

CORS Configuration

# Gemfile
gem "rack-cors"

# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins ENV.fetch("ALLOWED_ORIGINS", "http://localhost:3000").split(",")

    resource "/api/*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      expose: ["X-Total-Count", "X-Page", "X-Per-Page"],
      max_age: 600
  end
end

API Error Envelope

# app/controllers/concerns/api_response.rb
module ApiResponse
  extend ActiveSupport::Concern

  def render_success(data, status: :ok, meta: {})
    response = { data: data }
    response[:meta] = meta if meta.present?
    render json: response, status: status
  end

  def render_error(message, status: :bad_request, details: nil)
    response = { error: { message: message } }
    response[:error][:details] = details if details
    render json: response, status: status
  end

  def render_created(data)
    render_success(data, status: :created)
  end
end

Best Practices

  • Use consistent response envelopes ({ data: ..., meta: ... } for success, { error: ... } for failures).
  • Version your API from day one using URL namespacing (/api/v1/).
  • Return appropriate HTTP status codes: 200, 201, 204 for success; 400, 401, 403, 404, 422, 429 for errors.
  • Use includes and select to avoid N+1 queries and over-fetching columns in list endpoints.
  • Paginate all list endpoints. Return pagination metadata in the response body or headers.
  • Use status: :no_content (204) for successful DELETE responses with no body.
  • Keep serialization logic out of controllers. Use dedicated serializer classes or gems like alba or blueprinter.
  • Document your API with OpenAPI/Swagger using rswag or similar tools.

Common Pitfalls

  • Exposing internal errors: Never render raw exception messages in production. Use rescue handlers to return safe error messages.
  • Missing authentication on endpoints: Audit routes to ensure all sensitive endpoints require authentication.
  • No rate limiting: Unthrottled APIs are vulnerable to abuse. Add rate limiting early.
  • Inconsistent response formats: Mixing bare arrays, objects, and envelopes confuses API consumers. Pick a convention and enforce it.
  • Not handling CORS: Frontend clients on different origins will fail silently without proper CORS headers.
  • Over-serializing: Returning entire model attributes including sensitive fields. Always use explicit serializers that whitelist fields.

Install this skill directly: skilldb add ruby-rails-skills

Get CLI access →