rails-autodoc 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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +63 -0
- data/.gitignore +16 -0
- data/.rspec +2 -0
- data/.rubocop.yml +81 -0
- data/.yardopts +3 -0
- data/Appraisals +26 -0
- data/CHANGELOG.md +20 -0
- data/CONTRIBUTING.md +54 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +298 -0
- data/LICENSE.txt +21 -0
- data/README.md +111 -0
- data/Rakefile +26 -0
- data/app/controllers/rails_autodoc/spec_controller.rb +52 -0
- data/config/routes.rb +6 -0
- data/docs/annotation-dsl.md +56 -0
- data/docs/architecture.md +37 -0
- data/docs/ci-integration.md +32 -0
- data/docs/configuration.md +48 -0
- data/docs/faq.md +29 -0
- data/docs/getting-started.md +54 -0
- data/docs/index.md +21 -0
- data/docs/inference-rules.md +74 -0
- data/docs/limitations.md +34 -0
- data/docs/migration-from-rswag.md +42 -0
- data/docs/serializer-support.md +25 -0
- data/lib/generators/rails_autodoc/install_generator.rb +34 -0
- data/lib/generators/rails_autodoc/templates/autodoc-verify.yml +19 -0
- data/lib/generators/rails_autodoc/templates/initializer.rb +20 -0
- data/lib/rails_autodoc/ast_traversal.rb +74 -0
- data/lib/rails_autodoc/configuration.rb +45 -0
- data/lib/rails_autodoc/dsl/controller_extensions.rb +65 -0
- data/lib/rails_autodoc/engine.rb +13 -0
- data/lib/rails_autodoc/generator.rb +54 -0
- data/lib/rails_autodoc/openapi_spec_builder.rb +334 -0
- data/lib/rails_autodoc/railtie.rb +25 -0
- data/lib/rails_autodoc/registry.rb +71 -0
- data/lib/rails_autodoc/response_inferencer.rb +158 -0
- data/lib/rails_autodoc/route_inspector.rb +139 -0
- data/lib/rails_autodoc/schema_mapper.rb +142 -0
- data/lib/rails_autodoc/serializers/active_model_serializer.rb +27 -0
- data/lib/rails_autodoc/serializers/alba.rb +39 -0
- data/lib/rails_autodoc/serializers/base.rb +19 -0
- data/lib/rails_autodoc/serializers/blueprinter.rb +27 -0
- data/lib/rails_autodoc/serializers/registry.rb +29 -0
- data/lib/rails_autodoc/strong_params_parser.rb +188 -0
- data/lib/rails_autodoc/tasks/autodoc.rake +26 -0
- data/lib/rails_autodoc/version.rb +5 -0
- data/lib/rails_autodoc.rb +47 -0
- data/mkdocs.yml +16 -0
- data/rails-autodoc.gemspec +61 -0
- data/spec/combustion/config.ru +4 -0
- data/spec/dummy/app/assets/config/manifest.js +1 -0
- data/spec/dummy/app/controllers/api/v1/users_controller.rb +45 -0
- data/spec/dummy/app/models/user.rb +5 -0
- data/spec/dummy/config/application.rb +14 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/database.yml +3 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/test.rb +12 -0
- data/spec/dummy/config/initializers/rails_autodoc.rb +8 -0
- data/spec/dummy/config/initializers/sqlite3_boolean.rb +8 -0
- data/spec/dummy/config/routes.rb +11 -0
- data/spec/dummy/db/migrate/001_create_users.rb +14 -0
- data/spec/dummy/db/schema.rb +12 -0
- data/spec/rails_autodoc/configuration_spec.rb +34 -0
- data/spec/rails_autodoc/dsl_integration_spec.rb +77 -0
- data/spec/rails_autodoc/engine_spec.rb +26 -0
- data/spec/rails_autodoc/gem_spec.rb +27 -0
- data/spec/rails_autodoc/generator_spec.rb +39 -0
- data/spec/rails_autodoc/golden_spec.rb +67 -0
- data/spec/rails_autodoc/integration_spec.rb +114 -0
- data/spec/rails_autodoc/registry_spec.rb +26 -0
- data/spec/rails_autodoc/response_inferencer_spec.rb +26 -0
- data/spec/rails_autodoc/route_inspector_spec.rb +56 -0
- data/spec/rails_autodoc/schema_mapper_spec.rb +42 -0
- data/spec/rails_autodoc/serializers/registry_spec.rb +33 -0
- data/spec/rails_autodoc/strong_params_parser_spec.rb +41 -0
- data/spec/spec_helper.rb +43 -0
- metadata +320 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
class OperationAnnotation
|
|
5
|
+
attr_accessor :controller,
|
|
6
|
+
:action,
|
|
7
|
+
:summary,
|
|
8
|
+
:description,
|
|
9
|
+
:tags,
|
|
10
|
+
:deprecated,
|
|
11
|
+
:body_params,
|
|
12
|
+
:query_params,
|
|
13
|
+
:responses,
|
|
14
|
+
:security,
|
|
15
|
+
:request_body_schema,
|
|
16
|
+
:exclude
|
|
17
|
+
|
|
18
|
+
def initialize(controller:, action:)
|
|
19
|
+
@controller = controller
|
|
20
|
+
@action = action
|
|
21
|
+
@summary = nil
|
|
22
|
+
@description = nil
|
|
23
|
+
@tags = []
|
|
24
|
+
@deprecated = false
|
|
25
|
+
@body_params = []
|
|
26
|
+
@query_params = []
|
|
27
|
+
@responses = {}
|
|
28
|
+
@security = nil
|
|
29
|
+
@request_body_schema = nil
|
|
30
|
+
@exclude = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def operation_id
|
|
34
|
+
"#{controller.name.underscore.tr('/', '_')}_#{action}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Registry
|
|
39
|
+
def initialize
|
|
40
|
+
@annotations = {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def register(controller, action, &block)
|
|
44
|
+
key = annotation_key(controller, action)
|
|
45
|
+
annotation = (@annotations[key] ||= OperationAnnotation.new(
|
|
46
|
+
controller: controller,
|
|
47
|
+
action: action
|
|
48
|
+
))
|
|
49
|
+
DSL::ControllerExtensions::AnnotationBuilder.new(annotation).instance_eval(&block)
|
|
50
|
+
annotation
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def find(controller, action)
|
|
54
|
+
@annotations[annotation_key(controller, action)]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def all
|
|
58
|
+
@annotations.values
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def clear!
|
|
62
|
+
@annotations.clear
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def annotation_key(controller, action)
|
|
68
|
+
"#{controller.name}##{action}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parser/current"
|
|
4
|
+
|
|
5
|
+
module RailsAutodoc
|
|
6
|
+
class ResponseInferencer
|
|
7
|
+
include AstTraversal
|
|
8
|
+
|
|
9
|
+
ResponseHint = Struct.new(:status, :schema_ref, :schema, :description, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
DEFAULT_STATUS = {
|
|
12
|
+
"GET" => "200",
|
|
13
|
+
"POST" => "201",
|
|
14
|
+
"PUT" => "200",
|
|
15
|
+
"PATCH" => "200",
|
|
16
|
+
"DELETE" => "204"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(source_path:, class_name:, serializer_registry: Serializers::Registry.new)
|
|
20
|
+
@source_path = source_path
|
|
21
|
+
@class_name = class_name
|
|
22
|
+
@serializer_registry = serializer_registry
|
|
23
|
+
@buffer, = Parser::CurrentRuby.parse_file(source_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def responses_for_action(action_name, verb: "GET")
|
|
27
|
+
action_node = find_action_node(action_name)
|
|
28
|
+
hints = action_node ? extract_render_hints(action_node) : []
|
|
29
|
+
|
|
30
|
+
hints << default_response(verb) if hints.empty?
|
|
31
|
+
|
|
32
|
+
hints
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def class_node
|
|
38
|
+
@class_node ||= find_class_node(@buffer, class_name: @class_name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find_action_node(action_name)
|
|
42
|
+
each_method_definition(class_node) do |child|
|
|
43
|
+
next unless child.type == :def
|
|
44
|
+
|
|
45
|
+
return child if child.children[0].to_s == action_name.to_s
|
|
46
|
+
end
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def extract_render_hints(action_node)
|
|
51
|
+
hints = []
|
|
52
|
+
walk_nodes(action_node) do |node|
|
|
53
|
+
next unless node.type == :send
|
|
54
|
+
|
|
55
|
+
if node.children[1] == :render
|
|
56
|
+
hint = build_hint_from_render(node)
|
|
57
|
+
hints << hint if hint
|
|
58
|
+
elsif node.children[1] == :head
|
|
59
|
+
status = normalize_status(node.children[2])
|
|
60
|
+
hints << ResponseHint.new(status: status, schema: nil, description: "No content")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
hints
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_hint_from_render(node)
|
|
67
|
+
status = "200"
|
|
68
|
+
schema_ref = nil
|
|
69
|
+
schema = { type: "object" }
|
|
70
|
+
|
|
71
|
+
node.children[2..].each do |arg|
|
|
72
|
+
next unless arg
|
|
73
|
+
|
|
74
|
+
next unless arg.type == :hash
|
|
75
|
+
|
|
76
|
+
each_hash_pair(arg) do |key, value|
|
|
77
|
+
case literal_value(key)
|
|
78
|
+
when :json
|
|
79
|
+
schema_ref, schema = infer_json_schema(value)
|
|
80
|
+
when :status
|
|
81
|
+
status = normalize_status(value)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
ResponseHint.new(status: status, schema_ref: schema_ref, schema: schema, description: "Successful response")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def infer_json_schema(value_node)
|
|
90
|
+
case value_node.type
|
|
91
|
+
when :send
|
|
92
|
+
receiver = value_node.children[0]
|
|
93
|
+
method_name = value_node.children[1]
|
|
94
|
+
if receiver&.type == :const && method_name == :new
|
|
95
|
+
serializer_class = const_path(receiver)
|
|
96
|
+
schema = @serializer_registry.schema_for(serializer_class)
|
|
97
|
+
return [serializer_class, schema]
|
|
98
|
+
end
|
|
99
|
+
if receiver&.type == :ivar
|
|
100
|
+
model_name = infer_model_from_ivar(receiver)
|
|
101
|
+
return [model_name, { "$ref" => "#/components/schemas/#{model_name}" }] if model_name
|
|
102
|
+
end
|
|
103
|
+
when :const
|
|
104
|
+
model_name = const_path(value_node)
|
|
105
|
+
return [model_name, { "$ref" => "#/components/schemas/#{model_name}" }]
|
|
106
|
+
when :hash
|
|
107
|
+
return [nil, { type: "object" }]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
[nil, { type: "object" }]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def infer_model_from_ivar(node)
|
|
114
|
+
node.children[0].to_s.sub(/^@/, "").classify
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def each_hash_pair(hash_node, &block)
|
|
120
|
+
hash_node.children.each do |pair_node|
|
|
121
|
+
next unless pair_node.type == :pair
|
|
122
|
+
|
|
123
|
+
yield pair_node.children[0], pair_node.children[1]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def default_response(verb)
|
|
128
|
+
status = DEFAULT_STATUS.fetch(verb, "200")
|
|
129
|
+
if status == "204"
|
|
130
|
+
ResponseHint.new(status: status, schema: nil, description: "No content")
|
|
131
|
+
else
|
|
132
|
+
ResponseHint.new(status: status, schema: { type: "object" }, description: "Successful response")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def normalize_status(node)
|
|
137
|
+
value = literal_value(node)
|
|
138
|
+
case value
|
|
139
|
+
when Integer then value.to_s
|
|
140
|
+
when Symbol
|
|
141
|
+
Rack::Utils.status_code(value).to_s
|
|
142
|
+
else
|
|
143
|
+
value.to_s
|
|
144
|
+
end
|
|
145
|
+
rescue StandardError
|
|
146
|
+
"200"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def literal_value(node)
|
|
150
|
+
case node&.type
|
|
151
|
+
when :sym then node.children[0]
|
|
152
|
+
when :int then node.children[0]
|
|
153
|
+
when :str then node.children[0]
|
|
154
|
+
else node.to_s
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
class RouteOperation
|
|
5
|
+
attr_reader :verb,
|
|
6
|
+
:path,
|
|
7
|
+
:controller_class,
|
|
8
|
+
:action,
|
|
9
|
+
:route_name,
|
|
10
|
+
:path_params,
|
|
11
|
+
:tags,
|
|
12
|
+
:constraints
|
|
13
|
+
|
|
14
|
+
def initialize(verb:, path:, controller_class:, action:, route_name:, path_params:, tags:, constraints: {})
|
|
15
|
+
@verb = verb
|
|
16
|
+
@path = path
|
|
17
|
+
@controller_class = controller_class
|
|
18
|
+
@action = action.to_s
|
|
19
|
+
@route_name = route_name
|
|
20
|
+
@path_params = path_params
|
|
21
|
+
@tags = tags
|
|
22
|
+
@constraints = constraints
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def operation_id
|
|
26
|
+
"#{controller_class.name.underscore.tr('/', '_')}_#{action}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def openapi_path
|
|
30
|
+
path.gsub(/:([a-zA-Z_][a-zA-Z0-9_]*)/, '{\1}')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class RouteInspector
|
|
35
|
+
HTTP_VERBS = %w[GET HEAD POST PUT PATCH DELETE OPTIONS].freeze
|
|
36
|
+
|
|
37
|
+
def initialize(config: RailsAutodoc.config)
|
|
38
|
+
@config = config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def operations
|
|
42
|
+
ensure_controllers_loaded!
|
|
43
|
+
collect_operations.sort_by { |op| [op.path, op.verb, op.action] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def ensure_controllers_loaded!
|
|
49
|
+
return unless defined?(Rails) && Rails.application
|
|
50
|
+
|
|
51
|
+
Rails.application.eager_load! if Rails.application.config.eager_load == false
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def collect_operations
|
|
57
|
+
routes.flat_map { |route| operation_from_route(route) }.compact
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def routes
|
|
61
|
+
Rails.application.routes.routes
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def operation_from_route(route)
|
|
65
|
+
return nil unless route.respond_to?(:requirements)
|
|
66
|
+
|
|
67
|
+
requirements = route.requirements
|
|
68
|
+
controller_name = requirements[:controller]
|
|
69
|
+
action = requirements[:action]
|
|
70
|
+
return nil if controller_name.blank? || action.blank?
|
|
71
|
+
|
|
72
|
+
controller_class = resolve_controller(controller_name)
|
|
73
|
+
return nil unless controller_class
|
|
74
|
+
return nil unless controller_class.action_methods.include?(action.to_s)
|
|
75
|
+
|
|
76
|
+
path = normalize_path(route.path.spec.to_s)
|
|
77
|
+
return nil if @config.excluded_path?(path)
|
|
78
|
+
|
|
79
|
+
verbs = extract_verbs(route)
|
|
80
|
+
path_params = extract_path_params(path)
|
|
81
|
+
tags = extract_tags(controller_class)
|
|
82
|
+
|
|
83
|
+
verbs.map do |verb|
|
|
84
|
+
RouteOperation.new(
|
|
85
|
+
verb: verb,
|
|
86
|
+
path: path,
|
|
87
|
+
controller_class: controller_class,
|
|
88
|
+
action: action,
|
|
89
|
+
route_name: route.name,
|
|
90
|
+
path_params: path_params,
|
|
91
|
+
tags: tags,
|
|
92
|
+
constraints: route.constraints
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def resolve_controller(controller_name)
|
|
100
|
+
controller_path = "#{controller_name.camelize}Controller"
|
|
101
|
+
controller_path.constantize
|
|
102
|
+
rescue NameError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_path(raw_path)
|
|
107
|
+
path = raw_path
|
|
108
|
+
path = path.sub(/\(\.:format\)\z/, "")
|
|
109
|
+
path = path.sub("(.:format)", "")
|
|
110
|
+
path = "/" if path.blank?
|
|
111
|
+
path
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def extract_verbs(route)
|
|
115
|
+
verb = route.verb
|
|
116
|
+
if verb.is_a?(Regexp)
|
|
117
|
+
HTTP_VERBS.grep(verb)
|
|
118
|
+
elsif verb.is_a?(String)
|
|
119
|
+
verb.split("|").map(&:upcase).reject { |v| v == "HEAD" }
|
|
120
|
+
else
|
|
121
|
+
["GET"]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def extract_path_params(path)
|
|
126
|
+
path.scan(/:([a-zA-Z_][a-zA-Z0-9_]*)/).flatten.uniq
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def extract_tags(controller_class)
|
|
130
|
+
parts = controller_class.name.split("::")
|
|
131
|
+
controller_part = parts.last.sub(/Controller\z/, "")
|
|
132
|
+
tags = [controller_part]
|
|
133
|
+
|
|
134
|
+
namespace_parts = parts[0..-2]
|
|
135
|
+
tags.concat(namespace_parts) unless namespace_parts.empty?
|
|
136
|
+
tags.uniq
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
class SchemaMapper
|
|
5
|
+
TYPE_MAP = {
|
|
6
|
+
string: { type: "string" },
|
|
7
|
+
text: { type: "string" },
|
|
8
|
+
citext: { type: "string" },
|
|
9
|
+
uuid: { type: "string", format: "uuid" },
|
|
10
|
+
integer: { type: "integer" },
|
|
11
|
+
bigint: { type: "integer", format: "int64" },
|
|
12
|
+
float: { type: "number", format: "float" },
|
|
13
|
+
decimal: { type: "number", format: "double" },
|
|
14
|
+
boolean: { type: "boolean" },
|
|
15
|
+
datetime: { type: "string", format: "date-time" },
|
|
16
|
+
date: { type: "string", format: "date" },
|
|
17
|
+
time: { type: "string", format: "time" },
|
|
18
|
+
json: { type: "object" },
|
|
19
|
+
jsonb: { type: "object" }
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(schema_path: default_schema_path)
|
|
23
|
+
@schema_path = schema_path
|
|
24
|
+
@tables = {}
|
|
25
|
+
@models = {}
|
|
26
|
+
load_schema if @schema_path&.exist?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def apply_types!(schema, model_name: nil)
|
|
30
|
+
return schema unless schema.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
schema[:properties]&.each do |field, field_schema|
|
|
33
|
+
typed = column_schema(model_name, field) if model_name
|
|
34
|
+
schema[:properties][field] = merge_schemas(field_schema, typed) if typed
|
|
35
|
+
apply_types!(schema[:properties][field], model_name: model_name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
schema
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def model_schema(model_name)
|
|
42
|
+
return nil unless defined?(ActiveRecord::Base)
|
|
43
|
+
|
|
44
|
+
model = model_name.constantize
|
|
45
|
+
table = model.table_name
|
|
46
|
+
columns = @tables[table]
|
|
47
|
+
return nil unless columns
|
|
48
|
+
|
|
49
|
+
properties = {}
|
|
50
|
+
required = []
|
|
51
|
+
columns.each do |column_name, column_meta|
|
|
52
|
+
next if %w[id created_at updated_at].include?(column_name)
|
|
53
|
+
|
|
54
|
+
properties[column_name] = column_meta.dup
|
|
55
|
+
required << column_name unless column_meta[:nullable]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: properties,
|
|
61
|
+
required: required
|
|
62
|
+
}
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def infer_model_from_controller(controller_class)
|
|
68
|
+
name = controller_class.name.split("::").last.sub(/Controller\z/, "").singularize
|
|
69
|
+
name.constantize.name
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def all_model_schemas
|
|
75
|
+
return {} unless defined?(ActiveRecord::Base)
|
|
76
|
+
|
|
77
|
+
schemas = {}
|
|
78
|
+
@tables.each_key do |table|
|
|
79
|
+
model_name = table.classify
|
|
80
|
+
schema = model_schema(model_name)
|
|
81
|
+
schemas[model_name] = schema if schema
|
|
82
|
+
rescue StandardError
|
|
83
|
+
next
|
|
84
|
+
end
|
|
85
|
+
schemas
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def default_schema_path
|
|
91
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:root)
|
|
92
|
+
|
|
93
|
+
Rails.root.join("db/schema.rb")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def load_schema
|
|
97
|
+
content = @schema_path.read.gsub("\r\n", "\n")
|
|
98
|
+
parse_create_tables(content)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_create_tables(content)
|
|
102
|
+
content.scan(/create_table\s+"([^"]+)"[^\n]*\n(.*?)end/m).each do |table, body|
|
|
103
|
+
@tables[table] = parse_columns(body)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_columns(body)
|
|
108
|
+
columns = {}
|
|
109
|
+
body.scan(/t\.(\w+)\s+"([^"]+)"(?:,\s*(.*?))?(?:\r)?$/).each do |type, name, options|
|
|
110
|
+
columns[name] = build_column_schema(type, options)
|
|
111
|
+
end
|
|
112
|
+
columns
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_column_schema(type, options)
|
|
116
|
+
schema = (TYPE_MAP[type.to_sym] || { type: "string" }).dup
|
|
117
|
+
schema[:nullable] = options.to_s.include?("null: false") == false
|
|
118
|
+
schema
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def column_schema(model_name, field)
|
|
122
|
+
return nil unless model_name
|
|
123
|
+
|
|
124
|
+
model = model_name.constantize
|
|
125
|
+
table = model.table_name
|
|
126
|
+
column = @tables.dig(table, field.to_s)
|
|
127
|
+
return nil unless column
|
|
128
|
+
|
|
129
|
+
column.dup.tap { |s| s.delete(:nullable) }
|
|
130
|
+
rescue StandardError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def merge_schemas(base, overlay)
|
|
135
|
+
return overlay unless base.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
base.merge(overlay) do |_key, old_val, new_val|
|
|
138
|
+
old_val.is_a?(Hash) && new_val.is_a?(Hash) ? old_val.merge(new_val) : new_val
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
module Serializers
|
|
5
|
+
class ActiveModelSerializer < Base
|
|
6
|
+
def detect?
|
|
7
|
+
defined?(::ActiveModel::Serializer)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def attributes_for(serializer_class)
|
|
11
|
+
if serializer_class.respond_to?(:_attributes)
|
|
12
|
+
serializer_class._attributes.keys.map(&:to_s)
|
|
13
|
+
else
|
|
14
|
+
[]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def schema_for(serializer_class)
|
|
19
|
+
properties = attributes_for(serializer_class).to_h do |field|
|
|
20
|
+
[field, { type: "string" }]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
{ type: "object", properties: properties }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
module Serializers
|
|
5
|
+
class Alba < Base
|
|
6
|
+
def detect?
|
|
7
|
+
defined?(::Alba)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def attributes_for(serializer_class)
|
|
11
|
+
serializer_class.instance_methods(false).grep(/^[a-z]/) +
|
|
12
|
+
extract_alba_attributes(serializer_class)
|
|
13
|
+
rescue StandardError
|
|
14
|
+
[]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def schema_for(serializer_class)
|
|
18
|
+
fields = extract_alba_attributes(serializer_class)
|
|
19
|
+
properties = fields.to_h do |field|
|
|
20
|
+
[field.to_s, { type: "string" }]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
{ type: "object", properties: properties }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def extract_alba_attributes(serializer_class)
|
|
29
|
+
if serializer_class.respond_to?(:attributes)
|
|
30
|
+
serializer_class.attributes.keys.map(&:to_s)
|
|
31
|
+
elsif serializer_class.respond_to?(:_attributes)
|
|
32
|
+
serializer_class._attributes.keys.map(&:to_s)
|
|
33
|
+
else
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
module Serializers
|
|
5
|
+
class Base
|
|
6
|
+
def detect?
|
|
7
|
+
false
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def attributes_for(_serializer_class)
|
|
11
|
+
[]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def schema_for(_serializer_class)
|
|
15
|
+
{ type: "object" }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
module Serializers
|
|
5
|
+
class Blueprinter < Base
|
|
6
|
+
def detect?
|
|
7
|
+
defined?(::Blueprinter)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def attributes_for(serializer_class)
|
|
11
|
+
if serializer_class.respond_to?(:fields)
|
|
12
|
+
serializer_class.fields.keys.map(&:to_s)
|
|
13
|
+
else
|
|
14
|
+
[]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def schema_for(serializer_class)
|
|
19
|
+
properties = attributes_for(serializer_class).to_h do |field|
|
|
20
|
+
[field, { type: "string" }]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
{ type: "object", properties: properties }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "alba"
|
|
5
|
+
require_relative "blueprinter"
|
|
6
|
+
require_relative "active_model_serializer"
|
|
7
|
+
|
|
8
|
+
module RailsAutodoc
|
|
9
|
+
module Serializers
|
|
10
|
+
class Registry
|
|
11
|
+
ADAPTERS = [
|
|
12
|
+
Alba.new,
|
|
13
|
+
Blueprinter.new,
|
|
14
|
+
ActiveModelSerializer.new
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def active_adapters
|
|
18
|
+
ADAPTERS.select(&:detect?)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def schema_for(serializer_class)
|
|
22
|
+
adapter = active_adapters.find do |candidate|
|
|
23
|
+
candidate.schema_for(serializer_class).fetch(:properties, {}).any?
|
|
24
|
+
end
|
|
25
|
+
adapter ? adapter.schema_for(serializer_class) : { type: "object" }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|