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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails-api-docs"
5
+
6
+ module RailsApiDocs
7
+ module Generators
8
+ # The full implementation lives here. `UpdateGenerator` is a thin alias
9
+ # subclass that just overrides the namespace — see update_generator.rb.
10
+ # Both invocations run identical code; the distinction is semantic:
11
+ #
12
+ # rails g rails-api-docs:init # scaffold the YAML the first time
13
+ # rails g rails-api-docs:update # re-run to absorb new routes
14
+ #
15
+ # The generator self-detects whether the YAML already exists and either
16
+ # creates it fresh (init flow) or appends only new routes (update flow).
17
+ # Existing entries are never modified — safe to re-run any time.
18
+ class InitGenerator < ::Rails::Generators::Base
19
+ # Force the hyphenated namespace so the CLI is `rails g rails-api-docs:init`,
20
+ # matching the gem name (the module-derived default would be `rails_api_docs:init`).
21
+ namespace "rails-api-docs:init"
22
+
23
+ desc <<~DESC
24
+ Initialize config/rails-api-docs.yml from your Rails app's routes.
25
+
26
+ Scaffolds the full config with every discovered route. To absorb
27
+ new routes after editing your routes file later, run
28
+ `rails g rails-api-docs:update` — it appends new routes without
29
+ touching existing entries.
30
+ DESC
31
+
32
+ # `nil` default lets us distinguish "flag not passed" (fall back to
33
+ # global config) from explicit `--no-api-only` (override config).
34
+ class_option :api_only, type: :boolean, default: nil,
35
+ desc: "Only include routes whose controller is JSON-returning " \
36
+ "(ActionController::API descendant, or action has `render json:`)"
37
+
38
+ class_option :only_controllers, type: :array, default: nil,
39
+ desc: "Whitelist controllers (others are dropped). " \
40
+ "Accepts space-, comma-, or bracket-separated names. " \
41
+ "Bare names match across namespaces (e.g. 'users' matches " \
42
+ "both `users` and `api/v1/users`); paths with '/' are exact. " \
43
+ "Examples:\n" \
44
+ " --only-controllers users posts\n" \
45
+ " --only-controllers=users,posts,comments\n" \
46
+ " --only-controllers=[users,posts,comments]\n" \
47
+ " --only-controllers api/v1/users # namespaced exact"
48
+
49
+ class_option :verbose_yaml, type: :boolean, default: nil,
50
+ desc: "Emit every possible YAML key per endpoint and field " \
51
+ "(format, enum, default, min/max, pattern, read/write_only, " \
52
+ "nullable, response headers/schema, etc.) with defaults. " \
53
+ "Default (without flag) emits the commonly-edited subset only."
54
+
55
+ def create_or_update_config_file
56
+ routes = Inspectors::RouteInspector.new(
57
+ route_set: RailsApiDocs.configuration.route_source,
58
+ config: scoped_config
59
+ ).call
60
+ routes = filter_json_only(routes) if api_only?
61
+
62
+ inferrer = Inspectors::BodyInferrer.new(root: destination_root, verbose: verbose_yaml?)
63
+ generated = Config::Builder.new(routes: routes, body_inferrer: inferrer, verbose: verbose_yaml?).call
64
+
65
+ if File.exist?(absolute_path)
66
+ append_into_existing(generated)
67
+ else
68
+ create_fresh(generated)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def api_only?
75
+ options[:api_only].nil? ? RailsApiDocs.configuration.api_only : options[:api_only]
76
+ end
77
+
78
+ def verbose_yaml?
79
+ options[:verbose_yaml].nil? ? RailsApiDocs.configuration.verbose_yaml : options[:verbose_yaml]
80
+ end
81
+
82
+ def filter_json_only(routes)
83
+ detector = Inspectors::JsonRouteDetector.new(root: destination_root)
84
+ kept = routes.select { |r| detector.call(controller: r[:controller], action: r[:action]) }
85
+ skipped = routes.size - kept.size
86
+ say_status :filtered, "--api-only kept #{kept.size}/#{routes.size} routes (#{skipped} non-JSON skipped)", :cyan if skipped.positive?
87
+ kept
88
+ end
89
+
90
+ # Returns a per-run configuration: a dup of the global config with
91
+ # CLI-supplied options overlaid. Inspectors get this explicit config
92
+ # so we don't mutate global state.
93
+ def scoped_config
94
+ cfg = RailsApiDocs.configuration.dup
95
+ only_ctrls = parse_only_controllers
96
+ cfg.only_controllers = only_ctrls if only_ctrls
97
+ cfg
98
+ end
99
+
100
+ # Accepts all four CLI forms:
101
+ # --only-controllers users posts (space — Thor native array)
102
+ # --only-controllers=users,posts (comma)
103
+ # --only-controllers=[users,posts,comments] (bracketed)
104
+ # --only-controllers=[ users , posts ] (whitespace inside)
105
+ # Returns nil if flag wasn't passed, [] if passed but empty after parsing.
106
+ def parse_only_controllers
107
+ raw = options[:only_controllers]
108
+ return nil unless raw
109
+
110
+ raw.flat_map { |item| item.gsub(/[\[\]]/, "").split(",") }
111
+ .map(&:strip)
112
+ .reject(&:empty?)
113
+ end
114
+
115
+ def relative_path
116
+ RailsApiDocs.configuration.config_path
117
+ end
118
+
119
+ def absolute_path
120
+ File.join(destination_root, relative_path)
121
+ end
122
+
123
+ def create_fresh(generated)
124
+ content = file_header + YAML.dump(generated)
125
+ create_file relative_path, content
126
+ end
127
+
128
+ def append_into_existing(generated)
129
+ existing = Config::Loader.load(absolute_path)
130
+ appender = Config::Appender.new(existing: existing, generated: generated)
131
+
132
+ unless appender.changes?
133
+ say_status :unchanged, relative_path, :yellow
134
+ return
135
+ end
136
+
137
+ header = Config::Loader.header(absolute_path)
138
+ merged = appender.call
139
+ content = (header.empty? ? file_header : header) + YAML.dump(merged)
140
+
141
+ File.write(absolute_path, content)
142
+
143
+ d = appender.diff
144
+ new_sections = d[:new_sections].size
145
+ new_endpoints = d[:new_endpoints_by_section].values.sum(&:size)
146
+ say_status :updated, "#{relative_path} (+#{new_sections} section(s), +#{new_endpoints} endpoint(s))", :green
147
+ end
148
+
149
+ def file_header
150
+ <<~YAML
151
+ # config/rails-api-docs.yml
152
+ # Auto-generated by rails-api-docs.
153
+ #
154
+ # You can safely edit this file:
155
+ # - Add descriptions, examples, body fields and params.
156
+ # - Set `show: false` to hide a route or section from the docs.
157
+ # - Tweak `general_configurations` (colors, title, base_url, etc.).
158
+ #
159
+ # Re-run `rails g rails-api-docs:update` whenever your routes change —
160
+ # it only APPENDS new routes; existing entries are never modified.
161
+ # Delete a section/endpoint from this file to have it regenerated.
162
+
163
+ YAML
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../init/init_generator"
4
+
5
+ module RailsApiDocs
6
+ module Generators
7
+ # Alias for InitGenerator with a different namespace. Both commands run
8
+ # the same code; this one reads more naturally for re-runs:
9
+ #
10
+ # rails g rails-api-docs:update # equivalent to :init
11
+ # rails g rails-api-docs:update --api-only # all flags supported
12
+ #
13
+ # The generator's behavior is self-detecting (file exists → append-only
14
+ # merge; file missing → fresh scaffold), so the practical distinction is
15
+ # purely the name printed in `rails g --help`.
16
+ class UpdateGenerator < InitGenerator
17
+ namespace "rails-api-docs:update"
18
+
19
+ desc <<~DESC
20
+ Update config/rails-api-docs.yml with new routes from your Rails app.
21
+
22
+ Append-only: only routes not yet in the YAML are added — existing
23
+ entries (and your edits) are never modified. Accepts the same flags
24
+ as `rails g rails-api-docs:init`.
25
+ DESC
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsApiDocs
4
+ module Config
5
+ # Append-only merge between an existing parsed config and a freshly
6
+ # generated one.
7
+ #
8
+ # Rules:
9
+ # - Endpoint identity = "#{method} #{path}".
10
+ # - Existing endpoints win on every field (user edits are sacred).
11
+ # - New endpoints (not in existing) are appended to their section's
12
+ # `endpoints` array in the order returned by RouteInspector.
13
+ # - Brand-new sections are appended at the end of `sections`.
14
+ # - `general_configurations`: existing keys win; only missing keys
15
+ # are filled in from the generated defaults — so a new gem version
16
+ # introducing a new option doesn't force a manual edit.
17
+ class Appender
18
+ def initialize(existing:, generated:)
19
+ @existing = existing || {}
20
+ @generated = generated || {}
21
+ end
22
+
23
+ def call
24
+ {
25
+ "general_configurations" => merge_general,
26
+ "sections" => merge_sections
27
+ }
28
+ end
29
+
30
+ # { new_sections: [keys...], new_endpoints_by_section: { key => [endpoints...] } }
31
+ def diff
32
+ {
33
+ new_sections: new_section_keys,
34
+ new_endpoints_by_section: new_endpoints_by_section
35
+ }
36
+ end
37
+
38
+ def changes?
39
+ d = diff
40
+ !d[:new_sections].empty? || !d[:new_endpoints_by_section].empty?
41
+ end
42
+
43
+ private
44
+
45
+ def merge_general
46
+ existing = @existing["general_configurations"] || {}
47
+ generated = @generated["general_configurations"] || {}
48
+ generated.merge(existing)
49
+ end
50
+
51
+ def merge_sections
52
+ existing = @existing["sections"] || {}
53
+ generated = @generated["sections"] || {}
54
+
55
+ merged = existing.dup
56
+
57
+ generated.each do |key, gen_section|
58
+ merged[key] = merged.key?(key) ? merge_section(merged[key], gen_section) : gen_section
59
+ end
60
+
61
+ merged
62
+ end
63
+
64
+ def merge_section(existing_section, generated_section)
65
+ existing_endpoints = existing_section["endpoints"] || []
66
+ generated_endpoints = generated_section["endpoints"] || []
67
+
68
+ existing_keys = existing_endpoints.map { |e| endpoint_key(e) }
69
+ new_only = generated_endpoints.reject { |e| existing_keys.include?(endpoint_key(e)) }
70
+
71
+ existing_section.merge("endpoints" => existing_endpoints + new_only)
72
+ end
73
+
74
+ def endpoint_key(endpoint)
75
+ "#{endpoint['method']} #{endpoint['path']}"
76
+ end
77
+
78
+ def new_section_keys
79
+ existing_keys = (@existing["sections"] || {}).keys
80
+ generated_keys = (@generated["sections"] || {}).keys
81
+ generated_keys - existing_keys
82
+ end
83
+
84
+ def new_endpoints_by_section
85
+ result = {}
86
+ (@generated["sections"] || {}).each do |section_key, gen_section|
87
+ existing_section = @existing.dig("sections", section_key)
88
+ next unless existing_section
89
+
90
+ existing_keys = (existing_section["endpoints"] || []).map { |e| endpoint_key(e) }
91
+ new_endpoints = (gen_section["endpoints"] || []).reject { |e| existing_keys.include?(endpoint_key(e)) }
92
+ result[section_key] = new_endpoints unless new_endpoints.empty?
93
+ end
94
+ result
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module RailsApiDocs
7
+ module Config
8
+ # Turns the array of route hashes produced by RouteInspector into the
9
+ # full config structure that gets written to config/rails-api-docs.yml.
10
+ #
11
+ # Output shape:
12
+ # {
13
+ # "general_configurations" => { ... defaults ... },
14
+ # "sections" => {
15
+ # "<controller_key>" => {
16
+ # "name" => "...",
17
+ # "description" => "",
18
+ # "show" => true,
19
+ # "endpoints" => [ { "method" =>, "path" =>, ... }, ... ]
20
+ # }
21
+ # }
22
+ # }
23
+ class Builder
24
+ DEFAULT_GENERAL = {
25
+ "title" => "API Documentation",
26
+ "base_url" => "https://api.example.com",
27
+ "primary_color" => "#CC0000",
28
+ "secondary_color" => "#2E2E2E",
29
+ "accent_color" => "#D30001",
30
+ "font_family" => "system-ui, -apple-system, sans-serif",
31
+ "show_curl" => true,
32
+ "show_examples" => true
33
+ }.freeze
34
+
35
+ ACTION_VERB_PHRASE = {
36
+ "index" => "List",
37
+ "show" => "Show",
38
+ "new" => "New",
39
+ "create" => "Create",
40
+ "edit" => "Edit",
41
+ "update" => "Update",
42
+ "destroy" => "Delete"
43
+ }.freeze
44
+
45
+ def initialize(routes:, general: nil, body_inferrer: nil, verbose: false)
46
+ @routes = routes
47
+ @general = general || DEFAULT_GENERAL.dup
48
+ @body_inferrer = body_inferrer
49
+ @verbose = verbose
50
+ end
51
+
52
+ def call
53
+ {
54
+ "general_configurations" => @general,
55
+ "sections" => build_sections
56
+ }
57
+ end
58
+
59
+ def to_yaml
60
+ YAML.dump(call)
61
+ end
62
+
63
+ private
64
+
65
+ def build_sections
66
+ @routes.group_by { |r| r[:controller] }.each_with_object({}) do |(controller, routes), acc|
67
+ acc[controller] = {
68
+ "name" => section_name(controller),
69
+ "description" => "",
70
+ "show" => true,
71
+ "endpoints" => routes.map { |r| build_endpoint(r) }
72
+ }
73
+ end
74
+ end
75
+
76
+ def section_name(controller)
77
+ controller.split("/").last.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
78
+ end
79
+
80
+ def build_endpoint(route)
81
+ endpoint = {
82
+ "method" => route[:verb],
83
+ "path" => route[:path],
84
+ "name" => endpoint_name(route),
85
+ "description" => "",
86
+ "show" => true
87
+ }
88
+
89
+ endpoint.merge!(verbose_endpoint_meta) if @verbose
90
+
91
+ params = path_params(route)
92
+ endpoint["params"] = params unless params.empty?
93
+ endpoint["params"] = [] if @verbose && params.empty? # discoverability
94
+
95
+ body = @body_inferrer&.call(controller: route[:controller], action: route[:action])
96
+ endpoint["body"] = body if body && !body.empty?
97
+ endpoint["body"] = [] if @verbose && (body.nil? || body.empty?)
98
+
99
+ endpoint["request_example"] = "" if @verbose
100
+ endpoint["responses"] = response_stub
101
+
102
+ endpoint
103
+ end
104
+
105
+ def response_stub
106
+ if @verbose
107
+ { "200" => { "description" => "", "headers" => [], "schema" => [], "example" => "" } }
108
+ else
109
+ { "200" => { "description" => "", "example" => "" } }
110
+ end
111
+ end
112
+
113
+ # Methods (not frozen constants) so each endpoint gets fresh array
114
+ # values — otherwise YAML.dump emits noisy anchors tying multiple
115
+ # endpoints together.
116
+ def verbose_endpoint_meta
117
+ { "deprecated" => false, "auth" => "", "tags" => [], "headers" => [] }
118
+ end
119
+
120
+ def verbose_field_defaults
121
+ {
122
+ "format" => "",
123
+ "enum" => [],
124
+ "default" => nil,
125
+ "min" => nil,
126
+ "max" => nil,
127
+ "min_length" => nil,
128
+ "max_length" => nil,
129
+ "pattern" => "",
130
+ "read_only" => false,
131
+ "write_only" => false,
132
+ "nullable" => false
133
+ }
134
+ end
135
+
136
+ def path_params(route)
137
+ Array(route[:path_params]).map do |name|
138
+ type = path_param_type(name.to_s)
139
+ base = {
140
+ "name" => name.to_s,
141
+ "type" => type,
142
+ "required" => true,
143
+ "in" => "path",
144
+ "description" => "",
145
+ "example" => RailsApiDocs::SampleValue.for(type)
146
+ }
147
+ @verbose ? base.merge(verbose_field_defaults) : base
148
+ end
149
+ end
150
+
151
+ def path_param_type(name)
152
+ name == "id" || name.end_with?("_id") ? "integer" : "string"
153
+ end
154
+
155
+ def endpoint_name(route)
156
+ phrase = ACTION_VERB_PHRASE[route[:action]] || humanize(route[:action])
157
+ noun = route[:action] == "index" ? pluralize_last_segment(route[:controller])
158
+ : singularize_last_segment(route[:controller])
159
+ "#{phrase} #{noun.capitalize}"
160
+ end
161
+
162
+ def humanize(action)
163
+ action.to_s.tr("_", " ").capitalize
164
+ end
165
+
166
+ def singularize_last_segment(controller)
167
+ controller.split("/").last.to_s.singularize
168
+ end
169
+
170
+ def pluralize_last_segment(controller)
171
+ controller.split("/").last.to_s.pluralize
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RailsApiDocs
6
+ module Config
7
+ module Loader
8
+ module_function
9
+
10
+ def load(path)
11
+ return {} unless File.exist?(path)
12
+
13
+ raw = File.read(path)
14
+ YAML.safe_load(raw, permitted_classes: [Symbol, Date, Time]) || {}
15
+ end
16
+
17
+ # Returns the leading comment block ("#"-prefixed lines and blank lines
18
+ # at the top of the file). Used to preserve the auto-generated header
19
+ # — and any user notes they tacked at the top — across re-runs.
20
+ def header(path)
21
+ return "" unless File.exist?(path)
22
+
23
+ File.read(path)
24
+ .lines
25
+ .take_while { |line| line.start_with?("#") || line.strip.empty? }
26
+ .join
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsApiDocs
4
+ class Configuration
5
+ attr_accessor :config_path, :output_path, :mount_path, :mount_in_development,
6
+ :ignored_path_prefixes, :ignored_controllers, :ignored_actions,
7
+ :only_controllers, :api_only, :verbose_yaml
8
+ attr_writer :route_source
9
+
10
+ def initialize
11
+ @config_path = "config/rails-api-docs.yml"
12
+ @output_path = "public/api-docs.html"
13
+ @mount_path = "/rails/api-docs"
14
+ @mount_in_development = true
15
+ @route_source = nil
16
+
17
+ # User-extensible filters applied by RouteInspector. They stack on top
18
+ # of the built-in INTERNAL_PREFIXES list — they don't replace it.
19
+ #
20
+ # ignored_* are blacklists. only_controllers is a whitelist. Strings
21
+ # without "/" match by boundary-aware suffix (so "users" matches
22
+ # `users` and `api/v1/users` but not `super_users`). Strings with "/"
23
+ # match exactly. Regexps match via `Regexp#match?`.
24
+ # Blacklist wins when a controller is in both lists.
25
+ @ignored_path_prefixes = []
26
+ @ignored_controllers = []
27
+ @ignored_actions = []
28
+ @only_controllers = []
29
+
30
+ # When true, scaffold only includes routes whose controller is JSON-returning
31
+ # (inherits from ActionController::API, or has `render json:` in the action body).
32
+ # Can be set per-run via `rails g rails-api-docs:init --api-only` (or `:update`).
33
+ @api_only = false
34
+
35
+ # When true, scaffold emits EVERY possible YAML key per endpoint and field
36
+ # (format, enum, default, min/max, pattern, read/write_only, nullable,
37
+ # response headers/schema, etc.) with defaults — full discoverability at
38
+ # the cost of much longer files. Default (false) emits only the commonly-
39
+ # edited keys: description, example, and the existing inferred fields.
40
+ # Per-run via `--verbose-yaml`.
41
+ @verbose_yaml = false
42
+ end
43
+
44
+ # Default lazy lookup — resolved at call time so Rails.application is set
45
+ # by the time the generator runs. Tests can inject a custom RouteSet.
46
+ def route_source
47
+ @route_source || Rails.application.routes
48
+ end
49
+ end
50
+
51
+ class << self
52
+ def configuration
53
+ @configuration ||= Configuration.new
54
+ end
55
+
56
+ def configure
57
+ yield configuration
58
+ end
59
+
60
+ def reset_configuration!
61
+ @configuration = Configuration.new
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsApiDocs
6
+ module Doc
7
+ # Renders a copy-pasteable multi-line curl command for an endpoint.
8
+ #
9
+ # Body precedence:
10
+ # 1. endpoint["request_example"] (verbatim — user has full control)
11
+ # 2. JSON.pretty_generate of inferred sample from endpoint["body"]
12
+ # 3. no --data (no body at all)
13
+ #
14
+ # Path param substitution prefers param["example"] when present,
15
+ # otherwise falls back to a type-based sample (always "1" for integers,
16
+ # "example" for everything else).
17
+ class CurlRenderer
18
+ def initialize(endpoint, base_url:)
19
+ @endpoint = endpoint
20
+ @base_url = base_url.to_s
21
+ end
22
+
23
+ def call
24
+ lines = ["curl --request #{method} \\", " --url #{url}"]
25
+
26
+ all_headers = curl_headers
27
+ all_headers.each do |name, value|
28
+ lines[-1] += " \\"
29
+ lines << " --header '#{shell_escape("#{name}: #{value}")}'"
30
+ end
31
+
32
+ if body_present?
33
+ lines[-1] += " \\"
34
+ lines << " --header 'Content-Type: application/json' \\"
35
+ lines << " --data '#{shell_escape(body_json)}'"
36
+ end
37
+ lines.join("\n")
38
+ end
39
+
40
+ private
41
+
42
+ # Returns [[name, value], ...] of headers to emit before --data.
43
+ # Order: user-declared headers (in YAML insertion order) first,
44
+ # then an Authorization placeholder if `auth:` is set and the user
45
+ # didn't already declare one.
46
+ def curl_headers
47
+ user_headers = Array(@endpoint["headers"]).map do |h|
48
+ example = h["example"]
49
+ example = sample_value(h["type"]) if example.nil?
50
+ [h["name"].to_s, example.to_s]
51
+ end
52
+
53
+ if @endpoint["auth"] && !@endpoint["auth"].to_s.empty? &&
54
+ !user_headers.any? { |n, _| n.casecmp?("Authorization") }
55
+ placeholder = auth_placeholder(@endpoint["auth"])
56
+ user_headers << ["Authorization", placeholder] if placeholder
57
+ end
58
+
59
+ user_headers
60
+ end
61
+
62
+ def auth_placeholder(auth)
63
+ case auth.to_s.downcase
64
+ when "bearer" then "Bearer YOUR_TOKEN_HERE"
65
+ when "basic" then "Basic BASE64_ENCODED_CREDENTIALS"
66
+ when "none" then nil
67
+ else auth.to_s
68
+ end
69
+ end
70
+
71
+ def method
72
+ @endpoint["method"].to_s.upcase
73
+ end
74
+
75
+ def url
76
+ path = @endpoint["path"].to_s.dup
77
+ Array(@endpoint["params"]).each do |param|
78
+ next unless param["in"] == "path"
79
+ path.sub!(":#{param['name']}", path_param_sample(param).to_s)
80
+ end
81
+ "#{@base_url}#{path}"
82
+ end
83
+
84
+ def path_param_sample(param)
85
+ return param["example"] unless param["example"].nil?
86
+ sample_value(param["type"])
87
+ end
88
+
89
+ def body_present?
90
+ !@endpoint["request_example"].to_s.strip.empty? ||
91
+ (@endpoint["body"].is_a?(Array) && !@endpoint["body"].empty?)
92
+ end
93
+
94
+ def body_json
95
+ if @endpoint["request_example"] && !@endpoint["request_example"].to_s.strip.empty?
96
+ @endpoint["request_example"].to_s.strip
97
+ else
98
+ hash = @endpoint["body"].each_with_object({}) do |field, acc|
99
+ # Field's own example wins; type-derived sample is the fallback.
100
+ acc[field["name"]] = field.key?("example") && !field["example"].nil? ?
101
+ field["example"] : sample_value(field["type"])
102
+ end
103
+ JSON.pretty_generate(hash)
104
+ end
105
+ end
106
+
107
+ # `'...'` in shell cannot contain a single quote. The standard escape
108
+ # for `'` inside is `'\''` (close, escaped quote, reopen).
109
+ # Block form of gsub avoids the replacement-string backslash trap
110
+ # (`\'` would be interpreted as the post-match reference).
111
+ def shell_escape(str)
112
+ str.gsub("'") { "'\\''" }
113
+ end
114
+
115
+ def sample_value(type)
116
+ RailsApiDocs::SampleValue.for(type)
117
+ end
118
+ end
119
+ end
120
+ end