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,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Exporters
5
+ # Writes a Postman Collection v2.1 (`postman/collection.json`). Folders
6
+ # follow the controller namespaces, each documented endpoint is one request
7
+ # built from the successful captured example, and every captured status
8
+ # is saved as a response example. Hoppscotch imports the same file.
9
+ class Postman
10
+ SCHEMA_URL = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
11
+ PLACEHOLDER = /\{\{([A-Za-z0-9_]+)\}\}/
12
+ BEARER = /\ABearer\s+\{\{([A-Za-z0-9_]+)\}\}\z/i
13
+
14
+ def self.call(dataset:, output_dir:, configuration:)
15
+ new(
16
+ dataset: dataset,
17
+ output_dir: output_dir,
18
+ configuration: configuration
19
+ ).call
20
+ end
21
+
22
+ def initialize(dataset:, output_dir:, configuration:)
23
+ @dataset = dataset
24
+ @output_dir = Pathname(output_dir).join("postman")
25
+ @configuration = configuration
26
+ end
27
+
28
+ def call
29
+ FileUtils.mkdir_p(output_dir)
30
+
31
+ path = output_dir.join("collection.json")
32
+ File.write(path, "#{JSON.pretty_generate(collection)}\n")
33
+
34
+ [path.to_s]
35
+ end
36
+
37
+ def collection
38
+ {
39
+ info: {
40
+ name: "#{File.basename(configuration.root.to_s)} API",
41
+ description: "Generated by Reqcord from the application's integration tests.",
42
+ schema: SCHEMA_URL
43
+ },
44
+ item: folders,
45
+ variable: variables,
46
+ auth: auth
47
+ }.compact
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :dataset, :output_dir, :configuration
53
+
54
+ # api/v2/customers -> Api > V2 > Customers, requests in the leaf folder.
55
+ def folders
56
+ tree = { items: [], children: {} }
57
+
58
+ dataset.resources.each do |resource|
59
+ requests = resource.endpoints.select(&:curl_ready?).map { |endpoint| request_item(endpoint) }
60
+ next if requests.empty?
61
+
62
+ leaf = resource.segments.reduce(tree) do |node, segment|
63
+ node[:children][segment] ||= { items: [], children: {} }
64
+ end
65
+
66
+ leaf[:items].concat(requests)
67
+ end
68
+
69
+ render_folders(tree)
70
+ end
71
+
72
+ def render_folders(node)
73
+ node[:children].map do |segment, child|
74
+ { name: Support.titleize(segment), item: render_folders(child) + child[:items] }
75
+ end
76
+ end
77
+
78
+ def request_item(endpoint)
79
+ {
80
+ name: endpoint.name,
81
+ request: request_object(endpoint.primary_request_example, endpoint),
82
+ response: endpoint.responses.map { |response| response_object(response, endpoint) }
83
+ }
84
+ end
85
+
86
+ def request_object(example, endpoint)
87
+ {
88
+ method: example.method,
89
+ description: description(endpoint),
90
+ header: headers(example),
91
+ url: url_object(example),
92
+ body: body_object(example),
93
+ auth: request_auth(example)
94
+ }.compact
95
+ end
96
+
97
+ def description(endpoint)
98
+ lines = ["#{endpoint.method} #{endpoint.path}", "#{endpoint.controller}##{endpoint.action}"]
99
+ lines << "Also answers #{endpoint.also_methods.join(', ')}." unless endpoint.also_methods.empty?
100
+ lines << "Route: #{endpoint.route_name}" if endpoint.route_name
101
+
102
+ lines.join("\n")
103
+ end
104
+
105
+ # The bearer credential lives on the collection; a request that carried
106
+ # it inherits, one that did not says so, so public endpoints replay
107
+ # exactly as their tests did.
108
+ def headers(example)
109
+ example.headers.filter_map do |key, value|
110
+ next if auth && key.to_s.casecmp?("Authorization")
111
+
112
+ { key: key.to_s, value: value.to_s }
113
+ end
114
+ end
115
+
116
+ def request_auth(example)
117
+ return nil unless auth
118
+ return nil if example.headers.any? { |key, _| key.to_s.casecmp?("Authorization") }
119
+
120
+ { type: "noauth" }
121
+ end
122
+
123
+ def url_object(example)
124
+ url = {
125
+ raw: "{{base_url}}#{Renderers::Payload.path_with_query(example)}",
126
+ host: ["{{base_url}}"],
127
+ path: example.path.split("/").reject(&:empty?),
128
+ query: Renderers::Payload.query_pairs(example).map { |key, value| { key: key, value: value.to_s } }
129
+ }
130
+
131
+ url.delete(:query) if url[:query].empty?
132
+ url
133
+ end
134
+
135
+ def body_object(example)
136
+ return nil unless example.body?
137
+
138
+ if Renderers::Payload.json?(example)
139
+ { mode: "raw", raw: Renderers::Payload.raw_body(example), options: { raw: { language: "json" } } }
140
+ elsif example.body.is_a?(Hash)
141
+ pairs = Renderers::Payload.form_pairs(example).map { |key, value| { key: key, value: value.to_s, type: "text" } }
142
+
143
+ { mode: "urlencoded", urlencoded: pairs }
144
+ else
145
+ { mode: "raw", raw: example.body.to_s }
146
+ end
147
+ end
148
+
149
+ def response_object(response, endpoint)
150
+ example = response.example
151
+ request = endpoint.request_examples.find { |candidate| candidate.response_status == response.status } ||
152
+ endpoint.primary_request_example
153
+
154
+ {
155
+ name: example.title,
156
+ originalRequest: request_object(request, endpoint),
157
+ status: example.status_text,
158
+ code: response.status,
159
+ _postman_previewlanguage: example.content_type.to_s.include?("json") ? "json" : "text",
160
+ header: example.headers.map { |key, value| { key: key.to_s, value: value.to_s } },
161
+ body: response_body(example)
162
+ }
163
+ end
164
+
165
+ def response_body(example)
166
+ return "" unless example.body?
167
+ return example.body if example.body.is_a?(String)
168
+
169
+ JSON.pretty_generate(example.body)
170
+ end
171
+
172
+ # `base_url`, plus every `{{name}}` the sanitizer wrote into the
173
+ # documented examples, so the collection declares what it expects.
174
+ def variables
175
+ names = documented_examples.flat_map do |example|
176
+ [example.headers, example.body, example.query_params].flat_map do |value|
177
+ JSON.generate(value).scan(PLACEHOLDER).flatten
178
+ end
179
+ end
180
+
181
+ names |= (configuration.sanitized_headers.values + configuration.sanitized_body_keys.values)
182
+ .flat_map { |value| value.to_s.scan(PLACEHOLDER).flatten }
183
+
184
+ [{ key: "base_url", value: configuration.base_url, type: "string" }] +
185
+ (names - ["base_url"]).uniq.sort.map { |name| { key: name, value: "", type: "string" } }
186
+ end
187
+
188
+ def auth
189
+ return @auth if defined?(@auth)
190
+
191
+ placeholder = documented_examples.filter_map do |example|
192
+ value = example.headers.find { |key, _| key.to_s.casecmp?("Authorization") }&.last
193
+ value.to_s[BEARER, 1]
194
+ end.first
195
+
196
+ @auth = placeholder && { type: "bearer", bearer: [{ key: "token", value: "{{#{placeholder}}}", type: "string" }] }
197
+ end
198
+
199
+ def documented_examples
200
+ @documented_examples ||= dataset.curl_ready_endpoints.flat_map(&:request_examples)
201
+ end
202
+ end
203
+
204
+ register("postman", Postman)
205
+ end
206
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ # Exporters register themselves here, so adding one means adding a file
5
+ # rather than editing the pipeline.
6
+ module Exporters
7
+ class << self
8
+ def register(name, exporter)
9
+ registry[name.to_s] = exporter
10
+ end
11
+
12
+ def fetch(name)
13
+ registry.fetch(name.to_s) do
14
+ raise ConfigurationError,
15
+ "unknown exporter #{name.inspect}, expected one of #{names.join(', ')}"
16
+ end
17
+ end
18
+
19
+ def registered?(name)
20
+ registry.key?(name.to_s)
21
+ end
22
+
23
+ def names
24
+ registry.keys.sort
25
+ end
26
+
27
+ def registry
28
+ @registry ||= {}
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require "securerandom"
6
+
7
+ module Reqcord
8
+ # Runs the test suite in a subprocess with capture enabled, then turns the
9
+ # captured exchanges into documentation. Route collection happens here, in a
10
+ # process that already has the application booted.
11
+ class Generator
12
+ def self.call(
13
+ resources: [],
14
+ version: nil
15
+ )
16
+ new(
17
+ resources: resources,
18
+ version: version
19
+ ).call
20
+ end
21
+
22
+ def initialize(resources:, version:, configuration: Reqcord.configuration)
23
+ @resources = Array(resources).map(&:to_s)
24
+ @version = version&.to_s
25
+ @configuration = configuration
26
+ end
27
+
28
+ def call
29
+ validate!
30
+
31
+ routes = collect_routes
32
+
33
+ capture_file = build_capture_file
34
+
35
+ run_tests(capture_file)
36
+
37
+ exchanges =
38
+ read_exchanges(capture_file)
39
+
40
+ dataset =
41
+ build_dataset(
42
+ routes,
43
+ exchanges
44
+ )
45
+
46
+ report(exchanges, dataset)
47
+
48
+ write_outputs(dataset)
49
+
50
+ dataset
51
+ ensure
52
+ FileUtils.rm_f(capture_file) if capture_file
53
+ end
54
+
55
+ # Paths that were captured but belong to no documented route. Kept so the
56
+ # run can say why a request did not turn into documentation.
57
+ attr_reader :unmatched_paths
58
+
59
+ # Routes seen in the table but not documentable, by reason (redirect,
60
+ # mount). Set by collect_routes; exposed so a run can be reconciled.
61
+ attr_accessor :skipped_routes
62
+
63
+ # The command that runs the suite. An explicit `test.command` is used as
64
+ # written (globs expanded, since no shell is involved); otherwise a runner
65
+ # is built from `test.paths`: `bin/rails test` when the application has
66
+ # one, a plain Ruby runner when it does not, `rspec` for request specs.
67
+ def test_argv
68
+ explicit = configuration.test_command
69
+ return expand_globs(Shellwords.split(explicit)) if explicit
70
+
71
+ paths = configuration.test_paths
72
+ return %w[bin/rails test] if paths.empty?
73
+
74
+ case configuration.test_framework.to_s
75
+ when "rspec"
76
+ ["rspec", *paths]
77
+ else
78
+ if configuration.root.join("bin", "rails").exist?
79
+ ["bin/rails", "test", *paths]
80
+ else
81
+ ["ruby", "-Itest", "-e", "ARGV.each { |file| require File.expand_path(file) }", *test_files(paths)]
82
+ end
83
+ end
84
+ end
85
+
86
+ # Builds the dataset from exchanges that were captured earlier, without
87
+ # running the suite again.
88
+ def build_dataset(routes, exchanges)
89
+ endpoints = {}
90
+ @unmatched_paths = []
91
+
92
+ # A route answering any verb is documented once per verb a test used.
93
+ routes.each do |route|
94
+ endpoints[[route, route.method]] = route.endpoint unless route.any_verb?
95
+ end
96
+
97
+ exchanges.each do |raw_exchange|
98
+ sanitized =
99
+ Sanitizers::Sanitizer.call(
100
+ raw_exchange,
101
+ configuration: configuration
102
+ )
103
+
104
+ route = find_route(routes, sanitized)
105
+ request = sanitized.fetch("request")
106
+
107
+ unless route
108
+ @unmatched_paths << "#{request['method']} #{request['path']}"
109
+ next
110
+ end
111
+
112
+ verb = route.any_verb? ? request.fetch("method").to_s.upcase : route.method
113
+ endpoint = endpoints[[route, verb]] ||= route.endpoint(method: verb)
114
+
115
+ attach_exchange(endpoint, sanitized)
116
+ end
117
+
118
+ routes.select(&:any_verb?).each do |route|
119
+ next if endpoints.keys.any? { |(seen, _verb)| seen == route }
120
+
121
+ endpoints[[route, RouteCollector::ANY]] = route.endpoint
122
+ end
123
+
124
+ Dataset.new(
125
+ endpoints: Dataset.fold_method_twins(endpoints.values)
126
+ )
127
+ end
128
+
129
+ private
130
+
131
+ attr_reader :resources,
132
+ :version,
133
+ :configuration
134
+
135
+ # Silence here is the worst outcome: an empty page set looks the same
136
+ # whether the API has no tests or the capture never ran.
137
+ def report(exchanges, dataset)
138
+ if exchanges.empty?
139
+ Reqcord.warn("no request was captured")
140
+ Reqcord.warn(" is reqcord in the :test group of your Gemfile, and does `test.command` run integration tests?")
141
+ return
142
+ end
143
+
144
+ covered = dataset.curl_ready_endpoints.size
145
+ uncovered = dataset.uncovered_endpoints.size
146
+ matched = exchanges.size - unmatched_paths.size
147
+ skipped = skipped_routes || {}
148
+ skipped_total = skipped.values.sum
149
+
150
+ Reqcord.log("captured #{exchanges.size} request(s), #{matched} matched a documented route")
151
+ Reqcord.log("captured a successful 2xx request for #{covered} of #{dataset.endpoints.size} endpoint(s)")
152
+
153
+ # Every route lands in exactly one bucket; the sum is the proof.
154
+ Reqcord.log(
155
+ "routes: #{dataset.endpoints.size + skipped_total} = " \
156
+ "#{covered} documented + #{uncovered} uncovered + #{skipped_total} skipped"
157
+ )
158
+
159
+ if skipped_total.positive?
160
+ reasons = skipped.map { |reason, count| "#{count} #{reason}" }.join(", ")
161
+ Reqcord.log("skipped #{skipped_total} route(s) that cannot be documented: #{reasons}")
162
+ end
163
+
164
+ return if unmatched_paths.empty?
165
+
166
+ shown = unmatched_paths.uniq.first(5)
167
+
168
+ Reqcord.log("#{unmatched_paths.uniq.size} path(s) matched no documented route, for example:")
169
+ shown.each { |path| Reqcord.log(" #{path}") }
170
+ end
171
+
172
+ FRAMEWORKS = %w[minitest rspec].freeze
173
+
174
+ def validate!
175
+ unless FRAMEWORKS.include?(configuration.test_framework.to_s)
176
+ raise ConfigurationError,
177
+ "unsupported test framework: #{configuration.test_framework.inspect}, " \
178
+ "expected one of #{FRAMEWORKS.join(', ')}"
179
+ end
180
+
181
+ configuration.exporters.each { |name| Exporters.fetch(name) }
182
+
183
+ nil
184
+ end
185
+
186
+ def collect_routes
187
+ collector = RouteCollector.new(
188
+ resources: resources,
189
+ version: version,
190
+ prefix: configuration.route_prefix
191
+ )
192
+
193
+ routes = collector.call
194
+ @skipped_routes = collector.skipped
195
+
196
+ routes
197
+ end
198
+
199
+ # A directory means every *_test.rb (or *_spec.rb) beneath it.
200
+ def test_files(paths)
201
+ pattern = configuration.test_framework.to_s == "rspec" ? "*_spec.rb" : "*_test.rb"
202
+
203
+ paths.flat_map do |path|
204
+ if configuration.root.join(path).directory?
205
+ Dir.glob(File.join(path, "**", pattern), base: configuration.root.to_s).sort
206
+ else
207
+ expand_globs([path])
208
+ end
209
+ end
210
+ end
211
+
212
+ def expand_globs(args)
213
+ args.flat_map do |arg|
214
+ next [arg] unless arg.match?(/[*?\[{]/)
215
+
216
+ matches = Dir.glob(arg, base: configuration.root.to_s).sort
217
+ matches.empty? ? [arg] : matches
218
+ end
219
+ end
220
+
221
+ def build_capture_file
222
+ Reqcord.root.join(
223
+ "tmp",
224
+ "reqcord",
225
+ "#{SecureRandom.hex(12)}.ndjson"
226
+ ).to_s
227
+ end
228
+
229
+ def run_tests(capture_file)
230
+ FileUtils.mkdir_p(
231
+ File.dirname(capture_file)
232
+ )
233
+
234
+ env = {
235
+ "REQCORD_CAPTURE" => "1",
236
+ "REQCORD_CAPTURE_FILE" => capture_file
237
+ }
238
+
239
+ command = test_argv
240
+
241
+ Reqcord.log("running #{Shellwords.join(command)}")
242
+
243
+ # Streamed rather than captured: a long suite should print as it runs.
244
+ status =
245
+ Open3.popen2e(
246
+ env,
247
+ *command,
248
+ chdir: Reqcord.root.to_s
249
+ ) do |stdin, output, wait_thread|
250
+ stdin.close
251
+
252
+ output.each_line { |line| $stdout.print(line) }
253
+
254
+ wait_thread.value
255
+ end
256
+
257
+ return if status.success?
258
+
259
+ raise GenerationError,
260
+ "Test suite failed while generating Reqcord documentation"
261
+ end
262
+
263
+ def read_exchanges(capture_file)
264
+ return [] unless File.exist?(capture_file)
265
+
266
+ File.readlines(capture_file)
267
+ .filter_map do |line|
268
+ line = line.strip
269
+
270
+ next if line.empty?
271
+
272
+ begin
273
+ JSON.parse(line)
274
+ rescue JSON::ParserError
275
+ Reqcord.warn("skipping malformed capture line")
276
+ nil
277
+ end
278
+ end
279
+ end
280
+
281
+ def find_route(routes, exchange)
282
+ request = exchange.fetch("request")
283
+ method = request.fetch("method")
284
+ path = request.fetch("path")
285
+
286
+ routes.find { |route| route.matches?(method, path) }
287
+ rescue StandardError => e
288
+ raise GenerationError, "could not match #{method} #{path} against the route table: #{e.message}"
289
+ end
290
+
291
+ def attach_exchange(endpoint, exchange)
292
+ request_data = exchange.fetch("request")
293
+ response_data = exchange.fetch("response")
294
+ source = normalize_source(exchange["source"])
295
+ status = response_data.fetch("status")
296
+
297
+ request =
298
+ RequestExample.new(
299
+ name: example_name(source),
300
+ method: request_data.fetch("method"),
301
+ # The concrete path, not the route pattern: the cURL must be runnable.
302
+ path: request_data.fetch("path"),
303
+ path_params: request_data["path_params"] || {},
304
+ query_params: request_data["query_params"] || {},
305
+ headers: request_data["headers"] || {},
306
+ body: request_data["body"],
307
+ content_type: request_data["content_type"],
308
+ response_status: status,
309
+ source: source
310
+ )
311
+
312
+ response =
313
+ ResponseExample.new(
314
+ name: example_name(source),
315
+ status: status,
316
+ headers: response_data["headers"] || {},
317
+ body: response_data["body"],
318
+ content_type: response_data["content_type"],
319
+ source: source
320
+ )
321
+
322
+ endpoint.add_exchange(
323
+ request: request,
324
+ response: response
325
+ )
326
+ end
327
+
328
+ # "test_creates_customer" -> "Creates Customer"
329
+ def example_name(source)
330
+ test = source["test"].to_s.sub(/\Atest_/, "")
331
+
332
+ test.empty? ? nil : Support.titleize(test)
333
+ end
334
+
335
+ def normalize_source(source)
336
+ source = (source || {}).transform_keys(&:to_s)
337
+ file = source["file"].to_s
338
+ prefix = "#{Reqcord.root}/"
339
+
340
+ source["file"] = file.delete_prefix(prefix) unless file.empty?
341
+ source.compact
342
+ end
343
+
344
+ def write_outputs(dataset)
345
+ output = configuration.output_directory
346
+
347
+ FileUtils.mkdir_p(output)
348
+
349
+ written = [dataset.write(output.join("dataset.json"))]
350
+
351
+ configuration.exporters.each do |name|
352
+ written.concat(
353
+ Exporters.fetch(name).call(
354
+ dataset: dataset,
355
+ output_dir: output,
356
+ configuration: configuration
357
+ )
358
+ )
359
+ end
360
+
361
+ written
362
+ end
363
+ end
364
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path(
7
+ "../tasks/reqcord.rake",
8
+ __dir__
9
+ )
10
+ end
11
+
12
+ initializer "reqcord.capture" do
13
+ next unless Reqcord::Capture.enabled?
14
+
15
+ require "action_dispatch/testing/integration"
16
+ require_relative "capture/integration_patch"
17
+
18
+ unless ActionDispatch::Integration::Session <
19
+ Reqcord::Capture::IntegrationPatch
20
+
21
+ ActionDispatch::Integration::Session.prepend(
22
+ Reqcord::Capture::IntegrationPatch
23
+ )
24
+ end
25
+
26
+ # Capture itself is framework agnostic; only naming the example needs to
27
+ # know which test framework is running.
28
+ case Reqcord.configuration.test_framework.to_s
29
+ when "minitest"
30
+ require "minitest/test"
31
+ require_relative "capture/minitest_context"
32
+
33
+ unless Minitest::Test < Reqcord::Capture::MinitestContext
34
+ Minitest::Test.prepend(
35
+ Reqcord::Capture::MinitestContext
36
+ )
37
+ end
38
+
39
+ when "rspec"
40
+ require "rspec/core"
41
+ require_relative "capture/rspec_context"
42
+
43
+ Reqcord::Capture::RSpecContext.install!
44
+
45
+ else
46
+ raise ConfigurationError,
47
+ "unsupported test framework: #{Reqcord.configuration.test_framework.inspect}"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Renderers
5
+ class Curl
6
+ def self.call(request, base_url:)
7
+ new(
8
+ request,
9
+ base_url: base_url
10
+ ).call
11
+ end
12
+
13
+ def initialize(request, base_url:)
14
+ @request = request
15
+ @base_url = base_url.to_s.sub(%r{/$}, "")
16
+ end
17
+
18
+ def call
19
+ parts = [
20
+ "curl --request #{request.method}",
21
+ %(--url "#{url}")
22
+ ]
23
+
24
+ request.headers.each do |key, value|
25
+ parts << %(--header "#{key}: #{escape_header(value)}")
26
+ end
27
+
28
+ parts << data_argument if request.body?
29
+
30
+ parts.join(" \\\n ")
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :request, :base_url
36
+
37
+ def url
38
+ "#{base_url}#{Payload.path_with_query(request)}"
39
+ end
40
+
41
+ def data_argument
42
+ "--data '#{shell_single_quote(Payload.raw_body(request))}'"
43
+ end
44
+
45
+ def shell_single_quote(value)
46
+ value.to_s.gsub("'", %q('"'"'))
47
+ end
48
+
49
+ def escape_header(value)
50
+ value.to_s
51
+ .gsub("\\") { "\\\\" }
52
+ .gsub('"') { '\\"' }
53
+ end
54
+ end
55
+ end
56
+ end