reqcord 0.1.3 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b5479eb9ebc313b6b16250eb40692a664c4d7f56eba83a5875a061153714941
4
- data.tar.gz: 61a8fa46ddcef53e0e318589d94fd485e25f7666a3042d2050d05138350db83b
3
+ metadata.gz: 2e075e88e6b448b287ccc4b845cf82b0e18d4d7b9778a8e82d1c09f798d2b1f8
4
+ data.tar.gz: 6a395b1ab6f789b184a72c63b14f9873f2465e891f96ade6c91b19cc102211a5
5
5
  SHA512:
6
- metadata.gz: 3794123441aba25e1aa5a0fd5560ef280cda236ce4eee98b939f684d2cb40a6833b82358331cdf5a609b18076112901b7b9cefa623bea10145350a5d6f729205
7
- data.tar.gz: 5b416b598625d8f76df50cdce032eafddd6102f0a00f364231f334c7d3939041b2e5d622adb801ee3e04ef00d61936ab0c75b1850c3018445954e78ea8236869
6
+ metadata.gz: f5c43bee1cdcbe7d6d6b3de653027a5704995bd9ddd602e879c6905e0a1fdb27ca1936d55ad11be42b1d1969c925ceaf519e5c193f7d86c787b0d8f6f02eba28
7
+ data.tar.gz: f80362f26eddd01297d81f62080e0b93ae50742d49c1616ec92b940550a5a7b3e63e33d69b427cd9e8c7a3091c78c3cffaaae58fc15db13ec0cdc8a18ce01299
data/CHANGELOG.md CHANGED
@@ -1,6 +1,34 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.3] - Unreleased
3
+ ## [0.2.0] - Unreleased
4
+
5
+ ### Added
6
+
7
+ * OpenAPI 3.1 exporter (`openapi/openapi.json`, on by default): one path item
8
+ per documented route (`/items(/:id)` becomes `/items` and `/items/{id}`),
9
+ parameters and request bodies from the inferred schemas, one response per
10
+ captured status with its example, bearer / API-key security schemes from
11
+ the sanitized headers.
12
+ * `Reqcord::Web`: `mount Reqcord::Web => "/api-docs"` serves the OpenAPI
13
+ document with Scalar and every other generated file (`dataset.json`, the
14
+ Postman collection, Markdown pages, cURL scripts) from inside the
15
+ application, Sidekiq::Web style.
16
+
17
+ ## [0.1.4] - 2026-09-16
18
+
19
+ ### Changed
20
+
21
+ * A failing test run no longer discards the output. The docs are generated
22
+ from what the suite captured, the warning names the exit status and the
23
+ report points out that routes reached only by failing tests are listed as
24
+ uncovered. The previous behaviour is `test.strict: true`.
25
+
26
+ ### Added
27
+
28
+ * `test.strict` (`REQCORD_STRICT=1`): abort on a failing suite instead of
29
+ documenting what it captured.
30
+
31
+ ## [0.1.3] - 2026-09-16
4
32
 
5
33
  ### Added
6
34
 
data/README.md CHANGED
@@ -21,7 +21,7 @@ Minitest Integration Tests
21
21
 
22
22
  Canonical Dataset
23
23
 
24
- Markdown · cURL · Postman
24
+ Markdown · cURL · Postman · OpenAPI (Scalar)
25
25
  ```
26
26
 
27
27
  ## Why Reqcord?
@@ -172,8 +172,10 @@ The parameter tables are inferred from the requests the application
172
172
  **accepted**: two passing tests sent `"active"` and `"passive"`, a third sent
173
173
  `"inactive"` and got a `422`, so the documentation lists the two values that
174
174
  work and keeps the rejection only as a response example. The same run also
175
- writes a runnable `curl/api/v2/customers/create.sh` and a Postman collection
176
- with this request and its three saved responses.
175
+ writes a runnable `curl/api/v2/customers/create.sh`, a Postman collection
176
+ with this request and its three saved responses, and an OpenAPI 3.1 document
177
+ you can browse with Scalar by mounting `Reqcord::Web` (see
178
+ [Serve the docs in your app](#serve-the-docs-in-your-app)).
177
179
 
178
180
  ## Examples
179
181
 
@@ -205,9 +207,9 @@ Minitest ──────► Test Adapter
205
207
 
206
208
  Reqcord Dataset
207
209
 
208
- ┌────────────┼────────────┐
209
- ▼ ▼ ▼
210
- Markdown cURL Postman
210
+ ┌────────────┼────────────┬────────────┐
211
+ ▼ ▼ ▼
212
+ Markdown cURL Postman OpenAPI ──► Scalar
211
213
  ```
212
214
 
213
215
  The internal dataset is framework-independent and output-independent.
@@ -277,6 +279,7 @@ exporters:
277
279
  - curl
278
280
  - markdown
279
281
  - postman
282
+ - openapi
280
283
 
281
284
  variables:
282
285
  base_url: http://localhost:3000
@@ -401,8 +404,10 @@ docs/api/
401
404
  │ └── api/v2/customers/
402
405
  │ ├── create.sh
403
406
  │ └── show.sh
404
- └── postman/
405
- └── collection.json
407
+ ├── postman/
408
+ └── collection.json
409
+ └── openapi/
410
+ └── openapi.json
406
411
  ```
407
412
 
408
413
  `dataset.json` contains Reqcord's normalized representation of the captured
@@ -446,6 +451,50 @@ they are counted as *skipped* in the report rather than dropped.
446
451
  Hoppscotch imports Postman v2.1 collections directly: *Import → Postman* and
447
452
  point it at the same file.
448
453
 
454
+ ## OpenAPI
455
+
456
+ `openapi/openapi.json` is an OpenAPI 3.1 document built from the same dataset:
457
+
458
+ * one path item per documented route, in OpenAPI notation — `/customers/:id`
459
+ becomes `/customers/{id}`, and `/items(/:id)` becomes both `/items` and
460
+ `/items/{id}`,
461
+ * path and query parameters from the inferred schemas, closed value sets as
462
+ `enum`,
463
+ * a `requestBody` (`application/json` or `application/x-www-form-urlencoded`,
464
+ whichever the test sent) whose JSON Schema is rebuilt from the field paths,
465
+ nested objects and arrays included, `required` from what every accepted
466
+ request carried,
467
+ * one response per captured status with its schema and example,
468
+ * `bearerAuth` / `apiKeyAuth` security schemes derived from the sanitized
469
+ `Authorization` and `X-Api-Key` headers, applied per operation — so public
470
+ endpoints stay public.
471
+
472
+ Anything that reads OpenAPI (Scalar, Swagger UI, Redoc, code generators) can
473
+ consume the file as is.
474
+
475
+ ## Serve the docs in your app
476
+
477
+ `Reqcord::Web` is a Rack application that serves the generated output from
478
+ inside the Rails app, the way `Sidekiq::Web` does:
479
+
480
+ ```ruby
481
+ # config/routes.rb
482
+ mount Reqcord::Web => "/api-docs" if Rails.env.development?
483
+ ```
484
+
485
+ * `/api-docs` renders `openapi/openapi.json` with
486
+ [Scalar](https://scalar.com) — a searchable reference with a *Try it*
487
+ client, loaded from the Scalar CDN,
488
+ * `/api-docs/openapi/openapi.json`, `/api-docs/dataset.json`,
489
+ `/api-docs/postman/collection.json`, `/api-docs/api/v2/customers/create.md`,
490
+ `/api-docs/curl/api/v2/customers/create.sh` … serve the generated files,
491
+ * nothing outside `output.directory` is ever served.
492
+
493
+ `Reqcord::Web` only reads; run `bin/rails reqcord:generate` first (before the
494
+ first run the page tells you so). Because the files are static, mounting it
495
+ in production is a deployment decision, not a Reqcord one — guard it as you
496
+ would any internal page.
497
+
449
498
  ## Request Capture
450
499
 
451
500
  Reqcord captures HTTP information from Rails integration tests.
@@ -651,10 +700,13 @@ Reqcord
651
700
  │ ├── Payload (JSON vs form, nested query flattening)
652
701
  │ └── Curl
653
702
 
654
- └── Exporters
655
- ├── Markdown
656
- ├── Curl (one .sh per endpoint)
657
- └── Postman (Collection v2.1, also for Hoppscotch)
703
+ ├── Exporters
704
+ ├── Markdown
705
+ ├── Curl (one .sh per endpoint)
706
+ │ ├── Postman (Collection v2.1, also for Hoppscotch)
707
+ │ └── Openapi (OpenAPI 3.1)
708
+
709
+ └── Web (Rack app: Scalar page + generated files)
658
710
  ```
659
711
 
660
712
  Test adapters are responsible only for converting test execution into Reqcord's canonical model.
@@ -667,7 +719,7 @@ Minitest ──┐
667
719
  RSpec ─────┼──► Dataset ──► Markdown
668
720
  │ ├─► cURL
669
721
  Other ─────┘ ├─► Postman (→ Hoppscotch)
670
- └─► OpenAPI (0.2)
722
+ └─► OpenAPI (→ Scalar via Reqcord::Web)
671
723
  ```
672
724
 
673
725
  ## v0.1 Scope
@@ -689,6 +741,7 @@ The first Reqcord release focuses on proving the capture pipeline.
689
741
  * Markdown documentation
690
742
  * Generated cURL requests (in the Markdown and as runnable `.sh` files)
691
743
  * Postman Collection v2.1 (imports into Hoppscotch as well)
744
+ * OpenAPI 3.1 document, browsable with Scalar through `mount Reqcord::Web`
692
745
  * The whole route table: custom actions, `match via:`, `via: :all`,
693
746
  singular resources, optional segments and globs, mounted engines
694
747
  * Resource filtering
@@ -696,8 +749,6 @@ The first Reqcord release focuses on proving the capture pipeline.
696
749
 
697
750
  ### Not included yet
698
751
 
699
- * OpenAPI generation
700
- * Scalar integration
701
752
  * Multipart requests
702
753
  * CI documentation drift detection
703
754
 
@@ -705,24 +756,6 @@ These features belong to later releases rather than expanding the initial scope.
705
756
 
706
757
  ## Roadmap
707
758
 
708
- ### v0.2
709
-
710
- OpenAPI 3.1 export and Scalar integration.
711
-
712
- ```text
713
- Reqcord Dataset
714
-
715
- OpenAPI 3.1
716
-
717
- Scalar
718
- ```
719
-
720
- This will allow a development application to expose documentation such as:
721
-
722
- ```text
723
- http://localhost:3000/api-docs
724
- ```
725
-
726
759
  ### v0.3
727
760
 
728
761
  Rack::Test capture, so frameworks other than Rails (Sinatra, Roda, Hanami) can
@@ -767,7 +800,7 @@ Reqcord is currently in early development.
767
800
 
768
801
  The initial goal is intentionally narrow:
769
802
 
770
- > Capture real Rails API requests and responses from the test suite and generate accurate, sanitized Markdown documentation, executable cURL examples and a Postman collection — without the developer writing any of them by hand.
803
+ > Capture real Rails API requests and responses from the test suite and generate accurate, sanitized Markdown documentation, executable cURL examples, a Postman collection and an OpenAPI document — without the developer writing any of them by hand.
771
804
 
772
805
  Once that pipeline is reliable, additional adapters and exporters can be built on top of the same dataset.
773
806
 
@@ -52,6 +52,7 @@ before any test runs. YAML aliases are disabled.
52
52
  | --- | --- |
53
53
  | `REQCORD_TEST_FRAMEWORK` | `test.framework` |
54
54
  | `REQCORD_TEST_COMMAND` | `test.command` (and therefore `test.paths`) |
55
+ | `REQCORD_STRICT` | `test.strict` (`1` or `true`) |
55
56
  | `REQCORD_OUTPUT` | `output.directory` |
56
57
  | `REQCORD_BASE_URL` | `variables.base_url` |
57
58
  | `RESOURCE`, `VERSION` | run-time filters, see [Filtering a run](#filtering-a-run) |
@@ -123,9 +124,23 @@ absent.
123
124
 
124
125
  With neither `paths` nor `command`, Reqcord runs `bin/rails test`.
125
126
 
126
- Whatever runs must exit successfully. A failing suite aborts the run with
127
- `Reqcord::GenerationError` and nothing is written — documentation is only
128
- generated from a green suite.
127
+ ### `test.strict`
128
+
129
+ Default `false`. What happens when the suite does not exit successfully:
130
+
131
+ * `false` — the run continues and documents what the tests captured. A
132
+ warning names the exit status, and the report reminds you that routes
133
+ exercised only by failing tests show up as *uncovered*. A test that failed
134
+ on an assertion after its request got a `2xx` still counts: the request
135
+ and response are what the application really did.
136
+ * `true` (or `REQCORD_STRICT=1`) — a failing suite aborts the run with
137
+ `Reqcord::GenerationError` and nothing is written. Use it in CI when the
138
+ generated docs are an artifact that must come from a green build.
139
+
140
+ ```yaml
141
+ test:
142
+ strict: true
143
+ ```
129
144
 
130
145
  ---
131
146
 
@@ -214,17 +229,41 @@ pages as well, each carrying a note that nothing was captured.
214
229
 
215
230
  ## `exporters`
216
231
 
217
- Which outputs to write. Default: all three.
232
+ Which outputs to write. Default: all four.
218
233
 
219
234
  | Name | Writes |
220
235
  | --- | --- |
221
236
  | `markdown` | `README.md` plus one page per endpoint: headers, typed parameter tables, example request, cURL, one example and field table per response status |
222
237
  | `curl` | one runnable `.sh` per endpoint under `curl/`, built from the successful captured request |
223
238
  | `postman` | `postman/collection.json`, a Postman Collection v2.1 — folders per controller namespace, one request per endpoint, every captured status saved as an example, placeholders as collection variables, bearer auth at collection level. Hoppscotch imports the same file |
239
+ | `openapi` | `openapi/openapi.json`, an OpenAPI 3.1 document — one path item per documented route (`/items(/:id)` becomes `/items` and `/items/{id}`), parameters and request body schemas rebuilt from the inferred fields, one response per captured status with schema and example, `bearerAuth` / `apiKeyAuth` security schemes from the sanitized headers. `servers` comes from `variables.base_url` |
224
240
 
225
241
  `dataset.json` is always written. An unknown name raises
226
242
  `Reqcord::ConfigurationError` before any test runs.
227
243
 
244
+ ### Browsing the output: `Reqcord::Web`
245
+
246
+ The generated directory can be served from the application itself:
247
+
248
+ ```ruby
249
+ # config/routes.rb
250
+ mount Reqcord::Web => "/api-docs" if Rails.env.development?
251
+ ```
252
+
253
+ `/api-docs` renders the OpenAPI document with Scalar (loaded from
254
+ `cdn.jsdelivr.net`); every other path under the mount is a file from
255
+ `output.directory` — `openapi/openapi.json`, `dataset.json`,
256
+ `postman/collection.json`, the Markdown pages, the cURL scripts. Requests
257
+ that resolve outside that directory get a `404`. Nothing is generated on the
258
+ fly: run `bin/rails reqcord:generate` first, and again after the tests change.
259
+
260
+ `Reqcord::Web` reads `output.directory` from `reqcord.yml` (and
261
+ `REQCORD_OUTPUT`). To serve a different directory, mount an instance instead:
262
+
263
+ ```ruby
264
+ mount Reqcord::Web.new(root: Rails.root.join("public/api-docs")) => "/api-docs"
265
+ ```
266
+
228
267
  ---
229
268
 
230
269
  ## `variables`
data/examples/reqcord.yml CHANGED
@@ -19,6 +19,11 @@ test:
19
19
  # Or spell the command out yourself; it wins over `paths`. Globs expand.
20
20
  # command: bin/rails test test/integration test/api
21
21
 
22
+ # A failing suite still documents what it captured; routes reached only by
23
+ # failing tests are listed as uncovered. `true` aborts instead
24
+ # (REQCORD_STRICT=1 per run).
25
+ # strict: false
26
+
22
27
  routes:
23
28
  # Only routes under this prefix are documented; a list works too:
24
29
  # prefix: [/api, /partner]
@@ -35,11 +40,13 @@ output:
35
40
  include_uncovered: false
36
41
 
37
42
  # markdown: pages under docs/api, curl: one runnable .sh per endpoint,
38
- # postman: postman/collection.json (import into Postman or Hoppscotch).
43
+ # postman: postman/collection.json (import into Postman or Hoppscotch),
44
+ # openapi: openapi/openapi.json — mount Reqcord::Web to browse it with Scalar.
39
45
  exporters:
40
46
  - curl
41
47
  - markdown
42
48
  - postman
49
+ - openapi
43
50
 
44
51
  variables:
45
52
  # Used as the host in every generated cURL and as the Postman `base_url`
@@ -8,7 +8,11 @@ module Reqcord
8
8
  # No default command: with neither `command` nor `paths` the generator
9
9
  # falls back to `bin/rails test`, and `paths` alone must be able to win.
10
10
  "test" => {
11
- "framework" => "minitest"
11
+ "framework" => "minitest",
12
+
13
+ # A red suite still documents what its green tests captured; strict
14
+ # runs abort instead, for CI that treats the docs as an artifact.
15
+ "strict" => false
12
16
  },
13
17
 
14
18
  "routes" => {
@@ -23,7 +27,7 @@ module Reqcord
23
27
  "include_uncovered" => false
24
28
  },
25
29
 
26
- "exporters" => %w[curl markdown postman],
30
+ "exporters" => %w[curl markdown postman openapi],
27
31
 
28
32
  "variables" => {
29
33
  "base_url" => "http://localhost:3000"
@@ -123,6 +127,13 @@ module Reqcord
123
127
  Array(data.dig("test", "paths")).map(&:to_s)
124
128
  end
125
129
 
130
+ # Abort on a failing suite instead of documenting what was captured.
131
+ def strict_tests?
132
+ value = ENV.fetch("REQCORD_STRICT") { data.dig("test", "strict") }
133
+
134
+ [true, "true", "1"].include?(value)
135
+ end
136
+
126
137
  # `prefix: /api` or `prefix: [/v1, /v2]`; a route under any of them is
127
138
  # documented. Empty means every route.
128
139
  def route_prefixes
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Exporters
5
+ # Writes an OpenAPI 3.1 document (`openapi/openapi.json`) from the dataset:
6
+ # one path item per documented route, parameters and request bodies from
7
+ # the inferred schemas, one response per captured status with its example,
8
+ # and security schemes derived from the sanitized credential headers. This
9
+ # is what Scalar (via Reqcord::Web), Swagger UI or Redoc render.
10
+ class Openapi
11
+ OPENAPI_VERSION = "3.1.0"
12
+ DYNAMIC_SEGMENT = /[:*]([a-zA-Z_][a-zA-Z0-9_]*)/
13
+ BEARER = /\ABearer\s+/i
14
+
15
+ def self.call(dataset:, output_dir:, configuration:)
16
+ new(
17
+ dataset: dataset,
18
+ output_dir: output_dir,
19
+ configuration: configuration
20
+ ).call
21
+ end
22
+
23
+ def initialize(dataset:, output_dir:, configuration:)
24
+ @dataset = dataset
25
+ @output_dir = Pathname(output_dir).join("openapi")
26
+ @configuration = configuration
27
+ end
28
+
29
+ def call
30
+ FileUtils.mkdir_p(output_dir)
31
+
32
+ path = output_dir.join("openapi.json")
33
+ File.write(path, "#{JSON.pretty_generate(document)}\n")
34
+
35
+ [path.to_s]
36
+ end
37
+
38
+ def document
39
+ {
40
+ openapi: OPENAPI_VERSION,
41
+ info: {
42
+ title: "#{File.basename(configuration.root.to_s)} API",
43
+ version: api_version,
44
+ description: "Generated by Reqcord from the application's integration tests."
45
+ },
46
+ servers: [{ url: configuration.base_url }],
47
+ tags: tags,
48
+ paths: paths,
49
+ components: components
50
+ }.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :dataset, :output_dir, :configuration
56
+
57
+ # A single API version across the dataset names the document; mixed
58
+ # versions fall back to a neutral one.
59
+ def api_version
60
+ versions = dataset.curl_ready_endpoints.map(&:api_version).compact.uniq
61
+
62
+ versions.size == 1 ? versions.first : "1.0.0"
63
+ end
64
+
65
+ def tags
66
+ dataset.resources
67
+ .select { |resource| resource.endpoints.any?(&:curl_ready?) }
68
+ .map { |resource| { name: resource.title, description: resource.name }.compact }
69
+ end
70
+
71
+ def paths
72
+ dataset.resources.each_with_object({}) do |resource, result|
73
+ resource.endpoints.each do |endpoint|
74
+ next unless endpoint.curl_ready?
75
+ next if endpoint.method == RouteCollector::ANY
76
+
77
+ path_variants(endpoint.path).each do |oas_path|
78
+ result[oas_path] ||= {}
79
+ result[oas_path][endpoint.method.downcase] = operation(endpoint, oas_path, resource)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ # `/items(/:id)` is two OpenAPI paths: `/items` and `/items/{id}`.
86
+ def path_variants(pattern)
87
+ required = pattern.gsub(/\([^()]*\)/, "")
88
+ expanded = pattern.tr("()", "")
89
+
90
+ [required, expanded].uniq.map { |path| path.gsub(DYNAMIC_SEGMENT, '{\1}') }
91
+ end
92
+
93
+ def operation(endpoint, oas_path, resource)
94
+ example = endpoint.primary_request_example
95
+
96
+ {
97
+ operationId: operation_id(endpoint, oas_path),
98
+ summary: endpoint.name,
99
+ description: description(endpoint),
100
+ tags: [resource.title],
101
+ parameters: path_parameters(endpoint, oas_path) + query_parameters(endpoint),
102
+ requestBody: request_body(endpoint, example),
103
+ responses: responses(endpoint),
104
+ security: security_for(example)
105
+ }.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
106
+ end
107
+
108
+ def operation_id(endpoint, oas_path)
109
+ Support.parameterize("#{endpoint.method} #{oas_path.tr('{}', '')}").tr("-", "_")
110
+ end
111
+
112
+ def description(endpoint)
113
+ lines = ["#{endpoint.controller}##{endpoint.action}"]
114
+ lines << "Also answers #{endpoint.also_methods.join(', ')}." unless endpoint.also_methods.empty?
115
+ lines << "Route: #{endpoint.route_name}" if endpoint.route_name
116
+
117
+ lines.join("\n")
118
+ end
119
+
120
+ def path_parameters(endpoint, oas_path)
121
+ oas_path.scan(/\{([^}]+)\}/).flatten.map do |name|
122
+ field = endpoint.path_param_schema[name]
123
+
124
+ {
125
+ name: name,
126
+ in: "path",
127
+ required: true,
128
+ schema: field ? scalar_schema(field) : { type: "string" },
129
+ example: field&.example
130
+ }.compact
131
+ end
132
+ end
133
+
134
+ # `filter.category` was captured as Rails' `filter[category]`.
135
+ def query_parameters(endpoint)
136
+ endpoint.query_schema.map do |field|
137
+ {
138
+ name: query_name(field.path),
139
+ in: "query",
140
+ required: field.required?,
141
+ schema: scalar_schema(field),
142
+ example: field.example
143
+ }.compact
144
+ end
145
+ end
146
+
147
+ def query_name(path)
148
+ head, *rest = path.split(".")
149
+
150
+ head + rest.map { |segment| "[#{segment}]" }.join
151
+ end
152
+
153
+ def request_body(endpoint, example)
154
+ return nil if endpoint.body_schema.empty?
155
+
156
+ content_type = Renderers::Payload.json?(example) ? "application/json" : "application/x-www-form-urlencoded"
157
+
158
+ {
159
+ required: true,
160
+ content: {
161
+ content_type => { schema: json_schema(endpoint.body_schema), example: example.body }.compact
162
+ }
163
+ }
164
+ end
165
+
166
+ def responses(endpoint)
167
+ endpoint.responses.each_with_object({}) do |response, result|
168
+ example = response.example
169
+ entry = { description: example.status_text }
170
+
171
+ if example.body?
172
+ content_type = example.content_type.to_s.empty? ? "application/json" : example.content_type
173
+ entry[:content] = { content_type => { schema: json_schema(response.schema), example: example.body }.compact }
174
+ end
175
+
176
+ result[response.status.to_s] = entry
177
+ end
178
+ end
179
+
180
+ # --- schemas ----------------------------------------------------------
181
+
182
+ # Turns the flattened field list (`customer.items[].sku`) back into a
183
+ # JSON Schema tree. A leaf every accepted request carried is required,
184
+ # and so is every object on the way down to it.
185
+ def json_schema(schema)
186
+ return nil if schema.empty?
187
+
188
+ root = {}
189
+
190
+ schema.each do |field|
191
+ node = root
192
+
193
+ segments_for(field.path).each do |segment|
194
+ if segment == :array
195
+ node[:type] = "array"
196
+ node[:items] ||= {}
197
+ node = node[:items]
198
+ else
199
+ node[:type] = "object"
200
+ node[:properties] ||= {}
201
+ node[:properties][segment] ||= {}
202
+ node[:required] = (node[:required] || []) | [segment] if field.required?
203
+ node = node[:properties][segment]
204
+ end
205
+ end
206
+
207
+ node.merge!(scalar_schema(field)) if node.empty?
208
+ end
209
+
210
+ root
211
+ end
212
+
213
+ # "customer.items[].sku" -> ["customer", "items", :array, "sku"];
214
+ # "[].id" -> [:array, "id"]
215
+ def segments_for(path)
216
+ path.split(".").flat_map do |part|
217
+ name, brackets = part.match(/\A([^\[\]]*)((?:\[\])*)\z/).captures
218
+ pieces = []
219
+ pieces << name unless name.empty?
220
+ pieces.concat([:array] * (brackets.length / 2))
221
+ pieces
222
+ end
223
+ end
224
+
225
+ def scalar_schema(field)
226
+ types = field.types.to_a.sort
227
+ schema = { type: types.size == 1 ? types.first : types }
228
+ schema[:enum] = field.listed_values if field.enum?
229
+ schema[:example] = field.example unless field.example.nil?
230
+
231
+ schema
232
+ end
233
+
234
+ # --- security ---------------------------------------------------------
235
+
236
+ def components
237
+ schemes = {}
238
+ schemes[:bearerAuth] = { type: "http", scheme: "bearer" } if bearer_used?
239
+ schemes[:apiKeyAuth] = { type: "apiKey", in: "header", name: api_key_header } if api_key_header
240
+
241
+ schemes.empty? ? nil : { securitySchemes: schemes }
242
+ end
243
+
244
+ def security_for(example)
245
+ requirements = []
246
+ requirements << { bearerAuth: [] } if bearer?(example)
247
+ requirements << { apiKeyAuth: [] } if api_key_header && header(example, api_key_header)
248
+
249
+ requirements
250
+ end
251
+
252
+ def bearer_used?
253
+ documented_examples.any? { |example| bearer?(example) }
254
+ end
255
+
256
+ def bearer?(example)
257
+ header(example, "Authorization").to_s.match?(BEARER)
258
+ end
259
+
260
+ # The first API-key style header the sanitizer knows about that a
261
+ # documented request actually carried.
262
+ def api_key_header
263
+ return @api_key_header if defined?(@api_key_header)
264
+
265
+ candidates = configuration.sanitized_headers.keys.reject { |name| name.casecmp?("Authorization") }
266
+
267
+ @api_key_header = candidates.find do |name|
268
+ documented_examples.any? { |example| header(example, name) }
269
+ end
270
+ end
271
+
272
+ def header(example, name)
273
+ example.headers.find { |key, _| key.to_s.casecmp?(name) }&.last
274
+ end
275
+
276
+ def documented_examples
277
+ @documented_examples ||= dataset.curl_ready_endpoints.map(&:primary_request_example)
278
+ end
279
+ end
280
+
281
+ register("openapi", Openapi)
282
+ end
283
+ end
@@ -56,6 +56,14 @@ module Reqcord
56
56
  # run can say why a request did not turn into documentation.
57
57
  attr_reader :unmatched_paths
58
58
 
59
+ # Exit status of the test run, so a caller can tell a fully green run
60
+ # from documentation generated out of a partly failing suite.
61
+ attr_reader :test_status
62
+
63
+ def tests_passed?
64
+ test_status.nil? || test_status.success?
65
+ end
66
+
59
67
  # Routes seen in the table but not documentable, by reason (redirect,
60
68
  # mount). Set by collect_routes; exposed so a run can be reconciled.
61
69
  attr_accessor :skipped_routes
@@ -161,6 +169,10 @@ module Reqcord
161
169
  Reqcord.log("skipped #{skipped_total} route(s) that cannot be documented: #{reasons}")
162
170
  end
163
171
 
172
+ unless tests_passed?
173
+ Reqcord.warn("the test run failed: routes exercised only by failing tests are listed as uncovered")
174
+ end
175
+
164
176
  return if unmatched_paths.empty?
165
177
 
166
178
  shown = unmatched_paths.uniq.first(5)
@@ -254,10 +266,19 @@ module Reqcord
254
266
  wait_thread.value
255
267
  end
256
268
 
269
+ @test_status = status
270
+
257
271
  return if status.success?
258
272
 
259
- raise GenerationError,
260
- "Test suite failed while generating Reqcord documentation"
273
+ if configuration.strict_tests?
274
+ raise GenerationError,
275
+ "Test suite failed while generating Reqcord documentation (test.strict is on)"
276
+ end
277
+
278
+ # A red suite still tells the truth about the requests that passed;
279
+ # dropping everything would hide the docs behind an unrelated failure.
280
+ Reqcord.warn("test run exited with status #{status.exitstatus}; documenting what it captured anyway")
281
+ Reqcord.warn(" set test.strict: true (or REQCORD_STRICT=1) to abort on a failing suite")
261
282
  end
262
283
 
263
284
  def read_exchanges(capture_file)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reqcord
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/mime"
4
+
5
+ module Reqcord
6
+ # Serves the generated documentation from inside the application, the way
7
+ # Sidekiq::Web does:
8
+ #
9
+ # # config/routes.rb
10
+ # mount Reqcord::Web => "/api-docs" if Rails.env.development?
11
+ #
12
+ # `/api-docs` renders the OpenAPI document with Scalar; every other path is
13
+ # a file from the output directory (`dataset.json`, `postman/collection.json`,
14
+ # the Markdown pages, the cURL scripts). Nothing is generated on request —
15
+ # run `bin/rails reqcord:generate` first.
16
+ class Web
17
+ SCALAR_SCRIPT = "https://cdn.jsdelivr.net/npm/@scalar/api-reference"
18
+ OPENAPI_PATH = "openapi/openapi.json"
19
+
20
+ class << self
21
+ # `mount Reqcord::Web => "/api-docs"` calls the class itself.
22
+ def call(env)
23
+ app.call(env)
24
+ end
25
+
26
+ def app
27
+ @app ||= new
28
+ end
29
+ end
30
+
31
+ def initialize(root: nil)
32
+ @root = root
33
+ end
34
+
35
+ def call(env)
36
+ path = Rack::Utils.unescape_path(env["PATH_INFO"].to_s)
37
+
38
+ return index(env) if path.empty? || path == "/"
39
+
40
+ file = resolve(path)
41
+
42
+ return not_found unless file
43
+
44
+ [200, { "content-type" => content_type(file) }, [File.binread(file)]]
45
+ end
46
+
47
+ private
48
+
49
+ def root
50
+ Pathname(@root || Reqcord.configuration.output_directory).expand_path
51
+ end
52
+
53
+ def index(env)
54
+ html = File.exist?(root.join(OPENAPI_PATH)) ? scalar_page(env) : empty_page(env)
55
+
56
+ [200, { "content-type" => "text/html; charset=utf-8" }, [html]]
57
+ end
58
+
59
+ # The path traversal guard: whatever the request says, the file must be
60
+ # inside the output directory.
61
+ def resolve(path)
62
+ candidate = root.join(path.delete_prefix("/")).expand_path
63
+
64
+ return nil unless candidate.to_s.start_with?("#{root}/")
65
+ return nil unless candidate.file?
66
+
67
+ candidate.to_s
68
+ end
69
+
70
+ def content_type(file)
71
+ case File.extname(file)
72
+ when ".md" then "text/markdown; charset=utf-8"
73
+ when ".sh" then "text/plain; charset=utf-8"
74
+ else Rack::Mime.mime_type(File.extname(file), "application/octet-stream")
75
+ end
76
+ end
77
+
78
+ def not_found
79
+ [404, { "content-type" => "text/plain" }, ["Not Found"]]
80
+ end
81
+
82
+ # Absolute links: the page is served at the mount point itself, so a
83
+ # relative `openapi/…` would resolve one level too high.
84
+ def base(env)
85
+ env["SCRIPT_NAME"].to_s.chomp("/")
86
+ end
87
+
88
+ def scalar_page(env)
89
+ <<~HTML
90
+ <!doctype html>
91
+ <html>
92
+ <head>
93
+ <meta charset="utf-8">
94
+ <meta name="viewport" content="width=device-width, initial-scale=1">
95
+ <title>API Reference</title>
96
+ </head>
97
+ <body>
98
+ <script id="api-reference" data-url="#{base(env)}/#{OPENAPI_PATH}"></script>
99
+ <script src="#{SCALAR_SCRIPT}"></script>
100
+ </body>
101
+ </html>
102
+ HTML
103
+ end
104
+
105
+ def empty_page(env)
106
+ <<~HTML
107
+ <!doctype html>
108
+ <html>
109
+ <head><meta charset="utf-8"><title>Reqcord</title></head>
110
+ <body style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; line-height: 1.5">
111
+ <h1>No documentation generated yet</h1>
112
+ <p>Reqcord serves the files under <code>#{root}</code>. Generate them from your test suite:</p>
113
+ <pre>bin/rails reqcord:generate</pre>
114
+ <p>Then reload this page: the OpenAPI document is rendered here with Scalar, and
115
+ <a href="#{base(env)}/dataset.json">dataset.json</a>,
116
+ <a href="#{base(env)}/postman/collection.json">postman/collection.json</a> and the Markdown pages are served alongside it.</p>
117
+ </body>
118
+ </html>
119
+ HTML
120
+ end
121
+ end
122
+ end
data/lib/reqcord.rb CHANGED
@@ -32,7 +32,9 @@ require_relative "reqcord/exporters"
32
32
  require_relative "reqcord/exporters/curl"
33
33
  require_relative "reqcord/exporters/markdown"
34
34
  require_relative "reqcord/exporters/postman"
35
+ require_relative "reqcord/exporters/openapi"
35
36
  require_relative "reqcord/generator"
37
+ require_relative "reqcord/web"
36
38
 
37
39
  require_relative "reqcord/railtie" if defined?(Rails::Railtie)
38
40
 
@@ -27,6 +27,10 @@ namespace :reqcord do
27
27
  # Or spell the command out yourself; it wins over `paths`.
28
28
  # command: bin/rails test test/integration test/api
29
29
 
30
+ # A failing suite still documents what it captured. Set true to abort
31
+ # instead (or run with REQCORD_STRICT=1).
32
+ # strict: false
33
+
30
34
  routes:
31
35
  # Only routes under this prefix are documented; a list works too
32
36
  # (`prefix: [/v1, /v2]`). Filter a single run with
@@ -41,11 +45,13 @@ namespace :reqcord do
41
45
  include_uncovered: false
42
46
 
43
47
  # markdown: pages under docs/api, curl: one runnable .sh per endpoint,
44
- # postman: postman/collection.json (import into Postman or Hoppscotch).
48
+ # postman: postman/collection.json (import into Postman or Hoppscotch),
49
+ # openapi: openapi/openapi.json (rendered with Scalar by Reqcord::Web).
45
50
  exporters:
46
51
  - curl
47
52
  - markdown
48
53
  - postman
54
+ - openapi
49
55
 
50
56
  variables:
51
57
  # Host of every generated cURL and the Postman `base_url` variable.
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.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ahmet Saridogan
@@ -165,6 +165,7 @@ files:
165
165
  - lib/reqcord/exporters.rb
166
166
  - lib/reqcord/exporters/curl.rb
167
167
  - lib/reqcord/exporters/markdown.rb
168
+ - lib/reqcord/exporters/openapi.rb
168
169
  - lib/reqcord/exporters/postman.rb
169
170
  - lib/reqcord/generator.rb
170
171
  - lib/reqcord/railtie.rb
@@ -177,6 +178,7 @@ files:
177
178
  - lib/reqcord/schema.rb
178
179
  - lib/reqcord/support.rb
179
180
  - lib/reqcord/version.rb
181
+ - lib/reqcord/web.rb
180
182
  - lib/tasks/reqcord.rake
181
183
  - reqcord.gemspec
182
184
  homepage: https://github.com/ahmetsaridogan/reqcord