CrackedRuby CrackedRuby

Overview

The REST Maturity Model, also known as the Richardson Maturity Model, provides a classification system for web service APIs based on how closely they adhere to REST architectural constraints. Leonard Richardson introduced this model to describe the spectrum from basic RPC-style services to fully RESTful hypermedia-driven APIs. The model defines four distinct levels (0 through 3), each representing increasing adherence to REST principles and corresponding benefits in terms of scalability, maintainability, and discoverability.

The model serves as both a descriptive tool for analyzing existing APIs and a prescriptive guide for API design. Many production APIs operate at Levels 1 or 2, where developers balance REST principles against practical implementation constraints. Level 3, which includes hypermedia controls, remains less common despite representing the purest form of REST as Roy Fielding originally conceived it.

Understanding maturity levels helps teams make informed decisions about API design trade-offs. A Level 0 API might suffice for internal services with tight coupling, while public APIs often target Level 2 or 3 to maximize flexibility and client independence. The model also facilitates communication among teams by providing shared vocabulary for discussing API design choices.

# Level 0: Single endpoint, single method
POST /api
{ "action": "getUser", "id": 123 }

# Level 1: Multiple resource endpoints
POST /api/users
GET /api/users/123

# Level 2: HTTP verbs with proper semantics
GET /api/users/123
PUT /api/users/123
DELETE /api/users/123

# Level 3: Hypermedia controls
GET /api/users/123
{
  "id": 123,
  "name": "Alice",
  "_links": {
    "self": { "href": "/api/users/123" },
    "orders": { "href": "/api/users/123/orders" }
  }
}

Key Principles

The REST Maturity Model organizes API design into four levels, each building on the constraints and benefits of previous levels. These levels represent discrete stages in adopting REST architectural principles rather than a continuous spectrum.

Level 0: The Swamp of POX (Plain Old XML)

Level 0 represents the most basic form of web service, where HTTP serves merely as a transport mechanism. APIs at this level expose a single URI endpoint and typically use a single HTTP method (usually POST) for all operations. The service operates as a remote procedure call (RPC) system, with the request body containing all operation details encoded in XML or JSON.

This level offers minimal benefits from HTTP infrastructure. Responses cannot be cached effectively because all requests use POST. Intermediaries like proxies and load balancers cannot route or filter requests based on URI patterns or HTTP methods. The client must understand the entire custom protocol embedded in request and response payloads.

# All operations go to one endpoint
endpoint = "http://api.example.com/service"

# Request contains operation details
request_body = {
  method: "getUserDetails",
  params: { user_id: 123 }
}

response = HTTP.post(endpoint, json: request_body)

Level 1: Resources

Level 1 introduces the concept of multiple resource-based URIs. Instead of tunneling all requests through a single endpoint, the API exposes distinct URIs for different resources. Each resource type gets its own endpoint, allowing clients to address specific entities directly.

This level enables some URI-based routing and filtering by intermediaries. Clients can bookmark or cache specific resource URIs. However, the API still typically relies on POST for all operations, missing HTTP's semantic richness. The service treats HTTP primarily as a transport layer with multiple addresses rather than a single tunnel.

# Different resources have different URIs
users_uri = "http://api.example.com/users"
orders_uri = "http://api.example.com/orders"
products_uri = "http://api.example.com/products"

# Still using POST for everything
response = HTTP.post("#{users_uri}/123", json: { action: "get" })

Level 2: HTTP Verbs

Level 2 incorporates proper HTTP verb semantics, using GET for retrieval, POST for creation, PUT or PATCH for updates, and DELETE for removal. This level respects HTTP's defined meaning for each method, including idempotency guarantees and safety constraints. GET and HEAD requests produce no side effects, making them cacheable. PUT and DELETE operations are idempotent, meaning repeated identical requests produce the same result.

Status codes become meaningful at this level. The API returns 200 for successful operations, 201 for created resources, 404 for missing resources, and appropriate error codes for various failure conditions. Clients can interpret responses based on standardized HTTP semantics rather than parsing custom response payloads for success or failure indicators.

Caching infrastructure becomes effective at Level 2. GET requests can specify cache control headers, and intermediaries can cache responses according to standard HTTP caching rules. Conditional requests using ETags or Last-Modified headers reduce bandwidth and server load.

# Proper HTTP verbs
HTTP.get("http://api.example.com/users/123")           # Retrieve
HTTP.post("http://api.example.com/users", json: data)  # Create
HTTP.put("http://api.example.com/users/123", json: data)  # Update
HTTP.delete("http://api.example.com/users/123")        # Delete

# Meaningful status codes
response.status  # 200, 201, 404, 500, etc.

Level 3: Hypermedia Controls (HATEOAS)

Level 3 represents full REST maturity by incorporating Hypermedia As The Engine Of Application State (HATEOAS). Responses include links to related resources and available actions, making the API self-descriptive and discoverable. Clients navigate the API by following links rather than constructing URIs based on documentation or conventions.

Hypermedia controls decouple clients from URI structures. The server can change URI formats or introduce new resources without breaking clients that follow links instead of hardcoding paths. The API becomes more evolvable, as new capabilities can be advertised through links without requiring client updates.

This level enables sophisticated client behaviors like automatically discovering pagination links, related resources, and state transitions. A client retrieving an order can follow links to the associated customer, line items, and available actions like cancellation or refund without prior knowledge of these relationships.

# Response includes hypermedia controls
{
  "id": 123,
  "name": "Alice",
  "email": "alice@example.com",
  "status": "active",
  "_links": {
    "self": {
      "href": "/api/users/123"
    },
    "orders": {
      "href": "/api/users/123/orders",
      "title": "User's orders"
    },
    "deactivate": {
      "href": "/api/users/123/deactivate",
      "method": "POST"
    }
  }
}

The progression through these levels represents increasing alignment with REST constraints and corresponding improvements in scalability, evolvability, and client-server independence. Each level builds on previous levels' foundations, adding new capabilities without removing earlier benefits.

Ruby Implementation

Ruby applications commonly implement REST APIs using frameworks like Rails or Sinatra. The implementation approach varies significantly across maturity levels, with Rails conventions naturally supporting Level 2 and requiring additional work for Level 3.

Level 0 Implementation

A Level 0 API in Ruby typically uses a single controller action that dispatches based on request parameters. This approach provides no structure around resources or HTTP semantics.

# Sinatra Level 0 API
require 'sinatra'
require 'json'

post '/api' do
  request_data = JSON.parse(request.body.read)
  
  case request_data['action']
  when 'getUser'
    user = User.find(request_data['id'])
    { status: 'success', data: user.to_h }.to_json
  when 'createUser'
    user = User.create(request_data['params'])
    { status: 'success', data: user.to_h }.to_json
  when 'deleteUser'
    User.delete(request_data['id'])
    { status: 'success' }.to_json
  else
    { status: 'error', message: 'Unknown action' }.to_json
  end
end

Level 1 Implementation

Level 1 introduces resource-based routing while maintaining verb limitations. Rails routing supports this pattern, though it underutilizes the framework's REST conventions.

# Rails Level 1 API
class UsersController < ApplicationController
  def endpoint
    case params[:action_type]
    when 'show'
      user = User.find(params[:id])
      render json: { status: 'success', data: user }
    when 'create'
      user = User.create(user_params)
      render json: { status: 'success', data: user }
    when 'update'
      user = User.find(params[:id])
      user.update(user_params)
      render json: { status: 'success', data: user }
    end
  end
  
  private
  
  def user_params
    params.require(:user).permit(:name, :email)
  end
end

# routes.rb
Rails.application.routes.draw do
  post 'users/:id', to: 'users#endpoint'
  post 'orders/:id', to: 'orders#endpoint'
  post 'products/:id', to: 'products#endpoint'
end

Level 2 Implementation

Level 2 aligns naturally with Rails conventions. RESTful routing maps HTTP verbs to controller actions, and Rails generates appropriate status codes by default. This represents the most common production implementation level.

# Rails Level 2 API
class UsersController < ApplicationController
  before_action :set_user, only: [:show, :update, :destroy]
  
  # GET /users
  def index
    @users = User.all
    render json: @users
  end
  
  # GET /users/:id
  def show
    render json: @user
  end
  
  # POST /users
  def create
    @user = User.new(user_params)
    
    if @user.save
      render json: @user, status: :created, location: @user
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end
  
  # PUT/PATCH /users/:id
  def update
    if @user.update(user_params)
      render json: @user
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end
  
  # DELETE /users/:id
  def destroy
    @user.destroy
    head :no_content
  end
  
  private
  
  def set_user
    @user = User.find(params[:id])
  end
  
  def user_params
    params.require(:user).permit(:name, :email, :status)
  end
end

# routes.rb
Rails.application.routes.draw do
  resources :users
  resources :orders
  resources :products
end

Level 3 Implementation

Level 3 requires explicit hypermedia link generation. Rails lacks built-in HATEOAS support, requiring gems or custom serializers to include links in responses.

# Using custom serializer for hypermedia
class UserSerializer
  include Rails.application.routes.url_helpers
  
  def initialize(user)
    @user = user
  end
  
  def as_json
    {
      id: @user.id,
      name: @user.name,
      email: @user.email,
      status: @user.status,
      _links: build_links
    }
  end
  
  private
  
  def build_links
    links = {
      self: {
        href: user_path(@user)
      },
      orders: {
        href: user_orders_path(@user),
        title: "User's orders"
      }
    }
    
    # Include action links based on state
    if @user.active?
      links[:deactivate] = {
        href: deactivate_user_path(@user),
        method: 'POST'
      }
    else
      links[:activate] = {
        href: activate_user_path(@user),
        method: 'POST'
      }
    end
    
    links
  end
end

# Controller using hypermedia serializer
class UsersController < ApplicationController
  def show
    user = User.find(params[:id])
    render json: UserSerializer.new(user).as_json
  end
  
  def index
    users = User.all
    render json: {
      users: users.map { |u| UserSerializer.new(u).as_json },
      _links: {
        self: { href: users_path },
        next: { href: users_path(page: params[:page].to_i + 1) }
      }
    }
  end
end

The ROAR gem provides more structured hypermedia support with representers that define link relationships declaratively.

# Using ROAR gem for Level 3
require 'roar/json/hal'

class UserRepresenter < Roar::Decorator
  include Roar::JSON::HAL
  
  property :id
  property :name
  property :email
  property :status
  
  link :self do
    "http://api.example.com/users/#{represented.id}"
  end
  
  link :orders do
    "http://api.example.com/users/#{represented.id}/orders"
  end
  
  link :deactivate do
    {
      href: "http://api.example.com/users/#{represented.id}/deactivate",
      method: 'POST'
    } if represented.active?
  end
end

# Controller
class UsersController < ApplicationController
  def show
    user = User.find(params[:id])
    render json: UserRepresenter.new(user).to_json
  end
end

Practical Examples

Level 0: Legacy SOAP-style API

A legacy system exposes all operations through a single endpoint, treating HTTP as a transport protocol only.

# Client code for Level 0 API
require 'net/http'
require 'json'

class LegacyApiClient
  def initialize(base_url)
    @base_url = base_url
    @endpoint = URI("#{base_url}/api")
  end
  
  def get_user(user_id)
    make_request('getUser', id: user_id)
  end
  
  def create_user(name, email)
    make_request('createUser', name: name, email: email)
  end
  
  def update_user(user_id, attributes)
    make_request('updateUser', id: user_id, attributes: attributes)
  end
  
  def delete_user(user_id)
    make_request('deleteUser', id: user_id)
  end
  
  private
  
  def make_request(action, params)
    http = Net::HTTP.new(@endpoint.host, @endpoint.port)
    request = Net::HTTP::Post.new(@endpoint.path)
    request.content_type = 'application/json'
    request.body = { action: action, params: params }.to_json
    
    response = http.request(request)
    JSON.parse(response.body)
  end
end

# Usage
client = LegacyApiClient.new('http://api.example.com')
result = client.get_user(123)
# All operations POST to the same endpoint

Level 1: Resource-oriented Structure

The API introduces separate URIs for different resources but maintains limited HTTP verb usage.

# Rails API at Level 1
class Api::V1::UsersController < ApplicationController
  def handle_request
    operation = params[:operation]
    user_id = params[:id]
    
    case operation
    when 'retrieve'
      user = User.find(user_id)
      render json: { success: true, user: user }
    when 'modify'
      user = User.find(user_id)
      user.update(user_params)
      render json: { success: true, user: user }
    when 'remove'
      user = User.find(user_id)
      user.destroy
      render json: { success: true }
    end
  rescue ActiveRecord::RecordNotFound
    render json: { success: false, error: 'User not found' }, status: 404
  end
  
  private
  
  def user_params
    params.permit(:name, :email)
  end
end

# routes.rb
post 'api/v1/users/:id', to: 'api/v1/users#handle_request'

# Client code
class Level1ApiClient
  def get_user(id)
    HTTP.post("http://api.example.com/api/v1/users/#{id}",
              json: { operation: 'retrieve' })
  end
  
  def update_user(id, attrs)
    HTTP.post("http://api.example.com/api/v1/users/#{id}",
              json: { operation: 'modify', user: attrs })
  end
end

Level 2: Full HTTP Verb Support

A production-ready API using proper HTTP semantics, status codes, and content negotiation.

# Rails API at Level 2 with proper error handling
class Api::V2::UsersController < ApplicationController
  before_action :set_user, only: [:show, :update, :destroy]
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
  
  def index
    @users = User.page(params[:page]).per(20)
    
    response.headers['X-Total-Count'] = @users.total_count.to_s
    response.headers['X-Page'] = params[:page] || '1'
    
    render json: @users
  end
  
  def show
    if stale?(@user)
      render json: @user
    end
  end
  
  def create
    @user = User.new(user_params)
    @user.save!
    
    render json: @user, status: :created, location: api_v2_user_url(@user)
  end
  
  def update
    @user.update!(user_params)
    render json: @user
  end
  
  def destroy
    @user.destroy
    head :no_content
  end
  
  private
  
  def set_user
    @user = User.find(params[:id])
  end
  
  def user_params
    params.require(:user).permit(:name, :email)
  end
  
  def not_found
    render json: { error: 'Resource not found' }, status: :not_found
  end
  
  def unprocessable_entity(exception)
    render json: { errors: exception.record.errors }, status: :unprocessable_entity
  end
end

# Client code leveraging HTTP semantics
class Level2ApiClient
  def get_user(id)
    response = HTTP.get("http://api.example.com/api/v2/users/#{id}")
    
    case response.status
    when 200
      JSON.parse(response.body)
    when 304
      # Use cached version
      @cached_user
    when 404
      raise UserNotFound
    end
  end
  
  def create_user(attrs)
    response = HTTP.post("http://api.example.com/api/v2/users",
                        json: { user: attrs })
    
    if response.status == 201
      location = response.headers['Location']
      JSON.parse(response.body)
    else
      handle_errors(response)
    end
  end
  
  def update_user(id, attrs)
    response = HTTP.put("http://api.example.com/api/v2/users/#{id}",
                       json: { user: attrs })
    
    response.status == 200 ? JSON.parse(response.body) : handle_errors(response)
  end
end

Level 3: Hypermedia-driven API

A fully RESTful API where clients discover capabilities through hypermedia controls.

# Complete Level 3 implementation with state-based links
class Api::V3::UsersController < ApplicationController
  def show
    user = User.find(params[:id])
    render json: UserRepresenter.new(user).to_hal
  end
  
  def index
    users = User.page(params[:page]).per(20)
    
    render json: {
      _embedded: {
        users: users.map { |u| UserRepresenter.new(u).to_hal }
      },
      _links: pagination_links(users),
      total: users.total_count
    }
  end
  
  private
  
  def pagination_links(collection)
    links = {
      self: { href: api_v3_users_url(page: params[:page]) }
    }
    
    if collection.next_page
      links[:next] = { href: api_v3_users_url(page: collection.next_page) }
    end
    
    if collection.prev_page
      links[:prev] = { href: api_v3_users_url(page: collection.prev_page) }
    end
    
    links[:first] = { href: api_v3_users_url(page: 1) }
    links[:last] = { href: api_v3_users_url(page: collection.total_pages) }
    
    links
  end
end

# Hypermedia-aware client
class HypermediaClient
  def initialize(entry_point)
    @current_url = entry_point
  end
  
  def navigate_to(link_name)
    response = HTTP.get(@current_url)
    data = JSON.parse(response.body)
    
    if data['_links'] && data['_links'][link_name]
      @current_url = data['_links'][link_name]['href']
      self
    else
      raise "Link #{link_name} not available"
    end
  end
  
  def get
    response = HTTP.get(@current_url)
    JSON.parse(response.body)
  end
  
  def follow_link(data, link_name)
    if data['_links'] && data['_links'][link_name]
      new_url = data['_links'][link_name]['href']
      response = HTTP.get(new_url)
      JSON.parse(response.body)
    end
  end
end

# Usage - client discovers capabilities
client = HypermediaClient.new('http://api.example.com/api/v3')
users_data = client.navigate_to('users').get

# Follow embedded links without knowing URL structure
first_user = users_data['_embedded']['users'].first
orders = client.follow_link(first_user, 'orders')

# Navigate pagination
if users_data['_links']['next']
  next_page = client.navigate_to('next').get
end

Design Considerations

Choosing the appropriate maturity level involves balancing REST principles against practical constraints like development time, client requirements, and infrastructure capabilities.

When Level 0 Suffices

Level 0 remains acceptable for internal systems with tightly coupled clients and servers under unified control. When both sides evolve together, the flexibility benefits of higher maturity levels provide minimal value. Batch processing systems and internal background jobs often operate effectively at Level 0, where simplicity outweighs architectural purity.

Legacy integration scenarios sometimes force Level 0 approaches. When interfacing with systems that expose RPC-style interfaces, building a Level 2 or 3 wrapper adds complexity without enabling new capabilities. The wrapper would simply translate REST calls into RPC calls, introducing an unnecessary translation layer.

Level 1 Trade-offs

Level 1 provides organizational benefits through resource-based URIs while avoiding the full commitment to HTTP semantics. This level suits scenarios where URI structure matters for routing or monitoring but verb semantics remain unimportant. Load balancers can route based on URI patterns, and logging systems can categorize traffic by resource type.

Organizations migrating from Level 0 sometimes adopt Level 1 as an intermediate step. Resource-based URIs require less client changes than full HTTP verb adoption. However, stopping at Level 1 captures few REST benefits while adding URL management complexity. Teams should view Level 1 as a waypoint rather than a destination.

Level 2 as the Pragmatic Standard

Most production APIs target Level 2, balancing REST benefits against implementation complexity. HTTP verb semantics enable effective caching, and standard status codes simplify error handling. Infrastructure like CDNs and reverse proxies work effectively with Level 2 APIs without custom configuration.

Level 2 aligns with common framework conventions. Rails, Django, and Express naturally support Level 2 APIs through their routing mechanisms. Development teams familiar with these frameworks can implement Level 2 APIs efficiently using existing knowledge and tools.

Browser-based clients benefit significantly from Level 2. Caching mechanisms operate correctly when GET requests truly have no side effects. Form submissions and AJAX calls map naturally to POST and PUT operations. Client-side frameworks like React and Vue interact cleanly with Level 2 APIs without requiring custom HTTP adapters.

The primary limitation of Level 2 involves client knowledge requirements. Clients must understand URI construction patterns and relationship between resources. Documentation must explicitly describe available endpoints, expected parameters, and response formats. API evolution requires careful versioning to avoid breaking client assumptions about URIs and resource relationships.

Level 3 Considerations

Level 3 maximizes server flexibility and client adaptability through hypermedia controls. The server controls URI structure completely, changing paths without breaking clients that follow links. New resources and capabilities appear through links without requiring client updates or documentation changes.

However, Level 3 introduces significant complexity in both implementation and client design. Servers must generate contextually appropriate links based on resource state and user permissions. Clients must include link-following logic and cannot optimize request patterns through hardcoded knowledge of resource relationships.

Mobile clients face particular challenges with Level 3. The discovery-oriented approach increases round trips, as clients must fetch resources to discover related links. Latency-sensitive applications benefit from knowing relationship structures upfront, allowing optimized request batching and prefetching. Offline-first mobile apps struggle with Level 3's reliance on runtime link discovery.

Level 3 provides maximum value for long-lived public APIs serving diverse clients with varying needs. When an API supports multiple client types across years of evolution, hypermedia controls justify their complexity through reduced coupling and improved evolvability. Internal APIs with known clients and shorter lifespans rarely justify Level 3's overhead.

Performance Implications

Lower maturity levels sometimes enable better performance through simplification. Level 0 APIs can batch multiple operations into single requests, reducing round trips. GraphQL and custom batch endpoints provide similar benefits without REST constraints.

Level 2 and 3 sometimes require more requests to achieve the same outcome as a custom Level 0 operation. Fetching a user with their orders, preferences, and activity requires multiple REST requests but could be a single Level 0 call. This trade-off exchanges performance for flexibility and caching benefits.

Caching effectiveness increases with maturity level. Level 0 provides almost no caching opportunities. Level 1 enables some URI-based caching. Level 2 allows full HTTP caching with conditional requests. Level 3 maintains Level 2 caching while enabling more aggressive prefetching through link discovery.

Security Considerations

Higher maturity levels simplify security implementation through standard HTTP patterns. Authentication headers and authorization checks map naturally to Level 2 verbs. GET requests require read permissions while POST, PUT, and DELETE need write access.

Level 3 enables dynamic permission representation through conditional link presence. An API can include edit and delete links only for resources the user can modify. Clients automatically receive capability-based interfaces without complex permission checking logic.

Lower levels require custom security mechanisms. Level 0 must embed authentication in request payloads and define custom authorization schemes. This approach increases implementation complexity and error potential compared to leveraging standard HTTP authentication and authorization patterns.

Tools & Ecosystem

Ruby's ecosystem includes various libraries and frameworks supporting different maturity levels, from basic routing to full hypermedia implementations.

Rails and RESTful Routing

Rails provides excellent Level 2 support through resourceful routing. The framework generates standard routes mapping HTTP verbs to controller actions.

# config/routes.rb
Rails.application.routes.draw do
  resources :users do
    resources :posts
    member do
      post :activate
      post :deactivate
    end
    collection do
      get :search
    end
  end
end

# Generates routes:
# GET    /users          -> users#index
# POST   /users          -> users#create
# GET    /users/:id      -> users#show
# PUT    /users/:id      -> users#update
# DELETE /users/:id      -> users#destroy
# GET    /users/:id/posts -> posts#index (nested)

Rails responders simplify status code and format handling for common patterns.

class UsersController < ApplicationController
  respond_to :json
  
  def create
    @user = User.create(user_params)
    respond_with @user, location: api_user_url(@user)
  end
  
  def update
    @user = User.find(params[:id])
    @user.update(user_params)
    respond_with @user
  end
end

Grape for API-focused Development

Grape provides a DSL specifically for building REST APIs with strong Level 2 support.

class UsersAPI < Grape::API
  format :json
  
  resource :users do
    desc 'Return a list of users'
    params do
      optional :page, type: Integer
      optional :per_page, type: Integer, values: 1..100
    end
    get do
      users = User.page(params[:page]).per(params[:per_page])
      present users, with: UserEntity
    end
    
    desc 'Create a user'
    params do
      requires :name, type: String
      requires :email, type: String
    end
    post do
      user = User.create!(declared(params))
      present user, with: UserEntity
    end
    
    route_param :id do
      desc 'Return a user'
      get do
        user = User.find(params[:id])
        present user, with: UserEntity
      end
      
      desc 'Update a user'
      params do
        optional :name, type: String
        optional :email, type: String
      end
      put do
        user = User.find(params[:id])
        user.update!(declared(params, include_missing: false))
        present user, with: UserEntity
      end
      
      delete do
        User.find(params[:id]).destroy
        status 204
      end
    end
  end
end

ROAR for Level 3 Hypermedia

ROAR implements HAL and other hypermedia formats, providing Level 3 support.

require 'roar/json/hal'

module UserRepresenter
  include Roar::JSON::HAL
  
  property :id
  property :name
  property :email
  
  link :self do
    user_url(represented.id)
  end
  
  link :posts do
    user_posts_url(represented.id)
  end
  
  link :edit do
    edit_user_url(represented.id) if can?(:edit, represented)
  end
  
  link :deactivate do
    {
      href: deactivate_user_url(represented.id),
      method: 'POST'
    } if represented.active?
  end
end

# In controller
def show
  user = User.find(params[:id])
  render json: user.extend(UserRepresenter).to_json
end

ActiveModel Serializers

ActiveModel Serializers facilitate Level 2 JSON API responses with relationship support.

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :email, :created_at
  
  has_many :posts
  belongs_to :organization
  
  link :self do
    user_url(object)
  end
end

# In controller
def show
  user = User.find(params[:id])
  render json: user
end

# Output includes relationships and links
{
  "data": {
    "id": "1",
    "type": "users",
    "attributes": {
      "name": "Alice",
      "email": "alice@example.com"
    },
    "relationships": {
      "posts": {
        "links": {
          "related": "http://example.com/users/1/posts"
        }
      }
    },
    "links": {
      "self": "http://example.com/users/1"
    }
  }
}

JsonApi-rb

JsonApi-rb implements the JSON:API specification for standardized Level 3 responses.

# Serializer
class SerializableUser < JSONAPI::Serializable::Resource
  type 'users'
  
  attributes :name, :email
  
  has_many :posts
  
  link :self do
    @url_helpers.user_url(@object)
  end
end

# In controller
def index
  users = User.all
  render jsonapi: users
end

def show
  user = User.find(params[:id])
  render jsonapi: user, include: [:posts]
end

Faraday for API Clients

Faraday provides a flexible HTTP client supporting different maturity levels.

# Level 2 client
class ApiClient
  def initialize(base_url)
    @conn = Faraday.new(url: base_url) do |f|
      f.request :json
      f.response :json
      f.adapter Faraday.default_adapter
    end
  end
  
  def get_user(id)
    response = @conn.get("/users/#{id}")
    handle_response(response)
  end
  
  def create_user(attrs)
    response = @conn.post('/users', attrs)
    handle_response(response)
  end
  
  def update_user(id, attrs)
    response = @conn.put("/users/#{id}", attrs)
    handle_response(response)
  end
  
  private
  
  def handle_response(response)
    case response.status
    when 200..299
      response.body
    when 404
      raise NotFoundError
    when 422
      raise ValidationError, response.body['errors']
    else
      raise ApiError, response.status
    end
  end
end

Her for Level 3 Client

Her provides an ORM-like interface for hypermedia APIs.

# Configure Her with hypermedia support
class User
  include Her::Model
  
  has_many :posts
  belongs_to :organization
end

# Usage follows ActiveRecord patterns
user = User.find(1)
user.posts  # Follows hypermedia links automatically
user.organization

# Her handles link following transparently
users = User.all
next_page = users.next_page  # If pagination links present

Common Pitfalls

Mixing Maturity Levels Inconsistently

APIs often inconsistently mix levels across endpoints, creating confusion and reducing caching effectiveness. An API might use proper verbs for user resources but POST for everything related to orders.

# Inconsistent - mixes levels
class Api::UsersController < ApplicationController
  def show
    render json: User.find(params[:id])  # Level 2
  end
  
  def update
    # Level 2
    user = User.find(params[:id])
    user.update(user_params)
    render json: user
  end
end

class Api::OrdersController < ApplicationController
  def process
    # Level 0 - all operations via POST with action parameter
    case params[:action_type]
    when 'get'
      render json: Order.find(params[:id])
    when 'update'
      order = Order.find(params[:id])
      order.update(order_params)
      render json: order
    end
  end
end

# Consistent Level 2 approach
class Api::OrdersController < ApplicationController
  def show
    render json: Order.find(params[:id])
  end
  
  def update
    order = Order.find(params[:id])
    order.update(order_params)
    render json: order
  end
end

Misusing HTTP Verbs

Developers frequently misuse HTTP verbs, particularly using GET for state-changing operations or POST for everything. This breaks caching and violates HTTP semantics.

# WRONG - GET with side effects
class ArticlesController < ApplicationController
  def increment_view_count
    @article = Article.find(params[:id])
    @article.increment!(:view_count)  # State change in GET
    render json: @article
  end
end

# routes.rb
get 'articles/:id/view', to: 'articles#increment_view_count'

# RIGHT - POST for state changes
class ArticlesController < ApplicationController
  def increment_view_count
    @article = Article.find(params[:id])
    @article.increment!(:view_count)
    render json: @article
  end
end

# routes.rb
post 'articles/:id/view', to: 'articles#increment_view_count'

Ignoring Idempotency

PUT and DELETE should be idempotent, meaning repeated requests produce identical results. Implementations often violate this principle.

# WRONG - non-idempotent PUT
class UsersController < ApplicationController
  def update
    @user = User.find(params[:id])
    @user.login_count += 1  # Side effect changes on each call
    @user.update(user_params)
    render json: @user
  end
end

# RIGHT - idempotent PUT
class UsersController < ApplicationController
  def update
    @user = User.find(params[:id])
    @user.update(user_params)  # Only updates specified fields
    render json: @user
  end
  
  # Separate non-idempotent operation
  def record_login
    @user = User.find(params[:id])
    @user.increment!(:login_count)
    render json: @user
  end
end

# routes.rb
put 'users/:id', to: 'users#update'
post 'users/:id/login', to: 'users#record_login'

Poor Status Code Usage

APIs commonly misuse status codes, returning 200 for errors or failing to distinguish between different error types.

# WRONG - returns 200 for errors
class UsersController < ApplicationController
  def show
    user = User.find_by(id: params[:id])
    if user
      render json: { success: true, data: user }
    else
      render json: { success: false, error: 'User not found' }  # Still 200
    end
  end
  
  def create
    user = User.new(user_params)
    if user.save
      render json: { success: true, data: user }
    else
      render json: { success: false, errors: user.errors }  # Still 200
    end
  end
end

# RIGHT - proper status codes
class UsersController < ApplicationController
  def show
    user = User.find(params[:id])  # Raises if not found
    render json: user
  rescue ActiveRecord::RecordNotFound
    render json: { error: 'User not found' }, status: :not_found
  end
  
  def create
    user = User.new(user_params)
    if user.save
      render json: user, status: :created, location: user
    else
      render json: { errors: user.errors }, status: :unprocessable_entity
    end
  end
end

Brittle Link Construction in Level 3

Level 3 implementations often construct links manually, creating fragile code when routes change.

# WRONG - hardcoded URLs
class UserRepresenter
  def as_json
    {
      id: user.id,
      name: user.name,
      _links: {
        self: { href: "/api/users/#{user.id}" },  # Hardcoded
        posts: { href: "/api/users/#{user.id}/posts" }  # Hardcoded
      }
    }
  end
end

# RIGHT - use URL helpers
class UserRepresenter
  include Rails.application.routes.url_helpers
  
  def as_json
    {
      id: user.id,
      name: user.name,
      _links: {
        self: { href: api_user_url(user) },  # Generated from routes
        posts: { href: api_user_posts_url(user) }  # Generated from routes
      }
    }
  end
end

Overfetching at Level 3

Hypermedia APIs sometimes force excessive requests by requiring link following for basic operations.

# WRONG - requires multiple requests for common operation
def get_user_with_posts(user_id)
  # Request 1: Get user
  user_response = HTTP.get("/api/users/#{user_id}")
  user_data = JSON.parse(user_response.body)
  
  # Request 2: Follow posts link
  posts_url = user_data['_links']['posts']['href']
  posts_response = HTTP.get(posts_url)
  posts_data = JSON.parse(posts_response.body)
  
  # Two requests for common operation
  { user: user_data, posts: posts_data }
end

# RIGHT - support including related resources
class UsersController < ApplicationController
  def show
    user = User.find(params[:id])
    
    response = UserRepresenter.new(user).as_json
    
    # Allow optional embedding
    if params[:include]&.include?('posts')
      response[:_embedded] = {
        posts: user.posts.map { |p| PostRepresenter.new(p).as_json }
      }
    end
    
    render json: response
  end
end

# Client can request: GET /api/users/123?include=posts

Insufficient Error Information

Level 2 and 3 APIs benefit from structured error responses with actionable information.

# WRONG - vague error messages
class UsersController < ApplicationController
  def create
    user = User.new(user_params)
    if user.save
      render json: user, status: :created
    else
      render json: { error: 'Invalid data' }, status: :unprocessable_entity
    end
  end
end

# RIGHT - detailed error structure
class UsersController < ApplicationController
  def create
    user = User.new(user_params)
    if user.save
      render json: user, status: :created
    else
      render json: {
        errors: user.errors.map do |attribute, message|
          {
            field: attribute,
            message: message,
            code: error_code_for(attribute, message)
          }
        end
      }, status: :unprocessable_entity
    end
  end
  
  private
  
  def error_code_for(attribute, message)
    case [attribute, message]
    when [:email, /taken/]
      'email_already_exists'
    when [:email, /invalid/]
      'email_invalid_format'
    else
      'validation_failed'
    end
  end
end

Reference

Maturity Level Comparison

Level Description URI Pattern HTTP Verbs Status Codes Caching Key Benefit
0 Single endpoint RPC /api POST only Custom in body None Simple implementation
1 Resource URIs /api/users/123 POST only Custom in body Limited Resource organization
2 HTTP verbs /api/users/123 GET, POST, PUT, DELETE Standard HTTP Full HTTP infrastructure
3 Hypermedia /api/users/123 GET, POST, PUT, DELETE Standard HTTP Full Runtime discoverability

HTTP Verb Semantics for Level 2+

Verb Purpose Idempotent Safe Request Body Response Body Status Codes
GET Retrieve resource Yes Yes No Resource 200, 304, 404
POST Create or action No No Yes Created resource 201, 400, 422
PUT Replace resource Yes No Yes Updated resource 200, 404
PATCH Partial update No No Yes Updated resource 200, 404
DELETE Remove resource Yes No No Empty or summary 204, 404
HEAD Get headers only Yes Yes No No 200, 304, 404
OPTIONS Get allowed methods Yes Yes No Method list 200

Common Status Codes

Code Name Usage Example Scenario
200 OK Successful GET, PUT, PATCH Resource retrieved or updated
201 Created Successful POST creating resource New user created
204 No Content Successful DELETE User deleted successfully
304 Not Modified Cached resource still valid ETag match on GET
400 Bad Request Malformed request syntax Invalid JSON in request
401 Unauthorized Authentication required Missing or invalid token
403 Forbidden Insufficient permissions Cannot edit other user's resource
404 Not Found Resource does not exist User ID does not exist
422 Unprocessable Entity Validation failed Email format invalid
429 Too Many Requests Rate limit exceeded API quota exceeded
500 Internal Server Error Server error occurred Database connection failed
503 Service Unavailable Temporary outage Maintenance mode

Rails Route Patterns by Level

Level Route Definition Generated Routes Controller Actions
0 post '/api', to: 'api#handle' POST /api handle
1 post '/users/:id', to: 'users#process' POST /users/:id process
2 resources :users GET/POST /users, GET/PUT/DELETE /users/:id index, create, show, update, destroy
3 resources :users (with serializers) Same as Level 2 plus hypermedia Same as Level 2 plus link generation

Hypermedia Link Relations (Level 3)

Relation Meaning Example Usage
self Current resource Link to user resource itself
next Next page Pagination to next page
prev Previous page Pagination to previous page
first First page Jump to first page
last Last page Jump to last page
related Related resource Link from user to their posts
edit Edit form or update endpoint Link to update user
delete Deletion endpoint Link to delete user
collection Parent collection Link from item to collection

Implementation Decision Matrix

Scenario Recommended Level Rationale
Internal microservices 0-1 Simplicity over flexibility
Mobile app backend 2 Caching benefits, standard patterns
Public API 2-3 Documentation vs discoverability trade-off
Long-lived public API 3 Maximum evolvability
High-traffic read-heavy 2 HTTP caching infrastructure
Real-time updates 1-2 WebSocket integration simpler
Legacy system integration 0-1 Match existing patterns
Greenfield project 2 Best balance of benefits and complexity

Ruby Gem Comparison

Gem Maturity Support Learning Curve Best For
Rails routing 2 Low Standard REST APIs
Grape 2 Medium API-first applications
ROAR 3 High Full hypermedia implementation
ActiveModel Serializers 2-3 Medium Rails JSON APIs
JsonApi-rb 3 Medium JSON:API specification compliance
Her 3 client Medium Consuming hypermedia APIs
Faraday Any Low Flexible HTTP client