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.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +63 -0
  3. data/.gitignore +16 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +81 -0
  6. data/.yardopts +3 -0
  7. data/Appraisals +26 -0
  8. data/CHANGELOG.md +20 -0
  9. data/CONTRIBUTING.md +54 -0
  10. data/Gemfile +10 -0
  11. data/Gemfile.lock +298 -0
  12. data/LICENSE.txt +21 -0
  13. data/README.md +111 -0
  14. data/Rakefile +26 -0
  15. data/app/controllers/rails_autodoc/spec_controller.rb +52 -0
  16. data/config/routes.rb +6 -0
  17. data/docs/annotation-dsl.md +56 -0
  18. data/docs/architecture.md +37 -0
  19. data/docs/ci-integration.md +32 -0
  20. data/docs/configuration.md +48 -0
  21. data/docs/faq.md +29 -0
  22. data/docs/getting-started.md +54 -0
  23. data/docs/index.md +21 -0
  24. data/docs/inference-rules.md +74 -0
  25. data/docs/limitations.md +34 -0
  26. data/docs/migration-from-rswag.md +42 -0
  27. data/docs/serializer-support.md +25 -0
  28. data/lib/generators/rails_autodoc/install_generator.rb +34 -0
  29. data/lib/generators/rails_autodoc/templates/autodoc-verify.yml +19 -0
  30. data/lib/generators/rails_autodoc/templates/initializer.rb +20 -0
  31. data/lib/rails_autodoc/ast_traversal.rb +74 -0
  32. data/lib/rails_autodoc/configuration.rb +45 -0
  33. data/lib/rails_autodoc/dsl/controller_extensions.rb +65 -0
  34. data/lib/rails_autodoc/engine.rb +13 -0
  35. data/lib/rails_autodoc/generator.rb +54 -0
  36. data/lib/rails_autodoc/openapi_spec_builder.rb +334 -0
  37. data/lib/rails_autodoc/railtie.rb +25 -0
  38. data/lib/rails_autodoc/registry.rb +71 -0
  39. data/lib/rails_autodoc/response_inferencer.rb +158 -0
  40. data/lib/rails_autodoc/route_inspector.rb +139 -0
  41. data/lib/rails_autodoc/schema_mapper.rb +142 -0
  42. data/lib/rails_autodoc/serializers/active_model_serializer.rb +27 -0
  43. data/lib/rails_autodoc/serializers/alba.rb +39 -0
  44. data/lib/rails_autodoc/serializers/base.rb +19 -0
  45. data/lib/rails_autodoc/serializers/blueprinter.rb +27 -0
  46. data/lib/rails_autodoc/serializers/registry.rb +29 -0
  47. data/lib/rails_autodoc/strong_params_parser.rb +188 -0
  48. data/lib/rails_autodoc/tasks/autodoc.rake +26 -0
  49. data/lib/rails_autodoc/version.rb +5 -0
  50. data/lib/rails_autodoc.rb +47 -0
  51. data/mkdocs.yml +16 -0
  52. data/rails-autodoc.gemspec +61 -0
  53. data/spec/combustion/config.ru +4 -0
  54. data/spec/dummy/app/assets/config/manifest.js +1 -0
  55. data/spec/dummy/app/controllers/api/v1/users_controller.rb +45 -0
  56. data/spec/dummy/app/models/user.rb +5 -0
  57. data/spec/dummy/config/application.rb +14 -0
  58. data/spec/dummy/config/boot.rb +5 -0
  59. data/spec/dummy/config/database.yml +3 -0
  60. data/spec/dummy/config/environment.rb +5 -0
  61. data/spec/dummy/config/environments/test.rb +12 -0
  62. data/spec/dummy/config/initializers/rails_autodoc.rb +8 -0
  63. data/spec/dummy/config/initializers/sqlite3_boolean.rb +8 -0
  64. data/spec/dummy/config/routes.rb +11 -0
  65. data/spec/dummy/db/migrate/001_create_users.rb +14 -0
  66. data/spec/dummy/db/schema.rb +12 -0
  67. data/spec/rails_autodoc/configuration_spec.rb +34 -0
  68. data/spec/rails_autodoc/dsl_integration_spec.rb +77 -0
  69. data/spec/rails_autodoc/engine_spec.rb +26 -0
  70. data/spec/rails_autodoc/gem_spec.rb +27 -0
  71. data/spec/rails_autodoc/generator_spec.rb +39 -0
  72. data/spec/rails_autodoc/golden_spec.rb +67 -0
  73. data/spec/rails_autodoc/integration_spec.rb +114 -0
  74. data/spec/rails_autodoc/registry_spec.rb +26 -0
  75. data/spec/rails_autodoc/response_inferencer_spec.rb +26 -0
  76. data/spec/rails_autodoc/route_inspector_spec.rb +56 -0
  77. data/spec/rails_autodoc/schema_mapper_spec.rb +42 -0
  78. data/spec/rails_autodoc/serializers/registry_spec.rb +33 -0
  79. data/spec/rails_autodoc/strong_params_parser_spec.rb +41 -0
  80. data/spec/spec_helper.rb +43 -0
  81. metadata +320 -0
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "RailsAutodoc gem packaging" do
6
+ it "builds a non-empty gemspec file list" do
7
+ gemspec = Gem::Specification.load("rails-autodoc.gemspec")
8
+
9
+ expect(gemspec.files).not_to be_empty
10
+ expect(gemspec.files).to include("lib/rails_autodoc.rb")
11
+ expect(gemspec.files).to include("lib/rails_autodoc/generator.rb")
12
+ expect(gemspec.files).to include("README.md")
13
+ end
14
+
15
+ it "requires Ruby 2.7 or newer" do
16
+ gemspec = Gem::Specification.load("rails-autodoc.gemspec")
17
+
18
+ expect(gemspec.required_ruby_version).to eq(Gem::Requirement.new(">= 2.7.0"))
19
+ end
20
+
21
+ it "declares runtime dependencies needed for inference" do
22
+ gemspec = Gem::Specification.load("rails-autodoc.gemspec")
23
+ names = gemspec.dependencies.map(&:name)
24
+
25
+ expect(names).to include("activesupport", "parser", "psych", "railties")
26
+ end
27
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe RailsAutodoc::Generator do
6
+ subject(:generator) { described_class.new }
7
+
8
+ before do
9
+ Rails.application.routes.draw do
10
+ mount RailsAutodoc::Engine => "/api-docs"
11
+ namespace :api do
12
+ namespace :v1 do
13
+ resources :users, only: %i[index show create update destroy]
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ it "generates a valid OpenAPI document" do
20
+ spec = generator.generate
21
+ expect(spec["openapi"]).to eq("3.0.3")
22
+ expect(spec["paths"]).not_to be_empty
23
+ end
24
+
25
+ it "includes user paths" do
26
+ spec = generator.generate
27
+ expect(spec["paths"].keys).to include("/api/v1/users", "/api/v1/users/{id}")
28
+ end
29
+
30
+ it "writes spec to configured output path" do
31
+ generator.generate!
32
+ expect(RailsAutodoc.config.resolved_output_path).to exist
33
+ end
34
+
35
+ it "verifies unchanged specs" do
36
+ generator.generate!
37
+ expect { generator.verify! }.not_to raise_error
38
+ end
39
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Golden OpenAPI fixtures" do
6
+ before do
7
+ users_controller = Class.new(ActionController::API) do
8
+ def self.name
9
+ "Api::V1::UsersController"
10
+ end
11
+
12
+ def index
13
+ render json: User.all
14
+ end
15
+
16
+ def create
17
+ user = User.new(user_params)
18
+ render json: user, status: :created
19
+ end
20
+
21
+ private
22
+
23
+ def user_params
24
+ params.require(:user).permit(:name, :email, :age, address: %i[street city], tags: [])
25
+ end
26
+ end
27
+
28
+ stub_const("Api::V1::UsersController", users_controller)
29
+
30
+ Rails.application.routes.draw do
31
+ namespace :api do
32
+ namespace :v1 do
33
+ resources :users, only: %i[index create]
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ it "matches expected create request schema fields" do
40
+ spec = RailsAutodoc::Generator.new.generate
41
+ post_operation = spec.dig("paths", "/api/v1/users", "post")
42
+ user_schema = post_operation.dig("requestBody", "content", "application/json", "schema", "properties", "user",
43
+ "properties")
44
+
45
+ expect(user_schema.keys).to include("name", "email", "age", "address", "tags")
46
+ expect(user_schema["age"]["type"]).to eq("integer")
47
+ end
48
+
49
+ it "matches key sections from the golden fixture" do
50
+ expected = YAML.safe_load(
51
+ File.read(File.expand_path("../fixtures/expected_specs/users_index_create.yaml", __dir__)),
52
+ permitted_classes: [Date, Time]
53
+ )
54
+ actual = RailsAutodoc::Generator.new.generate
55
+
56
+ expect(actual.dig("paths", "/api/v1/users", "get", "responses", "200")).to be_present
57
+ expect(actual.dig("paths", "/api/v1/users", "post", "requestBody", "required")).to be(true)
58
+ expect(actual.dig("paths", "/api/v1/users", "post", "responses", "201")).to be_present
59
+
60
+ expected_post_schema = expected.dig("paths", "/api/v1/users", "post", "requestBody", "content", "application/json",
61
+ "schema")
62
+ actual_post_schema = actual.dig("paths", "/api/v1/users", "post", "requestBody", "content", "application/json",
63
+ "schema")
64
+
65
+ expect(actual_post_schema).to eq(expected_post_schema)
66
+ end
67
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "RailsAutodoc integration", type: :request do
6
+ let(:generator) { RailsAutodoc::Generator.new }
7
+ let(:spec) { generator.generate }
8
+
9
+ before do
10
+ RailsAutodoc.configure do |config|
11
+ config.title = "Dummy API"
12
+ config.version = "1.0.0"
13
+ config.output_path = Rails.root.join("tmp/openapi.yaml")
14
+ end
15
+
16
+ Rails.application.routes.draw do
17
+ mount RailsAutodoc::Engine => "/api-docs"
18
+ namespace :api do
19
+ namespace :v1 do
20
+ resources :users, only: %i[index show create update destroy]
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ it "loads the published gem version" do
27
+ expect(RailsAutodoc::VERSION).to match(/\A\d+\.\d+\.\d+\z/)
28
+ end
29
+
30
+ it "generates a complete OpenAPI document" do
31
+ expect(spec).to include("openapi", "info", "servers", "tags", "paths", "components")
32
+ expect(spec["openapi"]).to eq("3.0.3")
33
+ expect(spec.dig("info", "title")).to eq("Dummy API")
34
+ expect(spec.dig("info", "version")).to eq("1.0.0")
35
+ expect(spec["servers"]).not_to be_empty
36
+ expect(spec["tags"]).not_to be_empty
37
+ end
38
+
39
+ it "documents CRUD user paths" do
40
+ expect(spec["paths"].keys).to include("/api/v1/users", "/api/v1/users/{id}")
41
+ expect(spec.dig("paths", "/api/v1/users", "get", "operationId")).to eq("api_v1_users_controller_index")
42
+ expect(spec.dig("paths", "/api/v1/users", "post", "operationId")).to eq("api_v1_users_controller_create")
43
+ end
44
+
45
+ it "documents path parameters for member routes" do
46
+ parameters = spec.dig("paths", "/api/v1/users/{id}", "get", "parameters")
47
+ id_param = parameters.find { |entry| entry["name"] == "id" }
48
+
49
+ expect(id_param).to include("in" => "path", "required" => true)
50
+ expect(id_param.dig("schema", "type")).to eq("string")
51
+ end
52
+
53
+ it "documents nested strong params on create" do
54
+ user_schema = spec.dig(
55
+ "paths", "/api/v1/users", "post",
56
+ "requestBody", "content", "application/json", "schema",
57
+ "properties", "user", "properties"
58
+ )
59
+
60
+ expect(user_schema.keys).to include("name", "email", "age", "address", "tags")
61
+ expect(user_schema["age"]["type"]).to eq("integer")
62
+ expect(user_schema["address"]["type"]).to eq("object")
63
+ expect(user_schema["tags"]["type"]).to eq("array")
64
+ end
65
+
66
+ it "infers response status codes from controller actions" do
67
+ expect(spec.dig("paths", "/api/v1/users", "post", "responses").keys).to include("201", "422")
68
+ expect(spec.dig("paths", "/api/v1/users/{id}", "delete", "responses").keys).to include("204")
69
+ end
70
+
71
+ it "includes User schema in components" do
72
+ user_component = spec.dig("components", "schemas", "User")
73
+
74
+ expect(user_component).to include("type" => "object")
75
+ expect(user_component).to have_key("properties")
76
+ expect(user_component["properties"].keys).to include("name", "email", "age", "active")
77
+ end
78
+
79
+ it "does not document excluded mount paths" do
80
+ expect(spec["paths"].keys).not_to include("/api-docs")
81
+ expect(spec["paths"].keys).not_to include("/api-docs/spec.json")
82
+ end
83
+
84
+ it "raises when generated output drifts from committed file" do
85
+ output_path = RailsAutodoc.config.resolved_output_path
86
+ original = output_path.exist? ? output_path.read : nil
87
+ generator.generate!
88
+
89
+ expect { generator.verify! }.not_to raise_error
90
+
91
+ output_path.write("openapi: 3.0.3\ninfo:\n title: Changed\n")
92
+
93
+ expect { generator.verify! }.to raise_error(
94
+ RailsAutodoc::SpecDriftError,
95
+ /OpenAPI spec drift detected/
96
+ )
97
+ ensure
98
+ if original
99
+ output_path.write(original)
100
+ elsif output_path.exist?
101
+ output_path.delete
102
+ end
103
+ end
104
+
105
+ it "serves generated operations over HTTP" do
106
+ get "/api-docs/spec.json"
107
+
108
+ expect(response).to have_http_status(:ok)
109
+
110
+ body = JSON.parse(response.body)
111
+ expect(body.dig("paths", "/api/v1/users", "post", "requestBody")).not_to be_nil
112
+ expect(body.dig("components", "schemas", "User", "properties", "email", "type")).to eq("string")
113
+ end
114
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe RailsAutodoc::Registry do
6
+ controller_class = Class.new do
7
+ def self.name
8
+ "DocsController"
9
+ end
10
+ end
11
+
12
+ it "registers annotation overrides" do
13
+ RailsAutodoc.registry.register(controller_class, :create) do
14
+ summary "Create resource"
15
+ tag "Docs"
16
+ body_param :role, :string, enum: %w[admin user]
17
+ response 201, ref: "User"
18
+ end
19
+
20
+ annotation = RailsAutodoc.registry.find(controller_class, :create)
21
+ expect(annotation.summary).to eq("Create resource")
22
+ expect(annotation.tags).to include("Docs")
23
+ expect(annotation.body_params.first[:enum]).to eq(%w[admin user])
24
+ expect(annotation.responses["201"][:ref]).to eq("User")
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe RailsAutodoc::ResponseInferencer do
6
+ let(:source_path) { File.expand_path("../dummy/app/controllers/api/v1/users_controller.rb", __dir__) }
7
+
8
+ subject(:inferencer) do
9
+ described_class.new(source_path: source_path, class_name: "Api::V1::UsersController")
10
+ end
11
+
12
+ it "infers default GET response" do
13
+ responses = inferencer.responses_for_action("index", verb: "GET")
14
+ expect(responses.first.status).to eq("200")
15
+ end
16
+
17
+ it "infers POST created status from render call" do
18
+ responses = inferencer.responses_for_action("create", verb: "POST")
19
+ expect(responses.map(&:status)).to include("201")
20
+ end
21
+
22
+ it "infers DELETE no content" do
23
+ responses = inferencer.responses_for_action("destroy", verb: "DELETE")
24
+ expect(responses.map(&:status)).to include("204")
25
+ end
26
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe RailsAutodoc::RouteInspector do
6
+ subject(:operations) { described_class.new.operations }
7
+
8
+ before do
9
+ users_controller = Class.new(ActionController::API) do
10
+ def self.name
11
+ "Api::V1::UsersController"
12
+ end
13
+
14
+ def index
15
+ head :ok
16
+ end
17
+
18
+ def show
19
+ head :ok
20
+ end
21
+
22
+ def create
23
+ head :ok
24
+ end
25
+ end
26
+
27
+ stub_const("Api::V1::UsersController", users_controller)
28
+
29
+ Rails.application.routes.draw do
30
+ namespace :api do
31
+ namespace :v1 do
32
+ resources :users, only: %i[index show create]
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ it "discovers REST routes" do
39
+ expect(operations.map(&:verb)).to include("GET", "POST")
40
+ end
41
+
42
+ it "extracts path parameters" do
43
+ show_operation = operations.find { |op| op.action == "show" }
44
+ expect(show_operation.path_params).to include("id")
45
+ end
46
+
47
+ it "normalizes OpenAPI path templates" do
48
+ show_operation = operations.find { |op| op.action == "show" }
49
+ expect(show_operation.openapi_path).to eq("/api/v1/users/{id}")
50
+ end
51
+
52
+ it "assigns controller tags" do
53
+ operation = operations.find { |op| op.action == "index" }
54
+ expect(operation.tags).to include("Users")
55
+ end
56
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe RailsAutodoc::SchemaMapper do
6
+ let(:schema_path) { Rails.root.join("db/schema.rb") }
7
+
8
+ subject(:mapper) { described_class.new(schema_path: schema_path) }
9
+
10
+ it "loads tables from schema.rb" do
11
+ expect(mapper.all_model_schemas).to have_key("User")
12
+ end
13
+
14
+ it "maps string columns" do
15
+ schema = mapper.model_schema("User")
16
+ expect(schema[:properties]["name"][:type]).to eq("string")
17
+ end
18
+
19
+ it "maps integer columns" do
20
+ schema = mapper.model_schema("User")
21
+ expect(schema[:properties]["age"][:type]).to eq("integer")
22
+ end
23
+
24
+ it "maps boolean columns" do
25
+ schema = mapper.model_schema("User")
26
+ expect(schema[:properties]["active"][:type]).to eq("boolean")
27
+ end
28
+
29
+ it "applies model types to param schemas" do
30
+ param_schema = {
31
+ type: "object",
32
+ properties: {
33
+ "name" => { type: "string" },
34
+ "age" => { type: "string" }
35
+ },
36
+ required: []
37
+ }
38
+
39
+ mapper.apply_types!(param_schema, model_name: "User")
40
+ expect(param_schema[:properties]["age"][:type]).to eq("integer")
41
+ end
42
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe RailsAutodoc::Serializers::Registry do
6
+ let(:registry) { described_class.new }
7
+
8
+ describe "Blueprinter adapter" do
9
+ let(:serializer_class) do
10
+ Class.new do
11
+ def self.name
12
+ "UserBlueprint"
13
+ end
14
+
15
+ def self.fields
16
+ { id: {}, email: {}, name: {} }
17
+ end
18
+ end
19
+ end
20
+
21
+ before do
22
+ stub_const("Blueprinter", Module.new)
23
+ stub_const("Blueprinter::Base", Class.new)
24
+ end
25
+
26
+ it "builds schema from serializer fields" do
27
+ adapter = RailsAutodoc::Serializers::Blueprinter.new
28
+ allow(adapter).to receive(:detect?).and_return(true)
29
+ schema = adapter.schema_for(serializer_class)
30
+ expect(schema[:properties].keys).to include("id", "email", "name")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe RailsAutodoc::StrongParamsParser do
6
+ let(:source_path) { File.expand_path("../fixtures/controllers/sample_controller.rb", __dir__) }
7
+
8
+ subject(:parser) do
9
+ described_class.new(source_path: source_path, class_name: "SampleController")
10
+ end
11
+
12
+ it "extracts param methods" do
13
+ methods = parser.param_methods
14
+ expect(methods).to have_key("sample_params")
15
+ end
16
+
17
+ it "parses required root key" do
18
+ result = parser.param_methods["sample_params"]
19
+ expect(result.root_key).to eq(:sample)
20
+ end
21
+
22
+ it "parses scalar permitted fields" do
23
+ properties = parser.param_methods["sample_params"].schema[:properties]
24
+ expect(properties).to include("name", "email")
25
+ end
26
+
27
+ it "parses nested hash fields" do
28
+ properties = parser.param_methods["sample_params"].schema[:properties]
29
+ expect(properties["address"][:type]).to eq("object")
30
+ end
31
+
32
+ it "parses array fields" do
33
+ properties = parser.param_methods["sample_params"].schema[:properties]
34
+ expect(properties["tags"][:type]).to eq("array")
35
+ end
36
+
37
+ it "maps params to actions" do
38
+ result = parser.params_for_action("create")
39
+ expect(result.method_name).to eq("sample_params")
40
+ end
41
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "rails_autodoc"
5
+ require "combustion"
6
+
7
+ Combustion.path = "spec/dummy"
8
+ Combustion.initialize! :all
9
+
10
+ [Rails.root.join("app/models"), Rails.root.join("app/controllers")].each do |load_path|
11
+ Dir[load_path.join("**", "*.rb")].sort.each { |path| require path }
12
+ end
13
+
14
+ require "rspec/rails"
15
+
16
+ RSpec.configure do |config|
17
+ config.expect_with :rspec do |expectations|
18
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
19
+ end
20
+
21
+ config.mock_with :rspec do |mocks|
22
+ mocks.verify_partial_doubles = true
23
+ end
24
+
25
+ config.shared_context_metadata_behavior = :apply_to_host_groups
26
+ config.filter_run_when_matching :focus
27
+ config.example_status_persistence_file_path = "spec/examples.txt"
28
+ config.disable_monkey_patching!
29
+ config.order = :random
30
+ Kernel.srand config.seed
31
+
32
+ config.before(:each) do
33
+ RailsAutodoc.registry.clear!
34
+ load Rails.root.join("config/initializers/rails_autodoc.rb")
35
+ clear_spec_controller_cache
36
+ end
37
+ end
38
+
39
+ def clear_spec_controller_cache
40
+ return unless defined?(RailsAutodoc::SpecController)
41
+
42
+ RailsAutodoc::SpecController.cached_spec = nil
43
+ end