
Restful Hateoas
- 243 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
restful-hateoas: A skill for development. This provides functionality for development workflows.
Key points
- restful-hateoas
Restful Hateoas by the numbers
- 243 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,551 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill restful-hateoasAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 243 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use restful-hateoas for development tasks?
Use restful-hateoas for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with restful-hateoas.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use restful-hateoas for development tasks, or when restful-hateoas: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to restful-hateoas: restful-hateoas.
Files
Community RESTful HATEOAS Best Practices
Comprehensive guide to building REST APIs that reach the Glory of REST (Richardson Maturity Level 3) in Ruby on Rails. Contains 47 rules across 9 categories, ordered by the request/response lifecycle — from resource URI design through hypermedia link relations to API evolution.
When to Apply
Reference these guidelines when:
- Designing new REST API endpoints and resource URIs
- Adding hypermedia controls (_links, affordances) to API responses
- Implementing content negotiation with HAL, JSON:API, or vendor media types
- Building paginated, filterable, sortable collection endpoints
- Reviewing APIs for proper HTTP method semantics and status codes
- Evolving APIs without breaking existing clients
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Resource Modeling | CRITICAL | res- |
| 2 | HTTP Method Semantics | CRITICAL | http- |
| 3 | Hypermedia & Link Relations | CRITICAL | link- |
| 4 | Status Codes & Response Headers | HIGH | status- |
| 5 | Content Negotiation & Media Types | HIGH | media- |
| 6 | Collection Patterns | MEDIUM-HIGH | coll- |
| 7 | Error Semantics | MEDIUM | err- |
| 8 | Caching & Conditional Requests | MEDIUM | cache- |
| 9 | API Evolution | LOW-MEDIUM | evolve- |
Quick Reference
1. Resource Modeling (CRITICAL)
- `res-noun-based-uris` - URIs must be nouns, not verbs
- `res-plural-collection-uris` - Always use plural nouns for collections
- `res-limit-nesting-depth` - Limit nested resources to max 2 levels
- `res-model-business-entities` - Model business entities, not database tables
- `res-use-consistent-identifiers` - Use opaque identifiers, never auto-increment IDs
- `res-sub-resources-for-relationships` - Express relationships as sub-resources
2. HTTP Method Semantics (CRITICAL)
- `http-get-must-be-safe` - Keep GET requests free of side effects
- `http-post-for-creation` - Return 201 Created with Location header from POST
- `http-put-for-full-replacement` - Use PUT only for full resource replacement
- `http-patch-for-partial-updates` - PATCH for partial updates with merge semantics
- `http-delete-is-idempotent` - Ensure DELETE is idempotent
- `http-head-for-metadata` - Use HEAD for metadata without body transfer
- `http-idempotency-key` - Use idempotency keys for safe POST retries
3. Hypermedia & Link Relations (CRITICAL)
- `link-self-link-every-resource` - Include a self link in every resource
- `link-related-resource-links` - Link to related resources instead of foreign keys
- `link-action-affordances` - Expose available actions as conditional links
- `link-standard-relation-types` - Use IANA-registered link relation types
- `link-entry-point` - Provide a root API entry point
- `link-pagination-links` - Use hypermedia links for pagination
- `link-embedded-vs-linked` - Choose between embedding and linking
4. Status Codes & Response Headers (HIGH)
- `status-201-with-location` - Return 201 Created with Location header
- `status-204-for-no-content` - Return 204 No Content for empty responses
- `status-409-for-conflicts` - Return 409 Conflict for state conflicts
- `status-202-for-async` - Return 202 Accepted for async operations
- `status-allow-header-on-405` - Return 405 with Allow header for wrong methods
- `status-rate-limit-headers` - Include rate limit headers in API responses
5. Content Negotiation & Media Types (HIGH)
- `media-accept-header-negotiation` - Respect the Accept header for content negotiation
- `media-content-type-in-responses` - Set the correct Content-Type in every response
- `media-vendor-media-types` - Use vendor media types for API versioning
- `media-406-for-unsupported-types` - Return 406 for unsupported media types
6. Collection Patterns (MEDIUM-HIGH)
- `coll-cursor-pagination` - Use cursor-based pagination instead of offset
- `coll-link-header-pagination` - Include pagination links in body and Link header
- `coll-filtering-via-query-params` - Support filtering via typed query parameters
- `coll-sorting-convention` - Support sorting with a standardized sort parameter
- `coll-field-selection` - Support sparse fieldsets via fields parameter
7. Error Semantics (MEDIUM)
- `err-problem-details` - Use Problem Details (RFC 9457) for errors
- `err-validation-errors` - Return structured validation errors
- `err-error-links` - Include recovery links in error responses
- `err-machine-readable-codes` - Use machine-readable error codes
- `err-auth-error-codes` - Distinguish 401 Unauthorized from 403 Forbidden
8. Caching & Conditional Requests (MEDIUM)
- `cache-etag-conditional-get` - Use ETags with stale? for conditional GET
- `cache-last-modified` - Set Last-Modified for time-based validation
- `cache-cache-control-headers` - Set explicit Cache-Control headers
- `cache-vary-header` - Include Vary header for content-dependent caching
9. API Evolution (LOW-MEDIUM)
- `evolve-additive-changes-only` - Make only additive changes to responses
- `evolve-deprecation-headers` - Use Deprecation and Sunset headers
- `evolve-hateoas-reduces-versioning` - Leverage HATEOAS to eliminate URL versioning
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
RESTful HATEOAS
This curated skill mirrors SKILL.md. When maintaining it, keep the guidance focused on REST resources, HTTP semantics, hypermedia controls, content negotiation, pagination, errors, and caching.
Rule Title Here
1-3 sentences explaining WHY this matters. Focus on REST maturity, HTTP semantics, or client-server decoupling implications.
Incorrect (problem/cost description):
# Rails code showing the anti-pattern
# Comment on the problematic line explaining the consequenceCorrect (benefit/solution description):
# Rails code showing the correct pattern
# Comment only where the key improvement happensBenefits:
- Benefit 1
- Benefit 2
When NOT to use this pattern:
- Exception 1
- Exception 2
Reference: Title
{
"version": "1.0.6",
"organization": "Community",
"technology": "RESTful HATEOAS (Ruby on Rails)",
"date": "February 2026",
"abstract": "Comprehensive guide to building REST APIs that reach the Glory of REST (Richardson Maturity Level 3) in Ruby on Rails. Contains 47 rules across 9 categories, ordered by the request/response lifecycle — from resource URI design (nouns, plural, max 2 nesting levels) through HTTP method semantics (safe GET, idempotent PUT/DELETE, idempotency keys) and hypermedia controls (_links, affordances, entry points) to API evolution (additive changes, deprecation headers). Each rule includes Rails-specific code examples with incorrect vs. correct patterns.",
"references": [
"https://martinfowler.com/articles/richardsonMaturityModel.html",
"https://www.rfc-editor.org/rfc/rfc8288.html",
"https://www.rfc-editor.org/rfc/rfc9457",
"https://opensource.zalando.com/restful-api-guidelines/",
"https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design",
"https://guides.rubyonrails.org/routing.html",
"https://ieftimov.com/posts/sprinkle-some-hateoas-on-rails-apis/",
"https://jsonapi.org/",
"https://stateless.group/hal_specification.html"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Resource Modeling (res)
Impact: CRITICAL Description: Wrong resource design cascades into every other category. Noun-based URIs, plural collections, and max 2 nesting levels form the foundation that HTTP methods, links, and caching all depend on.
2. HTTP Method Semantics (http)
Impact: CRITICAL Description: GET must be safe, PUT must be idempotent, POST creates — correct verb usage is what separates Richardson Level 2 from the Swamp of POX and enables caching, retries, and intermediary support.
3. Hypermedia & Link Relations (link)
Impact: CRITICAL Description: The crown jewel of REST: _links with standard rel types make APIs self-documenting and evolvable. Without hypermedia controls, clients hardcode URIs and servers cannot evolve independently.
4. Status Codes & Response Headers (status)
Impact: HIGH Description: 201+Location for creates, 202 for async, 409 for conflicts — correct status codes enable client automation, caching, retry logic, and proper intermediary behavior.
5. Content Negotiation & Media Types (media)
Impact: HIGH Description: Accept/Content-Type handling, HAL vs JSON:API, and custom vendor media types are the mechanism that enables HATEOAS and allows servers to serve multiple representations from a single URI.
6. Collection Patterns (coll)
Impact: MEDIUM-HIGH Description: Cursor-based pagination with Link headers, filtering, sorting, and field selection make collections navigable hypermedia resources instead of unbounded data dumps.
7. Error Semantics (err)
Impact: MEDIUM Description: Errors are resources too. Problem Details (RFC 9457) with machine-readable types, structured validation errors, and error links enable client automation instead of string parsing.
8. Caching & Conditional Requests (cache)
Impact: MEDIUM Description: ETags, stale?/fresh_when, Cache-Control, and If-None-Match are HTTP's built-in caching — but they only work when resources have proper URIs, methods, and status codes.
9. API Evolution (evolve)
Impact: LOW-MEDIUM Description: HATEOAS makes URL versioning unnecessary. Additive changes, deprecation headers, and media type versioning allow APIs to evolve without breaking existing clients.
Set Explicit Cache-Control Headers for API Responses
Without explicit Cache-Control headers, caching behavior is undefined -- browsers, CDNs, and proxies apply their own heuristics, which may cache private data publicly or never cache publicly shareable data. Setting private, max-age=60 for user-specific responses prevents proxy caching of personal data. Setting public, max-age=3600 for shared resources (product catalogs, categories) offloads traffic to CDN edge nodes.
Incorrect (no Cache-Control headers -- caching behavior left to browser defaults):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def show
order = current_user.orders.find(params[:id])
render json: OrderSerializer.new(order).as_json
# No Cache-Control header -- CDN might cache this user's order for everyone
end
def categories
categories = Category.active.ordered
render json: categories.map { |c| CategorySerializer.new(c).as_json }
# No Cache-Control header -- CDN cannot cache this even though it's public
end
endCorrect (explicit Cache-Control per endpoint based on data sensitivity):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def show
order = current_user.orders.find(params[:id])
if stale?(etag: order)
expires_in 1.minute, public: false # private, max-age=60 — user-specific
render json: OrderSerializer.new(order).as_json
end
end
end
# app/controllers/api/v1/categories_controller.rb
class Api::V1::CategoriesController < ApplicationController
def index
categories = Category.active.ordered
expires_in 1.hour, public: true # public, max-age=3600 — CDN-cacheable
render json: categories.map { |c| CategorySerializer.new(c).as_json }
end
end
# app/controllers/api/v1/shipments_controller.rb
class Api::V1::ShipmentsController < ApplicationController
def show
shipment = Shipment.find(params[:id])
response.headers["Cache-Control"] = "no-store" # real-time tracking, never cache
render json: ShipmentSerializer.new(shipment).as_json
end
endBenefits:
- Public resources served from CDN edge nodes reduce origin load and improve latency globally
- Private data is explicitly excluded from shared caches, preventing cross-user data leaks
no-storefor real-time resources ensures clients always get fresh data
When NOT to use: Do not set public on any response that varies by Authorization header without also setting a Vary: Authorization header. See restful-hateoas:cache-vary-header.
Reference: RFC 9111 (HTTP Caching). See also restful-hateoas:cache-etag-conditional-get for combining Cache-Control with conditional requests.
Use ETags with stale? for Conditional GET Requests
Without conditional GET, the server serializes and transmits the full response body on every request even when the client already has an identical copy. Rails' stale? compares the resource's ETag against the client's If-None-Match header and returns 304 Not Modified when they match -- skipping serialization, JSON generation, and body transmission entirely. This is HTTP's built-in caching mechanism, and it costs one line of code.
Incorrect (always serializing and returning 200 even when content is unchanged):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def show
order = current_user.orders.find(params[:id])
render json: OrderSerializer.new(order).as_json # serializes every time, even if nothing changed
end
endCorrect (stale? returns 304 automatically, skipping serialization on cache hit):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def show
order = current_user.orders.includes(:line_items).find(params[:id])
if stale?(etag: order) # compares ETag with If-None-Match, returns 304 on match
render json: OrderSerializer.new(order).as_json
end
end
def index
orders = current_user.orders.order(updated_at: :desc).limit(25)
if stale?(etag: orders) # works with collections too
render json: orders.map { |o| OrderSerializer.new(o).as_json }
end
end
end# First request -- full response with ETag
GET /api/v1/orders/42 HTTP/1.1
Authorization: Bearer <token>
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Content-Type: application/json
# ... full body ...
# Second request -- client sends ETag back
GET /api/v1/orders/42 HTTP/1.1
If-None-Match: "a1b2c3d4"
HTTP/1.1 304 Not Modified
# no body, no serialization costBenefits:
- Zero serialization cost on cache hits -- the response body is never generated
- Works with Rails' built-in
stale?out of the box, no external caching layer needed - Clients receive the ETag automatically in the response headers
Reference: RFC 9110 Section 13.1 (Conditional Requests). See also rails-dev:cache-conditional-get for deeper Rails integration, and restful-hateoas:cache-last-modified for time-based conditional requests.
Set Last-Modified Header for Time-Based Cache Validation
ETags work by comparing opaque tokens, but sometimes time-based validation is simpler and sufficient. Setting Last-Modified via fresh_when lets clients send If-Modified-Since on subsequent requests. If the resource hasn't changed since that timestamp, Rails returns 304 Not Modified without touching the serializer. This is especially useful for resources with a reliable updated_at column and no need for content-based hashing.
Incorrect (no Last-Modified header -- clients must re-fetch the full body every time):
# app/controllers/api/v1/customers_controller.rb
class Api::V1::CustomersController < ApplicationController
def show
customer = Customer.find(params[:id])
render json: CustomerSerializer.new(customer).as_json # no cache headers at all
end
endCorrect (fresh_when sets Last-Modified and handles If-Modified-Since automatically):
# app/controllers/api/v1/customers_controller.rb
class Api::V1::CustomersController < ApplicationController
def show
customer = Customer.find(params[:id])
if stale?(last_modified: customer.updated_at) # sets Last-Modified, checks If-Modified-Since
render json: CustomerSerializer.new(customer).as_json
end
end
def index
customers = Customer.order(updated_at: :desc).limit(50)
latest = customers.maximum(:updated_at)
if stale?(last_modified: latest) # use the most recent timestamp for collections
render json: customers.map { |c| CustomerSerializer.new(c).as_json }
end
end
end# First request
GET /api/v1/customers/7 HTTP/1.1
HTTP/1.1 200 OK
Last-Modified: Thu, 12 Jun 2025 14:30:00 GMT
# ... full body ...
# Second request with timestamp
GET /api/v1/customers/7 HTTP/1.1
If-Modified-Since: Thu, 12 Jun 2025 14:30:00 GMT
HTTP/1.1 304 Not ModifiedAlternative: Combine both ETag and Last-Modified for strongest caching. Use stale?(etag: customer, last_modified: customer.updated_at) to support both validation mechanisms simultaneously.
Benefits:
- No hash computation required -- just a timestamp comparison
- Works well with CDN and proxy caches that understand
Last-Modified stale?handles both setting the header and checking the conditional in one call
Reference: RFC 9110 Section 8.8.2 (Last-Modified). See also restful-hateoas:cache-etag-conditional-get for content-based validation.
Include Vary Header When Responses Differ by Request Headers
When the same URL produces different responses based on request headers (e.g., Accept for content negotiation, Authorization for user-specific data, Accept-Language for locale), caches must know which headers affect the response. Without a Vary header, a CDN might cache the HAL+JSON response and serve it to a client requesting JSON:API, or cache one user's order list and serve it to another. The Vary header tells caches to store separate entries per unique combination of the listed headers.
Incorrect (same URL serves different formats without Vary -- cache serves wrong representation):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def show
order = current_user.orders.find(params[:id])
respond_to do |format|
format.json { render json: OrderSerializer.new(order).as_json }
format.hal { render json: HalOrderSerializer.new(order).as_json, content_type: "application/hal+json" }
end
# No Vary header -- CDN caches the first format and serves it to all clients
end
endCorrect (Vary header ensures caches store separate entries per Accept and Authorization):
# app/controllers/concerns/vary_headers.rb
module VaryHeaders
extend ActiveSupport::Concern
included do
before_action :set_vary_headers
end
private
def set_vary_headers
response.headers["Vary"] = "Accept, Authorization"
end
end
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
include VaryHeaders
def show
order = current_user.orders.find(params[:id])
respond_to do |format|
format.json { render json: OrderSerializer.new(order).as_json }
format.hal { render json: HalOrderSerializer.new(order).as_json, content_type: "application/hal+json" }
end
# Vary: Accept, Authorization -- CDN stores separate entries per format and user
end
endGET /api/v1/orders/42 HTTP/1.1
Accept: application/hal+json
Authorization: Bearer user_a_token
HTTP/1.1 200 OK
Content-Type: application/hal+json
Vary: Accept, Authorization
Cache-Control: private, max-age=60Benefits:
- CDNs and proxies store and serve the correct representation per client request
- Prevents one user's cached response from leaking to another user
- Works with all standard HTTP caches -- no custom cache key configuration needed
When NOT to use: If your API serves only one format and does not vary by any request header, omit Vary to maximize cache hit rates. Adding unnecessary Vary headers fragments the cache.
Reference: RFC 9110 Section 12.5.5 (Vary). See also restful-hateoas:media-accept-header-negotiation for content negotiation and restful-hateoas:cache-cache-control-headers for Cache-Control directives.
Use Cursor-Based Pagination Instead of Offset
Offset-based pagination (page=5&per_page=25) breaks when records are inserted or deleted between requests -- rows shift positions and clients see duplicates or miss records entirely (page drift). At scale, OFFSET 10000 forces the database to scan and discard 10,000 rows before returning results, degrading to O(n). Cursor-based pagination uses a stable pointer (the last seen ID) and performs a constant-time indexed lookup.
Incorrect (offset pagination drifts on inserts and degrades at scale):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def index
page = params.fetch(:page, 1).to_i
per_page = params.fetch(:per_page, 25).to_i
orders = current_user.orders
.order(id: :desc)
.offset((page - 1) * per_page) # OFFSET 10000 scans 10000 rows
.limit(per_page)
render json: {
orders: orders.map { |o| OrderSerializer.new(o).as_json },
meta: { page: page, per_page: per_page, total_pages: (current_user.orders.count / per_page.to_f).ceil }
}
end
endCorrect (cursor pagination with composite cursor and hypermedia links):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
PER_PAGE = 25
def index
orders = current_user.orders.order(created_at: :desc, id: :desc)
if params[:cursor].present?
ts, id = decode_cursor(params[:cursor])
orders = orders.where("(created_at, id) < (?, ?)", ts, id) # composite cursor — works with any public ID scheme
end
orders = orders.limit(PER_PAGE + 1) # fetch one extra to detect next page
has_next = orders.size > PER_PAGE
orders = orders.first(PER_PAGE)
render json: {
_links: pagination_links(orders, has_next),
_embedded: { orders: orders.map { |o| OrderSerializer.new(o).as_json } }
}
end
private
def encode_cursor(record)
Base64.urlsafe_encode64("#{record.created_at.iso8601(6)}:#{record.id}", padding: false)
end
def decode_cursor(cursor)
ts_str, id_str = Base64.urlsafe_decode64(cursor).split(":", 2)
[Time.iso8601(ts_str), id_str.to_i]
end
def pagination_links(orders, has_next)
base = "/api/v1/orders"
links = { self: { href: request.original_url } }
links[:next] = { href: "#{base}?cursor=#{encode_cursor(orders.last)}" } if has_next
links[:first] = { href: base }
links
end
endBenefits:
- Pagination cost is constant regardless of how deep into the collection the client navigates (uses indexed lookup, not sequential scan)
- No page drift -- inserting or deleting records does not shift the cursor position
- The
_links.nexthref is opaque to clients, so you can change cursor encoding without breaking consumers - Composite cursor (
created_at:id) works with any public identifier scheme, including UUIDs
When NOT to use:
- Admin dashboards requiring "jump to page N" navigation need offset pagination alongside cursor links. Consider offering both: cursor links in
_linksand page metadata in ametaobject.
Reference: See also restful-hateoas:link-pagination-links for hypermedia pagination link structure, restful-hateoas:coll-link-header-pagination for HTTP Link headers.
Support Sparse Fieldsets via a Fields Parameter
Collection endpoints should support a fields query parameter that limits which fields are returned. Mobile clients listing orders need id, status, and total -- not the full 30-field representation with nested addresses and line items. Without field selection, every client pays the serialization and transfer cost of the largest consumer's needs.
Incorrect (always returns all fields regardless of client needs):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def index
orders = paginate(current_user.orders.order(id: :desc))
render json: {
_embedded: { orders: orders.map { |o| OrderSerializer.new(o).as_json } }
}
# Every response includes all 30 fields — mobile clients transfer 10x more data than needed
end
end
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
private
def resource_json
{
id: @resource.id, status: @resource.status, total: @resource.total.to_f,
currency: @resource.currency, notes: @resource.notes,
shipping_address: @resource.shipping_address, billing_address: @resource.billing_address,
line_items: @resource.line_items.map { |li| LineItemSerializer.new(li).as_json }
}
end
endCorrect (fields parameter filters both SELECT and serializer output):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def index
requested_fields = parse_fields(params[:fields])
orders = current_user.orders.order(id: :desc)
orders = orders.select(requested_fields & Order.column_names) if requested_fields # narrow SELECT
orders = paginate(orders)
render json: {
_links: {
self: { href: request.original_url },
fields: { href: "/api/v1/orders{?fields}", templated: true }
},
_embedded: {
orders: orders.map { |o| OrderSerializer.new(o, fields: requested_fields).as_json }
}
}
end
private
def parse_fields(fields_param)
return nil if fields_param.blank?
fields_param.split(",").map(&:strip) # "id,status,total" → ["id", "status", "total"]
end
end
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
ALLOWED_FIELDS = %w[id status total currency notes shipping_address billing_address].freeze
def initialize(resource, fields: nil, **opts)
super(resource, **opts)
@fields = fields&.intersection(ALLOWED_FIELDS) || ALLOWED_FIELDS # whitelist only
end
private
def resource_json
all_fields = {
id: @resource.id, status: @resource.status, total: @resource.total.to_f,
currency: @resource.currency, notes: @resource.notes,
shipping_address: @resource.shipping_address, billing_address: @resource.billing_address
}
all_fields.slice(*@fields.map(&:to_sym)) # return only requested fields
end
endGET /api/v1/orders?fields=id,status,total
→ { "_embedded": { "orders": [{ "id": "ord_1", "status": "shipped", "total": 129.99, "_links": { "self": { "href": "/api/v1/orders/ord_1" } } }] } }Benefits:
- Mobile list views request only
id,status,total-- payload drops from ~2KB to ~200 bytes per item - Database SELECT is narrowed, avoiding loading text columns and associations the client does not need
_links.selfis always included regardless of field selection, preserving hypermedia navigability- Templated
_links.fieldshref advertises sparse fieldset support
When NOT to use:
- Single-resource GET endpoints (
/orders/123) should default to full representation. Field selection is most valuable on collection endpoints with many items.
Reference: JSON:API Sparse Fieldsets specification. See also restful-hateoas:coll-filtering-via-query-params for combining field selection with filters.
Support Filtering via Typed Query Parameters
Collection endpoints should support filtering through explicit, typed query parameters -- one per filterable field. A single search parameter forces the server to parse a freeform string and guess intent, producing inconsistent results and making the API impossible to document precisely. Typed parameters like status=shipped&created_after=2024-01-01 are self-documenting, cacheable, and composable.
Incorrect (single search parameter requires server-side parsing guesswork):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def index
orders = current_user.orders
if params[:search].present?
# Parsing "shipped 2024" — is "2024" a year? An order number? Ambiguous.
terms = params[:search].split(/\s+/)
terms.each do |term|
orders = orders.where("status LIKE ? OR notes LIKE ?", "%#{term}%", "%#{term}%")
end
end
render json: { orders: orders.map { |o| OrderSerializer.new(o).as_json } }
end
endCorrect (typed, composable query parameters with discoverable filter links):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
ALLOWED_FILTERS = %w[status created_after created_before customer_id min_total].freeze
def index
orders = current_user.orders.order(id: :desc)
orders = apply_filters(orders)
orders = paginate(orders)
render json: {
_links: collection_links,
_embedded: { orders: orders.map { |o| OrderSerializer.new(o).as_json } }
}
end
private
def apply_filters(scope)
scope = scope.where(status: params[:status]) if params[:status].present?
scope = scope.where("created_at >= ?", Date.parse(params[:created_after])) if params[:created_after].present?
scope = scope.where("created_at <= ?", Date.parse(params[:created_before])) if params[:created_before].present?
scope = scope.where(customer_id: params[:customer_id]) if params[:customer_id].present?
scope = scope.where("total >= ?", params[:min_total].to_d) if params[:min_total].present?
scope
end
def collection_links
{
self: { href: request.original_url },
filters: { href: "/api/v1/orders{?status,created_after,created_before,customer_id,min_total}", templated: true }
}
end
endBenefits:
- Each filter maps to a single database condition -- no ambiguous string parsing
- Templated
_links.filtershref advertises available filters (RFC 6570 URI Templates) - Filters compose:
?status=shipped&min_total=100is a precise intersection, not a fuzzy search - Cached responses are keyed by exact query string --
?status=shippedand?status=pendingcache independently
When NOT to use:
- Full-text search across multiple fields is a separate concern -- expose it as a dedicated
/searchendpoint or aqparameter alongside field filters.
Reference: RFC 6570 (URI Template) for templated link relations. See also restful-hateoas:coll-sorting-convention for sorting alongside filters.
Include Pagination Links in Both Body and Link Header
Pagination links belong in two places: the JSON body (_links) for application-level clients and the HTTP Link header (RFC 8288) for generic HTTP clients, proxies, and caching layers. The Link header is the only pagination signal that HTTP intermediaries can read without parsing your response body -- omitting it means CDNs and prefetch mechanisms cannot discover the next page.
Incorrect (pagination metadata only in the JSON body):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def index
orders = paginate(current_user.orders.order(id: :desc))
render json: {
_links: { next: { href: "/api/v1/orders?cursor=#{orders.last.id}" } },
_embedded: { orders: orders.map { |o| OrderSerializer.new(o).as_json } }
}
# No Link header — proxies and generic HTTP clients cannot discover pagination
end
endCorrect (Link header mirrors _links for HTTP-level discoverability):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def index
orders = paginate(current_user.orders.order(id: :desc))
links = build_pagination_links(orders)
response.headers["Link"] = format_link_header(links) # RFC 8288 Link header
render json: {
_links: links,
_embedded: { orders: orders.map { |o| OrderSerializer.new(o).as_json } }
}
end
private
def build_pagination_links(orders)
base = "/api/v1/orders"
links = { self: { href: request.original_url } }
links[:next] = { href: "#{base}?cursor=#{orders.last.id}" } if @has_next
links[:prev] = { href: "#{base}?cursor=#{orders.first.id}&direction=before" } if @has_prev
links[:first] = { href: base }
links
end
def format_link_header(links)
links.except(:self).map { |rel, attrs|
%(<#{attrs[:href]}>; rel="#{rel}") # <URL>; rel="next"
}.join(", ")
end
endHTTP/1.1 200 OK
Link: </api/v1/orders?cursor=ord_42>; rel="next", </api/v1/orders>; rel="first"
Content-Type: application/hal+json
{
"_links": {
"self": { "href": "/api/v1/orders?cursor=ord_99" },
"next": { "href": "/api/v1/orders?cursor=ord_42" },
"first": { "href": "/api/v1/orders" }
},
"_embedded": { "orders": [ ... ] }
}Benefits:
- CDNs and HTTP caches can prefetch the next page using the
Linkheader without body parsing - Generic HTTP clients (curl, HTTPie) display pagination rels automatically
- Body
_linksand headerLinkare always in sync -- one source of truth rendered twice
Reference: RFC 8288 (Web Linking). See also restful-hateoas:link-pagination-links for HAL link structure, restful-hateoas:coll-cursor-pagination for cursor implementation.
Support Sorting with a Standardized Sort Parameter
Use a single sort query parameter with comma-separated field names and a - prefix for descending order (JSON:API convention). Splitting sort field and direction into separate parameters (sort_by + sort_dir) cannot express multi-column sorts and forces every client to learn your custom parameter names. The -field convention is widely adopted, self-documenting, and composable.
Incorrect (custom sort parameters that cannot express multi-column ordering):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def index
orders = current_user.orders
sort_by = params.fetch(:sort_by, "created_at")
sort_dir = params.fetch(:sort_dir, "desc") # only one column — cannot sort by status then date
orders = orders.order(sort_by => sort_dir)
render json: { orders: orders.map { |o| OrderSerializer.new(o).as_json } }
end
endCorrect (single `sort` parameter with `-` prefix convention and multi-column support):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
SORTABLE_FIELDS = %w[created_at total status customer_id].freeze
def index
orders = current_user.orders
orders = apply_sort(orders)
orders = paginate(orders)
render json: {
_links: {
self: { href: request.original_url },
sort: { href: "/api/v1/orders{?sort}", templated: true } # advertise sortability
},
_embedded: { orders: orders.map { |o| OrderSerializer.new(o).as_json } }
}
end
private
def apply_sort(scope)
sort_fields = params.fetch(:sort, "-created_at").split(",")
sort_fields.each do |field|
direction = field.start_with?("-") ? :desc : :asc # "-" prefix means descending
column = field.delete_prefix("-")
next unless column.in?(SORTABLE_FIELDS) # whitelist prevents SQL injection
scope = scope.order(column => direction)
end
scope
end
endGET /api/v1/orders?sort=-created_at,total
→ ORDER BY created_at DESC, total ASC
GET /api/v1/orders?sort=status,-total
→ ORDER BY status ASC, total DESCBenefits:
- Multi-column sort in a single parameter:
?sort=-created_at,totalis concise and composable - The
-prefix convention is immediately readable without documentation - Whitelisting
SORTABLE_FIELDSprevents SQL injection and limits sort to indexed columns - Templated
_links.sorthref tells clients sorting is available without out-of-band docs
Reference: JSON:API Sort specification. See also restful-hateoas:coll-filtering-via-query-params for composing sort with filters.
Distinguish 401 Unauthorized from 403 Forbidden
401 and 403 communicate fundamentally different problems. 401 means "I don't know who you are" -- the client should re-authenticate (refresh token, re-login). 403 means "I know who you are, and you're not allowed" -- re-authenticating won't help, the client needs different permissions. Returning 403 for missing tokens forces clients to guess whether re-authentication would fix the problem. Returning 401 for insufficient permissions causes infinite re-authentication loops.
Incorrect (403 for both missing auth and insufficient permissions):
class ApplicationController < ActionController::API
before_action :authenticate!
private
def authenticate!
unless current_user
render json: { error: "Forbidden" }, status: :forbidden # 403 — client thinks permissions are the problem
end
end
def authorize!(permission)
unless current_user.can?(permission)
render json: { error: "Forbidden" }, status: :forbidden # same 403 — client cannot distinguish
end
end
endCorrect (401 for authentication, 403 for authorization, with recovery links):
class ApplicationController < ActionController::API
before_action :authenticate!
private
def authenticate!
return if current_user
response.headers["WWW-Authenticate"] = 'Bearer realm="api"' # required by RFC 9110 on 401
render json: {
type: "https://api.example.com/problems/unauthenticated",
title: "Unauthorized",
status: 401,
detail: "Bearer token is missing or expired",
_links: { oauth_token: { href: "/oauth/token", title: "Obtain a new token" } }
}, status: :unauthorized, content_type: "application/problem+json"
end
def authorize!(permission)
return if current_user.can?(permission)
render json: {
type: "https://api.example.com/problems/forbidden",
title: "Forbidden",
status: 403,
detail: "Your account does not have the '#{permission}' permission",
_links: { self: { href: request.path } }
}, status: :forbidden, content_type: "application/problem+json"
end
end# Missing or expired token
GET /api/orders HTTP/1.1
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/unauthenticated",
"title": "Unauthorized",
"status": 401,
"detail": "Bearer token is missing or expired",
"_links": { "oauth_token": { "href": "/oauth/token" } }
}# Valid token but insufficient permissions
DELETE /api/orders/ord_abc HTTP/1.1
Authorization: Bearer valid_but_read_only_token
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "Your account does not have the 'orders:delete' permission"
}Benefits:
- Client SDKs implement correct retry logic: refresh token on 401, show permissions error on 403
- The
WWW-Authenticateheader on 401 is required by RFC 9110 and enables automated token refresh - Recovery links in 401 guide the client to the token endpoint
- No infinite re-authentication loops from misinterpreted 403s
When NOT to use: If your API must not reveal whether a resource exists to unauthorized users, return 404 instead of 403 to avoid information leakage (e.g., for private user profiles).
Reference: RFC 9110 Section 15.5.2 (401 Unauthorized), Section 15.5.4 (403 Forbidden). See also restful-hateoas:err-problem-details for error response format.
Include Recovery Links in Error Responses
An error response without links is a dead end -- the client knows something went wrong but has no machine-readable way to recover. Adding _links to error bodies turns errors into navigable resources. A 409 Conflict should link to the conflicting resource's current state. A 401 Unauthorized should link to the authentication endpoint. A 403 Forbidden can link to a permissions request flow.
Incorrect (error with no links -- client must hardcode recovery paths):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def update
order = current_user.orders.find(params[:id])
unless order.editable?
render json: {
error: "conflict",
message: "Order #{order.id} has already been shipped"
}, status: :conflict # client gets a message but no way to discover next steps
return
end
order.update!(order_params)
render json: OrderSerializer.new(order).as_json
end
endCorrect (error response includes recovery links):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def update
order = current_user.orders.find(params[:id])
unless order.editable?
render json: {
error: "conflict",
message: "Order #{order.id} has already been shipped",
_links: {
current_state: { href: "/api/v1/orders/#{order.id}", title: "View current order" },
shipment: { href: "/api/v1/orders/#{order.id}/shipment", title: "Track shipment" },
support: { href: "/api/v1/support/tickets", title: "Request order change" }
}
}, status: :conflict
return
end
order.update!(order_params)
render json: OrderSerializer.new(order).as_json
end
end
# app/controllers/concerns/authentication.rb -- 401 with auth link
module Authentication
def render_unauthorized
render json: {
error: "unauthorized",
message: "Authentication required",
_links: {
authenticate: { href: "/api/v1/oauth/authorize", title: "Begin authentication" }
}
}, status: :unauthorized
end
endBenefits:
- Clients follow links to recover from errors without hardcoding fallback URIs
- Server-side changes to recovery flows (new support ticket endpoint) propagate automatically through links
- Error responses become first-class hypermedia resources, consistent with the rest of the API
Reference: See also restful-hateoas:status-409-for-conflicts for when to use 409 vs 422, and restful-hateoas:link-action-affordances for embedding available actions.
Use Machine-Readable Error Codes Instead of Messages
Human-readable error messages change: copywriters reword them, i18n translates them, typo fixes alter them. Any client that branches on error.message.include?("already shipped") breaks silently when the message becomes "has already been dispatched". Machine-readable codes (snake_case strings) are stable contracts -- messages are for display, codes are for logic.
Incorrect (client branches on message strings -- breaks on any wording change):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def cancel
order = current_user.orders.find(params[:id])
unless order.cancellable?
render json: {
message: "This order has already been shipped and cannot be cancelled"
# client does: if error["message"].include?("shipped") -- fragile
}, status: :conflict
return
end
order.cancel!
head :no_content
end
endCorrect (stable code for logic, localizable message for display):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def cancel
order = current_user.orders.find(params[:id])
unless order.cancellable?
render json: {
code: "order_already_shipped", # stable, machine-readable
message: I18n.t("errors.order_already_shipped", id: order.id), # display-only, translatable
_links: { self: { href: "/api/v1/orders/#{order.id}" } }
}, status: :conflict
return
end
order.cancel!
head :no_content
end
end
# config/locales/en.yml
# en:
# errors:
# order_already_shipped: "Order %{id} has already been shipped and cannot be cancelled"Benefits:
- Client logic matches on
code(case error.code; when "order_already_shipped") -- immune to rewording - Messages can be freely translated, rewritten, or A/B tested without breaking clients
- Error codes serve as stable documentation anchors and monitoring labels
When NOT to use: For validation errors, use the code field from err-validation-errors (ActiveRecord error types like blank, taken). This rule applies to business-logic errors where you define custom codes.
Reference: See also restful-hateoas:err-problem-details for wrapping codes in RFC 9457 Problem Details format.
Use Problem Details (RFC 9457) for Error Responses
Errors are resources too. Without a standard error format, every API invents its own shape ({ error: "..." }, { message: "..." }, { errors: [...] }) and every client must write custom parsing logic for each one. RFC 9457 (Problem Details for HTTP APIs) defines a machine-readable format with type, title, status, detail, and instance fields that clients can handle generically.
Incorrect (ad-hoc error format -- every endpoint returns a different shape):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def show
order = current_user.orders.find_by(id: params[:id])
unless order
render json: { error: "not found" }, status: :not_found # no structure, no type URI
return
end
render json: OrderSerializer.new(order).as_json
end
endCorrect (RFC 9457 Problem Details with consistent structure):
# app/controllers/concerns/problem_details.rb
module ProblemDetails
extend ActiveSupport::Concern
private
def render_problem(type:, title:, status:, detail:, extras: {})
render json: {
type: "https://api.example.com/problems/#{type}",
title: title,
status: status,
detail: detail,
instance: request.original_url
}.merge(extras), status: status, content_type: "application/problem+json"
end
end
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
include ProblemDetails
def show
order = current_user.orders.find_by(id: params[:id])
unless order
render_problem(
type: "order-not-found",
title: "Order Not Found",
status: 404,
detail: "No order with id #{params[:id]} exists for this account"
)
return
end
render json: OrderSerializer.new(order).as_json
end
endBenefits:
- Clients parse every error with a single handler -- no endpoint-specific error shapes
- The
typeURI serves as both a machine-readable code and a link to human documentation - The
instancefield identifies the exact request that failed, simplifying debugging and support tickets
Reference: RFC 9457 (Problem Details for HTTP APIs). See also restful-hateoas:err-error-links for adding recovery links to error responses.
Return Structured Validation Errors with Field, Code, and Message
ActiveRecord's errors.full_messages returns human-readable strings like "Email can't be blank" -- fine for server logs, useless for client-side form rendering. Clients need to know which field failed, why it failed (as a machine-readable code), and what to display. Returning an array of objects with field, code, and message lets clients map errors directly to form fields.
Incorrect (returning full_messages -- client cannot map errors to fields):
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def create
order = current_user.orders.build(order_params)
unless order.save
render json: { errors: order.errors.full_messages }, status: :unprocessable_entity
# => { "errors": ["Email can't be blank", "Quantity must be greater than 0"] }
return
end
render json: OrderSerializer.new(order).as_json, status: :created
end
endCorrect (structured errors with field, code, and message):
# app/controllers/concerns/validation_errors.rb
module ValidationErrors
extend ActiveSupport::Concern
private
def render_validation_errors(record)
errors = record.errors.map do |error|
{
field: error.attribute.to_s.camelize(:lower), # "email", "shippingAddress"
code: error.type.to_s, # "blank", "too_short", "taken"
message: error.full_message # "Email can't be blank"
}
end
render json: { errors: errors }, status: :unprocessable_entity
end
end
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
include ValidationErrors
def create
order = current_user.orders.build(order_params)
unless order.save
render_validation_errors(order)
# => { "errors": [{ "field": "email", "code": "blank", "message": "Email can't be blank" }] }
return
end
render json: OrderSerializer.new(order).as_json, status: :created
end
endBenefits:
- Clients render inline errors under each form field using the
fieldkey - The
codekey enables programmatic branching (retry ontaken, highlight onblank) - The
messagekey provides a display-ready string, avoiding client-side message construction
Reference: See also restful-hateoas:err-machine-readable-codes for why codes must be snake_case strings, not integers.
Make Only Additive Changes to API Responses
Removing or renaming a field in an API response is a breaking change -- every client that reads that field will fail. Additive changes (new fields, new links, new embedded resources) are always safe because well-behaved clients ignore fields they do not recognize. When a field must be replaced, add the new field alongside the old one and deprecate the old field using headers and documentation, giving clients a migration window.
Incorrect (renaming and removing fields -- breaks existing clients):
# app/serializers/order_serializer.rb -- V2 "cleanup"
class OrderSerializer < BaseSerializer
private
def resource_json
{
id: @resource.id,
order_total: @resource.total.to_f, # renamed from "total" -- breaks clients reading "total"
status: @resource.status
# customer_id removed -- breaks clients that use it for lookups
}
end
endCorrect (add new fields alongside old ones, deprecate with Sunset header):
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
private
def resource_json
{
id: @resource.id,
total: @resource.total.to_f, # keep original field
order_total: @resource.total.to_f, # add new field alongside
customer_id: @resource.customer_id, # keep -- never remove without Sunset period
status: @resource.status
}
end
end
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
def show
order = current_user.orders.find(params[:id])
response.headers["Sunset"] = "Sat, 01 Mar 2026 00:00:00 GMT"
response.headers["Deprecation"] = "true"
response.headers["Link"] = '</docs/migration/order-total>; rel="deprecation"'
render json: OrderSerializer.new(order).as_json
end
endBenefits:
- Existing clients continue working without code changes
- New clients can adopt improved field names immediately
- The Sunset header gives clients a machine-readable deadline for migration
When NOT to use: Security-critical removals (e.g., accidentally exposing internal IDs or PII) should be removed immediately regardless of backward compatibility, with a clear incident notification to consumers.
Reference: See also restful-hateoas:evolve-deprecation-headers for the full Deprecation/Sunset header protocol.
Use Deprecation and Sunset Headers to Signal API Changes
Documenting API deprecations only in changelogs or developer portals requires humans to read and act on them. The Deprecation header (RFC 9745) and Sunset header (RFC 8594) provide machine-readable signals that automated tools and client SDKs can detect. Including a Link header with rel="deprecation" pointing to migration documentation closes the loop -- clients know something is deprecated, when it will be removed, and how to migrate.
Incorrect (removing endpoint without warning -- clients discover the break in production):
# config/routes.rb
# Removed: get "/api/v1/orders/:id/receipt", to: "orders#receipt"
# No headers, no warning, no migration guide -- clients get 404Correct (Deprecation + Sunset + Link headers during migration window):
# app/controllers/concerns/deprecatable.rb
module Deprecatable
extend ActiveSupport::Concern
private
def deprecate!(deprecated_at:, sunset:, migration_url:)
response.headers["Deprecation"] = "@#{deprecated_at.to_i}" # RFC 9745: Date as Unix timestamp
response.headers["Sunset"] = sunset.httpdate
response.headers["Link"] = "<#{migration_url}>; rel=\"deprecation\""
end
end
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
include Deprecatable
# Old endpoint -- deprecated in favor of /orders/:id/documents/receipt
def receipt
order = current_user.orders.find(params[:id])
deprecate!(
deprecated_at: Time.utc(2026, 1, 15), # when deprecation took effect
sunset: Time.utc(2026, 6, 1), # when endpoint will be removed
migration_url: "https://api.example.com/docs/migration/receipt-endpoint"
)
render json: ReceiptSerializer.new(order.receipt).as_json
end
endGET /api/v1/orders/42/receipt HTTP/1.1
Authorization: Bearer <token>
HTTP/1.1 200 OK
Deprecation: @1736899200
Sunset: Mon, 01 Jun 2026 00:00:00 GMT
Link: <https://api.example.com/docs/migration/receipt-endpoint>; rel="deprecation"
Content-Type: application/json
{ ... receipt data ... }Benefits:
- Client SDKs can log warnings or raise alerts when they receive the
Deprecationheader - The
Sunsetdate enables automated tracking of migration deadlines - The
Linkheader withrel="deprecation"provides a direct path to migration instructions
When NOT to use: Do not add deprecation headers to endpoints that are merely evolving additively (new fields). Deprecation signals indicate the endpoint or field will be removed entirely.
Reference: RFC 9745 (The Deprecation HTTP Response Header Field), RFC 8594 (The Sunset HTTP Header Field). See also restful-hateoas:evolve-additive-changes-only for non-breaking evolution strategies.
Leverage HATEOAS to Eliminate URL Versioning
URL versioning (/api/v1/, /api/v2/) forces servers to maintain parallel controller hierarchies and clients to hardcode version-specific base URLs. When clients follow links from API responses instead of constructing URIs, the server can change URI structure, add new resources, and restructure relationships without breaking consumers. The server controls the URI space; clients just follow _links. This is the fundamental promise of hypermedia: the server-side is free to evolve because clients do not embed knowledge of URI patterns.
Incorrect (URL versioning with duplicated controllers and hardcoded client URIs):
# config/routes.rb
namespace :api do
namespace :v1 do
resources :orders # /api/v1/orders
resources :customers # /api/v1/customers
end
namespace :v2 do
resources :orders # /api/v2/orders — duplicated logic
resources :customers # /api/v2/customers — duplicated logic
end
end
# Client hardcodes:
# BASE_URL = "https://api.example.com/api/v2"
# order_url = "#{BASE_URL}/orders/#{order_id}" # breaks if v3 changes the pathCorrect (single API with evolving link structure -- clients follow _links):
# config/routes.rb
namespace :api do
resources :orders do
resource :shipment, only: :show
resources :line_items, only: :index
end
resources :customers, only: %i[show index]
end
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
private
def resource_json
{
id: @resource.id,
total: @resource.total.to_f,
status: @resource.status
}
end
def self_href = "/api/orders/#{@resource.id}"
def extra_links
links = {}
links[:customer] = { href: "/api/customers/#{@resource.customer_id}" }
links[:shipment] = { href: "/api/orders/#{@resource.id}/shipment" } if @resource.shipped?
links[:line_items] = { href: "/api/orders/#{@resource.id}/line_items" }
links
end
end
# Client follows links:
# order = fetch(entry_point["_links"]["orders"]["href"])
# shipment = fetch(order["_links"]["shipment"]["href"]) # URI is opaque to clientBenefits:
- One set of controllers and serializers instead of duplicated version namespaces
- Server can restructure URIs (e.g., moving shipments to a separate service) by changing links -- clients follow automatically
- New capabilities are exposed by adding new
_linksentries, not new API versions
When NOT to use: If your API consumers cannot follow links (e.g., hardcoded integrations that refuse to change), URL versioning may still be necessary as a pragmatic fallback. Consider offering both: stable links for hypermedia-capable clients and versioned URLs for legacy consumers.
Reference: See also rails-dev:api-versioning for URL versioning patterns when they are needed, and restful-hateoas:link-entry-point for the API root that bootstraps link discovery.
Ensure DELETE Is Idempotent
DELETE removes a resource and must be idempotent -- deleting the same resource twice must not produce an error or unexpected side effect. If the first DELETE succeeds but the client does not receive the response (network timeout), the client will retry. A server that raises 500 on the second attempt forces the client into an unrecoverable error state for an operation that already succeeded.
Incorrect (raises exception on retry, returns HTML error page instead of JSON):
class OrdersController < ApplicationController
def destroy
order = Order.find(params[:id]) # raises ActiveRecord::RecordNotFound — 404 HTML page on retry
order.destroy!
head :no_content
end
endCorrect (idempotent DELETE, safe to retry):
class OrdersController < ApplicationController
def destroy
order = current_user.orders.find_by(id: params[:id])
if order
order.destroy!
head :no_content # 204 — resource successfully removed
else
head :not_found # 404 — already gone, safe idempotent response
end
end
endDELETE /api/v1/orders/42 HTTP/1.1
Authorization: Bearer <token>
HTTP/1.1 204 No ContentDELETE /api/v1/orders/42 HTTP/1.1
Authorization: Bearer <token>
HTTP/1.1 404 Not FoundBenefits:
- Network retries never cause 500 errors or cascade failures
- Clients can fire-and-forget DELETE without tracking whether a previous attempt succeeded
- Load balancers and queues can safely replay failed DELETE requests
- HEAD can check existence before DELETE when the client needs to distinguish "never existed" from "already deleted"
Note: Some APIs prefer returning 204 on repeated deletes (making the response idempotent too) rather than 404. Both are valid -- the key requirement is that the second DELETE must never produce a 500 error or unwanted side effect.
When NOT to use: If your domain requires audit trails, use soft-delete (discarded_at column) behind the same DELETE endpoint rather than changing HTTP semantics.
Reference: RFC 9110 Section 9.3.5 (DELETE), Section 9.2.2 (Idempotent Methods)
Keep GET Requests Free of Side Effects
GET is defined as a safe method (RFC 9110 Section 9.2.1) -- it must have no side effects. When GET mutates state, every cache, crawler, prefetch link, and browser back-button becomes a vector for silent data corruption. Proxies and CDNs freely replay GET requests, so a state-changing GET will fire multiple times without the client ever knowing.
Incorrect (GET route silently mutates data):
# config/routes.rb
get "notifications/mark_all_read", to: "notifications#mark_all_read"
# app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
def mark_all_read
current_user.notifications.unread.update_all(read_at: Time.current) # mutation on GET — prefetch corrupts data
redirect_to notifications_path
end
endCorrect (GET reads, POST creates a sub-resource for the state change):
# config/routes.rb
resources :notifications, only: [:index] do
resource :read_status, only: [:create], controller: "notification_read_statuses" # noun, not verb
end
# app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
def index
@notifications = current_user.notifications.order(created_at: :desc)
render json: NotificationSerializer.new(@notifications)
end
end
# app/controllers/notification_read_statuses_controller.rb
class NotificationReadStatusesController < ApplicationController
# POST /notifications/read_status — marks all notifications as read
def create
current_user.notifications.unread.update_all(read_at: Time.current)
head :no_content
end
endBenefits:
- Caches and CDNs can safely store and replay GET responses
- Browser prefetching and
<link rel="prefetch">work without side effects - Search engine crawlers cannot accidentally mutate your data
- Back/forward navigation never triggers unintended state changes
Reference: RFC 9110 Section 9.2.1 (Safe Methods). See also restful-hateoas:res-noun-based-uris for modelling state changes as sub-resource nouns.
Use HEAD for Metadata Without Transferring the Body
HEAD returns the same headers as GET but with no response body. Using GET and discarding the body to check whether a resource exists wastes bandwidth, server CPU serializing the response, and database I/O loading the full record. Rails supports HEAD on all GET routes by default, but controller actions must avoid rendering side effects that assume a body will be sent.
Incorrect (using GET and discarding the body to check existence):
# Client-side code checking if an order exists
class OrderCheckService
def exists?(order_id)
response = connection.get("/api/v1/orders/#{order_id}") # transfers full JSON body
response.status == 200
rescue Faraday::ResourceNotFound
false
end
end
# Controller renders full serialization even for existence checks
class OrdersController < ApplicationController
def show
order = Order.includes(:line_items, :customer).find(params[:id]) # eager loads everything
render json: OrderSerializer.new(order, include: [:line_items, :customer])
end
endCorrect (HEAD for metadata, controller handles HEAD efficiently):
# Client-side code using HEAD for existence check
class OrderCheckService
def exists?(order_id)
response = connection.head("/api/v1/orders/#{order_id}") # no body transferred
response.status == 200
rescue Faraday::ResourceNotFound
false
end
end
# Controller responds to HEAD without serializing the body
class OrdersController < ApplicationController
def show
order = Order.find_by(id: params[:id])
return head :not_found unless order
if request.head?
response.headers["X-Order-Status"] = order.status
response.headers["Last-Modified"] = order.updated_at.httpdate
head :ok # headers only, no serialization cost
else
render json: OrderSerializer.new(order, include: [:line_items, :customer])
end
end
endHEAD /api/v1/orders/42 HTTP/1.1
HTTP/1.1 200 OK
Last-Modified: Thu, 12 Feb 2026 10:30:00 GMT
ETag: "a1b2c3d4"
X-Order-Status: shipped
Content-Length: 1847Benefits:
- Zero bytes transferred for existence checks -- savings scale with resource size
Last-ModifiedandETagheaders enable conditional GET withIf-None-MatchContent-Lengthheader lets clients allocate buffers before downloading- Rails routes HEAD to the same action as GET by default -- no extra route needed
Reference: RFC 9110 Section 9.3.2 (HEAD)
Use Idempotency Keys for Safe POST Retries
POST is not idempotent by design -- retrying a POST can create duplicate resources. In payment, order, and transfer scenarios, duplicates mean real money lost. An Idempotency-Key header (proposed standard) lets clients attach a unique key to POST requests. If the server sees the same key twice, it returns the original response instead of creating a duplicate.
Incorrect (retrying POST creates duplicate orders):
class Api::OrdersController < ApplicationController
def create
order = current_user.orders.build(order_params)
if order.save
render json: OrderSerializer.new(order), status: :created, location: api_order_url(order)
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
# Client timeout → retry → second order created with same items
end
endCorrect (idempotency key prevents duplicate creation on retry):
# app/controllers/concerns/idempotent.rb
module Idempotent
extend ActiveSupport::Concern
private
def ensure_idempotency_key!
key = request.headers["Idempotency-Key"]
return render_missing_key unless key
cached = IdempotencyCache.find_by(key: key, user: current_user)
if cached
response.headers["Idempotency-Key"] = key
render json: cached.response_body, status: cached.response_status
return false
end
true
end
def store_idempotency_response(key, status:, body:)
IdempotencyCache.create!(
key: key, user: current_user,
response_status: status, response_body: body,
expires_at: 24.hours.from_now
)
end
def render_missing_key
render json: {
type: "https://api.example.com/problems/missing-idempotency-key",
title: "Missing Idempotency-Key",
status: 400,
detail: "POST requests require an Idempotency-Key header for safe retries"
}, status: :bad_request, content_type: "application/problem+json"
end
end
# app/controllers/api/orders_controller.rb
class Api::OrdersController < ApplicationController
include Idempotent
def create
return unless ensure_idempotency_key!
key = request.headers["Idempotency-Key"]
order = current_user.orders.build(order_params)
if order.save
body = OrderSerializer.new(order).as_json
store_idempotency_response(key, status: 201, body: body)
response.headers["Idempotency-Key"] = key
render json: body, status: :created, location: api_order_url(order)
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
end
endPOST /api/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: ord_req_a1b2c3d4
Authorization: Bearer <token>
{ "items": [{ "product_id": "prod_42", "quantity": 2 }] }
HTTP/1.1 201 Created
Idempotency-Key: ord_req_a1b2c3d4
Location: https://api.example.com/api/orders/ord_9f2a
# Retry with same key → returns cached 201, no duplicate createdBenefits:
- Network retries never create duplicate resources -- the same key always returns the same response
- Client libraries can safely retry on timeout without application-level deduplication
- The 24-hour expiry prevents unbounded cache growth while covering typical retry windows
When NOT to use: GET, PUT, and DELETE are already idempotent by HTTP semantics. Only POST needs idempotency keys. For PATCH with merge semantics, idempotency keys are optional but useful for non-idempotent patches.
Reference: IETF draft-ietf-httpapi-idempotency-key-header (Idempotency-Key HTTP Header Field). See also restful-hateoas:http-post-for-creation for POST creation semantics.
PATCH for Partial Updates with Merge Semantics
PATCH applies a partial modification to a resource. Using PUT to change a single field forces the client to send the entire representation, wasting bandwidth and risking accidental overwrites of fields modified by other clients between read and write. JSON Merge Patch (RFC 7396) provides simple, intuitive semantics: send only the fields you want to change, omitted fields remain untouched.
Incorrect (using PUT to update a single field):
# Client must GET the full resource, modify one field, and PUT everything back
# Another client may have changed `phone` between the GET and PUT — overwritten silently
class ShipmentsController < ApplicationController
def update
shipment = Shipment.find(params[:id])
shipment.update!(shipment_params) # PUT semantics on a partial payload — undefined behavior
render json: ShipmentSerializer.new(shipment)
end
private
def shipment_params
params.require(:shipment).permit(:tracking_number, :carrier, :status, :estimated_delivery,
:weight, :dimensions, :signature_required)
end
endCorrect (PATCH with only changed fields):
# config/initializers/mime_types.rb
Mime::Type.register "application/merge-patch+json", :merge_patch
# app/controllers/shipments_controller.rb
class ShipmentsController < ApplicationController
# PATCH /api/v1/shipments/:id
# Content-Type: application/merge-patch+json
def update
shipment = Shipment.find(params[:id])
unless request.content_type.in?(["application/merge-patch+json", "application/json"])
return render json: { error: "PATCH requires Content-Type: application/merge-patch+json or application/json" },
status: :unsupported_media_type
end
shipment.update!(shipment_params) # only supplied fields are changed
render json: ShipmentSerializer.new(shipment)
end
private
def shipment_params
params.require(:shipment).permit(:tracking_number, :carrier, :status, :estimated_delivery)
end
endPATCH /api/v1/shipments/19 HTTP/1.1
Content-Type: application/merge-patch+json
{
"shipment": {
"status": "in_transit",
"tracking_number": "1Z999AA10123456784"
}
}Benefits:
- Clients send only what changed -- less bandwidth, smaller payloads
- No risk of overwriting fields modified by concurrent requests
- Clear intent: PATCH signals partial modification, PUT signals full replacement
- Content-Type header (
application/merge-patch+json) makes merge semantics explicit
When NOT to use: Use PUT when the client has the full resource and wants to guarantee the entire state is set. Use JSON Patch (RFC 6902) when you need operations like add, remove, move on arrays or nested structures.
Reference: RFC 7396 (JSON Merge Patch), RFC 5789 (PATCH Method)
Return 201 Created with Location Header from POST
POST creates a new subordinate resource and the server assigns its URI. Returning 200 with just the body forces clients to guess or parse the new resource's URL. The Location header (RFC 9110 Section 10.2.2) is the standard mechanism for the server to tell the client where the new resource lives, enabling hypermedia-driven navigation.
Incorrect (returns 200, no Location header):
class OrdersController < ApplicationController
def create
order = current_user.orders.build(order_params)
if order.save
render json: OrderSerializer.new(order), status: :ok # 200 — client has no URI for the new resource
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
end
endCorrect (returns 201 with Location header):
class OrdersController < ApplicationController
def create
order = current_user.orders.build(order_params)
if order.save
render json: OrderSerializer.new(order),
status: :created, # 201 — resource was created
location: api_v1_order_url(order) # Location header points to the new resource
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
end
endHTTP/1.1 201 Created
Location: https://api.example.com/api/v1/orders/ord_9f2a
Content-Type: application/json
{
"id": "ord_9f2a",
"total": "129.99",
"status": "pending",
"_links": {
"self": { "href": "/api/v1/orders/ord_9f2a" },
"customer": { "href": "/api/v1/customers/cust_7" }
}
}Benefits:
- Clients follow the Location header to GET the new resource -- no URL construction needed
- Intermediaries and monitoring tools detect creation events via 201 status
- The server assigns the URI, not the client -- POST to a collection, server responds with Location
When NOT to use: POST for non-creation actions (triggering exports, sending emails) should return 200 or 202 instead. See restful-hateoas:status-202-for-async for long-running operations.
Reference: RFC 9110 Section 15.3.2 (201 Created). See also restful-hateoas:status-201-with-location for the status code and header mechanics.
Use PUT Only for Full Resource Replacement
PUT sends a complete replacement representation and must be idempotent -- sending the same PUT request twice produces the same resource state. This is what makes PUT safe to retry on network failures. Using PUT for partial updates violates the HTTP contract and breaks retry safety, because omitted fields might be nullified on retry but not on the original request.
Incorrect (PUT partially updates like PATCH):
class CustomersController < ApplicationController
def update
customer = Customer.find(params[:id])
# Partial update via PUT — omitted fields keep old values on first call
# but client expects full replacement semantics for retry safety
customer.update!(customer_params)
render json: CustomerSerializer.new(customer)
end
private
def customer_params
params.require(:customer).permit(:name, :email, :phone) # permits subset — not full replacement
end
endCorrect (PUT replaces all fields with complete representation):
class CustomersController < ApplicationController
REQUIRED_FIELDS = %i[name email phone address_line_1 address_city address_postcode].freeze
def update
customer = Customer.find(params[:id])
payload = customer_params
missing = REQUIRED_FIELDS - payload.keys.map(&:to_sym)
if missing.any?
return render json: { error: "PUT requires complete representation, missing: #{missing.join(', ')}" },
status: :unprocessable_entity
end
customer.update!(payload) # idempotent — same payload always produces same state
render json: CustomerSerializer.new(customer)
end
private
def customer_params
params.require(:customer).permit(*REQUIRED_FIELDS)
end
endPUT /api/v1/customers/cust_7f3a HTTP/1.1
Content-Type: application/json
{
"customer": {
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+44 7700 900000",
"address_line_1": "10 Downing Street",
"address_city": "London",
"address_postcode": "SW1A 2AA"
}
}Benefits:
- Safe to retry on timeout -- sending the same PUT twice cannot produce a different state
- Clients always know the full resource state after PUT, no hidden defaults
- Clear separation from PATCH avoids ambiguity about which fields are affected
When NOT to use: Use PATCH when clients only know a subset of fields, or when bandwidth is a concern for large resources.
Reference: RFC 9110 Section 9.3.4 (PUT)
Expose Available Actions as Links
The presence or absence of action links communicates which state transitions are currently allowed. If an order can be cancelled, include a cancel link. If it has already shipped, omit the link. This moves business rule enforcement from the client to the server -- clients never need to check status fields to decide what buttons to show.
Incorrect (client checks status and hardcodes which actions are allowed):
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
private
def resource_json
{
id: @resource.id,
total: @resource.total.to_f,
status: @resource.status # client reads this to decide: "if pending, show cancel button"
}
end
def self_href = "/api/v1/orders/#{@resource.id}"
# No action links -- client must duplicate business rules
endCorrect (server conditionally includes action links based on resource state):
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
private
def resource_json
{
id: @resource.id,
total: @resource.total.to_f,
status: @resource.status
}
end
def self_href = "/api/v1/orders/#{@resource.id}"
def extra_links
links = {}
if @resource.cancellable? # business rule lives in the model
links[:cancel] = { href: "/api/v1/orders/#{@resource.id}/cancellation", method: "POST" }
end
if @resource.shippable?
links[:ship] = { href: "/api/v1/orders/#{@resource.id}/shipment", method: "POST" }
end
if @resource.payable?
links[:pay] = { href: "/api/v1/orders/#{@resource.id}/payment", method: "POST" }
end
links
end
endBenefits:
- Adding a new action (e.g.,
refund) requires zero client changes -- the link just appears - Business rules change in one place (the model), not across every client
- UI can render buttons based on link presence:
show_button(:cancel) if links[:cancel]
Note: The method property on link objects is not part of the base HAL specification. It is a widely adopted pragmatic extension. For strict HAL compliance, use HAL-FORMS (_templates) to express available methods per link.
When NOT to use:
- For read-only resources with no state transitions, action links add no value
Reference: This is the core of HATEOAS -- Hypermedia as the Engine of Application State. See also restful-hateoas:res-noun-based-uris for modelling actions as sub-resource nouns.
Choose Between Embedding and Linking Related Resources
Decide between embedding related resources inline (_embedded) and linking to them (_links). Embedding always-needed data saves round trips; linking optional data keeps payloads small. The best APIs do both -- embed core associations and link to the rest, optionally letting clients request embedding via a query parameter.
Incorrect (always embedding all associations -- bloated responses):
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
private
def resource_json
{
id: @resource.id,
total: @resource.total.to_f,
status: @resource.status,
customer: CustomerSerializer.new(@resource.customer).as_json,
line_items: @resource.line_items.map { |li| LineItemSerializer.new(li).as_json },
shipment: @resource.shipment ? ShipmentSerializer.new(@resource.shipment).as_json : nil,
payments: @resource.payments.map { |p| PaymentSerializer.new(p).as_json },
audit_log: @resource.audit_entries.map { |e| AuditSerializer.new(e).as_json }
}
# Every response includes everything -- 5-10KB per order even for a list view
end
endCorrect (embed core data, link optional data, support client-requested embedding):
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
def initialize(resource, request: nil, embed: [])
super(resource, request: request)
@embed = Array(embed) # e.g., ["customer", "line_items"]
end
private
def resource_json
json = { id: @resource.id, total: @resource.total.to_f, status: @resource.status }
json[:_embedded] = embedded_resources if embedded_resources.any?
json
end
def self_href = "/api/v1/orders/#{@resource.id}"
def extra_links
{
customer: { href: "/api/v1/customers/#{@resource.customer_id}" },
line_items: { href: "/api/v1/orders/#{@resource.id}/line_items" },
shipment: (@resource.shipment_id &&
{ href: "/api/v1/shipments/#{@resource.shipment_id}" }),
payments: { href: "/api/v1/orders/#{@resource.id}/payments" }
}.compact
end
def embedded_resources
embeds = {}
if @embed.include?("customer")
embeds[:customer] = CustomerSerializer.new(@resource.customer).as_json
end
if @embed.include?("line_items")
embeds[:line_items] = @resource.line_items.map { |li| LineItemSerializer.new(li).as_json }
end
embeds
end
end
# Controller parses ?embed= query parameter
# GET /api/v1/orders/42?embed=customer,line_items
class Api::V1::OrdersController < Api::V1::BaseController
def show
order = Order.includes(*embed_associations).find(params[:id])
render json: OrderSerializer.new(order, embed: embed_params).as_json
end
private
def embed_params = params.fetch(:embed, "").split(",")
def embed_associations
allowed = { "customer" => :customer, "line_items" => :line_items }
embed_params.filter_map { |e| allowed[e] } # whitelist to prevent arbitrary includes
end
endBenefits:
- Default responses stay small -- only links, no embedded payloads
- Clients that need related data request it explicitly via
?embed= - Server eager-loads only requested associations, avoiding N+1 queries
When NOT to use:
- If a related resource is always needed in every client (e.g., order always needs line_items for display), embed it by default instead of requiring the
?embed=parameter
Reference: HAL specification defines _embedded for inline resource representations. See also restful-hateoas:link-related-resource-links.
Provide a Root API Entry Point
Provide a root endpoint (e.g., GET /api) that returns links to all top-level resources. This is the "homepage" of your API -- clients start here and discover the entire API through link traversal. Without a root, clients must know every resource URI upfront from out-of-band documentation.
Incorrect (no root endpoint -- clients must hardcode all resource URIs):
# config/routes.rb
namespace :api do
resources :orders
resources :customers
resources :products
resources :shipments
# No root route -- clients need a README to discover these
endCorrect (root controller returns a link map to all resources):
# config/routes.rb
namespace :api do
root "root#index" # GET /api
resources :orders
resources :customers
resources :products
resources :shipments
end
# app/controllers/api/root_controller.rb
class Api::RootController < Api::BaseController
def index
render json: {
_links: {
self: { href: "/api" },
orders: { href: "/api/orders", title: "Customer orders" },
customers: { href: "/api/customers", title: "Customer accounts" },
products: { href: "/api/products", title: "Product catalogue" },
shipments: { href: "/api/shipments", title: "Shipment tracking" }
}
}
end
endAlternative (include API metadata alongside links):
def index
render json: {
api: "Example Store API",
_links: {
self: { href: "/api" },
orders: { href: "/api/orders" },
customers: { href: "/api/customers" },
"https://api.example.com/rels/search": {
href: "/api/search{?q}", # URI template (RFC 6570)
templated: true
}
}
}
endBenefits:
- New resources are discoverable the moment you add them to the root
- Clients need exactly one bookmark: the root URI
- API documentation becomes a supplement, not a prerequisite
Reference: Roy Fielding's thesis, Section 5.2.1 -- "A REST API should be entered with no prior knowledge beyond the initial URI." See also restful-hateoas:link-standard-relation-types.
Use Hypermedia Links for Pagination
Collection responses must include next, prev, first, and last links for pagination. When clients follow links instead of constructing page URLs, you can migrate from offset to cursor-based pagination without breaking a single client -- the link structure stays the same, only the href value changes.
Incorrect (pagination metadata forces clients to build URLs):
# app/serializers/order_collection_serializer.rb
class OrderCollectionSerializer
def initialize(orders)
@orders = orders
end
def as_json
{
orders: @orders.map { |o| OrderSerializer.new(o).as_json },
meta: {
page: @orders.current_page,
per_page: 25,
total_pages: @orders.total_pages
}
# Client must build: "/orders?page=#{meta[:page] + 1}" -- coupled to URL scheme
}
end
endCorrect (pagination links -- clients follow hrefs without URL construction):
# app/serializers/order_collection_serializer.rb
class OrderCollectionSerializer
def initialize(orders, base_path:)
@orders = orders
@base_path = base_path
end
def as_json
{
_links: pagination_links,
_embedded: {
orders: @orders.map { |o| OrderSerializer.new(o).as_json }
},
total_count: @orders.total_count
}
end
private
def pagination_links
links = { self: { href: "#{@base_path}?cursor=#{@orders.first&.id}" } }
if @orders.length == 25 # more pages available
links[:next] = { href: "#{@base_path}?cursor=#{@orders.last.id}&direction=after" }
end
if @cursor.present? # cursor param indicates a previous page exists
links[:prev] = { href: "#{@base_path}?cursor=#{@orders.first.id}&direction=before" }
end
links
end
endBenefits:
- Switching from
?page=3to?cursor=abc123requires zero client changes - Clients use
response._links.next.hrefconsistently -- no URL construction - Absent
nextlink signals "last page" without a separate boolean field
When NOT to use:
- Internal admin tools where page-number jumping is a core UX requirement may still benefit from exposing page metadata alongside links
Reference: See also rails-dev:api-pagination for server-side pagination setup, and restful-hateoas:link-standard-relation-types for IANA next/prev/first/last rels.
Link to Related Resources Instead of Exposing Foreign Keys
Include links to related resources instead of just exposing foreign key IDs. When clients receive customer_id: 7, they must know how to build /api/v1/customers/7 -- coupling them to your URI structure. Links let clients follow relationships without knowing URL patterns.
Incorrect (foreign key IDs require client-side URI construction):
# app/serializers/order_serializer.rb
class OrderSerializer
def initialize(order)
@order = order
end
def as_json
{
id: @order.id,
customer_id: @order.customer_id, # client must know the customers URL pattern
shipment_id: @order.shipment_id, # client must know the shipments URL pattern
total: @order.total.to_f,
status: @order.status
}
end
endCorrect (links to related resources decouple clients from URI patterns):
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
private
def resource_json
{
id: @resource.id,
total: @resource.total.to_f,
status: @resource.status
}
end
def self_href = "/api/v1/orders/#{@resource.id}"
def extra_links
links = { customer: { href: "/api/v1/customers/#{@resource.customer_id}" } }
if @resource.shipment_id
links[:shipment] = { href: "/api/v1/shipments/#{@resource.shipment_id}" }
end
links[:line_items] = { href: "/api/v1/orders/#{@resource.id}/line_items" }
links
end
endBenefits:
- Server can rename
/customersto/accountswithout breaking clients -- just update the link - Clients discover relationships by following links, not by memorizing URI templates
- Optional associations (like
shipment) are simply absent rather thannullIDs
When NOT to use:
- If clients always need the related data in the same request, embed it in
_embedded(seerestful-hateoas:link-embedded-vs-linked)
Reference: See also restful-hateoas:link-standard-relation-types for choosing correct rel names.
Include a Self Link in Every Resource
Every resource representation must include a self link pointing to its canonical URI. Without it, clients cannot bookmark, cache, or refer back to a resource they received embedded in another response. The self link is the most fundamental hypermedia control -- it establishes resource identity.
Incorrect (response with no links -- client must reconstruct URIs):
# app/serializers/order_serializer.rb
class OrderSerializer
def initialize(order)
@order = order
end
def as_json
{
id: @order.id,
total: @order.total.to_f,
status: @order.status,
placed_at: @order.created_at.iso8601
}
# Client must know "/api/v1/orders/#{id}" to refetch this resource
end
endCorrect (base serializer adds self link to every resource):
# app/serializers/base_serializer.rb
class BaseSerializer
def initialize(resource, request: nil)
@resource = resource
@request = request
end
def as_json
resource_json.merge(_links: { self: { href: self_href } }.merge(extra_links))
end
private
def resource_json = raise NotImplementedError
def self_href = raise NotImplementedError
def extra_links = {} # override in subclasses to add more links
end
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
private
def resource_json
{
id: @resource.id,
total: @resource.total.to_f,
status: @resource.status,
placed_at: @resource.created_at.iso8601
}
end
def self_href = "/api/v1/orders/#{@resource.id}"
endBenefits:
- Clients cache and bookmark resources using the
selflink as the cache key - Embedded resources carry their own identity -- no URI guessing needed
- Every serializer inherits the pattern, ensuring consistency across the entire API
Reference: RFC 8288 (Web Linking) defines self as a standard relation type. See also restful-hateoas:link-standard-relation-types.
Use Standard IANA Link Relation Types
Use IANA-registered link relation types (self, next, prev, collection, item, edit, related) instead of inventing custom names. Standard rels have defined semantics that generic clients already understand. When you need a custom relation, use a URI (not a bare string) to avoid collisions and provide documentation.
Incorrect (invented relation names with no standard meaning):
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
private
def extra_links
{
get_customer: { href: "/api/v1/customers/#{@resource.customer_id}" },
do_cancel: { href: "/api/v1/orders/#{@resource.id}/cancel" },
fetch_items: { href: "/api/v1/orders/#{@resource.id}/items" },
parent_list: { href: "/api/v1/orders" }
}
# Every client must learn what "get_customer" and "do_cancel" mean
end
endCorrect (IANA standard rels, custom rels use URIs):
# app/serializers/order_serializer.rb
class OrderSerializer < BaseSerializer
CUSTOM_REL_BASE = "https://api.example.com/rels".freeze
private
def extra_links
links = {
related: { href: "/api/v1/customers/#{@resource.customer_id}" }, # IANA: related resource
collection: { href: "/api/v1/orders" }, # IANA: parent collection
item: @resource.line_items.map { |li| # IANA: child items
{ href: "/api/v1/orders/#{@resource.id}/line_items/#{li.id}" }
}
}
if @resource.cancellable?
links["#{CUSTOM_REL_BASE}/cancel"] = { # custom rel uses URI namespace
href: "/api/v1/orders/#{@resource.id}/cancellation",
method: "POST"
}
end
links
end
endCommon IANA relation types for REST APIs:
| Rel | Use for |
|---|---|
self | Canonical URI of this resource |
collection | Parent collection this item belongs to |
item | Individual items within a collection |
next / prev | Pagination navigation |
first / last | Pagination boundaries |
edit | URI to edit this resource (PUT/PATCH) |
related | A related resource |
Reference: IANA Link Relations Registry. See also restful-hateoas:link-self-link-every-resource.
Return 406 Not Acceptable for Unsupported Media Types
When a client sends an Accept header the server cannot satisfy, the correct response is 406 Not Acceptable. Silently falling back to JSON hides the mismatch -- the client thinks it got the format it asked for, parses it wrong, and breaks in subtle ways. A clear 406 with a list of supported types lets clients self-correct immediately.
Incorrect (unknown Accept header silently defaults to JSON):
# app/controllers/api/orders_controller.rb
class Api::OrdersController < ApplicationController
def show
order = Order.find(params[:id])
render json: OrderSerializer.new(order).as_json # always JSON, even if client asked for XML
end
endGET /api/orders/42 HTTP/1.1
Accept: application/xml
HTTP/1.1 200 OK
Content-Type: application/json
{"id":42,"total":99.95}Correct (return 406 with supported media types):
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
rescue_from ActionController::UnknownFormat, with: :not_acceptable
private
def not_acceptable
render json: {
error: "Not Acceptable",
message: "The requested media type is not supported.",
supported_media_types: [
"application/json",
"application/hal+json",
"application/vnd.api+json"
]
}, status: :not_acceptable, content_type: "application/json"
end
end
# app/controllers/api/orders_controller.rb
class Api::OrdersController < ApplicationController
def show
order = Order.find(params[:id])
respond_to do |format|
format.json { render json: OrderSerializer.new(order).as_json }
format.hal { render json: HalOrderSerializer.new(order).as_json, content_type: "application/hal+json" }
# Any other Accept value raises ActionController::UnknownFormat → 406
end
end
endGET /api/orders/42 HTTP/1.1
Accept: application/xml
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{
"error": "Not Acceptable",
"message": "The requested media type is not supported.",
"supported_media_types": [
"application/json",
"application/hal+json",
"application/vnd.api+json"
]
}Benefits:
- Clients discover exactly which media types are available without consulting docs
- No silent mismatches where a client parses the wrong format and fails downstream
- API gateways and monitoring dashboards can track 406s to spot misconfigured clients
Reference: RFC 9110 Section 15.5.7 (406 Not Acceptable). See also restful-hateoas:media-accept-header-negotiation for registering the MIME types that respond_to matches against.
Respect the Accept Header for Content Negotiation
The Accept header is the HTTP mechanism for clients to declare which representation they want. By registering custom MIME types and using respond_to, a single controller action can serve plain JSON, HAL+JSON, and JSON:API -- all from the same URI. Without this, you end up with parallel endpoints (/api/orders.json, /api/orders.hal) or, worse, every client gets the same format whether they can parse it or not.
Incorrect (ignoring Accept header -- all clients get the same JSON regardless):
# app/controllers/api/orders_controller.rb
class Api::OrdersController < ApplicationController
def show
order = Order.find(params[:id])
render json: OrderSerializer.new(order).as_json # always plain JSON, Accept header ignored
end
endCorrect (register MIME types and negotiate based on Accept header):
# config/initializers/mime_types.rb
Mime::Type.register "application/hal+json", :hal
Mime::Type.register "application/vnd.api+json", :jsonapi
# app/controllers/api/orders_controller.rb
class Api::OrdersController < ApplicationController
def show
order = Order.find(params[:id])
respond_to do |format|
format.json { render json: OrderSerializer.new(order).as_json }
format.hal { render json: HalOrderSerializer.new(order).as_json, content_type: "application/hal+json" }
format.jsonapi { render json: JsonApiOrderSerializer.new(order).as_json, content_type: "application/vnd.api+json" }
end
end
end# Client requesting HAL
GET /api/orders/42 HTTP/1.1
Accept: application/hal+json
HTTP/1.1 200 OK
Content-Type: application/hal+jsonBenefits:
- One URI, multiple representations -- no endpoint duplication
- Adding a new format (e.g., JSON:API) requires zero route changes
- Clients opt into richer hypermedia formats at their own pace
When NOT to use:
- Internal microservices where every consumer is under your control and a single format suffices
Reference: RFC 9110 Section 12.5.1 (Accept header). See also restful-hateoas:media-content-type-in-responses for setting the correct Content-Type on the way out.
Set the Correct Content-Type in Every Response
The Content-Type header tells the client how to parse the response body. If you serve HAL-formatted JSON but label it application/json, clients that understand HAL will not activate their link-following logic, and generic JSON clients will stumble over _links and _embedded keys they did not expect. The Content-Type must match the actual media type being served -- anything else is a lie that breaks client automation.
Incorrect (HAL body with wrong Content-Type -- client cannot distinguish format):
# app/controllers/api/orders_controller.rb
class Api::OrdersController < ApplicationController
def show
order = Order.find(params[:id])
payload = {
id: order.id,
total: order.total.to_f,
_links: { self: { href: "/api/orders/#{order.id}" } }
}
render json: payload # Content-Type defaults to application/json -- wrong for HAL
end
endHTTP/1.1 200 OK
Content-Type: application/json
{"id":42,"total":99.95,"_links":{"self":{"href":"/api/orders/42"}}}Correct (Content-Type matches the actual media type):
# app/controllers/api/orders_controller.rb
class Api::OrdersController < ApplicationController
def show
order = Order.find(params[:id])
payload = {
id: order.id,
total: order.total.to_f,
_links: { self: { href: "/api/orders/#{order.id}" } }
}
render json: payload, content_type: "application/hal+json" # matches the HAL structure
end
endHTTP/1.1 200 OK
Content-Type: application/hal+json
{"id":42,"total":99.95,"_links":{"self":{"href":"/api/orders/42"}}}Alternative:
# app/controllers/concerns/hal_response.rb — extract into a concern for consistency
module HalResponse
extend ActiveSupport::Concern
private
def render_hal(payload, status: :ok)
render json: payload, content_type: "application/hal+json", status: status
end
endBenefits:
- HAL-aware clients (e.g., HAL Browser, Spring HATEOAS) activate link parsing automatically
- Content-Type-based routing in API gateways and proxies works correctly
- Clients can reliably switch parsing strategy based on the Content-Type header
Reference: RFC 9110 Section 8.3 (Content-Type). See also restful-hateoas:media-accept-header-negotiation for negotiating which format to serve.
Use Vendor Media Types for API Versioning
Vendor media types (application/vnd.myapp.v2+json) encode the API version in the Accept header instead of the URI. This is the pure REST approach -- a resource's URI identifies the resource, not its representation version. URL versioning (/api/v1/orders, /api/v2/orders) creates parallel URI spaces for the same resource, breaks bookmarks, invalidates caches, and forces clients to update every hardcoded URL on version bumps.
Incorrect (URL-based versioning -- every URI changes on version bump):
# config/routes.rb
namespace :api do
namespace :v1 do
resources :orders, only: [:show] # /api/v1/orders/:id
end
namespace :v2 do
resources :orders, only: [:show] # /api/v2/orders/:id -- same resource, different URI
end
end
# Two controllers, two route sets, duplicated logic for the same resourceCorrect (vendor media type in Accept header -- URI stays the same):
# config/routes.rb
namespace :api do
resources :orders, only: [:show] # /api/orders/:id -- one URI per resource, always
end
# app/controllers/concerns/vendor_media_type.rb
module VendorMediaType
extend ActiveSupport::Concern
included do
before_action :parse_api_version
end
private
def parse_api_version
accept = request.headers["Accept"] || ""
if accept.match?(/application\/vnd\.myapp\.v(\d+)\+json/)
@api_version = accept.match(/v(\d+)/)[1].to_i
else
@api_version = 1 # default version
end
end
end
# app/controllers/api/orders_controller.rb
class Api::OrdersController < ApplicationController
include VendorMediaType
def show
order = Order.find(params[:id])
serializer = @api_version >= 2 ? V2::OrderSerializer : V1::OrderSerializer
render json: serializer.new(order).as_json,
content_type: "application/vnd.myapp.v#{@api_version}+json"
end
end# Client requesting v2 representation
GET /api/orders/42 HTTP/1.1
Accept: application/vnd.myapp.v2+json
HTTP/1.1 200 OK
Content-Type: application/vnd.myapp.v2+jsonBenefits:
- Resource URIs remain stable forever -- bookmarks and caches survive version changes
- Adding v3 requires a new serializer, not new routes or controllers
- Clients opt into new versions explicitly by changing the Accept header
- HATEOAS links in responses always point to stable URIs
When NOT to use:
- Public APIs where developer experience demands simple URL-based versioning (GitHub-style)
- When your API gateway does not support routing on Accept headers
Reference: RFC 6838 Section 3.2 (Vendor Media Types). See also restful-hateoas:media-accept-header-negotiation for the negotiation mechanism.
Limit Resource Nesting to Two Levels
Deeply nested URIs like /customers/5/orders/99/line_items/3 couple resources tightly and force clients to know the full ancestry chain. Limit nesting to two levels (/customers/5/orders) and use hypermedia links to connect deeper relationships. This lets each resource evolve its own URI independently.
Incorrect (3+ levels of nesting couples the entire hierarchy):
# config/routes.rb
Rails.application.routes.draw do
resources :customers do
resources :orders do
resources :line_items do
resources :adjustments # /customers/:id/orders/:id/line_items/:id/adjustments
end
end
end
endCorrect (shallow nesting with hypermedia links for traversal):
# config/routes.rb
Rails.application.routes.draw do
resources :customers do
resources :orders, shallow: true # /customers/:id/orders + /orders/:id
end
resources :orders, only: [] do
resources :line_items, shallow: true # /orders/:id/line_items + /line_items/:id
end
end
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
def show
line_item = LineItem.find(params[:id])
render json: {
id: line_item.id,
quantity: line_item.quantity,
_links: {
self: { href: line_item_path(line_item) },
order: { href: order_path(line_item.order) }, # link replaces deep nesting
customer: { href: customer_path(line_item.order.customer) }
}
}
end
endBenefits:
shallow: truegenerates collection routes under the parent but member routes at the top level- Clients follow
_linksto traverse relationships instead of constructing deep URIs - Resources can be moved, renamed, or restructured without breaking deeply nested paths
Model Business Entities, Not Database Tables
API resources represent business concepts, not ActiveRecord models. Exposing join tables, internal bookkeeping models, or raw database structure forces clients to understand your schema. Aggregate related data into resources that match how consumers think about the domain.
Incorrect (exposing internal join table as an API resource):
# Leaks schema: clients must know order_products is a join table
resources :orders
resources :products
resources :order_products # join table exposed as a resource
# app/controllers/order_products_controller.rb
class OrderProductsController < ApplicationController
def create
order_product = OrderProduct.create!(
order_id: params[:order_id],
product_id: params[:product_id],
quantity: params[:quantity],
unit_price: params[:unit_price] # client must set price -- business logic leak
)
render json: order_product, status: :created
end
endCorrect (aggregate resource models the business concept):
resources :orders do
resources :line_items, only: %i[index create destroy] # business concept, not a join table
end
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
def create
order = Order.find(params[:order_id])
line_item = order.add_item(params[:product_id], params[:quantity]) # domain logic stays server-side
render json: {
id: line_item.public_id,
product: line_item.product_name,
quantity: line_item.quantity,
unit_price: line_item.unit_price,
_links: {
self: { href: line_item_path(line_item) },
order: { href: order_path(order) }
}
}, status: :created
end
endBenefits:
- Clients work with "line items" and "orders" -- concepts they understand
- Server owns pricing, validation, and business rules behind the aggregate boundary
- Schema changes (renaming tables, splitting models) do not break the API contract
Use Noun-Based URIs, Not Verbs
URIs identify resources, not operations. Actions are expressed through HTTP methods (GET, POST, PUT, DELETE), not through verb segments in the path. Verb-based routes create an ever-growing list of custom endpoints that duplicate what standard CRUD already provides.
Incorrect (verb-based routes duplicate HTTP method semantics):
# config/routes.rb
Rails.application.routes.draw do
post "/orders/create", to: "orders#create"
put "/orders/update/:id", to: "orders#update"
post "/users/:id/activate", to: "users#activate" # verb in URI
post "/users/:id/deactivate", to: "users#deactivate"
get "/reports/generate", to: "reports#generate"
endCorrect (nouns as resources, HTTP methods as verbs):
# config/routes.rb
Rails.application.routes.draw do
resources :orders, only: %i[index show create update destroy]
resources :users, only: %i[show update] do
resource :activation, only: %i[create destroy] # sub-resource noun for state change
end
resources :reports, only: %i[create show] # POST creates, GET retrieves
endBenefits:
- Standard CRUD routes need no documentation -- the HTTP method is the verb
- Sub-resources like
/users/5/activationmodel state transitions as nouns - Clients and intermediaries can rely on method semantics for caching and retries
Reference: See also rails-dev:ctrl-restful-routes for controller-level conventions.
Use Plural Nouns for Collection URIs
Collections must use plural nouns (/orders, /customers) so that the collection URI and individual resource URI form a natural hierarchy: GET /orders lists all, GET /orders/5 fetches one. Mixing singular and plural creates ambiguity about which URI returns a collection versus a single resource.
Incorrect (inconsistent singular/plural URIs):
# config/routes.rb
Rails.application.routes.draw do
resource :order # /order -- singular, no index route
resources :customers # /customers -- plural
get "/user/:id", to: "users#show" # singular noun
get "/user/:id/setting", to: "settings#show" # singular nested
endCorrect (consistent plural collection URIs):
# config/routes.rb
Rails.application.routes.draw do
resources :orders # GET /orders, GET /orders/:id, POST /orders ...
resources :customers # GET /customers, GET /customers/:id ...
resources :users, only: %i[show] do
resources :settings, only: %i[index show update] # /users/:id/settings
end
endBenefits:
GET /ordersalways returns a collection;GET /orders/:idalways returns one resource- Rails
resourcesgenerates plural routes by default -- fighting the convention costs effort - Clients can predict URI patterns without consulting documentation
When NOT to use: Use resource (singular) only for true singletons scoped to the current user, such as /profile or /session, where there is exactly one resource per authenticated context.
Express Relationships as Sub-Resources
Ownership relationships belong in the URI path (/orders/42/line_items), not in query parameters (/line_items?order_id=42). Sub-resources make the parent-child relationship explicit and discoverable. Combine with hypermedia links so clients navigate from parent to children without constructing URIs.
Incorrect (flat resources with filter params hide ownership):
# config/routes.rb
Rails.application.routes.draw do
resources :orders, only: %i[index show]
resources :line_items, only: %i[index show create] # flat, relationship via params
end
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
def index
line_items = LineItem.where(order_id: params[:order_id]) # relationship buried in query string
render json: line_items.map { |item|
{ id: item.id, product: item.product_name, quantity: item.quantity }
}
end
endCorrect (sub-resources express ownership, hypermedia links enable traversal):
# config/routes.rb
Rails.application.routes.draw do
resources :orders, only: %i[index show] do
resources :line_items, only: %i[index create], shallow: true
end
end
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
def index
order = Order.find(params[:order_id])
line_items = order.line_items
render json: {
_embedded: {
line_items: line_items.map { |item|
{
id: item.public_id,
product: item.product_name,
quantity: item.quantity,
_links: {
self: { href: line_item_path(item) },
order: { href: order_path(order) } # navigate back to parent
}
}
}
},
_links: {
self: { href: order_line_items_path(order) },
order: { href: order_path(order) }
}
}
end
endBenefits:
POST /orders/42/line_itemsmakes it obvious the new item belongs to order 42- Clients discover child resources through
_linkson the parent, no URI templates needed - Authorization scoping is natural -- accessing line items implies access to the parent order
When NOT to use: Use query parameters for cross-cutting filters (/line_items?min_price=10) that do not express ownership. Sub-resources are for belongs-to relationships, not arbitrary filtering.
Use Consistent Opaque Identifiers Across Resources
Never expose auto-increment integer IDs in URIs. Sequential IDs reveal record counts, enable enumeration attacks, and tie your API to a single database's sequence. Use prefixed UUIDs or opaque identifiers that are consistent across all resources.
Incorrect (sequential integer IDs expose internal state):
# app/models/order.rb
class Order < ApplicationRecord
# Uses default auto-increment ID
# URI: /orders/42 -- attacker can guess /orders/41, /orders/43
end
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def show
order = Order.find(params[:id]) # sequential integer lookup
render json: {
id: order.id, # exposes 42, 43, 44 ...
total: order.total,
_links: {
self: { href: "/orders/#{order.id}" }
}
}
end
endCorrect (prefixed opaque identifiers prevent enumeration):
# app/models/order.rb
class Order < ApplicationRecord
before_create :assign_public_id
def to_param
public_id # Rails uses this for URL generation
end
private
def assign_public_id
self.public_id = "ord_#{SecureRandom.uuid.delete('-')}" # e.g. ord_8f3a2b4c5d6e7f8a9b0c
end
end
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def show
order = Order.find_by!(public_id: params[:id])
render json: {
id: order.public_id,
total: order.total,
_links: {
self: { href: order_path(order) } # /orders/ord_8f3a2b4c5d6e7f8a9b0c
}
}
end
endBenefits:
- Opaque IDs prevent competitors or attackers from guessing resource counts or scraping sequentially
- Prefixed IDs (
ord_,cust_) are self-describing in logs and debugging - UUIDs enable distributed ID generation without central sequence coordination
When NOT to use: Internal admin dashboards behind authentication may use integer IDs for convenience, but public-facing APIs should always use opaque identifiers.
Return 201 Created with Location Header
The 201 status code combined with the Location header is the server's mechanism for telling clients where a newly created resource lives. The Location header carries the canonical URI; the body carries the representation with _links. Returning 200 after creation hides the creation event from intermediaries and leaves clients without a standard way to discover the new resource's URI.
Incorrect (returns 200 with no Location header):
class Api::V1::OrdersController < ApplicationController
def create
order = current_user.orders.build(order_params)
if order.save
render json: OrderSerializer.new(order), status: :ok # 200 — client cannot discover the new resource URI
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
end
endHTTP/1.1 200 OK
Content-Type: application/json
{
"id": "ord_abc",
"total": "129.99",
"status": "pending"
}Correct (returns 201 Created with Location header and _links):
class Api::V1::OrdersController < ApplicationController
def create
order = current_user.orders.build(order_params)
if order.save
render json: OrderSerializer.new(order),
status: :created, # 201 — resource was created
location: api_v1_order_url(order) # Location header points to the new resource
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
end
endHTTP/1.1 201 Created
Location: https://api.example.com/api/v1/orders/ord_abc
Content-Type: application/json
{
"id": "ord_abc",
"total": "129.99",
"status": "pending",
"_links": {
"self": { "href": "/api/v1/orders/ord_abc" },
"customer": { "href": "/api/v1/customers/cust_7" },
"cancel": { "href": "/api/v1/orders/ord_abc/cancellation", "method": "POST" }
}
}Benefits:
- Clients follow the Location header to GET the new resource -- no URL construction needed
- Intermediaries and monitoring tools detect creation events via 201 status
- Combined with
_linksin the body, the client gets both the canonical URI and available next actions in one response
When NOT to use: Batch creation endpoints that create multiple resources may return 200 with a collection body instead, since Location can only hold a single URI.
Reference: RFC 9110 Section 15.3.2 (201 Created). See also restful-hateoas:http-post-for-creation, restful-hateoas:link-self-link-every-resource.
Related skills
FAQ
What does restful-hateoas do?
restful-hateoas: A skill for development. This provides functionality for development workflows.
When should I use restful-hateoas?
When you need to use restful-hateoas for development tasks, or when restful-hateoas: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
restful-hateoas.