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.,UserResourceforusersresource type) - Define permitted attributes using
attributes - Define relationships using
has_many,has_one, orbelongs_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 linkageDELETE /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 resourcesfields[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: truewhen the underlying model should not allow assignment (for example, when it lacks*_idssetters 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
}
}
Including Related Resources
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.
# 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
metamethods still inherit via Ruby method lookup; class-levelmetafollows 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:
- Loads all records into memory
- Sorts them in Ruby using the resource's getter method for the virtual attribute
- 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. = { 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
({ version: "v1", custom: "value" })
end
Instance-level dynamic meta:
class UserResource < JSONAPI::Resource
attributes :email, :name
def
{
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. = 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 seeauthorization_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. = 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 tocurrent_user,params, etc.)scope: The initial ActiveRecord scope (e.g.,User.allor 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. = lambda do |controller:, record:, action:, context: nil|
# Raise an exception if the user is not authorized
unless (controller.current_user, record, action)
raise JSONAPI::AuthorizationError, "Not authorized to #{action} this resource"
end
end
end
Parameters:
controller: The controller instancerecord: 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, includesrelationship: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 (error)
detail = error&..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. = 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. = 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. = nil
config. = nil
end
# Use custom authorization logic
JSONAPI.configure do |config|
config. = lambda do |controller:, record:, action:, context: nil|
# Your custom authorization logic here
unless MyAuthService.(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 creationjsonapi.{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 updatesjsonapi.{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.,
rolemight be set by the system) - Prevent certain fields from being updated (e.g.,
emailmight 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+jsonheader (returns415 Unsupported Media Typeif missing) - Accept: If an
Acceptheader is provided, it must includeapplication/vnd.api+jsonor be*/*(returns406 Not Acceptableif 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 parametersjsonapi_attributes- Extracted attributesjsonapi_relationships- Extracted relationshipsparse_include_param- Parsed include parameterparse_fields_param- Parsed fields parameterparse_filter_param- Parsed filter parameterparse_sort_param- Parsed sort parameterparse_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:
posts→post_ids(array) - To-one relationships:
account→account_id - Polymorphic relationships:
profile→profile_idandprofile_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
typeoridin relationship data returns400 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 attachmentsCollectionQuery- Service class for building filtered, sorted, and paginated queriesRelationshipHelpers- Utilities for relationship handlingParamHelpers- Parameter parsing utilitiesResponders- Content negotiation and error renderingInstrumentation- 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_blobsresources - Add download links for each blob
- Include blob details in the
includedsection when requested viaincludeparameter - 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: blobordocuments: [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_attachedrelationship tonullwill keep the existing attachment - Setting a
has_many_attachedrelationship 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: Whenappend_only: trueis set,purge_on_nilis automatically set tofalseand cannot be overriddenDeletions: Deletions via PATCH/PUT with empty arrays are not possible. Use the DELETE
/relationships/:nameendpoint 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 filenamecontent_type- MIME typebyte_size- File size in byteschecksum- File checksum- Download link in the
linkssection
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.