Project

respondo

0.0
The project is in a healthy, maintained state
Respondo standardizes JSON API responses across Rails applications. Every response gets success, data, message, and meta fields. Automatic pagination meta for Kaminari and Pagy collections. ActiveRecord serialization, error extraction, and flexible HTTP codes built in.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 13.0
~> 3.12
~> 0.22
~> 8.1.3
 Project Readme

Respondo 🎯

Gem Version GitHub Repo Views

Smart JSON API response formatter for Rails — consistent structure every time, across every app.

{
  "success": true,
  "message": "Users fetched",
  "data": [...],
  "meta": {
    "timestamp": "2024-06-15T10:30:00Z",
    "pagination": {
      "currentPage": 1,
      "perPage": 25,
      "totalPages": 4,
      "totalCount": 98,
      "nextPage": 2,
      "prevPage": null
    }
  }
}

The Problem

Different developers return different JSON structures — some use data, some use result, some forget success: true. This makes frontend integration (Flutter, React, etc.) brittle and unpredictable.

Respondo enforces one structure, everywhere, automatically.


Installation

gem "respondo"

Setup

✅ Recommended — use the install generator

After adding the gem, run:

rails generate respondo:install

The interactive wizard walks you through every option and writes a fully commented config/initializers/respondo.rb tailored to your project. No need to read the full README or copy-paste config by hand.

  ┌─ Project Info ─────────────────────────────────────────────────────┐

  Project / app name
  (Used as a comment header in the initializer)
  › [MyApp]:

  API version  (e.g. v1 — added to every response meta block)
  › [v1]:

  ┌─ Response Messages ────────────────────────────────────────────────┐

  Default success message
  › [Success]:

  Default error message
  › [An error occurred]:

  ...

The generator produces a file like this:

# frozen_string_literal: true

# Respondo initializer — MyApp
# Generated by: rails generate respondo:install
# Respondo version: 2.1.0

Respondo.configure do |config|

  # ── Messages ─────────────────────────────────────────────────────────
  config.default_success_message = "Success"
  config.default_error_message   = "Something went wrong"

  # ── Request ID ───────────────────────────────────────────────────────
  config.include_request_id = true

  # ── Key Format ───────────────────────────────────────────────────────
  config.camelize_keys = true

  # ── Global Meta ──────────────────────────────────────────────────────
  config.default_meta = {
    api_version: "v1",
    platform: "mobile"
  }

  # ── Custom Serializer ────────────────────────────────────────────────
  # config.serializer = ->(obj) { MySerializer.new(obj).as_json }

end

Re-run the generator any time to regenerate with different answers — it overwrites the existing initializer.


Manual setup (alternative)

If you prefer to write the initializer yourself, create config/initializers/respondo.rb:

# config/initializers/respondo.rb
Respondo.configure do |config|
  config.default_success_message = "OK"
  config.default_error_message   = "Something went wrong"
  config.include_request_id      = true   # adds request_id to every meta
  config.camelize_keys           = true   # snake_case → camelCase for Flutter/JS
end

Respondo auto-includes itself into ActionController::Base and ActionController::API via Railtie. No manual include needed in ApplicationController.


Response Structure

Every single response — success or error — returns the same four keys:

Key Type Description
success Boolean true or false
message String Human-readable description
data Object/Array/nil The payload
meta Object Timestamp + pagination (if passed) + optional request_id

Error responses additionally include:

Key Type Description
errors Hash Field-level errors { field: ["message", ...] }

errors is optional. Pass it when you want to surface field-level detail to the client (e.g. form validation, token issues). Omit it for simple human-readable-only responses — message alone is perfectly valid.


Complete Helper Reference

1xx — Informational Helpers

1xx responses are protocol-level and carry no body in standard HTTP/1.1. Respondo still returns a JSON body for API logging consistency. Use with care.

render_continue — 100

render_continue
render_continue(message: "Continue sending request body")

render_switching_protocols — 101

render_switching_protocols
render_switching_protocols(message: "Upgrading to WebSocket")

render_processing — 102

render_processing
render_processing(message: "Working on it — please wait")

render_early_hints — 103

render_early_hints
render_early_hints(message: "Early hints provided", meta: { link: "</style.css>; rel=preload" })

2xx — Success Helpers

render_ok — 200 OK

Explicit alias for render_success. Use when you want to be more descriptive.

render_ok(data: @user, message: "User found")

render_created — 201 Created

Use after a successful POST that creates a resource.

render_created(data: @post, message: "Post published")
render_created(data: @user)  # uses default "Created successfully" message

render_accepted — 202 Accepted

Use for async operations — the request was received but processing happens in the background.

render_accepted(message: "Your export is being processed. You will receive an email when ready.")
render_accepted(data: { job_id: "abc123" }, message: "Job queued")

render_non_authoritative — 203 Non-Authoritative Information

Use when data comes from a third-party or cache rather than the origin server.

render_non_authoritative(data: @user, message: "Data sourced from cache")

render_no_content — 200 OK

Use after DELETE or actions with no meaningful response body. Returns standard JSON structure for consistency.

render_no_content                                  # "Deleted successfully"
render_no_content(message: "Account deactivated")

render_reset_content — 205 Reset Content

Tell the client to reset the document view (e.g. clear a form after submission).

render_reset_content(message: "Form submitted — please reset the view")

render_partial_content — 206 Partial Content

Use for chunked or range-based responses.

render_partial_content(data: @chunk, message: "Page 1 of 5")
render_partial_content(data: @results, meta: { range: "0-99/500" })

render_multi_status — 207 Multi-Status

Use for batch operations where some succeed and some fail.

render_multi_status(
  data: { created: 8, failed: 2 },
  message: "Batch completed with partial failures"
)

render_already_reported — 208 Already Reported

render_already_reported(data: @resource, message: "Already reported in this binding")

render_im_used — 226 IM Used

render_im_used(data: @resource, message: "Delta encoding applied")

3xx — Redirect Helpers

These return a JSON body so your API can communicate redirect intent with context. Pass the target URL via meta: { redirect_url: "..." }.

render_multiple_choices — 300

render_multiple_choices(
  data: [{ format: "json", url: "/resource.json" }, { format: "xml", url: "/resource.xml" }],
  message: "Multiple representations available"
)

render_moved_permanently — 301

render_moved_permanently(message: "This endpoint has moved", meta: { redirect_url: new_url })

render_found — 302

render_found(message: "Resource temporarily at another URL", meta: { redirect_url: temp_url })

render_see_other — 303

render_see_other(message: "See the canonical resource", meta: { redirect_url: canonical_url })

render_not_modified — 304

render_not_modified(message: "Your cached version is still valid")

render_temporary_redirect — 307

render_temporary_redirect(message: "Repeat this request at the redirect URL", meta: { redirect_url: url })

render_permanent_redirect — 308

render_permanent_redirect(message: "Permanently moved — update your bookmarks", meta: { redirect_url: url })

4xx — Client Error Helpers

Two usage patterns for every error helper:

  • Message only — a human-readable string shown to the end user.
  • Message + errors — add errors: when you also need field-level detail for the client to act on (e.g. highlight a form field, log a specific token issue). errors is a Hash of { field: ["message", ...] }.

render_bad_request — 400 Bad Request

# Message only
render_bad_request(message: "The 'date' parameter is required")

# Message + errors
render_bad_request(message: "Invalid input", errors: { date: "must be a valid date"})

render_unauthorized — 401 Unauthorized

# Message only
render_unauthorized(message: "Please log in to continue")

# Message + errors
render_unauthorized(message: "Token has expired", errors: { token: "has expired, please log in again"})

render_payment_required — 402 Payment Required

# Message only
render_payment_required(message: "Upgrade to Pro to access this feature")

# Message + errors
render_payment_required( message: "Upgrade to Pro to access this feature", errors: { plan: "must be Pro or higher to access this feature"})

render_forbidden — 403 Forbidden

# Message only
render_forbidden(message: "You can only edit your own posts")

# Message + errors
render_forbidden(message: "You can only edit your own posts",errors: { post: "does not belong to you" })

render_not_found — 404 Not Found

# Message only
render_not_found(message: "User not found")

# Message + errors
render_not_found(message: "User not found", errors: { id: "no user exists with this ID" })

render_method_not_allowed — 405

# Message only
render_method_not_allowed(message: "This endpoint only accepts POST requests")

# Message + errors
render_method_not_allowed(message: "This endpoint only accepts POST requests",errors: { method: "GET is not allowed, use POST" })

render_not_acceptable — 406

# Message only
render_not_acceptable(message: "Only application/json is supported")

# Message + errors
render_not_acceptable(message: "Only application/json is supported", errors: { content_type: "must be application/json" })

render_proxy_auth_required — 407

# Message only
render_proxy_auth_required(message: "Authenticate with the proxy first")

# Message + errors
render_proxy_auth_required(message: "Authenticate with the proxy first", errors: { proxy_token: "is missing or invalid"})

render_request_timeout — 408

# Message only
render_request_timeout(message: "The query took too long. Try a smaller date range.")

# Message + errors
render_request_timeout( message: "The query took too long. Try a smaller date range.", errors: { date_range: "must span 90 days or fewer" })

render_conflict — 409 Conflict

# Message only
render_conflict(message: "Email address is already registered")

# Message + errors
render_conflict(message: "Email address is already registered",errors: { email: "has already been taken" })

render_gone — 410 Gone

# Message only
render_gone(message: "This account has been permanently deleted")

# Message + errors
render_gone(message: "This account has been permanently deleted",errors: { account: ["no longer exists and cannot be recovered"] })

render_length_required — 411

# Message only
render_length_required(message: "Content-Length header is required")

# Message + errors
render_length_required(message: "Content-Length header is required",errors: { content_length: ["header is missing from the request"] })

render_precondition_failed — 412

# Message only
render_precondition_failed(message: "Resource has been modified since your last request")

# Message + errors
render_precondition_failed(message: "Resource has been modified since your last request",errors: { etag: ["does not match the current resource version"] })

render_payload_too_large — 413

# Message only
render_payload_too_large(message: "File exceeds the 10 MB upload limit")

# Message + errors
render_payload_too_large(message: "File exceeds the 10 MB upload limit",errors: { file: ["must be smaller than 10 MB"] })

render_uri_too_long — 414

# Message only
render_uri_too_long(message: "That URL is too long to process")

# Message + errors
render_uri_too_long(message: "That URL is too long to process",errors: { url: ["must not exceed 2048 characters"] })

render_unsupported_media_type — 415

# Message only
render_unsupported_media_type(message: "Please send requests as application/json")

# Message + errors
render_unsupported_media_type(message: "Please send requests as application/json",errors: { content_type: ["must be application/json, got text/xml"] })

render_range_not_satisfiable — 416

# Message only
render_range_not_satisfiable(message: "Requested byte range is out of bounds")

# Message + errors
render_range_not_satisfiable(message: "Requested byte range is out of bounds", errors: { range: ["exceeds the total file size"] })

render_expectation_failed — 417

# Message only
render_expectation_failed(message: "Expect header value cannot be met")

# Message + errors
render_expectation_failed(message: "Expect header value cannot be met",errors: { expect: ["100-continue is not supported on this endpoint"] })

render_im_a_teapot — 418

# Message only
render_im_a_teapot(message: "I'm a teapot — I cannot brew coffee")

# Message + errors
render_im_a_teapot(message: "I'm a teapot — I cannot brew coffee",errors: { beverage: ["coffee is not supported, try tea"] })

render_misdirected_request — 421

# Message only
render_misdirected_request(message: "Request sent to the wrong server")

# Message + errors
render_misdirected_request(message: "Request sent to the wrong server",errors: { host: ["this server does not handle requests for this host"] })

render_unprocessable — 422 Unprocessable Entity

Validation errors. The most commonly used error helper in Rails APIs.

# Message only
render_unprocessable(message: "Validation failed")

# Message + errors — pass an ActiveModel::Errors object directly
render_unprocessable(message: "Validation failed",errors: user.errors)

# Message + errors — pass a plain hash
render_unprocessable(message: "Invalid data",errors: { name: ["can't be blank"], email: ["is invalid"] })

render_locked — 423

# Message only
render_locked(message: "This record is locked by another user")

# Message + errors
render_locked(message: "This record is locked by another user",errors: { record: ["is currently locked, try again later"] })

render_failed_dependency — 424

# Message only
render_failed_dependency(message: "Prerequisite resource creation failed")

# Message + errors
render_failed_dependency(message: "Prerequisite resource creation failed",errors: { dependency: ["parent record must exist before creating this resource"] })

render_too_early — 425

# Message only
render_too_early(message: "Request may be a replay — rejected for safety")

# Message + errors
render_too_early(message: "Request may be a replay — rejected for safety",errors: { request: ["early data replay detected, resend after handshake"] })

render_upgrade_required — 426

# Message only
render_upgrade_required(message: "Please upgrade to TLS 1.3")

# Message + errors
render_upgrade_required(message: "Please upgrade to TLS 1.3",errors: { protocol: ["TLS 1.2 is no longer supported, upgrade to TLS 1.3"] })

render_precondition_required — 428

# Message only
render_precondition_required(message: "Include an If-Match header with your request")

# Message + errors
render_precondition_required(message: "Include an If-Match header with your request",errors: { if_match: ["header is required to prevent lost updates"] })

render_too_many_requests — 429

# Message only
render_too_many_requests(message: "You have exceeded 100 requests per minute.")

# Message + errors
render_too_many_requests(message: "Rate limit exceeded",errors: { rate_limit: ["100 requests per minute allowed, retry after 60 seconds"] },meta: { retry_after: 60 })

render_request_header_fields_too_large — 431

# Message only
render_request_header_fields_too_large(message: "Cookie header is too large")

# Message + errors
render_request_header_fields_too_large(message: "Cookie header is too large",errors: { cookie: ["must not exceed 4096 bytes"] })

render_unavailable_for_legal_reasons — 451

# Message only
render_unavailable_for_legal_reasons(message: "This content is blocked in your region")

# Message + errors
render_unavailable_for_legal_reasons(message: "This content is blocked in your region",errors: { region: ["content is not licensed for distribution in your country"] })

5xx — Server Error Helpers

Two usage patterns for every error helper:

  • Message only — a human-readable string shown to the end user.
  • Message + errors — add errors: when you need to surface internal detail for debugging or logging (e.g. which downstream service failed). errors is a Hash of { field: ["message", ...] }.

render_server_error — 500 Internal Server Error

# Message only
render_server_error(message: "Something went wrong. Our team has been notified.")

# Message + errors
render_server_error(message: "Something went wrong. Our team has been notified.",errors: { server: ["unexpected exception in OrdersController#create"] })

# Common pattern — rescue unexpected exceptions
rescue StandardError => e
  Rails.logger.error(e)
  render_server_error(message: "An unexpected error occurred",errors: { server: [e.message] })

render_not_implemented — 501

# Message only
render_not_implemented(message: "CSV export is coming soon")

# Message + errors
render_not_implemented(message: "CSV export is coming soon",errors: { format: ["csv export is not yet implemented, use json"] })

render_bad_gateway — 502

# Message only
render_bad_gateway(message: "Payment gateway is currently unavailable")

# Message + errors
render_bad_gateway(message: "Payment gateway is currently unavailable",errors: { gateway: ["Stripe returned a 502, please try again"] })

render_service_unavailable — 503

# Message only
render_service_unavailable(message: "Down for maintenance. Back in 30 minutes.")

# Message + errors
render_service_unavailable(message: "Down for maintenance. Back in 30 minutes.",errors: { service: ["scheduled maintenance window until 03:00 UTC"] },meta: { retry_after: 1800 })

render_gateway_timeout — 504

# Message only
render_gateway_timeout(message: "The payment processor did not respond in time.")

# Message + errors
render_gateway_timeout(message: "The payment processor did not respond in time.",errors: { gateway: ["upstream timeout after 30 seconds, you have not been charged"] })

render_http_version_not_supported — 505

# Message only
render_http_version_not_supported(message: "Only HTTP/1.1 and HTTP/2 are supported")

# Message + errors
render_http_version_not_supported(message: "Only HTTP/1.1 and HTTP/2 are supported",errors: { http_version: ["HTTP/1.0 is not supported"] })

render_variant_also_negotiates — 506

# Message only
render_variant_also_negotiates(message: "Server content-negotiation loop detected")

# Message + errors
render_variant_also_negotiates(message: "Server content-negotiation loop detected",errors: { variant: ["misconfigured content negotiation caused an infinite loop"] })

render_insufficient_storage — 507

# Message only
render_insufficient_storage(message: "Disk quota exceeded on this node")

# Message + errors
render_insufficient_storage(message: "Disk quota exceeded on this node",errors: { storage: ["upload failed, node has 0 bytes remaining"] })

render_loop_detected — 508

# Message only
render_loop_detected(message: "Infinite redirect loop detected")

# Message + errors
render_loop_detected(message: "Infinite redirect loop detected",errors: { redirect: ["request visited the same URL more than 10 times"] })

render_not_extended — 510

# Message only
render_not_extended(message: "Further extensions required to fulfil this request")

# Message + errors
render_not_extended(message: "Further extensions required to fulfil this request",errors: { extension: ["mandatory extension 'auth' is missing from the request"] })

render_network_authentication_required — 511

# Message only
render_network_authentication_required(message: "Sign in to the network portal first")

# Message + errors
render_network_authentication_required(message: "Sign in to the network portal first",errors: { network: ["captive portal authentication required before accessing the API"] })

Real-World Controller Examples

class UsersController < ApplicationController

  def index
    page     = (params[:page]     || 1).to_i
    per_page = (params[:per_page] || 5).to_i

    @users =  Kaminari.paginate_array(User.active).page(page).per(per_page)

    render_ok(
      data:       @users,
      message:    "Users fetched",
      pagination: {
        per_page:         per_page.to_i,
        current_page:     @users.current_page,
        next_page:        @users.next_page,
        prev_page:        @users.prev_page,
        total_pages:      @users.total_pages,
        total_count:      @users.total_count
      }
    )
  end

  def show
    user = User.find(params[:id])
    render_ok(data: user, message: "User found")
  rescue ActiveRecord::RecordNotFound
    render_not_found(message: "User ##{params[:id]} not found",errors: { id: ["no user exists with ID #{params[:id]}"] })
  end

  def create
    user = User.new(user_params)
    if user.save
      render_created(data: user, message: "Account created successfully")
    else
      render_unprocessable(message: "Validation failed", errors: user.errors)
    end
  end

  def update
    user = User.find(params[:id])

    unless user == current_user || current_user.admin?
      render_forbidden( message: "You can only update your own profile", errors: { profile: ["you do not have permission to update this profile"] } )
      return
    end

    if user.update(user_params)
      render_ok(data: user, message: "Profile updated")
    else
      render_conflict(message: "Could not update profile", errors: user.errors)
    end
  end

  def destroy
    User.find(params[:id]).destroy!
    render_no_content(message: "Account deleted")
  rescue ActiveRecord::RecordNotFound
    render_gone(message: "This account no longer exists")
  end

end

class PaymentsController < ApplicationController

  def create
    result = PaymentGateway.charge(amount: params[:amount], token: params[:token])
    render_created(data: result, message: "Payment successful")
  rescue PaymentGateway::CardDeclined => e
    render_unprocessable(message: e.message, errors: { card: [e.message] })
  rescue PaymentGateway::Timeout
    render_gateway_timeout( message: "Payment processor timed out. You have not been charged.", errors: { gateway: ["upstream timeout, transaction was not processed"] } )
  rescue PaymentGateway::Error => e
    render_bad_gateway( message: "Payment gateway error: #{e.message}", errors: { gateway: [e.message] })
  end

end

class ReportsController < ApplicationController

  def generate
    ReportJob.perform_later(current_user.id, params[:type])
    render_accepted(
      data:    { estimated_time: "2 minutes" },
      message: "Report is being generated. We will email you when it is ready."
    )
  end

end

Pagination

Respondo does not paginate data for you — your pagination library does that. You build the pagination hash yourself and pass it via pagination:. This keeps the gem simple, dependency-free, and works with any library.

Kaminari

def index
  @users = User.page(params[:page]).per(25)

  render_ok(
    data:       @users,
    message:    "Users fetched",
    pagination: {
      current_page: @users.current_page,
      per_page:     @users.limit_value,
      total_pages:  @users.total_pages,
      total_count:  @users.total_count,
      next_page:    @users.next_page,
      prev_page:    @users.prev_page
    }
  )
end

Pagy

def index
  @pagy, @users = pagy(User.all, items: 25)

  render_ok(
    data:       @users,
    message:    "Users fetched",
    pagination: {
      current_page: @pagy.page,
      per_page:     @pagy.items,
      total_pages:  @pagy.pages,
      total_count:  @pagy.count,
      next_page:    @pagy.next,
      prev_page:    @pagy.prev
    }
  )
end

WillPaginate

def index
  @users = User.paginate(page: params[:page], per_page: 25)

  render_ok(
    data:       @users,
    message:    "Users fetched",
    pagination: {
      current_page: @users.current_page,
      per_page:     @users.per_page,
      total_pages:  @users.total_pages,
      total_count:  @users.total_entries,
      next_page:    @users.next_page,
      prev_page:    @users.previous_page
    }
  )
end

No pagination

render_ok(data: @user, message: "User found")
# → meta will have no pagination key at all

Quick Reference Card

# Core
render_success(data:, message:, meta:, pagination:, code:, status:)
render_error(message:, errors:, code:, meta:, status:)

# 1xx — Informational
render_continue(message:, meta:)
render_switching_protocols(message:, meta:)
render_processing(message:, meta:)
render_early_hints(message:, meta:)

# 2xx — Success
render_success(data:, message:, meta:, pagination:, code:, status:)
render_ok(data:, message:, meta:, pagination:)
render_created(data:, message:, meta:, pagination:)
render_accepted(data:, message:, meta:, pagination:)
render_non_authoritative(data:, message:, meta:, pagination:)
render_no_content(message:, meta:, pagination:)
render_reset_content(message:, meta:, pagination:)
render_partial_content(data:, message:, meta:, pagination:)
render_multi_status(data:, message:, meta:, pagination:)
render_already_reported(data:, message:, meta:, pagination:)
render_im_used(data:, message:, meta:, pagination:)

# 3xx — Redirects
render_multiple_choices(data:, message:, meta:, pagination:)
render_moved_permanently(message:, meta:, pagination:)
render_found(message:, meta:, pagination:)
render_see_other(message:, meta:, pagination:)
render_not_modified(message:, meta:, pagination:)
render_temporary_redirect(message:, meta:, pagination:)
render_permanent_redirect(message:, meta:, pagination:)

# 4xx — Client Errors
render_bad_request(message:, errors:, meta:)
render_unauthorized(message:, errors:, meta:)
render_payment_required(message:, errors:, meta:)
render_forbidden(message:, errors:, meta:)
render_not_found(message:, errors:, meta:)
render_method_not_allowed(message:, errors:, meta:)
render_not_acceptable(message:, errors:, meta:)
render_proxy_auth_required(message:, errors:, meta:)
render_request_timeout(message:, errors:, meta:)
render_conflict(message:, errors:, meta:)
render_gone(message:, errors:, meta:)
render_length_required(message:, errors:, meta:)
render_precondition_failed(message:, errors:, meta:)
render_payload_too_large(message:, errors:, meta:)
render_uri_too_long(message:, errors:, meta:)
render_unsupported_media_type(message:, errors:, meta:)
render_range_not_satisfiable(message:, errors:, meta:)
render_expectation_failed(message:, errors:, meta:)
render_im_a_teapot(message:, errors:, meta:)
render_misdirected_request(message:, errors:, meta:)
render_unprocessable(message:, errors:, meta:)
render_locked(message:, errors:, meta:)
render_failed_dependency(message:, errors:, meta:)
render_too_early(message:, errors:, meta:)
render_upgrade_required(message:, errors:, meta:)
render_precondition_required(message:, errors:, meta:)
render_too_many_requests(message:, errors:, meta:)
render_request_header_fields_too_large(message:, errors:, meta:)
render_unavailable_for_legal_reasons(message:, errors:, meta:)

# 5xx — Server Errors
render_server_error(message:, errors:, meta:)
render_not_implemented(message:, errors:, meta:)
render_bad_gateway(message:, errors:, meta:)
render_service_unavailable(message:, errors:, meta:)
render_gateway_timeout(message:, errors:, meta:)
render_http_version_not_supported(message:, errors:, meta:)
render_variant_also_negotiates(message:, errors:, meta:)
render_insufficient_storage(message:, errors:, meta:)
render_loop_detected(message:, errors:, meta:)
render_not_extended(message:, errors:, meta:)
render_network_authentication_required(message:, errors:, meta:)

Auto-Serialization

Respondo automatically handles:

Input type Output
ActiveRecord::Base instance record.as_json
ActiveRecord::Relation Array of as_json records
ActiveModel::Errors { field: ["message", ...] }
Hash Passed through (values serialized)
Array Each element serialized recursively
Exception { message: e.message }
Anything with #as_json .as_json
Anything with #to_h .to_h
Primitives (String, Integer...) As-is

Custom serializer

Respondo.configure do |config|
  # Use ActiveModelSerializers, Blueprinter, Panko, etc.
  config.serializer = ->(obj) { UserSerializer.new(obj).as_json }
end

camelCase for Flutter / JavaScript

Respondo.configure { |c| c.camelize_keys = true }

All keys in the response — including nested meta.pagination — are camelized: current_pagecurrentPage, total_counttotalCount, next_pagenextPage, error_codeerrorCode.

Flutter Integration

// Every response follows the same shape
class ApiResponse<T> {
  final bool success;
  final String message;
  final T? data;
  final Map<String, dynamic> meta;
  final Map<String, dynamic>? errors;

  const ApiResponse({
    required this.success,
    required this.message,
    this.data,
    required this.meta,
    this.errors,
  });
}

Architecture

lib/
├── respondo.rb                    # Entry point, configure, Railtie hook
├── respondo/
│   ├── version.rb                 # VERSION
│   ├── configuration.rb           # Config with defaults
│   ├── serializer.rb              # Auto-detects and serializes any object
│   ├── response_builder.rb        # Assembles the final Hash
│   ├── controller_helpers.rb      # All render_* helpers (1xx–5xx)
│   └── railtie.rb                 # Auto-includes into Rails controllers
└── generators/
    └── respondo/
        └── install/
            └── install_generator.rb   # rails generate respondo:install

Running Tests

bundle install
bundle exec rspec --format documentation

License

MIT