respondo 2.1.1 → 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
17
+
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.
5
32
 
6
- Smart JSON API response formatter for Rails consistent structure every time, across every app.
33
+ **Respondo fixes this permanently.** One response shape. Every controller. Every developer. Every time.
7
34
 
8
35
  ```json
9
36
  {
@@ -12,1025 +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.
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]:
61
+ That's it. No `include` in `ApplicationController`. No boilerplate. Respondo auto-injects via Railtie.
64
62
 
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:
134
-
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:
94
+ ## Why teams switch to Respondo
143
95
 
144
- | Key | Type | Description |
145
- |----------|------|--------------------------------------|
146
- | `errors` | Hash | Field-level errors `{ field: ["message", ...] }` |
147
-
148
- > **`errors` is optional.** Pass it when you want to surface field-level detail to the client (e.g. form validation, token issues). Omit it for simple human-readable-only responses — `message` alone is perfectly valid.
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 |
149
104
 
150
105
  ---
151
106
 
152
- ## Complete Helper Reference
153
-
154
- ### 1xx — Informational Helpers
107
+ ## What every response looks like
155
108
 
156
- > 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.
109
+ Every response success or error has the same four keys:
157
110
 
158
- #### `render_continue` 100
159
- ```ruby
160
- render_continue
161
- render_continue(message: "Continue sending request body")
162
- ```
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` |
163
117
 
164
- #### `render_switching_protocols` — 101
165
- ```ruby
166
- render_switching_protocols
167
- render_switching_protocols(message: "Upgrading to WebSocket")
168
- ```
118
+ Error responses additionally include `errors` — a hash of `{ field: ["message"] }`.
169
119
 
170
- #### `render_processing` — 102
171
- ```ruby
172
- render_processing
173
- 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
+ }
174
128
  ```
175
129
 
176
- #### `render_early_hints` — 103
177
- ```ruby
178
- render_early_hints
179
- 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
+ }
180
139
  ```
181
140
 
182
141
  ---
183
142
 
184
- ### 2xx Success Helpers
185
-
186
- #### `render_ok` — 200 OK
187
- Explicit alias for `render_success`. Use when you want to be more descriptive.
188
-
189
- ```ruby
190
- render_ok(data: @user, message: "User found")
191
- ```
192
-
193
- #### `render_created` — 201 Created
194
- Use after a successful POST that creates a resource.
195
-
196
- ```ruby
197
- render_created(data: @post, message: "Post published")
198
- render_created(data: @user) # uses default "Created successfully" message
199
- ```
200
-
201
- #### `render_accepted` — 202 Accepted
202
- Use for async operations — the request was received but processing happens in the background.
203
-
204
- ```ruby
205
- render_accepted(message: "Your export is being processed. You will receive an email when ready.")
206
- render_accepted(data: { job_id: "abc123" }, message: "Job queued")
207
- ```
208
-
209
- #### `render_non_authoritative` — 203 Non-Authoritative Information
210
- Use when data comes from a third-party or cache rather than the origin server.
211
-
212
- ```ruby
213
- render_non_authoritative(data: @user, message: "Data sourced from cache")
214
- ```
215
-
216
- #### `render_no_content` — 200 OK
217
- Use after DELETE or actions with no meaningful response body. Returns standard JSON structure for consistency.
218
-
219
- ```ruby
220
- render_no_content # "Deleted successfully"
221
- render_no_content(message: "Account deactivated")
222
- ```
223
-
224
- #### `render_reset_content` — 205 Reset Content
225
- Tell the client to reset the document view (e.g. clear a form after submission).
226
-
227
- ```ruby
228
- render_reset_content(message: "Form submitted — please reset the view")
229
- ```
230
-
231
- #### `render_partial_content` — 206 Partial Content
232
- Use for chunked or range-based responses.
233
-
234
- ```ruby
235
- render_partial_content(data: @chunk, message: "Page 1 of 5")
236
- render_partial_content(data: @results, meta: { range: "0-99/500" })
237
- ```
238
-
239
- #### `render_multi_status` — 207 Multi-Status
240
- Use for batch operations where some succeed and some fail.
143
+ ## Real-world controller (with pagination)
241
144
 
242
145
  ```ruby
243
- render_multi_status(
244
- data: { created: 8, failed: 2 },
245
- message: "Batch completed with partial failures"
246
- )
247
- ```
248
-
249
- #### `render_already_reported` — 208 Already Reported
250
- ```ruby
251
- render_already_reported(data: @resource, message: "Already reported in this binding")
252
- ```
146
+ class PostsController < ApplicationController
253
147
 
254
- #### `render_im_used` — 226 IM Used
255
- ```ruby
256
- render_im_used(data: @resource, message: "Delta encoding applied")
257
- ```
148
+ def index
149
+ @posts = Post.published.page(params[:page]).per(params[:per_page] || 20)
258
150
 
259
- ---
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
260
164
 
261
- ### 3xx — Redirect Helpers
165
+ def create
166
+ @post = current_user.posts.build(post_params)
262
167
 
263
- > These return a JSON body so your API can communicate redirect intent with context.
264
- > Pass the target URL via `meta: { redirect_url: "..." }`.
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
265
174
 
266
- #### `render_multiple_choices` — 300
267
- ```ruby
268
- render_multiple_choices(
269
- data: [{ format: "json", url: "/resource.json" }, { format: "xml", url: "/resource.xml" }],
270
- message: "Multiple representations available"
271
- )
272
- ```
175
+ def update
176
+ @post = Post.find(params[:id])
273
177
 
274
- #### `render_moved_permanently` 301
275
- ```ruby
276
- render_moved_permanently(message: "This endpoint has moved", meta: { redirect_url: new_url })
277
- ```
178
+ return render_forbidden(message: "Not your post") unless @post.user == current_user
278
179
 
279
- #### `render_found` — 302
280
- ```ruby
281
- render_found(message: "Resource temporarily at another URL", meta: { redirect_url: temp_url })
282
- ```
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
283
188
 
284
- #### `render_see_other` — 303
285
- ```ruby
286
- render_see_other(message: "See the canonical resource", meta: { redirect_url: canonical_url })
189
+ end
287
190
  ```
288
191
 
289
- #### `render_not_modified` — 304
290
- ```ruby
291
- render_not_modified(message: "Your cached version is still valid")
292
- ```
192
+ ## Auto-Serialization
293
193
 
294
- #### `render_temporary_redirect` — 307
295
- ```ruby
296
- render_temporary_redirect(message: "Repeat this request at the redirect URL", meta: { redirect_url: url })
297
- ```
194
+ Respondo automatically handles:
298
195
 
299
- #### `render_permanent_redirect` 308
300
- ```ruby
301
- render_permanent_redirect(message: "Permanently moved — update your bookmarks", meta: { redirect_url: url })
302
- ```
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 |
303
207
 
304
208
  ---
305
209
 
306
- ### 4xx — Client Error Helpers
307
-
308
- > **Two usage patterns for every error helper:**
309
- > - **Message only** — a human-readable string shown to the end user.
310
- > - **Message + errors** — add `errors:` when you also need field-level detail for the client to act on (e.g. highlight a form field, log a specific token issue). `errors` is a Hash of `{ field: ["message", ...] }`.
311
-
312
- #### `render_bad_request` — 400 Bad Request
313
- ```ruby
314
- # Message only
315
- render_bad_request(message: "The 'date' parameter is required")
316
-
317
- # Message + errors
318
- render_bad_request(message: "Invalid input", errors: { date: "must be a valid date"})
319
- ```
320
-
321
- #### `render_unauthorized` — 401 Unauthorized
322
- ```ruby
323
- # Message only
324
- render_unauthorized(message: "Please log in to continue")
325
-
326
- # Message + errors
327
- render_unauthorized(message: "Token has expired", errors: { token: "has expired, please log in again"})
328
- ```
329
-
330
- #### `render_payment_required` — 402 Payment Required
331
- ```ruby
332
- # Message only
333
- render_payment_required(message: "Upgrade to Pro to access this feature")
334
-
335
- # Message + errors
336
- render_payment_required( message: "Upgrade to Pro to access this feature", errors: { plan: "must be Pro or higher to access this feature"})
337
- ```
338
-
339
- #### `render_forbidden` — 403 Forbidden
340
- ```ruby
341
- # Message only
342
- render_forbidden(message: "You can only edit your own posts")
343
-
344
- # Message + errors
345
- render_forbidden(message: "You can only edit your own posts",errors: { post: "does not belong to you" })
346
- ```
210
+ ## Configuration
347
211
 
348
- #### `render_not_found`404 Not Found
349
- ```ruby
350
- # Message only
351
- render_not_found(message: "User not found")
352
-
353
- # Message + errors
354
- render_not_found(message: "User not found", errors: { id: "no user exists with this ID" })
355
- ```
356
-
357
- #### `render_method_not_allowed` — 405
358
- ```ruby
359
- # Message only
360
- render_method_not_allowed(message: "This endpoint only accepts POST requests")
361
-
362
- # Message + errors
363
- render_method_not_allowed(message: "This endpoint only accepts POST requests",errors: { method: "GET is not allowed, use POST" })
364
- ```
365
-
366
- #### `render_not_acceptable` — 406
367
- ```ruby
368
- # Message only
369
- render_not_acceptable(message: "Only application/json is supported")
370
-
371
- # Message + errors
372
- render_not_acceptable(message: "Only application/json is supported", errors: { content_type: "must be application/json" })
373
- ```
374
-
375
- #### `render_proxy_auth_required` — 407
376
- ```ruby
377
- # Message only
378
- render_proxy_auth_required(message: "Authenticate with the proxy first")
379
-
380
- # Message + errors
381
- render_proxy_auth_required(message: "Authenticate with the proxy first", errors: { proxy_token: "is missing or invalid"})
382
- ```
383
-
384
- #### `render_request_timeout` — 408
385
- ```ruby
386
- # Message only
387
- render_request_timeout(message: "The query took too long. Try a smaller date range.")
388
-
389
- # Message + errors
390
- render_request_timeout( message: "The query took too long. Try a smaller date range.", errors: { date_range: "must span 90 days or fewer" })
391
- ```
392
-
393
- #### `render_conflict` — 409 Conflict
394
- ```ruby
395
- # Message only
396
- render_conflict(message: "Email address is already registered")
212
+ Run the interactive generator it walks you through every option:
397
213
 
398
- # Message + errors
399
- render_conflict(message: "Email address is already registered",errors: { email: "has already been taken" })
400
- ```
401
-
402
- #### `render_gone` — 410 Gone
403
- ```ruby
404
- # Message only
405
- render_gone(message: "This account has been permanently deleted")
406
-
407
- # Message + errors
408
- render_gone(message: "This account has been permanently deleted",errors: { account: ["no longer exists and cannot be recovered"] })
409
- ```
410
-
411
- #### `render_length_required` — 411
412
- ```ruby
413
- # Message only
414
- render_length_required(message: "Content-Length header is required")
415
-
416
- # Message + errors
417
- render_length_required(message: "Content-Length header is required",errors: { content_length: ["header is missing from the request"] })
418
- ```
419
-
420
- #### `render_precondition_failed` — 412
421
- ```ruby
422
- # Message only
423
- render_precondition_failed(message: "Resource has been modified since your last request")
424
-
425
- # Message + errors
426
- render_precondition_failed(message: "Resource has been modified since your last request",errors: { etag: ["does not match the current resource version"] })
427
- ```
428
-
429
- #### `render_payload_too_large` — 413
430
- ```ruby
431
- # Message only
432
- render_payload_too_large(message: "File exceeds the 10 MB upload limit")
433
-
434
- # Message + errors
435
- render_payload_too_large(message: "File exceeds the 10 MB upload limit",errors: { file: ["must be smaller than 10 MB"] })
436
- ```
437
-
438
- #### `render_uri_too_long` — 414
439
- ```ruby
440
- # Message only
441
- render_uri_too_long(message: "That URL is too long to process")
442
-
443
- # Message + errors
444
- render_uri_too_long(message: "That URL is too long to process",errors: { url: ["must not exceed 2048 characters"] })
445
- ```
446
-
447
- #### `render_unsupported_media_type` — 415
448
- ```ruby
449
- # Message only
450
- render_unsupported_media_type(message: "Please send requests as application/json")
451
-
452
- # Message + errors
453
- render_unsupported_media_type(message: "Please send requests as application/json",errors: { content_type: ["must be application/json, got text/xml"] })
454
- ```
455
-
456
- #### `render_range_not_satisfiable` — 416
457
- ```ruby
458
- # Message only
459
- render_range_not_satisfiable(message: "Requested byte range is out of bounds")
460
-
461
- # Message + errors
462
- render_range_not_satisfiable(message: "Requested byte range is out of bounds", errors: { range: ["exceeds the total file size"] })
463
- ```
464
-
465
- #### `render_expectation_failed` — 417
466
- ```ruby
467
- # Message only
468
- render_expectation_failed(message: "Expect header value cannot be met")
469
-
470
- # Message + errors
471
- render_expectation_failed(message: "Expect header value cannot be met",errors: { expect: ["100-continue is not supported on this endpoint"] })
214
+ ```bash
215
+ rails generate respondo:install
472
216
  ```
473
217
 
474
- #### `render_im_a_teapot` 418
475
- ```ruby
476
- # Message only
477
- render_im_a_teapot(message: "I'm a teapot — I cannot brew coffee")
478
-
479
- # Message + errors
480
- render_im_a_teapot(message: "I'm a teapot — I cannot brew coffee",errors: { beverage: ["coffee is not supported, try tea"] })
481
- ```
218
+ Or write it manually:
482
219
 
483
- #### `render_misdirected_request` — 421
484
220
  ```ruby
485
- # Message only
486
- render_misdirected_request(message: "Request sent to the wrong server")
487
-
488
- # Message + errors
489
- render_misdirected_request(message: "Request sent to the wrong server",errors: { host: ["this server does not handle requests for this host"] })
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
490
229
  ```
491
230
 
492
- #### `render_unprocessable` 422 Unprocessable Entity
493
- Validation errors. The most commonly used error helper in Rails APIs.
494
-
495
- ```ruby
496
- # Message only
497
- render_unprocessable(message: "Validation failed")
498
-
499
- # Message + errors — pass an ActiveModel::Errors object directly
500
- render_unprocessable(message: "Validation failed",errors: user.errors)
501
-
502
- # Message + errors — pass a plain hash
503
- render_unprocessable(message: "Invalid data",errors: { name: ["can't be blank"], email: ["is invalid"] })
504
- ```
231
+ ### camelCase output (great for Flutter / React Native)
505
232
 
506
- #### `render_locked` — 423
507
233
  ```ruby
508
- # Message only
509
- render_locked(message: "This record is locked by another user")
510
-
511
- # Message + errors
512
- render_locked(message: "This record is locked by another user",errors: { record: ["is currently locked, try again later"] })
234
+ config.camelize_keys = true
513
235
  ```
514
236
 
515
- #### `render_failed_dependency` — 424
516
- ```ruby
517
- # Message only
518
- render_failed_dependency(message: "Prerequisite resource creation failed")
519
-
520
- # Message + errors
521
- render_failed_dependency(message: "Prerequisite resource creation failed",errors: { dependency: ["parent record must exist before creating this resource"] })
237
+ ```json
238
+ {
239
+ "success": true,
240
+ "data": { "firstName": "Alice", "createdAt": "2024-01-01" },
241
+ "meta": { "totalPages": 4, "currentPage": 1 }
242
+ }
522
243
  ```
523
244
 
524
- #### `render_too_early` — 425
525
- ```ruby
526
- # Message only
527
- render_too_early(message: "Request may be a replay — rejected for safety")
528
-
529
- # Message + errors
530
- render_too_early(message: "Request may be a replay — rejected for safety",errors: { request: ["early data replay detected, resend after handshake"] })
531
- ```
245
+ ### Custom serializer
532
246
 
533
- #### `render_upgrade_required` — 426
534
247
  ```ruby
535
- # Message only
536
- render_upgrade_required(message: "Please upgrade to TLS 1.3")
537
-
538
- # Message + errors
539
- render_upgrade_required(message: "Please upgrade to TLS 1.3",errors: { protocol: ["TLS 1.2 is no longer supported, upgrade to TLS 1.3"] })
248
+ config.serializer = ->(obj) { MySerializer.new(obj).as_json }
540
249
  ```
541
250
 
542
- #### `render_precondition_required` — 428
543
- ```ruby
544
- # Message only
545
- render_precondition_required(message: "Include an If-Match header with your request")
546
-
547
- # Message + errors
548
- render_precondition_required(message: "Include an If-Match header with your request",errors: { if_match: ["header is required to prevent lost updates"] })
549
- ```
251
+ ---
550
252
 
551
- #### `render_too_many_requests` 429
552
- ```ruby
553
- # Message only
554
- render_too_many_requests(message: "You have exceeded 100 requests per minute.")
253
+ ## Global error handling (recommended pattern)
555
254
 
556
- # Message + errors
557
- render_too_many_requests(message: "Rate limit exceeded",errors: { rate_limit: ["100 requests per minute allowed, retry after 60 seconds"] },meta: { retry_after: 60 })
558
- ```
255
+ Add this to `ApplicationController` to handle exceptions app-wide without try/rescue in every action:
559
256
 
560
- #### `render_request_header_fields_too_large` — 431
561
257
  ```ruby
562
- # Message only
563
- render_request_header_fields_too_large(message: "Cookie header is too large")
564
-
565
- # Message + errors
566
- render_request_header_fields_too_large(message: "Cookie header is too large",errors: { cookie: ["must not exceed 4096 bytes"] })
567
- ```
258
+ class ApplicationController < ActionController::API
259
+ rescue_from ActiveRecord::RecordNotFound do |e|
260
+ render_not_found(message: e.message)
261
+ end
568
262
 
569
- #### `render_unavailable_for_legal_reasons` 451
570
- ```ruby
571
- # Message only
572
- render_unavailable_for_legal_reasons(message: "This content is blocked in your region")
263
+ rescue_from ActionController::ParameterMissing do |e|
264
+ render_bad_request(message: e.message)
265
+ end
573
266
 
574
- # Message + errors
575
- render_unavailable_for_legal_reasons(message: "This content is blocked in your region",errors: { region: ["content is not licensed for distribution in your country"] })
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
576
272
  ```
577
273
 
578
274
  ---
579
275
 
580
- ### 5xx Server Error Helpers
581
-
582
- > **Two usage patterns for every error helper:**
583
- > - **Message only** — a human-readable string shown to the end user.
584
- > - **Message + errors** — add `errors:` when you need to surface internal detail for debugging or logging (e.g. which downstream service failed). `errors` is a Hash of `{ field: ["message", ...] }`.
585
-
586
- #### `render_server_error` — 500 Internal Server Error
587
- ```ruby
588
- # Message only
589
- render_server_error(message: "Something went wrong. Our team has been notified.")
276
+ ## Complete HTTP helper reference
590
277
 
591
- # Message + errors
592
- render_server_error(message: "Something went wrong. Our team has been notified.",errors: { server: ["unexpected exception in OrdersController#create"] })
278
+ Respondo covers every HTTP status code. Here are the helpers you'll use every day:
593
279
 
594
- # Common pattern rescue unexpected exceptions
595
- rescue StandardError => e
596
- Rails.logger.error(e)
597
- render_server_error(message: "An unexpected error occurred",errors: { server: [e.message] })
598
- ```
280
+ ### 2xxSuccess
599
281
 
600
- #### `render_not_implemented` 501
601
- ```ruby
602
- # Message only
603
- render_not_implemented(message: "CSV export is coming soon")
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 |
604
288
 
605
- # Message + errors
606
- render_not_implemented(message: "CSV export is coming soon",errors: { format: ["csv export is not yet implemented, use json"] })
607
- ```
289
+ > *Rails renders 200 with a JSON body for `render_no_content` to preserve consistent structure.
608
290
 
609
- #### `render_bad_gateway` — 502
610
291
  ```ruby
611
- # Message only
612
- render_bad_gateway(message: "Payment gateway is currently unavailable")
613
-
614
- # Message + errors
615
- render_bad_gateway(message: "Payment gateway is currently unavailable",errors: { gateway: ["Stripe returned a 502, please try again"] })
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")
616
296
  ```
617
297
 
618
- #### `render_service_unavailable`503
619
- ```ruby
620
- # Message only
621
- render_service_unavailable(message: "Down for maintenance. Back in 30 minutes.")
298
+ ### 4xxClient errors
622
299
 
623
- # Message + errors
624
- render_service_unavailable(message: "Down for maintenance. Back in 30 minutes.",errors: { service: ["scheduled maintenance window until 03:00 UTC"] },meta: { retry_after: 1800 })
625
- ```
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 |
626
309
 
627
- #### `render_gateway_timeout` — 504
628
310
  ```ruby
629
- # Message only
630
- render_gateway_timeout(message: "The payment processor did not respond in time.")
631
-
632
- # Message + errors
633
- render_gateway_timeout(message: "The payment processor did not respond in time.",errors: { gateway: ["upstream timeout after 30 seconds, you have not been charged"] })
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")
314
+ render_unprocessable(message: "Validation failed", errors: user.errors)
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 })
634
317
  ```
635
318
 
636
- #### `render_http_version_not_supported`505
637
- ```ruby
638
- # Message only
639
- render_http_version_not_supported(message: "Only HTTP/1.1 and HTTP/2 are supported")
640
-
641
- # Message + errors
642
- render_http_version_not_supported(message: "Only HTTP/1.1 and HTTP/2 are supported",errors: { http_version: ["HTTP/1.0 is not supported"] })
643
- ```
319
+ ### 5xxServer errors
644
320
 
645
- #### `render_variant_also_negotiates` — 506
646
321
  ```ruby
647
- # Message only
648
- render_variant_also_negotiates(message: "Server content-negotiation loop detected")
649
-
650
- # Message + errors
651
- render_variant_also_negotiates(message: "Server content-negotiation loop detected",errors: { variant: ["misconfigured content negotiation caused an infinite loop"] })
322
+ render_server_error(message: "Something went wrong. Our team has been notified.")
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")
652
325
  ```
653
326
 
654
- #### `render_insufficient_storage` — 507
655
- ```ruby
656
- # Message only
657
- render_insufficient_storage(message: "Disk quota exceeded on this node")
658
-
659
- # Message + errors
660
- render_insufficient_storage(message: "Disk quota exceeded on this node",errors: { storage: ["upload failed, node has 0 bytes remaining"] })
661
- ```
327
+ <details>
328
+ <summary><strong>Full list of all helpers (click to expand)</strong></summary>
662
329
 
663
- #### `render_loop_detected`508
664
- ```ruby
665
- # Message only
666
- render_loop_detected(message: "Infinite redirect loop detected")
330
+ #### 1xxInformational
331
+ `render_continue` · `render_switching_protocols` · `render_processing` · `render_early_hints`
667
332
 
668
- # Message + errors
669
- render_loop_detected(message: "Infinite redirect loop detected",errors: { redirect: ["request visited the same URL more than 10 times"] })
670
- ```
333
+ #### 2xx Success
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`
671
335
 
672
- #### `render_not_extended`510
673
- ```ruby
674
- # Message only
675
- render_not_extended(message: "Further extensions required to fulfil this request")
336
+ #### 3xxRedirect
337
+ `render_multiple_choices` · `render_moved_permanently` · `render_found` · `render_see_other` · `render_not_modified` · `render_temporary_redirect` · `render_permanent_redirect`
676
338
 
677
- # Message + errors
678
- render_not_extended(message: "Further extensions required to fulfil this request",errors: { extension: ["mandatory extension 'auth' is missing from the request"] })
679
- ```
339
+ #### 4xx Client 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`
680
341
 
681
- #### `render_network_authentication_required`511
682
- ```ruby
683
- # Message only
684
- render_network_authentication_required(message: "Sign in to the network portal first")
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`
685
344
 
686
- # Message + errors
687
- render_network_authentication_required(message: "Sign in to the network portal first",errors: { network: ["captive portal authentication required before accessing the API"] })
688
- ```
345
+ </details>
689
346
 
690
347
  ---
691
348
 
692
- ## Real-World Controller Examples
349
+ ## Testing your responses
693
350
 
694
- ```ruby
695
- class UsersController < ApplicationController
696
-
697
- def index
698
- page = (params[:page] || 1).to_i
699
- 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.
700
352
 
701
- @users = Kaminari.paginate_array(User.active).page(page).per(per_page)
353
+ ### RSpec
702
354
 
703
- render_ok(
704
- data: @users,
705
- message: "Users fetched",
706
- pagination: {
707
- per_page: per_page.to_i,
708
- current_page: @users.current_page,
709
- next_page: @users.next_page,
710
- prev_page: @users.prev_page,
711
- total_pages: @users.total_pages,
712
- total_count: @users.total_count
713
- }
714
- )
715
- end
716
-
717
- def show
718
- user = User.find(params[:id])
719
- render_ok(data: user, message: "User found")
720
- rescue ActiveRecord::RecordNotFound
721
- render_not_found(message: "User ##{params[:id]} not found",errors: { id: ["no user exists with ID #{params[:id]}"] })
722
- end
355
+ ```ruby
356
+ # spec/rails_helper.rb
357
+ require "respondo/testing/rspec"
723
358
 
724
- def create
725
- user = User.new(user_params)
726
- if user.save
727
- render_created(data: user, message: "Account created successfully")
728
- else
729
- 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")
730
365
  end
731
366
  end
732
367
 
733
- def update
734
- user = User.find(params[:id])
735
-
736
- unless user == current_user || current_user.admin?
737
- render_forbidden( message: "You can only update your own profile", errors: { profile: ["you do not have permission to update this profile"] } )
738
- return
739
- end
740
-
741
- if user.update(user_params)
742
- render_ok(data: user, message: "Profile updated")
743
- else
744
- 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)
745
373
  end
746
374
  end
747
-
748
- def destroy
749
- User.find(params[:id]).destroy!
750
- render_no_content(message: "Account deleted")
751
- rescue ActiveRecord::RecordNotFound
752
- render_gone(message: "This account no longer exists")
753
- end
754
-
755
- end
756
-
757
- class PaymentsController < ApplicationController
758
-
759
- def create
760
- result = PaymentGateway.charge(amount: params[:amount], token: params[:token])
761
- render_created(data: result, message: "Payment successful")
762
- rescue PaymentGateway::CardDeclined => e
763
- render_unprocessable(message: e.message, errors: { card: [e.message] })
764
- rescue PaymentGateway::Timeout
765
- render_gateway_timeout( message: "Payment processor timed out. You have not been charged.", errors: { gateway: ["upstream timeout, transaction was not processed"] } )
766
- rescue PaymentGateway::Error => e
767
- render_bad_gateway( message: "Payment gateway error: #{e.message}", errors: { gateway: [e.message] })
768
- end
769
-
770
- end
771
-
772
- class ReportsController < ApplicationController
773
-
774
- def generate
775
- ReportJob.perform_later(current_user.id, params[:type])
776
- render_accepted(
777
- data: { estimated_time: "2 minutes" },
778
- message: "Report is being generated. We will email you when it is ready."
779
- )
780
- end
781
-
782
- end
783
- ```
784
-
785
- ---
786
-
787
- ## Pagination
788
-
789
- Respondo does **not** paginate data for you — your pagination library does that.
790
- You build the pagination hash yourself and pass it via `pagination:`. This keeps the gem simple, dependency-free, and works with any library.
791
-
792
- ### Kaminari
793
-
794
- ```ruby
795
- def index
796
- @users = User.page(params[:page]).per(25)
797
-
798
- render_ok(
799
- data: @users,
800
- message: "Users fetched",
801
- pagination: {
802
- current_page: @users.current_page,
803
- per_page: @users.limit_value,
804
- total_pages: @users.total_pages,
805
- total_count: @users.total_count,
806
- next_page: @users.next_page,
807
- prev_page: @users.prev_page
808
- }
809
- )
810
- end
811
- ```
812
-
813
- ### Pagy
814
-
815
- ```ruby
816
- def index
817
- @pagy, @users = pagy(User.all, items: 25)
818
-
819
- render_ok(
820
- data: @users,
821
- message: "Users fetched",
822
- pagination: {
823
- current_page: @pagy.page,
824
- per_page: @pagy.items,
825
- total_pages: @pagy.pages,
826
- total_count: @pagy.count,
827
- next_page: @pagy.next,
828
- prev_page: @pagy.prev
829
- }
830
- )
831
375
  end
832
376
  ```
833
377
 
834
- ### WillPaginate
378
+ ### Minitest
835
379
 
836
380
  ```ruby
837
- def index
838
- @users = User.paginate(page: params[:page], per_page: 25)
839
-
840
- render_ok(
841
- data: @users,
842
- message: "Users fetched",
843
- pagination: {
844
- current_page: @users.current_page,
845
- per_page: @users.per_page,
846
- total_pages: @users.total_pages,
847
- total_count: @users.total_entries,
848
- next_page: @users.next_page,
849
- prev_page: @users.previous_page
850
- }
851
- )
852
- end
853
- ```
854
-
855
- ### No pagination
856
-
857
- ```ruby
858
- render_ok(data: @user, message: "User found")
859
- # → meta will have no pagination key at all
860
- ```
861
-
862
- ---
863
-
864
- ## Quick Reference Card
865
-
866
- ```ruby
867
- # Core
868
- render_success(data:, message:, meta:, pagination:, code:, status:)
869
- render_error(message:, errors:, code:, meta:, status:)
870
-
871
- # 1xx — Informational
872
- render_continue(message:, meta:)
873
- render_switching_protocols(message:, meta:)
874
- render_processing(message:, meta:)
875
- render_early_hints(message:, meta:)
876
-
877
- # 2xx — Success
878
- render_success(data:, message:, meta:, pagination:, code:, status:)
879
- render_ok(data:, message:, meta:, pagination:)
880
- render_created(data:, message:, meta:, pagination:)
881
- render_accepted(data:, message:, meta:, pagination:)
882
- render_non_authoritative(data:, message:, meta:, pagination:)
883
- render_no_content(message:, meta:, pagination:)
884
- render_reset_content(message:, meta:, pagination:)
885
- render_partial_content(data:, message:, meta:, pagination:)
886
- render_multi_status(data:, message:, meta:, pagination:)
887
- render_already_reported(data:, message:, meta:, pagination:)
888
- render_im_used(data:, message:, meta:, pagination:)
889
-
890
- # 3xx — Redirects
891
- render_multiple_choices(data:, message:, meta:, pagination:)
892
- render_moved_permanently(message:, meta:, pagination:)
893
- render_found(message:, meta:, pagination:)
894
- render_see_other(message:, meta:, pagination:)
895
- render_not_modified(message:, meta:, pagination:)
896
- render_temporary_redirect(message:, meta:, pagination:)
897
- render_permanent_redirect(message:, meta:, pagination:)
898
-
899
- # 4xx — Client Errors
900
- render_bad_request(message:, errors:, meta:)
901
- render_unauthorized(message:, errors:, meta:)
902
- render_payment_required(message:, errors:, meta:)
903
- render_forbidden(message:, errors:, meta:)
904
- render_not_found(message:, errors:, meta:)
905
- render_method_not_allowed(message:, errors:, meta:)
906
- render_not_acceptable(message:, errors:, meta:)
907
- render_proxy_auth_required(message:, errors:, meta:)
908
- render_request_timeout(message:, errors:, meta:)
909
- render_conflict(message:, errors:, meta:)
910
- render_gone(message:, errors:, meta:)
911
- render_length_required(message:, errors:, meta:)
912
- render_precondition_failed(message:, errors:, meta:)
913
- render_payload_too_large(message:, errors:, meta:)
914
- render_uri_too_long(message:, errors:, meta:)
915
- render_unsupported_media_type(message:, errors:, meta:)
916
- render_range_not_satisfiable(message:, errors:, meta:)
917
- render_expectation_failed(message:, errors:, meta:)
918
- render_im_a_teapot(message:, errors:, meta:)
919
- render_misdirected_request(message:, errors:, meta:)
920
- render_unprocessable(message:, errors:, meta:)
921
- render_locked(message:, errors:, meta:)
922
- render_failed_dependency(message:, errors:, meta:)
923
- render_too_early(message:, errors:, meta:)
924
- render_upgrade_required(message:, errors:, meta:)
925
- render_precondition_required(message:, errors:, meta:)
926
- render_too_many_requests(message:, errors:, meta:)
927
- render_request_header_fields_too_large(message:, errors:, meta:)
928
- render_unavailable_for_legal_reasons(message:, errors:, meta:)
929
-
930
- # 5xx — Server Errors
931
- render_server_error(message:, errors:, meta:)
932
- render_not_implemented(message:, errors:, meta:)
933
- render_bad_gateway(message:, errors:, meta:)
934
- render_service_unavailable(message:, errors:, meta:)
935
- render_gateway_timeout(message:, errors:, meta:)
936
- render_http_version_not_supported(message:, errors:, meta:)
937
- render_variant_also_negotiates(message:, errors:, meta:)
938
- render_insufficient_storage(message:, errors:, meta:)
939
- render_loop_detected(message:, errors:, meta:)
940
- render_not_extended(message:, errors:, meta:)
941
- render_network_authentication_required(message:, errors:, meta:)
942
- ```
943
-
944
- ---
945
-
946
- ## Auto-Serialization
947
-
948
- Respondo automatically handles:
949
-
950
- | Input type | Output |
951
- |----------------------------------|-------------------------------------|
952
- | `ActiveRecord::Base` instance | `record.as_json` |
953
- | `ActiveRecord::Relation` | Array of `as_json` records |
954
- | `ActiveModel::Errors` | `{ field: ["message", ...] }` |
955
- | `Hash` | Passed through (values serialized) |
956
- | `Array` | Each element serialized recursively |
957
- | `Exception` | `{ message: e.message }` |
958
- | Anything with `#as_json` | `.as_json` |
959
- | Anything with `#to_h` | `.to_h` |
960
- | Primitives (String, Integer...) | As-is |
961
-
962
- ### Custom serializer
381
+ # test/test_helper.rb
382
+ require "respondo/testing/minitest"
963
383
 
964
- ```ruby
965
- Respondo.configure do |config|
966
- # Use ActiveModelSerializers, Blueprinter, Panko, etc.
967
- 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
968
390
  end
969
391
  ```
970
392
 
971
393
  ---
972
394
 
973
- ## camelCase for Flutter / JavaScript
395
+ ## What's next
974
396
 
975
- ```ruby
976
- Respondo.configure { |c| c.camelize_keys = true }
977
- ```
397
+ - [ ] ActiveModelSerializers / Blueprinter auto-integration
398
+ - [ ] OpenAPI / Swagger schema generation from Respondo helpers
399
+ - [ ] Rack middleware for zero-config global exception handling
978
400
 
979
- All keys in the response including nested `meta.pagination` — are camelized:
980
- `current_page` → `currentPage`, `total_count` → `totalCount`, `next_page` → `nextPage`, `error_code` → `errorCode`.
981
-
982
- ### Flutter Integration
983
-
984
- ```dart
985
- // Every response follows the same shape
986
- class ApiResponse<T> {
987
- final bool success;
988
- final String message;
989
- final T? data;
990
- final Map<String, dynamic> meta;
991
- final Map<String, dynamic>? errors;
992
-
993
- const ApiResponse({
994
- required this.success,
995
- required this.message,
996
- this.data,
997
- required this.meta,
998
- this.errors,
999
- });
1000
- }
1001
- ```
401
+ Have a feature idea? [Open an issue →](https://github.com/spatelpatidar/respondo/issues)
1002
402
 
1003
403
  ---
1004
404
 
1005
- ## Architecture
1006
-
1007
- ```
1008
- lib/
1009
- ├── respondo.rb # Entry point, configure, Railtie hook
1010
- ├── respondo/
1011
- │ ├── version.rb # VERSION
1012
- │ ├── configuration.rb # Config with defaults
1013
- │ ├── serializer.rb # Auto-detects and serializes any object
1014
- │ ├── response_builder.rb # Assembles the final Hash
1015
- │ ├── controller_helpers.rb # All render_* helpers (1xx–5xx)
1016
- │ └── railtie.rb # Auto-includes into Rails controllers
1017
- └── generators/
1018
- └── respondo/
1019
- └── install/
1020
- └── install_generator.rb # rails generate respondo:install
1021
- ```
1022
-
1023
- ---
405
+ ## Contributing
1024
406
 
1025
- ## Running Tests
407
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/spatelpatidar/respondo).
1026
408
 
1027
409
  ```bash
410
+ git clone https://github.com/spatelpatidar/respondo
411
+ cd respondo
1028
412
  bundle install
1029
- bundle exec rspec --format documentation
413
+ bundle exec rspec
1030
414
  ```
1031
415
 
1032
416
  ---
1033
417
 
1034
418
  ## License
1035
419
 
1036
- 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>