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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Capture
5
+ module MinitestContext
6
+ def before_setup
7
+ file, line = reqcord_source_location
8
+
9
+ TestContext.current = {
10
+ test: name,
11
+ suite: self.class.name,
12
+ file: file,
13
+ line: line
14
+ }
15
+
16
+ super
17
+ end
18
+
19
+ def after_teardown
20
+ super
21
+ ensure
22
+ TestContext.clear
23
+ end
24
+
25
+ private
26
+
27
+ def reqcord_source_location
28
+ method(name).source_location
29
+ rescue NameError
30
+ [nil, nil]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Capture
5
+ # The RSpec counterpart of MinitestContext: names captured examples after
6
+ # the request spec that produced them.
7
+ module RSpecContext
8
+ class << self
9
+ def install!(rspec = ::RSpec)
10
+ return false if @installed
11
+
12
+ rspec.configure do |config|
13
+ config.before(:each) do |example|
14
+ TestContext.current = RSpecContext.context_for(example)
15
+ end
16
+
17
+ config.after(:each) do
18
+ TestContext.clear
19
+ end
20
+ end
21
+
22
+ @installed = true
23
+ end
24
+
25
+ def installed?
26
+ @installed == true
27
+ end
28
+
29
+ def context_for(example)
30
+ metadata = example.metadata
31
+
32
+ {
33
+ test: metadata[:description],
34
+ suite: metadata[:example_group][:description],
35
+ file: metadata[:file_path],
36
+ line: metadata[:line_number]
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Capture
5
+ # The test that is currently running, kept per thread so parallel runners
6
+ # do not attribute an exchange to the wrong test.
7
+ module TestContext
8
+ THREAD_KEY = :reqcord_test_context
9
+
10
+ class << self
11
+ def current
12
+ Thread.current[THREAD_KEY] || {}
13
+ end
14
+
15
+ def current=(value)
16
+ Thread.current[THREAD_KEY] = value
17
+ end
18
+
19
+ def clear
20
+ Thread.current[THREAD_KEY] = nil
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ # Capture runs inside the test process and writes to a file the generator
5
+ # reads afterwards. Both variables are set by `reqcord:generate`, so an
6
+ # ordinary test run patches nothing and writes nothing.
7
+ module Capture
8
+ class << self
9
+ def enabled?
10
+ ENV["REQCORD_CAPTURE"] == "1" &&
11
+ capture_file
12
+ end
13
+
14
+ def capture_file
15
+ ENV["REQCORD_CAPTURE_FILE"]
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class Configuration
5
+ DEFAULTS = {
6
+ "version" => 1,
7
+
8
+ # No default command: with neither `command` nor `paths` the generator
9
+ # falls back to `bin/rails test`, and `paths` alone must be able to win.
10
+ "test" => {
11
+ "framework" => "minitest"
12
+ },
13
+
14
+ "routes" => {
15
+ "prefix" => "/api"
16
+ },
17
+
18
+ "output" => {
19
+ "directory" => "docs/api",
20
+
21
+ # A route no test exercised is reported in the index; writing a page
22
+ # with nothing on it only adds noise.
23
+ "include_uncovered" => false
24
+ },
25
+
26
+ "exporters" => %w[curl markdown postman],
27
+
28
+ "variables" => {
29
+ "base_url" => "http://localhost:3000"
30
+ },
31
+
32
+ "sanitize" => {
33
+ "headers" => {
34
+ "Authorization" => "Bearer {{token}}",
35
+ "X-Api-Key" => "{{api_key}}"
36
+ },
37
+
38
+ "body" => {
39
+ "password" => "{{password}}",
40
+ "password_confirmation" => "{{password}}",
41
+ "token" => "{{token}}",
42
+ "access_token" => "{{token}}",
43
+ "refresh_token" => "{{token}}",
44
+ "api_key" => "{{api_key}}",
45
+ "secret" => "{{secret}}",
46
+ "client_secret" => "{{secret}}"
47
+ }
48
+ }
49
+ }.freeze
50
+
51
+ # Headers that describe the transport rather than the API. A documented
52
+ # cURL that carries them is worse than one that does not: `Host` alone
53
+ # would send the reader's request to the wrong virtual host.
54
+ NOISY_HEADERS = %w[
55
+ host
56
+ user-agent
57
+ connection
58
+ version
59
+ remote-addr
60
+ accept-encoding
61
+ cache-control
62
+ content-length
63
+ date
64
+ etag
65
+ server-timing
66
+ transfer-encoding
67
+ vary
68
+ x-content-type-options
69
+ x-download-options
70
+ x-frame-options
71
+ x-permitted-cross-domain-policies
72
+ x-request-id
73
+ x-runtime
74
+ x-xss-protection
75
+ referrer-policy
76
+ ].freeze
77
+
78
+ attr_reader :data, :root
79
+
80
+ def self.load(root:)
81
+ new(root: root).load
82
+ end
83
+
84
+ def initialize(root:)
85
+ @root = Pathname(root)
86
+ @data = deep_dup(DEFAULTS)
87
+ end
88
+
89
+ def load
90
+ path = root.join("reqcord.yml")
91
+
92
+ return self unless path.exist?
93
+
94
+ raw = YAML.safe_load_file(
95
+ path,
96
+ aliases: false
97
+ ) || {}
98
+
99
+ unless raw.is_a?(Hash)
100
+ raise ConfigurationError,
101
+ "reqcord.yml must contain a YAML object"
102
+ end
103
+
104
+ @data = deep_merge(@data, stringify_keys(raw))
105
+
106
+ self
107
+ end
108
+
109
+ def test_framework
110
+ ENV["REQCORD_TEST_FRAMEWORK"] ||
111
+ data.dig("test", "framework")
112
+ end
113
+
114
+ # An explicit command wins; nil means "build one from test.paths".
115
+ def test_command
116
+ value = ENV["REQCORD_TEST_COMMAND"] || data.dig("test", "command")
117
+
118
+ value.to_s.empty? ? nil : value.to_s
119
+ end
120
+
121
+ # Directories, files or globs the suite lives in; Reqcord picks the runner.
122
+ def test_paths
123
+ Array(data.dig("test", "paths")).map(&:to_s)
124
+ end
125
+
126
+ def route_prefix
127
+ data.dig("routes", "prefix")
128
+ end
129
+
130
+ def output_directory
131
+ value =
132
+ ENV["REQCORD_OUTPUT"] ||
133
+ data.dig("output", "directory") ||
134
+ "docs/api"
135
+
136
+ root.join(value)
137
+ end
138
+
139
+ def include_uncovered?
140
+ data.dig("output", "include_uncovered") == true
141
+ end
142
+
143
+ def exporters
144
+ Array(data["exporters"]).map(&:to_s)
145
+ end
146
+
147
+ def variables
148
+ (data["variables"] || {}).merge(
149
+ "base_url" => base_url
150
+ )
151
+ end
152
+
153
+ def base_url
154
+ ENV["REQCORD_BASE_URL"] ||
155
+ data.dig("variables", "base_url") ||
156
+ "http://localhost:3000"
157
+ end
158
+
159
+ def sanitized_headers
160
+ data.dig("sanitize", "headers") || {}
161
+ end
162
+
163
+ def sanitized_body_keys
164
+ data.dig("sanitize", "body") || {}
165
+ end
166
+
167
+ def noisy_header?(key)
168
+ NOISY_HEADERS.include?(key.to_s.downcase)
169
+ end
170
+
171
+ private
172
+
173
+ def deep_merge(left, right)
174
+ left.merge(right) do |_key, old_value, new_value|
175
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
176
+ deep_merge(old_value, new_value)
177
+ else
178
+ new_value
179
+ end
180
+ end
181
+ end
182
+
183
+ def deep_dup(value)
184
+ Marshal.load(Marshal.dump(value))
185
+ end
186
+
187
+ def stringify_keys(hash)
188
+ hash.each_with_object({}) do |(key, value), result|
189
+ result[key.to_s] =
190
+ if value.is_a?(Hash)
191
+ stringify_keys(value)
192
+ else
193
+ value
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class Dataset
5
+ # 2: endpoints carry `parameters` (path/query/body schemas), `responses`
6
+ # (one schema + example per status), `route_name` and `also_methods`.
7
+ SCHEMA_VERSION = 2
8
+
9
+ # An endpoint page must not overwrite the resource or dataset pages.
10
+ RESERVED_BASENAMES = {
11
+ "index" => "list",
12
+ "readme" => "readme-endpoint",
13
+ "dataset" => "dataset-endpoint"
14
+ }.freeze
15
+
16
+ # Endpoints served by one controller, as the exporters walk them. Keyed by
17
+ # the full controller path so `admin/users` and `api/v1/users` stay apart.
18
+ class Resource
19
+ attr_reader :name, :endpoints
20
+
21
+ def initialize(name)
22
+ @name = name.to_s
23
+ @endpoints = []
24
+ end
25
+
26
+ def segments
27
+ name.split("/")
28
+ end
29
+
30
+ def short_name
31
+ segments.last.to_s
32
+ end
33
+
34
+ def namespace
35
+ segments[0...-1].join("/")
36
+ end
37
+
38
+ # Nested directories / folders: api/v2/customers.
39
+ def slug
40
+ segments.map { |segment| Support.parameterize(segment) }.join("/")
41
+ end
42
+
43
+ def title
44
+ Support.titleize(short_name)
45
+ end
46
+
47
+ def api_versions
48
+ endpoints.map(&:api_version).compact.uniq.sort
49
+ end
50
+
51
+ # One stable file name per endpoint. Two routes sharing an action
52
+ # (`match … via: [:get, :post]`) are told apart by verb, never by a
53
+ # bare counter.
54
+ def file_basenames
55
+ bases = endpoints.to_h { |endpoint| [endpoint, RESERVED_BASENAMES.fetch(endpoint.slug, endpoint.slug)] }
56
+ shared = bases.values.tally
57
+ seen = Hash.new(0)
58
+
59
+ bases.to_h do |endpoint, base|
60
+ basename = shared[base] > 1 ? "#{base}-#{endpoint.method.downcase}" : base
61
+ seen[basename] += 1
62
+ basename = "#{basename}-#{seen[basename]}" if seen[basename] > 1
63
+
64
+ [endpoint, basename]
65
+ end
66
+ end
67
+ end
68
+
69
+ # Rails emits both PATCH and PUT for `update`; one page serves both. The
70
+ # verb a test used wins, PATCH when neither or both did.
71
+ def self.fold_method_twins(endpoints)
72
+ groups = endpoints.group_by { |endpoint| [endpoint.path, endpoint.controller, endpoint.action] }
73
+
74
+ endpoints.filter_map do |endpoint|
75
+ next endpoint unless %w[PATCH PUT].include?(endpoint.method)
76
+
77
+ twins = groups.fetch([endpoint.path, endpoint.controller, endpoint.action])
78
+ .select { |candidate| %w[PATCH PUT].include?(candidate.method) }
79
+
80
+ next endpoint unless twins.size == 2
81
+
82
+ primary = twins.find(&:curl_ready?) || twins.find { |candidate| candidate.method == "PATCH" }
83
+
84
+ next nil unless endpoint.equal?(primary)
85
+
86
+ twin = twins.find { |candidate| !candidate.equal?(primary) }
87
+ twin.request_examples.each { |example| primary.add_request_example(example) }
88
+ twin.response_examples.each { |example| primary.add_response_example(example) }
89
+ primary.also_methods = [twin.method]
90
+
91
+ primary
92
+ end
93
+ end
94
+
95
+ attr_reader :schema_version, :endpoints
96
+
97
+ def initialize(
98
+ schema_version: SCHEMA_VERSION,
99
+ endpoints: []
100
+ )
101
+ @schema_version = schema_version
102
+ @endpoints = endpoints
103
+ end
104
+
105
+ def add(endpoint)
106
+ endpoints << endpoint
107
+ end
108
+
109
+ def empty?
110
+ endpoints.empty?
111
+ end
112
+
113
+ # Endpoints for which Reqcord captured at least one successful 2xx
114
+ # request. These are safe to use as canonical cURL/documentation examples.
115
+ def curl_ready_endpoints
116
+ endpoints.select(&:curl_ready?)
117
+ end
118
+
119
+ # Backwards-compatible alias for callers that previously asked for
120
+ # documented endpoints. In the cURL-first MVP, documented means a
121
+ # successful request was actually captured.
122
+ def documented_endpoints
123
+ curl_ready_endpoints
124
+ end
125
+
126
+ def uncovered_endpoints
127
+ endpoints.reject(&:curl_ready?)
128
+ end
129
+
130
+ def resources
131
+ grouped = endpoints.each_with_object({}) do |endpoint, memo|
132
+ key = endpoint.controller.to_s.empty? ? endpoint.resource : endpoint.controller
133
+ resource = memo[key] ||= Resource.new(key)
134
+ resource.endpoints << endpoint
135
+ end
136
+
137
+ grouped.values.sort_by(&:name).each do |resource|
138
+ resource.endpoints.sort_by! { |endpoint| [endpoint.path, endpoint.method] }
139
+ end
140
+ end
141
+
142
+ def to_h
143
+ {
144
+ schema_version: schema_version,
145
+ generated_at: Time.now.utc.iso8601,
146
+ endpoints: curl_ready_endpoints.map(&:to_h),
147
+ uncovered_routes: uncovered_endpoints.map do |endpoint|
148
+ {
149
+ name: endpoint.name,
150
+ method: endpoint.method,
151
+ path: endpoint.path,
152
+ controller: endpoint.controller,
153
+ action: endpoint.action,
154
+ resource: endpoint.resource,
155
+ api_version: endpoint.api_version,
156
+ route_name: endpoint.route_name,
157
+ also_methods: endpoint.also_methods
158
+ }
159
+ end
160
+ }
161
+ end
162
+
163
+ def write(path)
164
+ path = Pathname(path)
165
+
166
+ FileUtils.mkdir_p(path.dirname)
167
+
168
+ File.write(
169
+ path,
170
+ JSON.pretty_generate(to_h)
171
+ )
172
+
173
+ path.to_s
174
+ end
175
+ end
176
+ end