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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +564 -0
- data/lib/respondo/configuration.rb +40 -0
- data/lib/respondo/controller_helpers.rb +243 -0
- data/lib/respondo/pagination.rb +94 -0
- data/lib/respondo/railtie.rb +18 -0
- data/lib/respondo/response_builder.rb +123 -0
- data/lib/respondo/serializer.rb +110 -0
- data/lib/respondo/version.rb +5 -0
- data/lib/respondo.rb +45 -0
- data/respondo.gemspec +29 -0
- metadata +116 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Respondo
|
|
4
|
+
# Mixed into Rails controllers to provide render_success and render_error.
|
|
5
|
+
#
|
|
6
|
+
# Success helpers (2xx):
|
|
7
|
+
# render_success, render_created, render_accepted, render_no_content,
|
|
8
|
+
# render_partial_content, render_multi_status
|
|
9
|
+
#
|
|
10
|
+
# Client error helpers (4xx):
|
|
11
|
+
# render_bad_request, render_unauthorized, render_payment_required,
|
|
12
|
+
# render_forbidden, render_not_found, render_method_not_allowed,
|
|
13
|
+
# render_not_acceptable, render_conflict, render_gone,
|
|
14
|
+
# render_unprocessable, render_too_many_requests, render_locked,
|
|
15
|
+
# render_precondition_failed, render_unsupported_media_type,
|
|
16
|
+
# render_request_timeout
|
|
17
|
+
#
|
|
18
|
+
# Server error helpers (5xx):
|
|
19
|
+
# render_server_error, render_not_implemented, render_bad_gateway,
|
|
20
|
+
# render_service_unavailable, render_gateway_timeout
|
|
21
|
+
module ControllerHelpers
|
|
22
|
+
|
|
23
|
+
# =========================================================================
|
|
24
|
+
# Core DSL — all helpers delegate to these two
|
|
25
|
+
# =========================================================================
|
|
26
|
+
|
|
27
|
+
# Render a successful JSON response.
|
|
28
|
+
#
|
|
29
|
+
# @param data [Object] payload — AR model, collection, Hash, Array, nil
|
|
30
|
+
# @param message [String] human-readable description
|
|
31
|
+
# @param meta [Hash] extra meta fields merged into the meta block
|
|
32
|
+
# @param pagy [Pagy] optional Pagy object (pass when using Pagy backend)
|
|
33
|
+
# @param pagination [Boolean] true = include pagination meta when available (default)
|
|
34
|
+
# false = always suppress pagination meta
|
|
35
|
+
# @param status [Symbol, Integer] HTTP status (default: :ok / 200)
|
|
36
|
+
def render_success(data: nil, message: nil, meta: {}, code: nil, pagy: nil, pagination: true, status: :ok)
|
|
37
|
+
merged_meta = code ? meta.merge(code: code, status: status) : meta
|
|
38
|
+
|
|
39
|
+
payload = ResponseBuilder.new(
|
|
40
|
+
success: true,
|
|
41
|
+
data: data,
|
|
42
|
+
message: message,
|
|
43
|
+
meta: merged_meta,
|
|
44
|
+
pagy: pagy,
|
|
45
|
+
pagination: pagination,
|
|
46
|
+
request: try(:request)
|
|
47
|
+
).build
|
|
48
|
+
|
|
49
|
+
render json: payload, status: status
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Render an error JSON response.
|
|
53
|
+
#
|
|
54
|
+
# @param message [String] human-readable error description
|
|
55
|
+
# @param errors [Hash, ActiveModel::Errors] field-level validation errors
|
|
56
|
+
# @param code [String, nil] machine-readable error code e.g. "AUTH_EXPIRED"
|
|
57
|
+
# @param meta [Hash] extra meta fields
|
|
58
|
+
# @param status [Symbol, Integer] HTTP status (default: :unprocessable_entity / 422)
|
|
59
|
+
|
|
60
|
+
def render_error(message: nil, errors: nil, code: nil, meta: {}, status: :unprocessable_entity)
|
|
61
|
+
extracted_errors = extract_errors(errors)
|
|
62
|
+
merged_meta = code ? meta.merge(code: code, status: status) : meta
|
|
63
|
+
|
|
64
|
+
payload = ResponseBuilder.new(
|
|
65
|
+
success: false,
|
|
66
|
+
data: nil,
|
|
67
|
+
message: message,
|
|
68
|
+
meta: merged_meta,
|
|
69
|
+
errors: extracted_errors,
|
|
70
|
+
pagination: false,
|
|
71
|
+
request: try(:request)
|
|
72
|
+
).build
|
|
73
|
+
|
|
74
|
+
render json: payload, status: status
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# =========================================================================
|
|
78
|
+
# 2xx Success helpers
|
|
79
|
+
# =========================================================================
|
|
80
|
+
|
|
81
|
+
# 200 OK — alias for render_success with no pagination (single record)
|
|
82
|
+
def render_ok(data: nil, message: nil, meta: {}, pagination: true)
|
|
83
|
+
render_success(data: data, message: message, meta: meta, pagination: pagination, code:200, status: :ok)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 201 Created
|
|
87
|
+
def render_created(data: nil, message: "Created successfully", pagination: false)
|
|
88
|
+
render_success(data: data, message: message, pagination: pagination, code:201, status: :created)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 202 Accepted — async jobs, background processing
|
|
92
|
+
def render_accepted(data: nil, message: "Request accepted and is being processed")
|
|
93
|
+
render_success(data: data, message: message, pagination: false, code:202, status: :accepted)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# 204 No Content — deletions, actions with no response body
|
|
97
|
+
# Note: we still return our standard JSON structure for consistency
|
|
98
|
+
def render_no_content(message: "Deleted successfully")
|
|
99
|
+
render_success(data: nil, message: message, pagination: false, code:204, status: :ok)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# 206 Partial Content — chunked / range responses
|
|
103
|
+
def render_partial_content(data: nil, message: "Partial content returned", meta: {})
|
|
104
|
+
render_success(data: data, message: message, meta: meta, pagination: false, code:206, status: :partial_content)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# 207 Multi-Status — batch operations with mixed results
|
|
108
|
+
def render_multi_status(data: nil, message: "Multi-status response", meta: {})
|
|
109
|
+
render_success(data: data, message: message, meta: meta, pagination: false, code:207, status: :multi_status)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# =========================================================================
|
|
113
|
+
# 4xx Client error helpers
|
|
114
|
+
# =========================================================================
|
|
115
|
+
|
|
116
|
+
# 400 Bad Request — malformed request, invalid params
|
|
117
|
+
def render_bad_request(message: "Bad request", errors: nil, code: "BAD_REQUEST")
|
|
118
|
+
render_error(message: message, errors: errors, code: code, status: :bad_request)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# 401 Unauthorized — not authenticated
|
|
122
|
+
def render_unauthorized(message: "Unauthorized", errors: nil, code: "UNAUTHORIZED")
|
|
123
|
+
render_error(message: message, errors: errors, code: code, status: :unauthorized)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# 402 Payment Required — paywalled features
|
|
127
|
+
def render_payment_required(message: "Payment required to access this resource", errors: nil, code: "PAYMENT_REQUIRED")
|
|
128
|
+
render_error(message: message, errors: errors, code: code, status: :payment_required)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# 403 Forbidden — authenticated but not authorized
|
|
132
|
+
def render_forbidden(message: "You do not have permission to perform this action", errors: nil, code: "FORBIDDEN")
|
|
133
|
+
render_error(message: message, errors: errors, code: code, status: :forbidden)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# 404 Not Found
|
|
137
|
+
def render_not_found(message: "Resource not found", errors: nil, code: "NOT_FOUND")
|
|
138
|
+
render_error(message: message, errors: errors, code: code, status: :not_found)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# 405 Method Not Allowed
|
|
142
|
+
def render_method_not_allowed(message: "HTTP method not allowed", errors: nil, code: "METHOD_NOT_ALLOWED")
|
|
143
|
+
render_error(message: message, errors: errors, code: code, status: :method_not_allowed)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# 406 Not Acceptable — client Accept header can't be satisfied
|
|
147
|
+
def render_not_acceptable(message: "Requested format not acceptable", errors: nil, code: "NOT_ACCEPTABLE")
|
|
148
|
+
render_error(message: message, errors: errors, code: code, status: :not_acceptable)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# 408 Request Timeout
|
|
152
|
+
def render_request_timeout(message: "Request timed out", errors: nil, code: "REQUEST_TIMEOUT")
|
|
153
|
+
render_error(message: message, errors: errors, code: code, status: :request_timeout)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# 409 Conflict — duplicate record, state conflict
|
|
157
|
+
def render_conflict(message: "Resource conflict", errors: nil, code: "CONFLICT")
|
|
158
|
+
render_error(message: message, errors: errors, code: code, status: :conflict)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# 410 Gone — resource permanently deleted
|
|
162
|
+
def render_gone(message: "Resource no longer available", errors: nil, code: "GONE")
|
|
163
|
+
render_error(message: message, errors: errors, code: code, status: :gone)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# 412 Precondition Failed — conditional request failed
|
|
167
|
+
def render_precondition_failed(message: "Precondition failed", errors: nil, code: "PRECONDITION_FAILED")
|
|
168
|
+
render_error(message: message, errors: errors, code: code, status: :precondition_failed)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# 415 Unsupported Media Type — wrong Content-Type header
|
|
172
|
+
def render_unsupported_media_type(message: "Unsupported media type", errors: nil, code: "UNSUPPORTED_MEDIA_TYPE")
|
|
173
|
+
render_error(message: message, errors: errors, code: code, status: :unsupported_media_type)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# 422 Unprocessable Entity — validation errors (most common for APIs)
|
|
177
|
+
def render_unprocessable(message: "Validation failed", errors: nil, code: "UNPROCESSABLE_ENTITY")
|
|
178
|
+
render_error(message: message, errors: errors, code: code, status: :unprocessable_entity)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# 423 Locked — resource is locked
|
|
182
|
+
def render_locked(message: "Resource is locked", errors: nil, code: "LOCKED")
|
|
183
|
+
render_error(message: message, errors: errors, code: code, status: :locked)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# 429 Too Many Requests — rate limiting
|
|
187
|
+
def render_too_many_requests(message: "Too many requests. Please slow down.", errors: nil, code: "RATE_LIMITED")
|
|
188
|
+
render_error(message: message, errors: errors, code: code, status: :too_many_requests)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# =========================================================================
|
|
192
|
+
# 5xx Server error helpers
|
|
193
|
+
# =========================================================================
|
|
194
|
+
|
|
195
|
+
# 500 Internal Server Error
|
|
196
|
+
def render_server_error(message: "An unexpected error occurred", errors: nil, code: "SERVER_ERROR")
|
|
197
|
+
render_error(message: message, errors: errors, code: code, status: :internal_server_error)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# 501 Not Implemented — feature not built yet
|
|
201
|
+
def render_not_implemented(message: "This feature is not yet implemented", errors: nil, code: "NOT_IMPLEMENTED")
|
|
202
|
+
render_error(message: message, errors: errors, code: code, status: :not_implemented)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# 502 Bad Gateway — upstream service failed
|
|
206
|
+
def render_bad_gateway(message: "Bad gateway — upstream service error", errors: nil, code: "BAD_GATEWAY")
|
|
207
|
+
render_error(message: message, errors: errors, code: code, status: :bad_gateway)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# 503 Service Unavailable — maintenance, overloaded
|
|
211
|
+
def render_service_unavailable(message: "Service temporarily unavailable", errors: nil, code: "SERVICE_UNAVAILABLE")
|
|
212
|
+
render_error(message: message, errors: errors, code: code, status: :service_unavailable)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# 504 Gateway Timeout — upstream service timed out
|
|
216
|
+
def render_gateway_timeout(message: "Gateway timeout — upstream service did not respond", errors: nil, code: "GATEWAY_TIMEOUT")
|
|
217
|
+
render_error(message: message, errors: errors, code: code, status: :gateway_timeout)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
# Normalize errors into a plain Hash regardless of source type.
|
|
223
|
+
def extract_errors(errors)
|
|
224
|
+
return nil if errors.nil?
|
|
225
|
+
|
|
226
|
+
if defined?(ActiveModel::Errors) && errors.is_a?(ActiveModel::Errors)
|
|
227
|
+
return errors.to_hash
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
return errors if errors.is_a?(Hash)
|
|
231
|
+
|
|
232
|
+
if errors.is_a?(Array)
|
|
233
|
+
return { base: errors }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if errors.is_a?(String)
|
|
237
|
+
return { base: [errors] }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Respondo
|
|
4
|
+
# Extracts pagination metadata from Kaminari or Pagy collection objects.
|
|
5
|
+
#
|
|
6
|
+
# Returned hash shape (always the same regardless of pagination library):
|
|
7
|
+
# {
|
|
8
|
+
# current_page: Integer,
|
|
9
|
+
# per_page: Integer,
|
|
10
|
+
# total_pages: Integer,
|
|
11
|
+
# total_count: Integer,
|
|
12
|
+
# next_page: Integer | nil,
|
|
13
|
+
# prev_page: Integer | nil
|
|
14
|
+
# }
|
|
15
|
+
module Pagination
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param collection [Object] any object — returns nil if not a paginated collection
|
|
19
|
+
# @return [Hash, nil]
|
|
20
|
+
def extract(collection)
|
|
21
|
+
return nil if collection.nil?
|
|
22
|
+
|
|
23
|
+
if pagy?(collection)
|
|
24
|
+
from_pagy(collection)
|
|
25
|
+
elsif kaminari?(collection)
|
|
26
|
+
from_kaminari(collection)
|
|
27
|
+
elsif will_paginate?(collection)
|
|
28
|
+
from_will_paginate(collection)
|
|
29
|
+
else
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# ----- Pagy ---------------------------------------------------------------
|
|
39
|
+
# Pagy stores metadata on a separate Pagy object, not the collection itself.
|
|
40
|
+
# We support both: passing the Pagy object directly, or a collection that
|
|
41
|
+
# has been decorated with pagy metadata via pagy_metadata.
|
|
42
|
+
def pagy?(object)
|
|
43
|
+
defined?(Pagy) && object.is_a?(Pagy)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def from_pagy(pagy)
|
|
47
|
+
{
|
|
48
|
+
current_page: pagy.page,
|
|
49
|
+
per_page: pagy.items,
|
|
50
|
+
total_pages: pagy.pages,
|
|
51
|
+
total_count: pagy.count,
|
|
52
|
+
next_page: pagy.next,
|
|
53
|
+
prev_page: pagy.prev
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ----- Kaminari -----------------------------------------------------------
|
|
58
|
+
def kaminari?(object)
|
|
59
|
+
object.respond_to?(:current_page) &&
|
|
60
|
+
object.respond_to?(:total_pages) &&
|
|
61
|
+
object.respond_to?(:limit_value)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def from_kaminari(collection)
|
|
65
|
+
{
|
|
66
|
+
current_page: collection.current_page,
|
|
67
|
+
per_page: collection.limit_value,
|
|
68
|
+
total_pages: collection.total_pages,
|
|
69
|
+
total_count: collection.total_count,
|
|
70
|
+
next_page: collection.next_page,
|
|
71
|
+
prev_page: collection.prev_page
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# ----- WillPaginate -------------------------------------------------------
|
|
76
|
+
def will_paginate?(object)
|
|
77
|
+
object.respond_to?(:current_page) &&
|
|
78
|
+
object.respond_to?(:total_pages) &&
|
|
79
|
+
object.respond_to?(:per_page) &&
|
|
80
|
+
!object.respond_to?(:limit_value) # distinguishes from Kaminari
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def from_will_paginate(collection)
|
|
84
|
+
{
|
|
85
|
+
current_page: collection.current_page,
|
|
86
|
+
per_page: collection.per_page,
|
|
87
|
+
total_pages: collection.total_pages,
|
|
88
|
+
total_count: collection.total_entries,
|
|
89
|
+
next_page: collection.next_page,
|
|
90
|
+
prev_page: collection.previous_page
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Respondo
|
|
4
|
+
# Railtie automatically includes Respondo::ControllerHelpers into
|
|
5
|
+
# ActionController::Base and ActionController::API when Rails is present.
|
|
6
|
+
# No manual include needed in ApplicationController.
|
|
7
|
+
class Railtie < Rails::Railtie
|
|
8
|
+
initializer "respondo.include_controller_helpers" do
|
|
9
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
10
|
+
include Respondo::ControllerHelpers
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ActiveSupport.on_load(:action_controller_api) do
|
|
14
|
+
include Respondo::ControllerHelpers
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Respondo
|
|
6
|
+
# Builds the standardized response hash.
|
|
7
|
+
#
|
|
8
|
+
# Every response always contains these top-level keys:
|
|
9
|
+
# {
|
|
10
|
+
# success: Boolean,
|
|
11
|
+
# message: String,
|
|
12
|
+
# data: Object | Array | nil,
|
|
13
|
+
# meta: Hash
|
|
14
|
+
# }
|
|
15
|
+
#
|
|
16
|
+
# The meta block always contains:
|
|
17
|
+
# { timestamp: ISO8601 String }
|
|
18
|
+
# Plus pagination keys when pagination: true and the data is a paginated collection.
|
|
19
|
+
# Plus request_id when config.include_request_id is true.
|
|
20
|
+
class ResponseBuilder
|
|
21
|
+
# @param success [Boolean]
|
|
22
|
+
# @param data [Object] anything — serialized automatically
|
|
23
|
+
# @param message [String]
|
|
24
|
+
# @param meta [Hash] caller-supplied extra meta (merged in)
|
|
25
|
+
# @param errors [Hash] field-level errors (for 422 responses)
|
|
26
|
+
# @param pagy [Pagy] optional Pagy object for pagination meta
|
|
27
|
+
# @param pagination [Boolean] true = include pagination meta (default),
|
|
28
|
+
# false = always suppress pagination meta
|
|
29
|
+
# @param request [Object] ActionDispatch::Request (for request_id)
|
|
30
|
+
def initialize(success:, data: nil, message: nil, meta: {}, errors: nil,
|
|
31
|
+
pagy: nil, pagination: true, request: nil)
|
|
32
|
+
@success = success
|
|
33
|
+
@raw_data = data
|
|
34
|
+
@message = message
|
|
35
|
+
@extra_meta = meta || {}
|
|
36
|
+
@errors = errors
|
|
37
|
+
@pagy = pagy
|
|
38
|
+
@pagination = pagination
|
|
39
|
+
@request = request
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Hash] the complete response payload
|
|
43
|
+
def build
|
|
44
|
+
# We initialize the hash in the exact order we want keys to appear
|
|
45
|
+
payload = {
|
|
46
|
+
success: @success,
|
|
47
|
+
message: resolve_message,
|
|
48
|
+
data: serialize_data,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Add errors before meta if they exist
|
|
52
|
+
payload[:errors] = @errors if @errors && !@errors.empty?
|
|
53
|
+
|
|
54
|
+
# Finally, add meta so it appears at the bottom
|
|
55
|
+
payload[:meta] = build_meta
|
|
56
|
+
|
|
57
|
+
apply_camelize(payload)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def resolve_message
|
|
63
|
+
return @message if @message && !@message.empty?
|
|
64
|
+
@success ? Respondo.config.default_success_message : Respondo.config.default_error_message
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def serialize_data
|
|
68
|
+
Serializer.call(@raw_data)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_meta
|
|
72
|
+
meta = { timestamp: current_timestamp }
|
|
73
|
+
|
|
74
|
+
# Only extract pagination when caller has not explicitly disabled it
|
|
75
|
+
if @pagination
|
|
76
|
+
pagination = if @pagy
|
|
77
|
+
Pagination.extract(@pagy)
|
|
78
|
+
else
|
|
79
|
+
Pagination.extract(@raw_data)
|
|
80
|
+
end
|
|
81
|
+
meta[:pagination] = pagination if pagination
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Request ID (Rails only, opt-in via config)
|
|
85
|
+
if Respondo.config.include_request_id && @request&.respond_to?(:request_id)
|
|
86
|
+
meta[:request_id] = @request.request_id
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Merge any caller-supplied meta last (allows overriding)
|
|
90
|
+
meta.merge(@extra_meta)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def current_timestamp
|
|
94
|
+
if defined?(Time.current)
|
|
95
|
+
Time.current.iso8601
|
|
96
|
+
else
|
|
97
|
+
Time.now.utc.iso8601
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def apply_camelize(hash)
|
|
102
|
+
return hash unless Respondo.config.camelize_keys
|
|
103
|
+
camelize_hash(hash)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def camelize_hash(obj)
|
|
107
|
+
case obj
|
|
108
|
+
when Hash
|
|
109
|
+
obj.each_with_object({}) do |(k, v), memo|
|
|
110
|
+
memo[camelize_key(k)] = camelize_hash(v)
|
|
111
|
+
end
|
|
112
|
+
when Array
|
|
113
|
+
obj.map { |item| camelize_hash(item) }
|
|
114
|
+
else
|
|
115
|
+
obj
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def camelize_key(key)
|
|
120
|
+
key.to_s.gsub(/_([a-z])/) { $1.upcase }.to_sym
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Respondo
|
|
4
|
+
# Responsible for converting any Ruby object into a JSON-safe Hash or Array.
|
|
5
|
+
#
|
|
6
|
+
# Priority order:
|
|
7
|
+
# 1. Custom serializer from Respondo.config (if set)
|
|
8
|
+
# 2. ActiveRecord::Base / ActiveRecord::Relation
|
|
9
|
+
# 3. ActiveModel::Errors / objects responding to #errors
|
|
10
|
+
# 4. Objects responding to #as_json or #to_h
|
|
11
|
+
# 5. Arrays — each element is serialized recursively
|
|
12
|
+
# 6. Primitives — returned as-is
|
|
13
|
+
module Serializer
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param object [Object] anything — AR model, collection, error, hash, array, primitive
|
|
17
|
+
# @return [Hash, Array, String, Numeric, nil]
|
|
18
|
+
def call(object)
|
|
19
|
+
return nil if object.nil?
|
|
20
|
+
|
|
21
|
+
# 1. Custom serializer wins
|
|
22
|
+
if Respondo.config.serializer
|
|
23
|
+
return Respondo.config.serializer.call(object)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 2. ActiveRecord::Relation or any object with #map (lazy collections)
|
|
27
|
+
if ar_relation?(object)
|
|
28
|
+
return object.map { |record| serialize_record(record) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 3. Array — recurse
|
|
32
|
+
if object.is_a?(Array)
|
|
33
|
+
return object.map { |item| call(item) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 4. ActiveRecord model instance
|
|
37
|
+
if ar_model?(object)
|
|
38
|
+
return serialize_record(object)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 5. ActiveModel::Errors object
|
|
42
|
+
if active_model_errors?(object)
|
|
43
|
+
return serialize_errors(object)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 6. Exception / StandardError
|
|
47
|
+
if object.is_a?(Exception)
|
|
48
|
+
return { message: object.message }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 7. Hash — recursively serialize values
|
|
52
|
+
if object.is_a?(Hash)
|
|
53
|
+
return object.transform_values { |v| call(v) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# 8. Responds to #as_json (ActiveSupport)
|
|
57
|
+
if object.respond_to?(:as_json)
|
|
58
|
+
return object.as_json
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 9. Responds to #to_h
|
|
62
|
+
if object.respond_to?(:to_h)
|
|
63
|
+
return object.to_h
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 10. Primitive — return as-is
|
|
67
|
+
object
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
module_function
|
|
73
|
+
|
|
74
|
+
def ar_relation?(object)
|
|
75
|
+
defined?(ActiveRecord::Relation) &&
|
|
76
|
+
object.is_a?(ActiveRecord::Relation)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ar_model?(object)
|
|
80
|
+
defined?(ActiveRecord::Base) &&
|
|
81
|
+
object.is_a?(ActiveRecord::Base)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def active_model_errors?(object)
|
|
85
|
+
defined?(ActiveModel::Errors) &&
|
|
86
|
+
object.is_a?(ActiveModel::Errors)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def serialize_record(record)
|
|
90
|
+
if record.respond_to?(:as_json)
|
|
91
|
+
record.as_json
|
|
92
|
+
elsif record.respond_to?(:to_h)
|
|
93
|
+
record.to_h
|
|
94
|
+
else
|
|
95
|
+
record
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def serialize_errors(errors)
|
|
100
|
+
# ActiveModel::Errors — return { field: ["msg", ...] }
|
|
101
|
+
if errors.respond_to?(:to_hash)
|
|
102
|
+
errors.to_hash
|
|
103
|
+
elsif errors.respond_to?(:messages)
|
|
104
|
+
errors.messages
|
|
105
|
+
else
|
|
106
|
+
{}
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/respondo.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "respondo/version"
|
|
4
|
+
require_relative "respondo/configuration"
|
|
5
|
+
require_relative "respondo/serializer"
|
|
6
|
+
require_relative "respondo/pagination"
|
|
7
|
+
require_relative "respondo/response_builder"
|
|
8
|
+
require_relative "respondo/controller_helpers"
|
|
9
|
+
|
|
10
|
+
# Respondo — Smart JSON API response formatter for Rails.
|
|
11
|
+
#
|
|
12
|
+
# @example Configure once in an initializer
|
|
13
|
+
# # config/initializers/respondo.rb
|
|
14
|
+
# Respondo.configure do |config|
|
|
15
|
+
# config.default_success_message = "OK"
|
|
16
|
+
# config.default_error_message = "Something went wrong"
|
|
17
|
+
# config.include_request_id = true
|
|
18
|
+
# config.camelize_keys = true # for Flutter/JS clients
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Include manually (without Railtie / outside Rails)
|
|
22
|
+
# class MyController
|
|
23
|
+
# include Respondo::ControllerHelpers
|
|
24
|
+
# end
|
|
25
|
+
module Respondo
|
|
26
|
+
class << self
|
|
27
|
+
# @return [Respondo::Configuration]
|
|
28
|
+
def config
|
|
29
|
+
@config ||= Configuration.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @yield [Respondo::Configuration]
|
|
33
|
+
def configure
|
|
34
|
+
yield config
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Reset config (useful in tests)
|
|
38
|
+
def reset!
|
|
39
|
+
@config = Configuration.new
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Auto-integrate with Rails if present
|
|
45
|
+
require_relative "respondo/railtie" if defined?(Rails::Railtie)
|
data/respondo.gemspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/respondo/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "respondo"
|
|
7
|
+
spec.version = Respondo::VERSION
|
|
8
|
+
spec.authors = ["shailendra Kumar"]
|
|
9
|
+
spec.email = ["shailendrapatidar00@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Smart JSON API response formatter for Rails — consistent structure every time."
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
Respondo standardizes JSON API responses across Rails applications.
|
|
14
|
+
Every response gets success, data, message, and meta fields.
|
|
15
|
+
Automatic pagination meta for Kaminari and Pagy collections.
|
|
16
|
+
ActiveRecord serialization, error extraction, and flexible HTTP codes built in.
|
|
17
|
+
DESC
|
|
18
|
+
spec.homepage = "https://github.com/spatelpatidar/respondo"
|
|
19
|
+
spec.license = "MIT"
|
|
20
|
+
spec.required_ruby_version = ">= 2.7.0"
|
|
21
|
+
|
|
22
|
+
spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE.txt", "CHANGELOG.md", "respondo.gemspec"]
|
|
23
|
+
spec.require_paths = ["lib"]
|
|
24
|
+
|
|
25
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
26
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
27
|
+
spec.add_development_dependency "activesupport","~> 7.0"
|
|
28
|
+
spec.add_development_dependency 'simplecov', '~> 0.22'
|
|
29
|
+
end
|