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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +252 -95
- data/Rakefile +13 -0
- data/docs/configuration.md +319 -0
- data/examples/reqcord.yml +58 -0
- data/gemfiles/rails_7.1.gemfile +13 -0
- data/gemfiles/rails_7.2.gemfile +13 -0
- data/gemfiles/rails_8.0.gemfile +13 -0
- data/gemfiles/rails_8.1.gemfile +13 -0
- data/lib/reqcord/capture/collector.rb +31 -0
- data/lib/reqcord/capture/integration_patch.rb +209 -0
- data/lib/reqcord/capture/minitest_context.rb +34 -0
- data/lib/reqcord/capture/rspec_context.rb +42 -0
- data/lib/reqcord/capture/test_context.rb +25 -0
- data/lib/reqcord/capture.rb +19 -0
- data/lib/reqcord/configuration.rb +198 -0
- data/lib/reqcord/dataset.rb +176 -0
- data/lib/reqcord/endpoint.rb +263 -0
- data/lib/reqcord/errors.rb +9 -0
- data/lib/reqcord/exporters/curl.rb +68 -0
- data/lib/reqcord/exporters/markdown.rb +295 -0
- data/lib/reqcord/exporters/postman.rb +206 -0
- data/lib/reqcord/exporters.rb +32 -0
- data/lib/reqcord/generator.rb +364 -0
- data/lib/reqcord/railtie.rb +51 -0
- data/lib/reqcord/renderers/curl.rb +56 -0
- data/lib/reqcord/renderers/payload.rb +69 -0
- data/lib/reqcord/request_example.rb +104 -0
- data/lib/reqcord/response_example.rb +72 -0
- data/lib/reqcord/route_collector.rb +242 -0
- data/lib/reqcord/sanitizers/sanitizer.rb +140 -0
- data/lib/reqcord/schema.rb +187 -0
- data/lib/reqcord/support.rb +58 -0
- data/lib/reqcord/version.rb +5 -0
- data/lib/reqcord.rb +78 -0
- data/lib/tasks/reqcord.rake +99 -0
- data/reqcord.gemspec +60 -0
- 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
|