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,134 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Reqcord separates **capturing API behaviour** from **rendering
|
|
4
|
+
documentation**. Test execution produces a framework-independent dataset;
|
|
5
|
+
exporters operate on that dataset and nothing else.
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
Rails Routes
|
|
9
|
+
│
|
|
10
|
+
▼
|
|
11
|
+
Route Collector ─────────────────────────────┐
|
|
12
|
+
│ │
|
|
13
|
+
Minitest ──────► Test Adapter ──► Capture file (NDJSON) │
|
|
14
|
+
RSpec ──────► │ │
|
|
15
|
+
▼ │
|
|
16
|
+
Sanitizer │
|
|
17
|
+
│ │
|
|
18
|
+
▼ ▼
|
|
19
|
+
Reqcord Dataset ◄──── uncovered routes, skipped counts
|
|
20
|
+
│
|
|
21
|
+
┌────────────┼────────────┬────────────┐
|
|
22
|
+
▼ ▼ ▼ ▼
|
|
23
|
+
Markdown cURL Postman OpenAPI ──► Scalar (Reqcord::Web)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Modules
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
Reqcord
|
|
30
|
+
├── Configuration reqcord.yml + ENV, defaults, deep merge
|
|
31
|
+
├── RouteCollector every route kind, engines walked, skips counted by reason
|
|
32
|
+
│
|
|
33
|
+
├── Capture
|
|
34
|
+
│ ├── IntegrationPatch prepended to ActionDispatch::Integration::Session#process
|
|
35
|
+
│ ├── Collector appends one NDJSON line per exchange, flock'd
|
|
36
|
+
│ ├── TestContext thread-local "which test is running"
|
|
37
|
+
│ └── MinitestContext / RSpecContext
|
|
38
|
+
│
|
|
39
|
+
├── Sanitizers::Sanitizer headers and bodies, before the dataset
|
|
40
|
+
│
|
|
41
|
+
├── Dataset
|
|
42
|
+
│ ├── Resource one controller path → directory / folder / tag
|
|
43
|
+
│ ├── Endpoint route + examples + inferred schemas + responses
|
|
44
|
+
│ ├── RequestExample / ResponseExample
|
|
45
|
+
│ └── Schema fields, types, required, closed value sets
|
|
46
|
+
│
|
|
47
|
+
├── Generator validate → collect routes → run tests → read captures
|
|
48
|
+
│ → build dataset → report → write outputs
|
|
49
|
+
├── Check generate into a scratch dir, diff against the committed docs
|
|
50
|
+
├── FileValue the upload marker ({"$file": name, "content_type": type})
|
|
51
|
+
│
|
|
52
|
+
├── Renderers
|
|
53
|
+
│ ├── Payload JSON vs form, nested query flattening (shared by cURL and Postman)
|
|
54
|
+
│ └── Curl
|
|
55
|
+
│
|
|
56
|
+
├── Exporters registry: register("name", Klass)
|
|
57
|
+
│ ├── Markdown
|
|
58
|
+
│ ├── Curl
|
|
59
|
+
│ ├── Postman
|
|
60
|
+
│ └── Openapi
|
|
61
|
+
│
|
|
62
|
+
├── Web Rack app: Scalar page + generated files
|
|
63
|
+
└── Railtie rake tasks; installs the patch only when capture is on
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## The pipeline, step by step
|
|
67
|
+
|
|
68
|
+
1. **Validate** — framework and exporter names are checked before anything
|
|
69
|
+
runs.
|
|
70
|
+
2. **Collect routes** — the route table is walked (mounted engines
|
|
71
|
+
recursively, with their mount prefix), filtered by `routes.prefix`,
|
|
72
|
+
`RESOURCE` and `VERSION`; redirects and Rack mounts are counted as
|
|
73
|
+
skipped; multi-verb routes become one route per verb.
|
|
74
|
+
3. **Run the tests** — `test.command` or the runner built from `test.paths`,
|
|
75
|
+
in a subprocess with `REQCORD_CAPTURE=1` and a capture file path. Inside
|
|
76
|
+
the test process the Railtie prepends the integration patch and installs
|
|
77
|
+
the framework context, so each request becomes one exchange line. Output
|
|
78
|
+
is streamed. A red suite is a warning unless `test.strict`.
|
|
79
|
+
4. **Read and sanitize** — each line is parsed, malformed lines are skipped
|
|
80
|
+
with a warning, and the sanitizer replaces credentials before anything is
|
|
81
|
+
kept.
|
|
82
|
+
5. **Build the dataset** — exchanges are matched to routes; `via: :all`
|
|
83
|
+
routes are materialized per captured verb; PATCH/PUT twins fold; examples
|
|
84
|
+
dedupe by sanitized signature + status; schemas are inferred from accepted
|
|
85
|
+
requests.
|
|
86
|
+
6. **Report** — `routes = documented + uncovered + skipped`, unmatched paths,
|
|
87
|
+
the test outcome.
|
|
88
|
+
7. **Write** — `dataset.json`, then every configured exporter.
|
|
89
|
+
|
|
90
|
+
## Design principles
|
|
91
|
+
|
|
92
|
+
**Tests are the source of truth.** Reqcord observes existing tests instead
|
|
93
|
+
of asking for a documentation DSL. It never reads models, serializers or
|
|
94
|
+
contracts: what the application accepted and answered is the specification.
|
|
95
|
+
|
|
96
|
+
**Capture once, export anywhere.** Adapters turn test execution into the
|
|
97
|
+
canonical dataset; exporters know nothing about Minitest, RSpec or Rails
|
|
98
|
+
internals. A new format is one class with `call(dataset:, output_dir:,
|
|
99
|
+
configuration:)`.
|
|
100
|
+
|
|
101
|
+
**Nothing is lost silently.** Every route lands in one bucket and the report
|
|
102
|
+
reconciles the sum; unmatched captures are printed; malformed capture lines
|
|
103
|
+
are warned about.
|
|
104
|
+
|
|
105
|
+
**Generated documentation must be safe.** Sanitization runs before the
|
|
106
|
+
dataset exists, so `dataset.json` is as clean as the pages built from it.
|
|
107
|
+
|
|
108
|
+
**Useful without a server.** Markdown and cURL read fine on GitHub or in a
|
|
109
|
+
checkout; `Reqcord::Web` is optional.
|
|
110
|
+
|
|
111
|
+
## Scope
|
|
112
|
+
|
|
113
|
+
Included today: Rails 7.1–8.1 on Ruby 3.2+, Minitest integration tests and
|
|
114
|
+
RSpec request specs, the whole route table, JSON, form and multipart bodies,
|
|
115
|
+
multiple responses per endpoint, sanitization, inferred parameter and
|
|
116
|
+
response schemas, deterministic output, Markdown, cURL, Postman (Hoppscotch),
|
|
117
|
+
OpenAPI 3.1 and Scalar, `reqcord:check` for CI.
|
|
118
|
+
|
|
119
|
+
Not yet: capture outside Rails (Rack::Test for Sinatra, Roda, Hanami).
|
|
120
|
+
Candidate exporters: Bruno, Insomnia, `llms.txt`, static HTML, JSON Schema.
|
|
121
|
+
|
|
122
|
+
## Working on Reqcord
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
bundle install
|
|
126
|
+
bundle exec rake test # 190+ tests, ~12 s
|
|
127
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The suite includes an out-of-process run of `test/dummy` (a small Rails app
|
|
131
|
+
with every route kind), a live Puma replay of every generated cURL script and
|
|
132
|
+
Postman request, Postman schema validation, and generation of the four
|
|
133
|
+
[`examples/`](../examples) — whose committed `docs/api` must match what the
|
|
134
|
+
code produces.
|
data/docs/capture.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Capture, sanitization and inference
|
|
2
|
+
|
|
3
|
+
How a request made by a test ends up as a row in a parameter table.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
integration test ──► capture ──► sanitize ──► dataset ──► exporters
|
|
7
|
+
(in the test (before (schemas
|
|
8
|
+
process) anything inferred
|
|
9
|
+
is stored) per endpoint)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Capture
|
|
13
|
+
|
|
14
|
+
`reqcord:generate` runs the suite in a subprocess with `REQCORD_CAPTURE=1`
|
|
15
|
+
and `REQCORD_CAPTURE_FILE=<path>`. Inside that process the Railtie prepends a
|
|
16
|
+
patch to `ActionDispatch::Integration::Session#process`, so every `get`,
|
|
17
|
+
`post`, `patch`, `put` and `delete` a test makes — through Minitest
|
|
18
|
+
integration tests or RSpec request specs — appends one JSON line to the
|
|
19
|
+
capture file. The file is append-only and locked per write, so parallel test
|
|
20
|
+
workers share it. Without those variables an ordinary `bin/rails test`
|
|
21
|
+
patches nothing and writes nothing.
|
|
22
|
+
|
|
23
|
+
Each line is one **exchange**:
|
|
24
|
+
|
|
25
|
+
| Field | Source |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `request.method`, `request.path` | the verb and the concrete path the test called (`/api/v2/customers/42`), method-override forms normalized |
|
|
28
|
+
| `request.path_params` | the dynamic segments the router filled in (`id: "42"`) |
|
|
29
|
+
| `request.query_params` | the query string, nested (`filter: { category: "mugs" }`) |
|
|
30
|
+
| `request.headers` | the headers the test passed, plus `Content-Type` / `Accept` when they carry information (Rails' default `Accept` is dropped) |
|
|
31
|
+
| `request.body`, `request.content_type` | the params the test sent, as JSON, form fields or multipart parts; an uploaded file becomes `{"$file": "label.png", "content_type": "image/png"}` — name and type, never the bytes |
|
|
32
|
+
| `response.status`, `response.headers`, `response.body`, `response.content_type` | the response; JSON bodies parsed, others kept as text |
|
|
33
|
+
| `source` | the test method or example name, its class or group, file and line |
|
|
34
|
+
|
|
35
|
+
Supported request bodies are JSON, form-encoded and multipart
|
|
36
|
+
(`Rack::Test::UploadedFile` / `fixture_file_upload` in the params). The file
|
|
37
|
+
marker is what every exporter reads: `file` in the parameter table,
|
|
38
|
+
`--form name=@file` in cURL, a `formdata` file part in Postman,
|
|
39
|
+
`format: binary` in OpenAPI.
|
|
40
|
+
|
|
41
|
+
Exchanges are ordered by test file, line and name before the dataset is
|
|
42
|
+
built, so the output does not depend on the (random) order the suite ran
|
|
43
|
+
in; requests made inside one test keep their execution order.
|
|
44
|
+
|
|
45
|
+
## Sanitization
|
|
46
|
+
|
|
47
|
+
Sanitization runs on every exchange **before** it reaches the dataset, so no
|
|
48
|
+
generated file — and not `dataset.json` either — contains a credential.
|
|
49
|
+
|
|
50
|
+
**Headers** are matched case-insensitively and replaced verbatim with what
|
|
51
|
+
you configure:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
sanitize:
|
|
55
|
+
headers:
|
|
56
|
+
Authorization: "Bearer {{token}}" # default
|
|
57
|
+
X-Api-Key: "{{api_key}}" # default
|
|
58
|
+
X-Account-Id: "{{account_id}}"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Some headers are redacted whether configured or not: `Authorization`,
|
|
62
|
+
`Proxy-Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`,
|
|
63
|
+
`X-Csrf-Token`. Without a configured replacement the value becomes a
|
|
64
|
+
placeholder named after the header (`{{cookie}}`), and a `Bearer`, `Token` or
|
|
65
|
+
`Basic` scheme is kept so the cURL stays runnable.
|
|
66
|
+
|
|
67
|
+
Transport noise (`Host`, `User-Agent`, `Content-Length`, `X-Request-Id`,
|
|
68
|
+
`X-Runtime`, `ETag`, the `X-*-Options` security headers …) and empty headers
|
|
69
|
+
are dropped from requests and responses alike.
|
|
70
|
+
|
|
71
|
+
**Body keys** are matched case-insensitively at any depth, in requests and
|
|
72
|
+
responses:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
sanitize:
|
|
76
|
+
body:
|
|
77
|
+
password: "{{password}}" # default, with password_confirmation
|
|
78
|
+
token: "{{token}}" # default, with access_token, refresh_token
|
|
79
|
+
api_key: "{{api_key}}" # default
|
|
80
|
+
secret: "{{secret}}" # default, with client_secret
|
|
81
|
+
payment_url: "{{payment_url}}"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The whole value under the key is replaced, whatever its type.
|
|
85
|
+
|
|
86
|
+
**Placeholders** are the documentation's variables: `{{name}}` stays as-is in
|
|
87
|
+
Markdown and cURL, is listed in the index under *Placeholders*, becomes a
|
|
88
|
+
collection variable in Postman and feeds the security schemes in OpenAPI.
|
|
89
|
+
The full list of rules is in
|
|
90
|
+
[configuration.md → sanitize](configuration.md#sanitize).
|
|
91
|
+
|
|
92
|
+
## Building the dataset
|
|
93
|
+
|
|
94
|
+
Each exchange is matched against the route table (concrete path → route
|
|
95
|
+
pattern, engine mount prefixes included) and attached to that endpoint as a
|
|
96
|
+
request example and a response example. Two captures that are identical
|
|
97
|
+
after sanitization and produced the same status collapse into one example;
|
|
98
|
+
a different status is always kept, so a right and a wrong password are two
|
|
99
|
+
examples even though both read `{{password}}`.
|
|
100
|
+
|
|
101
|
+
## Inference
|
|
102
|
+
|
|
103
|
+
The parameter tables and response field tables are **inferred**, and only
|
|
104
|
+
from the requests the application **accepted** (`2xx`). A request that was
|
|
105
|
+
rejected tells you nothing reliable about what the endpoint takes, so its
|
|
106
|
+
payload is kept as a response example and nothing more.
|
|
107
|
+
|
|
108
|
+
For each endpoint, all accepted bodies, query hashes and path parameter
|
|
109
|
+
hashes are flattened to field paths and merged:
|
|
110
|
+
|
|
111
|
+
| Column | Rule |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| `Field` | the path: `customer.name`, `order.line_items[].sku`, `filter.category` |
|
|
114
|
+
| `Type` | the JSON types seen (`string`, `integer`, `number`, `boolean`, `null`, `object`, `array`, `file` for an upload), joined with `\|` when they differ |
|
|
115
|
+
| `Required` | `yes` only when **every** accepted request carried the field; a request accepted with no parameters at all counts, so `?status=` on a list endpoint is optional |
|
|
116
|
+
| `Values` | a closed set (`"active"` \| `"passive"`) when the values look like a choice; otherwise one example |
|
|
117
|
+
|
|
118
|
+
A field is shown as a closed set when it has 2–6 distinct scalar values, is
|
|
119
|
+
not an identifier (`id`, `*_id`, `uuid`, `token`, `slug`, `*_key`), and
|
|
120
|
+
either a value repeated across requests or every value reads like a token
|
|
121
|
+
(`active`, `pending_review`, `USD`) rather than content (`Ada Lovelace`,
|
|
122
|
+
`ada@example.com`, `e-00056197`).
|
|
123
|
+
|
|
124
|
+
Response fields use the same inference over every body captured with that
|
|
125
|
+
status, with one difference: repetition across responses does not make a
|
|
126
|
+
set — fixture names that come back in every list response are content, not a
|
|
127
|
+
choice — so only the token rule applies. The first body captured for a
|
|
128
|
+
status is the example shown.
|
|
129
|
+
|
|
130
|
+
## What the dataset holds
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"schema_version": 2,
|
|
135
|
+
"endpoints": [
|
|
136
|
+
{
|
|
137
|
+
"name": "Create Customer",
|
|
138
|
+
"method": "POST",
|
|
139
|
+
"path": "/api/v2/customers",
|
|
140
|
+
"controller": "api/v2/customers",
|
|
141
|
+
"action": "create",
|
|
142
|
+
"route_name": "api_v2_customers",
|
|
143
|
+
"also_methods": [],
|
|
144
|
+
"parameters": {
|
|
145
|
+
"path": [],
|
|
146
|
+
"query": [],
|
|
147
|
+
"body": [
|
|
148
|
+
{ "path": "customer.name", "type": "string", "required": true, "values": ["John Doe"] },
|
|
149
|
+
{ "path": "customer.status", "type": "string", "required": true, "values": ["active", "passive"] }
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
"responses": [
|
|
153
|
+
{ "status": 201, "schema": [ { "path": "id", "type": "integer", "required": true, "values": [42] } ],
|
|
154
|
+
"example": { "id": 42, "name": "John Doe" } },
|
|
155
|
+
{ "status": 422, "schema": [ "…" ], "example": { "errors": { "email": ["can't be blank"] } } }
|
|
156
|
+
],
|
|
157
|
+
"request_examples": [ "every captured request, sanitized, with its source test" ],
|
|
158
|
+
"response_examples": [ "every captured response, sanitized" ]
|
|
159
|
+
}
|
|
160
|
+
],
|
|
161
|
+
"uncovered_routes": [
|
|
162
|
+
{ "method": "DELETE", "path": "/api/v2/customers/:id", "controller": "api/v2/customers", "action": "destroy" }
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`endpoints` holds the documented ones; everything else is under
|
|
168
|
+
`uncovered_routes`. `schema_version` lets the format evolve — readers ignore
|
|
169
|
+
keys they do not know.
|
data/docs/configuration.md
CHANGED
|
@@ -23,6 +23,7 @@ exporters:
|
|
|
23
23
|
- curl
|
|
24
24
|
- markdown
|
|
25
25
|
- postman
|
|
26
|
+
- openapi
|
|
26
27
|
|
|
27
28
|
variables:
|
|
28
29
|
base_url: http://localhost:3000
|
|
@@ -212,7 +213,8 @@ as is). Override per run with `REQCORD_OUTPUT`. A run writes:
|
|
|
212
213
|
│ ├── index.md
|
|
213
214
|
│ └── create.md
|
|
214
215
|
├── curl/api/v1/customers/create.sh (curl)
|
|
215
|
-
|
|
216
|
+
├── postman/collection.json (postman)
|
|
217
|
+
└── openapi/openapi.json (openapi)
|
|
216
218
|
```
|
|
217
219
|
|
|
218
220
|
Directories follow the controller path, so `admin/customers` and
|
|
@@ -349,6 +351,7 @@ soon as `base_url` and `token` are filled in.
|
|
|
349
351
|
| --- | --- |
|
|
350
352
|
| `bin/rails reqcord:init` | writes `reqcord.yml` (never overwrites) and creates `docs/api/` |
|
|
351
353
|
| `bin/rails reqcord:generate` | collects routes, runs the suite with capture, writes every exporter, prints the report |
|
|
354
|
+
| `bin/rails reqcord:check` | generates into a scratch directory and compares with `output.directory`; exits 1 with an `A`/`D`/`M` file list when the committed docs are behind the tests. Honours `RESOURCE` / `VERSION` |
|
|
352
355
|
| `bin/rails reqcord:routes` | lists the routes the current `prefix` (and `RESOURCE` / `VERSION`) would document |
|
|
353
356
|
|
|
354
357
|
Every `generate` run ends with a reconciliation of the whole route table:
|
|
@@ -369,4 +372,6 @@ documented route is listed, never dropped silently.
|
|
|
369
372
|
| `no request was captured` | the gem is not in the `:test` group of the Gemfile, or `test.paths` / `test.command` runs no integration tests |
|
|
370
373
|
| many requests captured, few matched | `routes.prefix` does not cover them — the unmatched paths are printed |
|
|
371
374
|
| routes documented but few covered | the tests that exercise them are not in `test.paths` (a `2xx` from a test is what makes an endpoint documented) |
|
|
372
|
-
| `
|
|
375
|
+
| `test run exited with status …` | the suite is red; the docs were still generated from what it captured. `test.strict: true` aborts instead |
|
|
376
|
+
|
|
377
|
+
More in [troubleshooting.md](troubleshooting.md).
|
data/docs/exporters.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Exporters
|
|
2
|
+
|
|
3
|
+
Every exporter reads `dataset.json` and nothing else; none of them knows
|
|
4
|
+
about Minitest, RSpec or Rails. Pick them in `reqcord.yml`:
|
|
5
|
+
|
|
6
|
+
```yaml
|
|
7
|
+
exporters:
|
|
8
|
+
- curl
|
|
9
|
+
- markdown
|
|
10
|
+
- postman
|
|
11
|
+
- openapi
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
All four are on by default. `dataset.json` is always written. An unknown name
|
|
15
|
+
raises `Reqcord::ConfigurationError` before any test runs.
|
|
16
|
+
|
|
17
|
+
Only **documented** endpoints — those a test reached with a `2xx` — get a
|
|
18
|
+
Markdown page, a cURL script, a Postman request and an OpenAPI operation.
|
|
19
|
+
Uncovered routes are listed in the Markdown index and in
|
|
20
|
+
`dataset.json` (`uncovered_routes`); `output.include_uncovered: true` also
|
|
21
|
+
writes a placeholder page for each.
|
|
22
|
+
|
|
23
|
+
## What the canonical request is
|
|
24
|
+
|
|
25
|
+
Every output that shows a request — the cURL, the Postman request, the OpenAPI
|
|
26
|
+
example — uses the same one: the first captured request that returned a
|
|
27
|
+
`2xx`, with its concrete URL, headers, query and body, sanitized. Reqcord
|
|
28
|
+
never invents a payload. Requests that produced other statuses are kept as
|
|
29
|
+
response examples, never promoted to the canonical request.
|
|
30
|
+
|
|
31
|
+
## `markdown`
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
docs/api/
|
|
35
|
+
├── README.md
|
|
36
|
+
├── api/v2/customers/
|
|
37
|
+
│ ├── index.md
|
|
38
|
+
│ ├── list.md
|
|
39
|
+
│ ├── create.md
|
|
40
|
+
│ ├── show.md
|
|
41
|
+
│ └── update.md
|
|
42
|
+
└── admin/customers/
|
|
43
|
+
└── …
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
* **`README.md`** — the base URL, a *Placeholders* table (every sanitized
|
|
47
|
+
header and its replacement), one section per resource with a
|
|
48
|
+
method / path / description table linking to the endpoint pages, and a *No
|
|
49
|
+
Successful Request Captured* section listing the uncovered routes.
|
|
50
|
+
* **`<controller path>/index.md`** — the resource's endpoints.
|
|
51
|
+
* **`<controller path>/<action>.md`** — the endpoint page: method and path
|
|
52
|
+
(with `(also PUT)` when PATCH/PUT twins were folded), the namespace, the
|
|
53
|
+
headers, *Path / Query / Body Parameters* tables (`Field | Type | Required |
|
|
54
|
+
Values`), the example request, the cURL, and one `### <status>` section per
|
|
55
|
+
captured status with a *Fields* table and an example body.
|
|
56
|
+
|
|
57
|
+
Directories follow the full controller path, so `admin/customers` and
|
|
58
|
+
`api/v2/customers` never collide. File names come from the action
|
|
59
|
+
(`index` → `list.md`); when two endpoints of a resource would share a name the
|
|
60
|
+
verb is appended.
|
|
61
|
+
|
|
62
|
+
Form bodies are shown as they were sent, with a note that the request is
|
|
63
|
+
`application/x-www-form-urlencoded`. In a multipart request a file part is
|
|
64
|
+
shown by name and type (`"image": "label.png (image/png)"`) and typed `file`
|
|
65
|
+
in the parameter table.
|
|
66
|
+
|
|
67
|
+
## `curl`
|
|
68
|
+
|
|
69
|
+
One runnable script per documented endpoint:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
docs/api/curl/api/v2/customers/create.sh
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
curl --request POST \
|
|
77
|
+
--url "http://localhost:3000/api/v2/customers" \
|
|
78
|
+
--header "Authorization: Bearer {{token}}" \
|
|
79
|
+
--header "Content-Type: application/json" \
|
|
80
|
+
--data '{
|
|
81
|
+
"customer": {
|
|
82
|
+
"name": "John Doe",
|
|
83
|
+
"email": "john@example.com",
|
|
84
|
+
"status": "active"
|
|
85
|
+
}
|
|
86
|
+
}'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
* The host is `variables.base_url`.
|
|
90
|
+
* JSON bodies are `--data '<json>'`; form bodies are `--data 'a=b'` pairs,
|
|
91
|
+
never re-encoded as JSON; query strings keep Rails' bracket notation
|
|
92
|
+
(`filter[category]=mugs`).
|
|
93
|
+
* Uploads are one `--form` per part, the file as `@name`
|
|
94
|
+
(`--form 'image=@label.png;type=image/png'`) — run the script from the
|
|
95
|
+
directory that holds the file. curl sets the multipart `Content-Type` and
|
|
96
|
+
boundary itself, so none is written.
|
|
97
|
+
* Placeholders (`{{token}}`) are left for you to substitute — the scripts
|
|
98
|
+
are meant to be copied into a terminal or a runbook.
|
|
99
|
+
|
|
100
|
+
Reqcord's own test suite replays every generated script of its dummy
|
|
101
|
+
application against a live Puma and asserts the documented status, so the
|
|
102
|
+
scripts are known to run.
|
|
103
|
+
|
|
104
|
+
## `postman`
|
|
105
|
+
|
|
106
|
+
`docs/api/postman/collection.json`, a Postman Collection v2.1:
|
|
107
|
+
|
|
108
|
+
* one folder per controller namespace (`Api › V2 › Customers`),
|
|
109
|
+
* one request per documented endpoint, from the canonical request — JSON
|
|
110
|
+
bodies as `raw`, form bodies as `urlencoded`, uploads as `formdata` with
|
|
111
|
+
the file part's `src` set to the file name (pick the file in Postman),
|
|
112
|
+
* every captured status saved as a response example on that request,
|
|
113
|
+
* collection variables for `base_url` and every placeholder the sanitizer
|
|
114
|
+
wrote (`{{token}}`, `{{api_key}}` …) — Postman's variable syntax is the
|
|
115
|
+
same, so the collection runs as soon as the variables are filled in,
|
|
116
|
+
* `Authorization: Bearer {{token}}` lifted to collection-level bearer auth;
|
|
117
|
+
requests made without credentials are marked `noauth`, so they replay
|
|
118
|
+
exactly as their tests did.
|
|
119
|
+
|
|
120
|
+
The collection is validated against the published v2.1 JSON Schema in
|
|
121
|
+
Reqcord's tests, and replayed against the dummy application.
|
|
122
|
+
|
|
123
|
+
**Hoppscotch** imports Postman v2.1 collections directly: *Import → Postman*
|
|
124
|
+
and point it at the same file.
|
|
125
|
+
|
|
126
|
+
## `openapi`
|
|
127
|
+
|
|
128
|
+
`docs/api/openapi/openapi.json`, an OpenAPI 3.1 document:
|
|
129
|
+
|
|
130
|
+
* `info.title` is `<application directory> API`; `info.version` is the API
|
|
131
|
+
version when the dataset has exactly one (`v2`), `1.0.0` otherwise;
|
|
132
|
+
`servers` is `variables.base_url`.
|
|
133
|
+
* One path item per documented route in OpenAPI notation: `/customers/:id`
|
|
134
|
+
→ `/customers/{id}`, `/files/*path` → `/files/{path}`. An optional segment
|
|
135
|
+
becomes two paths: `/items(/:id)` → `/items` and `/items/{id}`.
|
|
136
|
+
* One operation per verb: `operationId` from the verb and path
|
|
137
|
+
(`post_api_v2_customers`), `summary` is the endpoint name, `tags` is the
|
|
138
|
+
resource, `description` carries `controller#action`, the route name and
|
|
139
|
+
the folded verbs.
|
|
140
|
+
* **Parameters**: path parameters (always required) and query parameters
|
|
141
|
+
from the inferred schemas, with `enum` for closed value sets and the
|
|
142
|
+
captured example. Nested query params keep Rails' bracket notation
|
|
143
|
+
(`filter[status]`).
|
|
144
|
+
* **Request body**: `application/json`, `application/x-www-form-urlencoded`
|
|
145
|
+
or `multipart/form-data`, whichever the test sent. The JSON Schema is
|
|
146
|
+
rebuilt from the flattened field paths — `order.line_items[].sku` becomes
|
|
147
|
+
object → array → object — with `required` at every level from what every
|
|
148
|
+
accepted request carried, and the canonical request as `example`. A file
|
|
149
|
+
part is `type: string, format: binary`, its example the file name.
|
|
150
|
+
* **Responses**: one per captured status, `description` from the status
|
|
151
|
+
text, schema and example from the captured bodies.
|
|
152
|
+
* **Security**: `bearerAuth` (HTTP bearer) when a documented request carried
|
|
153
|
+
`Authorization: Bearer …`, `apiKeyAuth` (header) for the first sanitized
|
|
154
|
+
API-key header a request carried (`X-Api-Key` by default). Applied per
|
|
155
|
+
operation, so endpoints called without credentials stay public.
|
|
156
|
+
|
|
157
|
+
Anything that reads OpenAPI consumes the file as is: Scalar (through
|
|
158
|
+
[`Reqcord::Web`](web.md)), Swagger UI, Redoc, client generators.
|
|
159
|
+
|
|
160
|
+
## Writing your own
|
|
161
|
+
|
|
162
|
+
An exporter is a class with one class method, registered under a name:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
module Reqcord
|
|
166
|
+
module Exporters
|
|
167
|
+
class Bruno
|
|
168
|
+
def self.call(dataset:, output_dir:, configuration:)
|
|
169
|
+
# dataset: Reqcord::Dataset — resources, endpoints, examples, schemas
|
|
170
|
+
# returns the paths it wrote
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
register("bruno", Bruno)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Once registered, `exporters: [bruno]` in `reqcord.yml` runs it after the
|
|
180
|
+
tests. `dataset.json` on disk is the same structure, if you would rather
|
|
181
|
+
post-process the file from another language. See
|
|
182
|
+
[architecture.md](architecture.md) for the dataset.
|