reqcord 0.1.4 → 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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +67 -34
- data/docs/configuration.md +25 -1
- data/examples/reqcord.yml +3 -1
- data/lib/reqcord/configuration.rb +1 -1
- data/lib/reqcord/exporters/openapi.rb +283 -0
- data/lib/reqcord/version.rb +1 -1
- data/lib/reqcord/web.rb +122 -0
- data/lib/reqcord.rb +2 -0
- data/lib/tasks/reqcord.rake +3 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e075e88e6b448b287ccc4b845cf82b0e18d4d7b9778a8e82d1c09f798d2b1f8
|
|
4
|
+
data.tar.gz: 6a395b1ab6f789b184a72c63b14f9873f2465e891f96ade6c91b19cc102211a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f5c43bee1cdcbe7d6d6b3de653027a5704995bd9ddd602e879c6905e0a1fdb27ca1936d55ad11be42b1d1969c925ceaf519e5c193f7d86c787b0d8f6f02eba28
|
|
7
|
+
data.tar.gz: f80362f26eddd01297d81f62080e0b93ae50742d49c1616ec92b940550a5a7b3e63e33d69b427cd9e8c7a3091c78c3cffaaae58fc15db13ec0cdc8a18ce01299
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
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
|
+
|
|
3
17
|
## [0.1.4] - 2026-09-16
|
|
4
18
|
|
|
5
19
|
### Changed
|
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
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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 (
|
|
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
|
|
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
|
|
data/docs/configuration.md
CHANGED
|
@@ -229,17 +229,41 @@ pages as well, each carrying a note that nothing was captured.
|
|
|
229
229
|
|
|
230
230
|
## `exporters`
|
|
231
231
|
|
|
232
|
-
Which outputs to write. Default: all
|
|
232
|
+
Which outputs to write. Default: all four.
|
|
233
233
|
|
|
234
234
|
| Name | Writes |
|
|
235
235
|
| --- | --- |
|
|
236
236
|
| `markdown` | `README.md` plus one page per endpoint: headers, typed parameter tables, example request, cURL, one example and field table per response status |
|
|
237
237
|
| `curl` | one runnable `.sh` per endpoint under `curl/`, built from the successful captured request |
|
|
238
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` |
|
|
239
240
|
|
|
240
241
|
`dataset.json` is always written. An unknown name raises
|
|
241
242
|
`Reqcord::ConfigurationError` before any test runs.
|
|
242
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
|
+
|
|
243
267
|
---
|
|
244
268
|
|
|
245
269
|
## `variables`
|
data/examples/reqcord.yml
CHANGED
|
@@ -40,11 +40,13 @@ output:
|
|
|
40
40
|
include_uncovered: false
|
|
41
41
|
|
|
42
42
|
# markdown: pages under docs/api, curl: one runnable .sh per endpoint,
|
|
43
|
-
# 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.
|
|
44
45
|
exporters:
|
|
45
46
|
- curl
|
|
46
47
|
- markdown
|
|
47
48
|
- postman
|
|
49
|
+
- openapi
|
|
48
50
|
|
|
49
51
|
variables:
|
|
50
52
|
# Used as the host in every generated cURL and as the Postman `base_url`
|
|
@@ -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
|
data/lib/reqcord/version.rb
CHANGED
data/lib/reqcord/web.rb
ADDED
|
@@ -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
|
|
data/lib/tasks/reqcord.rake
CHANGED
|
@@ -45,11 +45,13 @@ namespace :reqcord do
|
|
|
45
45
|
include_uncovered: false
|
|
46
46
|
|
|
47
47
|
# markdown: pages under docs/api, curl: one runnable .sh per endpoint,
|
|
48
|
-
# 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).
|
|
49
50
|
exporters:
|
|
50
51
|
- curl
|
|
51
52
|
- markdown
|
|
52
53
|
- postman
|
|
54
|
+
- openapi
|
|
53
55
|
|
|
54
56
|
variables:
|
|
55
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.
|
|
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
|