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