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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class Endpoint
5
+ ACTION_TITLES = {
6
+ "index" => "List %<plural>s",
7
+ "show" => "Get %<singular>s",
8
+ "create" => "Create %<singular>s",
9
+ "update" => "Update %<singular>s",
10
+ "destroy" => "Delete %<singular>s",
11
+ "new" => "New %<singular>s",
12
+ "edit" => "Edit %<singular>s"
13
+ }.freeze
14
+
15
+ # One status the endpoint was seen to return, described by every body
16
+ # captured with that status; the first capture stands as the example.
17
+ Response = Struct.new(:status, :schema, :examples, keyword_init: true) do
18
+ def example
19
+ examples.first
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ status: status,
25
+ schema: schema.to_a,
26
+ example: example.body,
27
+ headers: example.headers,
28
+ content_type: example.content_type
29
+ }
30
+ end
31
+ end
32
+
33
+ attr_accessor :name,
34
+ :method,
35
+ :path,
36
+ :controller,
37
+ :action,
38
+ :resource,
39
+ :api_version,
40
+ :route_name,
41
+ :also_methods,
42
+ :request_examples,
43
+ :response_examples
44
+
45
+ def initialize(
46
+ method:,
47
+ path:,
48
+ controller:,
49
+ action:,
50
+ name: nil,
51
+ resource: nil,
52
+ api_version: nil,
53
+ route_name: nil,
54
+ also_methods: [],
55
+ request_examples: [],
56
+ response_examples: []
57
+ )
58
+ @method = method.to_s.upcase
59
+ @path = path
60
+ @controller = controller
61
+ @action = action
62
+ @resource = resource || infer_resource(controller)
63
+ @api_version = api_version
64
+ @route_name = route_name
65
+ @also_methods = also_methods
66
+ @request_examples = request_examples
67
+ @response_examples = response_examples
68
+ @name = name || default_name
69
+ end
70
+
71
+ def http_method
72
+ method
73
+ end
74
+
75
+ def key
76
+ "#{method} #{path}"
77
+ end
78
+
79
+ # A request only counts as successful through the status it received, so
80
+ # the pair is what tells us; callers should not have to set it by hand.
81
+ def add_exchange(request:, response:)
82
+ request.response_status ||= response.status if request && response
83
+
84
+ add_request_example(request)
85
+ add_response_example(response)
86
+ end
87
+
88
+ def add_request_example(example)
89
+ return if example.nil?
90
+ return if request_examples.any? { |candidate| candidate.signature == example.signature }
91
+
92
+ reset_schemas!
93
+ request_examples << example
94
+ end
95
+
96
+ def add_response_example(example)
97
+ return if example.nil?
98
+ return if response_examples.any? { |candidate| candidate.signature == example.signature }
99
+
100
+ response_examples << example
101
+ end
102
+
103
+ def documented?
104
+ !request_examples.empty? || !response_examples.empty?
105
+ end
106
+
107
+ # cURL examples must come from a request that the application actually
108
+ # accepted. Error-case payloads are valuable response examples, but they
109
+ # must never become the endpoint's canonical request example.
110
+ def primary_request_example
111
+ successful_request_examples.first
112
+ end
113
+
114
+ def curl_ready?
115
+ !primary_request_example.nil?
116
+ end
117
+
118
+ def successful_request_examples
119
+ request_examples.select(&:successful?)
120
+ end
121
+
122
+ def documented_request_examples
123
+ successful = successful_request_examples
124
+ successful.empty? ? request_examples : successful
125
+ end
126
+
127
+ def responses_by_status
128
+ response_examples.group_by(&:status).sort_by { |status, _| status }.to_h
129
+ end
130
+
131
+ # Every status seen, each with a schema inferred from all of its bodies.
132
+ # Non-JSON bodies (plain text, HTML) carry no fields to describe.
133
+ def responses
134
+ responses_by_status.map do |status, examples|
135
+ bodies = examples.map(&:body).select { |body| body.is_a?(Hash) || body.is_a?(Array) }
136
+
137
+ Response.new(status: status, schema: Schema.infer(bodies, repetition: false), examples: examples)
138
+ end
139
+ end
140
+
141
+ # What the endpoint accepts, described only by requests the application
142
+ # accepted: a rejected payload says what the API refuses, not what it takes.
143
+ def body_schema
144
+ @body_schema ||= Schema.infer(successful_request_examples.map(&:body))
145
+ end
146
+
147
+ def query_schema
148
+ @query_schema ||= Schema.infer(successful_request_examples.map(&:query_params))
149
+ end
150
+
151
+ def path_param_schema
152
+ @path_param_schema ||= Schema.infer(successful_request_examples.map(&:path_params))
153
+ end
154
+
155
+ # A member route addresses one record: a required `:param` follows the
156
+ # resource segment. Optional groups (`/items(/:id)`) and globs
157
+ # (`/files/*path`) do not make a route a member route.
158
+ def member?
159
+ segments = required_path.split("/").reject(&:empty?)
160
+ singular = Support.singularize(resource.to_s)
161
+ index = segments.rindex { |segment| segment == resource.to_s || segment == singular }
162
+
163
+ # `resource :cart` is served by CartsController at /cart: one record,
164
+ # so its custom actions (/cart/checkout) address that one record.
165
+ return true if index && segments[index] == singular && singular != resource.to_s
166
+
167
+ candidates = index ? segments[(index + 1)..] : segments
168
+
169
+ candidates.any? { |segment| segment.start_with?(":") }
170
+ end
171
+
172
+ # Every dynamic segment, including globs and those inside optional groups.
173
+ def path_params
174
+ path.scan(/[:*]([a-zA-Z_][a-zA-Z0-9_]*)/).flatten
175
+ end
176
+
177
+ # The path with its optional groups removed: what a request must carry.
178
+ def required_path
179
+ stripped = path.to_s
180
+
181
+ stripped = stripped.gsub(/\([^()]*\)/, "") while stripped.match?(/\([^()]*\)/)
182
+
183
+ stripped
184
+ end
185
+
186
+ def slug
187
+ Support.parameterize(action.to_s.empty? ? key : action)
188
+ end
189
+
190
+ def to_h
191
+ {
192
+ name: name,
193
+ method: method,
194
+ path: path,
195
+ controller: controller,
196
+ action: action,
197
+ resource: resource,
198
+ api_version: api_version,
199
+ route_name: route_name,
200
+ also_methods: also_methods,
201
+ path_params: path_params,
202
+ parameters: {
203
+ path: path_param_schema.to_a,
204
+ query: query_schema.to_a,
205
+ body: body_schema.to_a
206
+ },
207
+ responses: responses.map(&:to_h),
208
+ request_examples: request_examples.map(&:to_h),
209
+ response_examples: response_examples.map(&:to_h)
210
+ }
211
+ end
212
+
213
+ private
214
+
215
+ def reset_schemas!
216
+ @body_schema = nil
217
+ @query_schema = nil
218
+ @path_param_schema = nil
219
+ end
220
+
221
+ def infer_resource(controller)
222
+ controller.to_s.split("/").last
223
+ end
224
+
225
+ def default_name
226
+ label = resource.to_s.empty? ? "resource" : resource.to_s.tr("/", " ")
227
+ plural = Support.titleize(Support.pluralize(label))
228
+ singular = Support.titleize(Support.singularize(label))
229
+
230
+ # `root to: "home#index"` is a page, not a list of homes.
231
+ return singular if action.to_s == "index" && !resource_in_path?
232
+
233
+ template = ACTION_TITLES[action.to_s]
234
+ return format(template, plural: plural, singular: singular) if template
235
+ return plural if action.to_s.empty?
236
+
237
+ # `post "auth/login", to: "auth#login"`: the action is the page, the
238
+ # controller is only where it lives — "Login", not "Login Auths".
239
+ return Support.titleize(action) if singular_resource? && action_in_path?
240
+
241
+ "#{Support.titleize(action)} #{member? || singular_resource? ? singular : plural}".strip
242
+ end
243
+
244
+ # A controller named for one thing (auth, home, health) rather than a
245
+ # collection (customers).
246
+ def singular_resource?
247
+ Support.pluralize(resource.to_s) != resource.to_s
248
+ end
249
+
250
+ def action_in_path?
251
+ required_path.split("/").last.to_s == action.to_s
252
+ end
253
+
254
+ # Whether the path itself names the resource (/customers, /cart), as
255
+ # opposed to a route like `/` or `/dashboard` served by some controller.
256
+ def resource_in_path?
257
+ static = required_path.split("/").reject { |segment| segment.empty? || segment.start_with?(":", "*") }
258
+ names = [resource.to_s, Support.singularize(resource.to_s)]
259
+
260
+ static.any? { |segment| names.include?(segment) }
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class GenerationError < Error; end
9
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Exporters
5
+ # Writes one runnable .sh file per covered endpoint. The command is always
6
+ # rendered from the endpoint's successful captured request when available.
7
+ class Curl
8
+ def self.call(dataset:, output_dir:, configuration:)
9
+ new(
10
+ dataset: dataset,
11
+ output_dir: output_dir,
12
+ configuration: configuration
13
+ ).call
14
+ end
15
+
16
+ def initialize(dataset:, output_dir:, configuration:)
17
+ @dataset = dataset
18
+ @output_dir = Pathname(output_dir).join("curl")
19
+ @configuration = configuration
20
+ end
21
+
22
+ def call
23
+ FileUtils.mkdir_p(output_dir)
24
+
25
+ dataset.resources.flat_map do |resource|
26
+ basenames = resource.file_basenames
27
+
28
+ resource.endpoints.filter_map do |endpoint|
29
+ next unless endpoint.curl_ready?
30
+
31
+ example = endpoint.primary_request_example
32
+
33
+ directory = output_dir.join(resource.slug)
34
+ FileUtils.mkdir_p(directory)
35
+
36
+ path = directory.join("#{basenames.fetch(endpoint)}.sh")
37
+ File.write(path, script(endpoint, example))
38
+ File.chmod(0o755, path)
39
+
40
+ path.to_s
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :dataset, :output_dir, :configuration
48
+
49
+ def script(endpoint, example)
50
+ command = Renderers::Curl.call(
51
+ example,
52
+ base_url: configuration.base_url
53
+ )
54
+
55
+ <<~SH
56
+ #!/usr/bin/env bash
57
+ set -euo pipefail
58
+
59
+ # #{endpoint.name}
60
+ # #{endpoint.method} #{endpoint.path}
61
+ #{command}
62
+ SH
63
+ end
64
+ end
65
+
66
+ register("curl", Curl)
67
+ end
68
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Exporters
5
+ class Markdown
6
+ def self.call(dataset:, output_dir:, configuration:)
7
+ new(
8
+ dataset: dataset,
9
+ output_dir: output_dir,
10
+ configuration: configuration
11
+ ).call
12
+ end
13
+
14
+ def initialize(dataset:, output_dir:, configuration:)
15
+ @dataset = dataset
16
+ @output_dir = Pathname(output_dir)
17
+ @configuration = configuration
18
+ end
19
+
20
+ # Returns every written path.
21
+ def call
22
+ FileUtils.mkdir_p(output_dir)
23
+
24
+ written = [write(output_dir.join("README.md"), index_document)]
25
+
26
+ written_resources.each do |resource|
27
+ directory = output_dir.join(resource.slug)
28
+
29
+ FileUtils.mkdir_p(directory)
30
+
31
+ written << write(
32
+ directory.join("index.md"),
33
+ resource_document(resource)
34
+ )
35
+
36
+ filenames(resource).each do |endpoint, filename|
37
+ next unless documented?(endpoint)
38
+
39
+ written << write(
40
+ directory.join(filename),
41
+ endpoint_document(resource, endpoint)
42
+ )
43
+ end
44
+ end
45
+
46
+ written
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :dataset, :output_dir, :configuration
52
+
53
+ # An endpoint gets a page when a test reached it, or when the project
54
+ # asked for the gaps to be written out too.
55
+ def documented?(endpoint)
56
+ endpoint.curl_ready? || configuration.include_uncovered?
57
+ end
58
+
59
+ # A resource with nothing to show gets no directory, so the index never
60
+ # links to a page that was not written.
61
+ def written_resources
62
+ @written_resources ||= dataset.resources.select do |resource|
63
+ resource.endpoints.any? { |endpoint| documented?(endpoint) }
64
+ end
65
+ end
66
+
67
+ def written?(resource)
68
+ written_resources.include?(resource)
69
+ end
70
+
71
+ def write(path, content)
72
+ File.write(path, content)
73
+
74
+ path.to_s
75
+ end
76
+
77
+ def filenames(resource)
78
+ resource.file_basenames.transform_values { |basename| "#{basename}.md" }
79
+ end
80
+
81
+ def index_document
82
+ lines = ["# API Documentation", ""]
83
+ lines << "Generated by Reqcord from the application's integration tests."
84
+ lines << ""
85
+ lines << "Base URL: `#{configuration.base_url}`"
86
+ lines << ""
87
+ lines.concat(placeholders_section)
88
+
89
+ dataset.resources.each do |resource|
90
+ lines << if written?(resource)
91
+ "## [#{resource.title}](#{resource.slug}/index.md)"
92
+ else
93
+ "## #{resource.title}"
94
+ end
95
+ lines << ""
96
+ lines.concat(namespace_line(resource))
97
+ lines.concat(endpoint_table(resource, prefix: "#{resource.slug}/"))
98
+ lines << ""
99
+ end
100
+
101
+ undocumented = dataset.endpoints.reject(&:curl_ready?)
102
+
103
+ unless undocumented.empty?
104
+ lines << "## No Successful Request Captured"
105
+ lines << ""
106
+ lines << "No successful 2xx request was captured for these routes:"
107
+ lines << ""
108
+ undocumented.each { |endpoint| lines << "- `#{endpoint.method} #{endpoint.path}`" }
109
+ lines << ""
110
+ end
111
+
112
+ lines.join("\n")
113
+ end
114
+
115
+ # Sanitized values are written as placeholders; the reader has to know
116
+ # what to substitute.
117
+ def placeholders_section
118
+ placeholders = configuration.sanitized_headers.values.grep(/\{\{.+\}\}/)
119
+
120
+ return [] if placeholders.empty?
121
+
122
+ rows = configuration.sanitized_headers.filter_map do |header, value|
123
+ "| #{header} | `#{value}` |" if value.to_s.match?(/\{\{.+\}\}/)
124
+ end
125
+
126
+ ["## Placeholders", "", "| Header | Replace with |", "| --- | --- |", *rows, ""]
127
+ end
128
+
129
+ # Two resources can share a title (admin/customers, api/v2/customers);
130
+ # the namespace tells them apart.
131
+ def namespace_line(resource)
132
+ return [] if resource.namespace.empty?
133
+
134
+ ["Namespace: `#{resource.namespace}`", ""]
135
+ end
136
+
137
+ def resource_document(resource)
138
+ lines = ["# #{resource.title}", ""]
139
+ lines.concat(namespace_line(resource))
140
+
141
+ versions = resource.api_versions
142
+
143
+ unless versions.empty?
144
+ lines << "API version: #{versions.map { |version| "`#{version}`" }.join(', ')}"
145
+ lines << ""
146
+ end
147
+
148
+ lines.concat(endpoint_table(resource))
149
+ lines << ""
150
+ lines.join("\n")
151
+ end
152
+
153
+ def endpoint_table(resource, prefix: "")
154
+ rows = filenames(resource).map do |endpoint, filename|
155
+ path =
156
+ if documented?(endpoint)
157
+ "[`#{endpoint.path}`](#{prefix}#{filename})"
158
+ else
159
+ "`#{endpoint.path}`"
160
+ end
161
+
162
+ covered = endpoint.curl_ready? ? "" : " _(no successful request captured)_"
163
+
164
+ "| `#{endpoint.method}` | #{path} | #{endpoint.name}#{covered} |"
165
+ end
166
+
167
+ ["| Method | Path | Description |", "| --- | --- | --- |", *rows]
168
+ end
169
+
170
+ def endpoint_document(resource, endpoint)
171
+ example = endpoint.primary_request_example
172
+
173
+ lines = ["# #{endpoint.name}", "", endpoint_line(endpoint), ""]
174
+ lines.concat(namespace_line(resource))
175
+
176
+ unless endpoint.curl_ready?
177
+ lines << "_No successful 2xx request was captured for this endpoint yet._"
178
+ lines << ""
179
+ end
180
+
181
+ lines.concat(headers_section(example))
182
+ lines.concat(schema_section("Path Parameters", endpoint.path_param_schema))
183
+ lines.concat(schema_section("Query Parameters", endpoint.query_schema))
184
+ lines.concat(schema_section("Body Parameters", endpoint.body_schema))
185
+ lines.concat(request_body_section(example))
186
+ lines.concat(curl_section(example))
187
+ lines.concat(responses_section(endpoint))
188
+ lines.concat(["---", "", "Resource: [#{resource.title}](index.md)", ""])
189
+
190
+ lines.join("\n")
191
+ end
192
+
193
+ # Field, type, whether every accepted request carried it, and the values
194
+ # those requests used. Values are listed only when they look like a
195
+ # closed set; otherwise one of them stands as an example.
196
+ def schema_section(title, schema, level: 2)
197
+ return [] if schema.empty?
198
+
199
+ rows = schema.map do |field|
200
+ "| `#{field.path}` | #{field.type} | #{field.required? ? 'yes' : 'no'} | #{values_cell(field)} |"
201
+ end
202
+
203
+ ["#{'#' * level} #{title}", "", "| Field | Type | Required | Values |", "| --- | --- | --- | --- |", *rows, ""]
204
+ end
205
+
206
+ def values_cell(field)
207
+ return "-" if field.values.empty?
208
+ return field.listed_values.map { |value| "`#{value.inspect}`" }.join(" \\| ") if field.enum?
209
+
210
+ "`#{field.example.inspect}`"
211
+ end
212
+
213
+ # `PATCH /api/cart` (also `PUT`)
214
+ def endpoint_line(endpoint)
215
+ line = "`#{endpoint.method} #{endpoint.path}`"
216
+
217
+ return line if endpoint.also_methods.empty?
218
+
219
+ "#{line} (also #{endpoint.also_methods.map { |method| "`#{method}`" }.join(', ')})"
220
+ end
221
+
222
+ def headers_section(example)
223
+ return [] if example.nil? || example.headers.empty?
224
+
225
+ rows = example.headers.map { |name, value| "| #{name} | `#{value}` |" }
226
+
227
+ ["## Headers", "", "| Header | Value |", "| --- | --- |", *rows, ""]
228
+ end
229
+
230
+ # A form body is shown as the key/value structure the test built, which
231
+ # reads better than the encoded string; the note says how it travels.
232
+ def request_body_section(example)
233
+ return [] unless example&.body?
234
+
235
+ lines = ["## Example Request", ""]
236
+
237
+ unless Renderers::Payload.json?(example) || example.content_type.to_s.empty?
238
+ lines << "Sent as `#{example.content_type}`; the cURL below carries it in that encoding."
239
+ lines << ""
240
+ end
241
+
242
+ lines.concat([*code_block(example.body, example.content_type), ""])
243
+ end
244
+
245
+ def curl_section(example)
246
+ return [] if example.nil?
247
+
248
+ ["## cURL", "", "```bash", curl_for(example), "```", ""]
249
+ end
250
+
251
+ def responses_section(endpoint)
252
+ return [] if endpoint.response_examples.empty?
253
+
254
+ lines = ["## Responses", ""]
255
+
256
+ endpoint.responses.each do |response|
257
+ lines << "### #{response.example.title}"
258
+ lines << ""
259
+ lines.concat(schema_section("Fields", response.schema, level: 4))
260
+ lines.concat(response_body(response.example))
261
+ end
262
+
263
+ lines
264
+ end
265
+
266
+ def response_body(example)
267
+ return ["_No response body._", ""] unless example.body?
268
+
269
+ [*code_block(example.body, example.content_type), ""]
270
+ end
271
+
272
+ def curl_for(example)
273
+ Renderers::Curl.call(
274
+ example,
275
+ base_url: configuration.base_url
276
+ )
277
+ end
278
+
279
+ def code_block(value, content_type)
280
+ case value
281
+ when Hash, Array then ["```json", pretty_json(value), "```"]
282
+ else ["```#{content_type.to_s.include?('json') ? 'json' : 'text'}", value.to_s, "```"]
283
+ end
284
+ end
285
+
286
+ def pretty_json(value)
287
+ JSON.pretty_generate(value)
288
+ rescue JSON::GeneratorError
289
+ value.to_s
290
+ end
291
+ end
292
+
293
+ register("markdown", Markdown)
294
+ end
295
+ end