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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -0
  3. data/Gemfile +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +252 -95
  6. data/Rakefile +13 -0
  7. data/docs/configuration.md +319 -0
  8. data/examples/reqcord.yml +58 -0
  9. data/gemfiles/rails_7.1.gemfile +13 -0
  10. data/gemfiles/rails_7.2.gemfile +13 -0
  11. data/gemfiles/rails_8.0.gemfile +13 -0
  12. data/gemfiles/rails_8.1.gemfile +13 -0
  13. data/lib/reqcord/capture/collector.rb +31 -0
  14. data/lib/reqcord/capture/integration_patch.rb +209 -0
  15. data/lib/reqcord/capture/minitest_context.rb +34 -0
  16. data/lib/reqcord/capture/rspec_context.rb +42 -0
  17. data/lib/reqcord/capture/test_context.rb +25 -0
  18. data/lib/reqcord/capture.rb +19 -0
  19. data/lib/reqcord/configuration.rb +198 -0
  20. data/lib/reqcord/dataset.rb +176 -0
  21. data/lib/reqcord/endpoint.rb +263 -0
  22. data/lib/reqcord/errors.rb +9 -0
  23. data/lib/reqcord/exporters/curl.rb +68 -0
  24. data/lib/reqcord/exporters/markdown.rb +295 -0
  25. data/lib/reqcord/exporters/postman.rb +206 -0
  26. data/lib/reqcord/exporters.rb +32 -0
  27. data/lib/reqcord/generator.rb +364 -0
  28. data/lib/reqcord/railtie.rb +51 -0
  29. data/lib/reqcord/renderers/curl.rb +56 -0
  30. data/lib/reqcord/renderers/payload.rb +69 -0
  31. data/lib/reqcord/request_example.rb +104 -0
  32. data/lib/reqcord/response_example.rb +72 -0
  33. data/lib/reqcord/route_collector.rb +242 -0
  34. data/lib/reqcord/sanitizers/sanitizer.rb +140 -0
  35. data/lib/reqcord/schema.rb +187 -0
  36. data/lib/reqcord/support.rb +58 -0
  37. data/lib/reqcord/version.rb +5 -0
  38. data/lib/reqcord.rb +78 -0
  39. data/lib/tasks/reqcord.rake +99 -0
  40. data/reqcord.gemspec +60 -0
  41. metadata +165 -3
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Renderers
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.
9
+ module Payload
10
+ module_function
11
+
12
+ def json?(request)
13
+ request.content_type.to_s.include?("json") ||
14
+ request.headers.any? do |key, value|
15
+ key.to_s.casecmp?("Content-Type") && value.to_s.include?("json")
16
+ end
17
+ end
18
+
19
+ # Rails bracket notation: { filter: { status: "a" }, ids: [1, 2] } becomes
20
+ # [["filter[status]", "a"], ["ids[]", 1], ["ids[]", 2]].
21
+ def flatten_query(hash, prefix = nil)
22
+ hash.flat_map do |key, value|
23
+ current = prefix ? "#{prefix}[#{key}]" : key.to_s
24
+
25
+ case value
26
+ when Hash
27
+ flatten_query(value, current)
28
+ when Array
29
+ value.flat_map do |item|
30
+ if item.is_a?(Hash)
31
+ flatten_query(item, "#{current}[]")
32
+ else
33
+ [["#{current}[]", item]]
34
+ end
35
+ end
36
+ else
37
+ [[current, value]]
38
+ end
39
+ end
40
+ end
41
+
42
+ def query_pairs(request)
43
+ flatten_query(request.query_params)
44
+ end
45
+
46
+ def form_pairs(request)
47
+ request.body.is_a?(Hash) ? flatten_query(request.body) : []
48
+ end
49
+
50
+ def path_with_query(request)
51
+ return request.path if request.query_params.empty?
52
+
53
+ "#{request.path}?#{URI.encode_www_form(query_pairs(request))}"
54
+ end
55
+
56
+ # The body as it goes on the wire: pretty JSON, a form string, or the
57
+ # raw text the test sent.
58
+ def raw_body(request)
59
+ if json?(request)
60
+ JSON.pretty_generate(request.body)
61
+ elsif request.body.is_a?(Hash)
62
+ URI.encode_www_form(form_pairs(request))
63
+ else
64
+ request.body.to_s
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class RequestExample
5
+ ATTRIBUTES = %i[
6
+ name
7
+ method
8
+ path
9
+ path_params
10
+ query_params
11
+ headers
12
+ body
13
+ content_type
14
+ response_status
15
+ source
16
+ ].freeze
17
+
18
+ attr_accessor(*ATTRIBUTES)
19
+
20
+ def initialize(
21
+ method:,
22
+ path:,
23
+ name: nil,
24
+ path_params: {},
25
+ query_params: {},
26
+ headers: {},
27
+ body: nil,
28
+ content_type: nil,
29
+ response_status: nil,
30
+ source: {}
31
+ )
32
+ @name = name
33
+ @method = method.to_s.upcase
34
+ @path = path
35
+ @path_params = path_params || {}
36
+ @query_params = query_params || {}
37
+ @headers = headers || {}
38
+ @body = body
39
+ @content_type = content_type
40
+ @response_status = response_status&.to_i
41
+ @source = source || {}
42
+ end
43
+
44
+ # `method` is the HTTP verb here; this alias keeps call sites that mean the
45
+ # verb from reading like reflection.
46
+ def http_method
47
+ method
48
+ end
49
+
50
+ # A payload of empty containers ({"experience" => {}}) carries nothing to
51
+ # document, and sending it as --data would only mislead.
52
+ def body?
53
+ meaningful?(body)
54
+ end
55
+
56
+ # The request the endpoint page leads with should be one that worked.
57
+ def successful?
58
+ response_status.nil? ? false : (200..299).cover?(response_status)
59
+ end
60
+
61
+ # Identical requests captured by several tests are stored once — but the
62
+ # same request that produced a different status is a different example.
63
+ # Sanitization can make a right and a wrong password look identical; the
64
+ # 200 must not be dropped because the 401 was captured first.
65
+ def signature
66
+ JSON.generate([method, path, headers, query_params, body, response_status])
67
+ end
68
+
69
+ def to_h
70
+ {
71
+ name: name,
72
+ method: method,
73
+ path: path,
74
+ path_params: path_params,
75
+ query_params: query_params,
76
+ headers: headers,
77
+ body: body,
78
+ content_type: content_type,
79
+ response_status: response_status,
80
+ source: source
81
+ }
82
+ end
83
+
84
+ # Tolerates keys this version does not know, so a dataset written by a
85
+ # newer Reqcord still loads.
86
+ def self.from_h(hash)
87
+ hash = hash.transform_keys(&:to_sym).slice(*ATTRIBUTES)
88
+
89
+ new(**hash)
90
+ end
91
+
92
+ private
93
+
94
+ def meaningful?(value)
95
+ case value
96
+ when nil then false
97
+ when Hash then value.any? { |_key, nested| meaningful?(nested) }
98
+ when Array then value.any? { |item| meaningful?(item) }
99
+ when String then !value.empty?
100
+ else true
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class ResponseExample
5
+ ATTRIBUTES = %i[
6
+ name
7
+ status
8
+ headers
9
+ body
10
+ content_type
11
+ source
12
+ ].freeze
13
+
14
+ attr_accessor(*ATTRIBUTES)
15
+
16
+ def initialize(
17
+ status:,
18
+ name: nil,
19
+ headers: {},
20
+ body: nil,
21
+ content_type: nil,
22
+ source: {}
23
+ )
24
+ @name = name
25
+ @status = status.to_i
26
+ @headers = headers || {}
27
+ @body = body
28
+ @content_type = content_type
29
+ @source = source || {}
30
+ end
31
+
32
+ # "201 Created"
33
+ def status_text
34
+ Rack::Utils::HTTP_STATUS_CODES[status] || "Unknown"
35
+ end
36
+
37
+ def title
38
+ "#{status} #{status_text}"
39
+ end
40
+
41
+ def body?
42
+ !(body.nil? || (body.respond_to?(:empty?) && body.empty?))
43
+ end
44
+
45
+ def empty_body?
46
+ !body?
47
+ end
48
+
49
+ def signature
50
+ JSON.generate([status, body])
51
+ end
52
+
53
+ def to_h
54
+ {
55
+ name: name,
56
+ status: status,
57
+ headers: headers,
58
+ body: body,
59
+ content_type: content_type,
60
+ source: source
61
+ }
62
+ end
63
+
64
+ # Tolerates keys this version does not know, so a dataset written by a
65
+ # newer Reqcord still loads.
66
+ def self.from_h(hash)
67
+ hash = hash.transform_keys(&:to_sym).slice(*ATTRIBUTES)
68
+
69
+ new(**hash)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ # The documented surface is the route table, not the captured traffic: every
5
+ # matching route becomes an endpoint, and captures are attached to it. Routes
6
+ # no test exercised stay in the dataset as documentation gaps.
7
+ #
8
+ # Nothing is dropped silently: a route either becomes a Route, or is counted
9
+ # in `skipped` with the reason (redirect, Rack mount, Rails internal).
10
+ class RouteCollector
11
+ # A route that answers any verb (`via: :all`); the captured verb decides
12
+ # what gets documented.
13
+ ANY = "ANY"
14
+
15
+ Route = Struct.new(
16
+ :name,
17
+ :method,
18
+ :path,
19
+ :controller,
20
+ :action,
21
+ :resource,
22
+ :api_version,
23
+ :rails_route,
24
+ :mount_prefix,
25
+ keyword_init: true
26
+ ) do
27
+ def endpoint(method: self.method)
28
+ Endpoint.new(
29
+ method: method,
30
+ path: path,
31
+ controller: controller,
32
+ action: action,
33
+ resource: resource,
34
+ api_version: api_version,
35
+ route_name: name
36
+ )
37
+ end
38
+
39
+ def any_verb?
40
+ method == ANY
41
+ end
42
+
43
+ def matches?(request_method, request_path)
44
+ return false unless any_verb? || method == request_method.to_s.upcase
45
+
46
+ relative = relative_path(request_path)
47
+ return false if relative.nil?
48
+
49
+ !!rails_route.path.match(relative)
50
+ end
51
+
52
+ private
53
+
54
+ # An engine's pattern knows nothing about where it was mounted.
55
+ def relative_path(request_path)
56
+ return request_path if mount_prefix.nil? || mount_prefix.empty?
57
+ return nil unless request_path.start_with?(mount_prefix)
58
+
59
+ rest = request_path.delete_prefix(mount_prefix)
60
+ return nil unless rest.empty? || rest.start_with?("/")
61
+
62
+ rest.empty? ? "/" : rest
63
+ end
64
+ end
65
+
66
+ def self.call(
67
+ resources: [],
68
+ version: nil,
69
+ prefix: nil,
70
+ route_set: nil
71
+ )
72
+ new(
73
+ resources: resources,
74
+ version: version,
75
+ prefix: prefix,
76
+ route_set: route_set
77
+ ).call
78
+ end
79
+
80
+ # Routes that were seen but cannot be documented, by reason.
81
+ attr_reader :skipped
82
+
83
+ def initialize(resources:, version:, prefix:, route_set: nil)
84
+ @resources = Array(resources).map(&:to_s)
85
+ @version = version&.to_s
86
+ @prefix = prefix
87
+ @route_set = route_set
88
+ @skipped = Hash.new(0)
89
+ end
90
+
91
+ def call
92
+ @skipped = Hash.new(0)
93
+
94
+ collect(route_set.routes, mount_prefix: nil)
95
+ end
96
+
97
+ def skipped_count
98
+ skipped.values.sum
99
+ end
100
+
101
+ private
102
+
103
+ attr_reader :resources, :version, :prefix
104
+
105
+ def route_set
106
+ @route_set ||= Rails.application.routes
107
+ end
108
+
109
+ def collect(rails_routes, mount_prefix:)
110
+ rails_routes.flat_map { |rails_route| build_routes(rails_route, mount_prefix) }
111
+ end
112
+
113
+ def build_routes(rails_route, mount_prefix)
114
+ return [] if rails_route.internal
115
+
116
+ app = rails_route.app
117
+ spec = normalize_path(rails_route.path.spec.to_s)
118
+
119
+ # A mounted engine's routes live in its own table, relative to the mount.
120
+ if engine?(app)
121
+ return collect(app.rack_app.routes.routes, mount_prefix: join(mount_prefix, spec))
122
+ end
123
+
124
+ controller = rails_route.defaults[:controller]&.to_s
125
+ action = rails_route.defaults[:action]&.to_s
126
+
127
+ if controller.nil? || action.nil?
128
+ @skipped[skip_reason(app)] += 1
129
+ return []
130
+ end
131
+
132
+ return [] if internal?(controller)
133
+
134
+ path = join(mount_prefix, spec)
135
+
136
+ return [] unless matches_prefix?(path)
137
+ return [] unless matches_version?(controller, path)
138
+
139
+ resource = controller.split("/").last
140
+
141
+ return [] unless matches_resource?(resource, controller)
142
+
143
+ normalize_methods(rails_route.verb).map do |method|
144
+ Route.new(
145
+ name: rails_route.name,
146
+ method: method,
147
+ path: path,
148
+ controller: controller,
149
+ action: action,
150
+ resource: resource,
151
+ api_version: detect_version(controller, path),
152
+ rails_route: rails_route,
153
+ mount_prefix: mount_prefix
154
+ )
155
+ end
156
+ end
157
+
158
+ # A mounted app that carries its own route table (a Rails::Engine, or
159
+ # anything shaped like one). Checked by shape rather than by class: the
160
+ # `Rails::Engine` constant need not be loaded, and a Sinatra app — whose
161
+ # `routes` is a plain Hash — is a Rack mount, not an engine.
162
+ def engine?(app)
163
+ return false unless app.respond_to?(:rack_app)
164
+
165
+ rack_app = app.rack_app
166
+
167
+ rack_app.respond_to?(:routes) && rack_app.routes.respond_to?(:routes)
168
+ end
169
+
170
+ def skip_reason(app)
171
+ rack_app = app.respond_to?(:rack_app) ? app.rack_app : app
172
+
173
+ if defined?(ActionDispatch::Routing::Redirect) && rack_app.is_a?(ActionDispatch::Routing::Redirect)
174
+ "redirect"
175
+ else
176
+ "mount"
177
+ end
178
+ end
179
+
180
+ # Rails 8 exposes the verb as a plain string: "GET", "GET|POST" for
181
+ # `via: [:get, :post]`, "" for `via: :all`. Older versions used a regexp
182
+ # whose source carried anchors.
183
+ def normalize_methods(verb)
184
+ values = verb.to_s
185
+ .gsub(/[$^]/, "")
186
+ .split("|")
187
+ .map(&:strip)
188
+ .reject(&:empty?)
189
+ .map(&:upcase)
190
+
191
+ values.empty? ? [ANY] : values
192
+ end
193
+
194
+ def normalize_path(path)
195
+ path.sub(/\(\.:format\)\z/, "")
196
+ end
197
+
198
+ def join(mount_prefix, path)
199
+ return path if mount_prefix.nil? || mount_prefix.empty?
200
+ return mount_prefix if path == "/"
201
+
202
+ "#{mount_prefix}#{path}"
203
+ end
204
+
205
+ def internal?(controller)
206
+ controller.start_with?(
207
+ "rails/",
208
+ "active_storage/",
209
+ "action_mailbox/",
210
+ "turbo/"
211
+ )
212
+ end
213
+
214
+ def matches_prefix?(path)
215
+ return true if prefix.nil? || prefix.empty?
216
+
217
+ path.start_with?(prefix)
218
+ end
219
+
220
+ # `RESOURCE=customers`, `RESOURCE=cart` (a singular resource is served by
221
+ # `carts`) and `RESOURCE=api/v2/customers` all name the same thing.
222
+ def matches_resource?(resource, controller)
223
+ return true if resources.empty?
224
+
225
+ candidates = [resource, controller, Support.singularize(resource)]
226
+
227
+ resources.any? { |wanted| candidates.include?(wanted) }
228
+ end
229
+
230
+ def matches_version?(controller, path)
231
+ return true if version.nil? || version.empty?
232
+
233
+ controller.split("/").include?(version) ||
234
+ path.split("/").include?(version)
235
+ end
236
+
237
+ def detect_version(controller, path)
238
+ controller.split("/").find { |segment| segment.match?(/\Av\d+\z/) } ||
239
+ path.split("/").find { |segment| segment.match?(/\Av\d+\z/) }
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Sanitizers
5
+ # Works on a raw captured exchange, before anything reaches the dataset:
6
+ # credentials must never be written to a generated file.
7
+ class Sanitizer
8
+ DEFAULT_SECRET_HEADERS = %w[
9
+ Authorization
10
+ Proxy-Authorization
11
+ Cookie
12
+ Set-Cookie
13
+ X-Api-Key
14
+ X-Auth-Token
15
+ X-Csrf-Token
16
+ ].freeze
17
+
18
+ SCHEME_PATTERN = /\A(Bearer|Token|Basic)\s+/i
19
+
20
+ def self.call(exchange, configuration:)
21
+ new(
22
+ exchange,
23
+ configuration: configuration
24
+ ).call
25
+ end
26
+
27
+ def initialize(exchange, configuration:)
28
+ @exchange = deep_dup(exchange)
29
+ @configuration = configuration
30
+ end
31
+
32
+ def call
33
+ sanitize_headers!(
34
+ exchange.dig("request", "headers")
35
+ )
36
+
37
+ sanitize_headers!(
38
+ exchange.dig("response", "headers"),
39
+ response: true
40
+ )
41
+
42
+ sanitize_body!("request")
43
+ sanitize_body!("response")
44
+
45
+ exchange
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :exchange, :configuration
51
+
52
+ def sanitize_headers!(headers, response: false)
53
+ return unless headers.is_a?(Hash)
54
+
55
+ replacements = configuration.sanitized_headers
56
+
57
+ headers.keys.each do |key|
58
+ if configuration.noisy_header?(key)
59
+ headers.delete(key)
60
+ next
61
+ end
62
+
63
+ if blank?(headers[key])
64
+ headers.delete(key)
65
+ next
66
+ end
67
+
68
+ replacement = find_replacement(replacements, key)
69
+
70
+ if replacement
71
+ headers[key] = replacement
72
+ next
73
+ end
74
+
75
+ headers[key] = redact(key, headers[key]) if secret_header?(key)
76
+ end
77
+ end
78
+
79
+ def sanitize_body!(side)
80
+ body = exchange.dig(side, "body")
81
+
82
+ return unless body.is_a?(Hash) || body.is_a?(Array)
83
+
84
+ exchange[side]["body"] = sanitize_value(body)
85
+ end
86
+
87
+ def sanitize_value(value)
88
+ case value
89
+ when Hash
90
+ value.each_with_object({}) do |(key, nested), result|
91
+ replacement = body_replacement(key)
92
+
93
+ result[key] = replacement || sanitize_value(nested)
94
+ end
95
+ when Array
96
+ value.map { |item| sanitize_value(item) }
97
+ else
98
+ value
99
+ end
100
+ end
101
+
102
+ def body_replacement(key)
103
+ configuration.sanitized_body_keys.find do |name, _value|
104
+ name.to_s.casecmp?(key.to_s)
105
+ end&.last
106
+ end
107
+
108
+ def find_replacement(replacements, key)
109
+ pair = replacements.find do |header, _value|
110
+ header.to_s.casecmp?(key.to_s)
111
+ end
112
+
113
+ pair&.last
114
+ end
115
+
116
+ # The scheme is kept so the generated cURL stays copy-pasteable.
117
+ def redact(key, value)
118
+ placeholder = "{{#{key.to_s.tr('-', '_').downcase}}}"
119
+
120
+ match = value.to_s.match(SCHEME_PATTERN)
121
+
122
+ match ? "#{match[1]} #{placeholder}" : placeholder
123
+ end
124
+
125
+ def secret_header?(key)
126
+ DEFAULT_SECRET_HEADERS.any? do |header|
127
+ header.casecmp?(key.to_s)
128
+ end
129
+ end
130
+
131
+ def blank?(value)
132
+ value.nil? || value.to_s.empty?
133
+ end
134
+
135
+ def deep_dup(value)
136
+ Marshal.load(Marshal.dump(value))
137
+ end
138
+ end
139
+ end
140
+ end