rails_map 1.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/.idea/.gitignore +8 -0
  3. data/.idea/material_theme_project_new.xml +12 -0
  4. data/AUTHENTICATION.md +221 -0
  5. data/CHANGELOG.md +75 -0
  6. data/Gemfile +10 -0
  7. data/LICENSE.txt +21 -0
  8. data/QUICKSTART.md +156 -0
  9. data/README.md +178 -0
  10. data/Rakefile +8 -0
  11. data/app/controllers/rails_map/docs_controller.rb +73 -0
  12. data/app/models/rails_map/user.rb +12 -0
  13. data/app/views/rails_map/docs/_styles.html.erb +489 -0
  14. data/app/views/rails_map/docs/controller.html.erb +137 -0
  15. data/app/views/rails_map/docs/index.html.erb +212 -0
  16. data/app/views/rails_map/docs/model.html.erb +214 -0
  17. data/app/views/rails_map/docs/routes.html.erb +139 -0
  18. data/config/initializers/rails_map.example.rb +44 -0
  19. data/config/routes.rb +9 -0
  20. data/docs/index.html +1354 -0
  21. data/lib/generators/rails_map/install_generator.rb +49 -0
  22. data/lib/generators/rails_map/templates/README +47 -0
  23. data/lib/generators/rails_map/templates/initializer.rb +38 -0
  24. data/lib/generators/rails_map/templates/migration.rb +14 -0
  25. data/lib/rails_map/auth.rb +15 -0
  26. data/lib/rails_map/configuration.rb +35 -0
  27. data/lib/rails_map/engine.rb +11 -0
  28. data/lib/rails_map/generators/html_generator.rb +120 -0
  29. data/lib/rails_map/parsers/model_parser.rb +257 -0
  30. data/lib/rails_map/parsers/route_parser.rb +356 -0
  31. data/lib/rails_map/railtie.rb +46 -0
  32. data/lib/rails_map/version.rb +5 -0
  33. data/lib/rails_map.rb +66 -0
  34. data/templates/controller.html.erb +74 -0
  35. data/templates/index.html.erb +64 -0
  36. data/templates/layout.html.erb +289 -0
  37. data/templates/model.html.erb +219 -0
  38. data/templates/routes.html.erb +86 -0
  39. metadata +144 -0
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+
6
+ module RailsMap
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ desc "Installs RailsMap with environment-based authentication (use --skip-auth to disable)"
14
+
15
+ class_option :skip_auth, type: :boolean, default: false, desc: "Skip authentication setup"
16
+ class_option :skip_routes, type: :boolean, default: false, desc: "Skip adding route mount"
17
+
18
+ def copy_initializer
19
+ template 'initializer.rb', 'config/initializers/rails_map.rb'
20
+ end
21
+
22
+ def add_route_mount
23
+ unless options[:skip_routes]
24
+ route "mount RailsMap::Engine, at: '/api-doc'"
25
+ end
26
+ end
27
+
28
+ def add_to_gitignore
29
+ gitignore_path = '.gitignore'
30
+
31
+ if File.exist?(gitignore_path)
32
+ gitignore_content = File.read(gitignore_path)
33
+
34
+ unless gitignore_content.include?('doc/api')
35
+ append_to_file gitignore_path do
36
+ "\n# Ignore generated documentation\n/doc/api\n"
37
+ end
38
+ end
39
+ else
40
+ create_file gitignore_path, "# Ignore generated documentation\n/doc/api\n"
41
+ end
42
+ end
43
+
44
+ def show_readme
45
+ readme 'README' if behavior == :invoke
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ ===============================================================================
2
+
3
+ RailsMap has been installed!
4
+
5
+ ✓ Configuration file created at:
6
+ config/initializers/rails_map.rb
7
+
8
+ <% unless options[:skip_routes] %>
9
+ ✓ Route mounted in config/routes.rb:
10
+ mount RailsMap::Engine, at: '/api-doc'
11
+ <% end %>
12
+
13
+ ✓ Added /doc/api to .gitignore
14
+
15
+ <% unless options[:skip_auth] %>
16
+ Authentication has been ENABLED (default)
17
+
18
+ Next steps:
19
+ 1. Set environment variables:
20
+ export RAILS_MAP_USERNAME=admin
21
+ export RAILS_MAP_PASSWORD=your_secure_password
22
+
23
+ Or add to .env file:
24
+ RAILS_MAP_USERNAME=admin
25
+ RAILS_MAP_PASSWORD=your_secure_password
26
+
27
+ 2. Restart your server and visit /api-doc
28
+ You'll be prompted to login with your credentials
29
+
30
+ To disable authentication, run:
31
+ rails destroy rails_map:install
32
+ rails g rails_map:install --skip-auth
33
+ <% else %>
34
+ Authentication has been DISABLED (--skip-auth flag used)
35
+
36
+ To enable authentication:
37
+ 1. Set RAILS_MAP_USERNAME and RAILS_MAP_PASSWORD environment variables
38
+ 2. Uncomment authentication in config/initializers/rails_map.rb
39
+ <% end %>
40
+
41
+ Documentation:
42
+ Visit http://localhost:3000/api-doc after starting your server
43
+
44
+ For more information, see:
45
+ https://rails-map.netlify.app
46
+
47
+ ===============================================================================
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsMap.configure do |config|
4
+ # Application name displayed in the documentation
5
+ config.app_name = '<%= Rails.application.class.module_parent_name %>'
6
+
7
+ # Theme color (any valid CSS color)
8
+ config.theme_color = '#3B82F6'
9
+
10
+ # Output directory for static HTML generation
11
+ config.output_dir = Rails.root.join('doc', 'api').to_s
12
+
13
+ # Include timestamp columns (created_at, updated_at) in model documentation
14
+ config.include_timestamps = true
15
+
16
+ # Include model validations in documentation
17
+ config.include_validations = true
18
+
19
+ # Include model scopes in documentation
20
+ config.include_scopes = true
21
+
22
+ # ============================================================================
23
+ # AUTHENTICATION
24
+ # ============================================================================
25
+ # Set RAILS_MAP_USERNAME and RAILS_MAP_PASSWORD environment variables
26
+ # to enable authentication. Leave them unset for public access.
27
+
28
+ <% unless options[:skip_auth] %>
29
+ config.authenticate_with = proc {
30
+ RailsMap::Auth.authenticate(self)
31
+ }
32
+ <% else %>
33
+ # Authentication disabled via --skip-auth flag
34
+ # To enable: Set RAILS_MAP_USERNAME and RAILS_MAP_PASSWORD environment variables
35
+ # and uncomment the line below:
36
+ # config.authenticate_with = proc { RailsMap::Auth.authenticate(self) }
37
+ <% end %>
38
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailsMapUsers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :rails_map_users do |t|
6
+ t.string :username, null: false
7
+ t.string :password_digest, null: false
8
+
9
+ t.timestamps
10
+ end
11
+
12
+ add_index :rails_map_users, :username, unique: true
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMap
4
+ module Auth
5
+ class << self
6
+ # Authenticates using environment variables with HTTP Basic Auth
7
+ def authenticate(controller)
8
+ controller.authenticate_or_request_with_http_basic('Documentation') do |username, password|
9
+ username == (ENV['RAILS_MAP_USERNAME'] || 'admin') &&
10
+ password == (ENV['RAILS_MAP_PASSWORD'] || 'password')
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMap
4
+ class Configuration
5
+ attr_accessor :output_dir, :app_name, :include_timestamps, :include_validations,
6
+ :include_scopes, :theme_color, :authenticate_with
7
+
8
+ def initialize
9
+ @output_dir = default_output_dir
10
+ @app_name = default_app_name
11
+ @include_timestamps = true
12
+ @include_validations = true
13
+ @include_scopes = true
14
+ @theme_color = "#3B82F6" # Default blue
15
+ end
16
+
17
+ private
18
+
19
+ def default_output_dir
20
+ if defined?(Rails)
21
+ Rails.root.join("doc", "api").to_s
22
+ else
23
+ File.join(Dir.pwd, "doc", "api")
24
+ end
25
+ end
26
+
27
+ def default_app_name
28
+ if defined?(Rails)
29
+ Rails.application.class.module_parent_name
30
+ else
31
+ "Rails Application"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMap
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace RailsMap
6
+
7
+ initializer "rails_map.assets" do |app|
8
+ # No external assets needed - CSS is inline
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+
6
+ module RailsMap
7
+ module Generators
8
+ class HtmlGenerator
9
+ attr_reader :routes, :models, :output_dir
10
+
11
+ TEMPLATES_DIR = File.expand_path("../../../templates", __dir__)
12
+
13
+ def initialize(routes:, models:, output_dir:)
14
+ @routes = routes
15
+ @models = models
16
+ @output_dir = output_dir
17
+ end
18
+
19
+ def generate_all
20
+ generate_index
21
+ generate_routes_page
22
+ generate_controller_pages
23
+ generate_model_pages
24
+ end
25
+
26
+ private
27
+
28
+ def generate_index
29
+ content = render_template("index", binding_for_index)
30
+ write_page("index.html", "Home", content)
31
+ end
32
+
33
+ def generate_routes_page
34
+ content = render_template("routes", binding_for_routes)
35
+ write_page("routes.html", "All Routes", content)
36
+ end
37
+
38
+ def generate_controller_pages
39
+ routes.each do |controller_name, data|
40
+ content = render_template("controller", binding_for_controller(controller_name, data))
41
+ filename = "controllers/#{controller_name.gsub('/', '_')}.html"
42
+ write_page(filename, "#{controller_name.camelize}Controller", content)
43
+ end
44
+ end
45
+
46
+ def generate_model_pages
47
+ models.each do |name, model|
48
+ content = render_template("model", binding_for_model(model))
49
+ filename = "models/#{name.underscore.gsub('/', '_')}.html"
50
+ write_page(filename, name, content)
51
+ end
52
+ end
53
+
54
+ def render_template(name, template_binding)
55
+ template_path = File.join(TEMPLATES_DIR, "#{name}.html.erb")
56
+ template = ERB.new(File.read(template_path), trim_mode: "-")
57
+ template.result(template_binding)
58
+ end
59
+
60
+ def write_page(filename, title, content)
61
+ layout_path = File.join(TEMPLATES_DIR, "layout.html.erb")
62
+ layout = ERB.new(File.read(layout_path), trim_mode: "-")
63
+
64
+ html = layout.result(binding_for_layout(title, content))
65
+
66
+ filepath = File.join(output_dir, filename)
67
+ FileUtils.mkdir_p(File.dirname(filepath))
68
+ File.write(filepath, html)
69
+ end
70
+
71
+ def binding_for_layout(title, content)
72
+ app_name = RailsMap.configuration.app_name
73
+ theme_color = RailsMap.configuration.theme_color
74
+ binding
75
+ end
76
+
77
+ def binding_for_index
78
+ controllers = routes
79
+ controllers_count = routes.size
80
+ routes_count = routes.values.sum { |d| d[:routes].size }
81
+ models_count = models.size
82
+ binding
83
+ end
84
+
85
+ def binding_for_routes
86
+ routes_count = routes.values.sum { |d| d[:routes].size }
87
+ binding
88
+ end
89
+
90
+ def binding_for_controller(controller_name, data)
91
+ routes_list = data[:routes]
92
+ actions = data[:actions]
93
+ base_path = data[:base_path]
94
+ routes = routes_list # alias for template
95
+ binding
96
+ end
97
+
98
+ def binding_for_model(model)
99
+ binding
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ # String extensions for camelization if not in Rails
106
+ unless String.method_defined?(:camelize)
107
+ class String
108
+ def camelize
109
+ split(/[_\/]/).map(&:capitalize).join("::")
110
+ end
111
+
112
+ def underscore
113
+ gsub("::", "/")
114
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
115
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
116
+ .tr("-", "_")
117
+ .downcase
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMap
4
+ module Parsers
5
+ class ModelParser
6
+ ColumnInfo = Struct.new(
7
+ :name, :type, :default, :null, :limit, :precision, :scale,
8
+ keyword_init: true
9
+ )
10
+
11
+ AssociationInfo = Struct.new(
12
+ :name, :type, :class_name, :foreign_key, :options,
13
+ keyword_init: true
14
+ )
15
+
16
+ ValidationInfo = Struct.new(
17
+ :attribute, :kind, :options,
18
+ keyword_init: true
19
+ )
20
+
21
+ ScopeInfo = Struct.new(
22
+ :name, :arity,
23
+ keyword_init: true
24
+ )
25
+
26
+ ModelInfo = Struct.new(
27
+ :name, :table_name, :columns, :associations, :validations, :scopes, :primary_key,
28
+ keyword_init: true
29
+ )
30
+
31
+ def parse
32
+ return {} unless defined?(Rails) && defined?(ActiveRecord::Base)
33
+
34
+ # Eager load all models - force it even in development
35
+ begin
36
+ Rails.application.eager_load!
37
+ rescue => e
38
+ Rails.logger.warn "Could not eager load: #{e.message}" if defined?(Rails.logger)
39
+ end
40
+
41
+ # Also try to load app/models directory explicitly
42
+ if defined?(Rails.root)
43
+ Dir[Rails.root.join('app/models/**/*.rb')].each do |file|
44
+ begin
45
+ require_dependency file
46
+ rescue => e
47
+ # Ignore errors, file might already be loaded
48
+ end
49
+ end
50
+ end
51
+
52
+ models = ActiveRecord::Base.descendants.reject do |model|
53
+ model.abstract_class? ||
54
+ model.name.nil? ||
55
+ model.name.start_with?("HABTM_") ||
56
+ excluded_model?(model)
57
+ end
58
+
59
+ models.each_with_object({}) do |model, hash|
60
+ hash[model.name] = parse_model(model)
61
+ rescue StandardError => e
62
+ # Skip models that can't be introspected
63
+ Rails.logger.warn("Could not parse model #{model.name}: #{e.message}") if defined?(Rails.logger)
64
+ end.sort.to_h
65
+ end
66
+
67
+ private
68
+
69
+ def parse_model(model)
70
+ ModelInfo.new(
71
+ name: model.name,
72
+ table_name: safe_table_name(model),
73
+ columns: parse_columns(model),
74
+ associations: parse_associations(model),
75
+ validations: parse_validations(model),
76
+ scopes: parse_scopes(model),
77
+ primary_key: safe_primary_key(model)
78
+ )
79
+ end
80
+
81
+ def safe_table_name(model)
82
+ model.table_name
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ def safe_primary_key(model)
88
+ model.primary_key
89
+ rescue StandardError
90
+ "id"
91
+ end
92
+
93
+ def parse_columns(model)
94
+ return [] unless model.table_exists?
95
+
96
+ model.columns.map do |column|
97
+ ColumnInfo.new(
98
+ name: column.name,
99
+ type: column.type.to_s,
100
+ default: column.default,
101
+ null: column.null,
102
+ limit: column.limit,
103
+ precision: column.precision,
104
+ scale: column.scale
105
+ )
106
+ end.sort_by(&:name)
107
+ rescue StandardError
108
+ []
109
+ end
110
+
111
+ def parse_associations(model)
112
+ model.reflect_on_all_associations.map do |assoc|
113
+ AssociationInfo.new(
114
+ name: assoc.name.to_s,
115
+ type: assoc.macro.to_s,
116
+ class_name: association_class_name(assoc),
117
+ foreign_key: association_foreign_key(assoc),
118
+ options: extract_association_options(assoc)
119
+ )
120
+ end.sort_by(&:name)
121
+ rescue StandardError
122
+ []
123
+ end
124
+
125
+ def association_class_name(assoc)
126
+ assoc.class_name
127
+ rescue StandardError
128
+ assoc.name.to_s.classify
129
+ end
130
+
131
+ def association_foreign_key(assoc)
132
+ assoc.foreign_key.to_s
133
+ rescue StandardError
134
+ nil
135
+ end
136
+
137
+ def extract_association_options(assoc)
138
+ options = {}
139
+ options[:dependent] = assoc.options[:dependent] if assoc.options[:dependent]
140
+ options[:through] = assoc.options[:through].to_s if assoc.options[:through]
141
+ options[:polymorphic] = true if assoc.options[:polymorphic]
142
+ options[:as] = assoc.options[:as].to_s if assoc.options[:as]
143
+ options
144
+ end
145
+
146
+ def parse_validations(model)
147
+ return [] unless RailsMap.configuration.include_validations
148
+
149
+ model.validators.flat_map do |validator|
150
+ validator.attributes.map do |attr|
151
+ ValidationInfo.new(
152
+ attribute: attr.to_s,
153
+ kind: validator.kind.to_s,
154
+ options: extract_validation_options(validator)
155
+ )
156
+ end
157
+ end.sort_by { |v| [v.attribute, v.kind] }
158
+ rescue StandardError
159
+ []
160
+ end
161
+
162
+ def extract_validation_options(validator)
163
+ validator.options.reject { |k, _| k == :if || k == :unless }
164
+ .transform_values(&:to_s)
165
+ rescue StandardError
166
+ {}
167
+ end
168
+
169
+ def parse_scopes(model)
170
+ return [] unless RailsMap.configuration.include_scopes
171
+
172
+ scope_names = model.methods.grep(/^_scope_/).map { |m| m.to_s.sub("_scope_", "") }
173
+
174
+ # Alternative: check defined scopes
175
+ if model.respond_to?(:defined_scopes)
176
+ scope_names = model.defined_scopes.keys.map(&:to_s)
177
+ end
178
+
179
+ # Fallback: look at singleton methods that return ActiveRecord::Relation
180
+ if scope_names.empty?
181
+ scope_names = extract_scope_names_from_singleton_methods(model)
182
+ end
183
+
184
+ scope_names.uniq.sort.map do |name|
185
+ ScopeInfo.new(
186
+ name: name,
187
+ arity: safe_scope_arity(model, name)
188
+ )
189
+ end
190
+ rescue StandardError
191
+ []
192
+ end
193
+
194
+ def extract_scope_names_from_singleton_methods(model)
195
+ # This is a heuristic approach
196
+ model.singleton_methods(false).select do |method|
197
+ # Skip common non-scope methods
198
+ !%w[table_name primary_key inheritance_column].include?(method.to_s)
199
+ end.map(&:to_s)
200
+ rescue StandardError
201
+ []
202
+ end
203
+
204
+ def safe_scope_arity(model, name)
205
+ model.method(name).arity
206
+ rescue StandardError
207
+ 0
208
+ end
209
+
210
+ def excluded_model?(model)
211
+ return true if model.name.nil?
212
+
213
+ # Exclude models from gems/engines by namespace
214
+ excluded_namespaces = %w[
215
+ RailsMap
216
+ ActionMailbox
217
+ ActionText
218
+ ActiveStorage
219
+ Turbo
220
+ Devise
221
+ Sidekiq
222
+ SolidQueue
223
+ SolidCache
224
+ GoodJob
225
+ Que
226
+ Delayed
227
+ Flipper
228
+ PgHero
229
+ Blazer
230
+ Avo
231
+ MissionControl
232
+ Administrate
233
+ RailsAdmin
234
+ Ahoy
235
+ ]
236
+
237
+ # Check namespace exclusions first (fast check)
238
+ model_name = model.name.to_s
239
+ first_namespace = model_name.split('::').first
240
+ return true if excluded_namespaces.include?(first_namespace)
241
+ return true if excluded_namespaces.any? { |ns| model_name.start_with?("#{ns}::") }
242
+
243
+ # Check if model file exists in app/models of host app
244
+ if defined?(Rails.root)
245
+ # Convert model name to file path (e.g., User -> user.rb, Admin::User -> admin/user.rb)
246
+ model_path = model_name.underscore + '.rb'
247
+ model_file = Rails.root.join('app', 'models', model_path)
248
+
249
+ # If the file doesn't exist in app/models, it's likely from a gem
250
+ return true unless File.exist?(model_file)
251
+ end
252
+
253
+ false
254
+ end
255
+ end
256
+ end
257
+ end