docit 0.3.1 → 0.5.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.
data/lib/docit/dsl.rb CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  module Docit
4
4
  # Included in all Rails controllers via the Engine.
5
- # Provides +swagger_doc+ and +use_docs+ class methods.
5
+ # Provides +doc_for+ and +use_docs+ class methods.
6
6
  module DSL
7
7
  def self.included(base)
8
8
  base.extend(ClassMethods)
9
9
  end
10
10
 
11
11
  module ClassMethods
12
- def swagger_doc(action, &block)
12
+ def doc_for(action, &block)
13
13
  operation = Operation.new(
14
14
  controller: name,
15
15
  action: action
@@ -18,9 +18,12 @@ module Docit
18
18
  Registry.register(operation)
19
19
  end
20
20
 
21
+ # Backward-compatible alias
22
+ alias swagger_doc doc_for
23
+
21
24
  def use_docs(doc_module)
22
25
  doc_module.actions.each do |action|
23
- swagger_doc(action, &doc_module[action])
26
+ doc_for(action, &doc_module[action])
24
27
  end
25
28
  end
26
29
  end
@@ -2,11 +2,11 @@
2
2
 
3
3
  module Docit
4
4
  # Represents the documentation for a single controller action.
5
- # Created by +swagger_doc+ and stored in the {Registry}.
5
+ # Created by +doc_for+ and stored in the {Registry}.
6
6
  class Operation
7
7
  attr_reader :controller, :action, :_summary, :_description,
8
8
  :_tags, :_responses, :_request_body, :_parameters,
9
- :_security, :_deprecated
9
+ :_security, :_deprecated, :_operation_id
10
10
 
11
11
  def initialize(controller:, action:)
12
12
  @controller = controller
@@ -17,6 +17,7 @@ module Docit
17
17
  @_request_body = nil
18
18
  @_security = []
19
19
  @_deprecated = false
20
+ @_operation_id = nil
20
21
  end
21
22
 
22
23
  def summary(text)
@@ -35,6 +36,10 @@ module Docit
35
36
  @_deprecated = value
36
37
  end
37
38
 
39
+ def operation_id(text)
40
+ @_operation_id = text
41
+ end
42
+
38
43
  def security(scheme)
39
44
  @_security << scheme
40
45
  end
@@ -5,7 +5,7 @@ module Docit
5
5
  class RouteInspector
6
6
  VALID_METHODS = %w[get post put patch delete].freeze
7
7
 
8
- # Eagerly loads controller classes so swagger_doc/use_docs macros run before spec generation.
8
+ # Eagerly loads controller classes so doc_for/use_docs macros run before spec generation.
9
9
  def self.eager_load_controllers!
10
10
  return if defined?(Rails).nil? || Rails.application.routes.nil?
11
11
 
@@ -12,11 +12,7 @@ module Docit
12
12
 
13
13
  spec = {
14
14
  openapi: "3.0.3",
15
- info: {
16
- title: config.title,
17
- version: config.version,
18
- description: config.description
19
- },
15
+ info: build_info(config),
20
16
  paths: build_paths,
21
17
  components: {
22
18
  securitySchemes: config.security_schemes
@@ -37,6 +33,18 @@ module Docit
37
33
 
38
34
  private
39
35
 
36
+ def build_info(config)
37
+ info = {
38
+ title: config.title,
39
+ version: config.version,
40
+ description: config.description
41
+ }
42
+ info[:license] = config.license_info if config.license_info
43
+ info[:contact] = config.contact_info if config.contact_info
44
+ info[:termsOfService] = config.terms_of_service_url if config.terms_of_service_url
45
+ info
46
+ end
47
+
40
48
  def build_paths
41
49
  paths = {}
42
50
 
@@ -58,6 +66,7 @@ module Docit
58
66
 
59
67
  def build_operation(operation)
60
68
  result = {
69
+ operationId: operation._operation_id || generate_operation_id(operation),
61
70
  summary: operation._summary || operation.action.humanize,
62
71
  description: operation._description || "",
63
72
  tags: operation._tags,
@@ -183,5 +192,15 @@ module Docit
183
192
  hash[ex[:name].to_s] = entry
184
193
  end
185
194
  end
195
+
196
+ def generate_operation_id(operation)
197
+ # "Api::V1::UsersController" → "users" ; "index" → "listUsers"
198
+ resource = operation.controller
199
+ .gsub(/.*::/, "") # strip namespace
200
+ .gsub(/Controller$/, "") # strip suffix
201
+ action = operation.action
202
+
203
+ "#{action}_#{resource}".gsub("::", "_").downcase
204
+ end
186
205
  end
187
206
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module SystemGraph
5
+ class Edge
6
+ attr_reader :id, :source, :target, :type, :confidence, :evidence
7
+
8
+ def initialize(id:, source:, target:, type:, confidence:, evidence:)
9
+ @id = id.to_s
10
+ @source = source.to_s
11
+ @target = target.to_s
12
+ @type = type.to_s
13
+ @confidence = confidence.to_s
14
+ @evidence = evidence.to_s
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ id: id,
20
+ source: source,
21
+ target: target,
22
+ type: type,
23
+ confidence: confidence,
24
+ evidence: evidence
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module SystemGraph
5
+ class Generator
6
+ def self.generate
7
+ new.generate
8
+ end
9
+
10
+ def generate
11
+ RailsAnalyzer.new.analyze.to_h
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Docit
6
+ module SystemGraph
7
+ class Graph
8
+ VERSION = "1.0"
9
+
10
+ attr_reader :nodes, :edges, :framework
11
+
12
+ def initialize(framework: "rails")
13
+ @framework = framework
14
+ @nodes = {}
15
+ @edges = {}
16
+ end
17
+
18
+ def add_node(node)
19
+ nodes[node.id] ||= node
20
+ end
21
+
22
+ def add_edge(edge)
23
+ return if edge.source == edge.target
24
+ return unless nodes.key?(edge.source) && nodes.key?(edge.target)
25
+
26
+ edges[edge.id] ||= edge
27
+ end
28
+
29
+ def to_h
30
+ node_values = nodes.values
31
+ edge_values = edges.values
32
+
33
+ {
34
+ version: VERSION,
35
+ generated_at: Time.now.utc.iso8601,
36
+ framework: framework,
37
+ nodes: node_values.map(&:to_h),
38
+ edges: edge_values.map(&:to_h),
39
+ stats: stats(node_values, edge_values)
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def stats(node_values, edge_values)
46
+ {
47
+ nodes: node_values.length,
48
+ edges: edge_values.length,
49
+ node_types: node_values.group_by(&:type).transform_values(&:length),
50
+ edge_types: edge_values.group_by(&:type).transform_values(&:length)
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module SystemGraph
5
+ class Node
6
+ attr_reader :id, :type, :label, :metadata, :file, :line, :status
7
+
8
+ def initialize(id:, type:, label:, metadata: {}, file: nil, line: nil, status: nil)
9
+ @id = id.to_s
10
+ @type = type.to_s
11
+ @label = label.to_s
12
+ @metadata = metadata
13
+ @file = file
14
+ @line = line
15
+ @status = status
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ id: id,
21
+ type: type,
22
+ label: label,
23
+ metadata: metadata
24
+ }.tap do |hash|
25
+ hash[:file] = file if file
26
+ hash[:line] = line if line
27
+ hash[:status] = status if status
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module SystemGraph
5
+ class RailsAnalyzer
6
+ VALID_METHODS = %w[get post put patch delete].freeze
7
+ SKIP_PREFIXES = %w[docit/ rails/ active_storage/ action_mailbox/].freeze
8
+
9
+ def initialize(graph: Graph.new, scanner: SourceScanner.new(root: Rails.root))
10
+ @graph = graph
11
+ @scanner = scanner
12
+ end
13
+
14
+ def analyze
15
+ add_routes_and_actions
16
+ add_schemas
17
+ add_models
18
+ add_source_nodes
19
+ graph
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :graph, :scanner
25
+
26
+ def add_routes_and_actions
27
+ route_infos.each do |info|
28
+ controller_id = node_id("controller", info[:controller])
29
+ action_id = "#{controller_id}##{info[:action]}"
30
+ route_id = "route:#{info[:method]}:#{info[:path]}"
31
+ operation = Registry.find(controller: info[:controller], action: info[:action])
32
+
33
+ graph.add_node(Node.new(
34
+ id: controller_id,
35
+ type: "controller",
36
+ label: info[:controller],
37
+ file: controller_file(info[:controller]),
38
+ metadata: { controller_path: info[:controller_path] }
39
+ ))
40
+ graph.add_node(Node.new(
41
+ id: action_id,
42
+ type: "action",
43
+ label: "#{info[:controller]}##{info[:action]}",
44
+ file: controller_file(info[:controller]),
45
+ status: operation ? "documented" : "undocumented",
46
+ metadata: { action: info[:action], http_method: info[:method], path: info[:path] }
47
+ ))
48
+ graph.add_node(Node.new(
49
+ id: route_id,
50
+ type: "route",
51
+ label: "#{info[:method].upcase} #{info[:path]}",
52
+ metadata: { method: info[:method], path: info[:path] },
53
+ status: operation ? "documented" : "undocumented"
54
+ ))
55
+
56
+ graph.add_edge(edge(route_id, action_id, "routes_to", "high", "Rails route table"))
57
+ graph.add_edge(edge(controller_id, action_id, "contains", "high", "Rails controller action"))
58
+ add_doc_node(info, operation, action_id) if operation
59
+ end
60
+ end
61
+
62
+ def add_doc_node(info, operation, action_id)
63
+ doc_id = "doc:#{info[:controller].underscore}:#{info[:action]}"
64
+
65
+ request_body_info = nil
66
+ if operation._request_body
67
+ request_body_info = {
68
+ required: operation._request_body.required,
69
+ content_type: operation._request_body.content_type,
70
+ schema_ref: operation._request_body.schema_ref,
71
+ properties: operation._request_body.properties
72
+ }
73
+ end
74
+
75
+ responses_info = operation._responses.map do |res|
76
+ {
77
+ status: res.status,
78
+ description: res.description,
79
+ schema_ref: res.schema_ref,
80
+ properties: res.properties,
81
+ examples: res.examples
82
+ }
83
+ end
84
+
85
+ parameters_info = operation._parameters.params.map do |param|
86
+ {
87
+ name: param[:name],
88
+ location: param[:in],
89
+ type: param[:schema][:type],
90
+ required: param[:required],
91
+ description: param[:description]
92
+ }
93
+ end
94
+
95
+ graph.add_node(Node.new(
96
+ id: doc_id,
97
+ type: "doc",
98
+ label: operation._summary || "#{info[:controller]}##{info[:action]}",
99
+ metadata: {
100
+ controller: info[:controller],
101
+ action: info[:action],
102
+ description: operation._description,
103
+ tags: operation._tags,
104
+ request_body: request_body_info,
105
+ responses: responses_info,
106
+ parameters: parameters_info
107
+ },
108
+ status: "documented"
109
+ ))
110
+ graph.add_edge(edge(doc_id, action_id, "documents", "high", "Docit registry"))
111
+ end
112
+
113
+ def add_schemas
114
+ Docit.schemas.each_key do |name|
115
+ graph.add_node(Node.new(
116
+ id: node_id("schema", name),
117
+ type: "schema",
118
+ label: name.to_s,
119
+ metadata: { properties: Docit.schemas[name].properties.map { |prop| prop[:name].to_s } }
120
+ ))
121
+ end
122
+ end
123
+
124
+ def add_models
125
+ return unless defined?(ActiveRecord::Base)
126
+
127
+ ActiveRecord::Base.descendants.each do |model|
128
+ next if model.name.nil?
129
+
130
+ model_id = node_id("model", model.name)
131
+ graph.add_node(Node.new(
132
+ id: model_id,
133
+ type: "model",
134
+ label: model.name,
135
+ file: model_file(model.name),
136
+ metadata: { table_name: table_name(model) }
137
+ ))
138
+ add_model_associations(model, model_id)
139
+ end
140
+ end
141
+
142
+ def add_model_associations(model, model_id)
143
+ return unless model.respond_to?(:reflect_on_all_associations)
144
+
145
+ model.reflect_on_all_associations.each do |association|
146
+ target = association.class_name
147
+ target_id = node_id("model", target)
148
+ next unless graph.nodes.key?(target_id)
149
+
150
+ graph.add_edge(edge(model_id, target_id, "association", "high", "ActiveRecord reflection: #{association.name}"))
151
+ end
152
+ end
153
+
154
+ def add_source_nodes
155
+ source_nodes = scanner.source_nodes
156
+ source_nodes.each { |node| graph.add_node(node) }
157
+
158
+ labels = graph.nodes.values.select { |node| %w[model service job mailer].include?(node.type) }.map(&:label)
159
+ graph.nodes.values.select { |node| %w[controller action service job mailer].include?(node.type) }.each do |source|
160
+ scanner.references_for(source.file, labels).each do |label|
161
+ target = graph.nodes.values.find { |node| node.label == label }
162
+ next unless target
163
+
164
+ graph.add_edge(edge(source.id, target.id, edge_type_for(target), "medium", "Constant reference in #{source.file}"))
165
+ end
166
+ end
167
+ end
168
+
169
+ def route_infos
170
+ return [] if defined?(Rails).nil? || Rails.application.routes.nil?
171
+
172
+ Rails.application.routes.routes.filter_map do |route|
173
+ controller_path = route.defaults[:controller]
174
+ action = route.defaults[:action]
175
+ next if controller_path.nil? || action.nil?
176
+ next if skip_route?(controller_path)
177
+
178
+ method = extract_verb(route)
179
+ next if VALID_METHODS.exclude?(method)
180
+
181
+ controller = "#{controller_path}_controller".camelize
182
+ next if excluded_path?(controller_file(controller))
183
+
184
+ {
185
+ controller: controller,
186
+ controller_path: controller_path,
187
+ action: action.to_s,
188
+ method: method,
189
+ path: normalize_path(route.path.spec.to_s)
190
+ }
191
+ end.uniq
192
+ end
193
+
194
+ def edge(source, target, type, confidence, evidence)
195
+ Edge.new(
196
+ id: "#{type}:#{source}->#{target}",
197
+ source: source,
198
+ target: target,
199
+ type: type,
200
+ confidence: confidence,
201
+ evidence: evidence
202
+ )
203
+ end
204
+
205
+ def node_id(type, label)
206
+ "#{type}:#{label.to_s.underscore.tr('/', ':')}"
207
+ end
208
+
209
+ def controller_file(controller)
210
+ "app/controllers/#{controller.underscore}.rb"
211
+ end
212
+
213
+ def model_file(model)
214
+ "app/models/#{model.underscore}.rb"
215
+ end
216
+
217
+ def edge_type_for(target)
218
+ target.type == "model" ? "uses_model" : "calls"
219
+ end
220
+
221
+ def table_name(model)
222
+ model.table_name if model.respond_to?(:table_name)
223
+ rescue ActiveRecord::StatementInvalid
224
+ nil
225
+ end
226
+
227
+ def skip_route?(controller_path)
228
+ SKIP_PREFIXES.any? { |prefix| controller_path.start_with?(prefix) }
229
+ end
230
+
231
+ def excluded_path?(path)
232
+ Docit.configuration.system_graph_excluded_paths.any? do |excluded|
233
+ path.start_with?(excluded.to_s)
234
+ end
235
+ end
236
+
237
+ def extract_verb(route)
238
+ verb = route.verb
239
+ verb = verb.source if verb.is_a?(Regexp)
240
+ verb.to_s.downcase.gsub(/[^a-z]/, "")
241
+ end
242
+
243
+ def normalize_path(path)
244
+ path
245
+ .gsub("(.:format)", "")
246
+ .gsub(/\(\.?:(\w+)\)/, '{\1}')
247
+ .gsub(/:(\w+)/, '{\1}')
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module SystemGraph
5
+ class SourceScanner
6
+ SOURCE_TYPES = {
7
+ "app/services" => "service",
8
+ "app/jobs" => "job",
9
+ "app/mailers" => "mailer"
10
+ }.freeze
11
+
12
+ def initialize(root:, excluded_paths: Docit.configuration.system_graph_excluded_paths)
13
+ @root = root
14
+ @excluded_paths = excluded_paths.map(&:to_s)
15
+ end
16
+
17
+ def source_nodes
18
+ SOURCE_TYPES.each_with_object([]) do |(dir, type), nodes|
19
+ scan_dir(dir).each do |path|
20
+ relative = relative_path(path)
21
+ label = constant_name(relative, dir)
22
+ nodes << Node.new(
23
+ id: node_id(type, label),
24
+ type: type,
25
+ label: label,
26
+ file: relative,
27
+ metadata: { path: relative }
28
+ )
29
+ end
30
+ end
31
+ end
32
+
33
+ def references_for(path, candidates)
34
+ return [] unless path && File.exist?(full_path(path))
35
+
36
+ content = File.read(full_path(path))
37
+ candidates.select do |candidate|
38
+ content.match?(/\b#{Regexp.escape(candidate)}\b/)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :root
45
+ attr_reader :excluded_paths
46
+
47
+ def scan_dir(dir)
48
+ full_dir = root.join(dir)
49
+ return [] unless Dir.exist?(full_dir)
50
+
51
+ Dir.glob(full_dir.join("**", "*.rb")).sort.reject do |path|
52
+ excluded?(relative_path(path))
53
+ end
54
+ end
55
+
56
+ def relative_path(path)
57
+ path.to_s.sub("#{root}/", "")
58
+ end
59
+
60
+ def constant_name(relative, dir)
61
+ relative.delete_prefix("#{dir}/").delete_suffix(".rb").camelize
62
+ end
63
+
64
+ def node_id(type, label)
65
+ "#{type}:#{label.underscore.tr('/', ':')}"
66
+ end
67
+
68
+ def full_path(path)
69
+ root.join(path)
70
+ end
71
+
72
+ def excluded?(path)
73
+ excluded_paths.any? { |excluded| path.start_with?(excluded) }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "system_graph/node"
4
+ require_relative "system_graph/edge"
5
+ require_relative "system_graph/graph"
6
+ require_relative "system_graph/source_scanner"
7
+ require_relative "system_graph/rails_analyzer"
8
+ require_relative "system_graph/generator"