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,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&.content_type
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
- if value.respond_to?(:to_unsafe_h)
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
@@ -142,7 +142,6 @@ module Reqcord
142
142
  def to_h
143
143
  {
144
144
  schema_version: schema_version,
145
- generated_at: Time.now.utc.iso8601,
146
145
  endpoints: curl_ready_endpoints.map(&:to_h),
147
146
  uncovered_routes: uncovered_endpoints.map do |endpoint|
148
147
  {
@@ -234,12 +234,15 @@ module Reqcord
234
234
 
235
235
  lines = ["## Example Request", ""]
236
236
 
237
- unless Renderers::Payload.json?(example) || example.content_type.to_s.empty?
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.body, example.content_type), ""])
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 = Renderers::Payload.json?(example) ? "application/json" : "application/x-www-form-urlencoded"
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: example.body }.compact
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.json?(example)
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" } }