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 |