reqcord 0.2.0 → 0.3.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 +115 -746
- data/docs/architecture.md +134 -0
- data/docs/capture.md +169 -0
- data/docs/configuration.md +7 -2
- data/docs/exporters.md +182 -0
- data/docs/getting-started.md +186 -0
- data/docs/route-coverage.md +91 -0
- data/docs/troubleshooting.md +113 -0
- data/docs/web.md +66 -0
- data/lib/reqcord/capture/integration_patch.rb +9 -2
- data/lib/reqcord/check.rb +73 -0
- data/lib/reqcord/configuration.rb +9 -0
- data/lib/reqcord/dataset.rb +0 -1
- data/lib/reqcord/exporters/markdown.rb +5 -2
- data/lib/reqcord/exporters/openapi.rb +14 -2
- data/lib/reqcord/exporters/postman.rb +11 -1
- data/lib/reqcord/file_value.rb +62 -0
- data/lib/reqcord/generator.rb +17 -3
- data/lib/reqcord/renderers/curl.rb +22 -1
- data/lib/reqcord/renderers/payload.rb +25 -7
- data/lib/reqcord/route_collector.rb +10 -1
- data/lib/reqcord/schema.rb +11 -1
- data/lib/reqcord/version.rb +1 -1
- data/lib/reqcord.rb +2 -0
- data/lib/tasks/reqcord.rake +23 -0
- data/reqcord.gemspec +2 -1
- metadata +12 -2
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
Reqcord documents an API from the integration tests that already exercise it.
|
|
4
|
+
Nothing in the tests changes; Reqcord runs them, watches the requests they
|
|
5
|
+
make, and writes the documentation from what it saw.
|
|
6
|
+
|
|
7
|
+
## 1. Install
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
group :development, :test do
|
|
12
|
+
gem "reqcord"
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bundle install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Both groups matter: `reqcord:generate` runs in development, the capture runs
|
|
21
|
+
inside the test process.
|
|
22
|
+
|
|
23
|
+
## 2. Create the configuration
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bin/rails reqcord:init
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This writes an annotated `reqcord.yml` (never overwriting one that exists)
|
|
30
|
+
and creates `docs/api/`. The two keys worth checking before the first run:
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
test:
|
|
34
|
+
framework: minitest # or: rspec
|
|
35
|
+
paths:
|
|
36
|
+
- test/integration # where the tests that call the API live
|
|
37
|
+
|
|
38
|
+
routes:
|
|
39
|
+
prefix: /api # only routes under this prefix are documented
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Every key is described in [configuration.md](configuration.md).
|
|
43
|
+
|
|
44
|
+
## 3. Generate
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bin/rails reqcord:generate
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Reqcord collects the route table, runs the tests under `test.paths` in a
|
|
51
|
+
subprocess with capture enabled, sanitizes what was captured and writes every
|
|
52
|
+
exporter. The run ends with a reconciliation of the whole route table:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
[reqcord] captured 87 request(s), 85 matched a documented route
|
|
56
|
+
[reqcord] captured a successful 2xx request for 15 of 16 endpoint(s)
|
|
57
|
+
[reqcord] routes: 18 = 15 documented + 1 uncovered + 2 skipped
|
|
58
|
+
[reqcord] skipped 2 route(s) that cannot be documented: 1 redirect, 1 mount
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Every route is in exactly one bucket:
|
|
62
|
+
|
|
63
|
+
| Bucket | Meaning |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| documented | a test got a `2xx` from it — it has a page, a cURL script, a Postman request and an OpenAPI operation |
|
|
66
|
+
| uncovered | the route exists, but no test reached it with a `2xx`; listed in the index and in `dataset.json` |
|
|
67
|
+
| skipped | `redirect(...)` routes and plain Rack mounts, which no test can document |
|
|
68
|
+
|
|
69
|
+
A red suite still produces documentation from what its green tests captured
|
|
70
|
+
(see [`test.strict`](configuration.md#teststrict) to abort instead).
|
|
71
|
+
|
|
72
|
+
## 4. Read the output
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
docs/api/
|
|
76
|
+
├── README.md index: placeholders, resources, endpoints, gaps
|
|
77
|
+
├── dataset.json the canonical dataset every exporter reads
|
|
78
|
+
├── api/v2/customers/
|
|
79
|
+
│ ├── index.md one page per controller path
|
|
80
|
+
│ └── create.md one page per endpoint
|
|
81
|
+
├── curl/api/v2/customers/
|
|
82
|
+
│ └── create.sh runnable, sanitized
|
|
83
|
+
├── postman/collection.json Postman v2.1, also for Hoppscotch
|
|
84
|
+
└── openapi/openapi.json OpenAPI 3.1
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
An endpoint page looks like this:
|
|
88
|
+
|
|
89
|
+
````markdown
|
|
90
|
+
# Create Customer
|
|
91
|
+
|
|
92
|
+
`POST /api/v2/customers`
|
|
93
|
+
|
|
94
|
+
## Headers
|
|
95
|
+
|
|
96
|
+
| Header | Value |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| Authorization | `Bearer {{token}}` |
|
|
99
|
+
| Content-Type | `application/json` |
|
|
100
|
+
|
|
101
|
+
## Body Parameters
|
|
102
|
+
|
|
103
|
+
| Field | Type | Required | Values |
|
|
104
|
+
| --- | --- | --- | --- |
|
|
105
|
+
| `customer.name` | string | yes | `"John Doe"` |
|
|
106
|
+
| `customer.email` | string | yes | `"john@example.com"` |
|
|
107
|
+
| `customer.status` | string | yes | `"active"` \| `"passive"` |
|
|
108
|
+
|
|
109
|
+
## cURL
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
curl --request POST \
|
|
113
|
+
--url "http://localhost:3000/api/v2/customers" \
|
|
114
|
+
--header "Authorization: Bearer {{token}}" \
|
|
115
|
+
--header "Content-Type: application/json" \
|
|
116
|
+
--data '{"customer":{"name":"John Doe","email":"john@example.com","status":"active"}}'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Responses
|
|
120
|
+
|
|
121
|
+
### 201 Created
|
|
122
|
+
### 401 Unauthorized
|
|
123
|
+
### 422 Unprocessable Content
|
|
124
|
+
````
|
|
125
|
+
|
|
126
|
+
The parameter table comes from the requests the application **accepted**: two
|
|
127
|
+
tests sent `"active"` and `"passive"`, a third sent `"inactive"` and got a
|
|
128
|
+
`422`, so the page lists the two values that work and keeps the rejection as a
|
|
129
|
+
response example. [capture.md](capture.md) explains the rules.
|
|
130
|
+
|
|
131
|
+
## 5. Browse it in the app (optional)
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# config/routes.rb
|
|
135
|
+
mount Reqcord::Web => "/api-docs" if Rails.env.development?
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`/api-docs` renders the OpenAPI document with Scalar and serves every other
|
|
139
|
+
generated file. See [web.md](web.md).
|
|
140
|
+
|
|
141
|
+
## 6. Keep it current in CI
|
|
142
|
+
|
|
143
|
+
The output is a function of the routes and the tests, so it can be checked
|
|
144
|
+
like generated code:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
bin/rails reqcord:check
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
It regenerates into a scratch directory, compares with `docs/api` file by
|
|
151
|
+
file and exits `1` with a git-status style list when they differ:
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
Reqcord: /app/docs/api is out of date.
|
|
155
|
+
|
|
156
|
+
M api/v2/customers/create.md
|
|
157
|
+
A api/v2/customers/destroy.md
|
|
158
|
+
D curl/api/v2/orders/cancel.sh
|
|
159
|
+
|
|
160
|
+
Run `bin/rails reqcord:generate` and commit the result.
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```yaml
|
|
164
|
+
# .github/workflows/ci.yml
|
|
165
|
+
- run: bin/rails reqcord:check
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
A PR that changes a request or a response without regenerating the docs goes
|
|
169
|
+
red; `bin/rails reqcord:generate` and a commit fix it.
|
|
170
|
+
|
|
171
|
+
## Narrowing a run
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
bin/rails reqcord:generate RESOURCE=customers
|
|
175
|
+
bin/rails reqcord:generate RESOURCE=customers,cart
|
|
176
|
+
bin/rails reqcord:generate VERSION=v2
|
|
177
|
+
bin/rails reqcord:routes # what would be documented, without running anything
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Next
|
|
181
|
+
|
|
182
|
+
* [configuration.md](configuration.md) — every key in `reqcord.yml`
|
|
183
|
+
* [exporters.md](exporters.md) — what each output format contains
|
|
184
|
+
* [route-coverage.md](route-coverage.md) — how the route table becomes endpoints
|
|
185
|
+
* [troubleshooting.md](troubleshooting.md) — when the output looks thin
|
|
186
|
+
* [`examples/`](../examples) — four runnable applications with their generated docs committed
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Route coverage
|
|
2
|
+
|
|
3
|
+
The documented surface is the application's **route table**, not the traffic
|
|
4
|
+
the tests happened to produce. Every route under `routes.prefix` becomes an
|
|
5
|
+
endpoint; captured requests are attached to it. That is what lets the run end
|
|
6
|
+
with an accounting nothing can slip through:
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
routes = documented + uncovered + skipped
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
| Bucket | Rule |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| documented | at least one captured request returned a `2xx` |
|
|
15
|
+
| uncovered | no `2xx` was captured; the route is listed under *No Successful Request Captured* and in `dataset.json` → `uncovered_routes` |
|
|
16
|
+
| skipped | `redirect(...)` routes and plain Rack mounts: nothing a test could document; counted by reason in the report |
|
|
17
|
+
|
|
18
|
+
A captured request that matches no documented route is reported with its
|
|
19
|
+
path (the first five), never dropped silently — usually a sign that
|
|
20
|
+
`routes.prefix` is too narrow.
|
|
21
|
+
|
|
22
|
+
## What becomes an endpoint
|
|
23
|
+
|
|
24
|
+
| Route | Documented as |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `resources :customers` | one endpoint per action; `index` is titled *List Customers*, `show` *Get Customer*, `create`, `update`, `destroy`, `new`, `edit` likewise |
|
|
27
|
+
| `post :activate, on: :member` | *Activate Customer* |
|
|
28
|
+
| `resource :cart` | `GET /cart` (*Get Cart*), `PATCH /cart` (also `PUT`) — one record, no id |
|
|
29
|
+
| `post "checkout", on: :collection` under `resource :cart` | *Checkout Cart* |
|
|
30
|
+
| `post "auth/login", to: "auth#login"` | *Login* — the action is the page, the controller only where it lives |
|
|
31
|
+
| `root to: "home#index"` | `GET /`, titled *Home* |
|
|
32
|
+
| `match "/echo", via: [:get, :post]` | `GET /echo` and `POST /echo`, two endpoints |
|
|
33
|
+
| `match "/anything", via: :all` | one endpoint per verb the tests actually used; one `ANY` entry when none did |
|
|
34
|
+
| `patch` + `put` for `update` | folded into one endpoint, `also_methods: ["PUT"]`; the captured verb wins, `PATCH` preferred |
|
|
35
|
+
| `get "/items(/:id)"` | one endpoint; the optional segment is kept in the path, `id` is a path parameter |
|
|
36
|
+
| `get "/files/*path"` | `path` as a path parameter |
|
|
37
|
+
| `resources :items, param: :sku` | `sku` as the path parameter |
|
|
38
|
+
| `namespace :admin { resources :customers }` | `admin/customers`, a separate resource from `api/v2/customers` |
|
|
39
|
+
| `mount Billing::Engine => "/billing"` | the engine's own routes, walked under `/billing`; the prefix filter sees the full path |
|
|
40
|
+
| `scope module: :v2` / `namespace :v2` | `v2` detected as the API version (`VERSION=v2` filters on it) |
|
|
41
|
+
|
|
42
|
+
Rails' own routes (`rails/…`, Active Storage, Action Mailbox, Turbo — anything
|
|
43
|
+
flagged `internal`) are always left out.
|
|
44
|
+
|
|
45
|
+
## Grouping
|
|
46
|
+
|
|
47
|
+
Endpoints are grouped by the **full controller path**: `api/v2/customers`,
|
|
48
|
+
`admin/customers`, `billing/invoices`. That path becomes the Markdown
|
|
49
|
+
directory, the Postman folder chain (`Api › V2 › Customers`) and the OpenAPI
|
|
50
|
+
tag, so two controllers with the same last segment never collide.
|
|
51
|
+
|
|
52
|
+
## Naming
|
|
53
|
+
|
|
54
|
+
Endpoint names come from the action and the resource, not from the test
|
|
55
|
+
names — tests name the *examples*. The table above shows the patterns; a
|
|
56
|
+
custom action on a plural resource is `<Action> <Resource>` (*Cancel Order*),
|
|
57
|
+
on a singular resource `<Action> <Singular>` (*Checkout Cart*), and a custom
|
|
58
|
+
action whose name is in the path of a singular controller is just the action
|
|
59
|
+
(*Login*).
|
|
60
|
+
|
|
61
|
+
## Filtering a run
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
bin/rails reqcord:generate RESOURCE=customers
|
|
65
|
+
bin/rails reqcord:generate RESOURCE=customers,cart
|
|
66
|
+
bin/rails reqcord:generate VERSION=v2
|
|
67
|
+
bin/rails reqcord:generate RESOURCE=orders VERSION=v1
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`RESOURCE` matches the controller's last segment (`customers`), its singular
|
|
71
|
+
(`cart` for `CartsController`) or the full controller path
|
|
72
|
+
(`api/v2/customers`). `VERSION` matches a `v<number>` segment in the
|
|
73
|
+
controller path or the route path.
|
|
74
|
+
|
|
75
|
+
`bin/rails reqcord:routes` prints what the current prefix and filters would
|
|
76
|
+
document, without running any test.
|
|
77
|
+
|
|
78
|
+
## Prefixes
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
routes:
|
|
82
|
+
prefix: /api # everything under /api, all versions
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
routes:
|
|
87
|
+
prefix: [/v1, /v2, /partner] # APIs whose versions do not share a root
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
An empty prefix documents every route. For a mounted engine the prefix is
|
|
91
|
+
matched against the full path (`/api/billing/invoices`).
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
The generate report is the first place to look; it says how many requests
|
|
4
|
+
were captured, how many matched a route, and where every route ended up.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
[reqcord] captured 87 request(s), 85 matched a documented route
|
|
8
|
+
[reqcord] captured a successful 2xx request for 15 of 16 endpoint(s)
|
|
9
|
+
[reqcord] routes: 18 = 15 documented + 1 uncovered + 2 skipped
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## `no request was captured`
|
|
13
|
+
|
|
14
|
+
Nothing reached the capture file. In order of likelihood:
|
|
15
|
+
|
|
16
|
+
1. **The gem is not loaded in the test process.** It must be in the `:test`
|
|
17
|
+
group of the Gemfile, not only `:development`.
|
|
18
|
+
2. **The run did not include the API tests.** `test.paths` (or
|
|
19
|
+
`test.command`) points somewhere else. Name the directories that hold the
|
|
20
|
+
integration tests / request specs:
|
|
21
|
+
```yaml
|
|
22
|
+
test:
|
|
23
|
+
paths:
|
|
24
|
+
- test/integration
|
|
25
|
+
- test/controllers/api
|
|
26
|
+
```
|
|
27
|
+
3. **The tests are not integration tests.** Reqcord patches
|
|
28
|
+
`ActionDispatch::Integration::Session`; unit tests and controller tests
|
|
29
|
+
that stub the request never go through it.
|
|
30
|
+
4. **The framework is wrong.** `test.framework: rspec` for request specs, or
|
|
31
|
+
the examples are not named and the runner is not `rspec`.
|
|
32
|
+
|
|
33
|
+
## Many requests captured, few matched a route
|
|
34
|
+
|
|
35
|
+
`routes.prefix` does not cover them. The report prints the first unmatched
|
|
36
|
+
paths:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
[reqcord] 12 path(s) matched no documented route, for example:
|
|
40
|
+
[reqcord] /v1/customers
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Widen the prefix or give a list (`prefix: [/api, /v1]`). `bin/rails
|
|
44
|
+
reqcord:routes` shows what the current prefix documents.
|
|
45
|
+
|
|
46
|
+
## Routes documented, few covered
|
|
47
|
+
|
|
48
|
+
An endpoint is *documented* only when a test got a `2xx` from it. Routes that
|
|
49
|
+
tests reach only with `401`/`422` stay uncovered — add a happy-path test, or
|
|
50
|
+
check that the test which does succeed is under `test.paths`.
|
|
51
|
+
|
|
52
|
+
## The suite is red
|
|
53
|
+
|
|
54
|
+
By default Reqcord still writes the documentation and warns:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
[reqcord] test run exited with status 1; documenting what it captured anyway
|
|
58
|
+
[reqcord] the test run failed: routes exercised only by failing tests are listed as uncovered
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A failed assertion after a `2xx` still counts as a documented request. If you
|
|
62
|
+
would rather get nothing from a red build, set `test.strict: true` (or
|
|
63
|
+
`REQCORD_STRICT=1`); the run then aborts with `Reqcord::GenerationError`.
|
|
64
|
+
|
|
65
|
+
## `unknown exporter "…"`
|
|
66
|
+
|
|
67
|
+
The `exporters:` list is used exactly as written (lists are not merged with
|
|
68
|
+
the defaults). Valid names: `curl`, `markdown`, `postman`, `openapi`.
|
|
69
|
+
|
|
70
|
+
## The cURL does not run
|
|
71
|
+
|
|
72
|
+
* `{{token}}`, `{{base_url}}` and other placeholders are meant to be replaced
|
|
73
|
+
before running — they are the documentation's variables.
|
|
74
|
+
* The host is `variables.base_url` (`REQCORD_BASE_URL` per run).
|
|
75
|
+
* The command replays the request a test made; if the test relied on
|
|
76
|
+
fixtures (an `id` that exists only in the test database) the real server
|
|
77
|
+
needs equivalent data.
|
|
78
|
+
|
|
79
|
+
## `reqcord:check` fails but nothing changed
|
|
80
|
+
|
|
81
|
+
The comparison is byte for byte, so the committed docs must come from the
|
|
82
|
+
same code and configuration:
|
|
83
|
+
|
|
84
|
+
* a different Reqcord version, `reqcord.yml`, `REQCORD_BASE_URL` or
|
|
85
|
+
`REQCORD_OUTPUT` between the commit and CI;
|
|
86
|
+
* a `RESOURCE` / `VERSION` filter on one side only;
|
|
87
|
+
* files added by hand under `docs/api` (listed as `D`), or stale files from
|
|
88
|
+
an endpoint that no longer exists — `generate` never deletes.
|
|
89
|
+
|
|
90
|
+
Run `bin/rails reqcord:generate` locally with the same settings and commit
|
|
91
|
+
what changes. Test order does not matter: the output is ordered by test
|
|
92
|
+
file and line, not by execution.
|
|
93
|
+
|
|
94
|
+
## `/api-docs` shows "No documentation generated yet"
|
|
95
|
+
|
|
96
|
+
`Reqcord::Web` only serves files; run `bin/rails reqcord:generate` first. If
|
|
97
|
+
you generated into a different directory (`REQCORD_OUTPUT`), mount
|
|
98
|
+
`Reqcord::Web.new(root: …)` for that directory, see [web.md](web.md).
|
|
99
|
+
|
|
100
|
+
## A value is listed as a closed set, or is not
|
|
101
|
+
|
|
102
|
+
The rules are in [capture.md → Inference](capture.md#inference): 2–6 distinct
|
|
103
|
+
values, not an identifier, and either repeated across requests or every value
|
|
104
|
+
a token. Free-text and generated values (`e-00056197`) are shown as one
|
|
105
|
+
example on purpose.
|
|
106
|
+
|
|
107
|
+
## A route is missing entirely
|
|
108
|
+
|
|
109
|
+
* `redirect(...)` routes and plain Rack mounts are *skipped*, and counted as
|
|
110
|
+
such in the report.
|
|
111
|
+
* Rails' internal routes are always left out.
|
|
112
|
+
* Engine routes are walked under their mount path; the prefix filter sees
|
|
113
|
+
the full path (`/api/billing/invoices`).
|
data/docs/web.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Reqcord::Web
|
|
2
|
+
|
|
3
|
+
`Reqcord::Web` is a Rack application that serves the generated documentation
|
|
4
|
+
from inside the Rails app, the way `Sidekiq::Web` does. It renders the
|
|
5
|
+
OpenAPI document with [Scalar](https://scalar.com) and serves every other
|
|
6
|
+
generated file.
|
|
7
|
+
|
|
8
|
+
## Mount it
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
# config/routes.rb
|
|
12
|
+
mount Reqcord::Web => "/api-docs" if Rails.env.development?
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then `bin/rails reqcord:generate` and open `http://localhost:3000/api-docs`.
|
|
16
|
+
|
|
17
|
+
## What it serves
|
|
18
|
+
|
|
19
|
+
| Path | Content |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `/api-docs` | the Scalar reference for `openapi/openapi.json` — searchable, with a *Try it* client |
|
|
22
|
+
| `/api-docs/openapi/openapi.json` | the OpenAPI document |
|
|
23
|
+
| `/api-docs/dataset.json` | the canonical dataset |
|
|
24
|
+
| `/api-docs/postman/collection.json` | the Postman collection |
|
|
25
|
+
| `/api-docs/README.md`, `/api-docs/api/v2/customers/create.md` … | the Markdown pages (`text/markdown`) |
|
|
26
|
+
| `/api-docs/curl/api/v2/customers/create.sh` | the cURL scripts (`text/plain`) |
|
|
27
|
+
|
|
28
|
+
The mount point can be anything; the page computes its links from it.
|
|
29
|
+
|
|
30
|
+
Before the first generate, `/api-docs` shows a page saying what to run.
|
|
31
|
+
|
|
32
|
+
## Rules
|
|
33
|
+
|
|
34
|
+
* **Read-only.** Nothing is generated on request. Run `reqcord:generate`
|
|
35
|
+
after the tests change; the page picks up the new files on reload.
|
|
36
|
+
* **Only the output directory.** Every request is resolved inside
|
|
37
|
+
`output.directory`; a path that escapes it (`..`, encoded or not) is a
|
|
38
|
+
`404`, as is anything that is not a regular file.
|
|
39
|
+
* **Scalar comes from a CDN.** The page loads
|
|
40
|
+
`https://cdn.jsdelivr.net/npm/@scalar/api-reference`. With a strict
|
|
41
|
+
Content-Security-Policy, allow that host for `script-src`.
|
|
42
|
+
|
|
43
|
+
## Serving a different directory
|
|
44
|
+
|
|
45
|
+
`Reqcord::Web` reads `output.directory` from `reqcord.yml` (and
|
|
46
|
+
`REQCORD_OUTPUT`). To serve another directory, mount an instance:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
mount Reqcord::Web.new(root: Rails.root.join("public/api-docs")) => "/api-docs"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Production
|
|
53
|
+
|
|
54
|
+
The files are static and contain only sanitized data, so mounting in
|
|
55
|
+
production is a deployment decision rather than a Reqcord one. Guard the
|
|
56
|
+
route like any internal page:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
authenticate :admin do
|
|
60
|
+
mount Reqcord::Web => "/api-docs"
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
or leave the mount behind `Rails.env.development?` and publish the generated
|
|
65
|
+
`docs/api` directory (or the OpenAPI file alone) through whatever already
|
|
66
|
+
hosts your documentation.
|
|
@@ -120,12 +120,15 @@ module Reqcord
|
|
|
120
120
|
result
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
+
# The media type without parameters: a multipart boundary is noise that
|
|
124
|
+
# would make every upload request look different.
|
|
123
125
|
def reqcord_content_type(body, request_format)
|
|
124
126
|
return nil unless body
|
|
125
127
|
|
|
126
128
|
return "application/json" if request_format.to_s == "json"
|
|
129
|
+
return nil unless request
|
|
127
130
|
|
|
128
|
-
request
|
|
131
|
+
request.respond_to?(:media_type) ? request.media_type : request.content_type
|
|
129
132
|
end
|
|
130
133
|
|
|
131
134
|
def reqcord_header_name(key)
|
|
@@ -173,7 +176,11 @@ module Reqcord
|
|
|
173
176
|
when Array
|
|
174
177
|
value.map { |item| reqcord_normalize_value(item) }
|
|
175
178
|
else
|
|
176
|
-
|
|
179
|
+
# Rack::Test::UploadedFile / ActionDispatch::Http::UploadedFile: the
|
|
180
|
+
# bytes stay out of the documentation, the name and type go in.
|
|
181
|
+
if value.respond_to?(:original_filename)
|
|
182
|
+
Reqcord::FileValue.marker(value.original_filename, value.respond_to?(:content_type) ? value.content_type : nil)
|
|
183
|
+
elsif value.respond_to?(:to_unsafe_h)
|
|
177
184
|
reqcord_normalize_value(value.to_unsafe_h)
|
|
178
185
|
elsif value.respond_to?(:to_h)
|
|
179
186
|
reqcord_normalize_value(value.to_h)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module Reqcord
|
|
6
|
+
# `reqcord:check`: generates the documentation into a scratch directory and
|
|
7
|
+
# compares it with the committed one, file by file. The output is a
|
|
8
|
+
# function of the routes and the tests, so any difference means the docs
|
|
9
|
+
# in the repository are behind the code — the CI signal a "living
|
|
10
|
+
# documentation" promise needs.
|
|
11
|
+
class Check
|
|
12
|
+
Result = Struct.new(:added, :removed, :changed, keyword_init: true) do
|
|
13
|
+
def clean?
|
|
14
|
+
added.empty? && removed.empty? && changed.empty?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# git-status style, one line per file.
|
|
18
|
+
def lines
|
|
19
|
+
added.map { |path| "A #{path}" } +
|
|
20
|
+
removed.map { |path| "D #{path}" } +
|
|
21
|
+
changed.map { |path| "M #{path}" }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.call(resources: [], version: nil, configuration: Reqcord.configuration)
|
|
26
|
+
new(resources: resources, version: version, configuration: configuration).call
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# `expected` is what the repository holds, `actual` what the code produces.
|
|
30
|
+
def self.compare(expected:, actual:)
|
|
31
|
+
expected_files = files_under(expected)
|
|
32
|
+
actual_files = files_under(actual)
|
|
33
|
+
|
|
34
|
+
Result.new(
|
|
35
|
+
added: (actual_files - expected_files).sort,
|
|
36
|
+
removed: (expected_files - actual_files).sort,
|
|
37
|
+
changed: (expected_files & actual_files).sort.reject do |path|
|
|
38
|
+
FileUtils.identical?(File.join(expected, path), File.join(actual, path))
|
|
39
|
+
end
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.files_under(directory)
|
|
44
|
+
return [] unless File.directory?(directory)
|
|
45
|
+
|
|
46
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: directory).select do |path|
|
|
47
|
+
File.file?(File.join(directory, path))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(resources:, version:, configuration:)
|
|
52
|
+
@resources = resources
|
|
53
|
+
@version = version
|
|
54
|
+
@configuration = configuration
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def call
|
|
58
|
+
Dir.mktmpdir("reqcord-check") do |scratch|
|
|
59
|
+
Generator.call(
|
|
60
|
+
resources: resources,
|
|
61
|
+
version: version,
|
|
62
|
+
configuration: configuration.with_output_directory(scratch)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.class.compare(expected: configuration.output_directory.to_s, actual: scratch)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
attr_reader :resources, :version, :configuration
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -145,6 +145,8 @@ module Reqcord
|
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
def output_directory
|
|
148
|
+
return Pathname(@output_override) if @output_override
|
|
149
|
+
|
|
148
150
|
value =
|
|
149
151
|
ENV["REQCORD_OUTPUT"] ||
|
|
150
152
|
data.dig("output", "directory") ||
|
|
@@ -153,6 +155,13 @@ module Reqcord
|
|
|
153
155
|
root.join(value)
|
|
154
156
|
end
|
|
155
157
|
|
|
158
|
+
# The same configuration writing somewhere else — how `reqcord:check`
|
|
159
|
+
# generates into a scratch directory while the configured one stays the
|
|
160
|
+
# thing to compare against.
|
|
161
|
+
def with_output_directory(path)
|
|
162
|
+
dup.tap { |copy| copy.instance_variable_set(:@output_override, path.to_s) }
|
|
163
|
+
end
|
|
164
|
+
|
|
156
165
|
def include_uncovered?
|
|
157
166
|
data.dig("output", "include_uncovered") == true
|
|
158
167
|
end
|
data/lib/reqcord/dataset.rb
CHANGED
|
@@ -234,12 +234,15 @@ module Reqcord
|
|
|
234
234
|
|
|
235
235
|
lines = ["## Example Request", ""]
|
|
236
236
|
|
|
237
|
-
|
|
237
|
+
if Renderers::Payload.multipart?(example)
|
|
238
|
+
lines << "Sent as `multipart/form-data`; file parts are shown by name, the cURL below attaches them with `--form`."
|
|
239
|
+
lines << ""
|
|
240
|
+
elsif !Renderers::Payload.json?(example) && !example.content_type.to_s.empty?
|
|
238
241
|
lines << "Sent as `#{example.content_type}`; the cURL below carries it in that encoding."
|
|
239
242
|
lines << ""
|
|
240
243
|
end
|
|
241
244
|
|
|
242
|
-
lines.concat([*code_block(example
|
|
245
|
+
lines.concat([*code_block(Renderers::Payload.display_body(example), example.content_type), ""])
|
|
243
246
|
end
|
|
244
247
|
|
|
245
248
|
def curl_section(example)
|
|
@@ -153,12 +153,22 @@ module Reqcord
|
|
|
153
153
|
def request_body(endpoint, example)
|
|
154
154
|
return nil if endpoint.body_schema.empty?
|
|
155
155
|
|
|
156
|
-
content_type =
|
|
156
|
+
content_type =
|
|
157
|
+
if Renderers::Payload.multipart?(example)
|
|
158
|
+
"multipart/form-data"
|
|
159
|
+
elsif Renderers::Payload.json?(example)
|
|
160
|
+
"application/json"
|
|
161
|
+
else
|
|
162
|
+
"application/x-www-form-urlencoded"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A file part is shown by name in the example; the bytes are not data.
|
|
166
|
+
body = Renderers::Payload.display_body(example) { |file| FileValue.filename(file) }
|
|
157
167
|
|
|
158
168
|
{
|
|
159
169
|
required: true,
|
|
160
170
|
content: {
|
|
161
|
-
content_type => { schema: json_schema(endpoint.body_schema), example:
|
|
171
|
+
content_type => { schema: json_schema(endpoint.body_schema), example: body }.compact
|
|
162
172
|
}
|
|
163
173
|
}
|
|
164
174
|
end
|
|
@@ -224,6 +234,8 @@ module Reqcord
|
|
|
224
234
|
|
|
225
235
|
def scalar_schema(field)
|
|
226
236
|
types = field.types.to_a.sort
|
|
237
|
+
return { type: "string", format: "binary" } if types == ["file"]
|
|
238
|
+
|
|
227
239
|
schema = { type: types.size == 1 ? types.first : types }
|
|
228
240
|
schema[:enum] = field.listed_values if field.enum?
|
|
229
241
|
schema[:example] = field.example unless field.example.nil?
|
|
@@ -135,7 +135,17 @@ module Reqcord
|
|
|
135
135
|
def body_object(example)
|
|
136
136
|
return nil unless example.body?
|
|
137
137
|
|
|
138
|
-
if Renderers::Payload.
|
|
138
|
+
if Renderers::Payload.multipart?(example)
|
|
139
|
+
parts = Renderers::Payload.form_pairs(example).map do |key, value|
|
|
140
|
+
if FileValue.file?(value)
|
|
141
|
+
{ key: key, type: "file", src: FileValue.filename(value) }
|
|
142
|
+
else
|
|
143
|
+
{ key: key, value: value.to_s, type: "text" }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
{ mode: "formdata", formdata: parts }
|
|
148
|
+
elsif Renderers::Payload.json?(example)
|
|
139
149
|
{ mode: "raw", raw: Renderers::Payload.raw_body(example), options: { raw: { language: "json" } } }
|
|
140
150
|
elsif example.body.is_a?(Hash)
|
|
141
151
|
pairs = Renderers::Payload.form_pairs(example).map { |key, value| { key: key, value: value.to_s, type: "text" } }
|