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

bundle add jpie

Requirements

  • Ruby >= 3.4.0
  • Rails >= 8.0.0

Routing

Use the jsonapi_resources DSL in your routes file to create standard RESTful routes (index, show, create, update, destroy) that default to the json_api/resources controller and jsonapi format:

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

To use a custom controller instead of the default:

jsonapi_resources :users, controller: "api/users"

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

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])"
    }
  }
}

You can also define setters for virtual attributes that transform incoming values into real model attributes:

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.

Creatable and Updatable Fields

By default, all attributes are available for both create and update. Restrict fields per operation:

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

  creatable_fields :name, :email, :phone  # role is system-set
  updatable_fields :name, :phone          # email is immutable
end

Relationship Endpoints

Manage relationship links independently of the parent resource. This will not delete the related resource itself, but only manage the relationship.

If the relationship is User -(has_one)-> AccountUser -(belong_to)-> Account and we DELETE /users/1/relationships/account this will not delete the account, but delete the account user.

  • GET /users/:id/relationships/:relationship_name - Show relationship linkage
  • PATCH /users/:id/relationships/:relationship_name - Replace relationship linkage
  • DELETE /users/:id/relationships/:relationship_name - Remove relationship linkage

Filtering

Declare permitted filters in your resource class:

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

Filters ending in _eq, _match, _lt, _lte, _gt, or _gte are applied to the corresponding column:

GET /users?filter[name_eq]=John
GET /users?filter[created_at_gte]=2024-01-01

Filter through relationships by nesting the filter key:

GET /posts?filter[user][email][email protected]
GET /comments?filter[post][user][id]=123

To pass multiple values, use bracket notation:

GET /users?filter[roles][]=admin&filter[roles][]=editor

Pagination

Paginate collections with page[number] and page[size]:

GET /users?page[number]=1&page[size]=10
GET /users?page[number]=2&page[size]=25

Paginated responses include a links object with self, first, last, and conditional prev/next URLs, plus meta.total with the full count:

{
  "data": [...],
  "links": {
    "self": "/users?page[number]=2&page[size]=10",
    "first": "/users?page[number]=1&page[size]=10",
    "last": "/users?page[number]=5&page[size]=10",
    "prev": "/users?page[number]=1&page[size]=10",
    "next": "/users?page[number]=3&page[size]=10"
  },
  "meta": { "total": 42 }
}

Default page size is 25, maximum is 100 (configurable).

Includes

Include related resources with the include parameter:

GET /users?include=posts
GET /users?include=posts,comments
GET /users?include=posts.comments.author

Nested includes use dot notation and support arbitrary depth. Related resources appear in the included array, and relationships reference them by type and id:

{
  "data": {
    "type": "users",
    "id": "1",
    "relationships": {
      "posts": {
        "data": [{ "type": "posts", "id": "5" }]
      }
    }
  },
  "included": [
    {
      "type": "posts",
      "id": "5",
      "attributes": { "title": "Hello World" }
    }
  ]
}

Polymorphic Relationships

Polymorphic relationships are accessed through the parent resource's relationship endpoints and includes. Route only the parent resource; the gem automatically handles polymorphic types through the relationship endpoints.

# config/routes.rb
jsonapi_resources :users

The user model declares a polymorphic belongs_to:

class User < ActiveRecord::Base
  belongs_to :profile, polymorphic: true
end

Define a resource class for each concrete type:

# app/resources/user_resource.rb
class UserResource < JSONAPI::Resource
  attributes :name, :email
  has_one :profile  # auto-detected as polymorphic from model
end

# app/resources/admin_profile_resource.rb
class AdminProfileResource < JSONAPI::Resource
  attributes :department, :level
end

# app/resources/customer_profile_resource.rb
class CustomerProfileResource < JSONAPI::Resource
  attributes :company_name, :industry
end

The gem auto-detects polymorphism from the model's association. To override, use polymorphic: true explicitly.

Responses use the concrete type. For example, a user with an admin profile returns the concrete type in the relationship data and included array:

GET /users/1?include=profile HTTP/1.1
Accept: application/vnd.api+json

HTTP/1.1 200 OK
Content-Type: application/vnd.api+json

{
  "data": {
    "type": "users",
    "id": "1",
    "relationships": {
      "profile": {
        "data": { "type": "admin_profiles", "id": "5" }
      }
    }
  },
  "included": [
    {
      "type": "admin_profiles",
      "id": "5",
      "attributes": { "department": "Engineering", "level": "Senior" }
    }
  ]
}

When creating or updating, provide the concrete type in the relationship payload:

PATCH /users/1 HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json

{
  "data": {
    "type": "users",
    "id": "1",
    "relationships": {
      "profile": {
        "data": { "type": "customer_profiles", "id": "10" }
      }
    }
  }
}

If you need direct access to polymorphic resources (e.g., GET /admin_profiles/1), add explicit routes:

jsonapi_resources :admin_profiles
jsonapi_resources :customer_profiles

Single Table Inheritance (STI)

STI subclasses are treated as first-class JSON:API resources with their own types. Use the sti option in routes:

jsonapi_resources :notifications, sti: [:email_notifications, :sms_notifications]

This generates:

  • GET /notifications — lists all notifications (index only)
  • GET /email_notifications/:id, POST /email_notifications, etc. — full CRUD for each subtype
  • GET /sms_notifications/:id, POST /sms_notifications, etc.

The models use standard Rails STI inheritance:

class Notification < ActiveRecord::Base
  validates :subject, presence: true
end

class EmailNotification < Notification
  validates :recipient_email, presence: true
end

class SmsNotification < Notification
  validates :phone_number, presence: true
end

Subclass resources inherit attributes from the parent:

class NotificationResource < JSONAPI::Resource
  attributes :subject, :body
end

class EmailNotificationResource < NotificationResource
  attributes :recipient_email  # inherits :subject, :body
end

class SmsNotificationResource < NotificationResource
  attributes :phone_number  # inherits :subject, :body
end

The base endpoint returns all subtypes with their concrete types:

GET /notifications HTTP/1.1
Accept: application/vnd.api+json

HTTP/1.1 200 OK
Content-Type: application/vnd.api+json

{
  "data": [
    {
      "type": "email_notifications",
      "id": "1",
      "attributes": {
        "subject": "Welcome",
        "body": "...",
        "recipient_email": "[email protected]"
      }
    },
    {
      "type": "sms_notifications",
      "id": "2",
      "attributes": {
        "subject": "Alert",
        "body": "...",
        "phone_number": "555-1234"
      }
    }
  ]
}

To create or update, use the subtype endpoint with the concrete type:

POST /email_notifications HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json

{
  "data": {
    "type": "email_notifications",
    "attributes": {
      "subject": "Welcome",
      "body": "Hello",
      "recipient_email": "[email protected]"
    }
  }
}

Sorting

Sort collections with the sort parameter. Prefix with - for descending. Seperate multiple sorts with comma ,:

GET /users?sort=name,-created_at HTTP/1.1
Accept: application/vnd.api+json

Virtual attributes can also be sorted (loaded into memory first). Declare sort-only fields with sortable_fields to allow sorting without exposing the value:

class User < ActiveRecord::Base
  has_many :posts
end
class UserResource < JSONAPI::Resource
  attributes :name, :email
  sortable_fields :posts_count

  def posts_count
    resource.posts.size
  end
end

JSON:API Object

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

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

The version is hardcoded to "1.1". You can add meta information to the jsonapi object via configuration:

# config/initializers/json_api.rb
JSONAPI.configure do |config|
  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. Pagination automatically includes total when pagination is applied.

To add custom document-level meta globally, configure document_meta_resolver:

JSONAPI.configure do |config|
  config.document_meta_resolver = lambda do |controller:|
    {
      request_id: controller.request.request_id,
      api_version: "v1"
    }
  end
end

The resolver receives the controller instance and returns a hash that is merged with pagination meta:

{
  "jsonapi": { "version": "1.1" },
  "data": [...],
  "meta": {
    "request_id": "abc-123",
    "api_version": "v1",
    "total": 100
  }
}

For per-controller customization, override jsonapi_document_meta in a custom controller:

class Api::UsersController < JSONAPI::ResourcesController
  private

  def jsonapi_document_meta(extra_meta = {})
    super(extra_meta.merge(custom_field: "value"))
  end
end

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_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 two optional authorization hooks:

  • authorization_scope — filters collections (index) to authorized records
  • authorization_handler — authorizes individual actions (show, create, update, destroy)

If not configured, authorization is bypassed.

Pundit Integration

# config/initializers/json_api.rb
JSONAPI.configure do |config|
  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

  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)
    unless policy.public_send("#{action}?")
      raise JSONAPI::AuthorizationError, "Not authorized to #{action} this resource"
    end
  end
end

The gem automatically renders 403 Forbidden for JSONAPI::AuthorizationError and Pundit::NotAuthorizedError.

Relationship endpoints authorize the parent resource with action: :update and context: { relationship: :relationship_name }.

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

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 HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json

{
  "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/1 HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json

{
  "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:

PATCH /users/1 HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json

{
  "data": {
    "type": "users",
    "id": "1",
    "relationships": {
      "profile": {
        "data": null
      }
    }
  }
}
PATCH /users/1 HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json

{
  "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 Endpoint Details

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

Show Relationship

GET /users/1/relationships/posts HTTP/1.1
Accept: application/vnd.api+json

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/1/relationships/posts?sort=title,-created_at HTTP/1.1
Accept: application/vnd.api+json

Update Relationship

PATCH /users/1/relationships/posts HTTP/1.1
Content-Type: application/vnd.api+json
Accept: 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/1/relationships/posts HTTP/1.1
Content-Type: application/vnd.api+json
Accept: 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"
      }
    }
  ]
}

ActiveStorage Support

The gem automatically detects and serializes ActiveStorage attachments when exposed as relationships.

Exposing Attachments

Declare ActiveStorage attachments as relationships in your resource:

class User < ApplicationRecord
  has_one_attached :avatar
  has_many_attached :documents
end

class UserResource < JSONAPI::Resource
  attributes :name, :email
  has_one :avatar
  has_many :documents
end

The gem auto-detects these are ActiveStorage attachments and serializes them as active_storage_blobs relationships:

GET /users/1?include=avatar HTTP/1.1
Accept: application/vnd.api+json

HTTP/1.1 200 OK
Content-Type: application/vnd.api+json

{
  "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" }
      ]}
    }
  },
  "included": [{
    "type": "active_storage_blobs",
    "id": "1",
    "attributes": {
      "filename": "avatar.jpg",
      "content_type": "image/jpeg",
      "byte_size": 102400,
      "checksum": "abc123...",
      "url": "/rails/active_storage/blobs/.../avatar.jpg"
    },
    "links": {
      "self": "/active_storage_blobs/1",
      "download": "/rails/active_storage/blobs/.../avatar.jpg"
    }
  }]
}

The built-in JSONAPI::ActiveStorageBlobResource provides filename, content_type, byte_size, checksum, and url attributes, plus a download link.

Attaching Files

Clients attach files by providing signed blob IDs from ActiveStorage direct uploads:

POST /users HTTP/1.1
Content-Type: application/vnd.api+json

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

The deserializer validates signed IDs via ActiveStorage::Blob.find_signed! and converts them to blob objects for attachment. Invalid signed IDs raise ActiveSupport::MessageVerifier::InvalidSignature.

Detaching Files

Send null or [] to detach attachments:

PATCH /users/1 HTTP/1.1
Content-Type: application/vnd.api+json

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

By default, this purges the attachments. For has_one, sending null detaches. For has_many, sending [] removes all.

Relationship Options

purge_on_nil (default: true) — Controls whether attachments are purged when set to null/[]:

has_one :avatar, purge_on_nil: false   # Keep existing when null
has_many :documents, purge_on_nil: false

append_only (has_many only, default: false) — Append new blobs instead of replacing:

has_many :documents, append_only: true

When enabled:

  • New blobs append to existing: [blob1, blob2] + [blob3] → [blob1, blob2, blob3]
  • Empty array [] is a no-op (preserves existing)
  • Implicitly sets purge_on_nil: false
  • Remove attachments via the DELETE relationship endpoint

These options are mutually exclusive — append_only: true with purge_on_nil: true raises ArgumentError.

Client Integration: devour-client-ts

The devour-client-ts npm package is a TypeScript JSON:API client that works seamlessly with this gem. This section covers how to configure and use devour-client-ts as a frontend client.

Installation

npm install devour-client-ts

Basic Client Setup

import { JsonApi } from "devour-client-ts";

const api = new JsonApi({
  apiUrl: "http://localhost:3000",
  headers: {
    "Content-Type": "application/vnd.api+json",
    Accept: "application/vnd.api+json",
  },
  trailingSlash: false, // Rails doesn't use trailing slashes
  resetBuilderOnCall: true, // Prevent state pollution between calls
});

Defining Models

Define models that match your Rails resources. Model names are singular, but collectionPath should be plural to match JSON:API conventions:

// Simple model
api.define(
  "user",
  {
    email: {},
    name: {},
    phone: {},
  },
  {
    collectionPath: "users",
  }
);

// Model with relationships
api.define(
  "post",
  {
    title: {},
    body: {},
    user: {
      jsonApi: "hasOne",
      type: "users", // Must be plural
    },
    comments: {
      jsonApi: "hasMany",
      type: "comments", // Must be plural
    },
  },
  {
    collectionPath: "posts",
  }
);

Important: All relationship type values must be plural to match JSON:API specification. Using singular types (e.g., type: 'user') will cause "Type Mismatch" errors.

CRUD Operations

Find all resources:

const { data: users } = await api.findAll("user").toPromise();

Find a single resource:

const { data: user } = await api.find("user", "123").toPromise();

Find with relationships:

const { data: user } = await api
  .find("user", "123", {
    include: ["posts", "profile"],
  })
  .toPromise();

// Relationships are deserialized onto the resource
console.log(user.posts); // Array of post objects
console.log(user.profile); // Profile object

Create a resource:

const { data: newUser } = await api
  .create("user", {
    name: "John Doe",
    email: "[email protected]",
  })
  .toPromise();

Create with relationships:

const { data: newPost } = await api
  .create("post", {
    title: "My Post",
    body: "Content here",
    user: { id: "123", type: "users" }, // hasOne - single object
  })
  .toPromise();

Update a resource:

const { data: updated } = await api
  .update("user", {
    id: "123",
    name: "Updated Name",
  })
  .toPromise();

Delete a resource:

await api.destroy("user", "123").toPromise();

Query Parameters

Filtering:

const { data: users } = await api
  .findAll("user", {
    filter: {
      name_eq: "John",
      created_at_gte: "2024-01-01",
    },
  })
  .toPromise();

Sorting:

const { data: users } = await api
  .findAll("user", {
    sort: "name", // Single field ascending
  })
  .toPromise();

const { data: users } = await api
  .findAll("user", {
    sort: "-created_at", // Descending (prefix with -)
  })
  .toPromise();

const { data: users } = await api
  .findAll("user", {
    sort: ["name", "-created_at"], // Multiple fields
  })
  .toPromise();

Pagination:

const { data: users, meta } = await api
  .findAll("user", {
    page: {
      number: 1,
      size: 10,
    },
  })
  .toPromise();

console.log(meta.total); // Total count

Sparse fieldsets:

const { data: users } = await api
  .findAll("user", {
    fields: {
      users: ["name", "email"],
      posts: ["title"],
    },
  })
  .toPromise();

Including related resources:

const { data: user } = await api
  .find("user", "123", {
    include: ["posts", "posts.comments"],
  })
  .toPromise();

Relationship Operations

Update a relationship:

await api
  .one("user", "123")
  .relationships("posts")
  .patch([
    { id: "1", type: "posts" },
    { id: "2", type: "posts" },
  ])
  .toPromise();

Authentication Middleware

Add authentication middleware to automatically inject tokens:

const authMiddleware = {
  name: "auth",
  req: (payload) => {
    const token = localStorage.getItem("auth_token");
    if (token) {
      payload.req.headers = {
        ...payload.req.headers,
        Authorization: `Bearer ${token}`,
      };
    }
    return payload;
  },
  error: (payload) => {
    if (payload.res?.status === 401) {
      localStorage.removeItem("auth_token");
      // Redirect to login
    }
    throw payload;
  },
};

api.replaceMiddleware("add-bearer-token", authMiddleware);

Polymorphic Relationships

For polymorphic relationships, omit the type in the model definition and provide it when creating/updating:

// Model definition - no type constraint
api.define(
  "workstream",
  {
    topic: {},
    subject: {
      jsonApi: "hasOne",
      // No type - polymorphic
    },
  },
  {
    collectionPath: "workstreams",
  }
);

// Create with polymorphic relationship
const { data: workstream } = await api
  .create("workstream", {
    topic: "edit",
    subject: { id: "456", type: "vendors" }, // Provide type at runtime
  })
  .toPromise();

Error Handling

try {
  const { data: user } = await api.find("user", "123").toPromise();
} catch (error) {
  if (error.response?.status === 401) {
    // Authentication error
  } else if (error.response?.status === 422) {
    // Validation errors
    const errors = error.response.data.errors;
    errors.forEach((err) => {
      console.error(`${err.source?.pointer}: ${err.detail}`);
    });
  } else if (error.response?.status === 400) {
    // Bad request (invalid filters, sort fields, etc.)
    console.error(error.response.data.errors);
  }
}

Key Integration Points

Rails (json_api gem) devour-client-ts
attributes :name, :email { name: {}, email: {} }
has_many :posts posts: { jsonApi: 'hasMany', type: 'posts' }
has_one :profile profile: { jsonApi: 'hasOne', type: 'profiles' }
filters :name_eq filter: { name_eq: 'value' }
sort=name,-date sort: ['name', '-date']
include=posts.comments include: ['posts', 'posts.comments']
page[number]=1&page[size]=10 page: { number: 1, size: 10 }
fields[users]=name,email fields: { users: ['name', 'email'] }

Common Gotchas

  1. Plural types required: All type values in relationships must be plural (users, not user)
  2. Relationship assignment: Assign relationships directly on the resource (user: { id, type }), not in a relationships wrapper
  3. Content-Type header: Must be application/vnd.api+json for POST/PATCH/PUT requests
  4. Filter values: Comma-separated filter values are parsed as a single string; use array notation for multiple values

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.