Overview
JSON:API defines a specification for building APIs that return JSON responses with consistent structure, relationships, and metadata. Ruby implements JSON:API through several gems, with jsonapi-resources
and jsonapi-serializer
being the most widely adopted solutions.
The JSON:API specification standardizes how resources, relationships, errors, and metadata appear in API responses. Ruby applications typically handle JSON:API through serializer classes that transform ActiveRecord models or plain Ruby objects into compliant JSON structures.
# Basic JSON:API response structure
{
"data": {
"type": "articles",
"id": "1",
"attributes": {
"title": "JSON:API in Ruby"
},
"relationships": {
"author": {
"data": { "type": "people", "id": "42" }
}
}
}
}
Ruby JSON:API implementations provide serializers that generate this structure automatically. The jsonapi-serializer
gem offers fast serialization with minimal dependencies, while jsonapi-resources
provides full CRUD operations with routing integration.
class ArticleSerializer
include JSONAPI::Serializer
attributes :title, :content, :published_at
belongs_to :author, serializer: AuthorSerializer
has_many :comments, serializer: CommentSerializer
end
# Generate JSON:API response
serializer = ArticleSerializer.new(article)
serializer.serializable_hash
Ruby applications handle JSON:API requests through controller actions that parse incoming data, validate resources, and return properly formatted responses. The specification requires specific HTTP status codes and error formats that Ruby implementations handle automatically.
Basic Usage
Ruby JSON:API development starts with defining serializer classes that map Ruby objects to JSON:API resources. Serializers declare attributes, relationships, and metadata that appear in API responses.
class UserSerializer
include JSONAPI::Serializer
attributes :email, :first_name, :last_name
attribute :full_name do |user|
"#{user.first_name} #{user.last_name}"
end
has_many :posts
has_one :profile
end
Controllers handle JSON:API requests by parsing incoming data and generating responses through serializers. The jsonapi-rails
gem provides controller mixins that handle common operations.
class UsersController < ApplicationController
def show
user = User.find(params[:id])
render json: UserSerializer.new(user).serializable_hash
end
def create
user = User.new(user_params)
if user.save
render json: UserSerializer.new(user).serializable_hash, status: :created
else
render json: { errors: user.errors }, status: :unprocessable_entity
end
end
private
def user_params
params.require(:data).require(:attributes).permit(:email, :first_name, :last_name)
end
end
JSON:API requests include resource data in the data
attribute with type
, attributes
, and relationships
sections. Ruby applications extract this data through strong parameters or dedicated parsing methods.
# Incoming JSON:API request
{
"data": {
"type": "users",
"attributes": {
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe"
},
"relationships": {
"profile": {
"data": { "type": "profiles", "id": "123" }
}
}
}
}
Serializers handle relationships through belongs_to
and has_many
declarations. Ruby loads related objects based on includes parameters in requests, supporting compound documents that contain multiple resource types.
class PostSerializer
include JSONAPI::Serializer
attributes :title, :content, :published_at
belongs_to :author, serializer: UserSerializer
has_many :comments, serializer: CommentSerializer
# Conditional attributes based on current user
attribute :edit_url, if: proc { |record, params|
params[:current_user]&.can_edit?(record)
}
end
# Controller with includes
def show
post = Post.includes(:author, :comments).find(params[:id])
options = { include: [:author, :comments], params: { current_user: current_user } }
render json: PostSerializer.new(post, options).serializable_hash
end
Advanced Usage
Ruby JSON:API implementations support complex relationship patterns, custom serialization logic, and performance optimizations for large datasets. Advanced serializers handle polymorphic relationships, conditional includes, and custom resource identifiers.
class ActivitySerializer
include JSONAPI::Serializer
attributes :action, :created_at
# Polymorphic relationship
belongs_to :subject, polymorphic: true
belongs_to :actor, serializer: UserSerializer
# Custom type resolution for polymorphic resources
attribute :subject_type do |activity|
activity.subject.class.name.downcase
end
# Conditional relationship based on permissions
belongs_to :target, if: proc { |record, params|
params[:current_user]&.can_view?(record.target)
}
end
Sparse fieldsets allow clients to request specific attributes, reducing response size and improving performance. Ruby serializers handle fieldsets through the fields
parameter in serialization options.
# Request with sparse fieldsets
GET /articles?fields[articles]=title,published_at&fields[people]=name
# Controller handling sparse fieldsets
def index
articles = Article.includes(:author)
options = {
fields: {
articles: [:title, :published_at],
people: [:name]
},
include: [:author]
}
render json: ArticleSerializer.new(articles, options).serializable_hash
end
Custom serialization contexts enable different serialization behavior based on user permissions, API versions, or request parameters. Ruby serializers receive context through the params hash.
class DocumentSerializer
include JSONAPI::Serializer
attributes :title, :created_at
# Version-specific attributes
attribute :content, if: proc { |record, params|
params[:api_version] >= 2
}
# Permission-based attributes
attribute :internal_notes, if: proc { |record, params|
params[:current_user]&.admin?
}
# Dynamic relationship inclusion
has_many :revisions, if: proc { |record, params|
params[:include_history]
}
end
# Controller with serialization context
def show
document = Document.find(params[:id])
options = {
params: {
current_user: current_user,
api_version: request.headers['API-Version']&.to_i || 1,
include_history: params[:include_history] == 'true'
}
}
render json: DocumentSerializer.new(document, options).serializable_hash
end
Meta information and pagination data require custom serialization methods. Ruby applications add meta information through serializer options or custom methods that calculate pagination details.
class PaginatedSerializer
def self.serialize_collection(resources, serializer_class, options = {})
serializer = serializer_class.new(resources, options)
hash = serializer.serializable_hash
# Add pagination meta
hash[:meta] = {
current_page: resources.current_page,
per_page: resources.per_page,
total_pages: resources.total_pages,
total_count: resources.total_count
}
# Add pagination links
hash[:links] = build_pagination_links(resources, options[:base_url])
hash
end
private
def self.build_pagination_links(resources, base_url)
links = { self: "#{base_url}?page=#{resources.current_page}" }
links[:first] = "#{base_url}?page=1" if resources.total_pages > 1
links[:last] = "#{base_url}?page=#{resources.total_pages}" if resources.total_pages > 1
links[:prev] = "#{base_url}?page=#{resources.prev_page}" if resources.prev_page
links[:next] = "#{base_url}?page=#{resources.next_page}" if resources.next_page
links
end
end
Error Handling & Debugging
JSON:API specifies error response formats that Ruby implementations must follow. Error objects contain status codes, titles, details, and source pointers that identify specific request elements causing validation failures.
class ApiError
attr_reader :status, :code, :title, :detail, :source
def initialize(status:, code: nil, title:, detail:, source: nil)
@status = status
@code = code
@title = title
@detail = detail
@source = source
end
def to_jsonapi
error = {
status: status.to_s,
title: title,
detail: detail
}
error[:code] = code if code
error[:source] = source if source
error
end
end
ActiveRecord validation errors convert to JSON:API error format through custom error handling. Ruby applications map validation errors to appropriate HTTP status codes and source pointers.
class ErrorSerializer
def self.serialize_validation_errors(record)
errors = record.errors.map do |error|
ApiError.new(
status: 422,
title: 'Validation Error',
detail: error.full_message,
source: { pointer: "/data/attributes/#{error.attribute}" }
)
end
{ errors: errors.map(&:to_jsonapi) }
end
def self.serialize_not_found(resource_type, id)
error = ApiError.new(
status: 404,
title: 'Resource Not Found',
detail: "#{resource_type.capitalize} with id #{id} not found"
)
{ errors: [error.to_jsonapi] }
end
end
# Controller error handling
class UsersController < ApplicationController
rescue_from ActiveRecord::RecordNotFound do |exception|
render json: ErrorSerializer.serialize_not_found('user', params[:id]),
status: :not_found
end
def create
user = User.new(user_params)
if user.save
render json: UserSerializer.new(user).serializable_hash, status: :created
else
render json: ErrorSerializer.serialize_validation_errors(user),
status: :unprocessable_entity
end
end
end
Relationship validation errors require source pointers that identify specific relationship data. Ruby applications handle relationship errors through custom validation methods and error serialization.
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments
validate :author_must_be_active
validate :comments_must_be_appropriate
private
def author_must_be_active
return unless author
unless author.active?
errors.add(:author, 'must be an active user')
end
end
def comments_must_be_appropriate
comments.each_with_index do |comment, index|
unless comment.appropriate?
errors.add(:comments, "comment at index #{index} contains inappropriate content")
end
end
end
end
# Enhanced error serializer for relationships
class ErrorSerializer
def self.serialize_validation_errors(record)
errors = record.errors.map do |error|
source_pointer = case error.attribute
when :author
'/data/relationships/author'
when :comments
'/data/relationships/comments'
else
"/data/attributes/#{error.attribute}"
end
ApiError.new(
status: 422,
title: 'Validation Error',
detail: error.full_message,
source: { pointer: source_pointer }
)
end
{ errors: errors.map(&:to_jsonapi) }
end
end
Debug information helps developers identify serialization issues and performance bottlenecks. Ruby JSON:API implementations provide debugging methods that reveal serialization paths and relationship loading patterns.
class DebugSerializer
include JSONAPI::Serializer
def self.new(resource, options = {})
if Rails.env.development? && options[:debug]
puts "Serializing #{resource.class.name} with options: #{options.inspect}"
if options[:include]
puts "Loading relationships: #{options[:include]}"
resource.association_cache.each do |name, association|
puts " #{name}: #{association.loaded? ? 'loaded' : 'not loaded'}"
end
end
end
super(resource, options)
end
end
# Usage in controller
def show
user = User.includes(:posts, :comments).find(params[:id])
options = {
include: [:posts, :comments],
debug: params[:debug] == 'true'
}
render json: DebugSerializer.new(user, options).serializable_hash
end
Production Patterns
Ruby JSON:API applications in production require caching strategies, monitoring, and performance optimizations. Rails applications integrate JSON:API through action caching, fragment caching, and relationship preloading.
class OptimizedController < ApplicationController
before_action :set_cache_headers
def index
cache_key = [
'posts-index',
params[:page],
params[:include],
params[:fields],
Post.maximum(:updated_at)
].compact.join('-')
cached_response = Rails.cache.fetch(cache_key, expires_in: 15.minutes) do
posts = Post.includes(included_relationships)
.page(params[:page])
.per(params[:per_page] || 20)
PaginatedSerializer.serialize_collection(
posts,
PostSerializer,
include: parse_include_params,
fields: parse_fields_params,
base_url: request.base_url + request.path
)
end
render json: cached_response
end
private
def included_relationships
include_params = parse_include_params
relationships = []
relationships << :author if include_params.include?(:author)
relationships << { comments: :author } if include_params.include?(:comments)
relationships
end
def parse_include_params
params[:include]&.split(',')&.map(&:to_sym) || []
end
def parse_fields_params
return {} unless params[:fields]
params[:fields].to_hash.transform_keys(&:to_sym).transform_values do |fields|
fields.split(',').map(&:to_sym)
end
end
def set_cache_headers
expires_in 15.minutes, public: true
fresh_when(last_modified: Post.maximum(:updated_at))
end
end
Background job processing handles expensive serialization operations for large datasets. Ruby applications queue serialization tasks and cache results for subsequent requests.
class SerializationJob < ApplicationJob
def perform(resource_class, resource_id, serializer_class, options = {})
resource = resource_class.constantize.find(resource_id)
serializer = serializer_class.constantize.new(resource, options)
cache_key = build_cache_key(resource_class, resource_id, options)
Rails.cache.write(cache_key, serializer.serializable_hash, expires_in: 1.hour)
# Notify clients via ActionCable or webhook
ActionCable.server.broadcast(
"serialization_#{resource_id}",
{ status: 'complete', cache_key: cache_key }
)
end
private
def build_cache_key(resource_class, resource_id, options)
[
'serialized',
resource_class.underscore,
resource_id,
Digest::MD5.hexdigest(options.to_json)
].join('-')
end
end
# Controller with background serialization
class UsersController < ApplicationController
def show
user = User.find(params[:id])
cache_key = build_cache_key(user)
cached_data = Rails.cache.read(cache_key)
if cached_data
render json: cached_data
else
# Queue background job and return processing status
SerializationJob.perform_later(
'User',
user.id,
'UserSerializer',
include: parse_include_params,
fields: parse_fields_params
)
render json: {
meta: {
status: 'processing',
cache_key: cache_key,
estimated_completion: 30.seconds.from_now
}
}, status: :accepted
end
end
end
Monitoring JSON:API performance requires tracking serialization times, relationship loading patterns, and cache hit rates. Ruby applications integrate with monitoring services through custom middleware and instrumentation.
class JsonApiMetricsMiddleware
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
return @app.call(env) unless json_api_request?(request)
start_time = Time.current
status, headers, response = @app.call(env)
duration = Time.current - start_time
track_metrics(request, status, duration, headers)
[status, headers, response]
end
private
def json_api_request?(request)
request.content_type&.include?('application/vnd.api+json') ||
request.accept&.include?('application/vnd.api+json')
end
def track_metrics(request, status, duration, headers)
metrics = {
path: request.path,
method: request.request_method,
status: status,
duration: duration,
includes: request.params['include']&.split(',')&.size || 0,
cache_hit: headers['X-Cache-Status'] == 'HIT'
}
Rails.logger.info("JSON:API Request: #{metrics.to_json}")
# Send to monitoring service
StatsD.increment('jsonapi.requests.total')
StatsD.timing('jsonapi.requests.duration', duration * 1000)
StatsD.increment("jsonapi.requests.status.#{status}")
end
end
Common Pitfalls
Ruby JSON:API implementations encounter specific issues around relationship loading, attribute serialization, and response formatting. Circular relationship dependencies create infinite loops during serialization without proper handling.
# Problematic circular relationship
class UserSerializer
include JSONAPI::Serializer
attributes :name, :email
has_many :posts # This will include PostSerializer
end
class PostSerializer
include JSONAPI::Serializer
attributes :title, :content
belongs_to :author, serializer: UserSerializer # Circular reference
has_many :comments
end
class CommentSerializer
include JSONAPI::Serializer
attributes :content
belongs_to :post, serializer: PostSerializer # Another circular path
belongs_to :author, serializer: UserSerializer
end
# Solution: Control relationship inclusion
class UserSerializer
include JSONAPI::Serializer
attributes :name, :email
has_many :posts, serializer: PostSerializer, links: {
related: -> (object) { "/users/#{object.id}/posts" }
}
end
class PostSerializer
include JSONAPI::Serializer
attributes :title, :content
belongs_to :author, serializer: UserSerializer, links: {
related: -> (object) { "/posts/#{object.id}/author" }
}
has_many :comments, serializer: CommentSerializer
end
N+1 query problems occur when serializers load relationships individually rather than using includes. Ruby applications must preload relationships at the controller level to avoid database performance issues.
# Problematic: N+1 queries
def index
posts = Post.all # Does not preload relationships
render json: PostSerializer.new(posts, include: [:author, :comments]).serializable_hash
# This triggers separate queries for each post's author and comments
end
# Solution: Preload relationships
def index
posts = Post.includes(:author, comments: :author).all
render json: PostSerializer.new(posts, include: [:author, :comments]).serializable_hash
end
# Advanced: Dynamic includes based on request
def index
includes_param = params[:include]&.split(',') || []
relationships = build_includes_hash(includes_param)
posts = Post.includes(relationships).all
render json: PostSerializer.new(posts, include: includes_param.map(&:to_sym)).serializable_hash
end
private
def build_includes_hash(includes)
relationships = []
includes.each do |include_path|
case include_path
when 'author'
relationships << :author
when 'comments'
relationships << :comments
when 'comments.author'
relationships << { comments: :author }
end
end
relationships
end
Attribute method name conflicts arise when model methods conflict with serializer attribute names. Ruby method resolution can return unexpected values when attributes shadow model methods.
class User < ApplicationRecord
# Model has a private method named 'type'
private
def type
'premium' if premium_account?
end
end
class UserSerializer
include JSONAPI::Serializer
# This attribute conflicts with model's private method
attribute :type do |user|
user.admin? ? 'admin' : 'regular'
end
# Solution: Use explicit attribute block or rename
attribute :account_type do |user|
user.admin? ? 'admin' : 'regular'
end
# Or access the model method explicitly
attribute :subscription_type do |user|
user.send(:type) # Explicitly call model method
end
end
Nested relationship serialization can cause memory issues with deeply nested data structures. Ruby applications must limit nesting depth and implement relationship pagination for large datasets.
class TreeNodeSerializer
include JSONAPI::Serializer
attributes :name, :value
belongs_to :parent, serializer: :self
has_many :children, serializer: :self
# Problem: Infinite nesting possible
# Solution: Limit depth
has_many :children, if: proc { |record, params|
depth = params[:depth] || 0
depth < 3
} do |record, params|
TreeNodeSerializer.new(
record.children,
params: { depth: (params[:depth] || 0) + 1 }
)
end
end
# Controller with depth control
def show
node = TreeNode.includes(children: { children: :children }).find(params[:id])
render json: TreeNodeSerializer.new(node, params: { depth: 0 }).serializable_hash
end
Parameter parsing errors occur when client requests contain malformed JSON:API data. Ruby applications must validate request structure before attempting to extract attributes and relationships.
class JsonApiController < ApplicationController
before_action :validate_json_api_request, only: [:create, :update]
private
def validate_json_api_request
return unless request.content_type&.include?('application/vnd.api+json')
begin
data = JSON.parse(request.body.read)
rescue JSON::ParserError
render json: { errors: [{ title: 'Invalid JSON' }] }, status: :bad_request
return
end
unless data['data'].is_a?(Hash)
render json: {
errors: [{
title: 'Invalid JSON:API format',
detail: 'Request must contain a data object'
}]
}, status: :bad_request
return
end
unless data['data']['type'].present?
render json: {
errors: [{
title: 'Missing resource type',
source: { pointer: '/data/type' }
}]
}, status: :bad_request
return
end
request.body.rewind # Reset for subsequent reads
end
end
Reference
Core Serializer Methods
Method | Parameters | Returns | Description |
---|---|---|---|
attributes(*attrs) |
*attrs (Symbol) |
nil |
Declares resource attributes for serialization |
attribute(name, &block) |
name (Symbol), block (Proc) |
nil |
Defines custom attribute with optional block |
belongs_to(name, **options) |
name (Symbol), options (Hash) |
nil |
Declares belongs_to relationship |
has_many(name, **options) |
name (Symbol), options (Hash) |
nil |
Declares has_many relationship |
has_one(name, **options) |
name (Symbol), options (Hash) |
nil |
Declares has_one relationship |
link(name, &block) |
name (Symbol), block (Proc) |
nil |
Adds custom link to resource |
meta(&block) |
block (Proc) | nil |
Adds metadata to resource |
new(resource, options = {}) |
resource (Object), options (Hash) |
Serializer |
Creates new serializer instance |
serializable_hash |
none | Hash |
Generates JSON:API compliant hash |
Serialization Options
Option | Type | Default | Description |
---|---|---|---|
:include |
Array<Symbol> |
[] |
Relationships to include in response |
:fields |
Hash<Symbol, Array> |
{} |
Sparse fieldsets for resource types |
:params |
Hash |
{} |
Custom parameters passed to serializers |
:meta |
Hash |
{} |
Top-level metadata |
:links |
Hash |
{} |
Top-level links |
:is_collection |
Boolean |
auto |
Forces collection or single resource format |
Relationship Options
Option | Type | Description |
---|---|---|
:serializer |
Class/Symbol |
Serializer class for relationship |
:if |
Proc |
Conditional inclusion logic |
:links |
Hash/Proc |
Relationship links |
:meta |
Hash/Proc |
Relationship metadata |
:polymorphic |
Boolean |
Enables polymorphic relationship handling |
Error Response Structure
Field | Type | Required | Description |
---|---|---|---|
id |
String |
No | Unique error identifier |
status |
String |
No | HTTP status code |
code |
String |
No | Application-specific error code |
title |
String |
No | Human-readable error summary |
detail |
String |
No | Human-readable error explanation |
source |
Object |
No | Error source reference |
source.pointer |
String |
No | JSON Pointer to error location |
source.parameter |
String |
No | Query parameter causing error |
meta |
Object |
No | Additional error metadata |
Common HTTP Status Codes
Status | Code | Usage |
---|---|---|
200 |
OK | Successful GET, PATCH requests |
201 |
Created | Successful POST requests |
202 |
Accepted | Asynchronous processing started |
204 |
No Content | Successful DELETE requests |
400 |
Bad Request | Malformed request syntax |
401 |
Unauthorized | Authentication required |
403 |
Forbidden | Insufficient permissions |
404 |
Not Found | Resource not found |
409 |
Conflict | Resource conflict |
422 |
Unprocessable Entity | Validation errors |
500 |
Internal Server Error | Server errors |
Query Parameters
Parameter | Format | Description |
---|---|---|
include |
relationship1,relationship2 |
Comma-separated relationship list |
fields[type] |
attribute1,attribute2 |
Sparse fieldsets for resource type |
sort |
attribute1,-attribute2 |
Sort order (- prefix for descending) |
page[number] |
integer |
Page number for pagination |
page[size] |
integer |
Number of resources per page |
filter[attribute] |
value |
Filter resources by attribute |
Content Type Headers
Header | Value | Usage |
---|---|---|
Content-Type |
application/vnd.api+json |
Required for JSON:API requests |
Accept |
application/vnd.api+json |
Requests JSON:API responses |
Accept-Charset |
utf-8 |
Character encoding specification |