docit 0.4.0 → 0.6.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.
@@ -16,6 +16,17 @@ module Docit
16
16
  render_ui(:scalar)
17
17
  end
18
18
 
19
+ def system
20
+ render_ui(:system)
21
+ end
22
+
23
+ def system_spec
24
+ return render(json: { error: "System graph disabled" }, status: :not_found) unless Docit.configuration.system_graph_enabled
25
+
26
+ RouteInspector.eager_load_controllers!
27
+ render json: SystemGraph::Generator.generate
28
+ end
29
+
19
30
  def spec
20
31
  RouteInspector.eager_load_controllers!
21
32
  render json: SchemaGenerator.generate
@@ -25,11 +36,16 @@ module Docit
25
36
 
26
37
  RENDERERS = {
27
38
  swagger: UI::SwaggerRenderer,
28
- scalar: UI::ScalarRenderer
39
+ scalar: UI::ScalarRenderer,
40
+ system: UI::SystemRenderer
29
41
  }.freeze
30
42
 
31
43
  def render_ui(ui_name)
32
- renderer = RENDERERS.fetch(ui_name).new(spec_url: spec_url, nav_paths: nav_paths)
44
+ renderer = RENDERERS.fetch(ui_name).new(
45
+ spec_url: spec_url,
46
+ system_url: system_url,
47
+ nav_paths: nav_paths
48
+ )
33
49
  render html: renderer.render.html_safe, layout: false
34
50
  end
35
51
 
@@ -37,9 +53,13 @@ module Docit
37
53
  "#{request.base_url}#{Docit::Engine.routes.url_helpers.spec_path}"
38
54
  end
39
55
 
56
+ def system_url
57
+ "#{request.base_url}#{Docit::Engine.routes.url_helpers.system_spec_path}"
58
+ end
59
+
40
60
  def nav_paths
41
61
  helpers = Docit::Engine.routes.url_helpers
42
- { swagger: helpers.swagger_path, scalar: helpers.scalar_path }
62
+ { swagger: helpers.swagger_path, scalar: helpers.scalar_path, system: helpers.system_path }
43
63
  end
44
64
  end
45
65
  end
data/config/routes.rb CHANGED
@@ -4,5 +4,7 @@ Docit::Engine.routes.draw do
4
4
  root to: "ui#index"
5
5
  get "swagger", to: "ui#swagger"
6
6
  get "scalar", to: "ui#scalar"
7
+ get "system.json", to: "ui#system_spec", defaults: { format: :json }, as: :system_spec
8
+ get "system", to: "ui#system", as: :system
7
9
  get "spec", to: "ui#spec", defaults: { format: :json }
8
10
  end
Binary file
Binary file
@@ -57,9 +57,7 @@ module Docit
57
57
  end
58
58
 
59
59
  initializer = Rails.root.join("config", "initializers", "docit.rb")
60
- if File.exist?(initializer) == false
61
- raise Docit::Error, "Docit is not installed. Run: rails generate docit:install"
62
- end
60
+ raise Docit::Error, "Docit is not installed. Run: rails generate docit:install" if File.exist?(initializer) == false
63
61
 
64
62
  routes_file = Rails.root.join("config", "routes.rb")
65
63
  return unless File.exist?(routes_file) && !File.read(routes_file).include?("Docit::Engine")
@@ -179,8 +177,15 @@ module Docit
179
177
  end
180
178
  end
181
179
 
180
+ endpoints = pluralize(@results[:generated], "endpoint")
181
+ files = pluralize(@results[:files].length, "file")
182
182
  @output.puts ""
183
- @output.puts "Generated docs for #{@results[:generated]} endpoint#{"s" if @results[:generated] > 1} in #{@results[:files].length} file#{"s" if @results[:files].length > 1}."
183
+ @output.puts "Generated docs for #{endpoints} in #{files}."
184
+ end
185
+
186
+ # "1 endpoint" / "2 endpoints" — keeps the summary lines short and readable.
187
+ def pluralize(count, noun)
188
+ "#{count} #{noun}#{"s" unless count == 1}"
184
189
  end
185
190
 
186
191
  def inject_tags(generated)
@@ -20,7 +20,7 @@ module Docit
20
20
  end
21
21
 
22
22
  def doc_module_name
23
- @controller_name.delete_suffix("Controller").gsub("::", "::") + "Docs"
23
+ "#{@controller_name.delete_suffix("Controller").gsub("::", "::")}Docs"
24
24
  end
25
25
 
26
26
  def file_exists?
@@ -44,7 +44,7 @@ module Docit
44
44
  match = content.match(class_pattern)
45
45
  return false if match.nil?
46
46
 
47
- indent = match[1][/^\s*/] + " "
47
+ indent = "#{match[1][/^\s*/]} "
48
48
  use_docs_line = "#{indent}use_docs #{doc_module_name}\n"
49
49
  content = content.sub(class_pattern, "\\1\n#{use_docs_line}")
50
50
 
@@ -100,7 +100,7 @@ module Docit
100
100
  lines << "#{" " * (@module_parts.length - 1 - i)}end"
101
101
  end
102
102
 
103
- lines.join("\n") + "\n"
103
+ "#{lines.join("\n")}\n"
104
104
  end
105
105
 
106
106
  def indent_block(block, depth)
@@ -59,18 +59,14 @@ module Docit
59
59
 
60
60
  def parse_retry_after(response, message)
61
61
  # Check Retry-After header first (seconds)
62
- if (header = response["Retry-After"])
63
- return header.to_f if header.to_f > 0
62
+ if (header = response["Retry-After"]) && header.to_f.positive?
63
+ return header.to_f
64
64
  end
65
65
 
66
66
  # Parse "try again in XmY.Zs" from error message
67
- if message =~ /(\d+)m([\d.]+)s/
68
- return (Regexp.last_match(1).to_i * 60) + Regexp.last_match(2).to_f
69
- end
67
+ return (Regexp.last_match(1).to_i * 60) + Regexp.last_match(2).to_f if message =~ /(\d+)m([\d.]+)s/
70
68
 
71
- if message =~ /([\d.]+)s/
72
- return Regexp.last_match(1).to_f
73
- end
69
+ return Regexp.last_match(1).to_f if message =~ /([\d.]+)s/
74
70
 
75
71
  nil
76
72
  end
@@ -50,8 +50,10 @@ module Docit
50
50
 
51
51
  inject_tags(grouped)
52
52
 
53
+ endpoints = "endpoint".pluralize(gaps.length)
54
+ files = "file".pluralize(@files_written.length)
53
55
  @output.puts ""
54
- @output.puts "Scaffolded #{gaps.length} endpoint#{"s" if gaps.length > 1} in #{@files_written.length} file#{"s" if @files_written.length > 1}."
56
+ @output.puts "Scaffolded #{gaps.length} #{endpoints} in #{@files_written.length} #{files}."
55
57
  @output.puts "Fill in the TODO placeholders in your doc files."
56
58
  @files_written
57
59
  end
@@ -24,7 +24,7 @@ module Docit
24
24
  insertion_point = find_insertion_point(content)
25
25
  return [] if insertion_point.nil?
26
26
 
27
- content = content.insert(insertion_point, "\n#{lines.join("\n")}")
27
+ content.insert(insertion_point, "\n#{lines.join("\n")}")
28
28
  File.write(initializer_path, content)
29
29
 
30
30
  new_tags
@@ -3,15 +3,16 @@
3
3
  module Docit
4
4
  module Builders
5
5
  # Builds the schema for a single HTTP response, including properties,
6
- # examples, and schema references.
6
+ # examples, headers, and schema references.
7
7
  class ResponseBuilder
8
- attr_reader :status, :description, :properties, :examples, :schema_ref
8
+ attr_reader :status, :description, :properties, :examples, :headers, :schema_ref
9
9
 
10
10
  def initialize(status:, description:)
11
11
  @status = status
12
12
  @description = description
13
13
  @properties = []
14
14
  @examples = []
15
+ @headers = []
15
16
  @schema_ref = nil
16
17
  end
17
18
 
@@ -19,6 +20,15 @@ module Docit
19
20
  @schema_ref = ref.to_sym
20
21
  end
21
22
 
23
+ # Declares a response header, e.g. a rate-limit or pagination header:
24
+ # header "X-RateLimit-Remaining", type: :integer, description: "..."
25
+ def header(name, type: :string, description: nil, example: nil)
26
+ entry = { name: name.to_s, type: type.to_s }
27
+ entry[:description] = description if description
28
+ entry[:example] = example unless example.nil?
29
+ @headers << entry
30
+ end
31
+
22
32
  def property(name, type:, format: nil, example: nil, enum: nil, description: nil, items: nil, **opts, &block)
23
33
  prop = { name: name, type: type }
24
34
  prop[:format] = format if format
@@ -5,7 +5,8 @@ module Docit
5
5
  class Configuration
6
6
  SUPPORTED_UIS = %i[scalar swagger].freeze
7
7
 
8
- attr_accessor :title, :version, :description, :base_url
8
+ attr_accessor :title, :version, :description, :base_url,
9
+ :system_graph_enabled, :system_graph_excluded_paths
9
10
  attr_reader :default_ui
10
11
 
11
12
  def initialize
@@ -13,6 +14,8 @@ module Docit
13
14
  @version = "1.0.0"
14
15
  @description = "Welcome to the API documentation. Browse the endpoints below to get started."
15
16
  @base_url = "/"
17
+ @system_graph_enabled = true
18
+ @system_graph_excluded_paths = []
16
19
  @default_ui = :scalar
17
20
  @security_schemes = {}
18
21
  @tags = []
@@ -24,9 +27,7 @@ module Docit
24
27
 
25
28
  def default_ui=(value)
26
29
  ui = value.to_sym
27
- unless SUPPORTED_UIS.include?(ui)
28
- raise ArgumentError, "Unsupported UI: #{value}. Must be one of: #{SUPPORTED_UIS.join(", ")}"
29
- end
30
+ raise ArgumentError, "Unsupported UI: #{value}. Must be one of: #{SUPPORTED_UIS.join(", ")}" unless SUPPORTED_UIS.include?(ui)
30
31
 
31
32
  @default_ui = ui
32
33
  end
@@ -104,10 +104,21 @@ module Docit
104
104
  entry[:content] = content
105
105
  end
106
106
 
107
+ entry[:headers] = build_response_headers(builder.headers) if builder.headers.any?
108
+
107
109
  hash[builder.status.to_s] = entry
108
110
  end
109
111
  end
110
112
 
113
+ def build_response_headers(headers)
114
+ headers.each_with_object({}) do |header, hash|
115
+ schema = { schema: { type: header[:type] } }
116
+ schema[:description] = header[:description] if header[:description]
117
+ schema[:schema][:example] = header[:example] if header.key?(:example)
118
+ hash[header[:name]] = schema
119
+ end
120
+ end
121
+
111
122
  def build_request_body(request_body_builder)
112
123
  if request_body_builder.schema_ref
113
124
  schema = { "$ref" => "#/components/schemas/#{request_body_builder.schema_ref}" }
@@ -138,43 +149,50 @@ module Docit
138
149
 
139
150
  def build_property_schema(prop)
140
151
  type = prop[:type].to_s
152
+ schema = base_property_schema(type, prop)
153
+
154
+ # These OpenAPI fields apply to any property shape, so set them once here
155
+ # rather than in each branch above. nil-checks let `false`/`0` through as
156
+ # explicit values (e.g. default: false), but skip unset options.
157
+ schema[:description] = prop[:description] if prop[:description]
158
+ schema[:example] = prop[:example] if prop[:example]
159
+ schema[:default] = prop[:default] unless prop[:default].nil?
160
+ schema[:nullable] = prop[:nullable] unless prop[:nullable].nil?
161
+ schema[:readOnly] = prop[:read_only] unless prop[:read_only].nil?
162
+ schema[:writeOnly] = prop[:write_only] unless prop[:write_only].nil?
163
+ schema
164
+ end
141
165
 
142
- if type == "file"
143
- schema = { type: "string", format: "binary" }
144
- schema[:description] = prop[:description] if prop[:description]
145
- schema
146
- elsif type == "array"
147
- schema = { type: "array" }
148
- if prop[:children]
149
- schema[:items] = {
150
- type: "object",
151
- properties: build_properties(prop[:children])
152
- }
166
+ def base_property_schema(type, prop)
167
+ case type
168
+ when "file"
169
+ { type: "string", format: "binary" }
170
+ when "array"
171
+ { type: "array", items: array_items_schema(prop) }
172
+ else
173
+ # An object with declared children becomes a nested schema; everything
174
+ # else (including a childless "object") is a scalar with type/format/enum.
175
+ if type == "object" && prop[:children]
176
+ { type: "object", properties: build_properties(prop[:children]) }
153
177
  else
154
- items_type = prop[:items]&.to_s || "string"
155
- schema[:items] = { type: items_type }
178
+ scalar_property_schema(type, prop)
156
179
  end
157
- schema[:description] = prop[:description] if prop[:description]
158
- schema[:example] = prop[:example] if prop[:example]
159
- schema
160
- elsif type == "object" && prop[:children]
161
- schema = {
162
- type: "object",
163
- properties: build_properties(prop[:children])
164
- }
165
- schema[:description] = prop[:description] if prop[:description]
166
- schema[:example] = prop[:example] if prop[:example]
167
- schema
168
- else
169
- schema = { type: type }
170
- schema[:format] = prop[:format].to_s if prop[:format]
171
- schema[:enum] = prop[:enum] if prop[:enum]
172
- schema[:example] = prop[:example] if prop[:example]
173
- schema[:description] = prop[:description] if prop[:description]
174
- schema
175
180
  end
176
181
  end
177
182
 
183
+ def array_items_schema(prop)
184
+ return { type: "object", properties: build_properties(prop[:children]) } if prop[:children]
185
+
186
+ { type: prop[:items]&.to_s || "string" }
187
+ end
188
+
189
+ def scalar_property_schema(type, prop)
190
+ schema = { type: type }
191
+ schema[:format] = prop[:format].to_s if prop[:format]
192
+ schema[:enum] = prop[:enum] if prop[:enum]
193
+ schema
194
+ end
195
+
178
196
  def build_component_schemas
179
197
  Docit.schemas.each_with_object({}) do |(name, definition), hash|
180
198
  schema = {
@@ -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