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.
@@ -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.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
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)
@@ -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.2.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: []