JSONAPI

A Rails 8+ gem that provides JSON:API compliant routing DSL and generic JSON:API controllers for producing and consuming JSON:API resources.

Features

  • JSON:API v1.1 compliant routing and controllers
  • Automatic MIME type registration (application/vnd.api+json)
  • Generic resource controller with CRUD operations
  • Built-in serialization and deserialization
  • Support for filtering (explicit filters with column-aware operators), sorting, pagination, sparse fieldsets, and includes
  • Relationship endpoints for managing resource relationships independently
  • Separate creatable and updatable field definitions
  • Configurable pagination options
  • Content negotiation with Accept and Content-Type headers
  • Support for polymorphic and STI relationships

Installation

Add this line to your application's Gemfile:

gem 'json_api'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install json_api

Requirements

  • Ruby >= 3.4.0
  • Rails >= 8.0.0

Usage

Basic Setup

Add the gem to your Gemfile and run bundle install. The gem will automatically register the JSON:API MIME type and extend Rails routing.

Routing

Use the jsonapi_resources DSL in your routes file:

# config/routes.rb
Rails.application.routes.draw do
  jsonapi_resources :users
  jsonapi_resources :posts
end

This creates standard RESTful routes (index, show, create, update, destroy) that default to the json_api/resources controller and jsonapi format.

Important: Each resource must have a corresponding resource class defined (e.g., UserResource for jsonapi_resources :users). The routing will fail at boot time if the resource class is missing.

Resource Definitions

Define resource classes to control which attributes and relationships are exposed via the JSON:API endpoint:

# app/resources/user_resource.rb
class UserResource < JSONAPI::Resource
  attributes :email, :name, :phone

  has_many :posts
  has_one :profile
end

Resource classes must:

  • Inherit from JSONAPI::Resource
  • Be named <ResourceName>Resource (e.g., UserResource for users resource type)
  • Define permitted attributes using attributes
  • Define relationships using has_many, has_one, or belongs_to

The generic controller uses these resource definitions to:

  • Validate requested fields (sparse fieldsets)
  • Validate requested includes
  • Control which attributes are exposed in responses
  • Validate filters
  • Control which fields can be created vs updated

Virtual Attributes

Resources can define virtual attributes that don't correspond to database columns. Virtual attributes are useful for computed values, formatted data, or transforming model attributes.

Defining Virtual Attributes

To create a virtual attribute, declare it in attributes and implement a getter method:

class UserResource < JSONAPI::Resource
  attributes :name, :email, :full_name

  # Virtual attribute getter
  def full_name
    "#{resource.name} (#{resource.email})"
  end
end

The getter method receives the underlying model instance via resource. Virtual attributes are serialized just like regular attributes and appear in the response:

{
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "name": "John Doe",
      "email": "[email protected]",
      "full_name": "John Doe ([email protected])"
    }
  }
}

Overriding Model Attributes

Resource getters take precedence over model attributes. If you define a getter for a real database column, it will override the model's attribute value:

class UserResource < JSONAPI::Resource
  attributes :name, :email

  # Override name attribute
  def name
    resource.name.upcase
  end
end

Virtual Attribute Setters

You can define setters for virtual attributes that transform incoming values into real model attributes. This is useful for accepting formatted input that needs to be stored differently:

class UserResource < JSONAPI::Resource
  attributes :name, :email, :display_name
  creatable_fields :name, :email, :display_name
  updatable_fields :name, :display_name

  def initialize(resource = nil, context = {})
    super
    @transformed_params = {}
  end

  # Setter that transforms virtual attribute to model attribute
  def display_name=(value)
    @transformed_params["name"] = value.upcase
  end

  # Getter for the virtual attribute (for serialization)
  def display_name
    resource&.name&.downcase
  end

  # Return transformed params accumulated by setters
  def transformed_params
    @transformed_params || {}
  end
end

When a client sends display_name in a create or update request, the setter transforms it to name (uppercase). The virtual attribute display_name is automatically excluded from the final params returned by the deserializer, so it won't be passed to the model.

Important: You must initialize @transformed_params in your initialize method if you use setters that modify it.

Custom Controllers

If you need custom behavior, you can override the default controller:

jsonapi_resources :users, controller: "api/users"

Controller Actions

The default JSONAPI::ResourcesController provides:

  • GET /users - List all users (with filtering, sorting, pagination)
  • GET /users/:id - Show a user (with includes, sparse fieldsets)
  • POST /users - Create a user (with relationships)
  • PATCH /users/:id - Update a user (with relationships)
  • DELETE /users/:id - Delete a user

Additionally, relationship endpoints are available via JSONAPI::RelationshipsController:

  • GET /users/:id/relationships/:relationship_name - Show relationship data (with sorting for collections)
  • PATCH /users/:id/relationships/:relationship_name - Update relationship linkage
  • DELETE /users/:id/relationships/:relationship_name - Remove relationship linkage

Example Responses

GET /users (Collection Response):

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": [
    {
      "type": "users",
      "id": "1",
      "attributes": {
        "name": "John Doe",
        "email": "[email protected]",
        "phone": "555-0100"
      },
      "relationships": {
        "posts": {
          "data": [
            { "type": "posts", "id": "1" },
            { "type": "posts", "id": "2" }
          ],
          "meta": {
            "count": 2
          }
        },
        "profile": {
          "data": null
        }
      },
      "links": {
        "self": "/users/1"
      },
      "meta": {
        "created_at": "2024-01-15T10:30:00Z",
        "updated_at": "2024-01-15T10:30:00Z"
      }
    }
  ]
}

GET /users/:id (Single Resource Response):

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "name": "John Doe",
      "email": "[email protected]",
      "phone": "555-0100"
    },
    "relationships": {
      "posts": {
        "data": [
          { "type": "posts", "id": "1" },
          { "type": "posts", "id": "2" }
        ],
        "meta": {
          "count": 2
        }
      },
      "profile": {
        "data": {
          "type": "admin_profiles",
          "id": "1"
        }
      }
    },
    "links": {
      "self": "/users/1"
    },
    "meta": {
      "created_at": "2024-01-15T10:30:00Z",
      "updated_at": "2024-01-15T10:30:00Z"
    }
  }
}

POST /users (Create Response):

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": {
    "type": "users",
    "id": "2",
    "attributes": {
      "name": "New User",
      "email": "[email protected]",
      "phone": "555-0101"
    },
    "relationships": {
      "posts": {
        "data": [],
        "meta": {
          "count": 0
        }
      },
      "profile": {
        "data": null
      }
    },
    "links": {
      "self": "/users/2"
    },
    "meta": {
      "created_at": "2024-01-15T11:00:00Z",
      "updated_at": "2024-01-15T11:00:00Z"
    }
  }
}

PATCH /users/:id (Update Response):

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "name": "Updated Name",
      "email": "[email protected]",
      "phone": "555-9999"
    },
    "relationships": {
      "posts": {
        "data": [{ "type": "posts", "id": "1" }],
        "meta": {
          "count": 1
        }
      },
      "profile": {
        "data": null
      }
    },
    "links": {
      "self": "/users/1"
    },
    "meta": {
      "created_at": "2024-01-15T10:30:00Z",
      "updated_at": "2024-01-15T11:15:00Z"
    }
  }
}

DELETE /users/:id:

Returns 204 No Content with an empty response body.

Query Parameters

The controller supports standard JSON:API query parameters:

  • filter[name]=John - Filter resources (must be declared in resource class)
  • sort=name,-created_at - Sort resources (ascending by default, prefix with - for descending)
  • page[number]=1&page[size]=25 - Pagination (number and size only)
  • include=posts,comments - Include related resources
  • fields[users]=name,email - Sparse fieldsets

Filtering

Filtering requires declaring permitted filters in your resource class:

class UserResource < JSONAPI::Resource
  attributes :name, :email, :phone
  filters :name_eq, :name_match, :created_at_gte
end

For regular filters, the gem applies column-aware operators when you use suffixes:

  • _eq
  • _match (string/text; uses ILIKE with sanitized patterns)
  • _lt, _lte, _gt, _gte (numeric, date, datetime)

If a filter name doesn't match a supported operator but a model scope with that name exists, the scope is called instead. Filters are only applied when declared via filters. Invalid filter names return a 400 Bad Request error.

Filter Value Formats

Filter values can be provided as either strings or arrays, depending on the filter's requirements:

String Format:

  • Single value: filter[name_eq]=John
  • Comma-separated values: filter[name_eq]=John,Jane (parsed as a single string "John,Jane")

Array Format:

  • Multiple values: filter[categories_include][]=security&filter[categories_include][]=governance
  • Rails parses this as an array: ["security", "governance"]

When Arrays Are Required:

Some filters require arrays, particularly when using PostgreSQL array operators:

class SelectedControl < ApplicationRecord
  # This scope uses PostgreSQL's ?| operator which requires an array
  scope :categories_include, ->(categories) {
    where("#{table_name}.categories ?| array[:categories]", categories:)
  }
end

For such scopes, filters must be provided in array format:

  • ✅ Correct: filter[categories_include][]=security&filter[categories_include][]=governance
  • ❌ Incorrect: filter[categories_include]=security,governance (parsed as string "security,governance")

How Rails Parses Query Parameters:

  • filter[key]=value → Rails parses as { "filter" => { "key" => "value" } }
  • filter[key][]=value1&filter[key][]=value2 → Rails parses as { "filter" => { "key" => ["value1", "value2"] } }
  • filter[key]=value1,value2 → Rails parses as { "filter" => { "key" => "value1,value2" } } (single string)

The json_api gem passes filter values directly to model scopes as parsed by Rails. If a scope expects an array (e.g., for PostgreSQL array operators), ensure the filter is sent in array format.

Filtering through relationships (nested filter hashes)

Expose filters on related resources using nested hashes. Relationships declared with has_one/has_many automatically allow nested filters on the related resource's filters plus primary key filters. Example:

class PostResource < JSONAPI::Resource
  filters :title
end

class User < ApplicationRecord
  # Column filters: filter[user][email][email protected]
  # Scope filters: filter[user][name_search]=Jane
  scope :name_search, ->(query) { where("users.name LIKE ?", "%#{query}%") }
end

Examples:

  • GET /posts?filter[user][id]=123 (joins users and filters on users.id)
  • GET /posts?filter[user][email][email protected] (filters on related column)
  • GET /posts?filter[user][name_search]=Jane (calls the related scope and merges it)
  • GET /comments?filter[post][user][email_eq][email protected] (multi-level chain)

Nested filter paths must point to either a column on the related model, a class method/scope on that model, or a filter declared on the related resource.

Invalid filter fields will return a 400 Bad Request error:

{
  "errors": [
    {
      "status": "400",
      "title": "Invalid Filter",
      "detail": "Invalid filters requested: invalid_field"
    }
  ]
}

Writable through relationships

By default, has-many-through and has-one-through relationships are writable via JSON:API payloads and relationship endpoints. Set readonly: true on the relationship to block writes and return ParameterNotAllowed.

class UserResource < JSONAPI::Resource
  has_many :post_comments, readonly: true
end
  • The opt-out applies to both main resource deserialization and relationship controller updates.
  • Use readonly: true when the underlying model should not allow assignment (for example, when it lacks *_ids setters or manages joins differently).
  • Without the flag, through relationships are writable.

Pagination

Resources can be paginated using the page[number] and page[size] parameters. Pagination is only available on collection (index) endpoints.

Examples:

  • GET /users?page[number]=1&page[size]=10 (first page, 10 items per page)
  • GET /users?page[number]=2&page[size]=10 (second page, 10 items per page)
  • GET /users?page[number]=1 (first page with default page size)

The default page size is 25, and the maximum page size is 100 (configurable via JSONAPI.configuration). If a size larger than the maximum is requested, it will be capped at the maximum.

Example Paginated Response:

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": [
    {
      "type": "users",
      "id": "1",
      "attributes": {
        "name": "User 1",
        "email": "[email protected]",
        "phone": "555-0001"
      },
      "links": {
        "self": "/users/1"
      }
    }
  ],
  "links": {
    "self": "/users?page[number]=2&page[size]=5",
    "first": "/users?page[number]=1&page[size]=5",
    "last": "/users?page[number]=3&page[size]=5",
    "prev": "/users?page[number]=1&page[size]=5",
    "next": "/users?page[number]=3&page[size]=5"
  },
  "meta": {
    "total": 15
  }
}

Use the include parameter to include related resources in the response. This is available on both index and show endpoints:

# Include single relationship
GET /users?include=posts

# Include multiple relationships
GET /users?include=posts,comments

# Include nested relationships (two levels)
GET /users?include=posts.comments

# Include deeply nested relationships (arbitrary depth)
GET /users?include=posts.comments.author

# Include multiple nested paths
GET /users?include=posts.comments,posts.author

# Mix single and nested includes
GET /users?include=posts.comments,notifications

The gem supports arbitrary depth for nested includes. You can chain as many associations as needed (e.g., posts.comments.author.profile). Overlapping paths are automatically merged, so posts.comments and posts.comments.author will correctly include posts, comments, and authors.

Invalid include paths will return a 400 Bad Request error with a JSON:API error response:

{
  "errors": [
    {
      "status": "400",
      "title": "Invalid Include Path",
      "detail": "Invalid include paths requested: invalid_association"
    }
  ]
}

Included resources appear in the included array at the top level of the response, and relationships reference them using resource identifiers (type and id).

Example Response with Includes:

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "name": "John Doe",
      "email": "[email protected]",
      "phone": "555-0100"
    },
    "relationships": {
      "posts": {
        "data": [
          { "type": "posts", "id": "1" },
          { "type": "posts", "id": "2" }
        ],
        "meta": {
          "count": 2
        }
      }
    },
    "links": {
      "self": "/users/1"
    }
  },
  "included": [
    {
      "type": "posts",
      "id": "1",
      "attributes": {
        "title": "First Post",
        "body": "Content 1"
      },
      "relationships": {
        "user": {
          "data": {
            "type": "users",
            "id": "1"
          }
        },
        "comments": {
          "data": [],
          "meta": {
            "count": 0
          }
        }
      },
      "links": {
        "self": "/posts/1"
      }
    },
    {
      "type": "posts",
      "id": "2",
      "attributes": {
        "title": "Second Post",
        "body": "Content 2"
      },
      "relationships": {
        "user": {
          "data": {
            "type": "users",
            "id": "1"
          }
        },
        "comments": {
          "data": [],
          "meta": {
            "count": 0
          }
        }
      },
      "links": {
        "self": "/posts/2"
      }
    }
  ]
}

Polymorphic Relationships

The gem supports polymorphic associations (both belongs_to :profile, polymorphic: true and has_many :activities, as: :actor). When including polymorphic relationships, the serializer automatically determines the correct resource type based on the actual class of the related object:

# User belongs_to :profile, polymorphic: true
# User has_many :activities, as: :actor

GET /users?include=profile
GET /users?include=activities
GET /users/:id?include=profile,activities

The response will include the correct resource type for each polymorphic association (e.g., customer_profiles or admin_profiles for a polymorphic profile association).

Example Response with Polymorphic Relationship:

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "name": "John Doe",
      "email": "[email protected]",
      "phone": "555-0100"
    },
    "relationships": {
      "profile": {
        "data": {
          "type": "admin_profiles",
          "id": "1"
        }
      }
    },
    "links": {
      "self": "/users/1"
    }
  },
  "included": [
    {
      "type": "admin_profiles",
      "id": "1",
      "attributes": {
        "department": "Engineering",
        "level": "Senior"
      },
      "links": {
        "self": "/admin_profiles/1"
      }
    }
  ]
}

Single Table Inheritance (STI) Support

The gem supports Single Table Inheritance (STI) resources and relationships. Subclasses are treated as first-class JSON:API resources with their own types, while sharing the underlying table.

Routing

To enable STI support, use the sti option in your routes configuration. Pass an array of subtype names to generate routes for both the base resource and its subclasses:

# config/routes.rb
Rails.application.routes.draw do
  # Generates routes for:
  # - /notifications (Base resource)
  # - /email_notifications
  # - /sms_notifications
  jsonapi_resources :notifications, sti: [:email_notifications, :sms_notifications]
end
Resource Definitions

Define a resource class for the base model and each subclass. Subclasses should inherit from the base resource class to share configuration:

# app/resources/notification_resource.rb
class NotificationResource < JSONAPI::Resource
  attributes :body, :created_at
  has_one :user
end

# app/resources/email_notification_resource.rb
class EmailNotificationResource < NotificationResource
  attributes :subject, :recipient_email
end

# app/resources/sms_notification_resource.rb
class SmsNotificationResource < NotificationResource
  attributes :phone_number
end
Serialization

Resources are serialized with their specific type. When querying the base endpoint (e.g., GET /notifications), the response will contain a mix of types:

{
  "data": [
    {
      "type": "email_notifications",
      "id": "1",
      "attributes": {
        "body": "Welcome!",
        "subject": "Hello"
      }
    },
    {
      "type": "sms_notifications",
      "id": "2",
      "attributes": {
        "body": "Code: 1234",
        "phone_number": "555-1234"
      }
    }
  ]
}
Creating STI Resources

To create a specific subclass, send a POST request to either the base endpoint or the specific subclass endpoint, specifying the correct type in the payload:

POST /notifications
Content-Type: application/vnd.api+json

{
  "data": {
    "type": "email_notifications",
    "attributes": {
      "subject": "Important",
      "body": "Please read this.",
      "recipient_email": "[email protected]"
    },
    "relationships": {
      "user": {
        "data": { "type": "users", "id": "1" }
      }
    }
  }
}

The controller automatically instantiates the correct model class based on the type field.

STI resource DSL inheritance

  • Subclasses inherit parent DSL (attributes, filters, sortable_fields, relationships, creatable_fields, updatable_fields, class-level meta) only when they do not declare that DSL themselves.
  • Once a subclass calls a DSL method, it uses only its own declarations; it must opt-in to parent definitions explicitly (e.g., attributes(*superclass.permitted_attributes, :child_attr)).
  • Instance-level meta methods still inherit via Ruby method lookup; class-level meta follows the “silent inherits, declare resets” rule.
  • For sparse fieldsets, expose needed attributes on the subtype by including the parent set when you declare subtype DSL.

Sorting

Sorting is only available on index endpoints (collection endpoints). Use the sort parameter to specify one or more fields to sort by:

# Sort by name ascending
GET /users?sort=name

# Sort by name descending (prefix with -)
GET /users?sort=-name

# Sort by multiple fields
GET /users?sort=name,created_at

# Sort by multiple fields with mixed directions
GET /users?sort=name,-created_at

Invalid sort fields will return a 400 Bad Request error with a JSON:API error response:

{
  "errors": [
    {
      "status": "400",
      "title": "Invalid Sort Field",
      "detail": "Invalid sort fields requested: invalid_field"
    }
  ]
}

The sort parameter is ignored on show endpoints (single resource endpoints). However, relationship endpoints support sorting for collection relationships:

# Sort posts relationship
GET /users/:id/relationships/posts?sort=title,-created_at

Sorting on relationship endpoints validates against the related model's columns, not the parent resource.

Virtual Attribute Sorting

The gem supports sorting by virtual attributes (attributes that don't correspond to database columns). When sorting by a virtual attribute, the gem:

  1. Loads all records into memory
  2. Sorts them in Ruby using the resource's getter method for the virtual attribute
  3. Recalculates the total count after sorting

You can mix database columns and virtual attributes in the same sort:

# Sort by database column first, then by virtual attribute
GET /users?sort=name,full_name

Example:

class UserResource < JSONAPI::Resource
  attributes :name, :email, :full_name

  def full_name
    "#{resource.name} (#{resource.email})"
  end
end
# Sort by virtual attribute ascending
GET /users?sort=full_name

# Sort by virtual attribute descending
GET /users?sort=-full_name

# Mix database column and virtual attribute
GET /users?sort=name,full_name

Performance Note: Sorting by virtual attributes requires loading all matching records into memory. For large collections, consider using database columns or computed database columns instead.

Sort-Only Fields

You can declare virtual fields that are sortable but not exposed as attributes. This is useful when you want to allow sorting by a computed value without making it readable or writable in the API response.

To declare a sort-only field, use sortable_fields in your resource class and implement a getter method:

class UserResource < JSONAPI::Resource
  attributes :name, :email

  # Declare sort-only field
  sortable_fields :posts_count

  # Implement getter method (same as virtual attribute)
  def posts_count
    resource.posts.size
  end
end

Sort-only fields:

  • Can be used in sort parameters: GET /users?sort=posts_count
  • Are validated as valid sort fields
  • Are NOT included in serialized attributes
  • Can be mixed with database columns and regular attributes in sort parameters

Example:

# Sort by sort-only field
GET /users?sort=posts_count

# Mix sort-only field with database column
GET /users?sort=posts_count,name

Note: Sort-only fields still require loading records into memory for sorting, just like virtual attributes. The difference is that sort-only fields won't appear in the response attributes.

JSON:API Object

All responses automatically include a jsonapi object indicating JSON:API version compliance:

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": { ... }
}

You can customize the jsonapi object via configuration:

# config/initializers/json_api.rb
JSONAPI.configure do |config|
  config.jsonapi_version = "1.1"
  config.jsonapi_meta = { ext: ["https://jsonapi.org/ext/atomic"] }
end

Meta Information

The gem supports meta information at three levels: document-level, resource-level, and relationship-level.

Document-Level Meta

Document-level meta appears at the top level of the response and is typically used for pagination:

{
  "jsonapi": { "version": "1.1" },
  "data": [ ... ],
  "meta": {
    "total": 100
  }
}

Pagination automatically includes meta with the total count when pagination is applied.

Example Document-Level Meta:

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": [
    {
      "type": "users",
      "id": "1",
      "attributes": {
        "name": "John Doe",
        "email": "[email protected]"
      }
    }
  ],
  "meta": {
    "total": 100
  }
}

Resource-Level Meta

Resource-level meta appears within each resource object. By default, the gem automatically includes created_at and updated_at timestamps in ISO8601 format if the model responds to these methods.

You can also define custom meta in two ways:

Class-level static meta:

class UserResource < JSONAPI::Resource
  attributes :email, :name

  meta({ version: "v1", custom: "value" })
end

Instance-level dynamic meta:

class UserResource < JSONAPI::Resource
  attributes :email, :name

  def meta
    {
      name_length: resource.name.length,
      custom_field: "value"
    }
  end
end

The instance method has access to the model instance via resource. Custom meta is merged with the default timestamp meta, with custom values taking precedence.

Example Resource-Level Meta:

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "name": "John Doe",
      "email": "[email protected]"
    },
    "meta": {
      "created_at": "2024-01-15T10:30:00Z",
      "updated_at": "2024-01-15T10:30:00Z",
      "name_length": 8,
      "custom_field": "value"
    },
    "links": {
      "self": "/users/1"
    }
  }
}

Relationship-Level Meta

Relationship-level meta appears within relationship objects:

class UserResource < JSONAPI::Resource
  attributes :email, :name

  has_many :posts, meta: { count: 5, custom: "relationship_meta" }
  has_one :profile, meta: { type: "polymorphic" }
end

The response will include:

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "name": "John Doe",
      "email": "[email protected]"
    },
    "relationships": {
      "posts": {
        "data": [
          { "type": "posts", "id": "1" },
          { "type": "posts", "id": "2" }
        ],
        "meta": {
          "count": 5,
          "custom": "relationship_meta"
        }
      },
      "profile": {
        "data": {
          "type": "admin_profiles",
          "id": "1"
        },
        "meta": {
          "type": "polymorphic"
        }
      }
    },
    "links": {
      "self": "/users/1"
    }
  }
}

Configuration

Configure the gem in an initializer:

# config/initializers/json_api.rb
JSONAPI.configure do |config|
  config.default_page_size = 25
  config.max_page_size = 100
  config.jsonapi_version = "1.1"
  config.jsonapi_meta = nil
  config.base_controller_class = ActionController::API  # Default: ActionController::API
end

Base Controller Class

By default, JSONAPI::BaseController inherits from ActionController::API. You can configure it to inherit from a different base class (e.g., ActionController::Base or a custom base controller):

JSONAPI.configure do |config|
  # Use ActionController::Base instead of ActionController::API
  config.base_controller_class = ActionController::Base

  # Or use a custom base controller
  config.base_controller_class = ApplicationController
end

This is useful when you need access to features available in ActionController::Base that aren't in ActionController::API, such as:

  • View rendering helpers
  • Layout support
  • Cookie-based sessions
  • Flash messages

Note: The configuration must be set before the gem's controllers are loaded. Set it in a Rails initializer that loads before json_api is required.

Authorization

The gem provides configurable authorization hooks that allow you to integrate with any authorization library (e.g., Pundit, CanCanCan). Authorization is handled through two hooks:

  • authorization_scope: Filters collection queries (index actions) to only return records the user is authorized to see
  • authorization_handler: Authorizes individual actions (show, create, update, destroy) on specific records

Both hooks are optional - if not configured, all records are accessible (authorization is bypassed).

Authorization Scope Hook

The authorization_scope hook receives the initial ActiveRecord scope and should return a filtered scope containing only records the user is authorized to access:

JSONAPI.configure do |config|
  config.authorization_scope = lambda do |controller:, scope:, action:, model_class:|
    # Filter the scope based on authorization logic
    # For example, only return records belonging to the current user
    scope.where(user_id: controller.current_user.id)
  end
end

Parameters:

  • controller: The controller instance (provides access to current_user, params, etc.)
  • scope: The initial ActiveRecord scope (e.g., User.all or preloaded resources)
  • action: The action being performed (:index)
  • model_class: The ActiveRecord model class (e.g., User)

Return value: An ActiveRecord scope containing only authorized records

Authorization Handler Hook

The authorization_handler hook is called for individual resource actions (show, create, update, destroy) and should raise an exception if the user is not authorized:

JSONAPI.configure do |config|
  config.authorization_handler = lambda do |controller:, record:, action:, context: nil|
    # Raise an exception if the user is not authorized
    unless authorized?(controller.current_user, record, action)
      raise JSONAPI::AuthorizationError, "Not authorized to #{action} this resource"
    end
  end
end

Parameters:

  • controller: The controller instance
  • record: The ActiveRecord record being accessed (for create, this is a new unsaved record)
  • action: The action being performed (:show, :create, :update, or :destroy)
  • context: Optional context hash (for relationship actions, includes relationship: key)

Exceptions: Raise JSONAPI::AuthorizationError to deny access. Your application is responsible for rescuing this error and rendering an appropriate response (e.g., a 403 Forbidden JSON:API error object).

Pundit Integration Example

Here's a complete example using Pundit:

# config/initializers/json_api_authorization.rb

# Include Pundit in JSONAPI::BaseController
JSONAPI::BaseController.class_eval do
  include Pundit::Authorization

  rescue_from JSONAPI::AuthorizationError, with: :render_jsonapi_authorization_error

  # Provide current_user method (override in your application)
  def current_user
    # Return the current authenticated user
    # This is application-specific
    @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
  end

  private

  def render_jsonapi_authorization_error(error)
    detail = error&.message.presence || "You are not authorized to perform this action"
    render json: {
      errors: [
        {
          status: "403",
          title: "Forbidden",
          detail:
        }
      ]
    }, status: :forbidden
  end
end

# Configure JSON:API authorization hooks using Pundit
JSONAPI.configure do |config|
  # Authorization scope hook - filters collections based on Pundit scopes
  config.authorization_scope = lambda do |controller:, scope:, action:, model_class:|
    policy_class = Pundit::PolicyFinder.new(model_class).policy
    policy_scope = policy_class.const_get(:Scope).new(controller.current_user, scope)
    policy_scope.resolve
  end

  # Authorization handler hook - authorizes individual actions using Pundit policies
  # Note: We convert Pundit authorization failures to JSONAPI::AuthorizationError
  # so the gem can handle them consistently
  config.authorization_handler = lambda do |controller:, record:, action:, context: nil|
    policy_class = Pundit::PolicyFinder.new(record).policy
    policy = policy_class.new(controller.current_user, record)

    action_method = "#{action}?"
    unless policy.public_send(action_method)
      raise JSONAPI::AuthorizationError, "Not authorized to #{action} this resource"
    end
  end
end

Example Policy:

# app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy
  def index?
    true
  end

  def show?
    record.public? || user == record
  end

  def create?
    user.admin?
  end

  def update?
    user.admin? || user == record
  end

  def destroy?
    user.admin?
  end

  class Scope < ApplicationPolicy::Scope
    def resolve
      if user.admin?
        scope.all
      else
        scope.where(public: true).or(scope.where(id: user.id))
      end
    end
  end
end

Relationship Authorization

Relationship endpoints (show, update, destroy) authorize the parent resource using the :update action with a context hash containing the relationship name:

# The authorization_handler receives:
{
  controller: controller_instance,
  record: parent_resource,
  action: :update,
  context: { relationship: :posts }
}

This allows you to implement relationship-specific authorization logic in your policies if needed.

Custom Authorization Error Handling

The gem automatically handles JSONAPI::AuthorizationError and Pundit::NotAuthorizedError exceptions, rendering a JSON:API compliant 403 Forbidden response:

{
  "jsonapi": {
    "version": "1.1"
  },
  "errors": [
    {
      "status": "403",
      "title": "Forbidden",
      "detail": "You are not authorized to perform this action"
    }
  ]
}

You can customize the error message by raising an exception with a specific message:

raise JSONAPI::AuthorizationError, "Not authorized to view this resource"

Overriding Authorization

Authorization hooks can be easily overridden or disabled:

# Disable authorization (allow all access)
JSONAPI.configure do |config|
  config.authorization_scope = nil
  config.authorization_handler = nil
end

# Use custom authorization logic
JSONAPI.configure do |config|
  config.authorization_handler = lambda do |controller:, record:, action:, context: nil|
    # Your custom authorization logic here
    unless MyAuthService.authorized?(controller.current_user, record, action)
      raise JSONAPI::AuthorizationError, "Access denied"
    end
  end
end

Instrumentation (Rails 8.1+)

When running on Rails 8.1 or later, the gem automatically emits structured events via Rails.event for all CRUD and relationship operations. This enables seamless integration with monitoring and APM platforms like Datadog, AppSignal, New Relic, or Honeycomb.

Resource Events

The gem emits events for resource lifecycle operations:

  • jsonapi.{resource_type}.created - Emitted after successful resource creation
  • jsonapi.{resource_type}.updated - Emitted after successful resource updates (includes changed fields)
  • jsonapi.{resource_type}.deleted - Emitted after successful resource deletion

Event Payload Structure:

{
  resource_type: "users",
  resource_id: 123,
  changes: { "name" => ["old", "new"], "phone" => ["old", "new"] }  # Only for updates
}

Example Usage:

# Subscribe to events
class JsonApiEventSubscriber
  def emit(event)
    encoded = ActiveSupport::EventReporter.encoder(:json).encode(event)
    # Forward to your monitoring service
    MonitoringService.send_event(encoded)
  end
end

Rails.event.subscribe(JsonApiEventSubscriber.new) if Rails.respond_to?(:event)

Relationship Events

The gem also emits events for relationship operations:

  • jsonapi.{resource_type}.relationship.updated - Emitted after successful relationship updates
  • jsonapi.{resource_type}.relationship.removed - Emitted after successful relationship removals

Event Payload Structure:

{
  resource_type: "users",
  resource_id: 123,
  relationship_name: "posts",
  related_type: "posts",      # Optional
  related_ids: [456, 789]      # Optional
}

Testing Instrumentation

Use Rails 8.1's assert_events_reported test helper to verify events are emitted:

assert_events_reported([
  { name: "jsonapi.users.created", payload: { resource_type: "users", resource_id: 123 } },
  { name: "jsonapi.users.relationship.updated", payload: { relationship_name: "posts" } }
]) do
  post "/users", params: payload.to_json, headers: jsonapi_headers
end

Compatibility

The instrumentation feature is automatically enabled when Rails.event is available (Rails 8.1+). On older Rails versions, the gem continues to work normally without emitting events. No configuration is required.

Creatable and Updatable Fields

By default, all attributes defined with attributes are available for both create and update operations. You can restrict which fields can be created or updated separately:

class UserResource < JSONAPI::Resource
  attributes :name, :email, :phone, :role

  # Only these fields can be set during creation
  creatable_fields :name, :email, :phone

  # Only these fields can be updated
  updatable_fields :name, :phone
end

If creatable_fields or updatable_fields are not explicitly defined, the gem defaults to using all permitted_attributes. This allows you to:

  • Prevent certain fields from being set during creation (e.g., role might be set by the system)
  • Prevent certain fields from being updated (e.g., email might be immutable after creation)
  • Have different field sets for create vs update operations

Content Negotiation

The gem enforces JSON:API content negotiation:

  • Content-Type: POST, PATCH, and PUT requests must include Content-Type: application/vnd.api+json header (returns 415 Unsupported Media Type if missing)
  • Accept: If an Accept header is provided, it must include application/vnd.api+json or be */* (returns 406 Not Acceptable if explicitly set to non-JSON:API types)

Blank or */* Accept headers are allowed to support browser defaults.

Custom Controllers

You can inherit from JSONAPI::BaseController to create custom controllers:

class UsersController < JsonApi::BaseController
  def index
    # Custom implementation
  end
end

The base controller provides helper methods:

  • jsonapi_params - Parsed JSON:API parameters
  • jsonapi_attributes - Extracted attributes
  • jsonapi_relationships - Extracted relationships
  • parse_include_param - Parsed include parameter
  • parse_fields_param - Parsed fields parameter
  • parse_filter_param - Parsed filter parameter
  • parse_sort_param - Parsed sort parameter
  • parse_page_param - Parsed page parameter

Serialization

Use JSONAPI::Serializer to serialize resources:

serializer = JSONAPI::Serializer.new(user)
serializer.to_hash(include: ["posts"], fields: { users: ["name", "email"] })

Deserialization

Use JSONAPI::Deserializer to deserialize JSON:API payloads:

deserializer = JSONAPI::Deserializer.new(params, resource_class: User)
deserializer.attributes # => { "name" => "John", "email" => "[email protected]" }
deserializer.relationship_ids(:posts) # => ["1", "2"]
deserializer.to_params # => { "name" => "John", "post_ids" => ["1", "2"], "profile_id" => "1", "profile_type" => "CustomerProfile" }

The deserializer automatically converts JSON:API relationship format to Rails-friendly params:

  • To-many relationships: postspost_ids (array)
  • To-one relationships: accountaccount_id
  • Polymorphic relationships: profileprofile_id and profile_type

Creating Resources with Relationships

You can include relationships when creating resources:

# POST /users
{
  "data": {
    "type": "users",
    "attributes": {
      "name": "John Doe",
      "email": "[email protected]"
    },
    "relationships": {
      "profile": {
        "data": {
          "type": "customer_profiles",
          "id": "1"
        }
      },
      "posts": {
        "data": [
          { "type": "posts", "id": "1" },
          { "type": "posts", "id": "2" }
        ]
      }
    }
  }
}

Updating Resources with Relationships

You can update relationships when updating resources:

# PATCH /users/:id
{
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "name": "John Doe Updated"
    },
    "relationships": {
      "profile": {
        "data": {
          "type": "admin_profiles",
          "id": "2"
        }
      },
      "posts": {
        "data": [
          { "type": "posts", "id": "3" }
        ]
      }
    }
  }
}

Clearing Relationships

To clear a relationship, send null for to-one relationships or an empty array for to-many relationships:

# Clear to-one relationship
{
  "data": {
    "type": "users",
    "id": "1",
    "relationships": {
      "profile": {
        "data": null
      }
    }
  }
}

# Clear to-many relationship
{
  "data": {
    "type": "users",
    "id": "1",
    "relationships": {
      "posts": {
        "data": []
      }
    }
  }
}

Relationship Validation

The gem validates relationship data:

  • Missing type or id in relationship data returns 400 Bad Request
  • Invalid relationship type (for non-polymorphic associations) returns 400 Bad Request
  • Invalid polymorphic type (class doesn't exist) returns 400 Bad Request
  • Attempting to unset linkage that cannot be nullified (e.g., foreign key has NOT NULL constraint) returns 400 Bad Request

Relationship Endpoints

The gem provides dedicated endpoints for managing relationships independently of the main resource:

Show Relationship

GET /users/:id/relationships/posts

Returns the relationship data (resource identifiers) with links and meta:

To-Many Relationship Response:

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": [
    { "type": "posts", "id": "1" },
    { "type": "posts", "id": "2" }
  ],
  "links": {
    "self": "/users/1/relationships/posts",
    "related": "/users/1/posts"
  },
  "meta": {
    "count": 2
  }
}

To-One Relationship Response:

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": {
    "type": "admin_profiles",
    "id": "1"
  },
  "links": {
    "self": "/users/1/relationships/profile",
    "related": "/users/1/profile"
  }
}

Empty To-One Relationship Response:

{
  "jsonapi": {
    "version": "1.1"
  },
  "data": null,
  "links": {
    "self": "/users/1/relationships/profile",
    "related": "/users/1/profile"
  }
}

For collection relationships, you can sort using the sort parameter:

GET /users/:id/relationships/posts?sort=title,-created_at

Update Relationship

PATCH /users/:id/relationships/posts
Content-Type: application/vnd.api+json

{
  "data": [
    { "type": "posts", "id": "3" },
    { "type": "posts", "id": "4" }
  ]
}

Replaces the entire relationship linkage. For to-one relationships, send a single resource identifier object (or null to clear). For to-many relationships, send an array of resource identifiers (or empty array [] to clear).

Delete Relationship Linkage

DELETE /users/:id/relationships/posts
Content-Type: application/vnd.api+json

{
  "data": [
    { "type": "posts", "id": "1" }
  ]
}

Removes specific resources from a to-many relationship by setting their foreign key to NULL. For to-one relationships, send a single resource identifier object to remove the linkage.

Important: Per JSON:API specification, relationship endpoints (DELETE /users/:id/relationships/posts) only modify linkage and never destroy resources. The gem attempts to unset the linkage by setting the foreign key to NULL. If this operation fails (due to NOT NULL constraints, validations, or other database constraints), a 400 Bad Request error is returned. To allow relationship removal, ensure the foreign key column allows NULL values and any validations permit nullification.

Error responses follow JSON:API error format:

Example Error Response:

{
  "jsonapi": {
    "version": "1.1"
  },
  "errors": [
    {
      "status": "400",
      "title": "Invalid Relationship",
      "detail": "Invalid relationship type for profile: 'invalid_type' does not correspond to a valid model class",
      "source": {
        "pointer": "/data/relationships/profile/data/type"
      }
    }
  ]
}

Example 404 Not Found Error:

{
  "jsonapi": {
    "version": "1.1"
  },
  "errors": [
    {
      "status": "404",
      "title": "Record Not Found",
      "detail": "Couldn't find User with 'id'=999"
    }
  ]
}

Example Validation Error:

{
  "jsonapi": {
    "version": "1.1"
  },
  "errors": [
    {
      "status": "422",
      "title": "Validation Error",
      "detail": "Email can't be blank",
      "source": {
        "pointer": "/data/attributes/email"
      }
    },
    {
      "status": "422",
      "title": "Validation Error",
      "detail": "Name is too short (minimum is 2 characters)",
      "source": {
        "pointer": "/data/attributes/name"
      }
    }
  ]
}

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bundle exec rspec to run the tests.

You can also run bin/console for an interactive prompt that will allow you to experiment.

Code Organization

The gem is organized into several directories within lib/json_api/:

  • controllers/ - Controller classes (BaseController, ResourcesController, RelationshipsController)
  • resources/ - Resource DSL and resource loading (Resource, ResourceLoader, ActiveStorageBlobResource)
  • serialization/ - Serialization and deserialization (Serializer, Deserializer)
  • support/ - Shared concerns and utilities:
    • ActiveStorageSupport - Concern for handling ActiveStorage attachments
    • CollectionQuery - Service class for building filtered, sorted, and paginated queries
    • RelationshipHelpers - Utilities for relationship handling
    • ParamHelpers - Parameter parsing utilities
    • Responders - Content negotiation and error rendering
    • Instrumentation - Rails event emission

This organization makes it easier to understand the gem's structure and locate specific functionality.

Testing

Run the test suite:

bundle exec rspec

Test Helper for Request Specs

The gem provides a test helper module that makes it easy to write request specs with proper JSON:API content negotiation. The helper ensures as: :jsonapi works consistently across all HTTP methods.

Setup (RSpec):

# spec/rails_helper.rb or spec/support/json_api.rb
require "json_api/testing"

RSpec.configure do |config|
  config.include JSONAPI::Testing::TestHelper, type: :request
end

Usage:

RSpec.describe "Users API", type: :request do
  let(:headers) { { "Authorization" => "Bearer #{token}" } }

  describe "GET /users" do
    it "returns users" do
      # GET requests: Accept header is set, params go to query string
      get users_path, params: { filter: { active: true } }, headers:, as: :jsonapi
      expect(response).to have_http_status(:ok)
    end
  end

  describe "POST /users" do
    it "creates a user" do
      # POST/PATCH/PUT/DELETE: Content-Type and Accept headers are set,
      # params are JSON-encoded in the request body
      payload = {
        data: {
          type: "users",
          attributes: { name: "John", email: "[email protected]" }
        }
      }
      post users_path, params: payload, headers:, as: :jsonapi
      expect(response).to have_http_status(:created)
    end
  end
end

Behavior by HTTP method:

Method Accept Header Content-Type Header Params Encoding
GET ✅ Set ❌ Not set Query string
POST ✅ Set ✅ Set JSON body
PATCH ✅ Set ✅ Set JSON body
PUT ✅ Set ✅ Set JSON body
DELETE ✅ Set ✅ Set JSON body

Integration Tests

The gem includes a dummy Rails app in spec/dummy for integration testing. The integration tests verify that the gem works correctly with a real Rails application.

To run only integration tests:

bundle exec rspec spec/integration

The integration tests cover:

  • Full CRUD operations (create, read, update, delete)
  • JSON:API response format validation using JSON Schema
  • Sparse fieldsets (fields parameter) for single and multiple fields
  • Sorting (ascending, descending, multiple fields, invalid fields)
  • Including related resources (include parameter) with validation
  • Creating and updating resources with relationships (to-one, to-many, polymorphic)
  • Relationship validation and error handling
  • Error handling and validation responses
  • HTTP status codes

The dummy app includes a simple User model with basic validations and relationships (posts, profile, activities, notifications) to test the full request/response cycle including relationship writes.

ActiveStorage Support

The gem includes built-in support for serializing and deserializing ActiveStorage attachments through JSON:API.

Serializing Attachments

When a model has ActiveStorage attachments (has_one_attached or has_many_attached), you can expose them as relationships in your resource:

class UserResource < JSONAPI::Resource
  attributes :name, :email
  has_one :avatar  # For has_one_attached :avatar
  has_many :documents  # For has_many_attached :documents
end

The serializer will automatically:

  • Include attachment relationships pointing to active_storage_blobs resources
  • Add download links for each blob
  • Include blob details in the included section when requested via include parameter
  • Filter out ActiveStorage attachments from include paths (attachments are loaded on-demand by the serializer, not via ActiveRecord includes)

Example response:

{
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "name": "John Doe",
      "email": "[email protected]"
    },
    "relationships": {
      "avatar": {
        "data": {
          "type": "active_storage_blobs",
          "id": "1"
        }
      },
      "documents": {
        "data": [
          { "type": "active_storage_blobs", "id": "2" },
          { "type": "active_storage_blobs", "id": "3" }
        ]
      }
    },
    "links": {
      "self": "/users/1"
    }
  },
  "included": [
    {
      "type": "active_storage_blobs",
      "id": "1",
      "attributes": {
        "filename": "avatar.jpg",
        "content_type": "image/jpeg",
        "byte_size": 102400,
        "checksum": "abc123..."
      },
      "links": {
        "self": "/active_storage_blobs/1",
        "download": "/rails/active_storage/blobs/.../avatar.jpg"
      }
    }
  ]
}

Deserializing Attachments

When creating or updating resources, clients can attach files by providing signed blob IDs obtained from ActiveStorage direct uploads:

{
  "data": {
    "type": "users",
    "attributes": {
      "name": "Jane Doe",
      "email": "[email protected]"
    },
    "relationships": {
      "avatar": {
        "data": {
          "type": "active_storage_blobs",
          "id": "eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--..."
        }
      },
      "documents": {
        "data": [
          { "type": "active_storage_blobs", "id": "signed-id-1" },
          { "type": "active_storage_blobs", "id": "signed-id-2" }
        ]
      }
    }
  }
}

The deserializer will:

  • Validate the signed blob IDs
  • Convert them to blob objects
  • Set them as parameters that ActiveStorage can attach (e.g., avatar: blob or documents: [blob1, blob2])

Detaching Attachments

To detach (remove) an attachment, send null for to-one relationships or an empty array [] for to-many relationships:

{
  "data": {
    "type": "users",
    "id": "1",
    "relationships": {
      "avatar": {
        "data": null
      },
      "documents": {
        "data": []
      }
    }
  }
}

By default, this will purge the attachments from the model. For to-one attachments, sending null detaches the current attachment. For to-many attachments, sending an empty array [] removes all attachments.

Controlling Purge Behavior with purge_on_nil

By default, when you set an attachment relationship to null (for has_one_attached) or an empty array [] (for has_many_attached), the gem will purge the existing attachments. You can opt out of this behavior by setting purge_on_nil: false in the relationship declaration:

class UserResource < JSONAPI::Resource
  attributes :name, :email

  # Opt out of purging when set to nil
  has_one :avatar, purge_on_nil: false
  has_many :documents, purge_on_nil: false
end

When purge_on_nil: false is set:

  • Setting a has_one_attached relationship to null will keep the existing attachment
  • Setting a has_many_attached relationship to an empty array [] will keep all existing attachments
  • Attaching new blobs will still replace existing attachments (this behavior is not affected by purge_on_nil)

Use cases for purge_on_nil: false:

  • When you want to prevent accidental deletion of attachments
  • When attachments should only be removed through explicit delete operations
  • When you need more control over attachment lifecycle management

Note: The default behavior (purge_on_nil: true) ensures that setting a relationship to null or [] actually removes the attachments, which is typically the expected behavior for most use cases.

Append-Only Mode with append_only

For has_many_attached relationships, you can enable append-only mode by setting append_only: true. In this mode, new blobs are appended to existing attachments rather than replacing them:

class UserResource < JSONAPI::Resource
  attributes :name, :email

  # Enable append-only mode for documents
  has_many :documents, append_only: true
end

Behavior when append_only: true is set:

  • Appending new blobs: When updating a resource with new blobs in the relationship, they are added to the existing attachments instead of replacing them
  // Existing attachments: [blob1, blob2]
  // Payload includes: [blob3, blob4]
  // Result: [blob1, blob2, blob3, blob4]
  • Empty array is a no-op: Sending an empty array [] preserves all existing attachments
  // Existing attachments: [blob1, blob2]
  // Payload includes: []
  // Result: [blob1, blob2] (unchanged)
  • Implicit purge_on_nil: false: When append_only: true is set, purge_on_nil is automatically set to false and cannot be overridden

  • Deletions: Deletions via PATCH/PUT with empty arrays are not possible. Use the DELETE /relationships/:name endpoint if you need to remove specific attachments

Important: append_only: true and purge_on_nil: true are mutually exclusive. If both are explicitly set, an ArgumentError will be raised at resource class definition time:

# This will raise ArgumentError
has_many :documents, append_only: true, purge_on_nil: true

Use cases for append_only: true:

  • When you want to accumulate attachments over time without replacing existing ones
  • When attachments represent a log or history that should only grow
  • When you need to prevent accidental replacement of existing attachments
  • When attachments should only be removed through explicit DELETE operations

Note: append_only only applies to has_many_attached relationships. For has_one_attached, attachments are always replaced regardless of this setting.

Example usage in a controller:

def create
  deserializer = JSONAPI::Deserializer.new(params, resource_class: User, action: :create)
  attrs = deserializer.to_params

  # attrs will include:
  # { "name" => "Jane Doe", "email" => "[email protected]", "avatar" => <ActiveStorage::Blob>, "documents" => [<ActiveStorage::Blob>, ...] }

  user = User.create!(attrs)
  # Attachments are automatically attached via ActiveStorage
end

Error Handling

Invalid signed IDs will raise ActiveSupport::MessageVerifier::InvalidSignature, which should be handled appropriately in your controllers.

Built-in ActiveStorage Resource

The gem provides a built-in JSONAPI::ActiveStorageBlobResource that automatically serializes ActiveStorage blobs with:

  • filename - The original filename
  • content_type - MIME type
  • byte_size - File size in bytes
  • checksum - File checksum
  • Download link in the links section

This resource is automatically used when serializing ActiveStorage attachments.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/klaay/json_api.

License

The gem is available as open source under the terms of the MIT License.