respondo 1.0.0 → 2.1.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +219 -0
- data/README.md +498 -194
- data/lib/generators/respondo/install/install_generator.rb +350 -0
- data/lib/respondo/controller_helpers.rb +232 -27
- data/lib/respondo/response_builder.rb +8 -12
- data/lib/respondo/version.rb +1 -1
- data/lib/respondo.rb +1 -1
- data/respondo.gemspec +13 -4
- metadata +25 -6
- data/lib/respondo/pagination.rb +0 -94
data/README.md
CHANGED
|
@@ -42,6 +42,78 @@ gem "respondo"
|
|
|
42
42
|
|
|
43
43
|
## Setup
|
|
44
44
|
|
|
45
|
+
### ✅ Recommended — use the install generator
|
|
46
|
+
|
|
47
|
+
After adding the gem, run:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
rails generate respondo:install
|
|
51
|
+
```
|
|
52
|
+
|
|
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.
|
|
54
|
+
|
|
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
|
+
```
|
|
75
|
+
|
|
76
|
+
The generator produces a file like this:
|
|
77
|
+
|
|
78
|
+
```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)
|
|
114
|
+
|
|
115
|
+
If you prefer to write the initializer yourself, create `config/initializers/respondo.rb`:
|
|
116
|
+
|
|
45
117
|
```ruby
|
|
46
118
|
# config/initializers/respondo.rb
|
|
47
119
|
Respondo.configure do |config|
|
|
@@ -60,23 +132,54 @@ Respondo auto-includes itself into `ActionController::Base` and `ActionControlle
|
|
|
60
132
|
|
|
61
133
|
Every single response — success or error — returns the same four keys:
|
|
62
134
|
|
|
63
|
-
| Key | Type | Description
|
|
64
|
-
|
|
65
|
-
| `success` | Boolean | `true` or `false`
|
|
66
|
-
| `message` | String | Human-readable description
|
|
67
|
-
| `data` | Object/Array/nil | The payload
|
|
68
|
-
| `meta` | Object | Timestamp + pagination + optional request_id |
|
|
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 |
|
|
69
141
|
|
|
70
142
|
Error responses additionally include:
|
|
71
143
|
|
|
72
144
|
| Key | Type | Description |
|
|
73
145
|
|----------|------|--------------------------------------|
|
|
74
146
|
| `errors` | Hash | Field-level errors `{field: [msgs]}` |
|
|
147
|
+
| `errors` | Hash | Field-level errors `{field: msgs}` |
|
|
75
148
|
|
|
76
149
|
---
|
|
77
150
|
|
|
78
151
|
## Complete Helper Reference
|
|
79
152
|
|
|
153
|
+
### 1xx — Informational Helpers
|
|
154
|
+
|
|
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.
|
|
156
|
+
|
|
157
|
+
#### `render_continue` — 100
|
|
158
|
+
```ruby
|
|
159
|
+
render_continue
|
|
160
|
+
render_continue(message: "Continue sending request body")
|
|
161
|
+
```
|
|
162
|
+
|
|
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")
|
|
173
|
+
```
|
|
174
|
+
|
|
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" })
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
80
183
|
### 2xx — Success Helpers
|
|
81
184
|
|
|
82
185
|
#### `render_ok` — 200 OK
|
|
@@ -102,6 +205,13 @@ render_accepted(message: "Your export is being processed. You will receive an em
|
|
|
102
205
|
render_accepted(data: { job_id: "abc123" }, message: "Job queued")
|
|
103
206
|
```
|
|
104
207
|
|
|
208
|
+
#### `render_non_authoritative` — 203 Non-Authoritative Information
|
|
209
|
+
Use when data comes from a third-party or cache rather than the origin server.
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
render_non_authoritative(data: @user, message: "Data sourced from cache")
|
|
213
|
+
```
|
|
214
|
+
|
|
105
215
|
#### `render_no_content` — 200 OK
|
|
106
216
|
Use after DELETE or actions with no meaningful response body. Returns standard JSON structure for consistency.
|
|
107
217
|
|
|
@@ -110,6 +220,13 @@ render_no_content # "Deleted successfully"
|
|
|
110
220
|
render_no_content(message: "Account deactivated")
|
|
111
221
|
```
|
|
112
222
|
|
|
223
|
+
#### `render_reset_content` — 205 Reset Content
|
|
224
|
+
Tell the client to reset the document view (e.g. clear a form after submission).
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
render_reset_content(message: "Form submitted — please reset the view")
|
|
228
|
+
```
|
|
229
|
+
|
|
113
230
|
#### `render_partial_content` — 206 Partial Content
|
|
114
231
|
Use for chunked or range-based responses.
|
|
115
232
|
|
|
@@ -128,138 +245,216 @@ render_multi_status(
|
|
|
128
245
|
)
|
|
129
246
|
```
|
|
130
247
|
|
|
248
|
+
#### `render_already_reported` — 208 Already Reported
|
|
249
|
+
```ruby
|
|
250
|
+
render_already_reported(data: @resource, message: "Already reported in this binding")
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### `render_im_used` — 226 IM Used
|
|
254
|
+
```ruby
|
|
255
|
+
render_im_used(data: @resource, message: "Delta encoding applied")
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
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
|
+
```
|
|
277
|
+
|
|
278
|
+
#### `render_found` — 302
|
|
279
|
+
```ruby
|
|
280
|
+
render_found(message: "Resource temporarily at another URL", meta: { redirect_url: temp_url })
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
#### `render_see_other` — 303
|
|
284
|
+
```ruby
|
|
285
|
+
render_see_other(message: "See the canonical resource", meta: { redirect_url: canonical_url })
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
#### `render_not_modified` — 304
|
|
289
|
+
```ruby
|
|
290
|
+
render_not_modified(message: "Your cached version is still valid")
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
#### `render_temporary_redirect` — 307
|
|
294
|
+
```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 })
|
|
301
|
+
```
|
|
302
|
+
|
|
131
303
|
---
|
|
132
304
|
|
|
133
305
|
### 4xx — Client Error Helpers
|
|
134
306
|
|
|
135
307
|
#### `render_bad_request` — 400 Bad Request
|
|
136
|
-
Malformed request, missing required parameters, invalid format.
|
|
137
|
-
|
|
138
308
|
```ruby
|
|
139
|
-
render_bad_request
|
|
140
|
-
render_bad_request("
|
|
141
|
-
render_bad_request("Invalid input", errors: { date: ["must be a valid date"] })
|
|
142
|
-
render_bad_request("Invalid input", code: "INVALID_FORMAT") # custom error code
|
|
309
|
+
render_bad_request(message: "The 'date' parameter is required")
|
|
310
|
+
render_bad_request(message: "Invalid input", errors: { date: ["must be a valid date"] })
|
|
143
311
|
```
|
|
144
312
|
|
|
145
313
|
#### `render_unauthorized` — 401 Unauthorized
|
|
146
|
-
User is not authenticated. Use when no valid token/session is present.
|
|
147
|
-
|
|
148
314
|
```ruby
|
|
149
|
-
render_unauthorized
|
|
150
|
-
render_unauthorized("
|
|
151
|
-
render_unauthorized("Token has expired", code: "TOKEN_EXPIRED")
|
|
315
|
+
render_unauthorized(message: "Please log in to continue")
|
|
316
|
+
render_unauthorized(message: "Token has expired")
|
|
152
317
|
```
|
|
153
318
|
|
|
154
319
|
#### `render_payment_required` — 402 Payment Required
|
|
155
|
-
Feature is behind a paywall or subscription.
|
|
156
|
-
|
|
157
320
|
```ruby
|
|
158
|
-
render_payment_required
|
|
159
|
-
render_payment_required("Upgrade to Pro to access this feature")
|
|
160
|
-
render_payment_required("Subscription expired", code: "SUBSCRIPTION_EXPIRED")
|
|
321
|
+
render_payment_required(message: "Upgrade to Pro to access this feature")
|
|
161
322
|
```
|
|
162
323
|
|
|
163
324
|
#### `render_forbidden` — 403 Forbidden
|
|
164
|
-
User is authenticated but lacks permission for this action.
|
|
165
|
-
|
|
166
325
|
```ruby
|
|
167
|
-
render_forbidden
|
|
168
|
-
render_forbidden("You can only edit your own posts")
|
|
169
|
-
render_forbidden("Admin access required", code: "ADMIN_REQUIRED")
|
|
326
|
+
render_forbidden(message: "You can only edit your own posts")
|
|
170
327
|
```
|
|
171
328
|
|
|
172
329
|
#### `render_not_found` — 404 Not Found
|
|
173
|
-
|
|
330
|
+
```ruby
|
|
331
|
+
render_not_found(message: "User not found")
|
|
332
|
+
render_not_found(message: "Post ##{params[:id]} does not exist")
|
|
333
|
+
```
|
|
174
334
|
|
|
335
|
+
#### `render_method_not_allowed` — 405
|
|
175
336
|
```ruby
|
|
176
|
-
|
|
177
|
-
render_not_found("User not found")
|
|
178
|
-
render_not_found("Post ##{params[:id]} does not exist")
|
|
337
|
+
render_method_not_allowed(message: "This endpoint only accepts POST requests")
|
|
179
338
|
```
|
|
180
339
|
|
|
181
|
-
#### `
|
|
182
|
-
|
|
340
|
+
#### `render_not_acceptable` — 406
|
|
341
|
+
```ruby
|
|
342
|
+
render_not_acceptable(message: "Only application/json is supported")
|
|
343
|
+
```
|
|
183
344
|
|
|
345
|
+
#### `render_proxy_auth_required` — 407
|
|
184
346
|
```ruby
|
|
185
|
-
|
|
186
|
-
render_method_not_allowed("This endpoint only accepts POST requests")
|
|
347
|
+
render_proxy_auth_required(message: "Authenticate with the proxy first")
|
|
187
348
|
```
|
|
188
349
|
|
|
189
|
-
#### `
|
|
190
|
-
|
|
350
|
+
#### `render_request_timeout` — 408
|
|
351
|
+
```ruby
|
|
352
|
+
render_request_timeout(message: "The query took too long. Try a smaller date range.")
|
|
353
|
+
```
|
|
191
354
|
|
|
355
|
+
#### `render_conflict` — 409 Conflict
|
|
192
356
|
```ruby
|
|
193
|
-
|
|
194
|
-
|
|
357
|
+
render_conflict(message: "Email address is already registered")
|
|
358
|
+
render_conflict(message: "Duplicate entry", errors: { email: ["has already been taken"] })
|
|
195
359
|
```
|
|
196
360
|
|
|
197
|
-
#### `
|
|
198
|
-
|
|
361
|
+
#### `render_gone` — 410 Gone
|
|
362
|
+
```ruby
|
|
363
|
+
render_gone(message: "This account has been permanently deleted")
|
|
364
|
+
```
|
|
199
365
|
|
|
366
|
+
#### `render_length_required` — 411
|
|
200
367
|
```ruby
|
|
201
|
-
|
|
202
|
-
render_request_timeout("The query took too long. Try a smaller date range.")
|
|
368
|
+
render_length_required(message: "Content-Length header is required")
|
|
203
369
|
```
|
|
204
370
|
|
|
205
|
-
#### `
|
|
206
|
-
|
|
371
|
+
#### `render_precondition_failed` — 412
|
|
372
|
+
```ruby
|
|
373
|
+
render_precondition_failed(message: "Resource has been modified since your last request")
|
|
374
|
+
```
|
|
207
375
|
|
|
376
|
+
#### `render_payload_too_large` — 413
|
|
208
377
|
```ruby
|
|
209
|
-
|
|
210
|
-
render_conflict("Email address is already registered")
|
|
211
|
-
render_conflict("Cannot cancel a completed order", code: "INVALID_STATE_TRANSITION")
|
|
212
|
-
render_conflict("Duplicate entry", errors: { email: ["has already been taken"] })
|
|
378
|
+
render_payload_too_large(message: "File exceeds the 10 MB upload limit")
|
|
213
379
|
```
|
|
214
380
|
|
|
215
|
-
#### `
|
|
216
|
-
|
|
381
|
+
#### `render_uri_too_long` — 414
|
|
382
|
+
```ruby
|
|
383
|
+
render_uri_too_long(message: "That URL is too long to process")
|
|
384
|
+
```
|
|
217
385
|
|
|
386
|
+
#### `render_unsupported_media_type` — 415
|
|
218
387
|
```ruby
|
|
219
|
-
|
|
220
|
-
render_gone("This account has been permanently deleted")
|
|
388
|
+
render_unsupported_media_type(message: "Please send requests as application/json")
|
|
221
389
|
```
|
|
222
390
|
|
|
223
|
-
#### `
|
|
224
|
-
|
|
391
|
+
#### `render_range_not_satisfiable` — 416
|
|
392
|
+
```ruby
|
|
393
|
+
render_range_not_satisfiable(message: "Requested byte range is out of bounds")
|
|
394
|
+
```
|
|
225
395
|
|
|
396
|
+
#### `render_expectation_failed` — 417
|
|
226
397
|
```ruby
|
|
227
|
-
|
|
228
|
-
render_precondition_failed("Resource has been modified since your last request")
|
|
398
|
+
render_expectation_failed(message: "Expect header value cannot be met")
|
|
229
399
|
```
|
|
230
400
|
|
|
231
|
-
#### `
|
|
232
|
-
|
|
401
|
+
#### `render_im_a_teapot` — 418
|
|
402
|
+
```ruby
|
|
403
|
+
render_im_a_teapot(message: "I'm a teapot — I cannot brew coffee")
|
|
404
|
+
```
|
|
233
405
|
|
|
406
|
+
#### `render_misdirected_request` — 421
|
|
234
407
|
```ruby
|
|
235
|
-
|
|
236
|
-
render_unsupported_media_type("Please send requests as application/json")
|
|
408
|
+
render_misdirected_request(message: "Request sent to the wrong server")
|
|
237
409
|
```
|
|
238
410
|
|
|
239
411
|
#### `render_unprocessable` — 422 Unprocessable Entity
|
|
240
412
|
Validation errors. The most commonly used error helper in Rails APIs.
|
|
241
413
|
|
|
242
414
|
```ruby
|
|
243
|
-
render_unprocessable("Validation failed", errors: user.errors)
|
|
244
|
-
render_unprocessable("Invalid data", errors: { name: ["can't be blank"]
|
|
415
|
+
render_unprocessable(message: "Validation failed", errors: user.errors)
|
|
416
|
+
render_unprocessable(message: "Invalid data", errors: { name: ["can't be blank"] })
|
|
245
417
|
```
|
|
246
418
|
|
|
247
|
-
#### `render_locked` — 423
|
|
248
|
-
|
|
419
|
+
#### `render_locked` — 423
|
|
420
|
+
```ruby
|
|
421
|
+
render_locked(message: "This record is locked by another user")
|
|
422
|
+
```
|
|
249
423
|
|
|
424
|
+
#### `render_failed_dependency` — 424
|
|
250
425
|
```ruby
|
|
251
|
-
|
|
252
|
-
render_locked("This record is locked by another user")
|
|
253
|
-
render_locked("Invoice is locked after approval", code: "INVOICE_LOCKED")
|
|
426
|
+
render_failed_dependency(message: "Prerequisite resource creation failed")
|
|
254
427
|
```
|
|
255
428
|
|
|
256
|
-
#### `
|
|
257
|
-
|
|
429
|
+
#### `render_too_early` — 425
|
|
430
|
+
```ruby
|
|
431
|
+
render_too_early(message: "Request may be a replay — rejected for safety")
|
|
432
|
+
```
|
|
258
433
|
|
|
434
|
+
#### `render_upgrade_required` — 426
|
|
259
435
|
```ruby
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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")
|
|
263
458
|
```
|
|
264
459
|
|
|
265
460
|
---
|
|
@@ -267,11 +462,8 @@ render_too_many_requests("API limit reached", code: "API_LIMIT_EXCEEDED")
|
|
|
267
462
|
### 5xx — Server Error Helpers
|
|
268
463
|
|
|
269
464
|
#### `render_server_error` — 500 Internal Server Error
|
|
270
|
-
An unexpected error occurred on the server.
|
|
271
|
-
|
|
272
465
|
```ruby
|
|
273
|
-
render_server_error
|
|
274
|
-
render_server_error("Something went wrong. Our team has been notified.")
|
|
466
|
+
render_server_error(message: "Something went wrong. Our team has been notified.")
|
|
275
467
|
|
|
276
468
|
# Common pattern — rescue unexpected exceptions
|
|
277
469
|
rescue StandardError => e
|
|
@@ -279,37 +471,55 @@ rescue StandardError => e
|
|
|
279
471
|
render_server_error("An unexpected error occurred")
|
|
280
472
|
```
|
|
281
473
|
|
|
282
|
-
#### `render_not_implemented` — 501
|
|
283
|
-
|
|
474
|
+
#### `render_not_implemented` — 501
|
|
475
|
+
```ruby
|
|
476
|
+
render_not_implemented(message: "CSV export is coming soon")
|
|
477
|
+
```
|
|
284
478
|
|
|
479
|
+
#### `render_bad_gateway` — 502
|
|
285
480
|
```ruby
|
|
286
|
-
|
|
287
|
-
render_not_implemented("CSV export is coming soon")
|
|
481
|
+
render_bad_gateway(message: "Payment gateway is currently unavailable")
|
|
288
482
|
```
|
|
289
483
|
|
|
290
|
-
#### `
|
|
291
|
-
|
|
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
|
+
```
|
|
292
489
|
|
|
490
|
+
#### `render_gateway_timeout` — 504
|
|
293
491
|
```ruby
|
|
294
|
-
|
|
295
|
-
render_bad_gateway("Payment gateway is currently unavailable")
|
|
296
|
-
render_bad_gateway("Could not reach the SMS service", code: "SMS_GATEWAY_ERROR")
|
|
492
|
+
render_gateway_timeout(message: "The payment processor did not respond in time.")
|
|
297
493
|
```
|
|
298
494
|
|
|
299
|
-
#### `
|
|
300
|
-
|
|
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
|
+
```
|
|
301
499
|
|
|
500
|
+
#### `render_variant_also_negotiates` — 506
|
|
302
501
|
```ruby
|
|
303
|
-
|
|
304
|
-
render_service_unavailable("We are currently under maintenance. Back in 30 minutes.")
|
|
502
|
+
render_variant_also_negotiates(message: "Server content-negotiation loop detected")
|
|
305
503
|
```
|
|
306
504
|
|
|
307
|
-
#### `
|
|
308
|
-
|
|
505
|
+
#### `render_insufficient_storage` — 507
|
|
506
|
+
```ruby
|
|
507
|
+
render_insufficient_storage(message: "Disk quota exceeded on this node")
|
|
508
|
+
```
|
|
309
509
|
|
|
510
|
+
#### `render_loop_detected` — 508
|
|
310
511
|
```ruby
|
|
311
|
-
|
|
312
|
-
|
|
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")
|
|
313
523
|
```
|
|
314
524
|
|
|
315
525
|
---
|
|
@@ -320,27 +530,38 @@ render_gateway_timeout("The payment processor did not respond in time. Please tr
|
|
|
320
530
|
class UsersController < ApplicationController
|
|
321
531
|
|
|
322
532
|
def index
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
533
|
+
page = (params[:page] || 1).to_i
|
|
534
|
+
per_page = (params[:per_page] || 5).to_i
|
|
535
|
+
|
|
536
|
+
@users = Kaminari.paginate_array(User.active).page(page).per(per_page)
|
|
537
|
+
|
|
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
|
+
)
|
|
326
550
|
end
|
|
327
551
|
|
|
328
552
|
def show
|
|
329
553
|
user = User.find(params[:id])
|
|
330
554
|
render_ok(data: user, message: "User found")
|
|
331
555
|
rescue ActiveRecord::RecordNotFound
|
|
332
|
-
render_not_found("User ##{params[:id]} not found")
|
|
333
|
-
# → 404, { success: false, errors_code: "NOT_FOUND" }
|
|
556
|
+
render_not_found(message: "User ##{params[:id]} not found", error: { id: "User #{params[:id]} not exist"})
|
|
334
557
|
end
|
|
335
558
|
|
|
336
559
|
def create
|
|
337
560
|
user = User.new(user_params)
|
|
338
561
|
if user.save
|
|
339
562
|
render_created(data: user, message: "Account created successfully")
|
|
340
|
-
# → 201
|
|
341
563
|
else
|
|
342
|
-
render_unprocessable("Validation failed", errors: user.errors)
|
|
343
|
-
# → 422, { errors: { email: ["is invalid"] } }
|
|
564
|
+
render_unprocessable(message: "Validation failed", errors: user.errors)
|
|
344
565
|
end
|
|
345
566
|
end
|
|
346
567
|
|
|
@@ -348,26 +569,22 @@ class UsersController < ApplicationController
|
|
|
348
569
|
user = User.find(params[:id])
|
|
349
570
|
|
|
350
571
|
unless user == current_user || current_user.admin?
|
|
351
|
-
render_forbidden("You can only update your own profile")
|
|
352
|
-
# → 403
|
|
572
|
+
render_forbidden(message: "You can only update your own profile", error: { profile: "update your own profile" })
|
|
353
573
|
return
|
|
354
574
|
end
|
|
355
575
|
|
|
356
576
|
if user.update(user_params)
|
|
357
577
|
render_ok(data: user, message: "Profile updated")
|
|
358
578
|
else
|
|
359
|
-
render_conflict("Could not update profile", errors: user.errors)
|
|
360
|
-
# → 409
|
|
579
|
+
render_conflict(message: "Could not update profile", errors: user.errors)
|
|
361
580
|
end
|
|
362
581
|
end
|
|
363
582
|
|
|
364
583
|
def destroy
|
|
365
584
|
User.find(params[:id]).destroy!
|
|
366
585
|
render_no_content(message: "Account deleted")
|
|
367
|
-
# → 200
|
|
368
586
|
rescue ActiveRecord::RecordNotFound
|
|
369
|
-
render_gone("This account no longer exists")
|
|
370
|
-
# → 410
|
|
587
|
+
render_gone(message: "This account no longer exists")
|
|
371
588
|
end
|
|
372
589
|
|
|
373
590
|
end
|
|
@@ -378,11 +595,11 @@ class PaymentsController < ApplicationController
|
|
|
378
595
|
result = PaymentGateway.charge(amount: params[:amount], token: params[:token])
|
|
379
596
|
render_created(data: result, message: "Payment successful")
|
|
380
597
|
rescue PaymentGateway::CardDeclined => e
|
|
381
|
-
render_unprocessable(e.message)
|
|
598
|
+
render_unprocessable(message: e.message)
|
|
382
599
|
rescue PaymentGateway::Timeout
|
|
383
|
-
render_gateway_timeout("Payment processor timed out. You have not been charged.")
|
|
600
|
+
render_gateway_timeout(message: "Payment processor timed out. You have not been charged.")
|
|
384
601
|
rescue PaymentGateway::Error => e
|
|
385
|
-
render_bad_gateway("Payment gateway error: #{e.message}")
|
|
602
|
+
render_bad_gateway(message: "Payment gateway error: #{e.message}")
|
|
386
603
|
end
|
|
387
604
|
|
|
388
605
|
end
|
|
@@ -392,10 +609,9 @@ class ReportsController < ApplicationController
|
|
|
392
609
|
def generate
|
|
393
610
|
ReportJob.perform_later(current_user.id, params[:type])
|
|
394
611
|
render_accepted(
|
|
395
|
-
data:
|
|
612
|
+
data: { estimated_time: "2 minutes" },
|
|
396
613
|
message: "Report is being generated. We will email you when it is ready."
|
|
397
614
|
)
|
|
398
|
-
# → 202
|
|
399
615
|
end
|
|
400
616
|
|
|
401
617
|
end
|
|
@@ -403,103 +619,188 @@ end
|
|
|
403
619
|
|
|
404
620
|
---
|
|
405
621
|
|
|
406
|
-
##
|
|
622
|
+
## Pagination
|
|
407
623
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
render_success(data:, message:, meta:, pagy:, pagination:,code: , status:)
|
|
411
|
-
render_ok(data:, message:, meta:, pagination:)
|
|
412
|
-
render_created(data:, message:, pagination:)
|
|
413
|
-
render_accepted(data:, message:)
|
|
414
|
-
render_no_content(message:)
|
|
415
|
-
render_partial_content(data:, message:, meta:)
|
|
416
|
-
render_multi_status(data:, message:, meta:)
|
|
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.
|
|
417
626
|
|
|
418
|
-
|
|
419
|
-
render_bad_request(message, errors:, code:)
|
|
420
|
-
render_unauthorized(message, code:)
|
|
421
|
-
render_payment_required(message, code:)
|
|
422
|
-
render_forbidden(message, code:)
|
|
423
|
-
render_not_found(message, code:)
|
|
424
|
-
render_method_not_allowed(message, code:)
|
|
425
|
-
render_not_acceptable(message, code:)
|
|
426
|
-
render_request_timeout(message, code:)
|
|
427
|
-
render_conflict(message, errors:, code:)
|
|
428
|
-
render_gone(message, code:)
|
|
429
|
-
render_precondition_failed(message, code:)
|
|
430
|
-
render_unsupported_media_type(message, code:)
|
|
431
|
-
render_unprocessable(message, errors:)
|
|
432
|
-
render_locked(message, code:)
|
|
433
|
-
render_too_many_requests(message, code:)
|
|
627
|
+
### Kaminari
|
|
434
628
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
render_bad_gateway(message, code:)
|
|
439
|
-
render_service_unavailable(message, code:)
|
|
440
|
-
render_gateway_timeout(message, code:)
|
|
441
|
-
```
|
|
629
|
+
```ruby
|
|
630
|
+
def index
|
|
631
|
+
@users = User.page(params[:page]).per(25)
|
|
442
632
|
|
|
443
|
-
|
|
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
|
+
end
|
|
646
|
+
```
|
|
444
647
|
|
|
445
|
-
|
|
648
|
+
### Pagy
|
|
446
649
|
|
|
447
|
-
|
|
650
|
+
```ruby
|
|
651
|
+
def index
|
|
652
|
+
@pagy, @users = pagy(User.all, items: 25)
|
|
448
653
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
+
```
|
|
460
668
|
|
|
461
|
-
###
|
|
669
|
+
### WillPaginate
|
|
462
670
|
|
|
463
671
|
```ruby
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
+
)
|
|
467
687
|
end
|
|
468
688
|
```
|
|
469
689
|
|
|
470
|
-
|
|
690
|
+
### No pagination
|
|
471
691
|
|
|
472
|
-
|
|
692
|
+
```ruby
|
|
693
|
+
render_ok(data: @user, message: "User found")
|
|
694
|
+
# → meta will have no pagination key at all
|
|
695
|
+
```
|
|
473
696
|
|
|
474
|
-
|
|
697
|
+
---
|
|
475
698
|
|
|
476
|
-
|
|
699
|
+
## Quick Reference Card
|
|
477
700
|
|
|
478
701
|
```ruby
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
```
|
|
702
|
+
# Core
|
|
703
|
+
render_success(data:, message:, meta:, code:, pagination:, code:, status:)
|
|
704
|
+
render_error(message:, errors:, code:, meta:, status:)
|
|
483
705
|
|
|
484
|
-
|
|
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:)
|
|
485
711
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
render_ok(data
|
|
489
|
-
|
|
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:)
|
|
490
733
|
|
|
491
|
-
|
|
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:)
|
|
492
764
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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:)
|
|
496
777
|
```
|
|
497
778
|
|
|
498
|
-
|
|
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
|
|
499
798
|
|
|
500
799
|
```ruby
|
|
501
|
-
|
|
502
|
-
|
|
800
|
+
Respondo.configure do |config|
|
|
801
|
+
# Use ActiveModelSerializers, Blueprinter, Panko, etc.
|
|
802
|
+
config.serializer = ->(obj) { UserSerializer.new(obj).as_json }
|
|
803
|
+
end
|
|
503
804
|
```
|
|
504
805
|
|
|
505
806
|
---
|
|
@@ -511,7 +812,7 @@ Respondo.configure { |c| c.camelize_keys = true }
|
|
|
511
812
|
```
|
|
512
813
|
|
|
513
814
|
All keys in the response — including nested `meta.pagination` — are camelized:
|
|
514
|
-
`current_page` → `currentPage`, `total_count` → `totalCount`, `error_code` → `errorCode`.
|
|
815
|
+
`current_page` → `currentPage`, `total_count` → `totalCount`, `next_page` → `nextPage`, `error_code` → `errorCode`.
|
|
515
816
|
|
|
516
817
|
### Flutter Integration
|
|
517
818
|
|
|
@@ -541,14 +842,17 @@ class ApiResponse<T> {
|
|
|
541
842
|
```
|
|
542
843
|
lib/
|
|
543
844
|
├── respondo.rb # Entry point, configure, Railtie hook
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
552
856
|
```
|
|
553
857
|
|
|
554
858
|
---
|