JsonapiResponses is a Ruby gem that simplifies API response handling by allowing multiple response formats from a single endpoint. Instead of creating separate endpoints for different data requirements, this gem enables frontend applications to request varying levels of detail using the same endpoint.
Add this line to your application's Gemfile:
gem 'jsonapi_responses'And then execute:
$ bundle installOr install it yourself as:
$ gem install jsonapi_responses- Include the respondable module in your
ApplicationController:
class ApplicationController < ActionController::API
include JsonapiResponses::Respondable
end- Create an application serializer (follows Rails convention):
class ApplicationSerializer
attr_reader :resource, :context
def initialize(resource, context = {})
@resource = resource
@context = context
end
def current_user
@context[:current_user]
end
end- Create your model serializers with different view formats:
class DigitalProductSerializer < ApplicationSerializer
def serializable_hash
case context[:view]
when :summary
summary_hash
when :minimal
minimal_hash
else
full_hash
end
end
private
def full_hash
{
id: resource.id,
name: resource.name,
description: resource.description,
# ... more attributes
}
end
def summary_hash
{
id: resource.id,
name: resource.name,
price: resource.price,
# ... fewer attributes
}
end
def minimal_hash
{
id: resource.id,
name: resource.name,
# ... minimal attributes
}
end
endUse render_with in your controllers to handle responses. The view parameter is automatically handled from the request params:
class Api::V1::ProductsController < ApplicationController
def index
products = Product.includes(:categories, :attachments)
render_with(products)
end
def create
@product = Product.new(product_params)
render_with(@product)
end
def show
render_with(@product)
end
def update
@product.update(product_params)
render_with(@product)
end
def destroy
render_with(@product)
end
# Optional: Override the view if needed
def custom_action
render_with(@product, context: { view: :custom_view })
end
endYou can request different view formats by adding the view parameter:
GET /api/v1/digital_products # Returns full response
GET /api/v1/digital_products?view=summary # Returns summary response
GET /api/v1/digital_products?view=minimal # Returns minimal response
By allowing the frontend to request only the needed data, you can:
- Reduce response payload size
- Improve API performance
- Avoid creating multiple endpoints for different data requirements
- Optimize database queries based on the requested view
New in v1.1.0: JsonapiResponses now automatically detects and handles paginated collections using Kaminari, making pagination effortless.
When you paginate your records with Kaminari, pagination metadata is automatically included in the response:
class Api::V1::AcademiesController < ApplicationController
include JsonapiResponses::Respondable
def index
academies = Academy.page(params[:page]).per(15)
# That's it! Pagination is automatic
render_with(academies)
end
endResponse:
{
"data": [
{ "id": 1, "name": "Academy 1", ... },
{ "id": 2, "name": "Academy 2", ... }
],
"meta": {
"current_page": 1,
"total_pages": 5,
"total_count": 73,
"per_page": 15
}
}JsonapiResponses automatically detects if your collection responds to Kaminari's pagination methods:
current_pagetotal_pagestotal_count
If these methods exist, pagination metadata is automatically included in the response.
Pagination works seamlessly with different view formats:
def index
academies = Academy
.includes(:owner, :courses)
.page(params[:page])
.per(params[:per_page] || 15)
# Supports view parameter and automatic pagination
render_with(academies)
endRequest:
GET /api/v1/academies?page=2&per_page=20&view=summary
Response:
{
"data": [
{ "id": 21, "name": "Academy 21", ... }
],
"meta": {
"current_page": 2,
"total_pages": 4,
"total_count": 73,
"per_page": 20
}
}- Kaminari gem must be installed and configured
- Your collection must be paginated with
.page()method
You can add additional metadata alongside automatic pagination:
def index
academies = Academy.page(params[:page]).per(15)
render_with(
academies,
context: { view: view },
meta: {
fetched_at: Time.current,
filters_applied: params[:search].present?
}
)
endResponse:
{
"data": [...],
"meta": {
"current_page": 1,
"total_pages": 5,
"total_count": 73,
"per_page": 15,
"fetched_at": "2024-01-15T10:30:00Z",
"filters_applied": true
}
}For custom responders, use the built-in pagination helpers:
class AcademyResponder < JsonapiResponses::Responder
def respond_for_index
if paginated?(record)
render json: {
data: serialize_collection(record, serializer_class, context),
meta: pagination_meta(record, context)
}
else
render json: {
data: serialize_collection(record, serializer_class, context)
}
end
end
endAvailable helpers:
paginated?(record)- Check if record supports paginationpagination_meta(record, context)- Extract pagination metadata hashrender_collection_with_meta(record, serializer_class, context)- Render with automatic pagination
The view parameter is a client-side suggestion and should never be trusted blindly. In a production environment, you must validate whether the current_user has permission to see the requested level of detail.
Use your authorization logic (like Pundit) inside the serializer to enforce security:
class DigitalProductSerializer < ApplicationSerializer
def serializable_hash
# Validate the view against permissions
authorized_view = authorize_view(context[:view] || :summary)
case authorized_view
when :full then full_hash
when :summary then summary_hash
else minimal_hash
end
end
private
def authorize_view(requested_view)
return requested_view unless requested_view == :full
# Check permission for the 'full' view
if Pundit.policy!(context[:current_user], resource).show_full?
:full
else
:summary # Fallback to a safe view
end
end
endAfter checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Beyond the standard CRUD actions (index, show, create, update, destroy), you can now support custom actions like public_index, export_csv, dashboard_stats, etc.
Option 1: Map to existing actions
class Api::V1::CoursesController < ApplicationController
include JsonapiResponses::Respondable
# Map custom actions to existing response methods
map_response_action :public_index, to: :index
map_response_action :public_show, to: :show
def public_index
# Your logic here
render_with(@courses) # Will use respond_for_index
end
endOption 2: Define custom response methods
class Api::V1::CoursesController < ApplicationController
include JsonapiResponses::Respondable
def dashboard_stats
# Your logic here
render_with(@stats)
end
private
def respond_for_dashboard_stats(record, serializer_class, context)
render json: {
data: record,
meta: { type: 'dashboard', generated_at: Time.current }
}
end
endOption 3: Metaprogramming (Recommended for complex scenarios)
class Api::V1::CoursesController < ApplicationController
include JsonapiResponses::Respondable
# Generate methods automatically
generate_rest_responses(
namespace: 'public',
actions: [:index, :show],
context: { access_level: 'public' }
)
# Define similar responses in batch
define_responses_for [:export_csv, :export_pdf] do |record, serializer_class, context|
format = action_name.to_s.split('_').last
render json: {
data: serialize_collection(record, serializer_class, context),
meta: { export_format: format, total: record.count }
}
end
endFor complex actions or non-standard logic (like bulk_upload, export_csv, or dashboard_stats), Responders provide a clean way to encapsulate response logic outside of your controllers.
If you have an action that doesn't fit the standard CRUD pattern, you can use the action: parameter to route the response to a specific method in your responder.
1. Define the action in your Responder:
# app/responders/product_responder.rb
class ProductResponder < ApplicationResponder
# Custom action for bulk uploads
def bulk_upload
render_json({
data: serialize_collection(record),
meta: {
processed_at: Time.current,
total_records: record.count,
status: 'completed'
}
})
end
end2. Call it from your Controller:
class Api::V1::ProductsController < ApplicationController
def bulk_upload
@products = Product.where(id: params[:ids])
# The 'action' parameter tells the responder which method to execute
render_with(@products, responder: ProductResponder, action: :bulk_upload)
end
end- Clean Controllers: Your controller only handles business logic and fetching data.
- Specific Metadata: Non-standard actions often require unique
metatags that would clutter a controller. - Organization: Even if you have "weird" actions, they remain organized within the Responder assigned to that controller.
Instead of creating one responder file per action, create one responder per controller:
app/responders/
├── base_responder.rb # Common helpers for all responders
├── academy_responder.rb # ALL academy actions
├── course_responder.rb # ALL course actions
└── user_responder.rb # ALL user actions
# app/responders/application_responder.rb
class ApplicationResponder < JsonapiResponses::Responder
protected
def render_collection_with_meta(type: nil, additional_meta: {})
render_json({
data: serialize_collection(record),
meta: base_meta.merge({ type: type }.compact).merge(additional_meta)
})
end
def base_meta
{
timestamp: Time.current.iso8601,
count: record_count
}.compact
end
def record_count
return nil unless collection?
record.respond_to?(:count) ? record.count : record.size
end
def filters_applied
filter_keys = [:category_id, :level, :status]
filters = {}
filter_keys.each { |key| filters[key] = params[key] if params[key].present? }
filters.empty? ? nil : filters
end
end# app/responders/academy_responder.rb
class AcademyResponder < ApplicationResponder
# GET /api/v1/academies/featured
def featured
if params[:category_id].present?
render_filtered_featured
else
render_all_featured
end
end
# GET /api/v1/academies/popular
def popular
render_collection_with_meta(
type: 'popular',
additional_meta: {
period: params[:period] || 'all_time',
algorithm: 'view_count'
}
)
end
# GET /api/v1/academies/recommended
def recommended
render_collection_with_meta(
type: 'recommended',
additional_meta: {
user_id: current_user&.id,
based_on: 'user_preferences'
}
)
end
private
def render_filtered_featured
render_json({
data: serialize_collection(record),
meta: {
type: 'featured',
filtered_by: params[:category_id]
}
})
end
def render_all_featured
render_collection_with_meta(type: 'featured')
end
endclass Api::V1::AcademiesController < ApplicationController
include JsonapiResponses::Respondable
def featured
@academies = load_featured_academies
render_with(@academies, responder: AcademyResponder, action: :featured)
end
def popular
@academies = Academy.popular.limit(20)
render_with(@academies, responder: AcademyResponder, action: :popular)
end
def recommended
@academies = Academy.recommended_for(current_user)
render_with(@academies, responder: AcademyResponder, action: :recommended)
end
endLike Pundit Policies:
- ✅ One file per controller (not per action)
- ✅ All related logic in one place
- ✅ Easy to find and maintain
- ✅ Shared helpers in base class
Example Structure:
AcademiesController → AcademyResponder (featured, popular, recommended)
CoursesController → CourseResponder (featured, search, progress)
UsersController → UserResponder (dashboard, activity, stats)
The Responder base class provides useful helpers:
class MyCustomResponder < JsonapiResponses::Responder
def render
# Access to controller instance
controller.current_user
# Access to params
params[:filter]
# Serialize data
serialize_collection(record) # For collections
serialize_item(record) # For single items
# Serialize using a named method on the serializer (falls back to serializable_hash)
serialize_for(:my_action) # Calls serializer.my_action if defined
# Check record type
collection? # true if record is a collection
single_item? # true if record is a single item
# Render JSON
render_json({ data: [], meta: {} })
end
endFor actions with a custom response envelope (like an email confirmation that returns { message:, user: } instead of the standard { data: }), you can define a named method on the serializer to describe the exact shape of the object for that action.
This mirrors the Pundit convention: just as PostPolicy#publish? handles the publish action, UserSerializer#confirm handles the confirm action.
Rule: the serializer owns the shape of the object; the responder owns the envelope.
serialize_for(:action) instantiates the serializer and calls .action on it if defined, otherwise falls back to serializable_hash.
1. Define the action shape in the serializer (alongside your existing views):
# app/serializers/user_serializer.rb
class UserSerializer < ApplicationSerializer
def serializable_hash
case view
when :minimal then minimal_hash
when :profile then profile_hash
else summary_hash
end
end
# Custom shape for the confirm action — reuses an existing private method
def confirm = auth_hash
private
def summary_hash = { id: resource.id, email: resource.email }
def auth_hash = { id: resource.id, email: resource.email, confirmed: resource.confirmed? }
end2. Use serialize_for in the responder — no shape logic leaks in:
# app/responders/confirmation_responder.rb
class ConfirmationResponder < ApplicationResponder
def confirm
render_json({
message: context[:message],
user: serialize_for(:confirm) # → UserSerializer#confirm
})
end
end3. Controller stays minimal:
class Api::V1::ConfirmationsController < ApplicationController
def confirm
# ... validation logic ...
render_with(
user,
responder: ConfirmationResponder,
action: :confirm,
serializer: UserSerializer,
context: { message: I18n.t("auth.confirmation.success") }
)
end
endIf the serializer does not define the named method, serialize_for silently falls back to serializable_hash, so existing serializers require no changes.
serialize_for(:confirm) # → UserSerializer#confirm (if defined)
serialize_for(:confirm) # → UserSerializer#serializable_hash (fallback)# app/responders/categorized_responder.rb
class CategorizedResponder < JsonapiResponses::Responder
def render
# Handle pre-structured data or group on the fly
if structured_data?
render_json(record)
else
render_json(group_by_category)
end
end
private
def structured_data?
record.is_a?(Array) &&
record.first.is_a?(Hash) &&
record.first.key?(:category)
end
def group_by_category
categories = {}
serialize_collection(record).each do |item|
category_id = item.dig(:category, :id) || 'uncategorized'
categories[category_id] ||= {
category: item[:category] || { name: 'Uncategorized' },
items: []
}
categories[category_id][:items] << item
end
categories.values.map do |group|
group.merge(count: group[:items].size)
end
end
end| Approach | Best For | Complexity |
|---|---|---|
map_response_action |
Simple actions similar to existing ones | Low |
respond_for_* methods |
1-2 custom actions with simple logic | Medium |
| Custom Responders | 3+ custom actions or complex response logic | High |
| Metaprogramming | Batch generation of similar actions | High |
You can combine different approaches in the same controller:
class Api::V1::ProductsController < ApplicationController
include JsonapiResponses::Respondable
# Map simple actions
map_response_action :public_index, to: :index
# Use responder for complex actions
def featured
@products = Product.featured
render_with(@products, responder: FeaturedResponder)
end
# Use custom method for one-off logic
def statistics
render_with(@stats)
end
private
def respond_for_statistics(record, serializer_class, context)
render json: { stats: record, generated_at: Time.current }
end
endBug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/jsonapi_responses. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the JsonapiResponses project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.