reqcord 0.1.0 → 0.1.2
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 +90 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +252 -95
- data/Rakefile +13 -0
- data/docs/configuration.md +319 -0
- data/examples/reqcord.yml +58 -0
- data/gemfiles/rails_7.1.gemfile +13 -0
- data/gemfiles/rails_7.2.gemfile +13 -0
- data/gemfiles/rails_8.0.gemfile +13 -0
- data/gemfiles/rails_8.1.gemfile +13 -0
- data/lib/reqcord/capture/collector.rb +31 -0
- data/lib/reqcord/capture/integration_patch.rb +209 -0
- data/lib/reqcord/capture/minitest_context.rb +34 -0
- data/lib/reqcord/capture/rspec_context.rb +42 -0
- data/lib/reqcord/capture/test_context.rb +25 -0
- data/lib/reqcord/capture.rb +19 -0
- data/lib/reqcord/configuration.rb +198 -0
- data/lib/reqcord/dataset.rb +176 -0
- data/lib/reqcord/endpoint.rb +263 -0
- data/lib/reqcord/errors.rb +9 -0
- data/lib/reqcord/exporters/curl.rb +68 -0
- data/lib/reqcord/exporters/markdown.rb +295 -0
- data/lib/reqcord/exporters/postman.rb +206 -0
- data/lib/reqcord/exporters.rb +32 -0
- data/lib/reqcord/generator.rb +364 -0
- data/lib/reqcord/railtie.rb +51 -0
- data/lib/reqcord/renderers/curl.rb +56 -0
- data/lib/reqcord/renderers/payload.rb +69 -0
- data/lib/reqcord/request_example.rb +104 -0
- data/lib/reqcord/response_example.rb +72 -0
- data/lib/reqcord/route_collector.rb +242 -0
- data/lib/reqcord/sanitizers/sanitizer.rb +140 -0
- data/lib/reqcord/schema.rb +187 -0
- data/lib/reqcord/support.rb +58 -0
- data/lib/reqcord/version.rb +5 -0
- data/lib/reqcord.rb +78 -0
- data/lib/tasks/reqcord.rake +99 -0
- data/reqcord.gemspec +60 -0
- metadata +165 -3
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
class Endpoint
|
|
5
|
+
ACTION_TITLES = {
|
|
6
|
+
"index" => "List %<plural>s",
|
|
7
|
+
"show" => "Get %<singular>s",
|
|
8
|
+
"create" => "Create %<singular>s",
|
|
9
|
+
"update" => "Update %<singular>s",
|
|
10
|
+
"destroy" => "Delete %<singular>s",
|
|
11
|
+
"new" => "New %<singular>s",
|
|
12
|
+
"edit" => "Edit %<singular>s"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# One status the endpoint was seen to return, described by every body
|
|
16
|
+
# captured with that status; the first capture stands as the example.
|
|
17
|
+
Response = Struct.new(:status, :schema, :examples, keyword_init: true) do
|
|
18
|
+
def example
|
|
19
|
+
examples.first
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_h
|
|
23
|
+
{
|
|
24
|
+
status: status,
|
|
25
|
+
schema: schema.to_a,
|
|
26
|
+
example: example.body,
|
|
27
|
+
headers: example.headers,
|
|
28
|
+
content_type: example.content_type
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_accessor :name,
|
|
34
|
+
:method,
|
|
35
|
+
:path,
|
|
36
|
+
:controller,
|
|
37
|
+
:action,
|
|
38
|
+
:resource,
|
|
39
|
+
:api_version,
|
|
40
|
+
:route_name,
|
|
41
|
+
:also_methods,
|
|
42
|
+
:request_examples,
|
|
43
|
+
:response_examples
|
|
44
|
+
|
|
45
|
+
def initialize(
|
|
46
|
+
method:,
|
|
47
|
+
path:,
|
|
48
|
+
controller:,
|
|
49
|
+
action:,
|
|
50
|
+
name: nil,
|
|
51
|
+
resource: nil,
|
|
52
|
+
api_version: nil,
|
|
53
|
+
route_name: nil,
|
|
54
|
+
also_methods: [],
|
|
55
|
+
request_examples: [],
|
|
56
|
+
response_examples: []
|
|
57
|
+
)
|
|
58
|
+
@method = method.to_s.upcase
|
|
59
|
+
@path = path
|
|
60
|
+
@controller = controller
|
|
61
|
+
@action = action
|
|
62
|
+
@resource = resource || infer_resource(controller)
|
|
63
|
+
@api_version = api_version
|
|
64
|
+
@route_name = route_name
|
|
65
|
+
@also_methods = also_methods
|
|
66
|
+
@request_examples = request_examples
|
|
67
|
+
@response_examples = response_examples
|
|
68
|
+
@name = name || default_name
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def http_method
|
|
72
|
+
method
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def key
|
|
76
|
+
"#{method} #{path}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A request only counts as successful through the status it received, so
|
|
80
|
+
# the pair is what tells us; callers should not have to set it by hand.
|
|
81
|
+
def add_exchange(request:, response:)
|
|
82
|
+
request.response_status ||= response.status if request && response
|
|
83
|
+
|
|
84
|
+
add_request_example(request)
|
|
85
|
+
add_response_example(response)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def add_request_example(example)
|
|
89
|
+
return if example.nil?
|
|
90
|
+
return if request_examples.any? { |candidate| candidate.signature == example.signature }
|
|
91
|
+
|
|
92
|
+
reset_schemas!
|
|
93
|
+
request_examples << example
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def add_response_example(example)
|
|
97
|
+
return if example.nil?
|
|
98
|
+
return if response_examples.any? { |candidate| candidate.signature == example.signature }
|
|
99
|
+
|
|
100
|
+
response_examples << example
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def documented?
|
|
104
|
+
!request_examples.empty? || !response_examples.empty?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# cURL examples must come from a request that the application actually
|
|
108
|
+
# accepted. Error-case payloads are valuable response examples, but they
|
|
109
|
+
# must never become the endpoint's canonical request example.
|
|
110
|
+
def primary_request_example
|
|
111
|
+
successful_request_examples.first
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def curl_ready?
|
|
115
|
+
!primary_request_example.nil?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def successful_request_examples
|
|
119
|
+
request_examples.select(&:successful?)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def documented_request_examples
|
|
123
|
+
successful = successful_request_examples
|
|
124
|
+
successful.empty? ? request_examples : successful
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def responses_by_status
|
|
128
|
+
response_examples.group_by(&:status).sort_by { |status, _| status }.to_h
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Every status seen, each with a schema inferred from all of its bodies.
|
|
132
|
+
# Non-JSON bodies (plain text, HTML) carry no fields to describe.
|
|
133
|
+
def responses
|
|
134
|
+
responses_by_status.map do |status, examples|
|
|
135
|
+
bodies = examples.map(&:body).select { |body| body.is_a?(Hash) || body.is_a?(Array) }
|
|
136
|
+
|
|
137
|
+
Response.new(status: status, schema: Schema.infer(bodies, repetition: false), examples: examples)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# What the endpoint accepts, described only by requests the application
|
|
142
|
+
# accepted: a rejected payload says what the API refuses, not what it takes.
|
|
143
|
+
def body_schema
|
|
144
|
+
@body_schema ||= Schema.infer(successful_request_examples.map(&:body))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def query_schema
|
|
148
|
+
@query_schema ||= Schema.infer(successful_request_examples.map(&:query_params))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def path_param_schema
|
|
152
|
+
@path_param_schema ||= Schema.infer(successful_request_examples.map(&:path_params))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A member route addresses one record: a required `:param` follows the
|
|
156
|
+
# resource segment. Optional groups (`/items(/:id)`) and globs
|
|
157
|
+
# (`/files/*path`) do not make a route a member route.
|
|
158
|
+
def member?
|
|
159
|
+
segments = required_path.split("/").reject(&:empty?)
|
|
160
|
+
singular = Support.singularize(resource.to_s)
|
|
161
|
+
index = segments.rindex { |segment| segment == resource.to_s || segment == singular }
|
|
162
|
+
|
|
163
|
+
# `resource :cart` is served by CartsController at /cart: one record,
|
|
164
|
+
# so its custom actions (/cart/checkout) address that one record.
|
|
165
|
+
return true if index && segments[index] == singular && singular != resource.to_s
|
|
166
|
+
|
|
167
|
+
candidates = index ? segments[(index + 1)..] : segments
|
|
168
|
+
|
|
169
|
+
candidates.any? { |segment| segment.start_with?(":") }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Every dynamic segment, including globs and those inside optional groups.
|
|
173
|
+
def path_params
|
|
174
|
+
path.scan(/[:*]([a-zA-Z_][a-zA-Z0-9_]*)/).flatten
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The path with its optional groups removed: what a request must carry.
|
|
178
|
+
def required_path
|
|
179
|
+
stripped = path.to_s
|
|
180
|
+
|
|
181
|
+
stripped = stripped.gsub(/\([^()]*\)/, "") while stripped.match?(/\([^()]*\)/)
|
|
182
|
+
|
|
183
|
+
stripped
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def slug
|
|
187
|
+
Support.parameterize(action.to_s.empty? ? key : action)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def to_h
|
|
191
|
+
{
|
|
192
|
+
name: name,
|
|
193
|
+
method: method,
|
|
194
|
+
path: path,
|
|
195
|
+
controller: controller,
|
|
196
|
+
action: action,
|
|
197
|
+
resource: resource,
|
|
198
|
+
api_version: api_version,
|
|
199
|
+
route_name: route_name,
|
|
200
|
+
also_methods: also_methods,
|
|
201
|
+
path_params: path_params,
|
|
202
|
+
parameters: {
|
|
203
|
+
path: path_param_schema.to_a,
|
|
204
|
+
query: query_schema.to_a,
|
|
205
|
+
body: body_schema.to_a
|
|
206
|
+
},
|
|
207
|
+
responses: responses.map(&:to_h),
|
|
208
|
+
request_examples: request_examples.map(&:to_h),
|
|
209
|
+
response_examples: response_examples.map(&:to_h)
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
def reset_schemas!
|
|
216
|
+
@body_schema = nil
|
|
217
|
+
@query_schema = nil
|
|
218
|
+
@path_param_schema = nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def infer_resource(controller)
|
|
222
|
+
controller.to_s.split("/").last
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def default_name
|
|
226
|
+
label = resource.to_s.empty? ? "resource" : resource.to_s.tr("/", " ")
|
|
227
|
+
plural = Support.titleize(Support.pluralize(label))
|
|
228
|
+
singular = Support.titleize(Support.singularize(label))
|
|
229
|
+
|
|
230
|
+
# `root to: "home#index"` is a page, not a list of homes.
|
|
231
|
+
return singular if action.to_s == "index" && !resource_in_path?
|
|
232
|
+
|
|
233
|
+
template = ACTION_TITLES[action.to_s]
|
|
234
|
+
return format(template, plural: plural, singular: singular) if template
|
|
235
|
+
return plural if action.to_s.empty?
|
|
236
|
+
|
|
237
|
+
# `post "auth/login", to: "auth#login"`: the action is the page, the
|
|
238
|
+
# controller is only where it lives — "Login", not "Login Auths".
|
|
239
|
+
return Support.titleize(action) if singular_resource? && action_in_path?
|
|
240
|
+
|
|
241
|
+
"#{Support.titleize(action)} #{member? || singular_resource? ? singular : plural}".strip
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# A controller named for one thing (auth, home, health) rather than a
|
|
245
|
+
# collection (customers).
|
|
246
|
+
def singular_resource?
|
|
247
|
+
Support.pluralize(resource.to_s) != resource.to_s
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def action_in_path?
|
|
251
|
+
required_path.split("/").last.to_s == action.to_s
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Whether the path itself names the resource (/customers, /cart), as
|
|
255
|
+
# opposed to a route like `/` or `/dashboard` served by some controller.
|
|
256
|
+
def resource_in_path?
|
|
257
|
+
static = required_path.split("/").reject { |segment| segment.empty? || segment.start_with?(":", "*") }
|
|
258
|
+
names = [resource.to_s, Support.singularize(resource.to_s)]
|
|
259
|
+
|
|
260
|
+
static.any? { |segment| names.include?(segment) }
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
module Exporters
|
|
5
|
+
# Writes one runnable .sh file per covered endpoint. The command is always
|
|
6
|
+
# rendered from the endpoint's successful captured request when available.
|
|
7
|
+
class Curl
|
|
8
|
+
def self.call(dataset:, output_dir:, configuration:)
|
|
9
|
+
new(
|
|
10
|
+
dataset: dataset,
|
|
11
|
+
output_dir: output_dir,
|
|
12
|
+
configuration: configuration
|
|
13
|
+
).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(dataset:, output_dir:, configuration:)
|
|
17
|
+
@dataset = dataset
|
|
18
|
+
@output_dir = Pathname(output_dir).join("curl")
|
|
19
|
+
@configuration = configuration
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
FileUtils.mkdir_p(output_dir)
|
|
24
|
+
|
|
25
|
+
dataset.resources.flat_map do |resource|
|
|
26
|
+
basenames = resource.file_basenames
|
|
27
|
+
|
|
28
|
+
resource.endpoints.filter_map do |endpoint|
|
|
29
|
+
next unless endpoint.curl_ready?
|
|
30
|
+
|
|
31
|
+
example = endpoint.primary_request_example
|
|
32
|
+
|
|
33
|
+
directory = output_dir.join(resource.slug)
|
|
34
|
+
FileUtils.mkdir_p(directory)
|
|
35
|
+
|
|
36
|
+
path = directory.join("#{basenames.fetch(endpoint)}.sh")
|
|
37
|
+
File.write(path, script(endpoint, example))
|
|
38
|
+
File.chmod(0o755, path)
|
|
39
|
+
|
|
40
|
+
path.to_s
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :dataset, :output_dir, :configuration
|
|
48
|
+
|
|
49
|
+
def script(endpoint, example)
|
|
50
|
+
command = Renderers::Curl.call(
|
|
51
|
+
example,
|
|
52
|
+
base_url: configuration.base_url
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
<<~SH
|
|
56
|
+
#!/usr/bin/env bash
|
|
57
|
+
set -euo pipefail
|
|
58
|
+
|
|
59
|
+
# #{endpoint.name}
|
|
60
|
+
# #{endpoint.method} #{endpoint.path}
|
|
61
|
+
#{command}
|
|
62
|
+
SH
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
register("curl", Curl)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reqcord
|
|
4
|
+
module Exporters
|
|
5
|
+
class Markdown
|
|
6
|
+
def self.call(dataset:, output_dir:, configuration:)
|
|
7
|
+
new(
|
|
8
|
+
dataset: dataset,
|
|
9
|
+
output_dir: output_dir,
|
|
10
|
+
configuration: configuration
|
|
11
|
+
).call
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(dataset:, output_dir:, configuration:)
|
|
15
|
+
@dataset = dataset
|
|
16
|
+
@output_dir = Pathname(output_dir)
|
|
17
|
+
@configuration = configuration
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns every written path.
|
|
21
|
+
def call
|
|
22
|
+
FileUtils.mkdir_p(output_dir)
|
|
23
|
+
|
|
24
|
+
written = [write(output_dir.join("README.md"), index_document)]
|
|
25
|
+
|
|
26
|
+
written_resources.each do |resource|
|
|
27
|
+
directory = output_dir.join(resource.slug)
|
|
28
|
+
|
|
29
|
+
FileUtils.mkdir_p(directory)
|
|
30
|
+
|
|
31
|
+
written << write(
|
|
32
|
+
directory.join("index.md"),
|
|
33
|
+
resource_document(resource)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
filenames(resource).each do |endpoint, filename|
|
|
37
|
+
next unless documented?(endpoint)
|
|
38
|
+
|
|
39
|
+
written << write(
|
|
40
|
+
directory.join(filename),
|
|
41
|
+
endpoint_document(resource, endpoint)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
written
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :dataset, :output_dir, :configuration
|
|
52
|
+
|
|
53
|
+
# An endpoint gets a page when a test reached it, or when the project
|
|
54
|
+
# asked for the gaps to be written out too.
|
|
55
|
+
def documented?(endpoint)
|
|
56
|
+
endpoint.curl_ready? || configuration.include_uncovered?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A resource with nothing to show gets no directory, so the index never
|
|
60
|
+
# links to a page that was not written.
|
|
61
|
+
def written_resources
|
|
62
|
+
@written_resources ||= dataset.resources.select do |resource|
|
|
63
|
+
resource.endpoints.any? { |endpoint| documented?(endpoint) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def written?(resource)
|
|
68
|
+
written_resources.include?(resource)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def write(path, content)
|
|
72
|
+
File.write(path, content)
|
|
73
|
+
|
|
74
|
+
path.to_s
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def filenames(resource)
|
|
78
|
+
resource.file_basenames.transform_values { |basename| "#{basename}.md" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def index_document
|
|
82
|
+
lines = ["# API Documentation", ""]
|
|
83
|
+
lines << "Generated by Reqcord from the application's integration tests."
|
|
84
|
+
lines << ""
|
|
85
|
+
lines << "Base URL: `#{configuration.base_url}`"
|
|
86
|
+
lines << ""
|
|
87
|
+
lines.concat(placeholders_section)
|
|
88
|
+
|
|
89
|
+
dataset.resources.each do |resource|
|
|
90
|
+
lines << if written?(resource)
|
|
91
|
+
"## [#{resource.title}](#{resource.slug}/index.md)"
|
|
92
|
+
else
|
|
93
|
+
"## #{resource.title}"
|
|
94
|
+
end
|
|
95
|
+
lines << ""
|
|
96
|
+
lines.concat(namespace_line(resource))
|
|
97
|
+
lines.concat(endpoint_table(resource, prefix: "#{resource.slug}/"))
|
|
98
|
+
lines << ""
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
undocumented = dataset.endpoints.reject(&:curl_ready?)
|
|
102
|
+
|
|
103
|
+
unless undocumented.empty?
|
|
104
|
+
lines << "## No Successful Request Captured"
|
|
105
|
+
lines << ""
|
|
106
|
+
lines << "No successful 2xx request was captured for these routes:"
|
|
107
|
+
lines << ""
|
|
108
|
+
undocumented.each { |endpoint| lines << "- `#{endpoint.method} #{endpoint.path}`" }
|
|
109
|
+
lines << ""
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
lines.join("\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Sanitized values are written as placeholders; the reader has to know
|
|
116
|
+
# what to substitute.
|
|
117
|
+
def placeholders_section
|
|
118
|
+
placeholders = configuration.sanitized_headers.values.grep(/\{\{.+\}\}/)
|
|
119
|
+
|
|
120
|
+
return [] if placeholders.empty?
|
|
121
|
+
|
|
122
|
+
rows = configuration.sanitized_headers.filter_map do |header, value|
|
|
123
|
+
"| #{header} | `#{value}` |" if value.to_s.match?(/\{\{.+\}\}/)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
["## Placeholders", "", "| Header | Replace with |", "| --- | --- |", *rows, ""]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Two resources can share a title (admin/customers, api/v2/customers);
|
|
130
|
+
# the namespace tells them apart.
|
|
131
|
+
def namespace_line(resource)
|
|
132
|
+
return [] if resource.namespace.empty?
|
|
133
|
+
|
|
134
|
+
["Namespace: `#{resource.namespace}`", ""]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def resource_document(resource)
|
|
138
|
+
lines = ["# #{resource.title}", ""]
|
|
139
|
+
lines.concat(namespace_line(resource))
|
|
140
|
+
|
|
141
|
+
versions = resource.api_versions
|
|
142
|
+
|
|
143
|
+
unless versions.empty?
|
|
144
|
+
lines << "API version: #{versions.map { |version| "`#{version}`" }.join(', ')}"
|
|
145
|
+
lines << ""
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
lines.concat(endpoint_table(resource))
|
|
149
|
+
lines << ""
|
|
150
|
+
lines.join("\n")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def endpoint_table(resource, prefix: "")
|
|
154
|
+
rows = filenames(resource).map do |endpoint, filename|
|
|
155
|
+
path =
|
|
156
|
+
if documented?(endpoint)
|
|
157
|
+
"[`#{endpoint.path}`](#{prefix}#{filename})"
|
|
158
|
+
else
|
|
159
|
+
"`#{endpoint.path}`"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
covered = endpoint.curl_ready? ? "" : " _(no successful request captured)_"
|
|
163
|
+
|
|
164
|
+
"| `#{endpoint.method}` | #{path} | #{endpoint.name}#{covered} |"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
["| Method | Path | Description |", "| --- | --- | --- |", *rows]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def endpoint_document(resource, endpoint)
|
|
171
|
+
example = endpoint.primary_request_example
|
|
172
|
+
|
|
173
|
+
lines = ["# #{endpoint.name}", "", endpoint_line(endpoint), ""]
|
|
174
|
+
lines.concat(namespace_line(resource))
|
|
175
|
+
|
|
176
|
+
unless endpoint.curl_ready?
|
|
177
|
+
lines << "_No successful 2xx request was captured for this endpoint yet._"
|
|
178
|
+
lines << ""
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
lines.concat(headers_section(example))
|
|
182
|
+
lines.concat(schema_section("Path Parameters", endpoint.path_param_schema))
|
|
183
|
+
lines.concat(schema_section("Query Parameters", endpoint.query_schema))
|
|
184
|
+
lines.concat(schema_section("Body Parameters", endpoint.body_schema))
|
|
185
|
+
lines.concat(request_body_section(example))
|
|
186
|
+
lines.concat(curl_section(example))
|
|
187
|
+
lines.concat(responses_section(endpoint))
|
|
188
|
+
lines.concat(["---", "", "Resource: [#{resource.title}](index.md)", ""])
|
|
189
|
+
|
|
190
|
+
lines.join("\n")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Field, type, whether every accepted request carried it, and the values
|
|
194
|
+
# those requests used. Values are listed only when they look like a
|
|
195
|
+
# closed set; otherwise one of them stands as an example.
|
|
196
|
+
def schema_section(title, schema, level: 2)
|
|
197
|
+
return [] if schema.empty?
|
|
198
|
+
|
|
199
|
+
rows = schema.map do |field|
|
|
200
|
+
"| `#{field.path}` | #{field.type} | #{field.required? ? 'yes' : 'no'} | #{values_cell(field)} |"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
["#{'#' * level} #{title}", "", "| Field | Type | Required | Values |", "| --- | --- | --- | --- |", *rows, ""]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def values_cell(field)
|
|
207
|
+
return "-" if field.values.empty?
|
|
208
|
+
return field.listed_values.map { |value| "`#{value.inspect}`" }.join(" \\| ") if field.enum?
|
|
209
|
+
|
|
210
|
+
"`#{field.example.inspect}`"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# `PATCH /api/cart` (also `PUT`)
|
|
214
|
+
def endpoint_line(endpoint)
|
|
215
|
+
line = "`#{endpoint.method} #{endpoint.path}`"
|
|
216
|
+
|
|
217
|
+
return line if endpoint.also_methods.empty?
|
|
218
|
+
|
|
219
|
+
"#{line} (also #{endpoint.also_methods.map { |method| "`#{method}`" }.join(', ')})"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def headers_section(example)
|
|
223
|
+
return [] if example.nil? || example.headers.empty?
|
|
224
|
+
|
|
225
|
+
rows = example.headers.map { |name, value| "| #{name} | `#{value}` |" }
|
|
226
|
+
|
|
227
|
+
["## Headers", "", "| Header | Value |", "| --- | --- |", *rows, ""]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A form body is shown as the key/value structure the test built, which
|
|
231
|
+
# reads better than the encoded string; the note says how it travels.
|
|
232
|
+
def request_body_section(example)
|
|
233
|
+
return [] unless example&.body?
|
|
234
|
+
|
|
235
|
+
lines = ["## Example Request", ""]
|
|
236
|
+
|
|
237
|
+
unless Renderers::Payload.json?(example) || example.content_type.to_s.empty?
|
|
238
|
+
lines << "Sent as `#{example.content_type}`; the cURL below carries it in that encoding."
|
|
239
|
+
lines << ""
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
lines.concat([*code_block(example.body, example.content_type), ""])
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def curl_section(example)
|
|
246
|
+
return [] if example.nil?
|
|
247
|
+
|
|
248
|
+
["## cURL", "", "```bash", curl_for(example), "```", ""]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def responses_section(endpoint)
|
|
252
|
+
return [] if endpoint.response_examples.empty?
|
|
253
|
+
|
|
254
|
+
lines = ["## Responses", ""]
|
|
255
|
+
|
|
256
|
+
endpoint.responses.each do |response|
|
|
257
|
+
lines << "### #{response.example.title}"
|
|
258
|
+
lines << ""
|
|
259
|
+
lines.concat(schema_section("Fields", response.schema, level: 4))
|
|
260
|
+
lines.concat(response_body(response.example))
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
lines
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def response_body(example)
|
|
267
|
+
return ["_No response body._", ""] unless example.body?
|
|
268
|
+
|
|
269
|
+
[*code_block(example.body, example.content_type), ""]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def curl_for(example)
|
|
273
|
+
Renderers::Curl.call(
|
|
274
|
+
example,
|
|
275
|
+
base_url: configuration.base_url
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def code_block(value, content_type)
|
|
280
|
+
case value
|
|
281
|
+
when Hash, Array then ["```json", pretty_json(value), "```"]
|
|
282
|
+
else ["```#{content_type.to_s.include?('json') ? 'json' : 'text'}", value.to_s, "```"]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def pretty_json(value)
|
|
287
|
+
JSON.pretty_generate(value)
|
|
288
|
+
rescue JSON::GeneratorError
|
|
289
|
+
value.to_s
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
register("markdown", Markdown)
|
|
294
|
+
end
|
|
295
|
+
end
|