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.
@@ -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.
@@ -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
- └── postman/collection.json (postman)
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
- | `Test suite failed while generating` | the suite is red; fix the tests, documentation is only generated from a passing run |
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.