respondo 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -1,9 +1,36 @@
1
+ <div align="center">
2
+
1
3
  # Respondo 🎯
2
4
 
3
- [![Gem Version](https://badge.fury.io/rb/respondo.svg?icon=si%3Arubygems&icon_color=%23ce0303)](https://badge.fury.io/rb/respondo)
4
- ![GitHub Repo Views](https://gitviews.com/repo/spatelpatidar/respondo.svg)
5
+ **Consistent JSON API responses for Rails — in one line.**
6
+
7
+ [![Gem Version](https://badge.fury.io/rb/respondo.svg)](https://rubygems.org/gems/respondo)
8
+ [![Downloads](https://img.shields.io/gem/dt/respondo.svg)](https://rubygems.org/gems/respondo)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
10
+ [![Ruby](https://img.shields.io/badge/Ruby-2.7%2B-red)](https://www.ruby-lang.org/)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## The problem every Rails API developer hits
5
17
 
6
- Smart JSON API response formatter for Rails consistent structure every time, across every app.
18
+ You're building an API consumed by a React frontend and a Flutter app. Three developers on your team. Three different response shapes:
19
+
20
+ ```json
21
+ // Developer A
22
+ { "data": [...], "status": "ok" }
23
+
24
+ // Developer B
25
+ { "result": [...], "success": true }
26
+
27
+ // Developer C
28
+ { "users": [...] }
29
+ ```
30
+
31
+ Your frontend devs are writing `if (res.data || res.result || res.users)`. Your Flutter devs are filing bugs. Your code reviews are arguments.
32
+
33
+ **Respondo fixes this permanently.** One response shape. Every controller. Every developer. Every time.
7
34
 
8
35
  ```json
9
36
  {
@@ -12,860 +39,388 @@ Smart JSON API response formatter for Rails — consistent structure every time,
12
39
  "data": [...],
13
40
  "meta": {
14
41
  "timestamp": "2024-06-15T10:30:00Z",
15
- "pagination": {
16
- "currentPage": 1,
17
- "perPage": 25,
18
- "totalPages": 4,
19
- "totalCount": 98,
20
- "nextPage": 2,
21
- "prevPage": null
22
- }
42
+ "pagination": { "currentPage": 1, "totalPages": 4, "totalCount": 98 }
23
43
  }
24
44
  }
25
45
  ```
26
46
 
27
- ## The Problem
28
-
29
- 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.
30
-
31
- Respondo enforces one structure, everywhere, automatically.
32
-
33
47
  ---
34
48
 
35
- ## Installation
49
+ ## Install
36
50
 
37
51
  ```ruby
52
+ # Gemfile
38
53
  gem "respondo"
39
54
  ```
40
55
 
41
- ---
42
-
43
- ## Setup
44
-
45
- ### ✅ Recommended — use the install generator
46
-
47
- After adding the gem, run:
48
-
49
56
  ```bash
57
+ bundle install
50
58
  rails generate respondo:install
51
59
  ```
52
60
 
53
- 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.
61
+ That's it. No `include` in `ApplicationController`. No boilerplate. Respondo auto-injects via Railtie.
54
62
 
55
- ```
56
- ┌─ Project Info ─────────────────────────────────────────────────────┐
57
-
58
- Project / app name
59
- (Used as a comment header in the initializer)
60
- › [MyApp]:
61
-
62
- API version (e.g. v1 — added to every response meta block)
63
- › [v1]:
64
-
65
- ┌─ Response Messages ────────────────────────────────────────────────┐
66
-
67
- Default success message
68
- › [Success]:
69
-
70
- Default error message
71
- › [An error occurred]:
72
-
73
- ...
74
- ```
63
+ ---
75
64
 
76
- The generator produces a file like this:
65
+ ## Your first response (30 seconds)
77
66
 
78
67
  ```ruby
79
- # frozen_string_literal: true
80
-
81
- # Respondo initializer MyApp
82
- # Generated by: rails generate respondo:install
83
- # Respondo version: 2.1.0
84
-
85
- Respondo.configure do |config|
86
-
87
- # ── Messages ─────────────────────────────────────────────────────────
88
- config.default_success_message = "Success"
89
- config.default_error_message = "Something went wrong"
90
-
91
- # ── Request ID ───────────────────────────────────────────────────────
92
- config.include_request_id = true
93
-
94
- # ── Key Format ───────────────────────────────────────────────────────
95
- config.camelize_keys = true
96
-
97
- # ── Global Meta ──────────────────────────────────────────────────────
98
- config.default_meta = {
99
- api_version: "v1",
100
- platform: "mobile"
101
- }
102
-
103
- # ── Custom Serializer ────────────────────────────────────────────────
104
- # config.serializer = ->(obj) { MySerializer.new(obj).as_json }
105
-
106
- end
107
- ```
108
-
109
- Re-run the generator any time to regenerate with different answers — it overwrites the existing initializer.
110
-
111
- ---
112
-
113
- ### Manual setup (alternative)
68
+ class UsersController < ApplicationController
69
+ def index
70
+ render_ok(data: User.all, message: "Users fetched")
71
+ end
114
72
 
115
- If you prefer to write the initializer yourself, create `config/initializers/respondo.rb`:
73
+ def create
74
+ user = User.new(user_params)
75
+ if user.save
76
+ render_created(data: user, message: "Account created")
77
+ else
78
+ render_unprocessable(message: "Validation failed", errors: user.errors)
79
+ end
80
+ end
116
81
 
117
- ```ruby
118
- # config/initializers/respondo.rb
119
- Respondo.configure do |config|
120
- config.default_success_message = "OK"
121
- config.default_error_message = "Something went wrong"
122
- config.include_request_id = true # adds request_id to every meta
123
- config.camelize_keys = true # snake_case → camelCase for Flutter/JS
82
+ def show
83
+ render_ok(data: User.find(params[:id]), message: "User found")
84
+ rescue ActiveRecord::RecordNotFound
85
+ render_not_found(message: "User not found")
86
+ end
124
87
  end
125
88
  ```
126
89
 
127
- Respondo auto-includes itself into `ActionController::Base` and `ActionController::API` via Railtie. No manual `include` needed in `ApplicationController`.
90
+ Your frontend now gets a **guaranteed** structure forever.
128
91
 
129
92
  ---
130
93
 
131
- ## Response Structure
132
-
133
- Every single response — success or error — returns the same four keys:
94
+ ## Why teams switch to Respondo
134
95
 
135
- | Key | Type | Description |
136
- |-----------|------------------|------------------------------------------------------|
137
- | `success` | Boolean | `true` or `false` |
138
- | `message` | String | Human-readable description |
139
- | `data` | Object/Array/nil | The payload |
140
- | `meta` | Object | Timestamp + pagination (if passed) + optional request_id |
141
-
142
- Error responses additionally include:
143
-
144
- | Key | Type | Description |
145
- |----------|------|--------------------------------------|
146
- | `errors` | Hash | Field-level errors `{field: [msgs]}` |
147
- | `errors` | Hash | Field-level errors `{field: msgs}` |
96
+ | Without Respondo | With Respondo |
97
+ |---|---|
98
+ | Each dev invents their own response format | One standard, enforced automatically |
99
+ | Frontend code full of defensive `||` checks | `response.data` always works |
100
+ | Pagination shape differs per endpoint | Pagination always in `meta.pagination` |
101
+ | Validation errors in different keys | Always in `errors`, always a hash |
102
+ | `render json:` boilerplate in every action | One expressive method call |
103
+ | camelCase conversion scattered across code | `config.camelize_keys = true` — done |
148
104
 
149
105
  ---
150
106
 
151
- ## Complete Helper Reference
107
+ ## What every response looks like
152
108
 
153
- ### 1xxInformational Helpers
109
+ Every responsesuccess or error — has the same four keys:
154
110
 
155
- > 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.
111
+ | Key | Type | Description |
112
+ |---|---|---|
113
+ | `success` | Boolean | `true` or `false` — always present |
114
+ | `message` | String | Human-readable description |
115
+ | `data` | Object / Array / nil | The payload |
116
+ | `meta` | Object | Timestamp + pagination + optional `request_id` |
156
117
 
157
- #### `render_continue` — 100
158
- ```ruby
159
- render_continue
160
- render_continue(message: "Continue sending request body")
161
- ```
118
+ Error responses additionally include `errors` — a hash of `{ field: ["message"] }`.
162
119
 
163
- #### `render_switching_protocols` — 101
164
- ```ruby
165
- render_switching_protocols
166
- render_switching_protocols(message: "Upgrading to WebSocket")
167
- ```
168
-
169
- #### `render_processing` 102
170
- ```ruby
171
- render_processing
172
- render_processing(message: "Working on it — please wait")
120
+ **Success:**
121
+ ```json
122
+ {
123
+ "success": true,
124
+ "message": "Post published",
125
+ "data": { "id": 42, "title": "Hello World" },
126
+ "meta": { "timestamp": "2024-06-15T10:30:00Z" }
127
+ }
173
128
  ```
174
129
 
175
- #### `render_early_hints` — 103
176
- ```ruby
177
- render_early_hints
178
- render_early_hints(message: "Early hints provided", meta: { link: "</style.css>; rel=preload" })
130
+ **Validation error:**
131
+ ```json
132
+ {
133
+ "success": false,
134
+ "message": "Validation failed",
135
+ "data": null,
136
+ "errors": { "email": ["is invalid"], "name": ["can't be blank"] },
137
+ "meta": { "timestamp": "2024-06-15T10:30:00Z" }
138
+ }
179
139
  ```
180
140
 
181
141
  ---
182
142
 
183
- ### 2xx Success Helpers
184
-
185
- #### `render_ok` — 200 OK
186
- Explicit alias for `render_success`. Use when you want to be more descriptive.
187
-
188
- ```ruby
189
- render_ok(data: @user, message: "User found")
190
- ```
191
-
192
- #### `render_created` — 201 Created
193
- Use after a successful POST that creates a resource.
194
-
195
- ```ruby
196
- render_created(data: @post, message: "Post published")
197
- render_created(data: @user) # uses default "Created successfully" message
198
- ```
199
-
200
- #### `render_accepted` — 202 Accepted
201
- Use for async operations — the request was received but processing happens in the background.
143
+ ## Real-world controller (with pagination)
202
144
 
203
145
  ```ruby
204
- render_accepted(message: "Your export is being processed. You will receive an email when ready.")
205
- render_accepted(data: { job_id: "abc123" }, message: "Job queued")
206
- ```
146
+ class PostsController < ApplicationController
207
147
 
208
- #### `render_non_authoritative` — 203 Non-Authoritative Information
209
- Use when data comes from a third-party or cache rather than the origin server.
148
+ def index
149
+ @posts = Post.published.page(params[:page]).per(params[:per_page] || 20)
210
150
 
211
- ```ruby
212
- render_non_authoritative(data: @user, message: "Data sourced from cache")
213
- ```
151
+ render_ok(
152
+ data: @posts,
153
+ message: "Posts fetched",
154
+ pagination: {
155
+ current_page: @posts.current_page,
156
+ next_page: @posts.next_page,
157
+ prev_page: @posts.prev_page,
158
+ total_pages: @posts.total_pages,
159
+ total_count: @posts.total_count,
160
+ per_page: @posts.limit_value
161
+ }
162
+ )
163
+ end
214
164
 
215
- #### `render_no_content` — 200 OK
216
- Use after DELETE or actions with no meaningful response body. Returns standard JSON structure for consistency.
165
+ def create
166
+ @post = current_user.posts.build(post_params)
217
167
 
218
- ```ruby
219
- render_no_content # "Deleted successfully"
220
- render_no_content(message: "Account deactivated")
221
- ```
168
+ if @post.save
169
+ render_created(data: @post, message: "Post published")
170
+ else
171
+ render_unprocessable(message: "Could not create post", errors: @post.errors)
172
+ end
173
+ end
222
174
 
223
- #### `render_reset_content` — 205 Reset Content
224
- Tell the client to reset the document view (e.g. clear a form after submission).
175
+ def update
176
+ @post = Post.find(params[:id])
225
177
 
226
- ```ruby
227
- render_reset_content(message: "Form submitted — please reset the view")
228
- ```
178
+ return render_forbidden(message: "Not your post") unless @post.user == current_user
229
179
 
230
- #### `render_partial_content` — 206 Partial Content
231
- Use for chunked or range-based responses.
180
+ if @post.update(post_params)
181
+ render_ok(data: @post, message: "Post updated")
182
+ else
183
+ render_unprocessable(message: "Update failed", errors: @post.errors)
184
+ end
185
+ rescue ActiveRecord::RecordNotFound
186
+ render_not_found(message: "Post not found")
187
+ end
232
188
 
233
- ```ruby
234
- render_partial_content(data: @chunk, message: "Page 1 of 5")
235
- render_partial_content(data: @results, meta: { range: "0-99/500" })
189
+ end
236
190
  ```
237
191
 
238
- #### `render_multi_status` — 207 Multi-Status
239
- Use for batch operations where some succeed and some fail.
240
-
241
- ```ruby
242
- render_multi_status(
243
- data: { created: 8, failed: 2 },
244
- message: "Batch completed with partial failures"
245
- )
246
- ```
192
+ ## Auto-Serialization
247
193
 
248
- #### `render_already_reported` — 208 Already Reported
249
- ```ruby
250
- render_already_reported(data: @resource, message: "Already reported in this binding")
251
- ```
194
+ Respondo automatically handles:
252
195
 
253
- #### `render_im_used` 226 IM Used
254
- ```ruby
255
- render_im_used(data: @resource, message: "Delta encoding applied")
256
- ```
196
+ | Input type | Output |
197
+ |----------------------------------|-------------------------------------|
198
+ | `ActiveRecord::Base` instance | `record.as_json` |
199
+ | `ActiveRecord::Relation` | Array of `as_json` records |
200
+ | `ActiveModel::Errors` | `{ field: ["message", ...] }` |
201
+ | `Hash` | Passed through (values serialized) |
202
+ | `Array` | Each element serialized recursively |
203
+ | `Exception` | `{ message: e.message }` |
204
+ | Anything with `#as_json` | `.as_json` |
205
+ | Anything with `#to_h` | `.to_h` |
206
+ | Primitives (String, Integer...) | As-is |
257
207
 
258
208
  ---
259
209
 
260
- ### 3xx — Redirect Helpers
261
-
262
- > These return a JSON body so your API can communicate redirect intent with context.
263
- > Pass the target URL via `meta: { redirect_url: "..." }`.
264
-
265
- #### `render_multiple_choices` — 300
266
- ```ruby
267
- render_multiple_choices(
268
- data: [{ format: "json", url: "/resource.json" }, { format: "xml", url: "/resource.xml" }],
269
- message: "Multiple representations available"
270
- )
271
- ```
272
-
273
- #### `render_moved_permanently` — 301
274
- ```ruby
275
- render_moved_permanently(message: "This endpoint has moved", meta: { redirect_url: new_url })
276
- ```
210
+ ## Configuration
277
211
 
278
- #### `render_found`302
279
- ```ruby
280
- render_found(message: "Resource temporarily at another URL", meta: { redirect_url: temp_url })
281
- ```
212
+ Run the interactive generator it walks you through every option:
282
213
 
283
- #### `render_see_other` — 303
284
- ```ruby
285
- render_see_other(message: "See the canonical resource", meta: { redirect_url: canonical_url })
214
+ ```bash
215
+ rails generate respondo:install
286
216
  ```
287
217
 
288
- #### `render_not_modified` 304
289
- ```ruby
290
- render_not_modified(message: "Your cached version is still valid")
291
- ```
218
+ Or write it manually:
292
219
 
293
- #### `render_temporary_redirect` — 307
294
220
  ```ruby
295
- render_temporary_redirect(message: "Repeat this request at the redirect URL", meta: { redirect_url: url })
296
- ```
297
-
298
- #### `render_permanent_redirect` 308
299
- ```ruby
300
- render_permanent_redirect(message: "Permanently moved update your bookmarks", meta: { redirect_url: url })
221
+ # config/initializers/respondo.rb
222
+ Respondo.configure do |config|
223
+ config.default_success_message = "OK"
224
+ config.default_error_message = "Something went wrong"
225
+ config.include_request_id = true # adds request_id to every meta
226
+ config.camelize_keys = true # snake_case camelCase (Flutter/JS friendly)
227
+ config.default_meta = { api_version: "v1" }
228
+ end
301
229
  ```
302
230
 
303
- ---
304
-
305
- ### 4xx — Client Error Helpers
231
+ ### camelCase output (great for Flutter / React Native)
306
232
 
307
- #### `render_bad_request` — 400 Bad Request
308
233
  ```ruby
309
- render_bad_request(message: "The 'date' parameter is required")
310
- render_bad_request(message: "Invalid input", errors: { date: ["must be a valid date"] })
234
+ config.camelize_keys = true
311
235
  ```
312
236
 
313
- #### `render_unauthorized` — 401 Unauthorized
314
- ```ruby
315
- render_unauthorized(message: "Please log in to continue")
316
- render_unauthorized(message: "Token has expired")
237
+ ```json
238
+ {
239
+ "success": true,
240
+ "data": { "firstName": "Alice", "createdAt": "2024-01-01" },
241
+ "meta": { "totalPages": 4, "currentPage": 1 }
242
+ }
317
243
  ```
318
244
 
319
- #### `render_payment_required` — 402 Payment Required
320
- ```ruby
321
- render_payment_required(message: "Upgrade to Pro to access this feature")
322
- ```
245
+ ### Custom serializer
323
246
 
324
- #### `render_forbidden` — 403 Forbidden
325
247
  ```ruby
326
- render_forbidden(message: "You can only edit your own posts")
248
+ config.serializer = ->(obj) { MySerializer.new(obj).as_json }
327
249
  ```
328
250
 
329
- #### `render_not_found` — 404 Not Found
330
- ```ruby
331
- render_not_found(message: "User not found")
332
- render_not_found(message: "Post ##{params[:id]} does not exist")
333
- ```
251
+ ---
334
252
 
335
- #### `render_method_not_allowed` 405
336
- ```ruby
337
- render_method_not_allowed(message: "This endpoint only accepts POST requests")
338
- ```
253
+ ## Global error handling (recommended pattern)
339
254
 
340
- #### `render_not_acceptable` 406
341
- ```ruby
342
- render_not_acceptable(message: "Only application/json is supported")
343
- ```
255
+ Add this to `ApplicationController` to handle exceptions app-wide without try/rescue in every action:
344
256
 
345
- #### `render_proxy_auth_required` — 407
346
257
  ```ruby
347
- render_proxy_auth_required(message: "Authenticate with the proxy first")
348
- ```
349
-
350
- #### `render_request_timeout` — 408
351
- ```ruby
352
- render_request_timeout(message: "The query took too long. Try a smaller date range.")
353
- ```
258
+ class ApplicationController < ActionController::API
259
+ rescue_from ActiveRecord::RecordNotFound do |e|
260
+ render_not_found(message: e.message)
261
+ end
354
262
 
355
- #### `render_conflict` 409 Conflict
356
- ```ruby
357
- render_conflict(message: "Email address is already registered")
358
- render_conflict(message: "Duplicate entry", errors: { email: ["has already been taken"] })
359
- ```
263
+ rescue_from ActionController::ParameterMissing do |e|
264
+ render_bad_request(message: e.message)
265
+ end
360
266
 
361
- #### `render_gone` 410 Gone
362
- ```ruby
363
- render_gone(message: "This account has been permanently deleted")
267
+ rescue_from StandardError do |e|
268
+ Rails.logger.error(e.full_message)
269
+ render_server_error(message: "An unexpected error occurred")
270
+ end
271
+ end
364
272
  ```
365
273
 
366
- #### `render_length_required` — 411
367
- ```ruby
368
- render_length_required(message: "Content-Length header is required")
369
- ```
274
+ ---
370
275
 
371
- #### `render_precondition_failed` 412
372
- ```ruby
373
- render_precondition_failed(message: "Resource has been modified since your last request")
374
- ```
276
+ ## Complete HTTP helper reference
375
277
 
376
- #### `render_payload_too_large` 413
377
- ```ruby
378
- render_payload_too_large(message: "File exceeds the 10 MB upload limit")
379
- ```
278
+ Respondo covers every HTTP status code. Here are the helpers you'll use every day:
380
279
 
381
- #### `render_uri_too_long`414
382
- ```ruby
383
- render_uri_too_long(message: "That URL is too long to process")
384
- ```
280
+ ### 2xxSuccess
385
281
 
386
- #### `render_unsupported_media_type` 415
387
- ```ruby
388
- render_unsupported_media_type(message: "Please send requests as application/json")
389
- ```
282
+ | Helper | Status | When to use |
283
+ |---|---|---|
284
+ | `render_ok` | 200 | Standard success |
285
+ | `render_created` | 201 | After POST creates a resource |
286
+ | `render_accepted` | 202 | Async jobs — request queued |
287
+ | `render_no_content` | 200* | After DELETE — no body |
390
288
 
391
- #### `render_range_not_satisfiable` 416
392
- ```ruby
393
- render_range_not_satisfiable(message: "Requested byte range is out of bounds")
394
- ```
289
+ > *Rails renders 200 with a JSON body for `render_no_content` to preserve consistent structure.
395
290
 
396
- #### `render_expectation_failed` — 417
397
291
  ```ruby
398
- render_expectation_failed(message: "Expect header value cannot be met")
292
+ render_ok(data: @user, message: "Profile fetched")
293
+ render_created(data: @order, message: "Order placed")
294
+ render_accepted(data: { job_id: "abc123" }, message: "Export queued — you'll get an email")
295
+ render_no_content(message: "Account deleted")
399
296
  ```
400
297
 
401
- #### `render_im_a_teapot`418
402
- ```ruby
403
- render_im_a_teapot(message: "I'm a teapot — I cannot brew coffee")
404
- ```
405
-
406
- #### `render_misdirected_request` — 421
407
- ```ruby
408
- render_misdirected_request(message: "Request sent to the wrong server")
409
- ```
298
+ ### 4xxClient errors
410
299
 
411
- #### `render_unprocessable` 422 Unprocessable Entity
412
- Validation errors. The most commonly used error helper in Rails APIs.
300
+ | Helper | Status | When to use |
301
+ |---|---|---|
302
+ | `render_bad_request` | 400 | Malformed input |
303
+ | `render_unauthorized` | 401 | Not logged in / token expired |
304
+ | `render_forbidden` | 403 | Logged in but not allowed |
305
+ | `render_not_found` | 404 | Record doesn't exist |
306
+ | `render_conflict` | 409 | Duplicate (e.g. email taken) |
307
+ | `render_unprocessable` | 422 | Validation errors |
308
+ | `render_too_many_requests` | 429 | Rate limiting |
413
309
 
414
310
  ```ruby
311
+ render_unauthorized(message: "Token has expired", errors: { token: ["has expired"] })
312
+ render_forbidden(message: "You can only edit your own posts")
313
+ render_not_found(message: "User ##{params[:id]} not found")
415
314
  render_unprocessable(message: "Validation failed", errors: user.errors)
416
- render_unprocessable(message: "Invalid data", errors: { name: ["can't be blank"] })
417
- ```
418
-
419
- #### `render_locked` — 423
420
- ```ruby
421
- render_locked(message: "This record is locked by another user")
422
- ```
423
-
424
- #### `render_failed_dependency` — 424
425
- ```ruby
426
- render_failed_dependency(message: "Prerequisite resource creation failed")
427
- ```
428
-
429
- #### `render_too_early` — 425
430
- ```ruby
431
- render_too_early(message: "Request may be a replay — rejected for safety")
432
- ```
433
-
434
- #### `render_upgrade_required` — 426
435
- ```ruby
436
- render_upgrade_required(message: "Please upgrade to TLS 1.3")
437
- ```
438
-
439
- #### `render_precondition_required` — 428
440
- ```ruby
441
- render_precondition_required(message: "Include an If-Match header with your request")
442
- ```
443
-
444
- #### `render_too_many_requests` — 429
445
- ```ruby
446
- render_too_many_requests(message: "You have exceeded 100 requests per minute.")
447
- render_too_many_requests(message: "Rate limit hit", meta: { retry_after: 60 })
448
- ```
449
-
450
- #### `render_request_header_fields_too_large` — 431
451
- ```ruby
452
- render_request_header_fields_too_large(message: "Cookie header is too large")
453
- ```
454
-
455
- #### `render_unavailable_for_legal_reasons` — 451
456
- ```ruby
457
- render_unavailable_for_legal_reasons(message: "This content is blocked in your region")
315
+ render_conflict(message: "Email already registered", errors: { email: ["has already been taken"] })
316
+ render_too_many_requests(message: "Slow down — 100 req/min max", meta: { retry_after: 60 })
458
317
  ```
459
318
 
460
- ---
461
-
462
- ### 5xx — Server Error Helpers
319
+ ### 5xx — Server errors
463
320
 
464
- #### `render_server_error` — 500 Internal Server Error
465
321
  ```ruby
466
322
  render_server_error(message: "Something went wrong. Our team has been notified.")
467
-
468
- # Common patternrescue unexpected exceptions
469
- rescue StandardError => e
470
- Rails.logger.error(e)
471
- render_server_error("An unexpected error occurred")
323
+ render_service_unavailable(message: "Down for maintenance. Back in 30 minutes.", meta: { retry_after: 1800 })
324
+ render_bad_gateway(message: "Payment processor is unreachable you have not been charged")
472
325
  ```
473
326
 
474
- #### `render_not_implemented` — 501
475
- ```ruby
476
- render_not_implemented(message: "CSV export is coming soon")
477
- ```
327
+ <details>
328
+ <summary><strong>Full list of all helpers (click to expand)</strong></summary>
478
329
 
479
- #### `render_bad_gateway`502
480
- ```ruby
481
- render_bad_gateway(message: "Payment gateway is currently unavailable")
482
- ```
330
+ #### 1xxInformational
331
+ `render_continue` · `render_switching_protocols` · `render_processing` · `render_early_hints`
483
332
 
484
- #### `render_service_unavailable`503
485
- ```ruby
486
- render_service_unavailable(message: "Down for maintenance. Back in 30 minutes.")
487
- render_service_unavailable(message: "Maintenance window", meta: { retry_after: 1800 })
488
- ```
333
+ #### 2xxSuccess
334
+ `render_ok` · `render_created` · `render_accepted` · `render_non_authoritative` · `render_no_content` · `render_reset_content` · `render_partial_content` · `render_multi_status` · `render_already_reported` · `render_im_used`
489
335
 
490
- #### `render_gateway_timeout`504
491
- ```ruby
492
- render_gateway_timeout(message: "The payment processor did not respond in time.")
493
- ```
336
+ #### 3xxRedirect
337
+ `render_multiple_choices` · `render_moved_permanently` · `render_found` · `render_see_other` · `render_not_modified` · `render_temporary_redirect` · `render_permanent_redirect`
494
338
 
495
- #### `render_http_version_not_supported`505
496
- ```ruby
497
- render_http_version_not_supported(message: "Only HTTP/1.1 and HTTP/2 are supported")
498
- ```
339
+ #### 4xxClient Error
340
+ `render_bad_request` · `render_unauthorized` · `render_payment_required` · `render_forbidden` · `render_not_found` · `render_method_not_allowed` · `render_not_acceptable` · `render_proxy_auth_required` · `render_request_timeout` · `render_conflict` · `render_gone` · `render_length_required` · `render_precondition_failed` · `render_payload_too_large` · `render_uri_too_long` · `render_unsupported_media_type` · `render_range_not_satisfiable` · `render_expectation_failed` · `render_im_a_teapot` · `render_misdirected_request` · `render_unprocessable` · `render_locked` · `render_failed_dependency` · `render_too_early` · `render_upgrade_required` · `render_precondition_required` · `render_too_many_requests` · `render_request_header_fields_too_large` · `render_unavailable_for_legal_reasons`
499
341
 
500
- #### `render_variant_also_negotiates`506
501
- ```ruby
502
- render_variant_also_negotiates(message: "Server content-negotiation loop detected")
503
- ```
342
+ #### 5xxServer Error
343
+ `render_server_error` · `render_not_implemented` · `render_bad_gateway` · `render_service_unavailable` · `render_gateway_timeout` · `render_http_version_not_supported` · `render_variant_also_negotiates` · `render_insufficient_storage` · `render_loop_detected` · `render_not_extended` · `render_network_authentication_required`
504
344
 
505
- #### `render_insufficient_storage` — 507
506
- ```ruby
507
- render_insufficient_storage(message: "Disk quota exceeded on this node")
508
- ```
509
-
510
- #### `render_loop_detected` — 508
511
- ```ruby
512
- render_loop_detected(message: "Infinite redirect loop detected")
513
- ```
514
-
515
- #### `render_not_extended` — 510
516
- ```ruby
517
- render_not_extended(message: "Further extensions required to fulfil this request")
518
- ```
519
-
520
- #### `render_network_authentication_required` — 511
521
- ```ruby
522
- render_network_authentication_required(message: "Sign in to the network portal first")
523
- ```
345
+ </details>
524
346
 
525
347
  ---
526
348
 
527
- ## Real-World Controller Examples
528
-
529
- ```ruby
530
- class UsersController < ApplicationController
349
+ ## Testing your responses
531
350
 
532
- def index
533
- page = (params[:page] || 1).to_i
534
- per_page = (params[:per_page] || 5).to_i
351
+ Respondo ships with test helpers for RSpec and Minitest so you can assert on response structure directly.
535
352
 
536
- @users = Kaminari.paginate_array(User.active).page(page).per(per_page)
353
+ ### RSpec
537
354
 
538
- render_ok(
539
- data: @users,
540
- message: "Users fetched",
541
- pagination: {
542
- per_page: per_page.to_i,
543
- current_page: @users.current_page,
544
- next_page: @users.next_page,
545
- prev_page: @users.prev_page,
546
- total_pages: @users.total_pages,
547
- total_count: @users.total_count
548
- }
549
- )
550
- end
551
-
552
- def show
553
- user = User.find(params[:id])
554
- render_ok(data: user, message: "User found")
555
- rescue ActiveRecord::RecordNotFound
556
- render_not_found(message: "User ##{params[:id]} not found", error: { id: "User #{params[:id]} not exist"})
557
- end
355
+ ```ruby
356
+ # spec/rails_helper.rb
357
+ require "respondo/testing/rspec"
558
358
 
559
- def create
560
- user = User.new(user_params)
561
- if user.save
562
- render_created(data: user, message: "Account created successfully")
563
- else
564
- render_unprocessable(message: "Validation failed", errors: user.errors)
359
+ RSpec.describe UsersController, type: :request do
360
+ describe "GET /users" do
361
+ it "returns a success response" do
362
+ get "/users"
363
+ expect(response).to be_respondo_success
364
+ expect(response).to have_respondo_message("Users fetched")
565
365
  end
566
366
  end
567
367
 
568
- def update
569
- user = User.find(params[:id])
570
-
571
- unless user == current_user || current_user.admin?
572
- render_forbidden(message: "You can only update your own profile", error: { profile: "update your own profile" })
573
- return
574
- end
575
-
576
- if user.update(user_params)
577
- render_ok(data: user, message: "Profile updated")
578
- else
579
- render_conflict(message: "Could not update profile", errors: user.errors)
368
+ describe "POST /users with invalid params" do
369
+ it "returns validation errors" do
370
+ post "/users", params: { user: { email: "" } }
371
+ expect(response).to be_respondo_error
372
+ expect(response).to have_respondo_errors(:email)
580
373
  end
581
374
  end
582
-
583
- def destroy
584
- User.find(params[:id]).destroy!
585
- render_no_content(message: "Account deleted")
586
- rescue ActiveRecord::RecordNotFound
587
- render_gone(message: "This account no longer exists")
588
- end
589
-
590
- end
591
-
592
- class PaymentsController < ApplicationController
593
-
594
- def create
595
- result = PaymentGateway.charge(amount: params[:amount], token: params[:token])
596
- render_created(data: result, message: "Payment successful")
597
- rescue PaymentGateway::CardDeclined => e
598
- render_unprocessable(message: e.message)
599
- rescue PaymentGateway::Timeout
600
- render_gateway_timeout(message: "Payment processor timed out. You have not been charged.")
601
- rescue PaymentGateway::Error => e
602
- render_bad_gateway(message: "Payment gateway error: #{e.message}")
603
- end
604
-
605
- end
606
-
607
- class ReportsController < ApplicationController
608
-
609
- def generate
610
- ReportJob.perform_later(current_user.id, params[:type])
611
- render_accepted(
612
- data: { estimated_time: "2 minutes" },
613
- message: "Report is being generated. We will email you when it is ready."
614
- )
615
- end
616
-
617
- end
618
- ```
619
-
620
- ---
621
-
622
- ## Pagination
623
-
624
- Respondo does **not** paginate data for you — your pagination library does that.
625
- You build the pagination hash yourself and pass it via `pagination:`. This keeps the gem simple, dependency-free, and works with any library.
626
-
627
- ### Kaminari
628
-
629
- ```ruby
630
- def index
631
- @users = User.page(params[:page]).per(25)
632
-
633
- render_ok(
634
- data: @users,
635
- message: "Users fetched",
636
- pagination: {
637
- current_page: @users.current_page,
638
- per_page: @users.limit_value,
639
- total_pages: @users.total_pages,
640
- total_count: @users.total_count,
641
- next_page: @users.next_page,
642
- prev_page: @users.prev_page
643
- }
644
- )
645
375
  end
646
376
  ```
647
377
 
648
- ### Pagy
378
+ ### Minitest
649
379
 
650
380
  ```ruby
651
- def index
652
- @pagy, @users = pagy(User.all, items: 25)
653
-
654
- render_ok(
655
- data: @users,
656
- message: "Users fetched",
657
- pagination: {
658
- current_page: @pagy.page,
659
- per_page: @pagy.items,
660
- total_pages: @pagy.pages,
661
- total_count: @pagy.count,
662
- next_page: @pagy.next,
663
- prev_page: @pagy.prev
664
- }
665
- )
666
- end
667
- ```
668
-
669
- ### WillPaginate
670
-
671
- ```ruby
672
- def index
673
- @users = User.paginate(page: params[:page], per_page: 25)
674
-
675
- render_ok(
676
- data: @users,
677
- message: "Users fetched",
678
- pagination: {
679
- current_page: @users.current_page,
680
- per_page: @users.per_page,
681
- total_pages: @users.total_pages,
682
- total_count: @users.total_entries,
683
- next_page: @users.next_page,
684
- prev_page: @users.previous_page
685
- }
686
- )
687
- end
688
- ```
689
-
690
- ### No pagination
381
+ # test/test_helper.rb
382
+ require "respondo/testing/minitest"
691
383
 
692
- ```ruby
693
- render_ok(data: @user, message: "User found")
694
- # → meta will have no pagination key at all
695
- ```
696
-
697
- ---
698
-
699
- ## Quick Reference Card
700
-
701
- ```ruby
702
- # Core
703
- render_success(data:, message:, meta:, code:, pagination:, code:, status:)
704
- render_error(message:, errors:, code:, meta:, status:)
705
-
706
- # 1xx — Informational
707
- render_continue(message:, meta:)
708
- render_switching_protocols(message:, meta:)
709
- render_processing(message:, meta:)
710
- render_early_hints(message:, meta:)
711
-
712
- # 2xx — Success
713
- render_success(data:, message:, meta:, pagination:, code: , status:)
714
- render_ok(data:, message:, meta:, pagination:)
715
- render_created(data:, message:, meta:, pagination:)
716
- render_accepted(data:, message:, meta:, pagination:)
717
- render_non_authoritative(data:, message:, meta:, pagination:)
718
- render_no_content(message:, meta:, pagination:)
719
- render_reset_content(message:, meta:, pagination:)
720
- render_partial_content(data:, message:, meta:, pagination:)
721
- render_multi_status(data:, message:, meta:, pagination:)
722
- render_already_reported(data:, message:, meta:, pagination:)
723
- render_im_used(data:, message:, meta:, pagination:)
724
-
725
- # 3xx — Redirects
726
- render_multiple_choices(data:, message:, meta:, pagination:)
727
- render_moved_permanently(message:, meta:, pagination:)
728
- render_found(message:, meta:, pagination:)
729
- render_see_other(message:, meta:, pagination:)
730
- render_not_modified(message:, meta:, pagination:)
731
- render_temporary_redirect(message:, meta:, pagination:)
732
- render_permanent_redirect(message:, meta:, pagination:)
733
-
734
- # 4xx — Client Errors
735
- render_bad_request(message:, errors:, meta:)
736
- render_unauthorized(message:, errors:, meta:)
737
- render_payment_required(message:, errors:, meta:)
738
- render_forbidden(message:, errors:, meta:)
739
- render_not_found(message:, errors:, meta:)
740
- render_method_not_allowed(message:, errors:, meta:)
741
- render_not_acceptable(message:, errors:, meta:)
742
- render_proxy_auth_required(message:, errors:, meta:)
743
- render_request_timeout(message:, errors:, meta:)
744
- render_conflict(message:, errors:, meta:)
745
- render_gone(message:, errors:, meta:)
746
- render_length_required(message:, errors:, meta:)
747
- render_precondition_failed(message:, errors:, meta:)
748
- render_payload_too_large(message:, errors:, meta:)
749
- render_uri_too_long(message:, errors:, meta:)
750
- render_unsupported_media_type(message:, errors:, meta:)
751
- render_range_not_satisfiable(message:, errors:, meta:)
752
- render_expectation_failed(message:, errors:, meta:)
753
- render_im_a_teapot(message:, errors:, meta:)
754
- render_misdirected_request(message:, errors:, meta:)
755
- render_unprocessable(message:, errors:, meta:)
756
- render_locked(message:, errors:, meta:)
757
- render_failed_dependency(message:, errors:, meta:)
758
- render_too_early(message:, errors:, meta:)
759
- render_upgrade_required(message:, errors:, meta:)
760
- render_precondition_required(message:, errors:, meta:)
761
- render_too_many_requests(message:, errors:, meta:)
762
- render_request_header_fields_too_large(message:, errors:, meta:)
763
- render_unavailable_for_legal_reasons(message:, errors:, meta:)
764
-
765
- # 5xx — Server Errors
766
- render_server_error(message:, errors:, meta:)
767
- render_not_implemented(message:, errors:, meta:)
768
- render_bad_gateway(message:, errors:, meta:)
769
- render_service_unavailable(message:, errors:, meta:)
770
- render_gateway_timeout(message:, errors:, meta:)
771
- render_http_version_not_supported(message:, errors:, meta:)
772
- render_variant_also_negotiates(message:, errors:, meta:)
773
- render_insufficient_storage(message:, errors:, meta:)
774
- render_loop_detected(message:, errors:, meta:)
775
- render_not_extended(message:, errors:, meta:)
776
- render_network_authentication_required(message:, errors:, meta:)
777
- ```
778
-
779
- ---
780
-
781
- ## Auto-Serialization
782
-
783
- Respondo automatically handles:
784
-
785
- | Input type | Output |
786
- |----------------------------------|-------------------------------------|
787
- | `ActiveRecord::Base` instance | `record.as_json` |
788
- | `ActiveRecord::Relation` | Array of `as_json` records |
789
- | `ActiveModel::Errors` | `{ field: ["message", ...] }` |
790
- | `Hash` | Passed through (values serialized) |
791
- | `Array` | Each element serialized recursively |
792
- | `Exception` | `{ message: e.message }` |
793
- | Anything with `#as_json` | `.as_json` |
794
- | Anything with `#to_h` | `.to_h` |
795
- | Primitives (String, Integer...) | As-is |
796
-
797
- ### Custom serializer
798
-
799
- ```ruby
800
- Respondo.configure do |config|
801
- # Use ActiveModelSerializers, Blueprinter, Panko, etc.
802
- config.serializer = ->(obj) { UserSerializer.new(obj).as_json }
384
+ class UsersControllerTest < ActionDispatch::IntegrationTest
385
+ def test_index_returns_success
386
+ get users_url
387
+ assert_respondo_success response
388
+ assert_respondo_message "Users fetched", response
389
+ end
803
390
  end
804
391
  ```
805
392
 
806
393
  ---
807
394
 
808
- ## camelCase for Flutter / JavaScript
395
+ ## What's next
809
396
 
810
- ```ruby
811
- Respondo.configure { |c| c.camelize_keys = true }
812
- ```
397
+ - [ ] ActiveModelSerializers / Blueprinter auto-integration
398
+ - [ ] OpenAPI / Swagger schema generation from Respondo helpers
399
+ - [ ] Rack middleware for zero-config global exception handling
813
400
 
814
- All keys in the response including nested `meta.pagination` — are camelized:
815
- `current_page` → `currentPage`, `total_count` → `totalCount`, `next_page` → `nextPage`, `error_code` → `errorCode`.
816
-
817
- ### Flutter Integration
818
-
819
- ```dart
820
- // Every response follows the same shape
821
- class ApiResponse<T> {
822
- final bool success;
823
- final String message;
824
- final T? data;
825
- final Map<String, dynamic> meta;
826
- final Map<String, dynamic>? errors;
827
-
828
- const ApiResponse({
829
- required this.success,
830
- required this.message,
831
- this.data,
832
- required this.meta,
833
- this.errors,
834
- });
835
- }
836
- ```
401
+ Have a feature idea? [Open an issue →](https://github.com/spatelpatidar/respondo/issues)
837
402
 
838
403
  ---
839
404
 
840
- ## Architecture
405
+ ## Contributing
841
406
 
842
- ```
843
- lib/
844
- ├── respondo.rb # Entry point, configure, Railtie hook
845
- ├── respondo/
846
- │ ├── version.rb # VERSION
847
- │ ├── configuration.rb # Config with defaults
848
- │ ├── serializer.rb # Auto-detects and serializes any object
849
- │ ├── response_builder.rb # Assembles the final Hash
850
- │ ├── controller_helpers.rb # All render_* helpers (1xx–5xx)
851
- │ └── railtie.rb # Auto-includes into Rails controllers
852
- └── generators/
853
- └── respondo/
854
- └── install/
855
- └── install_generator.rb # rails generate respondo:install
856
- ```
857
-
858
- ---
859
-
860
- ## Running Tests
407
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/spatelpatidar/respondo).
861
408
 
862
409
  ```bash
410
+ git clone https://github.com/spatelpatidar/respondo
411
+ cd respondo
863
412
  bundle install
864
- bundle exec rspec --format documentation
413
+ bundle exec rspec
865
414
  ```
866
415
 
867
416
  ---
868
417
 
869
418
  ## License
870
419
 
871
- MIT
420
+ Released under the [MIT License](LICENSE).
421
+
422
+ ---
423
+
424
+ <div align="center">
425
+ <sub>If Respondo saved you an hour, give it a ⭐ — it helps other developers find it.</sub>
426
+ </div>