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.
@@ -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