respondo 2.1.1 β 2.1.3
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 +118 -0
- data/README.md +271 -878
- data/lib/generators/respondo/install/install_generator.rb +351 -349
- 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 end up writing `if (res.data || res.result || res.users)` just to survive. The Front End team files bugs about the mismatch. Code review turns into an argument about whose format is "right."
|
|
5
32
|
|
|
6
|
-
|
|
33
|
+
Respondo fixes this: one response shape, enforced automatically across every controller. Every developer. Every time.
|
|
7
34
|
|
|
8
35
|
```json
|
|
9
36
|
{
|
|
@@ -12,1025 +39,391 @@ 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
|
+
That's it. No `include` in `ApplicationController`, no boilerplate β Respondo hooks in automatically via a Railtie.
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
βΊ [v1]:
|
|
64
|
-
|
|
65
|
-
ββ Response Messages βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
66
|
-
|
|
67
|
-
Default success message
|
|
68
|
-
βΊ [Success]:
|
|
69
|
-
|
|
70
|
-
Default error message
|
|
71
|
-
βΊ [An error occurred]:
|
|
72
|
-
|
|
73
|
-
...
|
|
74
|
-
```
|
|
63
|
+
---
|
|
75
64
|
|
|
76
|
-
|
|
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
|
+
Now your frontend can count on getting the same shape back every time.
|
|
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 |
|
|
94
|
+
## What changes
|
|
141
95
|
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
143
|
+
## Real-world controller (with pagination)
|
|
218
144
|
|
|
219
145
|
```ruby
|
|
220
|
-
|
|
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
|
-
```
|
|
146
|
+
class PostsController < ApplicationController
|
|
230
147
|
|
|
231
|
-
|
|
232
|
-
|
|
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.
|
|
241
|
-
|
|
242
|
-
```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
|
-
```
|
|
253
|
-
|
|
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
|
-
|
|
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.
|
|
231
|
+
### camelCase output (great for Flutter / React Native)
|
|
494
232
|
|
|
495
233
|
```ruby
|
|
496
|
-
|
|
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
|
-
```
|
|
505
|
-
|
|
506
|
-
#### `render_locked` β 423
|
|
507
|
-
```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
|
-
|
|
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 a 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", ...] }`.
|
|
276
|
+
## Complete HTTP helper reference
|
|
585
277
|
|
|
586
|
-
|
|
587
|
-
```ruby
|
|
588
|
-
# Message only
|
|
589
|
-
render_server_error(message: "Something went wrong. Our team has been notified.")
|
|
278
|
+
There's a helper for basically every HTTP status code. Here are the ones you'll actually reach for:
|
|
590
279
|
|
|
591
|
-
|
|
592
|
-
render_server_error(message: "Something went wrong. Our team has been notified.",errors: { server: ["unexpected exception in OrdersController#create"] })
|
|
280
|
+
### 2xx β Success
|
|
593
281
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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 |
|
|
599
288
|
|
|
600
|
-
|
|
601
|
-
```ruby
|
|
602
|
-
# Message only
|
|
603
|
-
render_not_implemented(message: "CSV export is coming soon")
|
|
289
|
+
> *Rails renders 200 with a JSON body for `render_no_content` to keep the response shape consistent.
|
|
604
290
|
|
|
605
|
-
# Message + errors
|
|
606
|
-
render_not_implemented(message: "CSV export is coming soon",errors: { format: ["csv export is not yet implemented, use json"] })
|
|
607
|
-
```
|
|
608
|
-
|
|
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")
|
|
319
|
+
### 5xx β Server errors
|
|
640
320
|
|
|
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
|
-
```
|
|
644
|
-
|
|
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
375
|
end
|
|
783
376
|
```
|
|
784
377
|
|
|
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
|
|
378
|
+
### Minitest
|
|
793
379
|
|
|
794
380
|
```ruby
|
|
795
|
-
|
|
796
|
-
|
|
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
|
|
381
|
+
# test/test_helper.rb
|
|
382
|
+
require "respondo/testing/minitest"
|
|
814
383
|
|
|
815
|
-
|
|
816
|
-
def
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
end
|
|
832
|
-
```
|
|
833
|
-
|
|
834
|
-
### WillPaginate
|
|
835
|
-
|
|
836
|
-
```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
|
|
963
|
-
|
|
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
|
-
##
|
|
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 your an hour, give it a Starβ.</sub>
|
|
426
|
+
</div>
|
|
427
|
+
<div align="center">
|
|
428
|
+
<sub>it helps other developers find it.</sub>
|
|
429
|
+
</div>
|