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 +4 -4
- data/CHANGELOG.md +29 -1
- data/README.md +67 -34
- data/docs/configuration.md +43 -4
- data/examples/reqcord.yml +8 -1
- data/lib/reqcord/configuration.rb +13 -2
- data/lib/reqcord/exporters/openapi.rb +283 -0
- data/lib/reqcord/generator.rb +23 -2
- 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 +7 -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,6 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
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
|
|
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
|
@@ -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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
data/lib/reqcord/generator.rb
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
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)
|
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
|
@@ -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.
|
|
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
|