reqcord 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +29 -1
- data/README.md +115 -746
- data/docs/architecture.md +134 -0
- data/docs/capture.md +169 -0
- data/docs/configuration.md +7 -2
- data/docs/exporters.md +182 -0
- data/docs/getting-started.md +186 -0
- data/docs/route-coverage.md +91 -0
- data/docs/troubleshooting.md +113 -0
- data/docs/web.md +66 -0
- data/lib/reqcord/capture/integration_patch.rb +9 -2
- data/lib/reqcord/check.rb +73 -0
- data/lib/reqcord/configuration.rb +9 -0
- data/lib/reqcord/dataset.rb +0 -1
- data/lib/reqcord/exporters/markdown.rb +5 -2
- data/lib/reqcord/exporters/openapi.rb +14 -2
- data/lib/reqcord/exporters/postman.rb +11 -1
- data/lib/reqcord/file_value.rb +62 -0
- data/lib/reqcord/generator.rb +17 -3
- data/lib/reqcord/renderers/curl.rb +22 -1
- data/lib/reqcord/renderers/payload.rb +25 -7
- data/lib/reqcord/route_collector.rb +10 -1
- data/lib/reqcord/schema.rb +11 -1
- data/lib/reqcord/version.rb +1 -1
- data/lib/reqcord.rb +2 -0
- data/lib/tasks/reqcord.rake +23 -0
- data/reqcord.gemspec +2 -1
- metadata +12 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
# How an uploaded file travels through the dataset. The capture cannot keep
|
|
5
|
+
# the bytes (and the documentation must not), so a file becomes a small
|
|
6
|
+
# marker hash — `{"$file" => "avatar.png", "content_type" => "image/png"}` —
|
|
7
|
+
# that every renderer recognizes: `--form avatar=@avatar.png` in cURL, a
|
|
8
|
+
# `file` entry in Postman, `format: binary` in OpenAPI, type `file` in the
|
|
9
|
+
# parameter tables.
|
|
10
|
+
module FileValue
|
|
11
|
+
KEY = "$file"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def marker(filename, content_type = nil)
|
|
16
|
+
{ KEY => filename.to_s, "content_type" => content_type.to_s.empty? ? nil : content_type.to_s }.compact
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def file?(value)
|
|
20
|
+
value.is_a?(Hash) && value.key?(KEY)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def filename(value)
|
|
24
|
+
value[KEY].to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def content_type(value)
|
|
28
|
+
value["content_type"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# "avatar.png (image/png)" — how a page shows the upload.
|
|
32
|
+
def describe(value)
|
|
33
|
+
type = content_type(value)
|
|
34
|
+
|
|
35
|
+
type ? "#{filename(value)} (#{type})" : filename(value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# True when any value, at any depth, is a file marker.
|
|
39
|
+
def any?(value)
|
|
40
|
+
case value
|
|
41
|
+
when Hash then file?(value) || value.values.any? { |nested| any?(nested) }
|
|
42
|
+
when Array then value.any? { |item| any?(item) }
|
|
43
|
+
else false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns a copy of `value` with every marker replaced by the block's
|
|
48
|
+
# result; used to render the body for a reader.
|
|
49
|
+
def map(value, &block)
|
|
50
|
+
case value
|
|
51
|
+
when Hash
|
|
52
|
+
return yield(value) if file?(value)
|
|
53
|
+
|
|
54
|
+
value.transform_values { |nested| map(nested, &block) }
|
|
55
|
+
when Array
|
|
56
|
+
value.map { |item| map(item, &block) }
|
|
57
|
+
else
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/reqcord/generator.rb
CHANGED
|
@@ -11,11 +11,13 @@ module Reqcord
|
|
|
11
11
|
class Generator
|
|
12
12
|
def self.call(
|
|
13
13
|
resources: [],
|
|
14
|
-
version: nil
|
|
14
|
+
version: nil,
|
|
15
|
+
configuration: Reqcord.configuration
|
|
15
16
|
)
|
|
16
17
|
new(
|
|
17
18
|
resources: resources,
|
|
18
|
-
version: version
|
|
19
|
+
version: version,
|
|
20
|
+
configuration: configuration
|
|
19
21
|
).call
|
|
20
22
|
end
|
|
21
23
|
|
|
@@ -102,7 +104,7 @@ module Reqcord
|
|
|
102
104
|
endpoints[[route, route.method]] = route.endpoint unless route.any_verb?
|
|
103
105
|
end
|
|
104
106
|
|
|
105
|
-
exchanges.each do |raw_exchange|
|
|
107
|
+
ordered_exchanges(exchanges).each do |raw_exchange|
|
|
106
108
|
sanitized =
|
|
107
109
|
Sanitizers::Sanitizer.call(
|
|
108
110
|
raw_exchange,
|
|
@@ -309,6 +311,18 @@ module Reqcord
|
|
|
309
311
|
raise GenerationError, "could not match #{method} #{path} against the route table: #{e.message}"
|
|
310
312
|
end
|
|
311
313
|
|
|
314
|
+
# Test suites run in random order, and "the first example captured" is
|
|
315
|
+
# what every page leads with. Ordering by where the test lives makes the
|
|
316
|
+
# output a function of the code, so regenerating never produces a diff
|
|
317
|
+
# by itself. Requests inside one test keep their execution order.
|
|
318
|
+
def ordered_exchanges(exchanges)
|
|
319
|
+
exchanges.each_with_index.sort_by do |exchange, index|
|
|
320
|
+
source = exchange["source"] || {}
|
|
321
|
+
|
|
322
|
+
[source["file"].to_s, source["line"].to_i, source["test"].to_s, index]
|
|
323
|
+
end.map(&:first)
|
|
324
|
+
end
|
|
325
|
+
|
|
312
326
|
def attach_exchange(endpoint, exchange)
|
|
313
327
|
request_data = exchange.fetch("request")
|
|
314
328
|
response_data = exchange.fetch("response")
|
|
@@ -22,10 +22,14 @@ module Reqcord
|
|
|
22
22
|
]
|
|
23
23
|
|
|
24
24
|
request.headers.each do |key, value|
|
|
25
|
+
next if key.to_s.casecmp?("Content-Type") && Payload.multipart?(request)
|
|
26
|
+
|
|
25
27
|
parts << %(--header "#{key}: #{escape_header(value)}")
|
|
26
28
|
end
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
if request.body?
|
|
31
|
+
parts.concat(Payload.multipart?(request) ? form_arguments : [data_argument])
|
|
32
|
+
end
|
|
29
33
|
|
|
30
34
|
parts.join(" \\\n ")
|
|
31
35
|
end
|
|
@@ -42,6 +46,23 @@ module Reqcord
|
|
|
42
46
|
"--data '#{shell_single_quote(Payload.raw_body(request))}'"
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
# One --form per part; a file is `@name`, relative to where the reader
|
|
50
|
+
# runs the command. curl sets the multipart Content-Type itself, so a
|
|
51
|
+
# captured one would only get in the way.
|
|
52
|
+
def form_arguments
|
|
53
|
+
Payload.form_pairs(request).map do |key, value|
|
|
54
|
+
part =
|
|
55
|
+
if FileValue.file?(value)
|
|
56
|
+
type = FileValue.content_type(value)
|
|
57
|
+
"#{key}=@#{FileValue.filename(value)}#{type ? ";type=#{type}" : ''}"
|
|
58
|
+
else
|
|
59
|
+
"#{key}=#{value}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
"--form '#{shell_single_quote(part)}'"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
45
66
|
def shell_single_quote(value)
|
|
46
67
|
value.to_s.gsub("'", %q('"'"'))
|
|
47
68
|
end
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
module Reqcord
|
|
4
4
|
module Renderers
|
|
5
5
|
# The wire-level decisions every output format has to agree on: whether a
|
|
6
|
-
# request is JSON,
|
|
7
|
-
# as text. cURL, Postman and any later
|
|
8
|
-
# own copy.
|
|
6
|
+
# request is JSON, a form or a multipart upload, how a nested query
|
|
7
|
+
# flattens, what the body looks like as text. cURL, Postman and any later
|
|
8
|
+
# exporter read these, never their own copy.
|
|
9
9
|
module Payload
|
|
10
10
|
module_function
|
|
11
11
|
|
|
@@ -16,18 +16,26 @@ module Reqcord
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# A body with a file in it can only travel as multipart/form-data,
|
|
20
|
+
# whatever the captured content type says.
|
|
21
|
+
def multipart?(request)
|
|
22
|
+
request.content_type.to_s.include?("multipart/form-data") ||
|
|
23
|
+
FileValue.any?(request.body)
|
|
24
|
+
end
|
|
25
|
+
|
|
19
26
|
# Rails bracket notation: { filter: { status: "a" }, ids: [1, 2] } becomes
|
|
20
|
-
# [["filter[status]", "a"], ["ids[]", 1], ["ids[]", 2]].
|
|
27
|
+
# [["filter[status]", "a"], ["ids[]", 1], ["ids[]", 2]]. A file marker is
|
|
28
|
+
# a leaf: the pair's value is the marker itself.
|
|
21
29
|
def flatten_query(hash, prefix = nil)
|
|
22
30
|
hash.flat_map do |key, value|
|
|
23
31
|
current = prefix ? "#{prefix}[#{key}]" : key.to_s
|
|
24
32
|
|
|
25
33
|
case value
|
|
26
34
|
when Hash
|
|
27
|
-
flatten_query(value, current)
|
|
35
|
+
FileValue.file?(value) ? [[current, value]] : flatten_query(value, current)
|
|
28
36
|
when Array
|
|
29
37
|
value.flat_map do |item|
|
|
30
|
-
if item.is_a?(Hash)
|
|
38
|
+
if item.is_a?(Hash) && !FileValue.file?(item)
|
|
31
39
|
flatten_query(item, "#{current}[]")
|
|
32
40
|
else
|
|
33
41
|
[["#{current}[]", item]]
|
|
@@ -54,7 +62,8 @@ module Reqcord
|
|
|
54
62
|
end
|
|
55
63
|
|
|
56
64
|
# The body as it goes on the wire: pretty JSON, a form string, or the
|
|
57
|
-
# raw text the test sent.
|
|
65
|
+
# raw text the test sent. Multipart has no single string; callers use
|
|
66
|
+
# form_pairs and render each part.
|
|
58
67
|
def raw_body(request)
|
|
59
68
|
if json?(request)
|
|
60
69
|
JSON.pretty_generate(request.body)
|
|
@@ -64,6 +73,15 @@ module Reqcord
|
|
|
64
73
|
request.body.to_s
|
|
65
74
|
end
|
|
66
75
|
end
|
|
76
|
+
|
|
77
|
+
# The body as a reader should see it: file markers replaced by their
|
|
78
|
+
# description ("avatar.png (image/png)"), or by whatever the block
|
|
79
|
+
# returns.
|
|
80
|
+
def display_body(request, &block)
|
|
81
|
+
block ||= FileValue.method(:describe)
|
|
82
|
+
|
|
83
|
+
FileValue.map(request.body, &block)
|
|
84
|
+
end
|
|
67
85
|
end
|
|
68
86
|
end
|
|
69
87
|
end
|
|
@@ -216,7 +216,16 @@ module Reqcord
|
|
|
216
216
|
prefixes = Array(prefix).map(&:to_s).reject(&:empty?)
|
|
217
217
|
return true if prefixes.empty?
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
# Segment-wise: `/api` covers `/api`, `/api/v1/…` and `/api(/:id)`, not
|
|
220
|
+
# `/api-docs`.
|
|
221
|
+
prefixes.any? do |candidate|
|
|
222
|
+
candidate = candidate.chomp("/")
|
|
223
|
+
next true if candidate.empty?
|
|
224
|
+
next false unless path.start_with?(candidate)
|
|
225
|
+
|
|
226
|
+
rest = path.delete_prefix(candidate)
|
|
227
|
+
rest.empty? || rest.start_with?("/", "(")
|
|
228
|
+
end
|
|
220
229
|
end
|
|
221
230
|
|
|
222
231
|
# `RESOURCE=customers`, `RESOURCE=cart` (a singular resource is served by
|
data/lib/reqcord/schema.rb
CHANGED
|
@@ -110,7 +110,7 @@ module Reqcord
|
|
|
110
110
|
)
|
|
111
111
|
|
|
112
112
|
field.types |= [type_of(value)]
|
|
113
|
-
field.values |= [value] unless value.nil? || value.is_a?(
|
|
113
|
+
field.values |= [example_value(value)] unless value.nil? || value.is_a?(Array) || (value.is_a?(Hash) && !FileValue.file?(value))
|
|
114
114
|
field.present_count += 1
|
|
115
115
|
end
|
|
116
116
|
end
|
|
@@ -159,6 +159,9 @@ module Reqcord
|
|
|
159
159
|
def flatten(value, prefix = nil, result = {})
|
|
160
160
|
case value
|
|
161
161
|
when Hash
|
|
162
|
+
# An upload is one field of type "file", not an object with two keys.
|
|
163
|
+
return result[prefix] = value if FileValue.file?(value) && prefix
|
|
164
|
+
|
|
162
165
|
value.each { |key, nested| flatten(nested, prefix ? "#{prefix}.#{key}" : key.to_s, result) }
|
|
163
166
|
when Array
|
|
164
167
|
# An empty array still tells the reader the field is a list.
|
|
@@ -171,7 +174,14 @@ module Reqcord
|
|
|
171
174
|
result
|
|
172
175
|
end
|
|
173
176
|
|
|
177
|
+
# The file name stands for an upload in the Values column.
|
|
178
|
+
def example_value(value)
|
|
179
|
+
FileValue.file?(value) ? FileValue.filename(value) : value
|
|
180
|
+
end
|
|
181
|
+
|
|
174
182
|
def type_of(value)
|
|
183
|
+
return "file" if FileValue.file?(value)
|
|
184
|
+
|
|
175
185
|
case value
|
|
176
186
|
when String then "string"
|
|
177
187
|
when Integer then "integer"
|
data/lib/reqcord/version.rb
CHANGED
data/lib/reqcord.rb
CHANGED
|
@@ -11,6 +11,7 @@ require "pathname"
|
|
|
11
11
|
require_relative "reqcord/version"
|
|
12
12
|
require_relative "reqcord/errors"
|
|
13
13
|
require_relative "reqcord/support"
|
|
14
|
+
require_relative "reqcord/file_value"
|
|
14
15
|
require_relative "reqcord/configuration"
|
|
15
16
|
|
|
16
17
|
require_relative "reqcord/request_example"
|
|
@@ -34,6 +35,7 @@ require_relative "reqcord/exporters/markdown"
|
|
|
34
35
|
require_relative "reqcord/exporters/postman"
|
|
35
36
|
require_relative "reqcord/exporters/openapi"
|
|
36
37
|
require_relative "reqcord/generator"
|
|
38
|
+
require_relative "reqcord/check"
|
|
37
39
|
require_relative "reqcord/web"
|
|
38
40
|
|
|
39
41
|
require_relative "reqcord/railtie" if defined?(Rails::Railtie)
|
data/lib/tasks/reqcord.rake
CHANGED
|
@@ -109,6 +109,29 @@ namespace :reqcord do
|
|
|
109
109
|
puts "Output: #{Reqcord.configuration.output_directory}"
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
desc "Fail when the committed documentation is behind the tests"
|
|
113
|
+
task check: :environment do
|
|
114
|
+
Reqcord.reload_configuration!
|
|
115
|
+
|
|
116
|
+
result =
|
|
117
|
+
Reqcord::Check.call(
|
|
118
|
+
resources: ENV.fetch("RESOURCE", "").split(",").map(&:strip).reject(&:empty?),
|
|
119
|
+
version: ENV["VERSION"].to_s.strip.then { |value| value.empty? ? nil : value }
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
output = Reqcord.configuration.output_directory
|
|
123
|
+
|
|
124
|
+
if result.clean?
|
|
125
|
+
puts "Reqcord: #{output} is up to date."
|
|
126
|
+
else
|
|
127
|
+
puts "Reqcord: #{output} is out of date."
|
|
128
|
+
puts
|
|
129
|
+
result.lines.each { |line| puts " #{line}" }
|
|
130
|
+
puts
|
|
131
|
+
abort "Run `bin/rails reqcord:generate` and commit the result."
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
112
135
|
desc "List the routes Reqcord would document"
|
|
113
136
|
task routes: :environment do
|
|
114
137
|
routes =
|
data/reqcord.gemspec
CHANGED
|
@@ -20,7 +20,8 @@ Gem::Specification.new do |spec|
|
|
|
20
20
|
spec.required_ruby_version = ">= 3.2"
|
|
21
21
|
|
|
22
22
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
23
|
-
spec.metadata["changelog_uri"] = spec.homepage
|
|
23
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
|
|
24
|
+
spec.metadata["documentation_uri"] = "#{spec.homepage}/tree/master/docs"
|
|
24
25
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
25
26
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
26
27
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: reqcord
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ahmet Saridogan
|
|
@@ -145,7 +145,14 @@ files:
|
|
|
145
145
|
- LICENSE.txt
|
|
146
146
|
- README.md
|
|
147
147
|
- Rakefile
|
|
148
|
+
- docs/architecture.md
|
|
149
|
+
- docs/capture.md
|
|
148
150
|
- docs/configuration.md
|
|
151
|
+
- docs/exporters.md
|
|
152
|
+
- docs/getting-started.md
|
|
153
|
+
- docs/route-coverage.md
|
|
154
|
+
- docs/troubleshooting.md
|
|
155
|
+
- docs/web.md
|
|
149
156
|
- examples/reqcord.yml
|
|
150
157
|
- gemfiles/rails_7.1.gemfile
|
|
151
158
|
- gemfiles/rails_7.2.gemfile
|
|
@@ -158,6 +165,7 @@ files:
|
|
|
158
165
|
- lib/reqcord/capture/minitest_context.rb
|
|
159
166
|
- lib/reqcord/capture/rspec_context.rb
|
|
160
167
|
- lib/reqcord/capture/test_context.rb
|
|
168
|
+
- lib/reqcord/check.rb
|
|
161
169
|
- lib/reqcord/configuration.rb
|
|
162
170
|
- lib/reqcord/dataset.rb
|
|
163
171
|
- lib/reqcord/endpoint.rb
|
|
@@ -167,6 +175,7 @@ files:
|
|
|
167
175
|
- lib/reqcord/exporters/markdown.rb
|
|
168
176
|
- lib/reqcord/exporters/openapi.rb
|
|
169
177
|
- lib/reqcord/exporters/postman.rb
|
|
178
|
+
- lib/reqcord/file_value.rb
|
|
170
179
|
- lib/reqcord/generator.rb
|
|
171
180
|
- lib/reqcord/railtie.rb
|
|
172
181
|
- lib/reqcord/renderers/curl.rb
|
|
@@ -186,7 +195,8 @@ licenses:
|
|
|
186
195
|
- MIT
|
|
187
196
|
metadata:
|
|
188
197
|
homepage_uri: https://github.com/ahmetsaridogan/reqcord
|
|
189
|
-
changelog_uri: https://github.com/ahmetsaridogan/reqcord
|
|
198
|
+
changelog_uri: https://github.com/ahmetsaridogan/reqcord/blob/master/CHANGELOG.md
|
|
199
|
+
documentation_uri: https://github.com/ahmetsaridogan/reqcord/tree/master/docs
|
|
190
200
|
source_code_uri: https://github.com/ahmetsaridogan/reqcord
|
|
191
201
|
rubygems_mfa_required: 'true'
|
|
192
202
|
rdoc_options: []
|