Respondo 🎯
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. = "Success"
config. = "Something went wrong"
# ── Request ID ───────────────────────────────────────────────────────
config.include_request_id = true
# ── Key Format ───────────────────────────────────────────────────────
config.camelize_keys = true
# ── Global Meta ──────────────────────────────────────────────────────
config. = {
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. = "OK"
config. = "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", ...] } |
errorsis 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 —messagealone 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.
(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).errorsis 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
(message: "Please log in to continue")
# Message + errors
(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).errorsis 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., errors: { card: [e.] })
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.}", errors: { gateway: [e.] })
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:)
(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:)
(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_page → currentPage, total_count → totalCount, next_page → nextPage, error_code → errorCode.
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