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 linkagePATCH /users/:id/relationships/:relationship_name- Replace relationship linkageDELETE /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 subtypeGET /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. = { 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. = 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 ( = {})
super(.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
({ 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. = 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 recordsauthorization_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. = 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. = 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 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
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 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
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 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
- Plural types required: All
typevalues in relationships must be plural (users, notuser) - Relationship assignment: Assign relationships directly on the resource (
user: { id, type }), not in arelationshipswrapper - Content-Type header: Must be
application/vnd.api+jsonfor POST/PATCH/PUT requests - 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.