respondo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 94ebeacf9ef38e8d7d2c41182edca0cd2ae55b0520e93a5900dafea65e84584d
4
+ data.tar.gz: 4e185739d3eb720712a9c0264558a7669d1f6bdd420d474ce9b68a85409ddf45
5
+ SHA512:
6
+ metadata.gz: 560d4983326902e3c8c5859af0105142a8799aeeeda2a4c6036917db393f117fc3d885cc8389ec6ad6f24b6a0846d4ad5262809e5b37af4c2c63dcdceb7a6059
7
+ data.tar.gz: 35a6a092f71d876b30c749ce92dfeff9414689898aa12adb2c1c74466b2e9df233763f446745d93fdcb63c9a4a9f7b1332aa1c2a743fe6683afb804a0e713f35
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] — Initial Release
4
+
5
+ ### Added
6
+ - Standardized JSON response structure: `success`, `message`, `data`, `meta`
7
+ - `render_success` — 200 OK with optional data, message, meta, pagy
8
+ - `render_error` — 422 default with field errors, machine-readable code
9
+ - Convenience helpers: `render_unauthorized`, `render_forbidden`, `render_not_found`, `render_server_error`, `render_created`, `render_no_content`
10
+ - Auto-serialization: ActiveRecord, Relation, ActiveModel::Errors, Hash, Array, Exception
11
+ - Pagination meta: Kaminari, Pagy, WillPaginate — auto-detected, no extra code
12
+ - `camelize_keys` config — snake_case → camelCase for Flutter/JS clients
13
+ - `include_request_id` config — adds Rails request ID to every meta block
14
+ - Custom serializer hook — plug in ActiveModelSerializers, Blueprinter, Panko, etc.
15
+ - Railtie — auto-includes into ActionController::Base and ActionController::API
16
+ - Zero hard dependencies — Rails, Kaminari, Pagy are all optional
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RESPONDO AUTHORS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,564 @@
1
+ # Respondo 🎯
2
+
3
+ Smart JSON API response formatter for Rails — consistent structure every time, across every app.
4
+
5
+ ```json
6
+ {
7
+ "success": true,
8
+ "message": "Users fetched",
9
+ "data": [...],
10
+ "meta": {
11
+ "timestamp": "2024-06-15T10:30:00Z",
12
+ "pagination": {
13
+ "currentPage": 1,
14
+ "perPage": 25,
15
+ "totalPages": 4,
16
+ "totalCount": 98,
17
+ "nextPage": 2,
18
+ "prevPage": null
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ ## The Problem
25
+
26
+ 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.
27
+
28
+ Respondo enforces one structure, everywhere, automatically.
29
+
30
+ ---
31
+
32
+ ## Installation
33
+
34
+ ```ruby
35
+ gem "respondo"
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Setup
41
+
42
+ ```ruby
43
+ # config/initializers/respondo.rb
44
+ Respondo.configure do |config|
45
+ config.default_success_message = "OK"
46
+ config.default_error_message = "Something went wrong"
47
+ config.include_request_id = true # adds request_id to every meta
48
+ config.camelize_keys = true # snake_case → camelCase for Flutter/JS
49
+ end
50
+ ```
51
+
52
+ Respondo auto-includes itself into `ActionController::Base` and `ActionController::API` via Railtie. No manual `include` needed in `ApplicationController`.
53
+
54
+ ---
55
+
56
+ ## Response Structure
57
+
58
+ Every single response — success or error — returns the same four keys:
59
+
60
+ | Key | Type | Description |
61
+ |-----------|------------------|----------------------------------------------|
62
+ | `success` | Boolean | `true` or `false` |
63
+ | `message` | String | Human-readable description |
64
+ | `data` | Object/Array/nil | The payload |
65
+ | `meta` | Object | Timestamp + pagination + optional request_id |
66
+
67
+ Error responses additionally include:
68
+
69
+ | Key | Type | Description |
70
+ |----------|------|--------------------------------------|
71
+ | `errors` | Hash | Field-level errors `{field: [msgs]}` |
72
+
73
+ ---
74
+
75
+ ## Complete Helper Reference
76
+
77
+ ### 2xx — Success Helpers
78
+
79
+ #### `render_ok` — 200 OK
80
+ Explicit alias for `render_success`. Use when you want to be more descriptive.
81
+
82
+ ```ruby
83
+ render_ok(data: @user, message: "User found")
84
+ ```
85
+
86
+ #### `render_created` — 201 Created
87
+ Use after a successful POST that creates a resource.
88
+
89
+ ```ruby
90
+ render_created(data: @post, message: "Post published")
91
+ render_created(data: @user) # uses default "Created successfully" message
92
+ ```
93
+
94
+ #### `render_accepted` — 202 Accepted
95
+ Use for async operations — the request was received but processing happens in the background.
96
+
97
+ ```ruby
98
+ render_accepted(message: "Your export is being processed. You will receive an email when ready.")
99
+ render_accepted(data: { job_id: "abc123" }, message: "Job queued")
100
+ ```
101
+
102
+ #### `render_no_content` — 200 OK
103
+ Use after DELETE or actions with no meaningful response body. Returns standard JSON structure for consistency.
104
+
105
+ ```ruby
106
+ render_no_content # "Deleted successfully"
107
+ render_no_content(message: "Account deactivated")
108
+ ```
109
+
110
+ #### `render_partial_content` — 206 Partial Content
111
+ Use for chunked or range-based responses.
112
+
113
+ ```ruby
114
+ render_partial_content(data: @chunk, message: "Page 1 of 5")
115
+ render_partial_content(data: @results, meta: { range: "0-99/500" })
116
+ ```
117
+
118
+ #### `render_multi_status` — 207 Multi-Status
119
+ Use for batch operations where some succeed and some fail.
120
+
121
+ ```ruby
122
+ render_multi_status(
123
+ data: { created: 8, failed: 2 },
124
+ message: "Batch completed with partial failures"
125
+ )
126
+ ```
127
+
128
+ ---
129
+
130
+ ### 4xx — Client Error Helpers
131
+
132
+ #### `render_bad_request` — 400 Bad Request
133
+ Malformed request, missing required parameters, invalid format.
134
+
135
+ ```ruby
136
+ render_bad_request # default message
137
+ render_bad_request("The 'date' parameter is required")
138
+ render_bad_request("Invalid input", errors: { date: ["must be a valid date"] })
139
+ render_bad_request("Invalid input", code: "INVALID_FORMAT") # custom error code
140
+ ```
141
+
142
+ #### `render_unauthorized` — 401 Unauthorized
143
+ User is not authenticated. Use when no valid token/session is present.
144
+
145
+ ```ruby
146
+ render_unauthorized # "Unauthorized"
147
+ render_unauthorized("Please log in to continue")
148
+ render_unauthorized("Token has expired", code: "TOKEN_EXPIRED")
149
+ ```
150
+
151
+ #### `render_payment_required` — 402 Payment Required
152
+ Feature is behind a paywall or subscription.
153
+
154
+ ```ruby
155
+ render_payment_required
156
+ render_payment_required("Upgrade to Pro to access this feature")
157
+ render_payment_required("Subscription expired", code: "SUBSCRIPTION_EXPIRED")
158
+ ```
159
+
160
+ #### `render_forbidden` — 403 Forbidden
161
+ User is authenticated but lacks permission for this action.
162
+
163
+ ```ruby
164
+ render_forbidden
165
+ render_forbidden("You can only edit your own posts")
166
+ render_forbidden("Admin access required", code: "ADMIN_REQUIRED")
167
+ ```
168
+
169
+ #### `render_not_found` — 404 Not Found
170
+ Requested resource does not exist.
171
+
172
+ ```ruby
173
+ render_not_found
174
+ render_not_found("User not found")
175
+ render_not_found("Post ##{params[:id]} does not exist")
176
+ ```
177
+
178
+ #### `render_method_not_allowed` — 405 Method Not Allowed
179
+ The HTTP verb used is not supported for this endpoint.
180
+
181
+ ```ruby
182
+ render_method_not_allowed
183
+ render_method_not_allowed("This endpoint only accepts POST requests")
184
+ ```
185
+
186
+ #### `render_not_acceptable` — 406 Not Acceptable
187
+ The server cannot produce a response matching the client's Accept header.
188
+
189
+ ```ruby
190
+ render_not_acceptable
191
+ render_not_acceptable("Only application/json is supported")
192
+ ```
193
+
194
+ #### `render_request_timeout` — 408 Request Timeout
195
+ The request took too long to process.
196
+
197
+ ```ruby
198
+ render_request_timeout
199
+ render_request_timeout("The query took too long. Try a smaller date range.")
200
+ ```
201
+
202
+ #### `render_conflict` — 409 Conflict
203
+ Request conflicts with the current state of the resource. Use for duplicate records, state conflicts.
204
+
205
+ ```ruby
206
+ render_conflict
207
+ render_conflict("Email address is already registered")
208
+ render_conflict("Cannot cancel a completed order", code: "INVALID_STATE_TRANSITION")
209
+ render_conflict("Duplicate entry", errors: { email: ["has already been taken"] })
210
+ ```
211
+
212
+ #### `render_gone` — 410 Gone
213
+ Resource existed but has been permanently deleted.
214
+
215
+ ```ruby
216
+ render_gone
217
+ render_gone("This account has been permanently deleted")
218
+ ```
219
+
220
+ #### `render_precondition_failed` — 412 Precondition Failed
221
+ Conditional request headers (If-Match, If-None-Match) did not match.
222
+
223
+ ```ruby
224
+ render_precondition_failed
225
+ render_precondition_failed("Resource has been modified since your last request")
226
+ ```
227
+
228
+ #### `render_unsupported_media_type` — 415 Unsupported Media Type
229
+ The Content-Type header is not supported.
230
+
231
+ ```ruby
232
+ render_unsupported_media_type
233
+ render_unsupported_media_type("Please send requests as application/json")
234
+ ```
235
+
236
+ #### `render_unprocessable` — 422 Unprocessable Entity
237
+ Validation errors. The most commonly used error helper in Rails APIs.
238
+
239
+ ```ruby
240
+ render_unprocessable("Validation failed", errors: user.errors)
241
+ render_unprocessable("Invalid data", errors: { name: ["can't be blank"], age: ["must be over 18"] })
242
+ ```
243
+
244
+ #### `render_locked` — 423 Locked
245
+ Resource is locked and cannot be modified.
246
+
247
+ ```ruby
248
+ render_locked
249
+ render_locked("This record is locked by another user")
250
+ render_locked("Invoice is locked after approval", code: "INVOICE_LOCKED")
251
+ ```
252
+
253
+ #### `render_too_many_requests` — 429 Too Many Requests
254
+ Rate limit exceeded.
255
+
256
+ ```ruby
257
+ render_too_many_requests
258
+ render_too_many_requests("You have exceeded 100 requests per minute. Retry after 60 seconds.")
259
+ render_too_many_requests("API limit reached", code: "API_LIMIT_EXCEEDED")
260
+ ```
261
+
262
+ ---
263
+
264
+ ### 5xx — Server Error Helpers
265
+
266
+ #### `render_server_error` — 500 Internal Server Error
267
+ An unexpected error occurred on the server.
268
+
269
+ ```ruby
270
+ render_server_error
271
+ render_server_error("Something went wrong. Our team has been notified.")
272
+
273
+ # Common pattern — rescue unexpected exceptions
274
+ rescue StandardError => e
275
+ Rails.logger.error(e)
276
+ render_server_error("An unexpected error occurred")
277
+ ```
278
+
279
+ #### `render_not_implemented` — 501 Not Implemented
280
+ The requested feature has not been built yet.
281
+
282
+ ```ruby
283
+ render_not_implemented
284
+ render_not_implemented("CSV export is coming soon")
285
+ ```
286
+
287
+ #### `render_bad_gateway` — 502 Bad Gateway
288
+ An upstream service (third-party API, microservice) returned an invalid response.
289
+
290
+ ```ruby
291
+ render_bad_gateway
292
+ render_bad_gateway("Payment gateway is currently unavailable")
293
+ render_bad_gateway("Could not reach the SMS service", code: "SMS_GATEWAY_ERROR")
294
+ ```
295
+
296
+ #### `render_service_unavailable` — 503 Service Unavailable
297
+ Server is temporarily unable to handle the request — maintenance, overloaded.
298
+
299
+ ```ruby
300
+ render_service_unavailable
301
+ render_service_unavailable("We are currently under maintenance. Back in 30 minutes.")
302
+ ```
303
+
304
+ #### `render_gateway_timeout` — 504 Gateway Timeout
305
+ An upstream service timed out before responding.
306
+
307
+ ```ruby
308
+ render_gateway_timeout
309
+ render_gateway_timeout("The payment processor did not respond in time. Please try again.")
310
+ ```
311
+
312
+ ---
313
+
314
+ ## Real-World Controller Examples
315
+
316
+ ```ruby
317
+ class UsersController < ApplicationController
318
+
319
+ def index
320
+ users = User.active.page(params[:page]).per(25)
321
+ render_ok(data: users, message: "Users fetched")
322
+ # → 200, with pagination meta auto-included
323
+ end
324
+
325
+ def show
326
+ user = User.find(params[:id])
327
+ render_ok(data: user, message: "User found")
328
+ rescue ActiveRecord::RecordNotFound
329
+ render_not_found("User ##{params[:id]} not found")
330
+ # → 404, { success: false, errors_code: "NOT_FOUND" }
331
+ end
332
+
333
+ def create
334
+ user = User.new(user_params)
335
+ if user.save
336
+ render_created(data: user, message: "Account created successfully")
337
+ # → 201
338
+ else
339
+ render_unprocessable("Validation failed", errors: user.errors)
340
+ # → 422, { errors: { email: ["is invalid"] } }
341
+ end
342
+ end
343
+
344
+ def update
345
+ user = User.find(params[:id])
346
+
347
+ unless user == current_user || current_user.admin?
348
+ render_forbidden("You can only update your own profile")
349
+ # → 403
350
+ return
351
+ end
352
+
353
+ if user.update(user_params)
354
+ render_ok(data: user, message: "Profile updated")
355
+ else
356
+ render_conflict("Could not update profile", errors: user.errors)
357
+ # → 409
358
+ end
359
+ end
360
+
361
+ def destroy
362
+ User.find(params[:id]).destroy!
363
+ render_no_content(message: "Account deleted")
364
+ # → 200
365
+ rescue ActiveRecord::RecordNotFound
366
+ render_gone("This account no longer exists")
367
+ # → 410
368
+ end
369
+
370
+ end
371
+
372
+ class PaymentsController < ApplicationController
373
+
374
+ def create
375
+ result = PaymentGateway.charge(amount: params[:amount], token: params[:token])
376
+ render_created(data: result, message: "Payment successful")
377
+ rescue PaymentGateway::CardDeclined => e
378
+ render_unprocessable(e.message)
379
+ rescue PaymentGateway::Timeout
380
+ render_gateway_timeout("Payment processor timed out. You have not been charged.")
381
+ rescue PaymentGateway::Error => e
382
+ render_bad_gateway("Payment gateway error: #{e.message}")
383
+ end
384
+
385
+ end
386
+
387
+ class ReportsController < ApplicationController
388
+
389
+ def generate
390
+ ReportJob.perform_later(current_user.id, params[:type])
391
+ render_accepted(
392
+ data: { estimated_time: "2 minutes" },
393
+ message: "Report is being generated. We will email you when it is ready."
394
+ )
395
+ # → 202
396
+ end
397
+
398
+ end
399
+ ```
400
+
401
+ ---
402
+
403
+ ## Quick Reference Card
404
+
405
+ ```ruby
406
+ # 2xx — Success
407
+ render_success(data:, message:, meta:, pagy:, pagination:,code: , status:)
408
+ render_ok(data:, message:, meta:, pagination:)
409
+ render_created(data:, message:, pagination:)
410
+ render_accepted(data:, message:)
411
+ render_no_content(message:)
412
+ render_partial_content(data:, message:, meta:)
413
+ render_multi_status(data:, message:, meta:)
414
+
415
+ # 4xx — Client Errors
416
+ render_bad_request(message, errors:, code:)
417
+ render_unauthorized(message, code:)
418
+ render_payment_required(message, code:)
419
+ render_forbidden(message, code:)
420
+ render_not_found(message, code:)
421
+ render_method_not_allowed(message, code:)
422
+ render_not_acceptable(message, code:)
423
+ render_request_timeout(message, code:)
424
+ render_conflict(message, errors:, code:)
425
+ render_gone(message, code:)
426
+ render_precondition_failed(message, code:)
427
+ render_unsupported_media_type(message, code:)
428
+ render_unprocessable(message, errors:)
429
+ render_locked(message, code:)
430
+ render_too_many_requests(message, code:)
431
+
432
+ # 5xx — Server Errors
433
+ render_server_error(message, code:)
434
+ render_not_implemented(message, code:)
435
+ render_bad_gateway(message, code:)
436
+ render_service_unavailable(message, code:)
437
+ render_gateway_timeout(message, code:)
438
+ ```
439
+
440
+ ---
441
+
442
+ ## Auto-Serialization
443
+
444
+ Respondo automatically handles:
445
+
446
+ | Input type | Output |
447
+ |---------------------------------|---------------------------------------------|
448
+ | `ActiveRecord::Base` instance | `record.as_json` |
449
+ | `ActiveRecord::Relation` | Array of `as_json` records |
450
+ | `ActiveModel::Errors` | `{ field: ["message", ...] }` |
451
+ | `Hash` | Passed through (values serialized) |
452
+ | `Array` | Each element serialized recursively |
453
+ | `Exception` | `{ message: e.message }` |
454
+ | Anything with `#as_json` | `.as_json` |
455
+ | Anything with `#to_h` | `.to_h` |
456
+ | Primitives (String, Integer...) | As-is |
457
+
458
+ ### Custom serializer
459
+
460
+ ```ruby
461
+ Respondo.configure do |config|
462
+ # Use ActiveModelSerializers, Blueprinter, Panko, etc.
463
+ config.serializer = ->(obj) { UserSerializer.new(obj).as_json }
464
+ end
465
+ ```
466
+
467
+ ---
468
+
469
+ ## Pagination Meta
470
+
471
+ Automatically detected — no extra code needed.
472
+
473
+ ### Kaminari
474
+
475
+ ```ruby
476
+ users = User.page(params[:page]).per(25)
477
+ render_ok(data: users)
478
+ # meta.pagination is populated automatically
479
+ ```
480
+
481
+ ### Pagy
482
+
483
+ ```ruby
484
+ pagy, users = pagy(User.all)
485
+ render_ok(data: users, pagy: pagy)
486
+ ```
487
+
488
+ ### WillPaginate
489
+
490
+ ```ruby
491
+ users = User.paginate(page: params[:page], per_page: 25)
492
+ render_ok(data: users)
493
+ ```
494
+
495
+ ### Suppress pagination
496
+
497
+ ```ruby
498
+ # Even if the collection is paginated, hide the meta
499
+ render_ok(data: users, pagination: false)
500
+ ```
501
+
502
+ ---
503
+
504
+ ## camelCase for Flutter / JavaScript
505
+
506
+ ```ruby
507
+ Respondo.configure { |c| c.camelize_keys = true }
508
+ ```
509
+
510
+ All keys in the response — including nested `meta.pagination` — are camelized:
511
+ `current_page` → `currentPage`, `total_count` → `totalCount`, `error_code` → `errorCode`.
512
+
513
+ ### Flutter Integration
514
+
515
+ ```dart
516
+ // Every response follows the same shape
517
+ class ApiResponse<T> {
518
+ final bool success;
519
+ final String message;
520
+ final T? data;
521
+ final Map<String, dynamic> meta;
522
+ final Map<String, dynamic>? errors;
523
+
524
+ const ApiResponse({
525
+ required this.success,
526
+ required this.message,
527
+ this.data,
528
+ required this.meta,
529
+ this.errors,
530
+ });
531
+ }
532
+ ```
533
+
534
+ ---
535
+
536
+ ## Architecture
537
+
538
+ ```
539
+ lib/
540
+ ├── respondo.rb # Entry point, configure, Railtie hook
541
+ └── respondo/
542
+ ├── version.rb # VERSION
543
+ ├── configuration.rb # Config with defaults
544
+ ├── serializer.rb # Auto-detects and serializes any object
545
+ ├── pagination.rb # Kaminari / Pagy / WillPaginate extractor
546
+ ├── response_builder.rb # Assembles the final Hash
547
+ ├── controller_helpers.rb # All render_* helpers (2xx, 4xx, 5xx)
548
+ └── railtie.rb # Auto-includes into Rails controllers
549
+ ```
550
+
551
+ ---
552
+
553
+ ## Running Tests
554
+
555
+ ```bash
556
+ bundle install
557
+ bundle exec rspec --format documentation
558
+ ```
559
+
560
+ ---
561
+
562
+ ## License
563
+
564
+ MIT
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Respondo
4
+ # Global configuration for Respondo.
5
+ #
6
+ # @example
7
+ # Respondo.configure do |config|
8
+ # config.default_success_message = "OK"
9
+ # config.default_error_message = "Something went wrong"
10
+ # config.include_request_id = true
11
+ # config.camelize_keys = true
12
+ # end
13
+ class Configuration
14
+ # Message used when none is supplied to render_success
15
+ attr_accessor :default_success_message
16
+
17
+ # Message used when none is supplied to render_error
18
+ attr_accessor :default_error_message
19
+
20
+ # When true, includes request.request_id in every meta block (Rails only)
21
+ attr_accessor :include_request_id
22
+
23
+ # When true, all response keys are camelized (suits Flutter/JS clients)
24
+ attr_accessor :camelize_keys
25
+
26
+ # Custom serializer callable — receives (object) and must return a Hash.
27
+ # Defaults to nil (built-in serialization strategy is used).
28
+ # @example use ActiveModelSerializers
29
+ # config.serializer = ->(obj) { SomeSerializer.new(obj).as_json }
30
+ attr_accessor :serializer
31
+
32
+ def initialize
33
+ @default_success_message = "Success"
34
+ @default_error_message = "An error occurred"
35
+ @include_request_id = false
36
+ @camelize_keys = false
37
+ @serializer = nil
38
+ end
39
+ end
40
+ end