rails-api-docs 0.1.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.

Potentially problematic release.


This version of rails-api-docs might be problematic. Click here for more details.

@@ -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
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "set"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ module RailsApiDocs
8
+ module Inspectors
9
+ # Decides whether an endpoint should be considered "JSON-returning" for
10
+ # the `--api-only` filter on the install generator.
11
+ #
12
+ # Decision logic for `call(controller:, action:)`:
13
+ # 1. If the controller's inheritance chain reaches `ActionController::API`
14
+ # (directly, or transitively via ApplicationController / a custom
15
+ # base controller) → true for every action.
16
+ # 2. Else if the action body contains a `render json: …` call (kwarg
17
+ # form or hash-rocket form, including inside `respond_to` blocks)
18
+ # → true.
19
+ # 3. Else → false.
20
+ #
21
+ # `:unknown` classifications (controller file missing, unresolvable
22
+ # parent constant) are strict — they DO NOT count as :api. Per-action
23
+ # render-json detection still runs if the file exists but inheritance
24
+ # is unresolvable.
25
+ #
26
+ # Each controller file is parsed at most once per detector instance —
27
+ # the same Prism AST feeds both visitors, and the result hash is cached
28
+ # so the inheritance chain ApplicationController → ActionController::API
29
+ # is walked once even when 50 controllers inherit from it.
30
+ #
31
+ # Known limitations:
32
+ # - `render json:` inside a helper method called from the action is
33
+ # not detected (we don't follow method calls).
34
+ # - `render template: "x", formats: :json` doesn't have a `json:`
35
+ # key in the call, so it's missed.
36
+ # - Includes (`include SomeRenderingModule`) are not traversed.
37
+ class JsonRouteDetector
38
+ def initialize(root: nil)
39
+ @root = root || (defined?(Rails) && Rails.root ? Rails.root.to_s : ".")
40
+ @cache = {}
41
+ end
42
+
43
+ def call(controller:, action:)
44
+ profile = profile_for(controller)
45
+ return true if profile[:type] == :api
46
+ profile[:json_actions].include?(action.to_s)
47
+ end
48
+
49
+ # Exposed for testing / debugging.
50
+ def profile_for(controller)
51
+ @cache[controller] ||= analyze(controller)
52
+ end
53
+
54
+ private
55
+
56
+ def analyze(controller)
57
+ file = controller_path(controller)
58
+ return blank_profile unless File.exist?(file)
59
+
60
+ ast = ::Prism.parse_file(file).value
61
+
62
+ sc_visitor = SuperclassVisitor.new
63
+ sc_visitor.visit(ast)
64
+
65
+ ja_visitor = JsonActionVisitor.new
66
+ ja_visitor.visit(ast)
67
+
68
+ {
69
+ type: classify(sc_visitor.superclass),
70
+ json_actions: ja_visitor.actions
71
+ }
72
+ end
73
+
74
+ def classify(parent_name)
75
+ return :unknown unless parent_name
76
+
77
+ case parent_name
78
+ when "ActionController::API" then :api
79
+ when "ActionController::Base" then :html
80
+ else
81
+ parent_controller = parent_name.underscore.sub(/_controller\z/, "")
82
+ # Guard against pathological cycles (e.g. file that references
83
+ # itself somehow). The cache hit on second visit makes the loop
84
+ # short-circuit because we'd recurse into ourselves and read
85
+ # back the same in-progress entry — but to be safe, mark visited.
86
+ return :unknown if @cache.key?(parent_controller)
87
+
88
+ @cache[parent_controller] = blank_profile # placeholder to break cycles
89
+ @cache[parent_controller] = analyze(parent_controller)
90
+ @cache[parent_controller][:type]
91
+ end
92
+ end
93
+
94
+ def blank_profile
95
+ { type: :unknown, json_actions: [] }
96
+ end
97
+
98
+ def controller_path(controller)
99
+ File.join(@root, "app/controllers", "#{controller}_controller.rb")
100
+ end
101
+
102
+ # ============================================================
103
+ # Visitors
104
+ # ============================================================
105
+
106
+ # Captures the superclass name from the FIRST class definition in
107
+ # the file. Handles both simple constants (`ApplicationController`)
108
+ # and qualified ones (`ActionController::API`, `Api::V1::BaseController`).
109
+ class SuperclassVisitor < ::Prism::Visitor
110
+ attr_reader :superclass
111
+
112
+ def visit_class_node(node)
113
+ @superclass ||= constant_name(node.superclass)
114
+ super
115
+ end
116
+
117
+ private
118
+
119
+ def constant_name(node)
120
+ case node
121
+ when nil
122
+ nil
123
+ when ::Prism::ConstantReadNode
124
+ node.name.to_s
125
+ when ::Prism::ConstantPathNode
126
+ parts = []
127
+ walk = node
128
+ while walk.is_a?(::Prism::ConstantPathNode)
129
+ parts.unshift(walk.name.to_s)
130
+ walk = walk.parent
131
+ end
132
+ parts.unshift(walk.name.to_s) if walk.is_a?(::Prism::ConstantReadNode)
133
+ parts.join("::")
134
+ end
135
+ end
136
+ end
137
+
138
+ # Walks every method definition and records the names of methods that
139
+ # contain a `render` call with a `json:` keyword. Nested calls inside
140
+ # `respond_to do |format| format.json { render json: … } end` are
141
+ # caught because `super` recurses into child nodes.
142
+ class JsonActionVisitor < ::Prism::Visitor
143
+ def initialize
144
+ @actions = Set.new
145
+ @current_method = nil
146
+ super
147
+ end
148
+
149
+ def actions
150
+ @actions.to_a
151
+ end
152
+
153
+ def visit_def_node(node)
154
+ prev = @current_method
155
+ @current_method = node.name.to_s
156
+ super
157
+ @current_method = prev
158
+ end
159
+
160
+ def visit_call_node(node)
161
+ if @current_method && node.name == :render && renders_json?(node)
162
+ @actions.add(@current_method)
163
+ end
164
+ super
165
+ end
166
+
167
+ private
168
+
169
+ def renders_json?(call_node)
170
+ args = call_node.arguments&.arguments || []
171
+ args.any? { |arg| has_json_key?(arg) }
172
+ end
173
+
174
+ def has_json_key?(node)
175
+ case node
176
+ when ::Prism::KeywordHashNode, ::Prism::HashNode
177
+ node.elements.any? do |elem|
178
+ next false unless elem.respond_to?(:key)
179
+ key = elem.key
180
+ key.is_a?(::Prism::SymbolNode) && key.value.to_s == "json"
181
+ end
182
+ else
183
+ false
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsApiDocs
4
+ module Inspectors
5
+ # Walks a Rails route set and produces a normalized array of hashes
6
+ # describing each user-facing route:
7
+ #
8
+ # { verb:, path:, controller:, action:, name:, path_params: }
9
+ #
10
+ # Internal Rails routes (mailers, conductor, active_storage, etc.) are
11
+ # skipped. Routes without a resolvable controller/action are skipped.
12
+ class RouteInspector
13
+ INTERNAL_PREFIXES = %w[
14
+ /rails/info
15
+ /rails/conductor
16
+ /rails/mailers
17
+ /rails/active_storage
18
+ /rails/action_mailbox
19
+ /action_cable
20
+ /assets
21
+ ].freeze
22
+
23
+ def initialize(route_set: nil, config: nil)
24
+ @route_set = route_set || (defined?(Rails) && Rails.application&.routes)
25
+ @config = config || (defined?(RailsApiDocs) ? RailsApiDocs.configuration : nil)
26
+ raise ArgumentError, "no route set available" unless @route_set
27
+ end
28
+
29
+ def call
30
+ @route_set.routes.flat_map { |route| extract(route) }.compact
31
+ end
32
+
33
+ private
34
+
35
+ def extract(route)
36
+ controller = route.defaults[:controller]
37
+ action = route.defaults[:action]
38
+ return [] unless controller && action
39
+ return [] if ignored_controller?(controller.to_s)
40
+ return [] if only_controllers_active? && !only_controllers_match?(controller.to_s)
41
+ return [] if ignored_action?(action.to_s)
42
+
43
+ path = normalize_path(route.path.spec.to_s)
44
+ return [] if internal?(path) || user_ignored?(path)
45
+
46
+ verbs = verbs_for(route)
47
+ return [] if verbs.empty?
48
+
49
+ verbs.map do |verb|
50
+ {
51
+ verb: verb,
52
+ path: path,
53
+ controller: controller.to_s,
54
+ action: action.to_s,
55
+ name: route.name,
56
+ path_params: extract_path_params(path)
57
+ }
58
+ end
59
+ end
60
+
61
+ def ignored_controller?(controller)
62
+ Array(@config&.ignored_controllers).any? { |pattern| controller_matches?(pattern, controller) }
63
+ end
64
+
65
+ def only_controllers_active?
66
+ Array(@config&.only_controllers).any?
67
+ end
68
+
69
+ def only_controllers_match?(controller)
70
+ Array(@config&.only_controllers).any? { |pattern| controller_matches?(pattern, controller) }
71
+ end
72
+
73
+ # Shared rule for matching a controller path against a user-supplied
74
+ # pattern. Used by both the blacklist (ignored_controllers) and the
75
+ # whitelist (only_controllers) so the two are symmetric.
76
+ #
77
+ # Rules:
78
+ # - Regexp pattern → standard regex match.
79
+ # - String containing "/" → exact path match. So "api/v1/users"
80
+ # matches only that exact controller, not the bare "users".
81
+ # - String without "/" → boundary-aware suffix match. So "users"
82
+ # matches both `users` and `api/v1/users`, but NOT `super_users`
83
+ # (the boundary requires a "/" before the pattern, or equality).
84
+ def controller_matches?(pattern, controller)
85
+ return pattern.match?(controller) if pattern.is_a?(Regexp)
86
+
87
+ s = pattern.to_s
88
+ if s.include?("/")
89
+ controller == s
90
+ else
91
+ controller == s || controller.end_with?("/#{s}")
92
+ end
93
+ end
94
+
95
+ def ignored_action?(action)
96
+ Array(@config&.ignored_actions).include?(action)
97
+ end
98
+
99
+ def user_ignored?(path)
100
+ Array(@config&.ignored_path_prefixes).any? { |prefix| path.start_with?(prefix) }
101
+ end
102
+
103
+ def normalize_path(raw)
104
+ raw.sub(/\(\.:format\)\z/, "").sub(/\/\z/, "").then { |p| p.empty? ? "/" : p }
105
+ end
106
+
107
+ def internal?(path)
108
+ INTERNAL_PREFIXES.any? { |prefix| path.start_with?(prefix) }
109
+ end
110
+
111
+ # `route.verb` is a String in modern Rails ("GET", "POST", ...).
112
+ # When a route accepts multiple verbs (via `match via: [:get, :post]`)
113
+ # it can return a regex-source string like "GET|POST" — we split it.
114
+ def verbs_for(route)
115
+ raw = route.verb.to_s
116
+ return [] if raw.empty?
117
+
118
+ raw.split("|").map { |v| v.upcase.strip }.reject(&:empty?)
119
+ end
120
+
121
+ def extract_path_params(path)
122
+ path.scan(/:(\w+)/).flatten
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsApiDocs
4
+ module Inspectors
5
+ # Looks up the ActiveRecord model corresponding to a controller and
6
+ # returns its columns metadata:
7
+ #
8
+ # { "name" => { type: :string, null: false, default: nil }, ... }
9
+ #
10
+ # If the model can't be resolved or ActiveRecord isn't loaded, returns
11
+ # an empty hash — schema inference is always best-effort.
12
+ class SchemaInspector
13
+ DEFAULT_RESOLVER = lambda do |controller|
14
+ last = controller.split("/").last.to_s
15
+ model_name = last.singularize.camelize
16
+ Object.const_get(model_name) if Object.const_defined?(model_name)
17
+ end
18
+
19
+ def initialize(controller:, model_resolver: nil)
20
+ @controller = controller
21
+ @model_resolver = model_resolver || DEFAULT_RESOLVER
22
+ end
23
+
24
+ def call
25
+ model = @model_resolver.call(@controller)
26
+ return {} unless model && model.respond_to?(:columns_hash)
27
+
28
+ model.columns_hash.each_with_object({}) do |(name, col), acc|
29
+ acc[name.to_s] = { type: col.type, null: col.null, default: col.default }
30
+ end
31
+ rescue StandardError
32
+ {}
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsApiDocs
4
+ # Single source of truth for type-derived placeholder values. Used by:
5
+ # - BodyInferrer → seeds `example:` for inferred body fields
6
+ # - Builder → seeds `example:` for inferred path params
7
+ # - CurlRenderer → fills body fields in `--data` when no example is set
8
+ # - Renderer → synthesizes a default response example from body schema
9
+ #
10
+ # The mapping is intentionally narrow — for niche types it returns nil
11
+ # rather than guessing, which JSON-encodes to `null` and signals to the
12
+ # user "we don't know, fill this in".
13
+ module SampleValue
14
+ module_function
15
+
16
+ def for(type)
17
+ case type.to_s
18
+ when "string", "text" then "example"
19
+ when "integer", "bigint" then 1
20
+ when "float", "decimal" then 1.0
21
+ when "boolean" then true
22
+ when "date" then "2026-01-01"
23
+ when "datetime", "time" then "2026-01-01T00:00:00Z"
24
+ else nil
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :"rails-api-docs" do
4
+ desc "Generate the API docs HTML at public/api-docs.html from config/rails-api-docs.yml.\n" \
5
+ "Override paths with CONFIG= and OUTPUT= env vars if needed."
6
+ task build: :environment do
7
+ config_relative = ENV["CONFIG"] || RailsApiDocs.configuration.config_path
8
+ output_relative = ENV["OUTPUT"] || RailsApiDocs.configuration.output_path
9
+
10
+ config_path = Rails.root.join(config_relative).to_s
11
+ output_path = Rails.root.join(output_relative).to_s
12
+
13
+ begin
14
+ written = RailsApiDocs::Doc::FileBuilder.new(
15
+ config_path: config_path,
16
+ output_path: output_path
17
+ ).call
18
+
19
+ puts "[rails-api-docs] wrote #{written} (#{File.size(written)} bytes)"
20
+ rescue RailsApiDocs::Doc::FileBuilder::MissingConfigError => e
21
+ warn "[rails-api-docs] #{e.message}"
22
+ exit 1
23
+ end
24
+ end
25
+ end