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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +142 -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/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 +155 -0
|
@@ -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,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
|