reqcord 0.1.0 → 0.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -0
  3. data/Gemfile +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +252 -95
  6. data/Rakefile +13 -0
  7. data/docs/configuration.md +319 -0
  8. data/examples/reqcord.yml +58 -0
  9. data/gemfiles/rails_7.1.gemfile +13 -0
  10. data/gemfiles/rails_7.2.gemfile +13 -0
  11. data/gemfiles/rails_8.0.gemfile +13 -0
  12. data/gemfiles/rails_8.1.gemfile +13 -0
  13. data/lib/reqcord/capture/collector.rb +31 -0
  14. data/lib/reqcord/capture/integration_patch.rb +209 -0
  15. data/lib/reqcord/capture/minitest_context.rb +34 -0
  16. data/lib/reqcord/capture/rspec_context.rb +42 -0
  17. data/lib/reqcord/capture/test_context.rb +25 -0
  18. data/lib/reqcord/capture.rb +19 -0
  19. data/lib/reqcord/configuration.rb +198 -0
  20. data/lib/reqcord/dataset.rb +176 -0
  21. data/lib/reqcord/endpoint.rb +263 -0
  22. data/lib/reqcord/errors.rb +9 -0
  23. data/lib/reqcord/exporters/curl.rb +68 -0
  24. data/lib/reqcord/exporters/markdown.rb +295 -0
  25. data/lib/reqcord/exporters/postman.rb +206 -0
  26. data/lib/reqcord/exporters.rb +32 -0
  27. data/lib/reqcord/generator.rb +364 -0
  28. data/lib/reqcord/railtie.rb +51 -0
  29. data/lib/reqcord/renderers/curl.rb +56 -0
  30. data/lib/reqcord/renderers/payload.rb +69 -0
  31. data/lib/reqcord/request_example.rb +104 -0
  32. data/lib/reqcord/response_example.rb +72 -0
  33. data/lib/reqcord/route_collector.rb +242 -0
  34. data/lib/reqcord/sanitizers/sanitizer.rb +140 -0
  35. data/lib/reqcord/schema.rb +187 -0
  36. data/lib/reqcord/support.rb +58 -0
  37. data/lib/reqcord/version.rb +5 -0
  38. data/lib/reqcord.rb +78 -0
  39. data/lib/tasks/reqcord.rake +99 -0
  40. data/reqcord.gemspec +60 -0
  41. metadata +165 -3
@@ -0,0 +1,319 @@
1
+ # `reqcord.yml` reference
2
+
3
+ Reqcord reads one file, `reqcord.yml`, from the application root (`Rails.root`).
4
+ `bin/rails reqcord:init` writes a starting point; every key is optional.
5
+
6
+ ```yaml
7
+ version: 1
8
+
9
+ test:
10
+ framework: minitest
11
+ paths:
12
+ - test/integration
13
+ - test/api
14
+
15
+ routes:
16
+ prefix: /api
17
+
18
+ output:
19
+ directory: docs/api
20
+ include_uncovered: false
21
+
22
+ exporters:
23
+ - curl
24
+ - markdown
25
+ - postman
26
+
27
+ variables:
28
+ base_url: http://localhost:3000
29
+
30
+ sanitize:
31
+ headers:
32
+ Authorization: "Bearer {{token}}"
33
+ X-Api-Key: "{{api_key}}"
34
+ body:
35
+ password: "{{password}}"
36
+ ```
37
+
38
+ ## How values are resolved
39
+
40
+ ```text
41
+ environment variable > reqcord.yml > Reqcord default
42
+ ```
43
+
44
+ The file is **deep-merged** over the defaults: setting `sanitize.headers.X-Account-Id`
45
+ keeps the default `Authorization` and `X-Api-Key` entries. Lists are replaced,
46
+ not merged: an `exporters:` list is used exactly as written.
47
+
48
+ The file must be a YAML mapping; anything else raises `Reqcord::ConfigurationError`
49
+ before any test runs. YAML aliases are disabled.
50
+
51
+ | Variable | Overrides |
52
+ | --- | --- |
53
+ | `REQCORD_TEST_FRAMEWORK` | `test.framework` |
54
+ | `REQCORD_TEST_COMMAND` | `test.command` (and therefore `test.paths`) |
55
+ | `REQCORD_OUTPUT` | `output.directory` |
56
+ | `REQCORD_BASE_URL` | `variables.base_url` |
57
+ | `RESOURCE`, `VERSION` | run-time filters, see [Filtering a run](#filtering-a-run) |
58
+
59
+ `REQCORD_CAPTURE` and `REQCORD_CAPTURE_FILE` are set **by** Reqcord on the test
60
+ process it starts; do not set them yourself. Without them an ordinary
61
+ `bin/rails test` patches nothing and writes nothing.
62
+
63
+ ---
64
+
65
+ ## `version`
66
+
67
+ ```yaml
68
+ version: 1
69
+ ```
70
+
71
+ The configuration format version. Informational today; keep it at `1`.
72
+
73
+ ---
74
+
75
+ ## `test`
76
+
77
+ Where the requests come from. `reqcord:generate` runs your suite in a
78
+ subprocess with capture enabled; every request the integration tests make
79
+ is captured, so point this at the tests that exercise the API.
80
+
81
+ ### `test.framework`
82
+
83
+ `minitest` (default) or `rspec`. Decides two things: how the suite is run
84
+ when only `paths` is given, and how captured examples are named — from the
85
+ Minitest test method (`test "creates customer"`) or the RSpec example
86
+ (`it "creates customer"`). Any other value is rejected before the run.
87
+
88
+ ### `test.paths`
89
+
90
+ Directories, files or globs. Reqcord builds the runner:
91
+
92
+ | Framework | Project has `bin/rails` | Command |
93
+ | --- | --- | --- |
94
+ | minitest | yes | `bin/rails test <paths…>` |
95
+ | minitest | no | `ruby -Itest -e '<require each file>' <every *_test.rb beneath the paths>` |
96
+ | rspec | — | `rspec <paths…>` |
97
+
98
+ A directory means every `*_test.rb` (or `*_spec.rb`) beneath it; a glob such as
99
+ `test/api/*_test.rb` is expanded by Reqcord.
100
+
101
+ ```yaml
102
+ test:
103
+ framework: minitest
104
+ paths:
105
+ - test/api
106
+ - test/controllers/api
107
+ - test/integration
108
+ ```
109
+
110
+ ### `test.command`
111
+
112
+ Spell the command out yourself. It wins over `paths`.
113
+
114
+ ```yaml
115
+ test:
116
+ command: bin/rails test test/api test/integration/orders_test.rb
117
+ ```
118
+
119
+ The command is split into words and executed **without a shell**: no pipes,
120
+ `&&`, environment assignments or quoting tricks. Globs in the arguments are
121
+ expanded by Reqcord (`test/api/*_test.rb` works). An empty string counts as
122
+ absent.
123
+
124
+ With neither `paths` nor `command`, Reqcord runs `bin/rails test`.
125
+
126
+ Whatever runs must exit successfully. A failing suite aborts the run with
127
+ `Reqcord::GenerationError` and nothing is written — documentation is only
128
+ generated from a green suite.
129
+
130
+ ---
131
+
132
+ ## `routes`
133
+
134
+ The documented surface is the application's route table (including mounted
135
+ engines), not the captured traffic: every matching route becomes an endpoint,
136
+ and captures are attached to it.
137
+
138
+ ### `routes.prefix`
139
+
140
+ Default `/api`. Only routes whose path starts with the prefix are documented.
141
+ For a mounted engine the prefix is matched against the full path
142
+ (`/api/billing/invoices`). Set it to an empty string to document every route.
143
+
144
+ ```yaml
145
+ routes:
146
+ prefix: /api/v2
147
+ ```
148
+
149
+ Rails' own routes (`rails/…`, Active Storage, Action Mailbox, Turbo) are always
150
+ left out. `redirect(...)` routes and plain Rack mounts cannot be documented from
151
+ a test; they are counted as *skipped* in the report rather than dropped.
152
+
153
+ ### Filtering a run
154
+
155
+ Filters are given per run, not in the file:
156
+
157
+ ```bash
158
+ bin/rails reqcord:generate RESOURCE=customers
159
+ bin/rails reqcord:generate RESOURCE=customers,cart
160
+ bin/rails reqcord:generate VERSION=v2
161
+ bin/rails reqcord:generate RESOURCE=orders VERSION=v1
162
+ ```
163
+
164
+ `RESOURCE` matches the controller's last segment (`customers`), its singular
165
+ (`cart` for `CartsController`) or the full controller path
166
+ (`api/v2/customers`). `VERSION` matches a `v<number>` segment in the controller
167
+ path or the route path.
168
+
169
+ ---
170
+
171
+ ## `output`
172
+
173
+ ### `output.directory`
174
+
175
+ Default `docs/api`, relative to the application root (an absolute path is used
176
+ as is). Override per run with `REQCORD_OUTPUT`. A run writes:
177
+
178
+ ```text
179
+ <directory>/
180
+ ├── dataset.json the canonical dataset every exporter reads
181
+ ├── README.md index of resources and endpoints (markdown)
182
+ ├── api/v1/customers/ one directory per controller path (markdown)
183
+ │ ├── index.md
184
+ │ └── create.md
185
+ ├── curl/api/v1/customers/create.sh (curl)
186
+ └── postman/collection.json (postman)
187
+ ```
188
+
189
+ Directories follow the controller path, so `admin/customers` and
190
+ `api/v1/customers` never collide.
191
+
192
+ ### `output.include_uncovered`
193
+
194
+ Default `false`. A route no test reached with a `2xx` response is listed in
195
+ the index under *No Successful Request Captured* and in `dataset.json` under
196
+ `uncovered_routes`, but gets no page of its own. Set `true` to write those
197
+ pages as well, each carrying a note that nothing was captured.
198
+
199
+ ---
200
+
201
+ ## `exporters`
202
+
203
+ Which outputs to write. Default: all three.
204
+
205
+ | Name | Writes |
206
+ | --- | --- |
207
+ | `markdown` | `README.md` plus one page per endpoint: headers, typed parameter tables, example request, cURL, one example and field table per response status |
208
+ | `curl` | one runnable `.sh` per endpoint under `curl/`, built from the successful captured request |
209
+ | `postman` | `postman/collection.json`, a Postman Collection v2.1 — folders per controller namespace, one request per endpoint, every captured status saved as an example, placeholders as collection variables, bearer auth at collection level. Hoppscotch imports the same file |
210
+
211
+ `dataset.json` is always written. An unknown name raises
212
+ `Reqcord::ConfigurationError` before any test runs.
213
+
214
+ ---
215
+
216
+ ## `variables`
217
+
218
+ ### `variables.base_url`
219
+
220
+ Default `http://localhost:3000`. The host in every generated cURL and the value
221
+ of the Postman `base_url` collection variable. A trailing slash is dropped.
222
+ Override per run with `REQCORD_BASE_URL`.
223
+
224
+ Other keys under `variables` are read but not used by the built-in exporters.
225
+
226
+ ---
227
+
228
+ ## `sanitize`
229
+
230
+ Captured tests carry real credentials. Sanitization runs on every exchange
231
+ before anything reaches the dataset, so generated files never contain them.
232
+
233
+ ### `sanitize.headers`
234
+
235
+ Header name → replacement, matched case-insensitively, replaced **verbatim**:
236
+
237
+ ```yaml
238
+ sanitize:
239
+ headers:
240
+ Authorization: "Bearer {{token}}"
241
+ X-Api-Key: "{{api_key}}"
242
+ X-Account-Id: "{{account_id}}"
243
+ ```
244
+
245
+ Defaults: `Authorization: "Bearer {{token}}"` and `X-Api-Key: "{{api_key}}"`.
246
+
247
+ Some headers are redacted **whether configured or not**: `Authorization`,
248
+ `Proxy-Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`,
249
+ `X-Csrf-Token`. Without a configured replacement the value becomes a
250
+ placeholder named after the header (`{{cookie}}`, `{{x_auth_token}}`), and a
251
+ `Bearer`, `Token` or `Basic` scheme is kept so the cURL stays runnable
252
+ (`Basic {{authorization}}`).
253
+
254
+ Two more things happen to headers, on requests and responses alike:
255
+
256
+ * transport noise is dropped: `Host`, `User-Agent`, `Connection`, `Version`,
257
+ `Remote-Addr`, `Accept-Encoding`, `Cache-Control`, `Content-Length`, `Date`,
258
+ `ETag`, `Server-Timing`, `Transfer-Encoding`, `Vary`, `X-Request-Id`,
259
+ `X-Runtime`, `Referrer-Policy` and the `X-*-Options` security headers;
260
+ * headers with an empty value are dropped.
261
+
262
+ ### `sanitize.body`
263
+
264
+ Body key → replacement, matched case-insensitively **at any depth**, in
265
+ request and response bodies alike. The whole value under the key is replaced,
266
+ whatever its type.
267
+
268
+ ```yaml
269
+ sanitize:
270
+ body:
271
+ password: "{{password}}"
272
+ payment_url: "{{payment_url}}"
273
+ ```
274
+
275
+ Defaults: `password`, `password_confirmation`, `token`, `access_token`,
276
+ `refresh_token`, `api_key`, `secret`, `client_secret` (→ `{{password}}`,
277
+ `{{token}}`, `{{api_key}}`, `{{secret}}`).
278
+
279
+ A sanitized value is what the documentation shows, so it also shapes the
280
+ parameter tables: two logins whose passwords both became `{{password}}` are two
281
+ examples of the same request, and the one that succeeded is the one used for
282
+ the cURL.
283
+
284
+ ### Placeholders
285
+
286
+ Every `{{name}}` you write is kept as-is in Markdown and cURL, listed in the
287
+ index under *Placeholders*, and declared as a variable in the Postman
288
+ collection — Postman's variable syntax is the same, so the collection runs as
289
+ soon as `base_url` and `token` are filled in.
290
+
291
+ ---
292
+
293
+ ## Rake tasks
294
+
295
+ | Task | Does |
296
+ | --- | --- |
297
+ | `bin/rails reqcord:init` | writes `reqcord.yml` (never overwrites) and creates `docs/api/` |
298
+ | `bin/rails reqcord:generate` | collects routes, runs the suite with capture, writes every exporter, prints the report |
299
+ | `bin/rails reqcord:routes` | lists the routes the current `prefix` (and `RESOURCE` / `VERSION`) would document |
300
+
301
+ Every `generate` run ends with a reconciliation of the whole route table:
302
+
303
+ ```text
304
+ [reqcord] captured 92 request(s), 87 matched a documented route
305
+ [reqcord] captured a successful 2xx request for 14 of 16 endpoint(s)
306
+ [reqcord] routes: 16 = 14 documented + 2 uncovered + 0 skipped
307
+ ```
308
+
309
+ Each route is in exactly one bucket; a captured request that matched no
310
+ documented route is listed, never dropped silently.
311
+
312
+ ## When the output looks thin
313
+
314
+ | Symptom | Usual cause |
315
+ | --- | --- |
316
+ | `no request was captured` | the gem is not in the `:test` group of the Gemfile, or `test.paths` / `test.command` runs no integration tests |
317
+ | many requests captured, few matched | `routes.prefix` does not cover them — the unmatched paths are printed |
318
+ | routes documented but few covered | the tests that exercise them are not in `test.paths` (a `2xx` from a test is what makes an endpoint documented) |
319
+ | `Test suite failed while generating` | the suite is red; fix the tests, documentation is only generated from a passing run |
@@ -0,0 +1,58 @@
1
+ # Reqcord configuration. Copy this file to your application root as
2
+ # `reqcord.yml`, or generate it with `bin/rails reqcord:init`.
3
+ #
4
+ # Precedence: environment variables > reqcord.yml > Reqcord defaults.
5
+ # Every key is documented in docs/configuration.md.
6
+
7
+ version: 1
8
+
9
+ test:
10
+ # minitest or rspec.
11
+ framework: minitest
12
+
13
+ # Where the tests that exercise your API live. Reqcord picks the runner:
14
+ # `bin/rails test <paths>` in a Rails app, `rspec <paths>` for request
15
+ # specs, a plain Ruby runner when there is no bin/rails.
16
+ paths:
17
+ - test/integration
18
+
19
+ # Or spell the command out yourself; it wins over `paths`. Globs expand.
20
+ # command: bin/rails test test/integration test/api
21
+
22
+ routes:
23
+ # Only routes under this prefix are documented. Filter a single run with
24
+ # RESOURCE=customers,cart or VERSION=v2 instead of editing this file.
25
+ prefix: /api
26
+
27
+ output:
28
+ # Relative to the application root. Override per run with REQCORD_OUTPUT.
29
+ directory: docs/api
30
+
31
+ # Routes no test reached with a 2xx are listed in the index either way;
32
+ # `true` also writes a page for each of them.
33
+ include_uncovered: false
34
+
35
+ # markdown: pages under docs/api, curl: one runnable .sh per endpoint,
36
+ # postman: postman/collection.json (import into Postman or Hoppscotch).
37
+ exporters:
38
+ - curl
39
+ - markdown
40
+ - postman
41
+
42
+ variables:
43
+ # Used as the host in every generated cURL and as the Postman `base_url`
44
+ # variable. Override with REQCORD_BASE_URL.
45
+ base_url: http://localhost:3000
46
+
47
+ sanitize:
48
+ # Captured header values are replaced with these, verbatim. Authorization,
49
+ # Cookie and X-Api-Key are always redacted, configured here or not.
50
+ headers:
51
+ Authorization: "Bearer {{token}}"
52
+ X-Api-Key: "{{api_key}}"
53
+ X-Account-Id: "{{account_id}}"
54
+
55
+ # Body keys, matched at any depth, in requests and responses alike.
56
+ body:
57
+ password: "{{password}}"
58
+ access_token: "{{token}}"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CI: BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test
4
+
5
+ source "https://rubygems.org"
6
+
7
+ gemspec path: ".."
8
+
9
+ gem "railties", "~> 7.1.0"
10
+ gem "actionpack", "~> 7.1.0"
11
+
12
+ # activesupport calls JSON.parse(json, options); json 3.0 dropped that signature.
13
+ gem "json", "< 3"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CI: BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle exec rake test
4
+
5
+ source "https://rubygems.org"
6
+
7
+ gemspec path: ".."
8
+
9
+ gem "railties", "~> 7.2.0"
10
+ gem "actionpack", "~> 7.2.0"
11
+
12
+ # activesupport calls JSON.parse(json, options); json 3.0 dropped that signature.
13
+ gem "json", "< 3"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CI: BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bundle exec rake test
4
+
5
+ source "https://rubygems.org"
6
+
7
+ gemspec path: ".."
8
+
9
+ gem "railties", "~> 8.0.0"
10
+ gem "actionpack", "~> 8.0.0"
11
+
12
+ # activesupport calls JSON.parse(json, options); json 3.0 dropped that signature.
13
+ gem "json", "< 3"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CI: BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile bundle exec rake test
4
+
5
+ source "https://rubygems.org"
6
+
7
+ gemspec path: ".."
8
+
9
+ gem "railties", "~> 8.1.0"
10
+ gem "actionpack", "~> 8.1.0"
11
+
12
+ # activesupport calls JSON.parse(json, options); json 3.0 dropped that signature.
13
+ gem "json", "< 3"
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Capture
5
+ # Appends one JSON line per exchange. The lock keeps parallel test workers
6
+ # from interleaving partial lines in the same file.
7
+ class Collector
8
+ class << self
9
+ def write(exchange)
10
+ return unless Capture.enabled?
11
+
12
+ path = Capture.capture_file
13
+
14
+ FileUtils.mkdir_p(File.dirname(path))
15
+
16
+ File.open(path, "a") do |file|
17
+ file.flock(File::LOCK_EX)
18
+
19
+ file.puts(
20
+ JSON.generate(exchange)
21
+ )
22
+
23
+ file.flush
24
+ ensure
25
+ file.flock(File::LOCK_UN)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Capture
5
+ # Captures exactly what the integration test passed to Rails. Reqcord does
6
+ # not reconstruct the payload from controller params: the test call is the
7
+ # source of truth for generated cURL examples.
8
+ module IntegrationPatch
9
+ RAILS_DEFAULT_ACCEPT =
10
+ "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5"
11
+
12
+ def process(method, path, **kwargs)
13
+ raw_params = kwargs[:params]
14
+ raw_headers = kwargs[:headers]
15
+ request_format = kwargs[:as]
16
+
17
+ result = super
18
+
19
+ if Reqcord::Capture.enabled?
20
+ Reqcord::Capture::Collector.write(
21
+ reqcord_exchange(
22
+ method: method,
23
+ path: path,
24
+ params: raw_params,
25
+ input_headers: raw_headers,
26
+ request_format: request_format
27
+ )
28
+ )
29
+ end
30
+
31
+ result
32
+ end
33
+
34
+ private
35
+
36
+ def reqcord_exchange(method:, path:, params:, input_headers:, request_format:)
37
+ verb = method.to_s.upcase
38
+ query_params, body = reqcord_split_params(verb, path, params)
39
+
40
+ {
41
+ request: {
42
+ method: verb,
43
+ path: reqcord_request_path(path),
44
+ path_params: reqcord_path_parameters,
45
+ query_params: query_params,
46
+ headers: reqcord_request_headers(input_headers, body, request_format),
47
+ body: body,
48
+ content_type: reqcord_content_type(body, request_format)
49
+ },
50
+ response: {
51
+ status: response&.status,
52
+ headers: reqcord_response_headers,
53
+ body: reqcord_response_body,
54
+ content_type: response&.media_type
55
+ },
56
+ source: TestContext.current
57
+ }
58
+ end
59
+
60
+ # Keep the concrete test URL (/customers/42), not the route pattern
61
+ # (/customers/:id). A generated cURL command must be runnable as-is.
62
+ def reqcord_request_path(path)
63
+ URI.parse(path.to_s).path
64
+ rescue URI::InvalidURIError
65
+ path.to_s.split("?").first
66
+ end
67
+
68
+ def reqcord_path_parameters
69
+ return {} unless request
70
+
71
+ request.path_parameters
72
+ .except(:controller, :action, :format)
73
+ .transform_keys(&:to_s)
74
+ end
75
+
76
+ # Rails integration tests use params as query parameters for GET/HEAD and
77
+ # as the request payload for mutating verbs. Read the original test input
78
+ # instead of trying to reverse-engineer it from ActionDispatch afterwards.
79
+ def reqcord_split_params(verb, path, params)
80
+ explicit_query = reqcord_query_from_path(path)
81
+ normalized = reqcord_normalize_value(params)
82
+
83
+ if %w[GET HEAD].include?(verb)
84
+ query = explicit_query
85
+ query = reqcord_deep_merge(query, normalized) if normalized.is_a?(Hash)
86
+ [query, nil]
87
+ else
88
+ [explicit_query, reqcord_meaningful?(normalized) ? normalized : nil]
89
+ end
90
+ end
91
+
92
+ def reqcord_query_from_path(path)
93
+ uri = URI.parse(path.to_s)
94
+ return {} if uri.query.nil? || uri.query.empty?
95
+
96
+ Rack::Utils.parse_nested_query(uri.query)
97
+ rescue URI::InvalidURIError
98
+ {}
99
+ end
100
+
101
+ def reqcord_request_headers(input_headers, body, request_format)
102
+ result = {}
103
+
104
+ (input_headers || {}).each do |key, value|
105
+ result[reqcord_header_name(key)] = reqcord_normalize_value(value)
106
+ end
107
+
108
+ result.delete("X-Http-Method-Override")
109
+
110
+ if body
111
+ content_type = reqcord_content_type(body, request_format)
112
+ result["Content-Type"] ||= content_type if content_type
113
+ end
114
+
115
+ if request
116
+ accept = request.headers["Accept"]
117
+ result["Accept"] ||= accept if accept.present? && accept != RAILS_DEFAULT_ACCEPT
118
+ end
119
+
120
+ result
121
+ end
122
+
123
+ def reqcord_content_type(body, request_format)
124
+ return nil unless body
125
+
126
+ return "application/json" if request_format.to_s == "json"
127
+
128
+ request&.content_type
129
+ end
130
+
131
+ def reqcord_header_name(key)
132
+ name = key.to_s
133
+
134
+ return name if name.include?("-")
135
+
136
+ name.delete_prefix("HTTP_")
137
+ .split("_")
138
+ .map { |part| part.empty? ? part : part.capitalize }
139
+ .join("-")
140
+ end
141
+
142
+ def reqcord_response_headers
143
+ return {} unless response
144
+
145
+ response.headers.to_h
146
+ end
147
+
148
+ def reqcord_response_body
149
+ return nil unless response
150
+
151
+ body = response.body
152
+ return nil if body.nil? || body.empty?
153
+
154
+ if response.media_type == "application/json"
155
+ JSON.parse(body)
156
+ else
157
+ body
158
+ end
159
+ rescue JSON::ParserError
160
+ body
161
+ end
162
+
163
+ def reqcord_normalize_value(value)
164
+ case value
165
+ when nil, true, false, Numeric, String
166
+ value
167
+ when Symbol
168
+ value.to_s
169
+ when Hash
170
+ value.each_with_object({}) do |(key, nested), result|
171
+ result[key.to_s] = reqcord_normalize_value(nested)
172
+ end
173
+ when Array
174
+ value.map { |item| reqcord_normalize_value(item) }
175
+ else
176
+ if value.respond_to?(:to_unsafe_h)
177
+ reqcord_normalize_value(value.to_unsafe_h)
178
+ elsif value.respond_to?(:to_h)
179
+ reqcord_normalize_value(value.to_h)
180
+ elsif value.respond_to?(:as_json)
181
+ reqcord_normalize_value(value.as_json)
182
+ else
183
+ value.to_s
184
+ end
185
+ end
186
+ end
187
+
188
+ def reqcord_deep_merge(left, right)
189
+ left.merge(right) do |_key, old_value, new_value|
190
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
191
+ reqcord_deep_merge(old_value, new_value)
192
+ else
193
+ new_value
194
+ end
195
+ end
196
+ end
197
+
198
+ def reqcord_meaningful?(value)
199
+ case value
200
+ when nil then false
201
+ when Hash then value.any? { |_key, nested| reqcord_meaningful?(nested) }
202
+ when Array then value.any? { |item| reqcord_meaningful?(item) }
203
+ when String then !value.empty?
204
+ else true
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end