CrackedRuby logo

CrackedRuby

JSON:API

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