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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +252 -95
- data/Rakefile +13 -0
- data/docs/configuration.md +319 -0
- data/examples/reqcord.yml +58 -0
- data/gemfiles/rails_7.1.gemfile +13 -0
- data/gemfiles/rails_7.2.gemfile +13 -0
- data/gemfiles/rails_8.0.gemfile +13 -0
- data/gemfiles/rails_8.1.gemfile +13 -0
- data/lib/reqcord/capture/collector.rb +31 -0
- data/lib/reqcord/capture/integration_patch.rb +209 -0
- data/lib/reqcord/capture/minitest_context.rb +34 -0
- data/lib/reqcord/capture/rspec_context.rb +42 -0
- data/lib/reqcord/capture/test_context.rb +25 -0
- data/lib/reqcord/capture.rb +19 -0
- data/lib/reqcord/configuration.rb +198 -0
- data/lib/reqcord/dataset.rb +176 -0
- data/lib/reqcord/endpoint.rb +263 -0
- data/lib/reqcord/errors.rb +9 -0
- data/lib/reqcord/exporters/curl.rb +68 -0
- data/lib/reqcord/exporters/markdown.rb +295 -0
- data/lib/reqcord/exporters/postman.rb +206 -0
- data/lib/reqcord/exporters.rb +32 -0
- data/lib/reqcord/generator.rb +364 -0
- data/lib/reqcord/railtie.rb +51 -0
- data/lib/reqcord/renderers/curl.rb +56 -0
- data/lib/reqcord/renderers/payload.rb +69 -0
- data/lib/reqcord/request_example.rb +104 -0
- data/lib/reqcord/response_example.rb +72 -0
- data/lib/reqcord/route_collector.rb +242 -0
- data/lib/reqcord/sanitizers/sanitizer.rb +140 -0
- data/lib/reqcord/schema.rb +187 -0
- data/lib/reqcord/support.rb +58 -0
- data/lib/reqcord/version.rb +5 -0
- data/lib/reqcord.rb +78 -0
- data/lib/tasks/reqcord.rake +99 -0
- data/reqcord.gemspec +60 -0
- 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
|