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.
@@ -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.json?(example)
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
@@ -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
- parts << data_argument if request.body?
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, how a nested query flattens, what the body looks like
7
- # as text. cURL, Postman and any later exporter read these, never their
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
- prefixes.any? { |candidate| path.start_with?(candidate) }
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
@@ -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?(Hash) || value.is_a?(Array)
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"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reqcord
4
- VERSION = "0.1.4"
4
+ VERSION = "0.3.0"
5
5
  end