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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/README.md +268 -878
- data/lib/generators/respondo/install/install_generator.rb +3 -1
- data/lib/respondo/controller_helpers.rb +53 -53
- data/lib/respondo/response_builder.rb +1 -1
- data/lib/respondo/version.rb +1 -1
- metadata +2 -2
data/README.md
CHANGED
|
@@ -1,9 +1,36 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# Respondo 🎯
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
**Consistent JSON API responses for Rails — in one line.**
|
|
6
|
+
|
|
7
|
+
[](https://rubygems.org/gems/respondo)
|
|
8
|
+
[](https://rubygems.org/gems/respondo)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
Default success message
|
|
68
|
-
› [Success]:
|
|
69
|
-
|
|
70
|
-
Default error message
|
|
71
|
-
› [An error occurred]:
|
|
72
|
-
|
|
73
|
-
...
|
|
74
|
-
```
|
|
63
|
+
---
|
|
75
64
|
|
|
76
|
-
|
|
65
|
+
## Your first response (30 seconds)
|
|
77
66
|
|
|
78
67
|
```ruby
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
90
|
+
Your frontend now gets a **guaranteed** structure — forever.
|
|
128
91
|
|
|
129
92
|
---
|
|
130
93
|
|
|
131
|
-
##
|
|
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
|
-
|
|
|
145
|
-
|
|
146
|
-
|
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
##
|
|
153
|
-
|
|
154
|
-
### 1xx — Informational Helpers
|
|
107
|
+
## What every response looks like
|
|
155
108
|
|
|
156
|
-
|
|
109
|
+
Every response — success or error — has the same four keys:
|
|
157
110
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
165
|
+
def create
|
|
166
|
+
@post = current_user.posts.build(post_params)
|
|
262
167
|
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
```ruby
|
|
286
|
-
render_see_other(message: "See the canonical resource", meta: { redirect_url: canonical_url })
|
|
189
|
+
end
|
|
287
190
|
```
|
|
288
191
|
|
|
289
|
-
|
|
290
|
-
```ruby
|
|
291
|
-
render_not_modified(message: "Your cached version is still valid")
|
|
292
|
-
```
|
|
192
|
+
## Auto-Serialization
|
|
293
193
|
|
|
294
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
rescue StandardError => e
|
|
596
|
-
Rails.logger.error(e)
|
|
597
|
-
render_server_error(message: "An unexpected error occurred",errors: { server: [e.message] })
|
|
598
|
-
```
|
|
280
|
+
### 2xx — Success
|
|
599
281
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
619
|
-
```ruby
|
|
620
|
-
# Message only
|
|
621
|
-
render_service_unavailable(message: "Down for maintenance. Back in 30 minutes.")
|
|
298
|
+
### 4xx — Client errors
|
|
622
299
|
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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
|
+
### 5xx — Server errors
|
|
644
320
|
|
|
645
|
-
#### `render_variant_also_negotiates` — 506
|
|
646
321
|
```ruby
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
655
|
-
|
|
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
|
-
####
|
|
664
|
-
|
|
665
|
-
# Message only
|
|
666
|
-
render_loop_detected(message: "Infinite redirect loop detected")
|
|
330
|
+
#### 1xx — Informational
|
|
331
|
+
`render_continue` · `render_switching_protocols` · `render_processing` · `render_early_hints`
|
|
667
332
|
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
####
|
|
673
|
-
|
|
674
|
-
# Message only
|
|
675
|
-
render_not_extended(message: "Further extensions required to fulfil this request")
|
|
336
|
+
#### 3xx — Redirect
|
|
337
|
+
`render_multiple_choices` · `render_moved_permanently` · `render_found` · `render_see_other` · `render_not_modified` · `render_temporary_redirect` · `render_permanent_redirect`
|
|
676
338
|
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
####
|
|
682
|
-
|
|
683
|
-
# Message only
|
|
684
|
-
render_network_authentication_required(message: "Sign in to the network portal first")
|
|
342
|
+
#### 5xx — Server 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
|
-
|
|
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
|
-
##
|
|
349
|
+
## Testing your responses
|
|
693
350
|
|
|
694
|
-
|
|
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
|
-
|
|
353
|
+
### RSpec
|
|
702
354
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
###
|
|
378
|
+
### Minitest
|
|
835
379
|
|
|
836
380
|
```ruby
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
##
|
|
395
|
+
## What's next
|
|
974
396
|
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
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>
|