rails-api-docs 0.1.1
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 +7 -0
- data/CHANGELOG.md +156 -0
- data/LICENSE.txt +21 -0
- data/README.md +552 -0
- data/app/controllers/rails_api_docs/docs_controller.rb +20 -0
- data/config/routes.rb +5 -0
- data/lib/generators/rails-api-docs/init/init_generator.rb +167 -0
- data/lib/generators/rails-api-docs/update/update_generator.rb +28 -0
- data/lib/rails-api-docs/config/appender.rb +98 -0
- data/lib/rails-api-docs/config/builder.rb +175 -0
- data/lib/rails-api-docs/config/loader.rb +30 -0
- data/lib/rails-api-docs/configuration.rb +64 -0
- data/lib/rails-api-docs/doc/curl_renderer.rb +120 -0
- data/lib/rails-api-docs/doc/file_builder.rb +36 -0
- data/lib/rails-api-docs/doc/renderer.rb +228 -0
- data/lib/rails-api-docs/doc/responder.rb +55 -0
- data/lib/rails-api-docs/engine.rb +27 -0
- data/lib/rails-api-docs/inspectors/body_inferrer.rb +88 -0
- data/lib/rails-api-docs/inspectors/controller_inspector.rb +78 -0
- data/lib/rails-api-docs/inspectors/json_route_detector.rb +189 -0
- data/lib/rails-api-docs/inspectors/route_inspector.rb +126 -0
- data/lib/rails-api-docs/inspectors/schema_inspector.rb +36 -0
- data/lib/rails-api-docs/sample_value.rb +28 -0
- data/lib/rails-api-docs/tasks/rails-api-docs.rake +25 -0
- data/lib/rails-api-docs/templates/api_docs.html.erb +695 -0
- data/lib/rails-api-docs/version.rb +5 -0
- data/lib/rails-api-docs.rb +21 -0
- metadata +159 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module RailsApiDocs
|
|
6
|
+
module Doc
|
|
7
|
+
# Reads the YAML config and writes the rendered HTML to disk.
|
|
8
|
+
# Used by `rake rails-api-docs:build`. Extracted as a service so the
|
|
9
|
+
# rake task body can stay tiny and the build flow is unit-testable.
|
|
10
|
+
class FileBuilder
|
|
11
|
+
class MissingConfigError < StandardError; end
|
|
12
|
+
|
|
13
|
+
def initialize(config_path:, output_path:)
|
|
14
|
+
@config_path = config_path
|
|
15
|
+
@output_path = output_path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Writes the HTML and returns the absolute output path.
|
|
19
|
+
def call
|
|
20
|
+
unless File.exist?(@config_path)
|
|
21
|
+
raise MissingConfigError,
|
|
22
|
+
"Config file not found at #{@config_path}. " \
|
|
23
|
+
"Run `rails g rails-api-docs:init` first."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
config = Config::Loader.load(@config_path)
|
|
27
|
+
html = Renderer.new(config).call
|
|
28
|
+
|
|
29
|
+
FileUtils.mkdir_p(File.dirname(@output_path))
|
|
30
|
+
File.write(@output_path, html)
|
|
31
|
+
|
|
32
|
+
@output_path
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RailsApiDocs
|
|
7
|
+
module Doc
|
|
8
|
+
# Turns the parsed YAML config into the final self-contained HTML
|
|
9
|
+
# document. Used by both the rake task (writes to public/api-docs.html)
|
|
10
|
+
# and the dev controller mounted at /rails/api-docs.
|
|
11
|
+
#
|
|
12
|
+
# The output is a single HTML string with CSS and JS inlined — no
|
|
13
|
+
# external assets, no asset pipeline required.
|
|
14
|
+
class Renderer
|
|
15
|
+
TEMPLATE_PATH = File.expand_path("../templates/api_docs.html.erb", __dir__)
|
|
16
|
+
|
|
17
|
+
DEFAULT_GENERAL = {
|
|
18
|
+
"title" => "API Documentation",
|
|
19
|
+
"base_url" => "https://api.example.com",
|
|
20
|
+
"primary_color" => "#CC0000",
|
|
21
|
+
"secondary_color" => "#2E2E2E",
|
|
22
|
+
"accent_color" => "#D30001",
|
|
23
|
+
"font_family" => "system-ui, -apple-system, sans-serif",
|
|
24
|
+
"show_curl" => true,
|
|
25
|
+
"show_examples" => true
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def initialize(config)
|
|
29
|
+
@config = config || {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call
|
|
33
|
+
ERB.new(File.read(TEMPLATE_PATH), trim_mode: "-").result(binding)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# ===== template helpers — public so ERB binding can reach them =====
|
|
37
|
+
|
|
38
|
+
def general
|
|
39
|
+
@general ||= DEFAULT_GENERAL.merge(@config["general_configurations"] || {})
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def visible_sections
|
|
43
|
+
@visible_sections ||= (@config["sections"] || {}).each_with_object({}) do |(key, section), acc|
|
|
44
|
+
next if section["show"] == false
|
|
45
|
+
|
|
46
|
+
endpoints = (section["endpoints"] || []).reject { |e| e["show"] == false }
|
|
47
|
+
next if endpoints.empty?
|
|
48
|
+
|
|
49
|
+
acc[key] = section.merge("endpoints" => endpoints)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def empty?
|
|
54
|
+
visible_sections.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def h(value)
|
|
58
|
+
ERB::Util.html_escape(value.to_s)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def endpoint_id(section_key, endpoint)
|
|
62
|
+
method = endpoint["method"].to_s.downcase
|
|
63
|
+
path = endpoint["path"].to_s.gsub(/[^a-z0-9]+/i, "-").gsub(/^-+|-+$/, "")
|
|
64
|
+
"#{section_slug(section_key)}--#{method}--#{path}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def section_slug(key)
|
|
68
|
+
key.to_s.gsub(/[^a-z0-9]+/i, "-")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def verb_class(method)
|
|
72
|
+
"verb-#{method.to_s.downcase}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def verb_label(method)
|
|
76
|
+
method.to_s.upcase == "DELETE" ? "DEL" : method.to_s.upcase
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def first_endpoint_id
|
|
80
|
+
visible_sections.each do |key, section|
|
|
81
|
+
return endpoint_id(key, section["endpoints"].first)
|
|
82
|
+
end
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def curl_for(endpoint)
|
|
87
|
+
CurlRenderer.new(endpoint, base_url: general["base_url"]).call
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns an array of { status:, description:, example:, headers:, schema: }
|
|
91
|
+
# always in YAML insertion order. If the user defined `responses:` in
|
|
92
|
+
# the YAML we honor it verbatim; otherwise we synthesize a single
|
|
93
|
+
# "200" entry from `response_example` or the inferred body sample.
|
|
94
|
+
def responses_for(endpoint)
|
|
95
|
+
if endpoint["responses"].is_a?(Hash) && !endpoint["responses"].empty?
|
|
96
|
+
endpoint["responses"].map do |status, resp|
|
|
97
|
+
resp ||= {}
|
|
98
|
+
{
|
|
99
|
+
"status" => status.to_s,
|
|
100
|
+
"description" => resp["description"].to_s,
|
|
101
|
+
"example" => resp["example"].to_s,
|
|
102
|
+
"headers" => Array(resp["headers"]),
|
|
103
|
+
"schema" => Array(resp["schema"])
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
[{
|
|
108
|
+
"status" => "200",
|
|
109
|
+
"description" => "",
|
|
110
|
+
"example" => default_response_example(endpoint),
|
|
111
|
+
"headers" => [],
|
|
112
|
+
"schema" => []
|
|
113
|
+
}]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def body_present?(endpoint)
|
|
118
|
+
endpoint["body"].is_a?(Array) && !endpoint["body"].empty?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def headers_present?(endpoint)
|
|
122
|
+
endpoint["headers"].is_a?(Array) && !endpoint["headers"].empty?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def params_present?(endpoint)
|
|
126
|
+
endpoint["params"].is_a?(Array) && !endpoint["params"].empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def responses_have_details?(endpoint)
|
|
130
|
+
responses_for(endpoint).any? do |r|
|
|
131
|
+
!r["headers"].empty? || !r["schema"].empty? || !r["description"].empty?
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Returns the `data-endpoint-tags='[...]'` attribute (with leading
|
|
136
|
+
# space) for a sidebar <li>, or an empty string when the endpoint
|
|
137
|
+
# has no tags. JSON encoding is intentional — robust against tag
|
|
138
|
+
# values that contain spaces or special characters.
|
|
139
|
+
def sidebar_tags_attr(endpoint)
|
|
140
|
+
tags = Array(endpoint["tags"])
|
|
141
|
+
return "" if tags.empty?
|
|
142
|
+
%( data-endpoint-tags='#{h(tags.to_json)}')
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def auth_label(auth)
|
|
146
|
+
case auth.to_s.downcase
|
|
147
|
+
when "bearer" then "Bearer auth"
|
|
148
|
+
when "basic" then "Basic auth"
|
|
149
|
+
when "none" then "No auth"
|
|
150
|
+
else auth.to_s
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Renders a single field row with all supported attributes. Used
|
|
155
|
+
# uniformly by body, params, headers, and response schema — one
|
|
156
|
+
# source of truth for badge rendering.
|
|
157
|
+
def render_field(field)
|
|
158
|
+
parts = []
|
|
159
|
+
parts << %(<div class="field">)
|
|
160
|
+
parts << %(<div class="field-row">)
|
|
161
|
+
parts << %(<span class="field-name">#{h(field["name"])}</span>)
|
|
162
|
+
parts << %(<span class="field-type">#{h(field["type"])}</span>) if field["type"]
|
|
163
|
+
parts << %(<span class="field-badge format">#{h(field["format"])}</span>) if field["format"]
|
|
164
|
+
parts << %(<span class="field-badge in-path">#{h(field["in"])}</span>) if field["in"]
|
|
165
|
+
parts << %(<span class="field-badge readonly">read-only</span>) if field["read_only"]
|
|
166
|
+
parts << %(<span class="field-badge writeonly">write-only</span>) if field["write_only"]
|
|
167
|
+
parts << %(<span class="field-badge nullable">nullable</span>) if field["nullable"]
|
|
168
|
+
parts << %(<span class="field-badge required">Required</span>) if field["required"]
|
|
169
|
+
|
|
170
|
+
min = field["min"] || field["min_length"]
|
|
171
|
+
max = field["max"] || field["max_length"]
|
|
172
|
+
parts << %(<span class="field-meta">min: #{h(min)}</span>) if min
|
|
173
|
+
parts << %(<span class="field-meta">max: #{h(max)}</span>) if max
|
|
174
|
+
parts << %(<span class="field-meta">default: #{h(field["default"])}</span>) unless field["default"].nil?
|
|
175
|
+
parts << %(<span class="field-meta mono">pattern: #{h(field["pattern"])}</span>) if field["pattern"]
|
|
176
|
+
parts << %(</div>)
|
|
177
|
+
|
|
178
|
+
if field["enum"].is_a?(Array) && !field["enum"].empty?
|
|
179
|
+
values = field["enum"].map { |v| %(<code>#{h(v)}</code>) }.join(" · ")
|
|
180
|
+
parts << %(<div class="field-enum">one of: #{values}</div>)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
parts << %(<div class="field-desc">#{h(field["description"])}</div>) if field["description"] && !field["description"].to_s.empty?
|
|
184
|
+
parts << %(<div class="field-example">Example: <code>#{h(field["example"])}</code></div>) unless field["example"].nil?
|
|
185
|
+
|
|
186
|
+
parts << %(</div>)
|
|
187
|
+
parts.join("\n")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Helper: render an array of fields as joined HTML. Use from the
|
|
191
|
+
# template like `<%= render_fields(endpoint["body"]) -%>`.
|
|
192
|
+
def render_fields(fields)
|
|
193
|
+
Array(fields).map { |f| render_field(f) }.join("\n")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def status_class(code)
|
|
197
|
+
case code.to_s[0]
|
|
198
|
+
when "2" then "status-2xx"
|
|
199
|
+
when "3" then "status-3xx"
|
|
200
|
+
when "4" then "status-4xx"
|
|
201
|
+
when "5" then "status-5xx"
|
|
202
|
+
else ""
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
def default_response_example(endpoint)
|
|
209
|
+
return endpoint["response_example"].to_s if endpoint["response_example"]
|
|
210
|
+
|
|
211
|
+
if endpoint["body"]
|
|
212
|
+
sample = endpoint["body"].each_with_object("id" => 1) do |field, acc|
|
|
213
|
+
# User-provided example wins over the type-derived sample.
|
|
214
|
+
acc[field["name"]] = field.key?("example") && !field["example"].nil? ?
|
|
215
|
+
field["example"] : sample_value(field["type"])
|
|
216
|
+
end
|
|
217
|
+
JSON.pretty_generate(sample)
|
|
218
|
+
else
|
|
219
|
+
"{}"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def sample_value(type)
|
|
224
|
+
RailsApiDocs::SampleValue.for(type)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsApiDocs
|
|
4
|
+
module Doc
|
|
5
|
+
# Pure-Ruby responder that decides what to serve at /rails/api-docs.
|
|
6
|
+
# Sits between the Rails controller and the Renderer so that the
|
|
7
|
+
# decision logic (file present? render fresh; file missing? show setup
|
|
8
|
+
# page) can be unit-tested without booting a Rails app.
|
|
9
|
+
class Responder
|
|
10
|
+
def initialize(config_path:)
|
|
11
|
+
@config_path = config_path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Returns [status, html_string].
|
|
15
|
+
def render
|
|
16
|
+
if File.exist?(@config_path)
|
|
17
|
+
html = Doc::Renderer.new(Config::Loader.load(@config_path)).call
|
|
18
|
+
[200, html]
|
|
19
|
+
else
|
|
20
|
+
[404, missing_config_page]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def missing_config_page
|
|
27
|
+
<<~HTML
|
|
28
|
+
<!DOCTYPE html>
|
|
29
|
+
<html lang="en">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="UTF-8">
|
|
32
|
+
<title>rails-api-docs · setup needed</title>
|
|
33
|
+
<style>
|
|
34
|
+
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 640px; margin: 80px auto; padding: 0 24px; color: #1F1F1F; line-height: 1.55; }
|
|
35
|
+
h1 { color: #CC0000; font-size: 24px; margin-bottom: 16px; letter-spacing: -0.01em; }
|
|
36
|
+
p { margin: 12px 0; color: #4B5563; }
|
|
37
|
+
code { background: #F4F4F4; padding: 2px 6px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; color: #1F1F1F; }
|
|
38
|
+
pre { background: #1B1B1F; color: #E5E5E5; padding: 16px 18px; border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; margin: 20px 0; overflow-x: auto; }
|
|
39
|
+
.hint { color: #6B7280; font-size: 13px; margin-top: 28px; }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<h1>rails-api-docs is installed — but the config file isn't there yet.</h1>
|
|
44
|
+
<p>Expected to find <code>#{ERB::Util.html_escape(@config_path)}</code>.</p>
|
|
45
|
+
<p>Run this in your terminal to generate it from your routes:</p>
|
|
46
|
+
<pre>$ rails g rails-api-docs:init</pre>
|
|
47
|
+
<p>Then edit the YAML to add descriptions / examples / body fields, and reload this page.</p>
|
|
48
|
+
<p class="hint">This page is only mounted in <code>development</code>. The generated <code>public/api-docs.html</code> is what gets served everywhere else — see <code>rake rails-api-docs:build</code>.</p>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
|
51
|
+
HTML
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module RailsApiDocs
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace RailsApiDocs
|
|
8
|
+
|
|
9
|
+
rake_tasks do
|
|
10
|
+
load File.expand_path("tasks/rails-api-docs.rake", __dir__)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
generators do
|
|
14
|
+
require_relative "../generators/rails-api-docs/init/init_generator"
|
|
15
|
+
require_relative "../generators/rails-api-docs/update/update_generator"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "rails_api_docs.mount" do |app|
|
|
19
|
+
config = RailsApiDocs.configuration
|
|
20
|
+
if config.mount_in_development && Rails.env.development?
|
|
21
|
+
app.routes.append do
|
|
22
|
+
mount RailsApiDocs::Engine, at: config.mount_path, as: :rails_api_docs
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsApiDocs
|
|
4
|
+
module Inspectors
|
|
5
|
+
# Composes ControllerInspector + SchemaInspector to produce a typed body
|
|
6
|
+
# field list for write actions (create / update).
|
|
7
|
+
#
|
|
8
|
+
# Returned shape per call:
|
|
9
|
+
#
|
|
10
|
+
# [
|
|
11
|
+
# { "name" => "email", "type" => "string", "required" => true },
|
|
12
|
+
# { "name" => "age", "type" => "integer", "required" => false }
|
|
13
|
+
# ]
|
|
14
|
+
#
|
|
15
|
+
# Returns `nil` (not an empty array) when there's nothing useful to add —
|
|
16
|
+
# the Builder uses that to decide whether to emit a `body:` key at all.
|
|
17
|
+
class BodyInferrer
|
|
18
|
+
WRITE_ACTIONS = %w[create update].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(root: nil, controller_inspector: nil, schema_inspector: nil, verbose: false)
|
|
21
|
+
@root = root
|
|
22
|
+
@controller_inspector_class = controller_inspector || ControllerInspector
|
|
23
|
+
@schema_inspector_class = schema_inspector || SchemaInspector
|
|
24
|
+
@verbose = verbose
|
|
25
|
+
@cache = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(controller:, action:)
|
|
29
|
+
return nil unless WRITE_ACTIONS.include?(action.to_s)
|
|
30
|
+
|
|
31
|
+
permitted = controller_permits(controller)
|
|
32
|
+
return nil if permitted.empty?
|
|
33
|
+
|
|
34
|
+
types = column_types(controller)
|
|
35
|
+
|
|
36
|
+
permitted.map { |name| build_field(name, types[name]) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build_field(name, col)
|
|
42
|
+
type = col ? col[:type].to_s : "string"
|
|
43
|
+
required = col ? required?(col) : false
|
|
44
|
+
base = {
|
|
45
|
+
"name" => name,
|
|
46
|
+
"type" => type,
|
|
47
|
+
"required" => required,
|
|
48
|
+
"description" => "",
|
|
49
|
+
"example" => RailsApiDocs::SampleValue.for(type)
|
|
50
|
+
}
|
|
51
|
+
@verbose ? base.merge(verbose_field_defaults) : base
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returned as a fresh hash (and fresh array/value instances inside) so
|
|
55
|
+
# multiple fields don't share references — otherwise `YAML.dump` emits
|
|
56
|
+
# noisy anchors (`enum: &1 []` / `enum: *1`) tying them together.
|
|
57
|
+
def verbose_field_defaults
|
|
58
|
+
{
|
|
59
|
+
"format" => "",
|
|
60
|
+
"enum" => [],
|
|
61
|
+
"default" => nil,
|
|
62
|
+
"min" => nil,
|
|
63
|
+
"max" => nil,
|
|
64
|
+
"min_length" => nil,
|
|
65
|
+
"max_length" => nil,
|
|
66
|
+
"pattern" => "",
|
|
67
|
+
"read_only" => false,
|
|
68
|
+
"write_only" => false,
|
|
69
|
+
"nullable" => false
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def required?(col)
|
|
74
|
+
!col[:null] && col[:default].nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def controller_permits(controller)
|
|
78
|
+
@cache[[:permits, controller]] ||=
|
|
79
|
+
@controller_inspector_class.new(controller: controller, root: @root).call
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def column_types(controller)
|
|
83
|
+
@cache[[:schema, controller]] ||=
|
|
84
|
+
@schema_inspector_class.new(controller: controller).call
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module RailsApiDocs
|
|
6
|
+
module Inspectors
|
|
7
|
+
# Walks a controller file via Prism and returns the list of attribute
|
|
8
|
+
# names declared in any `params.require(:X).permit(...)` (or
|
|
9
|
+
# `params.permit(...)`) call inside the file.
|
|
10
|
+
#
|
|
11
|
+
# This is a deliberately coarse pass — we don't try to map a permit
|
|
12
|
+
# call back to a specific action. In typical Rails controllers strong
|
|
13
|
+
# params live in a single shared `*_params` method used by both create
|
|
14
|
+
# and update, so the flat list of permitted attributes is enough for
|
|
15
|
+
# documentation purposes.
|
|
16
|
+
#
|
|
17
|
+
# Limitations (Phase 5):
|
|
18
|
+
# - Nested permits (`permit(items: [:name])`) — only top-level scalar
|
|
19
|
+
# keys are extracted; the nested array is ignored.
|
|
20
|
+
# - Method-call args inside permit (e.g. `permit(*PERMITTED)`) — not
|
|
21
|
+
# resolved; only literal symbols/strings are picked up.
|
|
22
|
+
class ControllerInspector
|
|
23
|
+
def initialize(controller:, root: nil)
|
|
24
|
+
@controller = controller
|
|
25
|
+
@root = root || (defined?(Rails) && Rails.root ? Rails.root.to_s : ".")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
return [] unless File.exist?(file_path)
|
|
30
|
+
|
|
31
|
+
result = Prism.parse_file(file_path)
|
|
32
|
+
visitor = PermitVisitor.new
|
|
33
|
+
visitor.visit(result.value)
|
|
34
|
+
|
|
35
|
+
visitor.permitted.uniq.map(&:to_s)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def file_path
|
|
39
|
+
File.join(@root, "app/controllers", "#{@controller}_controller.rb")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class PermitVisitor < ::Prism::Visitor
|
|
43
|
+
attr_reader :permitted
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
@permitted = []
|
|
47
|
+
super
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def visit_call_node(node)
|
|
51
|
+
collect_permitted(node) if node.name == :permit
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def collect_permitted(node)
|
|
58
|
+
args = node.arguments&.arguments || []
|
|
59
|
+
args.each do |arg|
|
|
60
|
+
case arg
|
|
61
|
+
when ::Prism::SymbolNode
|
|
62
|
+
@permitted << arg.value
|
|
63
|
+
when ::Prism::StringNode
|
|
64
|
+
@permitted << arg.unescaped
|
|
65
|
+
when ::Prism::KeywordHashNode, ::Prism::HashNode
|
|
66
|
+
# Nested permits — pick up the top-level keys only.
|
|
67
|
+
arg.elements.each do |element|
|
|
68
|
+
next unless element.respond_to?(:key)
|
|
69
|
+
key = element.key
|
|
70
|
+
@permitted << key.value if key.is_a?(::Prism::SymbolNode)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|