Ruby API Development
Building APIs with Rails and Sinatra
Explanation
Ruby API Frameworks
Ruby offers excellent frameworks for building APIs. Rails API mode provides a full-featured solution, while Sinatra offers lightweight simplicity.
Framework Comparison
| Feature | Rails API | Sinatra | Grape | |---------|-----------|---------|-------| | Learning Curve | Moderate | Low | Low | | Features | Full-stack | Minimal | API-focused | | Performance | Good | Fast | Fast | | Best For | Large apps | Small apps | API-only |
Demonstration
Example 1: Rails API Basics
# Create Rails API app
# rails new api_app --api
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users
resources :posts do
resources :comments, only: [:index, :create]
end
end
end
end
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
before_action :set_user, only: [:show, :update, :destroy]
# GET /api/v1/users
def index
@users = User.all
render json: { data: @users, meta: pagination_meta }
end
# GET /api/v1/users/:id
def show
render json: { data: @user }
end
# POST /api/v1/users
def create
@user = User.new(user_params)
if @user.save
render json: { data: @user }, status: :created
else
render json: { errors: @user.errors }, status: :unprocessable_entity
end
end
# PATCH/PUT /api/v1/users/:id
def update
if @user.update(user_params)
render json: { data: @user }
else
render json: { errors: @user.errors }, status: :unprocessable_entity
end
end
# DELETE /api/v1/users/:id
def destroy
@user.destroy
head :no_content
end
private
def set_user
@user = User.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
def user_params
params.require(:user).permit(:name, :email, :role)
end
def pagination_meta
{
page: params[:page] || 1,
per_page: params[:per_page] || 25,
total: User.count
}
end
end
end
end
Example 2: Serialization with Active Model Serializers
# Gemfile
gem 'active_model_serializers'
# app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email, :created_at
has_many :posts
has_many :comments
attribute :full_name do
"#{object.first_name} #{object.last_name}"
end
# Conditional attributes
attribute :admin_notes, if: :admin?
def admin?
scope&.admin?
end
# Custom links
link(:self) { api_v1_user_url(object) }
end
# app/serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :body, :published_at
belongs_to :author, serializer: UserSerializer
has_many :comments
has_many :tags
attribute :excerpt do
object.body.truncate(150)
end
attribute :reading_time do
(object.body.split.size / 200.0).ceil
end
end
# Controller usage
class PostsController < ApplicationController
def index
posts = Post.includes(:author, :tags).page(params[:page])
render json: posts,
each_serializer: PostSerializer,
include: ['author', 'tags'],
meta: { total: Post.count }
end
def show
post = Post.find(params[:id])
render json: post, include: ['author', 'comments.author']
end
end
# Using Blueprinter (alternative)
# Gemfile: gem 'blueprinter'
class UserBlueprint < Blueprinter::Base
identifier :id
fields :name, :email
view :extended do
fields :created_at, :updated_at
association :posts, blueprint: PostBlueprint
end
end
# Usage
UserBlueprint.render(user)
UserBlueprint.render(user, view: :extended)
UserBlueprint.render_as_hash(users)
Example 3: Authentication with JWT
# Gemfile
gem 'jwt'
gem 'bcrypt'
# lib/json_web_token.rb
class JsonWebToken
SECRET_KEY = Rails.application.credentials.secret_key_base
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY, 'HS256')
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')
HashWithIndifferentAccess.new(decoded[0])
rescue JWT::DecodeError, JWT::ExpiredSignature => e
nil
end
end
# app/controllers/concerns/authenticatable.rb
module Authenticatable
extend ActiveSupport::Concern
included do
before_action :authenticate_request
attr_reader :current_user
end
private
def authenticate_request
token = extract_token
return render_unauthorized unless token
decoded = JsonWebToken.decode(token)
return render_unauthorized unless decoded
@current_user = User.find_by(id: decoded[:user_id])
render_unauthorized unless @current_user
end
def extract_token
header = request.headers['Authorization']
header&.split(' ')&.last
end
def render_unauthorized
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end
# app/controllers/auth_controller.rb
class AuthController < ApplicationController
skip_before_action :authenticate_request, only: [:login, :register]
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: {
token: token,
user: UserSerializer.new(user)
}
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
def register
user = User.new(user_params)
if user.save
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token, user: user }, status: :created
else
render json: { errors: user.errors }, status: :unprocessable_entity
end
end
def refresh
token = JsonWebToken.encode(user_id: current_user.id)
render json: { token: token }
end
private
def user_params
params.permit(:name, :email, :password, :password_confirmation)
end
end
Example 4: Sinatra API
# Gemfile
source 'https://rubygems.org'
gem 'sinatra'
gem 'sinatra-contrib'
gem 'puma'
gem 'json'
gem 'sequel'
gem 'pg'
# app.rb
require 'sinatra/base'
require 'sinatra/json'
require 'sequel'
class Api < Sinatra::Base
helpers Sinatra::JSON
configure do
set :database, Sequel.connect(ENV['DATABASE_URL'])
end
before do
content_type :json
end
# Error handling
error Sequel::NoMatchingRow do
status 404
json error: 'Resource not found'
end
error do
status 500
json error: 'Internal server error'
end
# Routes
get '/api/users' do
users = settings.database[:users].all
json data: users
end
get '/api/users/:id' do
user = settings.database[:users].where(id: params[:id]).first!
json data: user
end
post '/api/users' do
data = JSON.parse(request.body.read)
id = settings.database[:users].insert(
name: data['name'],
email: data['email'],
created_at: Time.now
)
user = settings.database[:users].where(id: id).first
status 201
json data: user
end
put '/api/users/:id' do
data = JSON.parse(request.body.read)
settings.database[:users]
.where(id: params[:id])
.update(name: data['name'], email: data['email'])
user = settings.database[:users].where(id: params[:id]).first!
json data: user
end
delete '/api/users/:id' do
settings.database[:users].where(id: params[:id]).delete
status 204
end
end
# config.ru
require './app'
run Api
# With modular structure
# routes/users.rb
class UsersRoutes < Sinatra::Base
helpers Sinatra::JSON
get '/api/users' do
json data: User.all
end
# ... more routes
end
# app.rb
class Api < Sinatra::Base
use UsersRoutes
use PostsRoutes
end
Example 5: Grape API Framework
# Gemfile
gem 'grape'
gem 'grape-entity'
# app/api/v1/base.rb
module V1
class Base < Grape::API
version 'v1', using: :path
format :json
prefix :api
# Error handling
rescue_from ActiveRecord::RecordNotFound do |e|
error!({ error: 'Not found' }, 404)
end
rescue_from Grape::Exceptions::ValidationErrors do |e|
error!({ errors: e.full_messages }, 400)
end
# Authentication helper
helpers do
def current_user
@current_user ||= authenticate!
end
def authenticate!
token = headers['Authorization']&.split(' ')&.last
return nil unless token
decoded = JsonWebToken.decode(token)
User.find_by(id: decoded[:user_id]) if decoded
end
def authenticate_user!
error!('Unauthorized', 401) unless current_user
end
end
mount V1::Users
mount V1::Posts
end
end
# app/api/v1/users.rb
module V1
class Users < Grape::API
resource :users do
desc 'Get all users'
params do
optional :page, type: Integer, default: 1
optional :per_page, type: Integer, default: 25, values: 1..100
end
get do
users = User.page(params[:page]).per(params[:per_page])
present users, with: Entities::User
end
desc 'Get a user'
params do
requires :id, type: Integer
end
get ':id' do
user = User.find(params[:id])
present user, with: Entities::User
end
desc 'Create a user'
params do
requires :name, type: String
requires :email, type: String
optional :role, type: String, default: 'user'
end
post do
user = User.create!(declared(params))
present user, with: Entities::User
end
desc 'Update a user'
params do
requires :id, type: Integer
optional :name, type: String
optional :email, type: String
end
put ':id' do
user = User.find(params[:id])
user.update!(declared(params, include_missing: false))
present user, with: Entities::User
end
desc 'Delete a user'
delete ':id' do
User.find(params[:id]).destroy
status 204
end
end
end
end
# app/api/entities/user.rb
module Entities
class User < Grape::Entity
expose :id
expose :name
expose :email
expose :created_at, format_with: :iso_timestamp
format_with(:iso_timestamp) { |dt| dt.iso8601 }
expose :posts, using: Entities::Post, if: :include_posts
end
end
Example 6: Testing APIs
# spec/requests/users_spec.rb
require 'rails_helper'
RSpec.describe 'Users API', type: :request do
let(:user) { create(:user) }
let(:token) { JsonWebToken.encode(user_id: user.id) }
let(:headers) { { 'Authorization' => "Bearer #{token}" } }
describe 'GET /api/v1/users' do
before { create_list(:user, 10) }
it 'returns all users' do
get '/api/v1/users', headers: headers
expect(response).to have_http_status(:ok)
expect(json['data'].size).to eq(11)
end
it 'supports pagination' do
get '/api/v1/users', params: { page: 1, per_page: 5 }, headers: headers
expect(json['data'].size).to eq(5)
expect(json['meta']['page']).to eq(1)
end
end
describe 'POST /api/v1/users' do
let(:valid_params) do
{ user: { name: 'Test', email: 'test@example.com' } }
end
it 'creates a user' do
expect {
post '/api/v1/users', params: valid_params, headers: headers
}.to change(User, :count).by(1)
expect(response).to have_http_status(:created)
expect(json['data']['name']).to eq('Test')
end
it 'returns errors for invalid data' do
post '/api/v1/users',
params: { user: { name: '' } },
headers: headers
expect(response).to have_http_status(:unprocessable_entity)
expect(json['errors']).to be_present
end
end
private
def json
JSON.parse(response.body)
end
end
# spec/support/request_helpers.rb
module RequestHelpers
def json_response
JSON.parse(response.body)
end
def auth_headers(user)
token = JsonWebToken.encode(user_id: user.id)
{ 'Authorization' => "Bearer #{token}" }
end
end
RSpec.configure do |config|
config.include RequestHelpers, type: :request
end
Key Takeaways:
- Rails API mode for full-featured APIs
- Sinatra for lightweight services
- Grape for dedicated API projects
- Use serializers for consistent responses
- JWT for stateless authentication
Imitation
Challenge 1: Build a RESTful API
Task: Create a complete CRUD API for a blog with posts and comments.
Solution
# app/controllers/api/v1/posts_controller.rb
module Api
module V1
class PostsController < ApplicationController
before_action :authenticate_user!
before_action :set_post, only: [:show, :update, :destroy]
before_action :authorize_post, only: [:update, :destroy]
def index
posts = Post.includes(:author, :tags)
.published
.order(created_at: :desc)
.page(params[:page])
render json: {
data: posts.map { |p| PostSerializer.new(p) },
meta: {
page: posts.current_page,
total_pages: posts.total_pages,
total_count: posts.total_count
}
}
end
def show
render json: { data: PostSerializer.new(@post, include: [:comments]) }
end
def create
post = current_user.posts.build(post_params)
if post.save
render json: { data: PostSerializer.new(post) }, status: :created
else
render json: { errors: post.errors }, status: :unprocessable_entity
end
end
def update
if @post.update(post_params)
render json: { data: PostSerializer.new(@post) }
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])
end
def authorize_post
unless @post.author == current_user || current_user.admin?
render json: { error: 'Forbidden' }, status: :forbidden
end
end
def post_params
params.require(:post).permit(:title, :body, :published, tag_ids: [])
end
end
end
end
Practice
Exercise 1: Rate Limiting
Difficulty: Intermediate
Implement rate limiting middleware:
- Track requests per IP
- Return 429 when exceeded
- Include rate limit headers
Exercise 2: API Versioning
Difficulty: Advanced
Build a versioning system:
- Support header-based versioning
- Maintain backward compatibility
- Deprecation warnings
Summary
What you learned:
- Rails API development
- Sinatra and Grape frameworks
- JWT authentication
- Serialization patterns
- API testing
Next Steps:
- Read: Ruby OOP
- Practice: Add caching to your API
- Explore: GraphQL with Ruby
