api development patterns

API Development Patterns

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "api development patterns" with this command: npx skills add kaakati/rails-enterprise-dev/kaakati-rails-enterprise-dev-api-development-patterns

API Development Patterns

Complete patterns and best practices for building production-grade REST APIs in Rails 7.x/8.x.

RESTful API Conventions

Resource-Oriented Design

Core Principles:

  • Resources are nouns (not verbs): /users , /posts , not /get_user

  • Use HTTP methods for actions: GET (read), POST (create), PATCH/PUT (update), DELETE (destroy)

  • Nest resources for relationships, but limit nesting to 1-2 levels

  • Use plural resource names: /users not /user

Standard Resource Routes:

config/routes.rb

Rails.application.routes.draw do namespace :api do namespace :v1 do resources :posts do resources :comments, only: [:index, :create] # Nested but limited member do post :publish post :archive end collection do get :trending end end

  # Flat route for comments by ID (better than deep nesting)
  resources :comments, only: [:show, :update, :destroy]
end

end end

HTTP Methods & Status Codes

Standard API Actions:

Method Action Success Status Body

GET Index/List 200 OK Resource array + pagination

GET Show 200 OK Single resource

POST Create 201 Created Created resource

PATCH/PUT Update 200 OK Updated resource

DELETE Destroy 204 No Content Empty

Error Status Codes:

Code Meaning When to Use

400 Bad Request Invalid JSON, malformed request

401 Unauthorized Missing or invalid authentication

403 Forbidden Authenticated but not authorized

404 Not Found Resource doesn't exist

422 Unprocessable Entity Validation errors

429 Too Many Requests Rate limit exceeded

500 Internal Server Error Unexpected server error

503 Service Unavailable Maintenance mode, overloaded

Controller Example:

app/controllers/api/v1/posts_controller.rb

module Api module V1 class PostsController < Api::BaseController before_action :authenticate_api_user! before_action :set_post, only: [:show, :update, :destroy]

  def index
    @posts = Post.published
                 .page(params[:page])
                 .per(params[:per_page] || 25)

    render json: PostBlueprint.render(@posts, root: :posts), status: :ok
  end

  def show
    render json: PostBlueprint.render(@post), status: :ok
  end

  def create
    @post = Current.user.posts.build(post_params)

    if @post.save
      render json: PostBlueprint.render(@post), status: :created, location: api_v1_post_url(@post)
    else
      render json: { errors: @post.errors }, status: :unprocessable_entity
    end
  end

  def update
    if @post.update(post_params)
      render json: PostBlueprint.render(@post), status: :ok
    else
      render json: { errors: @post.errors }, status: :unprocessable_entity
    end
  end

  def destroy
    @post.destroy
    head :no_content
  end

  private

  def set_post
    @post = Post.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    render json: { error: "Post not found" }, status: :not_found
  end

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

end end

Serialization Patterns

Blueprinter (Recommended)

Installation:

Gemfile

gem 'blueprinter' gem 'oj' # Fast JSON parser

Basic Blueprint:

app/blueprints/post_blueprint.rb

class PostBlueprint < Blueprinter::Base identifier :id

fields :title, :body, :published_at, :created_at

field :slug do |post| post.title.parameterize end

association :author, blueprint: UserBlueprint, view: :compact

association :comments, blueprint: CommentBlueprint do |post, options| post.comments.limit(options[:comment_limit] || 10) end

view :compact do fields :id, :title, :slug end

view :extended do include_view :default fields :view_count, :like_count association :tags, blueprint: TagBlueprint end end

Using Views:

Compact view for lists

PostBlueprint.render(@posts, view: :compact, root: :posts)

Extended view for show

PostBlueprint.render(@post, view: :extended)

Pass options to associations

PostBlueprint.render(@post, comment_limit: 5)

JSONAPI::Serializer (Alternative)

For JSON:API Specification Compliance:

Gemfile

gem 'jsonapi-serializer'

app/serializers/post_serializer.rb

class PostSerializer include JSONAPI::Serializer

attributes :title, :body, :published_at

belongs_to :author, serializer: UserSerializer has_many :comments, serializer: CommentSerializer

attribute :slug do |post| post.title.parameterize end

link :self do |post| Rails.application.routes.url_helpers.api_v1_post_url(post) end end

Usage

PostSerializer.new(@posts, include: [:author, :comments]).serializable_hash

Alba (Lightweight Alternative)

Gemfile

gem 'alba'

app/serializers/post_serializer.rb

class PostSerializer include Alba::Resource

attributes :id, :title, :body, :published_at

one :author, resource: UserSerializer many :comments, resource: CommentSerializer

attribute :slug do |post| post.title.parameterize end end

Usage

PostSerializer.new(@posts).serialize

Authentication

JWT (JSON Web Tokens)

Installation:

Gemfile

gem 'jwt' gem 'bcrypt' # For password hashing

JWT Service:

app/services/json_web_token_service.rb

class JsonWebTokenService SECRET_KEY = Rails.application.credentials.secret_key_base ALGORITHM = 'HS256'

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

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

Authentication Controller:

app/controllers/api/v1/authentication_controller.rb

module Api module V1 class AuthenticationController < Api::BaseController skip_before_action :authenticate_api_user!, only: [:create]

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

    if user&#x26;.authenticate(params[:password])
      token = JsonWebTokenService.encode(user_id: user.id)
      render json: {
        token: token,
        user: UserBlueprint.render_as_hash(user)
      }, status: :ok
    else
      render json: { error: 'Invalid credentials' }, status: :unauthorized
    end
  end

  def destroy
    # Implement token revocation (requires Redis/database storage)
    head :no_content
  end
end

end end

Base Controller with JWT Authentication:

app/controllers/api/base_controller.rb

module Api class BaseController < ActionController::API before_action :authenticate_api_user!

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

private

def authenticate_api_user!
  token = request.headers['Authorization']&#x26;.split(' ')&#x26;.last
  return render_unauthorized unless token

  decoded_token = JsonWebTokenService.decode(token)
  return render_unauthorized unless decoded_token

  @current_user = User.find_by(id: decoded_token[:user_id])
  return render_unauthorized unless @current_user

  # Store in Current for easy access
  Current.user = @current_user
rescue
  render_unauthorized
end

def current_user
  @current_user
end

def render_unauthorized
  render json: { error: 'Unauthorized' }, status: :unauthorized
end

def not_found
  render json: { error: 'Resource not found' }, status: :not_found
end

def bad_request
  render json: { error: 'Bad request' }, status: :bad_request
end

end end

API Keys (Alternative)

For Service-to-Service Authentication:

Migration

create_table :api_keys do |t| t.references :user, null: false, foreign_key: true t.string :key, null: false, index: { unique: true } t.string :name # e.g., "Production Server", "Mobile App" t.datetime :last_used_at t.datetime :expires_at t.timestamps end

app/models/api_key.rb

class ApiKey < ApplicationRecord belongs_to :user

before_create :generate_key

scope :active, -> { where('expires_at IS NULL OR expires_at > ?', Time.current) }

def self.authenticate(key) active.find_by(key: key)&.tap do |api_key| api_key.update_column(:last_used_at, Time.current) end end

private

def generate_key self.key = SecureRandom.base58(32) end end

Authentication in controller

def authenticate_api_key! key = request.headers['X-API-Key'] || params[:api_key] return render_unauthorized unless key

@api_key = ApiKey.authenticate(key) return render_unauthorized unless @api_key

@current_user = @api_key.user Current.user = @current_user end

Authorization

Pundit for APIs

Gemfile

gem 'pundit'

app/controllers/api/base_controller.rb

module Api class BaseController < ActionController::API include Pundit::Authorization

rescue_from Pundit::NotAuthorizedError, with: :forbidden

private

def forbidden
  render json: { error: 'Forbidden' }, status: :forbidden
end

end end

app/policies/post_policy.rb

class PostPolicy < ApplicationPolicy def index? true end

def show? record.published? || record.author == user end

def create? user.present? end

def update? record.author == user end

def destroy? record.author == user || user.admin? end end

In controller

def show @post = Post.find(params[:id]) authorize @post render json: PostBlueprint.render(@post) end

Versioning Strategies

URL Versioning (Recommended)

Routes:

config/routes.rb

Rails.application.routes.draw do namespace :api do namespace :v1 do resources :posts end

namespace :v2 do
  resources :posts
end

end end

Pros: Simple, clear, cache-friendly Cons: URLs change between versions

Header Versioning

config/routes.rb

namespace :api, defaults: { format: :json } do scope module: :v1, constraints: ApiVersion.new('v1', default: true) do resources :posts end

scope module: :v2, constraints: ApiVersion.new('v2') do resources :posts end end

lib/api_version.rb

class ApiVersion def initialize(version, default = false) @version = version @default = default end

def matches?(request) @default || check_headers(request.headers) end

private

def check_headers(headers) accept = headers['Accept'] accept&.include?("application/vnd.myapp.#{@version}+json") end end

Usage:

Accept: application/vnd.myapp.v2+json

Pagination

Kaminari

Gemfile

gem 'kaminari'

Controller

def index @posts = Post.published .page(params[:page]) .per(params[:per_page] || 25)

render json: { posts: PostBlueprint.render_as_hash(@posts, view: :compact), meta: pagination_meta(@posts) } end

private

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

pagy (Faster Alternative)

Gemfile

gem 'pagy'

app/controllers/api/base_controller.rb

include Pagy::Backend

def index @pagy, @posts = pagy(Post.published, items: params[:per_page] || 25)

render json: { posts: PostBlueprint.render_as_hash(@posts), meta: pagy_metadata(@pagy) } end

private

def pagy_metadata(pagy_object) { current_page: pagy_object.page, next_page: pagy_object.next, prev_page: pagy_object.prev, total_pages: pagy_object.pages, total_count: pagy_object.count, per_page: pagy_object.items } end

Rate Limiting

Rack::Attack

Gemfile

gem 'rack-attack'

config/initializers/rack_attack.rb

class Rack::Attack

Throttle all requests by IP

throttle('req/ip', limit: 300, period: 5.minutes) do |req| req.ip if req.path.start_with?('/api/') end

Throttle API requests by authentication token

throttle('api/token', limit: 1000, period: 1.hour) do |req| req.env['HTTP_AUTHORIZATION']&.split(' ')&.last if req.path.start_with?('/api/') end

Throttle login attempts

throttle('logins/email', limit: 5, period: 20.minutes) do |req| if req.path == '/api/v1/login' && req.post? req.params['email'].to_s.downcase.gsub(/\s+/, "") end end

Block specific IPs

blocklist('block bad IPs') do |req| # Read from Redis or database Redis.current.sismember('blocked_ips', req.ip) end

Custom response for throttled requests

self.throttled_responder = lambda do |env| retry_after = 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 }.to_json] ] end end

config/application.rb

config.middleware.use Rack::Attack

Error Handling

Standardized Error Format

app/controllers/api/base_controller.rb

module Api class BaseController < ActionController::API rescue_from StandardError, with: :internal_server_error rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity rescue_from ActionController::ParameterMissing, with: :bad_request rescue_from Pundit::NotAuthorizedError, with: :forbidden

private

def render_error(message, status, details = {})
  render json: {
    error: {
      message: message,
      status: status,
      **details
    }
  }, status: status
end

def bad_request(exception)
  render_error('Bad request', :bad_request, details: exception.message)
end

def unauthorized
  render_error('Unauthorized', :unauthorized)
end

def forbidden(exception)
  render_error('Forbidden', :forbidden, details: exception.message)
end

def not_found(exception)
  render_error('Resource not found', :not_found, resource: exception.model)
end

def unprocessable_entity(exception)
  render json: {
    error: {
      message: 'Validation failed',
      status: 422,
      errors: exception.record.errors.as_json
    }
  }, status: :unprocessable_entity
end

def internal_server_error(exception)
  Rails.logger.error(exception.message)
  Rails.logger.error(exception.backtrace.join("\n"))

  # Report to error tracking service (Sentry, Rollbar, etc.)
  ErrorTrackingService.report(exception) if defined?(ErrorTrackingService)

  render_error('Internal server error', :internal_server_error)
end

end end

Validation Errors Format

app/models/post.rb

class Post < ApplicationRecord validates :title, presence: true, length: { minimum: 5, maximum: 100 } validates :body, presence: true end

Response for validation errors (422):

{ "error": { "message": "Validation failed", "status": 422, "errors": { "title": ["can't be blank", "is too short (minimum is 5 characters)"], "body": ["can't be blank"] } } }

CORS Configuration

Gemfile

gem 'rack-cors'

config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins 'https://example.com', 'https://app.example.com'

resource '/api/*',
  headers: :any,
  methods: [:get, :post, :put, :patch, :delete, :options, :head],
  credentials: true,
  max_age: 86400

end

Development

if Rails.env.development? allow do origins 'http://localhost:3000', 'http://localhost:3001' resource '*', headers: :any, methods: :any end end end

API Documentation

rswag (OpenAPI/Swagger)

Installation:

Gemfile

gem 'rswag'

Run installer

rails g rswag:install

Request Spec:

spec/requests/api/v1/posts_spec.rb

require 'swagger_helper'

RSpec.describe 'API V1 Posts', type: :request do path '/api/v1/posts' do get 'Retrieves posts' do tags 'Posts' produces 'application/json' parameter name: :page, in: :query, type: :integer, required: false parameter name: :per_page, in: :query, type: :integer, required: false

  response '200', 'posts found' do
    schema type: :object,
      properties: {
        posts: {
          type: :array,
          items: {
            type: :object,
            properties: {
              id: { type: :integer },
              title: { type: :string },
              body: { type: :string },
              published_at: { type: :string, format: 'date-time' }
            },
            required: ['id', 'title']
          }
        },
        meta: {
          type: :object,
          properties: {
            current_page: { type: :integer },
            total_pages: { type: :integer },
            total_count: { type: :integer }
          }
        }
      }

    run_test!
  end
end

post 'Creates a post' do
  tags 'Posts'
  consumes 'application/json'
  produces 'application/json'
  parameter name: :post, in: :body, schema: {
    type: :object,
    properties: {
      title: { type: :string },
      body: { type: :string }
    },
    required: ['title', 'body']
  }

  response '201', 'post created' do
    let(:post) { { title: 'Test Post', body: 'Test body' } }
    run_test!
  end

  response '422', 'invalid request' do
    let(:post) { { title: '' } }
    run_test!
  end
end

end end

Generate Swagger Docs:

rake rswag:specs:swaggerize

Access at: http://localhost:3000/api-docs

Performance Optimization

Caching

Controller with caching

def index @posts = Rails.cache.fetch(['posts', 'index', params[:page]], expires_in: 5.minutes) do Post.published .includes(:author, :tags) .page(params[:page]) .per(25) .to_a end

render json: PostBlueprint.render(@posts) end

ETags for conditional requests

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

if stale?(@post) render json: PostBlueprint.render(@post) end end

N+1 Query Prevention

Always use includes/eager_load for associations

def index @posts = Post.published .includes(:author, :tags, comments: :user) .page(params[:page])

render json: PostBlueprint.render(@posts) end

Bullet Gem (Development)

Gemfile

group :development do gem 'bullet' end

config/environments/development.rb

config.after_initialize do Bullet.enable = true Bullet.alert = true Bullet.bullet_logger = true Bullet.console = true Bullet.rails_logger = true Bullet.add_footer = false # API doesn't need HTML footer end

Testing

Request Specs

spec/requests/api/v1/posts_spec.rb

require 'rails_helper'

RSpec.describe 'API V1 Posts', type: :request do let(:user) { create(:user) } let(:token) { JsonWebTokenService.encode(user_id: user.id) } let(:auth_headers) { { 'Authorization' => "Bearer #{token}" } }

describe 'GET /api/v1/posts' do before do create_list(:post, 3, :published) create(:post, :draft) # Should not be included end

it 'returns published posts' do
  get '/api/v1/posts', headers: auth_headers

  expect(response).to have_http_status(:ok)
  json = JSON.parse(response.body)
  expect(json['posts'].size).to eq(3)
end

it 'paginates results' do
  create_list(:post, 30, :published)

  get '/api/v1/posts', params: { page: 2, per_page: 10 }, headers: auth_headers

  json = JSON.parse(response.body)
  expect(json['posts'].size).to eq(10)
  expect(json['meta']['current_page']).to eq(2)
end

it 'returns 401 without authentication' do
  get '/api/v1/posts'
  expect(response).to have_http_status(:unauthorized)
end

end

describe 'POST /api/v1/posts' do let(:valid_params) do { post: { title: 'Test Post', body: 'Test body' } } end

it 'creates a post' do
  expect {
    post '/api/v1/posts', params: valid_params, headers: auth_headers
  }.to change(Post, :count).by(1)

  expect(response).to have_http_status(:created)
  json = JSON.parse(response.body)
  expect(json['title']).to eq('Test Post')
  expect(response.headers['Location']).to be_present
end

it 'returns validation errors' do
  post '/api/v1/posts', params: { post: { title: '' } }, headers: auth_headers

  expect(response).to have_http_status(:unprocessable_entity)
  json = JSON.parse(response.body)
  expect(json['error']['errors']).to have_key('title')
end

end

describe 'PATCH /api/v1/posts/:id' do let(:post_record) { create(:post, author: user) }

it 'updates the post' do
  patch "/api/v1/posts/#{post_record.id}",
    params: { post: { title: 'Updated' } },
    headers: auth_headers

  expect(response).to have_http_status(:ok)
  expect(post_record.reload.title).to eq('Updated')
end

it 'returns 403 for unauthorized update' do
  other_post = create(:post)

  patch "/api/v1/posts/#{other_post.id}",
    params: { post: { title: 'Hacked' } },
    headers: auth_headers

  expect(response).to have_http_status(:forbidden)
end

end end

Factory for API Testing

spec/factories/users.rb

FactoryBot.define do factory :user do email { Faker::Internet.email } password { 'password123' } password_confirmation { 'password123' } end end

spec/factories/posts.rb

FactoryBot.define do factory :post do title { Faker::Lorem.sentence } body { Faker::Lorem.paragraphs(number: 3).join("\n") } association :author, factory: :user

trait :published do
  published_at { 1.day.ago }
end

trait :draft do
  published_at { nil }
end

end end

Shared Examples for API Responses

spec/support/shared_examples/api_responses.rb

RSpec.shared_examples 'requires authentication' do it 'returns 401 without token' do make_request(headers: {}) expect(response).to have_http_status(:unauthorized) end

it 'returns 401 with invalid token' do make_request(headers: { 'Authorization' => 'Bearer invalid' }) expect(response).to have_http_status(:unauthorized) end end

RSpec.shared_examples 'paginates results' do it 'includes pagination metadata' do make_request

json = JSON.parse(response.body)
expect(json['meta']).to include(
  'current_page',
  'total_pages',
  'total_count'
)

end end

Usage in specs

describe 'GET /api/v1/posts' do def make_request(headers: auth_headers) get '/api/v1/posts', headers: headers end

it_behaves_like 'requires authentication' it_behaves_like 'paginates results' end

Security Best Practices

Input Sanitization

Always use strong parameters

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

SQL Injection Prevention

BAD - vulnerable to SQL injection

Post.where("title = '#{params[:title]}'")

GOOD - use parameterized queries

Post.where("title = ?", params[:title]) Post.where(title: params[:title])

Mass Assignment Protection

Models automatically protected with strong parameters

Never use:

Post.new(params[:post]) # BAD Post.create(params[:post]) # BAD

Always use:

Post.new(post_params) # GOOD Post.create(post_params) # GOOD

Sensitive Data Filtering

config/initializers/filter_parameter_logging.rb

Rails.application.config.filter_parameters += [ :password, :password_confirmation, :token, :api_key, :secret, :credit_card ]

Anti-Patterns to Avoid

❌ Don't Return ActiveRecord Objects Directly

BAD

def index render json: Post.all # Exposes all attributes end

GOOD

def index render json: PostBlueprint.render(Post.all) end

❌ Don't Use Sessions/Cookies in APIs

APIs should be stateless

Use JWT or API keys, not session-based authentication

❌ Don't Skip Authorization

BAD

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

GOOD

def destroy @post = Post.find(params[:id]) authorize @post # Pundit @post.destroy end

❌ Don't Ignore Rate Limiting

Always implement rate limiting for public APIs

Use Rack::Attack or similar

❌ Don't Return 200 for All Responses

Use appropriate status codes

200 OK, 201 Created, 204 No Content, 400 Bad Request, etc.

Summary Checklist

When building a new API endpoint:

  • Use RESTful resource naming and HTTP methods

  • Implement proper authentication (JWT/API keys)

  • Add authorization checks (Pundit)

  • Use serializers (Blueprinter) - never expose raw models

  • Return appropriate HTTP status codes

  • Implement pagination for list endpoints

  • Add rate limiting (Rack::Attack)

  • Configure CORS properly

  • Handle errors consistently

  • Write comprehensive request specs

  • Document with rswag/OpenAPI

  • Optimize queries (includes, caching)

  • Version your API (URL or header)

  • Filter sensitive parameters in logs

  • Use strong parameters for mass assignment protection

This skill provides the foundation for building production-ready REST APIs in Rails!

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

flutter conventions & best practices

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

getx state management patterns

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

ruby oop patterns

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

rails localization (i18n) - english & arabic

No summary provided by upstream source.

Repository SourceNeeds Review