rails-openapi-gen 0.0.2 → 0.0.3

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/CLAUDE.md +17 -5
  4. data/README.md +25 -0
  5. data/lib/rails-openapi-gen/ast_nodes/array_node.rb +101 -0
  6. data/lib/rails-openapi-gen/ast_nodes/base_node.rb +139 -0
  7. data/lib/rails-openapi-gen/ast_nodes/comment_data.rb +180 -0
  8. data/lib/rails-openapi-gen/ast_nodes/node_factory.rb +206 -0
  9. data/lib/rails-openapi-gen/ast_nodes/object_node.rb +129 -0
  10. data/lib/rails-openapi-gen/ast_nodes/partial_node.rb +111 -0
  11. data/lib/rails-openapi-gen/ast_nodes/property_node.rb +74 -0
  12. data/lib/rails-openapi-gen/ast_nodes.rb +129 -0
  13. data/lib/rails-openapi-gen/configuration.rb +154 -22
  14. data/lib/rails-openapi-gen/debug_helpers.rb +185 -0
  15. data/lib/rails-openapi-gen/engine.rb +1 -1
  16. data/lib/rails-openapi-gen/generators/yaml_generator.rb +242 -27
  17. data/lib/rails-openapi-gen/generators.rb +5 -0
  18. data/lib/rails-openapi-gen/importer.rb +164 -145
  19. data/lib/rails-openapi-gen/parsers/comment_parser.rb +1 -1
  20. data/lib/rails-openapi-gen/parsers/comment_parsers/attribute_parser.rb +7 -7
  21. data/lib/rails-openapi-gen/parsers/comment_parsers/base_attribute_parser.rb +5 -9
  22. data/lib/rails-openapi-gen/parsers/comment_parsers/body_parser.rb +6 -6
  23. data/lib/rails-openapi-gen/parsers/comment_parsers/conditional_parser.rb +1 -1
  24. data/lib/rails-openapi-gen/parsers/comment_parsers/operation_parser.rb +5 -5
  25. data/lib/rails-openapi-gen/parsers/comment_parsers/param_parser.rb +6 -6
  26. data/lib/rails-openapi-gen/parsers/comment_parsers/query_parser.rb +6 -6
  27. data/lib/rails-openapi-gen/parsers/controller_parser.rb +64 -20
  28. data/lib/rails-openapi-gen/parsers/jbuilder/ast_parser.rb +914 -0
  29. data/lib/rails-openapi-gen/parsers/jbuilder/call_detectors/array_call_detector.rb +103 -0
  30. data/lib/rails-openapi-gen/parsers/jbuilder/call_detectors/base_detector.rb +107 -0
  31. data/lib/rails-openapi-gen/parsers/jbuilder/call_detectors/cache_call_detector.rb +112 -0
  32. data/lib/rails-openapi-gen/parsers/jbuilder/call_detectors/json_call_detector.rb +91 -0
  33. data/lib/rails-openapi-gen/parsers/jbuilder/call_detectors/key_format_detector.rb +27 -0
  34. data/lib/rails-openapi-gen/parsers/jbuilder/call_detectors/null_handling_detector.rb +27 -0
  35. data/lib/rails-openapi-gen/parsers/jbuilder/call_detectors/object_manipulation_detector.rb +27 -0
  36. data/lib/rails-openapi-gen/parsers/jbuilder/call_detectors/partial_call_detector.rb +125 -0
  37. data/lib/rails-openapi-gen/parsers/jbuilder/call_detectors.rb +95 -0
  38. data/lib/rails-openapi-gen/parsers/jbuilder/jbuilder_parser.rb +39 -0
  39. data/lib/rails-openapi-gen/parsers/jbuilder/operation_comment_parser.rb +26 -0
  40. data/lib/rails-openapi-gen/parsers/jbuilder/processors/array_processor.rb +266 -0
  41. data/lib/rails-openapi-gen/parsers/jbuilder/processors/base_processor.rb +235 -0
  42. data/lib/rails-openapi-gen/parsers/jbuilder/processors/composite_processor.rb +97 -0
  43. data/lib/rails-openapi-gen/parsers/jbuilder/processors/object_processor.rb +176 -0
  44. data/lib/rails-openapi-gen/parsers/jbuilder/processors/partial_processor.rb +69 -0
  45. data/lib/rails-openapi-gen/parsers/jbuilder/processors/property_processor.rb +68 -0
  46. data/lib/rails-openapi-gen/parsers/jbuilder/processors.rb +10 -0
  47. data/lib/rails-openapi-gen/parsers/jbuilder/property_comment_parser.rb +26 -0
  48. data/lib/rails-openapi-gen/parsers/jbuilder.rb +10 -0
  49. data/lib/rails-openapi-gen/parsers/routes_parser.rb +83 -9
  50. data/lib/rails-openapi-gen/parsers/template_processors/jbuilder_template_processor.rb +125 -131
  51. data/lib/rails-openapi-gen/parsers/template_processors/response_template_processor.rb +8 -12
  52. data/lib/rails-openapi-gen/parsers/template_processors.rb +6 -0
  53. data/lib/rails-openapi-gen/parsers.rb +9 -0
  54. data/lib/rails-openapi-gen/processors/ast_to_schema_processor.rb +226 -0
  55. data/lib/rails-openapi-gen/processors/base_processor.rb +124 -0
  56. data/lib/rails-openapi-gen/processors/component_schema_processor.rb +35 -0
  57. data/lib/rails-openapi-gen/processors/openapi_schema_processor.rb +218 -0
  58. data/lib/rails-openapi-gen/processors.rb +7 -0
  59. data/lib/rails-openapi-gen/railtie.rb +1 -1
  60. data/lib/rails-openapi-gen/tasks/openapi.rake +4 -4
  61. data/lib/rails-openapi-gen/version.rb +1 -1
  62. data/lib/rails-openapi-gen.rb +169 -196
  63. data/lib/tasks/openapi_import.rake +35 -36
  64. data/rails-openapi-gen.gemspec +6 -5
  65. metadata +62 -23
  66. data/lib/rails-openapi-gen/parsers/jbuilder_parser.rb +0 -529
@@ -2,16 +2,19 @@
2
2
 
3
3
  require "yaml"
4
4
  require "fileutils"
5
+ require_relative "../processors/component_schema_processor"
5
6
 
6
7
  module RailsOpenapiGen
7
8
  module Generators
8
9
  class YamlGenerator
9
- attr_reader :schemas
10
+ attr_reader :schemas, :components
10
11
 
11
12
  # Initializes YAML generator with schemas data
12
13
  # @param schemas [Hash] Hash of route information and schemas
13
- def initialize(schemas)
14
+ # @param components [Hash] Hash of component schemas from partials
15
+ def initialize(schemas, components: {})
14
16
  @schemas = schemas
17
+ @components = components
15
18
  @config = RailsOpenapiGen.configuration
16
19
  @base_path = @config.output_directory
17
20
  end
@@ -34,7 +37,8 @@ module RailsOpenapiGen
34
37
  end
35
38
 
36
39
  if @config.split_files?
37
- write_paths_files(paths_data)
40
+ write_endpoint_files(paths_data)
41
+ write_component_files if @components && @components.any?
38
42
  write_main_openapi_file
39
43
  else
40
44
  write_single_file(paths_data)
@@ -63,20 +67,54 @@ module RailsOpenapiGen
63
67
  false
64
68
  end
65
69
 
66
- # Creates necessary output directories
70
+ # Creates necessary output directories and cleans existing path files
67
71
  # @return [void]
68
72
  def setup_directories
69
73
  FileUtils.mkdir_p(@base_path)
70
74
  if @config.split_files?
71
- FileUtils.mkdir_p(File.join(@base_path, "paths"))
75
+ paths_dir = File.join(@base_path, "paths")
76
+ FileUtils.mkdir_p(paths_dir)
77
+ # Clean existing path files to prevent merging with stale data
78
+ clean_paths_directory(paths_dir)
79
+
80
+ # Create components directory for partial components
81
+ if @components && @components.any?
82
+ components_dir = File.join(@base_path, "components", "schemas")
83
+ FileUtils.mkdir_p(components_dir)
84
+ clean_components_directory(components_dir)
85
+ end
86
+ end
87
+ end
88
+
89
+ # Cleans existing YAML files from the paths directory
90
+ # @param paths_dir [String] Path to the paths directory
91
+ # @return [void]
92
+ def clean_paths_directory(paths_dir)
93
+ Dir[File.join(paths_dir, "*.yaml")].each do |file|
94
+ File.delete(file)
95
+ puts "🗑️ Removed existing path file: #{File.basename(file)}" if ENV['RAILS_OPENAPI_DEBUG']
96
+ end
97
+ end
98
+
99
+ # Cleans existing YAML files from the components directory
100
+ # @param components_dir [String] Path to the components directory
101
+ # @return [void]
102
+ def clean_components_directory(components_dir)
103
+ Dir[File.join(components_dir, "*.yaml")].each do |file|
104
+ File.delete(file)
105
+ puts "🗑️ Removed existing component file: #{File.basename(file)}" if ENV['RAILS_OPENAPI_DEBUG']
72
106
  end
73
107
  end
74
108
 
75
- # Converts Rails path format to OpenAPI format
76
- # @param path [String] Rails path (e.g., "/users/:id")
109
+ # Converts Rails path format to OpenAPI format and removes API prefix if configured
110
+ # @param path [String] Rails path (e.g., "/api/v1/users/:id")
77
111
  # @return [String] OpenAPI path (e.g., "/users/{id}")
78
112
  def normalize_path(path)
79
- path.gsub(/:(\w+)/, '{\\1}')
113
+ # First remove API prefix if configured
114
+ path_without_prefix = @config.remove_api_prefix(path)
115
+
116
+ # Then convert Rails path parameters to OpenAPI format
117
+ path_without_prefix.gsub(/:(\w+)/, '{\\1}')
80
118
  end
81
119
 
82
120
  # Builds OpenAPI operation object for a route
@@ -86,10 +124,13 @@ module RailsOpenapiGen
86
124
  # @param operation_info [Hash, nil] Operation metadata from comments
87
125
  # @return [Hash] OpenAPI operation object
88
126
  def build_operation(route, schema, parameters = {}, operation_info = nil)
127
+ # Generate operationId with prefix removal
128
+ default_operation_id = generate_operation_id(route)
129
+
89
130
  operation = {
90
131
  "summary" => operation_info&.dig(:summary) || "#{humanize(route[:action])} #{humanize(singularize(route[:controller]))}",
91
- "operationId" => operation_info&.dig(:operationId) || "#{route[:controller].gsub('/', '_')}_#{route[:action]}",
92
- "tags" => operation_info&.dig(:tags) || [humanize(route[:controller].split('/').first)]
132
+ "operationId" => operation_info&.dig(:operationId) || default_operation_id,
133
+ "tags" => operation_info&.dig(:tags) || generate_tag_names(route)
93
134
  }
94
135
 
95
136
  # Add description if provided
@@ -121,18 +162,18 @@ module RailsOpenapiGen
121
162
  operation
122
163
  end
123
164
 
124
- # Writes path data to separate YAML files grouped by resource
165
+ # Writes path data to separate YAML files for each endpoint
125
166
  # @param paths_data [Hash] OpenAPI paths data
126
167
  # @return [void]
127
- def write_paths_files(paths_data)
128
- grouped_paths = paths_data.group_by { |path, _| extract_resource_name(path) }
129
-
130
- grouped_paths.each do |resource, paths|
131
- file_data = {}
132
- paths.each { |path, operations| file_data[path] = operations }
168
+ def write_endpoint_files(paths_data)
169
+ paths_data.each do |path, operations|
170
+ endpoint_name = generate_endpoint_filename(path)
171
+ file_data = { path => operations }
133
172
 
134
- file_path = File.join(@base_path, "paths", "#{resource}.yaml")
173
+ file_path = File.join(@base_path, "paths", "#{endpoint_name}.yaml")
135
174
  File.write(file_path, file_data.to_yaml)
175
+
176
+ puts "📝 Written endpoint file: #{endpoint_name}.yaml" if ENV['RAILS_OPENAPI_DEBUG']
136
177
  end
137
178
  end
138
179
 
@@ -155,6 +196,11 @@ module RailsOpenapiGen
155
196
  end
156
197
  end
157
198
 
199
+ # Add components section with external references if we have any components
200
+ if @components && @components.any?
201
+ openapi_data["components"] = generate_components_references
202
+ end
203
+
158
204
  File.write(File.join(@base_path, @config.output_filename), openapi_data.to_yaml)
159
205
  end
160
206
 
@@ -188,14 +234,19 @@ module RailsOpenapiGen
188
234
  end
189
235
  end
190
236
 
191
- # Extracts resource name from API path
192
- # @param path [String] API path
193
- # @return [String] Resource name
194
- def extract_resource_name(path)
195
- parts = path.split('/')
196
- return "root" if parts.empty? || parts.all?(&:empty?)
197
-
198
- parts.reject { |p| p.empty? || p.start_with?('{') }.first || "root"
237
+ # Generates filename for endpoint-specific files
238
+ # @param path [String] API path (e.g., "/api/users/{id}")
239
+ # @return [String] Filename (e.g., "api_users_id")
240
+ def generate_endpoint_filename(path)
241
+ # Remove leading slash and replace special characters
242
+ clean_path = path.sub(%r{^/+}, '')
243
+ .gsub('/', '_')
244
+ .gsub(/[{}]/, '')
245
+ .gsub(/[^a-zA-Z0-9_]/, '_')
246
+ .gsub(/_+/, '_')
247
+ .gsub(/^_|_$/, '')
248
+
249
+ clean_path.empty? ? 'root' : clean_path
199
250
  end
200
251
 
201
252
  # Converts snake_case string to human readable format
@@ -214,6 +265,104 @@ module RailsOpenapiGen
214
265
  str.end_with?('s') ? str[0..-2] : str
215
266
  end
216
267
 
268
+ # Convert PascalCase/camelCase string to kebab-case with optional prefix removal
269
+ # @param string [String] String to convert
270
+ # @return [String] kebab-case string
271
+ def to_kebab_case(string)
272
+ # First remove component prefix if configured
273
+ name_without_prefix = @config.remove_component_prefix(string.to_s)
274
+
275
+ name_without_prefix.gsub(/([a-z\d])([A-Z])/, '\1-\2') # Insert dash before capital letters
276
+ .downcase # Convert to lowercase
277
+ end
278
+
279
+ # Convert PascalCase/camelCase string to snake_case
280
+ # @param string [String] String to convert
281
+ # @return [String] snake_case string
282
+ def to_snake_case(string)
283
+ string.to_s
284
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2') # Insert underscore before capital letters
285
+ .downcase # Convert to lowercase
286
+ end
287
+
288
+ # Generate operationId with prefix removal from controller path
289
+ # @param route [Hash] Route information
290
+ # @return [String] Operation ID with prefix removed
291
+ def generate_operation_id(route)
292
+ controller_path = route[:controller]
293
+ action = route[:action]
294
+
295
+ # Remove API prefix from controller path if configured
296
+ controller_without_prefix = remove_controller_prefix(controller_path)
297
+
298
+ # Convert to underscore format for operationId
299
+ "#{controller_without_prefix.gsub('/', '_')}_#{action}"
300
+ end
301
+
302
+ # Remove API prefix from controller path
303
+ # @param controller_path [String] Controller path (e.g., "api/v1/users")
304
+ # @return [String] Controller path with prefix removed (e.g., "users")
305
+ def remove_controller_prefix(controller_path)
306
+ # Use the same API prefix configuration
307
+ api_prefix = @config.view_paths&.dig(:api_prefix)
308
+ return controller_path unless api_prefix
309
+
310
+ # Convert api_prefix to controller format (e.g., "api/v1" -> "api/v1")
311
+ normalized_prefix = api_prefix.gsub(%r{^/+|/+$}, '') # Remove leading/trailing slashes
312
+
313
+ # Remove the prefix if the controller path starts with it
314
+ if controller_path.start_with?(normalized_prefix + '/')
315
+ remaining_path = controller_path[(normalized_prefix.length + 1)..-1]
316
+ remaining_path.empty? ? controller_path : remaining_path
317
+ elsif controller_path == normalized_prefix
318
+ # If controller path is exactly the prefix, return empty or fallback
319
+ 'root'
320
+ else
321
+ controller_path
322
+ end
323
+ end
324
+
325
+ # Generate multiple tags based on URL path and controller path
326
+ # @param route [Hash] Route information
327
+ # @return [Array<String>] Array of tag names for the resources
328
+ def generate_tag_names(route)
329
+ controller_path = route[:controller]
330
+ url_path = route[:path]
331
+
332
+ # Remove API prefix from controller path
333
+ controller_without_prefix = remove_controller_prefix(controller_path)
334
+
335
+ # Also extract resource names from URL path for nested routes
336
+ url_without_prefix = @config.remove_api_prefix(url_path)
337
+
338
+ tags = []
339
+
340
+ # Extract tags from controller path
341
+ controller_parts = controller_without_prefix.split('/')
342
+ controller_parts.each do |part|
343
+ next if part.empty?
344
+
345
+ tag = to_snake_case(part)
346
+ tags << tag unless tags.include?(tag)
347
+ end
348
+
349
+ # Extract additional tags from URL path for nested resources
350
+ # Match patterns like /users/{id}/posts to extract "users"
351
+ url_segments = url_without_prefix.split('/').reject(&:empty?)
352
+
353
+ url_segments.each do |segment|
354
+ # Skip parameter segments like {id} or :id
355
+ next if segment.match(/^[:{].*[}]?$/)
356
+
357
+ # Convert to snake_case for consistency
358
+ tag = segment.downcase.gsub('-', '_')
359
+ tags << tag unless tags.include?(tag)
360
+ end
361
+
362
+ # Ensure we always have at least one tag
363
+ tags.empty? ? ["Api"] : tags.uniq
364
+ end
365
+
217
366
  # Builds OpenAPI parameter objects from route and parameter data
218
367
  # @param route [Hash] Route information
219
368
  # @param parameters [Hash] Parameter definitions
@@ -297,6 +446,72 @@ module RailsOpenapiGen
297
446
 
298
447
  schema
299
448
  end
449
+
450
+ # Generate components section from collected partial components
451
+ # @return [Hash] Components section for OpenAPI spec
452
+ def generate_components_section
453
+ return {} if @components.empty?
454
+
455
+ components = {
456
+ "schemas" => {}
457
+ }
458
+
459
+ @components.each do |component_name, ast_node|
460
+ puts "📦 Generating component schema for: #{component_name}" if ENV['RAILS_OPENAPI_DEBUG']
461
+
462
+ # Convert AST node to OpenAPI schema
463
+ schema = Processors::AstToSchemaProcessor.new.process_to_schema(ast_node)
464
+ components["schemas"][component_name] = schema
465
+ end
466
+
467
+ components
468
+ end
469
+
470
+ # Generate components section with external file references
471
+ # @return [Hash] Components section with $ref to external files
472
+ def generate_components_references
473
+ return {} if @components.empty?
474
+
475
+ components = {
476
+ "schemas" => {}
477
+ }
478
+
479
+ @components.each_key do |component_name|
480
+ # Remove prefix from both schema name and file name
481
+ schema_name_without_prefix = @config.remove_component_prefix(component_name)
482
+ kebab_filename = to_kebab_case(component_name)
483
+
484
+ puts "🔗 Creating reference for component: #{component_name} -> #{schema_name_without_prefix} (#{kebab_filename}.yaml)" if ENV['RAILS_OPENAPI_DEBUG']
485
+
486
+ # Create $ref to external component file (using kebab-case filename)
487
+ # Use schema name without prefix as the key
488
+ components["schemas"][schema_name_without_prefix] = {
489
+ "$ref" => "./components/schemas/#{kebab_filename}.yaml"
490
+ }
491
+ end
492
+
493
+ components
494
+ end
495
+
496
+ # Write individual component files to components/schemas directory
497
+ # @return [void]
498
+ def write_component_files
499
+ return unless @components && @components.any?
500
+
501
+ @components.each do |component_name, ast_node|
502
+ kebab_filename = to_kebab_case(component_name)
503
+ puts "📝 Writing component file: #{kebab_filename}.yaml" if ENV['RAILS_OPENAPI_DEBUG']
504
+
505
+ # Convert AST node to OpenAPI schema (with inline expansion for components)
506
+ schema = Processors::ComponentSchemaProcessor.new.process_to_schema(ast_node)
507
+
508
+ # Write to individual YAML file
509
+ file_path = File.join(@base_path, "components", "schemas", "#{kebab_filename}.yaml")
510
+ File.write(file_path, schema.to_yaml)
511
+
512
+ puts "✅ Component file written: #{kebab_filename}.yaml" if ENV['RAILS_OPENAPI_DEBUG']
513
+ end
514
+ end
300
515
  end
301
516
  end
302
- end
517
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpenapiGen::Generators
4
+ autoload :YamlGenerator, "rails-openapi-gen/generators/yaml_generator"
5
+ end