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,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
module Capture
|
|
5
|
+
module MinitestContext
|
|
6
|
+
def before_setup
|
|
7
|
+
file, line = reqcord_source_location
|
|
8
|
+
|
|
9
|
+
TestContext.current = {
|
|
10
|
+
test: name,
|
|
11
|
+
suite: self.class.name,
|
|
12
|
+
file: file,
|
|
13
|
+
line: line
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def after_teardown
|
|
20
|
+
super
|
|
21
|
+
ensure
|
|
22
|
+
TestContext.clear
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def reqcord_source_location
|
|
28
|
+
method(name).source_location
|
|
29
|
+
rescue NameError
|
|
30
|
+
[nil, nil]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
module Capture
|
|
5
|
+
# The RSpec counterpart of MinitestContext: names captured examples after
|
|
6
|
+
# the request spec that produced them.
|
|
7
|
+
module RSpecContext
|
|
8
|
+
class << self
|
|
9
|
+
def install!(rspec = ::RSpec)
|
|
10
|
+
return false if @installed
|
|
11
|
+
|
|
12
|
+
rspec.configure do |config|
|
|
13
|
+
config.before(:each) do |example|
|
|
14
|
+
TestContext.current = RSpecContext.context_for(example)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
config.after(:each) do
|
|
18
|
+
TestContext.clear
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
@installed = true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def installed?
|
|
26
|
+
@installed == true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def context_for(example)
|
|
30
|
+
metadata = example.metadata
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
test: metadata[:description],
|
|
34
|
+
suite: metadata[:example_group][:description],
|
|
35
|
+
file: metadata[:file_path],
|
|
36
|
+
line: metadata[:line_number]
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
module Capture
|
|
5
|
+
# The test that is currently running, kept per thread so parallel runners
|
|
6
|
+
# do not attribute an exchange to the wrong test.
|
|
7
|
+
module TestContext
|
|
8
|
+
THREAD_KEY = :reqcord_test_context
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def current
|
|
12
|
+
Thread.current[THREAD_KEY] || {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def current=(value)
|
|
16
|
+
Thread.current[THREAD_KEY] = value
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def clear
|
|
20
|
+
Thread.current[THREAD_KEY] = nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
# Capture runs inside the test process and writes to a file the generator
|
|
5
|
+
# reads afterwards. Both variables are set by `reqcord:generate`, so an
|
|
6
|
+
# ordinary test run patches nothing and writes nothing.
|
|
7
|
+
module Capture
|
|
8
|
+
class << self
|
|
9
|
+
def enabled?
|
|
10
|
+
ENV["REQCORD_CAPTURE"] == "1" &&
|
|
11
|
+
capture_file
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def capture_file
|
|
15
|
+
ENV["REQCORD_CAPTURE_FILE"]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
class Configuration
|
|
5
|
+
DEFAULTS = {
|
|
6
|
+
"version" => 1,
|
|
7
|
+
|
|
8
|
+
# No default command: with neither `command` nor `paths` the generator
|
|
9
|
+
# falls back to `bin/rails test`, and `paths` alone must be able to win.
|
|
10
|
+
"test" => {
|
|
11
|
+
"framework" => "minitest"
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
"routes" => {
|
|
15
|
+
"prefix" => "/api"
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
"output" => {
|
|
19
|
+
"directory" => "docs/api",
|
|
20
|
+
|
|
21
|
+
# A route no test exercised is reported in the index; writing a page
|
|
22
|
+
# with nothing on it only adds noise.
|
|
23
|
+
"include_uncovered" => false
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
"exporters" => %w[curl markdown postman],
|
|
27
|
+
|
|
28
|
+
"variables" => {
|
|
29
|
+
"base_url" => "http://localhost:3000"
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
"sanitize" => {
|
|
33
|
+
"headers" => {
|
|
34
|
+
"Authorization" => "Bearer {{token}}",
|
|
35
|
+
"X-Api-Key" => "{{api_key}}"
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
"body" => {
|
|
39
|
+
"password" => "{{password}}",
|
|
40
|
+
"password_confirmation" => "{{password}}",
|
|
41
|
+
"token" => "{{token}}",
|
|
42
|
+
"access_token" => "{{token}}",
|
|
43
|
+
"refresh_token" => "{{token}}",
|
|
44
|
+
"api_key" => "{{api_key}}",
|
|
45
|
+
"secret" => "{{secret}}",
|
|
46
|
+
"client_secret" => "{{secret}}"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# Headers that describe the transport rather than the API. A documented
|
|
52
|
+
# cURL that carries them is worse than one that does not: `Host` alone
|
|
53
|
+
# would send the reader's request to the wrong virtual host.
|
|
54
|
+
NOISY_HEADERS = %w[
|
|
55
|
+
host
|
|
56
|
+
user-agent
|
|
57
|
+
connection
|
|
58
|
+
version
|
|
59
|
+
remote-addr
|
|
60
|
+
accept-encoding
|
|
61
|
+
cache-control
|
|
62
|
+
content-length
|
|
63
|
+
date
|
|
64
|
+
etag
|
|
65
|
+
server-timing
|
|
66
|
+
transfer-encoding
|
|
67
|
+
vary
|
|
68
|
+
x-content-type-options
|
|
69
|
+
x-download-options
|
|
70
|
+
x-frame-options
|
|
71
|
+
x-permitted-cross-domain-policies
|
|
72
|
+
x-request-id
|
|
73
|
+
x-runtime
|
|
74
|
+
x-xss-protection
|
|
75
|
+
referrer-policy
|
|
76
|
+
].freeze
|
|
77
|
+
|
|
78
|
+
attr_reader :data, :root
|
|
79
|
+
|
|
80
|
+
def self.load(root:)
|
|
81
|
+
new(root: root).load
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def initialize(root:)
|
|
85
|
+
@root = Pathname(root)
|
|
86
|
+
@data = deep_dup(DEFAULTS)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load
|
|
90
|
+
path = root.join("reqcord.yml")
|
|
91
|
+
|
|
92
|
+
return self unless path.exist?
|
|
93
|
+
|
|
94
|
+
raw = YAML.safe_load_file(
|
|
95
|
+
path,
|
|
96
|
+
aliases: false
|
|
97
|
+
) || {}
|
|
98
|
+
|
|
99
|
+
unless raw.is_a?(Hash)
|
|
100
|
+
raise ConfigurationError,
|
|
101
|
+
"reqcord.yml must contain a YAML object"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@data = deep_merge(@data, stringify_keys(raw))
|
|
105
|
+
|
|
106
|
+
self
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def test_framework
|
|
110
|
+
ENV["REQCORD_TEST_FRAMEWORK"] ||
|
|
111
|
+
data.dig("test", "framework")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# An explicit command wins; nil means "build one from test.paths".
|
|
115
|
+
def test_command
|
|
116
|
+
value = ENV["REQCORD_TEST_COMMAND"] || data.dig("test", "command")
|
|
117
|
+
|
|
118
|
+
value.to_s.empty? ? nil : value.to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Directories, files or globs the suite lives in; Reqcord picks the runner.
|
|
122
|
+
def test_paths
|
|
123
|
+
Array(data.dig("test", "paths")).map(&:to_s)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def route_prefix
|
|
127
|
+
data.dig("routes", "prefix")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def output_directory
|
|
131
|
+
value =
|
|
132
|
+
ENV["REQCORD_OUTPUT"] ||
|
|
133
|
+
data.dig("output", "directory") ||
|
|
134
|
+
"docs/api"
|
|
135
|
+
|
|
136
|
+
root.join(value)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def include_uncovered?
|
|
140
|
+
data.dig("output", "include_uncovered") == true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def exporters
|
|
144
|
+
Array(data["exporters"]).map(&:to_s)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def variables
|
|
148
|
+
(data["variables"] || {}).merge(
|
|
149
|
+
"base_url" => base_url
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def base_url
|
|
154
|
+
ENV["REQCORD_BASE_URL"] ||
|
|
155
|
+
data.dig("variables", "base_url") ||
|
|
156
|
+
"http://localhost:3000"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def sanitized_headers
|
|
160
|
+
data.dig("sanitize", "headers") || {}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def sanitized_body_keys
|
|
164
|
+
data.dig("sanitize", "body") || {}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def noisy_header?(key)
|
|
168
|
+
NOISY_HEADERS.include?(key.to_s.downcase)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def deep_merge(left, right)
|
|
174
|
+
left.merge(right) do |_key, old_value, new_value|
|
|
175
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
|
|
176
|
+
deep_merge(old_value, new_value)
|
|
177
|
+
else
|
|
178
|
+
new_value
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def deep_dup(value)
|
|
184
|
+
Marshal.load(Marshal.dump(value))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def stringify_keys(hash)
|
|
188
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
189
|
+
result[key.to_s] =
|
|
190
|
+
if value.is_a?(Hash)
|
|
191
|
+
stringify_keys(value)
|
|
192
|
+
else
|
|
193
|
+
value
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
class Dataset
|
|
5
|
+
# 2: endpoints carry `parameters` (path/query/body schemas), `responses`
|
|
6
|
+
# (one schema + example per status), `route_name` and `also_methods`.
|
|
7
|
+
SCHEMA_VERSION = 2
|
|
8
|
+
|
|
9
|
+
# An endpoint page must not overwrite the resource or dataset pages.
|
|
10
|
+
RESERVED_BASENAMES = {
|
|
11
|
+
"index" => "list",
|
|
12
|
+
"readme" => "readme-endpoint",
|
|
13
|
+
"dataset" => "dataset-endpoint"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# Endpoints served by one controller, as the exporters walk them. Keyed by
|
|
17
|
+
# the full controller path so `admin/users` and `api/v1/users` stay apart.
|
|
18
|
+
class Resource
|
|
19
|
+
attr_reader :name, :endpoints
|
|
20
|
+
|
|
21
|
+
def initialize(name)
|
|
22
|
+
@name = name.to_s
|
|
23
|
+
@endpoints = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def segments
|
|
27
|
+
name.split("/")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def short_name
|
|
31
|
+
segments.last.to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def namespace
|
|
35
|
+
segments[0...-1].join("/")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Nested directories / folders: api/v2/customers.
|
|
39
|
+
def slug
|
|
40
|
+
segments.map { |segment| Support.parameterize(segment) }.join("/")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def title
|
|
44
|
+
Support.titleize(short_name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def api_versions
|
|
48
|
+
endpoints.map(&:api_version).compact.uniq.sort
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One stable file name per endpoint. Two routes sharing an action
|
|
52
|
+
# (`match … via: [:get, :post]`) are told apart by verb, never by a
|
|
53
|
+
# bare counter.
|
|
54
|
+
def file_basenames
|
|
55
|
+
bases = endpoints.to_h { |endpoint| [endpoint, RESERVED_BASENAMES.fetch(endpoint.slug, endpoint.slug)] }
|
|
56
|
+
shared = bases.values.tally
|
|
57
|
+
seen = Hash.new(0)
|
|
58
|
+
|
|
59
|
+
bases.to_h do |endpoint, base|
|
|
60
|
+
basename = shared[base] > 1 ? "#{base}-#{endpoint.method.downcase}" : base
|
|
61
|
+
seen[basename] += 1
|
|
62
|
+
basename = "#{basename}-#{seen[basename]}" if seen[basename] > 1
|
|
63
|
+
|
|
64
|
+
[endpoint, basename]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Rails emits both PATCH and PUT for `update`; one page serves both. The
|
|
70
|
+
# verb a test used wins, PATCH when neither or both did.
|
|
71
|
+
def self.fold_method_twins(endpoints)
|
|
72
|
+
groups = endpoints.group_by { |endpoint| [endpoint.path, endpoint.controller, endpoint.action] }
|
|
73
|
+
|
|
74
|
+
endpoints.filter_map do |endpoint|
|
|
75
|
+
next endpoint unless %w[PATCH PUT].include?(endpoint.method)
|
|
76
|
+
|
|
77
|
+
twins = groups.fetch([endpoint.path, endpoint.controller, endpoint.action])
|
|
78
|
+
.select { |candidate| %w[PATCH PUT].include?(candidate.method) }
|
|
79
|
+
|
|
80
|
+
next endpoint unless twins.size == 2
|
|
81
|
+
|
|
82
|
+
primary = twins.find(&:curl_ready?) || twins.find { |candidate| candidate.method == "PATCH" }
|
|
83
|
+
|
|
84
|
+
next nil unless endpoint.equal?(primary)
|
|
85
|
+
|
|
86
|
+
twin = twins.find { |candidate| !candidate.equal?(primary) }
|
|
87
|
+
twin.request_examples.each { |example| primary.add_request_example(example) }
|
|
88
|
+
twin.response_examples.each { |example| primary.add_response_example(example) }
|
|
89
|
+
primary.also_methods = [twin.method]
|
|
90
|
+
|
|
91
|
+
primary
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
attr_reader :schema_version, :endpoints
|
|
96
|
+
|
|
97
|
+
def initialize(
|
|
98
|
+
schema_version: SCHEMA_VERSION,
|
|
99
|
+
endpoints: []
|
|
100
|
+
)
|
|
101
|
+
@schema_version = schema_version
|
|
102
|
+
@endpoints = endpoints
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def add(endpoint)
|
|
106
|
+
endpoints << endpoint
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def empty?
|
|
110
|
+
endpoints.empty?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Endpoints for which Reqcord captured at least one successful 2xx
|
|
114
|
+
# request. These are safe to use as canonical cURL/documentation examples.
|
|
115
|
+
def curl_ready_endpoints
|
|
116
|
+
endpoints.select(&:curl_ready?)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Backwards-compatible alias for callers that previously asked for
|
|
120
|
+
# documented endpoints. In the cURL-first MVP, documented means a
|
|
121
|
+
# successful request was actually captured.
|
|
122
|
+
def documented_endpoints
|
|
123
|
+
curl_ready_endpoints
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def uncovered_endpoints
|
|
127
|
+
endpoints.reject(&:curl_ready?)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def resources
|
|
131
|
+
grouped = endpoints.each_with_object({}) do |endpoint, memo|
|
|
132
|
+
key = endpoint.controller.to_s.empty? ? endpoint.resource : endpoint.controller
|
|
133
|
+
resource = memo[key] ||= Resource.new(key)
|
|
134
|
+
resource.endpoints << endpoint
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
grouped.values.sort_by(&:name).each do |resource|
|
|
138
|
+
resource.endpoints.sort_by! { |endpoint| [endpoint.path, endpoint.method] }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def to_h
|
|
143
|
+
{
|
|
144
|
+
schema_version: schema_version,
|
|
145
|
+
generated_at: Time.now.utc.iso8601,
|
|
146
|
+
endpoints: curl_ready_endpoints.map(&:to_h),
|
|
147
|
+
uncovered_routes: uncovered_endpoints.map do |endpoint|
|
|
148
|
+
{
|
|
149
|
+
name: endpoint.name,
|
|
150
|
+
method: endpoint.method,
|
|
151
|
+
path: endpoint.path,
|
|
152
|
+
controller: endpoint.controller,
|
|
153
|
+
action: endpoint.action,
|
|
154
|
+
resource: endpoint.resource,
|
|
155
|
+
api_version: endpoint.api_version,
|
|
156
|
+
route_name: endpoint.route_name,
|
|
157
|
+
also_methods: endpoint.also_methods
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def write(path)
|
|
164
|
+
path = Pathname(path)
|
|
165
|
+
|
|
166
|
+
FileUtils.mkdir_p(path.dirname)
|
|
167
|
+
|
|
168
|
+
File.write(
|
|
169
|
+
path,
|
|
170
|
+
JSON.pretty_generate(to_h)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
path.to_s
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|