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,19 @@
|
|
|
1
|
+
name: Autodoc Verify
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches:
|
|
7
|
+
- main
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
verify:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: ruby/setup-ruby@v1
|
|
15
|
+
with:
|
|
16
|
+
ruby-version: "3.3"
|
|
17
|
+
bundler-cache: true
|
|
18
|
+
- name: Verify OpenAPI spec
|
|
19
|
+
run: bundle exec rake autodoc:verify
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RailsAutodoc.configure do |config|
|
|
4
|
+
config.title = "<%= Rails.application.class.module_parent_name %> API"
|
|
5
|
+
config.version = "1.0.0"
|
|
6
|
+
config.description = "Auto-generated API documentation"
|
|
7
|
+
config.mount_path = "/api-docs"
|
|
8
|
+
config.output_path = Rails.root.join("openapi/openapi.yaml")
|
|
9
|
+
config.exclude_paths = [%r{^/rails/}, %r{^/api-docs}]
|
|
10
|
+
config.cache_spec_in_dev = true
|
|
11
|
+
|
|
12
|
+
# config.security_schemes = {
|
|
13
|
+
# "bearer_auth" => {
|
|
14
|
+
# "type" => "http",
|
|
15
|
+
# "scheme" => "bearer",
|
|
16
|
+
# "bearerFormat" => "JWT"
|
|
17
|
+
# }
|
|
18
|
+
# }
|
|
19
|
+
# config.default_security = :bearer_auth
|
|
20
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
module AstTraversal
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def walk_nodes(node, &block)
|
|
8
|
+
yield node
|
|
9
|
+
|
|
10
|
+
node.children.compact.each do |child|
|
|
11
|
+
walk_nodes(child, &block) if child.is_a?(Parser::AST::Node)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def each_method_definition(class_node, &block)
|
|
16
|
+
return unless class_node
|
|
17
|
+
|
|
18
|
+
walk_nodes(class_node) do |node|
|
|
19
|
+
yield node if method_definition?(node)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def method_definition?(node)
|
|
24
|
+
%i[def defs].include?(node.type)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def method_name_for(node)
|
|
28
|
+
case node.type
|
|
29
|
+
when :def then node.children[0].to_s
|
|
30
|
+
when :defs then node.children[1].to_s
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def find_class_node(node, class_name: nil)
|
|
35
|
+
return nil unless node
|
|
36
|
+
|
|
37
|
+
if node.type == :class && (class_name.nil? || class_name_matches?(node, class_name))
|
|
38
|
+
node
|
|
39
|
+
else
|
|
40
|
+
node.children.compact.each do |child|
|
|
41
|
+
next unless child.is_a?(Parser::AST::Node)
|
|
42
|
+
|
|
43
|
+
found = find_class_node(child, class_name: class_name)
|
|
44
|
+
return found if found
|
|
45
|
+
end
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def class_name_matches?(node, class_name)
|
|
51
|
+
const_node = node.children[0]
|
|
52
|
+
return false unless const_node
|
|
53
|
+
|
|
54
|
+
full_name = const_path(const_node)
|
|
55
|
+
simple_name = class_name.split("::").last
|
|
56
|
+
|
|
57
|
+
full_name == class_name ||
|
|
58
|
+
full_name == simple_name ||
|
|
59
|
+
class_name.end_with?("::#{full_name}") ||
|
|
60
|
+
full_name.end_with?("::#{simple_name}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def const_path(node)
|
|
64
|
+
case node.type
|
|
65
|
+
when :const
|
|
66
|
+
parent = node.children[0]
|
|
67
|
+
name = node.children[1].to_s
|
|
68
|
+
parent ? "#{const_path(parent)}::#{name}" : name
|
|
69
|
+
else
|
|
70
|
+
""
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :title,
|
|
6
|
+
:version,
|
|
7
|
+
:description,
|
|
8
|
+
:mount_path,
|
|
9
|
+
:output_path,
|
|
10
|
+
:exclude_paths,
|
|
11
|
+
:include_engines,
|
|
12
|
+
:default_security,
|
|
13
|
+
:cache_spec_in_dev,
|
|
14
|
+
:servers,
|
|
15
|
+
:security_schemes
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@title = "Rails API"
|
|
19
|
+
@version = "1.0.0"
|
|
20
|
+
@description = "Auto-generated API documentation"
|
|
21
|
+
@mount_path = "/api-docs"
|
|
22
|
+
@output_path = nil
|
|
23
|
+
@exclude_paths = [%r{^/rails/}, %r{^/api-docs}]
|
|
24
|
+
@include_engines = []
|
|
25
|
+
@default_security = nil
|
|
26
|
+
@cache_spec_in_dev = true
|
|
27
|
+
@servers = []
|
|
28
|
+
@security_schemes = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def excluded_path?(path)
|
|
32
|
+
exclude_paths.any? { |pattern| pattern.match?(path) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolved_output_path
|
|
36
|
+
return output_path if output_path
|
|
37
|
+
|
|
38
|
+
if defined?(Rails) && Rails.respond_to?(:root)
|
|
39
|
+
Rails.root.join("openapi/openapi.yaml")
|
|
40
|
+
else
|
|
41
|
+
Pathname.new("openapi/openapi.yaml")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
module DSL
|
|
5
|
+
module ControllerExtensions
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def swagger_doc(action:, &block)
|
|
10
|
+
RailsAutodoc.registry.register(self, action, &block)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class AnnotationBuilder
|
|
15
|
+
def initialize(annotation)
|
|
16
|
+
@annotation = annotation
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def summary(text)
|
|
20
|
+
@annotation.summary = text
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def description(text)
|
|
24
|
+
@annotation.description = text
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def tag(*tags)
|
|
28
|
+
@annotation.tags.concat(tags.map(&:to_s))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def deprecated(value = true)
|
|
32
|
+
@annotation.deprecated = value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def exclude(value = true)
|
|
36
|
+
@annotation.exclude = value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def body_param(name, type, options = {})
|
|
40
|
+
@annotation.body_params << { name: name.to_s, type: type.to_s }.merge(options)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def query_param(name, type, options = {})
|
|
44
|
+
@annotation.query_params << {
|
|
45
|
+
name: name.to_s,
|
|
46
|
+
type: type.to_s,
|
|
47
|
+
required: options.fetch(:required, false)
|
|
48
|
+
}.merge(options)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def response(status, options = {})
|
|
52
|
+
@annotation.responses[status.to_s] = options
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def security(scheme)
|
|
56
|
+
@annotation.security = scheme
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def request_body(schema)
|
|
60
|
+
@annotation.request_body_schema = schema
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rails_autodoc" unless defined?(RailsAutodoc) && RailsAutodoc.respond_to?(:configure)
|
|
4
|
+
|
|
5
|
+
module RailsAutodoc
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace RailsAutodoc
|
|
8
|
+
|
|
9
|
+
config.generators do |g|
|
|
10
|
+
g.test_framework :rspec
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RailsAutodoc
|
|
7
|
+
class Generator
|
|
8
|
+
def initialize(config: RailsAutodoc.config)
|
|
9
|
+
@config = config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def generate
|
|
13
|
+
operations = RouteInspector.new(config: @config).operations
|
|
14
|
+
OpenapiSpecBuilder.new(operations: operations, config: @config).build
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate!
|
|
18
|
+
spec = generate
|
|
19
|
+
write_spec(spec)
|
|
20
|
+
spec
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write_spec(spec)
|
|
24
|
+
output_path = @config.resolved_output_path
|
|
25
|
+
output_path.dirname.mkpath
|
|
26
|
+
output_path.write(YAML.dump(spec))
|
|
27
|
+
spec
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_json(*_args)
|
|
31
|
+
JSON.pretty_generate(generate)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def verify!
|
|
35
|
+
output_path = @config.resolved_output_path
|
|
36
|
+
current = output_path.exist? ? YAML.safe_load(output_path.read, permitted_classes: [Date, Time]) : {}
|
|
37
|
+
fresh = generate
|
|
38
|
+
|
|
39
|
+
unless normalize_spec(current) == normalize_spec(fresh)
|
|
40
|
+
raise SpecDriftError, "OpenAPI spec drift detected at #{output_path}. Run `rake autodoc:generate`."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def normalize_spec(spec)
|
|
49
|
+
JSON.pretty_generate(spec)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class SpecDriftError < StandardError; end
|
|
54
|
+
end
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
class OpenapiSpecBuilder
|
|
5
|
+
def initialize(
|
|
6
|
+
operations:,
|
|
7
|
+
config: RailsAutodoc.config,
|
|
8
|
+
registry: RailsAutodoc.registry,
|
|
9
|
+
schema_mapper: SchemaMapper.new
|
|
10
|
+
)
|
|
11
|
+
@operations = operations
|
|
12
|
+
@config = config
|
|
13
|
+
@registry = registry
|
|
14
|
+
@schema_mapper = schema_mapper
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build
|
|
18
|
+
{
|
|
19
|
+
"openapi" => "3.0.3",
|
|
20
|
+
"info" => info_block,
|
|
21
|
+
"servers" => servers_block,
|
|
22
|
+
"tags" => tags_block,
|
|
23
|
+
"paths" => paths_block,
|
|
24
|
+
"components" => components_block
|
|
25
|
+
}.compact
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def info_block
|
|
31
|
+
{
|
|
32
|
+
"title" => @config.title,
|
|
33
|
+
"version" => @config.version,
|
|
34
|
+
"description" => @config.description
|
|
35
|
+
}.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def servers_block
|
|
39
|
+
return @config.servers if @config.servers.any?
|
|
40
|
+
|
|
41
|
+
if defined?(Rails) && Rails.application.routes.default_url_options[:host]
|
|
42
|
+
host = Rails.application.routes.default_url_options[:host]
|
|
43
|
+
[{ "url" => "https://#{host}" }]
|
|
44
|
+
else
|
|
45
|
+
[{ "url" => "http://localhost:3000" }]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tags_block
|
|
50
|
+
@operations.flat_map(&:tags).uniq.sort.map { |tag| { "name" => tag } }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def paths_block
|
|
54
|
+
paths = {}
|
|
55
|
+
|
|
56
|
+
@operations.each do |operation|
|
|
57
|
+
annotation = @registry.find(operation.controller_class, operation.action)
|
|
58
|
+
next if annotation&.exclude
|
|
59
|
+
|
|
60
|
+
path_key = operation.openapi_path
|
|
61
|
+
paths[path_key] ||= {}
|
|
62
|
+
paths[path_key][operation.verb.downcase] = build_operation(operation, annotation)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
paths
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_operation(operation, annotation)
|
|
69
|
+
model_name = @schema_mapper.infer_model_from_controller(operation.controller_class)
|
|
70
|
+
request_body = build_request_body(operation, annotation, model_name)
|
|
71
|
+
responses = build_responses(operation, annotation, model_name)
|
|
72
|
+
|
|
73
|
+
operation_hash = {
|
|
74
|
+
"operationId" => annotation&.operation_id || operation.operation_id,
|
|
75
|
+
"tags" => merged_tags(operation, annotation),
|
|
76
|
+
"summary" => annotation&.summary || "#{operation.verb} #{operation.openapi_path}",
|
|
77
|
+
"description" => annotation&.description,
|
|
78
|
+
"deprecated" => annotation&.deprecated || false,
|
|
79
|
+
"parameters" => build_parameters(operation, annotation),
|
|
80
|
+
"responses" => responses
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
operation_hash["requestBody"] = request_body if request_body
|
|
84
|
+
operation_hash["security"] = build_security(annotation) if annotation&.security || @config.default_security
|
|
85
|
+
operation_hash.compact
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def merged_tags(operation, annotation)
|
|
89
|
+
tags = operation.tags.dup
|
|
90
|
+
tags.concat(annotation.tags) if annotation&.tags&.any?
|
|
91
|
+
tags.uniq
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_parameters(operation, annotation)
|
|
95
|
+
params = operation.path_params.map do |name|
|
|
96
|
+
{
|
|
97
|
+
"name" => name,
|
|
98
|
+
"in" => "path",
|
|
99
|
+
"required" => true,
|
|
100
|
+
"schema" => { "type" => "string" }
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
annotation&.query_params&.each do |query_param|
|
|
105
|
+
params << {
|
|
106
|
+
"name" => query_param[:name],
|
|
107
|
+
"in" => "query",
|
|
108
|
+
"required" => query_param.fetch(:required, false),
|
|
109
|
+
"schema" => query_schema(query_param)
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
inferred_query_params(operation).each do |query_param|
|
|
114
|
+
next if params.any? { |entry| entry["name"] == query_param[:name] }
|
|
115
|
+
|
|
116
|
+
params << {
|
|
117
|
+
"name" => query_param[:name],
|
|
118
|
+
"in" => "query",
|
|
119
|
+
"required" => query_param.fetch(:required, false),
|
|
120
|
+
"schema" => query_schema(query_param)
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
params
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def inferred_query_params(operation)
|
|
128
|
+
source_path = controller_source_path(operation.controller_class)
|
|
129
|
+
return [] unless source_path&.exist?
|
|
130
|
+
|
|
131
|
+
StrongParamsParser.new(
|
|
132
|
+
source_path: source_path,
|
|
133
|
+
class_name: operation.controller_class.name
|
|
134
|
+
).query_params_for_action(operation.action)
|
|
135
|
+
rescue StandardError
|
|
136
|
+
[]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def query_schema(query_param)
|
|
140
|
+
schema = { "type" => query_param[:type] || "string" }
|
|
141
|
+
schema["enum"] = query_param[:enum] if query_param[:enum]
|
|
142
|
+
schema
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_request_body(operation, annotation, model_name)
|
|
146
|
+
if annotation&.request_body_schema
|
|
147
|
+
return {
|
|
148
|
+
"required" => true,
|
|
149
|
+
"content" => {
|
|
150
|
+
"application/json" => {
|
|
151
|
+
"schema" => annotation.request_body_schema
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
schema = infer_request_schema(operation, model_name)
|
|
158
|
+
|
|
159
|
+
schema = { type: "object", properties: {}, required: [] } if schema.nil? && annotation&.body_params&.any?
|
|
160
|
+
|
|
161
|
+
return nil unless schema
|
|
162
|
+
|
|
163
|
+
if annotation&.body_params&.any?
|
|
164
|
+
schema[:properties] ||= {}
|
|
165
|
+
schema[:required] ||= []
|
|
166
|
+
|
|
167
|
+
annotation.body_params.each do |param|
|
|
168
|
+
schema[:properties][param[:name]] = {
|
|
169
|
+
"type" => param[:type]
|
|
170
|
+
}.tap do |entry|
|
|
171
|
+
entry["enum"] = param[:enum] if param[:enum]
|
|
172
|
+
end
|
|
173
|
+
schema[:required] << param[:name] unless schema[:required].include?(param[:name])
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
{
|
|
178
|
+
"required" => true,
|
|
179
|
+
"content" => {
|
|
180
|
+
"application/json" => {
|
|
181
|
+
"schema" => deep_stringify(schema)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def infer_request_schema(operation, model_name)
|
|
188
|
+
source_path = controller_source_path(operation.controller_class)
|
|
189
|
+
return nil unless source_path&.exist?
|
|
190
|
+
|
|
191
|
+
parser = StrongParamsParser.new(
|
|
192
|
+
source_path: source_path,
|
|
193
|
+
class_name: operation.controller_class.name
|
|
194
|
+
)
|
|
195
|
+
params_result = parser.params_for_action(operation.action)
|
|
196
|
+
return nil unless params_result
|
|
197
|
+
|
|
198
|
+
schema = params_result.schema.dup
|
|
199
|
+
@schema_mapper.apply_types!(schema, model_name: model_name)
|
|
200
|
+
|
|
201
|
+
if params_result.root_key
|
|
202
|
+
{
|
|
203
|
+
type: "object",
|
|
204
|
+
properties: {
|
|
205
|
+
params_result.root_key.to_s => deep_stringify(schema)
|
|
206
|
+
},
|
|
207
|
+
required: [params_result.root_key.to_s]
|
|
208
|
+
}
|
|
209
|
+
else
|
|
210
|
+
schema
|
|
211
|
+
end
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def build_responses(operation, annotation, model_name)
|
|
217
|
+
if annotation&.responses&.any?
|
|
218
|
+
return annotation.responses.transform_keys(&:to_s).transform_values do |response|
|
|
219
|
+
build_response_entry(response)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
source_path = controller_source_path(operation.controller_class)
|
|
224
|
+
hints = if source_path&.exist?
|
|
225
|
+
ResponseInferencer.new(
|
|
226
|
+
source_path: source_path,
|
|
227
|
+
class_name: operation.controller_class.name
|
|
228
|
+
).responses_for_action(operation.action, verb: operation.verb)
|
|
229
|
+
else
|
|
230
|
+
[]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if hints.empty?
|
|
234
|
+
hints = [ResponseInferencer::ResponseHint.new(status: "200", schema: { type: "object" },
|
|
235
|
+
description: "Successful response")]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
hints.each_with_object({}) do |hint, hash|
|
|
239
|
+
entry = { "description" => hint.description || "Response" }
|
|
240
|
+
if hint.schema
|
|
241
|
+
entry["content"] = {
|
|
242
|
+
"application/json" => {
|
|
243
|
+
"schema" => deep_stringify(hint.schema)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
elsif hint.schema_ref
|
|
247
|
+
entry["content"] = {
|
|
248
|
+
"application/json" => {
|
|
249
|
+
"schema" => { "$ref" => "#/components/schemas/#{hint.schema_ref}" }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
elsif model_name && hint.status != "204"
|
|
253
|
+
entry["content"] = {
|
|
254
|
+
"application/json" => {
|
|
255
|
+
"schema" => { "$ref" => "#/components/schemas/#{model_name}" }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
hash[hint.status] = entry
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def build_response_entry(response)
|
|
264
|
+
entry = { "description" => response[:description] || "Response" }
|
|
265
|
+
if response[:ref]
|
|
266
|
+
entry["content"] = {
|
|
267
|
+
"application/json" => {
|
|
268
|
+
"schema" => { "$ref" => "#/components/schemas/#{response[:ref]}" }
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
elsif response[:schema]
|
|
272
|
+
entry["content"] = {
|
|
273
|
+
"application/json" => {
|
|
274
|
+
"schema" => deep_stringify(response[:schema])
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
end
|
|
278
|
+
entry
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def build_security(annotation)
|
|
282
|
+
scheme = annotation&.security || @config.default_security
|
|
283
|
+
[{ scheme.to_s => [] }]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def components_block
|
|
287
|
+
schemas = @schema_mapper.all_model_schemas
|
|
288
|
+
components = {}
|
|
289
|
+
components["schemas"] = schemas.transform_values { |schema| deep_stringify(schema) } if schemas.any?
|
|
290
|
+
components["securitySchemes"] = @config.security_schemes if @config.security_schemes.any?
|
|
291
|
+
components.presence
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def controller_source_path(controller_class)
|
|
295
|
+
conventional_controller_path(controller_class) || existing_source_location_path(controller_class)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def existing_source_location_path(controller_class)
|
|
299
|
+
path = source_location_path(controller_class)
|
|
300
|
+
path if path&.exist?
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def source_location_path(controller_class)
|
|
304
|
+
return nil unless controller_class.respond_to?(:instance_method)
|
|
305
|
+
|
|
306
|
+
Pathname.new(controller_class.instance_method(:initialize).source_location.first)
|
|
307
|
+
rescue StandardError
|
|
308
|
+
nil
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def conventional_controller_path(controller_class)
|
|
312
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:root)
|
|
313
|
+
|
|
314
|
+
relative = "#{controller_class.name.underscore}.rb"
|
|
315
|
+
path = Rails.root.join("app/controllers", relative)
|
|
316
|
+
path.exist? ? path : nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def deep_stringify(value)
|
|
320
|
+
case value
|
|
321
|
+
when Hash
|
|
322
|
+
value.each_with_object({}) do |(key, val), result|
|
|
323
|
+
result[key.to_s] = deep_stringify(val)
|
|
324
|
+
end
|
|
325
|
+
when Array
|
|
326
|
+
value.map { |item| deep_stringify(item) }
|
|
327
|
+
when Symbol
|
|
328
|
+
value.to_s
|
|
329
|
+
else
|
|
330
|
+
value
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAutodoc
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
rake_tasks do
|
|
6
|
+
load "rails_autodoc/tasks/autodoc.rake"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
initializer "rails_autodoc.configure" do
|
|
10
|
+
if Rails.root.join("config/initializers/rails_autodoc.rb").exist?
|
|
11
|
+
require Rails.root.join("config/initializers/rails_autodoc.rb")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer "rails_autodoc.dsl" do
|
|
16
|
+
ActiveSupport.on_load(:action_controller) do
|
|
17
|
+
include RailsAutodoc::DSL::ControllerExtensions
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
config.to_prepare do
|
|
22
|
+
RailsAutodoc.registry.clear! if Rails.env.development?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|