respondo 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -42,6 +42,78 @@ gem "respondo"
42
42
 
43
43
  ## Setup
44
44
 
45
+ ### ✅ Recommended — use the install generator
46
+
47
+ After adding the gem, run:
48
+
49
+ ```bash
50
+ rails generate respondo:install
51
+ ```
52
+
53
+ The interactive wizard walks you through every option and writes a fully commented `config/initializers/respondo.rb` tailored to your project. No need to read the full README or copy-paste config by hand.
54
+
55
+ ```
56
+ ┌─ Project Info ─────────────────────────────────────────────────────┐
57
+
58
+ Project / app name
59
+ (Used as a comment header in the initializer)
60
+ › [MyApp]:
61
+
62
+ API version (e.g. v1 — added to every response meta block)
63
+ › [v1]:
64
+
65
+ ┌─ Response Messages ────────────────────────────────────────────────┐
66
+
67
+ Default success message
68
+ › [Success]:
69
+
70
+ Default error message
71
+ › [An error occurred]:
72
+
73
+ ...
74
+ ```
75
+
76
+ The generator produces a file like this:
77
+
78
+ ```ruby
79
+ # frozen_string_literal: true
80
+
81
+ # Respondo initializer — MyApp
82
+ # Generated by: rails generate respondo:install
83
+ # Respondo version: 2.1.0
84
+
85
+ Respondo.configure do |config|
86
+
87
+ # ── Messages ─────────────────────────────────────────────────────────
88
+ config.default_success_message = "Success"
89
+ config.default_error_message = "Something went wrong"
90
+
91
+ # ── Request ID ───────────────────────────────────────────────────────
92
+ config.include_request_id = true
93
+
94
+ # ── Key Format ───────────────────────────────────────────────────────
95
+ config.camelize_keys = true
96
+
97
+ # ── Global Meta ──────────────────────────────────────────────────────
98
+ config.default_meta = {
99
+ api_version: "v1",
100
+ platform: "mobile"
101
+ }
102
+
103
+ # ── Custom Serializer ────────────────────────────────────────────────
104
+ # config.serializer = ->(obj) { MySerializer.new(obj).as_json }
105
+
106
+ end
107
+ ```
108
+
109
+ Re-run the generator any time to regenerate with different answers — it overwrites the existing initializer.
110
+
111
+ ---
112
+
113
+ ### Manual setup (alternative)
114
+
115
+ If you prefer to write the initializer yourself, create `config/initializers/respondo.rb`:
116
+
45
117
  ```ruby
46
118
  # config/initializers/respondo.rb
47
119
  Respondo.configure do |config|
@@ -71,8 +143,9 @@ Error responses additionally include:
71
143
 
72
144
  | Key | Type | Description |
73
145
  |----------|------|--------------------------------------|
74
- | `errors` | Hash | Field-level errors `{field: [msgs]}` |
75
- | `errors` | Hash | Field-level errors `{field: msgs}` |
146
+ | `errors` | Hash | Field-level errors `{ field: ["message", ...] }` |
147
+
148
+ > **`errors` is optional.** Pass it when you want to surface field-level detail to the client (e.g. form validation, token issues). Omit it for simple human-readable-only responses — `message` alone is perfectly valid.
76
149
 
77
150
  ---
78
151
 
@@ -232,222 +305,386 @@ render_permanent_redirect(message: "Permanently moved — update your bookmarks"
232
305
 
233
306
  ### 4xx — Client Error Helpers
234
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
+
235
312
  #### `render_bad_request` — 400 Bad Request
236
313
  ```ruby
314
+ # Message only
237
315
  render_bad_request(message: "The 'date' parameter is required")
238
- render_bad_request(message: "Invalid input", errors: { date: ["must be a valid date"] })
316
+
317
+ # Message + errors
318
+ render_bad_request(message: "Invalid input", errors: { date: "must be a valid date"})
239
319
  ```
240
320
 
241
321
  #### `render_unauthorized` — 401 Unauthorized
242
322
  ```ruby
323
+ # Message only
243
324
  render_unauthorized(message: "Please log in to continue")
244
- render_unauthorized(message: "Token has expired")
325
+
326
+ # Message + errors
327
+ render_unauthorized(message: "Token has expired", errors: { token: "has expired, please log in again"})
245
328
  ```
246
329
 
247
330
  #### `render_payment_required` — 402 Payment Required
248
331
  ```ruby
332
+ # Message only
249
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"})
250
337
  ```
251
338
 
252
339
  #### `render_forbidden` — 403 Forbidden
253
340
  ```ruby
341
+ # Message only
254
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" })
255
346
  ```
256
347
 
257
348
  #### `render_not_found` — 404 Not Found
258
349
  ```ruby
350
+ # Message only
259
351
  render_not_found(message: "User not found")
260
- render_not_found(message: "Post ##{params[:id]} does not exist")
352
+
353
+ # Message + errors
354
+ render_not_found(message: "User not found", errors: { id: "no user exists with this ID" })
261
355
  ```
262
356
 
263
357
  #### `render_method_not_allowed` — 405
264
358
  ```ruby
359
+ # Message only
265
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" })
266
364
  ```
267
365
 
268
366
  #### `render_not_acceptable` — 406
269
367
  ```ruby
368
+ # Message only
270
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" })
271
373
  ```
272
374
 
273
375
  #### `render_proxy_auth_required` — 407
274
376
  ```ruby
377
+ # Message only
275
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"})
276
382
  ```
277
383
 
278
384
  #### `render_request_timeout` — 408
279
385
  ```ruby
386
+ # Message only
280
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" })
281
391
  ```
282
392
 
283
393
  #### `render_conflict` — 409 Conflict
284
394
  ```ruby
395
+ # Message only
285
396
  render_conflict(message: "Email address is already registered")
286
- render_conflict(message: "Duplicate entry", errors: { email: ["has already been taken"] })
397
+
398
+ # Message + errors
399
+ render_conflict(message: "Email address is already registered",errors: { email: "has already been taken" })
287
400
  ```
288
401
 
289
402
  #### `render_gone` — 410 Gone
290
403
  ```ruby
404
+ # Message only
291
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"] })
292
409
  ```
293
410
 
294
411
  #### `render_length_required` — 411
295
412
  ```ruby
413
+ # Message only
296
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"] })
297
418
  ```
298
419
 
299
420
  #### `render_precondition_failed` — 412
300
421
  ```ruby
422
+ # Message only
301
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"] })
302
427
  ```
303
428
 
304
429
  #### `render_payload_too_large` — 413
305
430
  ```ruby
431
+ # Message only
306
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"] })
307
436
  ```
308
437
 
309
438
  #### `render_uri_too_long` — 414
310
439
  ```ruby
440
+ # Message only
311
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"] })
312
445
  ```
313
446
 
314
447
  #### `render_unsupported_media_type` — 415
315
448
  ```ruby
449
+ # Message only
316
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"] })
317
454
  ```
318
455
 
319
456
  #### `render_range_not_satisfiable` — 416
320
457
  ```ruby
458
+ # Message only
321
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"] })
322
463
  ```
323
464
 
324
465
  #### `render_expectation_failed` — 417
325
466
  ```ruby
467
+ # Message only
326
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"] })
327
472
  ```
328
473
 
329
474
  #### `render_im_a_teapot` — 418
330
475
  ```ruby
476
+ # Message only
331
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"] })
332
481
  ```
333
482
 
334
483
  #### `render_misdirected_request` — 421
335
484
  ```ruby
485
+ # Message only
336
486
  render_misdirected_request(message: "Request sent to the wrong server")
487
+
488
+ # Message + errors
489
+ render_misdirected_request(message: "Request sent to the wrong server",errors: { host: ["this server does not handle requests for this host"] })
337
490
  ```
338
491
 
339
492
  #### `render_unprocessable` — 422 Unprocessable Entity
340
493
  Validation errors. The most commonly used error helper in Rails APIs.
341
494
 
342
495
  ```ruby
343
- render_unprocessable(message: "Validation failed", errors: user.errors)
344
- render_unprocessable(message: "Invalid data", errors: { name: ["can't be blank"] })
496
+ # Message only
497
+ render_unprocessable(message: "Validation failed")
498
+
499
+ # Message + errors — pass an ActiveModel::Errors object directly
500
+ render_unprocessable(message: "Validation failed",errors: user.errors)
501
+
502
+ # Message + errors — pass a plain hash
503
+ render_unprocessable(message: "Invalid data",errors: { name: ["can't be blank"], email: ["is invalid"] })
345
504
  ```
346
505
 
347
506
  #### `render_locked` — 423
348
507
  ```ruby
508
+ # Message only
349
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"] })
350
513
  ```
351
514
 
352
515
  #### `render_failed_dependency` — 424
353
516
  ```ruby
517
+ # Message only
354
518
  render_failed_dependency(message: "Prerequisite resource creation failed")
519
+
520
+ # Message + errors
521
+ render_failed_dependency(message: "Prerequisite resource creation failed",errors: { dependency: ["parent record must exist before creating this resource"] })
355
522
  ```
356
523
 
357
524
  #### `render_too_early` — 425
358
525
  ```ruby
526
+ # Message only
359
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"] })
360
531
  ```
361
532
 
362
533
  #### `render_upgrade_required` — 426
363
534
  ```ruby
535
+ # Message only
364
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"] })
365
540
  ```
366
541
 
367
542
  #### `render_precondition_required` — 428
368
543
  ```ruby
544
+ # Message only
369
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"] })
370
549
  ```
371
550
 
372
551
  #### `render_too_many_requests` — 429
373
552
  ```ruby
553
+ # Message only
374
554
  render_too_many_requests(message: "You have exceeded 100 requests per minute.")
375
- render_too_many_requests(message: "Rate limit hit", meta: { retry_after: 60 })
555
+
556
+ # Message + errors
557
+ render_too_many_requests(message: "Rate limit exceeded",errors: { rate_limit: ["100 requests per minute allowed, retry after 60 seconds"] },meta: { retry_after: 60 })
376
558
  ```
377
559
 
378
560
  #### `render_request_header_fields_too_large` — 431
379
561
  ```ruby
562
+ # Message only
380
563
  render_request_header_fields_too_large(message: "Cookie header is too large")
564
+
565
+ # Message + errors
566
+ render_request_header_fields_too_large(message: "Cookie header is too large",errors: { cookie: ["must not exceed 4096 bytes"] })
381
567
  ```
382
568
 
383
569
  #### `render_unavailable_for_legal_reasons` — 451
384
570
  ```ruby
571
+ # Message only
385
572
  render_unavailable_for_legal_reasons(message: "This content is blocked in your region")
573
+
574
+ # Message + errors
575
+ render_unavailable_for_legal_reasons(message: "This content is blocked in your region",errors: { region: ["content is not licensed for distribution in your country"] })
386
576
  ```
387
577
 
388
578
  ---
389
579
 
390
580
  ### 5xx — Server Error Helpers
391
581
 
582
+ > **Two usage patterns for every error helper:**
583
+ > - **Message only** — a human-readable string shown to the end user.
584
+ > - **Message + errors** — add `errors:` when you need to surface internal detail for debugging or logging (e.g. which downstream service failed). `errors` is a Hash of `{ field: ["message", ...] }`.
585
+
392
586
  #### `render_server_error` — 500 Internal Server Error
393
587
  ```ruby
588
+ # Message only
394
589
  render_server_error(message: "Something went wrong. Our team has been notified.")
395
590
 
591
+ # Message + errors
592
+ render_server_error(message: "Something went wrong. Our team has been notified.",errors: { server: ["unexpected exception in OrdersController#create"] })
593
+
396
594
  # Common pattern — rescue unexpected exceptions
397
595
  rescue StandardError => e
398
596
  Rails.logger.error(e)
399
- render_server_error("An unexpected error occurred")
597
+ render_server_error(message: "An unexpected error occurred",errors: { server: [e.message] })
400
598
  ```
401
599
 
402
600
  #### `render_not_implemented` — 501
403
601
  ```ruby
602
+ # Message only
404
603
  render_not_implemented(message: "CSV export is coming soon")
604
+
605
+ # Message + errors
606
+ render_not_implemented(message: "CSV export is coming soon",errors: { format: ["csv export is not yet implemented, use json"] })
405
607
  ```
406
608
 
407
609
  #### `render_bad_gateway` — 502
408
610
  ```ruby
611
+ # Message only
409
612
  render_bad_gateway(message: "Payment gateway is currently unavailable")
613
+
614
+ # Message + errors
615
+ render_bad_gateway(message: "Payment gateway is currently unavailable",errors: { gateway: ["Stripe returned a 502, please try again"] })
410
616
  ```
411
617
 
412
618
  #### `render_service_unavailable` — 503
413
619
  ```ruby
620
+ # Message only
414
621
  render_service_unavailable(message: "Down for maintenance. Back in 30 minutes.")
415
- render_service_unavailable(message: "Maintenance window", meta: { retry_after: 1800 })
622
+
623
+ # Message + errors
624
+ render_service_unavailable(message: "Down for maintenance. Back in 30 minutes.",errors: { service: ["scheduled maintenance window until 03:00 UTC"] },meta: { retry_after: 1800 })
416
625
  ```
417
626
 
418
627
  #### `render_gateway_timeout` — 504
419
628
  ```ruby
629
+ # Message only
420
630
  render_gateway_timeout(message: "The payment processor did not respond in time.")
631
+
632
+ # Message + errors
633
+ render_gateway_timeout(message: "The payment processor did not respond in time.",errors: { gateway: ["upstream timeout after 30 seconds, you have not been charged"] })
421
634
  ```
422
635
 
423
636
  #### `render_http_version_not_supported` — 505
424
637
  ```ruby
638
+ # Message only
425
639
  render_http_version_not_supported(message: "Only HTTP/1.1 and HTTP/2 are supported")
640
+
641
+ # Message + errors
642
+ render_http_version_not_supported(message: "Only HTTP/1.1 and HTTP/2 are supported",errors: { http_version: ["HTTP/1.0 is not supported"] })
426
643
  ```
427
644
 
428
645
  #### `render_variant_also_negotiates` — 506
429
646
  ```ruby
647
+ # Message only
430
648
  render_variant_also_negotiates(message: "Server content-negotiation loop detected")
649
+
650
+ # Message + errors
651
+ render_variant_also_negotiates(message: "Server content-negotiation loop detected",errors: { variant: ["misconfigured content negotiation caused an infinite loop"] })
431
652
  ```
432
653
 
433
654
  #### `render_insufficient_storage` — 507
434
655
  ```ruby
656
+ # Message only
435
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"] })
436
661
  ```
437
662
 
438
663
  #### `render_loop_detected` — 508
439
664
  ```ruby
665
+ # Message only
440
666
  render_loop_detected(message: "Infinite redirect loop detected")
667
+
668
+ # Message + errors
669
+ render_loop_detected(message: "Infinite redirect loop detected",errors: { redirect: ["request visited the same URL more than 10 times"] })
441
670
  ```
442
671
 
443
672
  #### `render_not_extended` — 510
444
673
  ```ruby
674
+ # Message only
445
675
  render_not_extended(message: "Further extensions required to fulfil this request")
676
+
677
+ # Message + errors
678
+ render_not_extended(message: "Further extensions required to fulfil this request",errors: { extension: ["mandatory extension 'auth' is missing from the request"] })
446
679
  ```
447
680
 
448
681
  #### `render_network_authentication_required` — 511
449
682
  ```ruby
683
+ # Message only
450
684
  render_network_authentication_required(message: "Sign in to the network portal first")
685
+
686
+ # Message + errors
687
+ render_network_authentication_required(message: "Sign in to the network portal first",errors: { network: ["captive portal authentication required before accessing the API"] })
451
688
  ```
452
689
 
453
690
  ---
@@ -467,12 +704,12 @@ class UsersController < ApplicationController
467
704
  data: @users,
468
705
  message: "Users fetched",
469
706
  pagination: {
470
- per_page: per_page.to_i,
471
- current_page: @users.current_page,
472
- next_page: @users.next_page,
473
- prev_page: @users.prev_page,
474
- total_pages: @users.total_pages,
475
- total_count: @users.total_count
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
476
713
  }
477
714
  )
478
715
  end
@@ -481,7 +718,7 @@ class UsersController < ApplicationController
481
718
  user = User.find(params[:id])
482
719
  render_ok(data: user, message: "User found")
483
720
  rescue ActiveRecord::RecordNotFound
484
- render_not_found(message: "User ##{params[:id]} not found", error: { id: "User #{params[:id]} not exist"})
721
+ render_not_found(message: "User ##{params[:id]} not found",errors: { id: ["no user exists with ID #{params[:id]}"] })
485
722
  end
486
723
 
487
724
  def create
@@ -497,7 +734,7 @@ class UsersController < ApplicationController
497
734
  user = User.find(params[:id])
498
735
 
499
736
  unless user == current_user || current_user.admin?
500
- render_forbidden(message: "You can only update your own profile", error: { profile: "update your own profile" })
737
+ render_forbidden( message: "You can only update your own profile", errors: { profile: ["you do not have permission to update this profile"] } )
501
738
  return
502
739
  end
503
740
 
@@ -523,11 +760,11 @@ class PaymentsController < ApplicationController
523
760
  result = PaymentGateway.charge(amount: params[:amount], token: params[:token])
524
761
  render_created(data: result, message: "Payment successful")
525
762
  rescue PaymentGateway::CardDeclined => e
526
- render_unprocessable(message: e.message)
763
+ render_unprocessable(message: e.message, errors: { card: [e.message] })
527
764
  rescue PaymentGateway::Timeout
528
- render_gateway_timeout(message: "Payment processor timed out. You have not been charged.")
765
+ render_gateway_timeout( message: "Payment processor timed out. You have not been charged.", errors: { gateway: ["upstream timeout, transaction was not processed"] } )
529
766
  rescue PaymentGateway::Error => e
530
- render_bad_gateway(message: "Payment gateway error: #{e.message}")
767
+ render_bad_gateway( message: "Payment gateway error: #{e.message}", errors: { gateway: [e.message] })
531
768
  end
532
769
 
533
770
  end
@@ -628,7 +865,7 @@ render_ok(data: @user, message: "User found")
628
865
 
629
866
  ```ruby
630
867
  # Core
631
- render_success(data:, message:, meta:, code:, pagination:, code:, status:)
868
+ render_success(data:, message:, meta:, pagination:, code:, status:)
632
869
  render_error(message:, errors:, code:, meta:, status:)
633
870
 
634
871
  # 1xx — Informational
@@ -638,7 +875,7 @@ render_processing(message:, meta:)
638
875
  render_early_hints(message:, meta:)
639
876
 
640
877
  # 2xx — Success
641
- render_success(data:, message:, meta:, pagination:, code: , status:)
878
+ render_success(data:, message:, meta:, pagination:, code:, status:)
642
879
  render_ok(data:, message:, meta:, pagination:)
643
880
  render_created(data:, message:, meta:, pagination:)
644
881
  render_accepted(data:, message:, meta:, pagination:)
@@ -770,13 +1007,17 @@ class ApiResponse<T> {
770
1007
  ```
771
1008
  lib/
772
1009
  ├── respondo.rb # Entry point, configure, Railtie hook
773
- └── respondo/
774
- ├── version.rb # VERSION
775
- ├── configuration.rb # Config with defaults
776
- ├── serializer.rb # Auto-detects and serializes any object
777
- ├── response_builder.rb # Assembles the final Hash
778
- ├── controller_helpers.rb # All render_* helpers (1xx–5xx)
779
- └── railtie.rb # Auto-includes into Rails controllers
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
780
1021
  ```
781
1022
 
782
1023
  ---