reqcord 0.1.4 → 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 +42 -0
- data/README.md +115 -713
- data/docs/architecture.md +134 -0
- data/docs/capture.md +169 -0
- data/docs/configuration.md +32 -3
- 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/examples/reqcord.yml +3 -1
- data/lib/reqcord/capture/integration_patch.rb +9 -2
- data/lib/reqcord/check.rb +73 -0
- data/lib/reqcord/configuration.rb +10 -1
- data/lib/reqcord/dataset.rb +0 -1
- data/lib/reqcord/exporters/markdown.rb +5 -2
- data/lib/reqcord/exporters/openapi.rb +295 -0
- 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/web.rb +122 -0
- data/lib/reqcord.rb +4 -0
- data/lib/tasks/reqcord.rake +26 -1
- data/reqcord.gemspec +2 -1
- metadata +14 -2
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
module Exporters
|
|
5
|
+
# Writes an OpenAPI 3.1 document (`openapi/openapi.json`) from the dataset:
|
|
6
|
+
# one path item per documented route, parameters and request bodies from
|
|
7
|
+
# the inferred schemas, one response per captured status with its example,
|
|
8
|
+
# and security schemes derived from the sanitized credential headers. This
|
|
9
|
+
# is what Scalar (via Reqcord::Web), Swagger UI or Redoc render.
|
|
10
|
+
class Openapi
|
|
11
|
+
OPENAPI_VERSION = "3.1.0"
|
|
12
|
+
DYNAMIC_SEGMENT = /[:*]([a-zA-Z_][a-zA-Z0-9_]*)/
|
|
13
|
+
BEARER = /\ABearer\s+/i
|
|
14
|
+
|
|
15
|
+
def self.call(dataset:, output_dir:, configuration:)
|
|
16
|
+
new(
|
|
17
|
+
dataset: dataset,
|
|
18
|
+
output_dir: output_dir,
|
|
19
|
+
configuration: configuration
|
|
20
|
+
).call
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(dataset:, output_dir:, configuration:)
|
|
24
|
+
@dataset = dataset
|
|
25
|
+
@output_dir = Pathname(output_dir).join("openapi")
|
|
26
|
+
@configuration = configuration
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
FileUtils.mkdir_p(output_dir)
|
|
31
|
+
|
|
32
|
+
path = output_dir.join("openapi.json")
|
|
33
|
+
File.write(path, "#{JSON.pretty_generate(document)}\n")
|
|
34
|
+
|
|
35
|
+
[path.to_s]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def document
|
|
39
|
+
{
|
|
40
|
+
openapi: OPENAPI_VERSION,
|
|
41
|
+
info: {
|
|
42
|
+
title: "#{File.basename(configuration.root.to_s)} API",
|
|
43
|
+
version: api_version,
|
|
44
|
+
description: "Generated by Reqcord from the application's integration tests."
|
|
45
|
+
},
|
|
46
|
+
servers: [{ url: configuration.base_url }],
|
|
47
|
+
tags: tags,
|
|
48
|
+
paths: paths,
|
|
49
|
+
components: components
|
|
50
|
+
}.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
attr_reader :dataset, :output_dir, :configuration
|
|
56
|
+
|
|
57
|
+
# A single API version across the dataset names the document; mixed
|
|
58
|
+
# versions fall back to a neutral one.
|
|
59
|
+
def api_version
|
|
60
|
+
versions = dataset.curl_ready_endpoints.map(&:api_version).compact.uniq
|
|
61
|
+
|
|
62
|
+
versions.size == 1 ? versions.first : "1.0.0"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tags
|
|
66
|
+
dataset.resources
|
|
67
|
+
.select { |resource| resource.endpoints.any?(&:curl_ready?) }
|
|
68
|
+
.map { |resource| { name: resource.title, description: resource.name }.compact }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def paths
|
|
72
|
+
dataset.resources.each_with_object({}) do |resource, result|
|
|
73
|
+
resource.endpoints.each do |endpoint|
|
|
74
|
+
next unless endpoint.curl_ready?
|
|
75
|
+
next if endpoint.method == RouteCollector::ANY
|
|
76
|
+
|
|
77
|
+
path_variants(endpoint.path).each do |oas_path|
|
|
78
|
+
result[oas_path] ||= {}
|
|
79
|
+
result[oas_path][endpoint.method.downcase] = operation(endpoint, oas_path, resource)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# `/items(/:id)` is two OpenAPI paths: `/items` and `/items/{id}`.
|
|
86
|
+
def path_variants(pattern)
|
|
87
|
+
required = pattern.gsub(/\([^()]*\)/, "")
|
|
88
|
+
expanded = pattern.tr("()", "")
|
|
89
|
+
|
|
90
|
+
[required, expanded].uniq.map { |path| path.gsub(DYNAMIC_SEGMENT, '{\1}') }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def operation(endpoint, oas_path, resource)
|
|
94
|
+
example = endpoint.primary_request_example
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
operationId: operation_id(endpoint, oas_path),
|
|
98
|
+
summary: endpoint.name,
|
|
99
|
+
description: description(endpoint),
|
|
100
|
+
tags: [resource.title],
|
|
101
|
+
parameters: path_parameters(endpoint, oas_path) + query_parameters(endpoint),
|
|
102
|
+
requestBody: request_body(endpoint, example),
|
|
103
|
+
responses: responses(endpoint),
|
|
104
|
+
security: security_for(example)
|
|
105
|
+
}.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def operation_id(endpoint, oas_path)
|
|
109
|
+
Support.parameterize("#{endpoint.method} #{oas_path.tr('{}', '')}").tr("-", "_")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def description(endpoint)
|
|
113
|
+
lines = ["#{endpoint.controller}##{endpoint.action}"]
|
|
114
|
+
lines << "Also answers #{endpoint.also_methods.join(', ')}." unless endpoint.also_methods.empty?
|
|
115
|
+
lines << "Route: #{endpoint.route_name}" if endpoint.route_name
|
|
116
|
+
|
|
117
|
+
lines.join("\n")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def path_parameters(endpoint, oas_path)
|
|
121
|
+
oas_path.scan(/\{([^}]+)\}/).flatten.map do |name|
|
|
122
|
+
field = endpoint.path_param_schema[name]
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
name: name,
|
|
126
|
+
in: "path",
|
|
127
|
+
required: true,
|
|
128
|
+
schema: field ? scalar_schema(field) : { type: "string" },
|
|
129
|
+
example: field&.example
|
|
130
|
+
}.compact
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# `filter.category` was captured as Rails' `filter[category]`.
|
|
135
|
+
def query_parameters(endpoint)
|
|
136
|
+
endpoint.query_schema.map do |field|
|
|
137
|
+
{
|
|
138
|
+
name: query_name(field.path),
|
|
139
|
+
in: "query",
|
|
140
|
+
required: field.required?,
|
|
141
|
+
schema: scalar_schema(field),
|
|
142
|
+
example: field.example
|
|
143
|
+
}.compact
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def query_name(path)
|
|
148
|
+
head, *rest = path.split(".")
|
|
149
|
+
|
|
150
|
+
head + rest.map { |segment| "[#{segment}]" }.join
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def request_body(endpoint, example)
|
|
154
|
+
return nil if endpoint.body_schema.empty?
|
|
155
|
+
|
|
156
|
+
content_type =
|
|
157
|
+
if Renderers::Payload.multipart?(example)
|
|
158
|
+
"multipart/form-data"
|
|
159
|
+
elsif Renderers::Payload.json?(example)
|
|
160
|
+
"application/json"
|
|
161
|
+
else
|
|
162
|
+
"application/x-www-form-urlencoded"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A file part is shown by name in the example; the bytes are not data.
|
|
166
|
+
body = Renderers::Payload.display_body(example) { |file| FileValue.filename(file) }
|
|
167
|
+
|
|
168
|
+
{
|
|
169
|
+
required: true,
|
|
170
|
+
content: {
|
|
171
|
+
content_type => { schema: json_schema(endpoint.body_schema), example: body }.compact
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def responses(endpoint)
|
|
177
|
+
endpoint.responses.each_with_object({}) do |response, result|
|
|
178
|
+
example = response.example
|
|
179
|
+
entry = { description: example.status_text }
|
|
180
|
+
|
|
181
|
+
if example.body?
|
|
182
|
+
content_type = example.content_type.to_s.empty? ? "application/json" : example.content_type
|
|
183
|
+
entry[:content] = { content_type => { schema: json_schema(response.schema), example: example.body }.compact }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
result[response.status.to_s] = entry
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# --- schemas ----------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
# Turns the flattened field list (`customer.items[].sku`) back into a
|
|
193
|
+
# JSON Schema tree. A leaf every accepted request carried is required,
|
|
194
|
+
# and so is every object on the way down to it.
|
|
195
|
+
def json_schema(schema)
|
|
196
|
+
return nil if schema.empty?
|
|
197
|
+
|
|
198
|
+
root = {}
|
|
199
|
+
|
|
200
|
+
schema.each do |field|
|
|
201
|
+
node = root
|
|
202
|
+
|
|
203
|
+
segments_for(field.path).each do |segment|
|
|
204
|
+
if segment == :array
|
|
205
|
+
node[:type] = "array"
|
|
206
|
+
node[:items] ||= {}
|
|
207
|
+
node = node[:items]
|
|
208
|
+
else
|
|
209
|
+
node[:type] = "object"
|
|
210
|
+
node[:properties] ||= {}
|
|
211
|
+
node[:properties][segment] ||= {}
|
|
212
|
+
node[:required] = (node[:required] || []) | [segment] if field.required?
|
|
213
|
+
node = node[:properties][segment]
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
node.merge!(scalar_schema(field)) if node.empty?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
root
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# "customer.items[].sku" -> ["customer", "items", :array, "sku"];
|
|
224
|
+
# "[].id" -> [:array, "id"]
|
|
225
|
+
def segments_for(path)
|
|
226
|
+
path.split(".").flat_map do |part|
|
|
227
|
+
name, brackets = part.match(/\A([^\[\]]*)((?:\[\])*)\z/).captures
|
|
228
|
+
pieces = []
|
|
229
|
+
pieces << name unless name.empty?
|
|
230
|
+
pieces.concat([:array] * (brackets.length / 2))
|
|
231
|
+
pieces
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def scalar_schema(field)
|
|
236
|
+
types = field.types.to_a.sort
|
|
237
|
+
return { type: "string", format: "binary" } if types == ["file"]
|
|
238
|
+
|
|
239
|
+
schema = { type: types.size == 1 ? types.first : types }
|
|
240
|
+
schema[:enum] = field.listed_values if field.enum?
|
|
241
|
+
schema[:example] = field.example unless field.example.nil?
|
|
242
|
+
|
|
243
|
+
schema
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# --- security ---------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def components
|
|
249
|
+
schemes = {}
|
|
250
|
+
schemes[:bearerAuth] = { type: "http", scheme: "bearer" } if bearer_used?
|
|
251
|
+
schemes[:apiKeyAuth] = { type: "apiKey", in: "header", name: api_key_header } if api_key_header
|
|
252
|
+
|
|
253
|
+
schemes.empty? ? nil : { securitySchemes: schemes }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def security_for(example)
|
|
257
|
+
requirements = []
|
|
258
|
+
requirements << { bearerAuth: [] } if bearer?(example)
|
|
259
|
+
requirements << { apiKeyAuth: [] } if api_key_header && header(example, api_key_header)
|
|
260
|
+
|
|
261
|
+
requirements
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def bearer_used?
|
|
265
|
+
documented_examples.any? { |example| bearer?(example) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def bearer?(example)
|
|
269
|
+
header(example, "Authorization").to_s.match?(BEARER)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The first API-key style header the sanitizer knows about that a
|
|
273
|
+
# documented request actually carried.
|
|
274
|
+
def api_key_header
|
|
275
|
+
return @api_key_header if defined?(@api_key_header)
|
|
276
|
+
|
|
277
|
+
candidates = configuration.sanitized_headers.keys.reject { |name| name.casecmp?("Authorization") }
|
|
278
|
+
|
|
279
|
+
@api_key_header = candidates.find do |name|
|
|
280
|
+
documented_examples.any? { |example| header(example, name) }
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def header(example, name)
|
|
285
|
+
example.headers.find { |key, _| key.to_s.casecmp?(name) }&.last
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def documented_examples
|
|
289
|
+
@documented_examples ||= dataset.curl_ready_endpoints.map(&:primary_request_example)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
register("openapi", Openapi)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -135,7 +135,17 @@ module Reqcord
|
|
|
135
135
|
def body_object(example)
|
|
136
136
|
return nil unless example.body?
|
|
137
137
|
|
|
138
|
-
if Renderers::Payload.
|
|
138
|
+
if Renderers::Payload.multipart?(example)
|
|
139
|
+
parts = Renderers::Payload.form_pairs(example).map do |key, value|
|
|
140
|
+
if FileValue.file?(value)
|
|
141
|
+
{ key: key, type: "file", src: FileValue.filename(value) }
|
|
142
|
+
else
|
|
143
|
+
{ key: key, value: value.to_s, type: "text" }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
{ mode: "formdata", formdata: parts }
|
|
148
|
+
elsif Renderers::Payload.json?(example)
|
|
139
149
|
{ mode: "raw", raw: Renderers::Payload.raw_body(example), options: { raw: { language: "json" } } }
|
|
140
150
|
elsif example.body.is_a?(Hash)
|
|
141
151
|
pairs = Renderers::Payload.form_pairs(example).map { |key, value| { key: key, value: value.to_s, type: "text" } }
|
|
@@ -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