rails-openapi-gen 0.0.1

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 (27) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +160 -0
  3. data/README.md +164 -0
  4. data/lib/rails_openapi_gen/configuration.rb +157 -0
  5. data/lib/rails_openapi_gen/engine.rb +11 -0
  6. data/lib/rails_openapi_gen/generators/yaml_generator.rb +302 -0
  7. data/lib/rails_openapi_gen/importer.rb +647 -0
  8. data/lib/rails_openapi_gen/parsers/comment_parser.rb +40 -0
  9. data/lib/rails_openapi_gen/parsers/comment_parsers/attribute_parser.rb +57 -0
  10. data/lib/rails_openapi_gen/parsers/comment_parsers/base_attribute_parser.rb +42 -0
  11. data/lib/rails_openapi_gen/parsers/comment_parsers/body_parser.rb +62 -0
  12. data/lib/rails_openapi_gen/parsers/comment_parsers/conditional_parser.rb +13 -0
  13. data/lib/rails_openapi_gen/parsers/comment_parsers/operation_parser.rb +50 -0
  14. data/lib/rails_openapi_gen/parsers/comment_parsers/param_parser.rb +62 -0
  15. data/lib/rails_openapi_gen/parsers/comment_parsers/query_parser.rb +62 -0
  16. data/lib/rails_openapi_gen/parsers/controller_parser.rb +153 -0
  17. data/lib/rails_openapi_gen/parsers/jbuilder_parser.rb +529 -0
  18. data/lib/rails_openapi_gen/parsers/routes_parser.rb +33 -0
  19. data/lib/rails_openapi_gen/parsers/template_processors/jbuilder_template_processor.rb +147 -0
  20. data/lib/rails_openapi_gen/parsers/template_processors/response_template_processor.rb +17 -0
  21. data/lib/rails_openapi_gen/railtie.rb +11 -0
  22. data/lib/rails_openapi_gen/tasks/openapi.rake +30 -0
  23. data/lib/rails_openapi_gen/version.rb +5 -0
  24. data/lib/rails_openapi_gen.rb +267 -0
  25. data/lib/tasks/openapi_import.rake +126 -0
  26. data/rails-openapi-gen.gemspec +30 -0
  27. metadata +155 -0
@@ -0,0 +1,647 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "set"
5
+
6
+ module RailsOpenapiGen
7
+ class Importer
8
+ # Initializes importer with OpenAPI specification file
9
+ # @param openapi_file [String, nil] Path to OpenAPI spec file (defaults to configured output)
10
+ def initialize(openapi_file = nil)
11
+ @openapi_file = openapi_file || File.join(RailsOpenapiGen.configuration.output_directory,
12
+ RailsOpenapiGen.configuration.output_filename)
13
+ @routes_parser = Parsers::RoutesParser.new
14
+ @processed_files = Set.new
15
+ end
16
+
17
+ # Runs the import process to generate @openapi comments in Jbuilder files
18
+ # @return [void]
19
+ def run
20
+ unless File.exist?(@openapi_file)
21
+ puts "❌ OpenAPI file not found: #{@openapi_file}"
22
+ return
23
+ end
24
+
25
+ openapi_spec = YAML.load_file(@openapi_file)
26
+ routes = @routes_parser.parse
27
+
28
+ processed_count = 0
29
+ @partial_schemas = {} # Store schemas for partials
30
+
31
+ # First pass: collect all response schemas
32
+ openapi_spec['paths']&.each do |path, methods|
33
+ methods.each do |method, operation|
34
+ next if method == 'parameters' # Skip path-level parameters
35
+
36
+ response_schema = extract_response_schema(operation)
37
+ if response_schema
38
+ if response_schema['type'] == 'array' && response_schema['items']
39
+ # For array responses, store the item schema
40
+ item_schema = response_schema['items']
41
+ if item_schema['properties']
42
+ # Extract resource name from path dynamically
43
+ resource_name = extract_resource_name_from_path(path)
44
+ if resource_name
45
+ @partial_schemas[resource_name] ||= item_schema
46
+ end
47
+ collect_partial_schemas(item_schema['properties'])
48
+ end
49
+ elsif response_schema['properties']
50
+ # For object responses
51
+ collect_partial_schemas(response_schema['properties'])
52
+
53
+ # Store the root schema if it's a detail endpoint
54
+ if path.match?(/\{(id|\w+_id)\}$/)
55
+ resource_name = extract_resource_name_from_path(path)
56
+ if resource_name
57
+ @partial_schemas["#{resource_name}_detail"] = response_schema
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ # Second pass: process files and add comments
66
+ openapi_spec['paths']&.each do |path, methods|
67
+ methods.each do |method, operation|
68
+ next if method == 'parameters' # Skip path-level parameters
69
+
70
+ matching_route = find_matching_route(path, method.upcase, routes)
71
+ next unless matching_route
72
+
73
+ controller_info = Parsers::ControllerParser.new(matching_route).parse
74
+ next unless controller_info[:jbuilder_path]
75
+
76
+ if add_comments_to_jbuilder(controller_info, operation, matching_route)
77
+ processed_count += 1
78
+ end
79
+ end
80
+ end
81
+
82
+ # Third pass: process partial files
83
+ process_partial_files()
84
+
85
+ puts "✅ Updated #{processed_count} Jbuilder templates with OpenAPI comments"
86
+ end
87
+
88
+ private
89
+
90
+ # Finds Rails route matching OpenAPI path and method
91
+ # @param openapi_path [String] OpenAPI path format (e.g., "/users/{id}")
92
+ # @param method [String] HTTP method
93
+ # @param routes [Array<Hash>] Array of Rails routes
94
+ # @return [Hash, nil] Matching route or nil
95
+ def find_matching_route(openapi_path, method, routes)
96
+ # Convert OpenAPI path format {id} to Rails format :id
97
+ rails_path = openapi_path.gsub(/\{(\w+)\}/, ':\1')
98
+
99
+ routes.find do |route|
100
+ route[:method] == method && normalize_path(route[:path]) == normalize_path(rails_path)
101
+ end
102
+ end
103
+
104
+ # Normalizes path by removing trailing slashes
105
+ # @param path [String] Path to normalize
106
+ # @return [String] Normalized path
107
+ def normalize_path(path)
108
+ # Remove trailing slashes and normalize
109
+ path.gsub(/\/$/, '')
110
+ end
111
+
112
+ # Extracts resource name from OpenAPI path
113
+ # @param path [String] OpenAPI path (e.g., "/users/{id}/posts")
114
+ # @return [String, nil] Singular resource name or nil
115
+ def extract_resource_name_from_path(path)
116
+ # Extract the resource name from OpenAPI path
117
+ # Examples:
118
+ # /posts -> posts
119
+ # /users/{id} -> users
120
+ # /users/{user_id}/posts -> posts
121
+ # /posts/{post_id}/comments -> comments
122
+
123
+ # Remove leading slash and split by '/'
124
+ segments = path.sub(/^\//, '').split('/')
125
+
126
+ # Find the last segment that doesn't contain parameters
127
+ resource_segment = segments.reverse.find { |segment| !segment.include?('{') }
128
+
129
+ # Return singular form if found
130
+ resource_segment&.singularize
131
+ end
132
+
133
+ # Collects schemas that might be used in partials
134
+ # @param properties [Hash] Properties hash from schema
135
+ # @param parent_key [String, nil] Parent property key for nested objects
136
+ # @return [void]
137
+ def collect_partial_schemas(properties, parent_key = nil)
138
+ # Collect schemas that might be used in partials
139
+ properties.each do |key, schema|
140
+ if schema['type'] == 'object' && schema['properties']
141
+ # Store schema for potential partial use
142
+ @partial_schemas[key] = schema
143
+
144
+ # Also store the full schema if it's from the root level
145
+ # This helps with matching post/user objects at the top level
146
+ if parent_key.nil? && (key == 'post' || key == 'posts' || key == 'user' || key == 'users')
147
+ @partial_schemas["_#{key.singularize}"] = schema
148
+ end
149
+
150
+ # Recursively collect nested schemas
151
+ collect_partial_schemas(schema['properties'], key)
152
+ elsif schema['type'] == 'array' && schema['items'] && schema['items']['properties']
153
+ @partial_schemas[key] = schema['items']
154
+ @partial_schemas["_#{key.singularize}"] = schema['items']
155
+ collect_partial_schemas(schema['items']['properties'], key)
156
+ end
157
+ end
158
+ end
159
+
160
+ # Processes partial files to add OpenAPI comments
161
+ # @return [void]
162
+ def process_partial_files
163
+ # Find and process partial files
164
+ Dir.glob(Rails.root.join('app/views/**/_*.json.jbuilder')).each do |partial_path|
165
+ next if @processed_files.include?(partial_path)
166
+
167
+ # Try to match partial with collected schemas
168
+ partial_name = File.basename(partial_path, '.json.jbuilder').sub(/^_/, '')
169
+
170
+ # Look for matching schema based on partial name
171
+ matching_schema = find_schema_for_partial(partial_name)
172
+ next unless matching_schema
173
+
174
+ if add_comments_to_partial(partial_path, matching_schema)
175
+ @processed_files << partial_path
176
+ puts " 📝 Updated partial: #{partial_path}"
177
+ end
178
+ end
179
+ end
180
+
181
+ # Finds appropriate schema for a partial template
182
+ # @param partial_name [String] Name of the partial (e.g., "user")
183
+ # @return [Hash, nil] Schema for the partial or nil
184
+ def find_schema_for_partial(partial_name)
185
+ # Try to find a schema that matches the partial name
186
+ # Look for singular and plural forms
187
+
188
+ # First, try direct name matching
189
+ return @partial_schemas[partial_name] if @partial_schemas[partial_name]
190
+ return @partial_schemas[partial_name.pluralize] if @partial_schemas[partial_name.pluralize]
191
+ return @partial_schemas[partial_name.singularize] if @partial_schemas[partial_name.singularize]
192
+
193
+ # Try to find common base properties across all schemas
194
+ # This is a more generic approach that doesn't hardcode specific models
195
+ base_schema = find_common_properties_schema(partial_name)
196
+ return base_schema if base_schema
197
+
198
+ # Fall back to property matching
199
+ find_schema_by_properties(partial_name)
200
+ end
201
+
202
+ # Finds schema with common properties for partial
203
+ # @param partial_name [String] Name of the partial
204
+ # @return [Hash, nil] Schema with common properties or nil
205
+ def find_common_properties_schema(partial_name)
206
+ # Find schemas that might represent this partial
207
+ # by looking for schemas with properties that match the partial content
208
+ partial_path = Dir.glob(Rails.root.join("app/views/**/_{partial_name}.json.jbuilder")).first
209
+ return nil unless partial_path && File.exist?(partial_path)
210
+
211
+ # Parse the partial to get its properties
212
+ partial_content = File.read(partial_path)
213
+ partial_properties = partial_content.scan(/json\.(\w+)/).flatten.uniq
214
+
215
+ # Find all schemas that could match
216
+ candidate_schemas = []
217
+ @partial_schemas.each do |key, schema|
218
+ next unless schema['properties']
219
+
220
+ # Calculate how many properties match
221
+ matching_props = partial_properties & schema['properties'].keys
222
+ if matching_props.size >= partial_properties.size * 0.5 # At least 50% match
223
+ candidate_schemas << {
224
+ schema: schema,
225
+ match_count: matching_props.size,
226
+ properties: schema['properties'].slice(*matching_props)
227
+ }
228
+ end
229
+ end
230
+
231
+ return nil if candidate_schemas.empty?
232
+
233
+ # If we have multiple candidates, find common properties across all of them
234
+ if candidate_schemas.size > 1
235
+ common_properties = extract_common_properties(candidate_schemas)
236
+ return { 'properties' => common_properties } if common_properties.any?
237
+ end
238
+
239
+ # Otherwise return the best matching schema
240
+ best_match = candidate_schemas.max_by { |c| c[:match_count] }
241
+ { 'properties' => best_match[:properties] }
242
+ end
243
+
244
+ # Extracts common properties from multiple schemas
245
+ # @param candidate_schemas [Array<Hash>] Array of candidate schemas
246
+ # @return [Hash] Schema with common properties
247
+ def extract_common_properties(candidate_schemas)
248
+ # Find properties that appear in all candidate schemas with the same type
249
+ common_props = {}
250
+
251
+ # Get all property names from the first schema
252
+ first_schema_props = candidate_schemas.first[:schema]['properties']
253
+
254
+ first_schema_props.each do |prop_name, prop_schema|
255
+ # Check if this property exists in all candidates with the same type
256
+ is_common = candidate_schemas.all? do |candidate|
257
+ candidate[:schema]['properties'][prop_name] &&
258
+ candidate[:schema]['properties'][prop_name]['type'] == prop_schema['type']
259
+ end
260
+
261
+ if is_common
262
+ # Use the most detailed schema for this property
263
+ # (the one with the most attributes like description, enum, etc.)
264
+ most_detailed = candidate_schemas.map { |c| c[:schema]['properties'][prop_name] }
265
+ .max_by { |p| p.keys.size }
266
+ common_props[prop_name] = most_detailed
267
+ end
268
+ end
269
+
270
+ common_props
271
+ end
272
+
273
+ # Finds schema by matching property names in partial
274
+ # @param partial_name [String] Name of the partial
275
+ # @return [Hash, nil] Matching schema or nil
276
+ def find_schema_by_properties(partial_name)
277
+ # Try to match by analyzing the partial content
278
+ # This is a more complex matching strategy
279
+ partial_path = Dir.glob(Rails.root.join("app/views/**/_{partial_name}.json.jbuilder")).first
280
+ return nil unless partial_path && File.exist?(partial_path)
281
+
282
+ content = File.read(partial_path)
283
+ property_names = content.scan(/json\.(\w+)/).flatten.uniq
284
+
285
+ # Find schema that has most matching properties
286
+ best_match = nil
287
+ best_score = 0
288
+
289
+ @partial_schemas.each do |key, schema|
290
+ next unless schema['properties']
291
+
292
+ matching_props = property_names & schema['properties'].keys
293
+ score = matching_props.size.to_f / property_names.size
294
+
295
+ if score > best_score && score > 0.5 # At least 50% match
296
+ best_match = schema
297
+ best_score = score
298
+ end
299
+ end
300
+
301
+ best_match
302
+ end
303
+
304
+ # Adds OpenAPI comments to a partial file
305
+ # @param partial_path [String] Path to partial file
306
+ # @param schema [Hash] Schema to use for comments
307
+ # @return [void]
308
+ def add_comments_to_partial(partial_path, schema)
309
+ return false unless File.exist?(partial_path)
310
+
311
+ content = File.read(partial_path)
312
+
313
+ # Parse the partial file
314
+ jbuilder_result = Parsers::JbuilderParser.new(partial_path).parse
315
+ properties = jbuilder_result[:properties]
316
+
317
+ # Generate new content with comments
318
+ new_content = generate_commented_jbuilder(content, properties, schema, nil, nil)
319
+
320
+ # Write back to file
321
+ File.write(partial_path, new_content)
322
+
323
+ true
324
+ end
325
+
326
+ # Adds OpenAPI comments to Jbuilder template
327
+ # @param controller_info [Hash] Controller information including jbuilder_path
328
+ # @param operation [Hash] OpenAPI operation data
329
+ # @param route [Hash] Route information
330
+ # @return [Boolean] True if file was updated
331
+ def add_comments_to_jbuilder(controller_info, operation, route)
332
+ jbuilder_path = controller_info[:jbuilder_path]
333
+ return false unless File.exist?(jbuilder_path)
334
+ return false if @processed_files.include?(jbuilder_path)
335
+
336
+ @processed_files << jbuilder_path
337
+
338
+ content = File.read(jbuilder_path)
339
+
340
+ # Parse the Jbuilder file to understand its structure
341
+ jbuilder_result = Parsers::JbuilderParser.new(jbuilder_path).parse
342
+ properties = jbuilder_result[:properties]
343
+
344
+ # Get the response schema from the operation
345
+ response_schema = extract_response_schema(operation)
346
+ return false unless response_schema
347
+
348
+ # Generate new content with comments
349
+ new_content = generate_commented_jbuilder(content, properties, response_schema, operation, route)
350
+
351
+ # Write back to file
352
+ File.write(jbuilder_path, new_content)
353
+
354
+ puts " 📝 Updated: #{jbuilder_path}"
355
+ true
356
+ end
357
+
358
+ # Extracts response schema from OpenAPI operation
359
+ # @param operation [Hash] OpenAPI operation data
360
+ # @return [Hash, nil] Response schema or nil
361
+ def extract_response_schema(operation)
362
+ # Look for 200 response first, then any other successful response
363
+ responses = operation['responses'] || {}
364
+
365
+ success_response = responses['200'] || responses['201'] || responses.values.first
366
+ return nil unless success_response
367
+
368
+ content = success_response['content'] || {}
369
+ json_content = content['application/json'] || {}
370
+ json_content['schema']
371
+ end
372
+
373
+ # Generates Jbuilder content with OpenAPI comments
374
+ # @param content [String] Original file content
375
+ # @param properties [Array<Hash>] Parsed properties from Jbuilder
376
+ # @param response_schema [Hash] Response schema from OpenAPI
377
+ # @param operation [Hash, nil] OpenAPI operation data
378
+ # @param route [Hash, nil] Route information
379
+ # @return [String] Updated content with comments
380
+ def generate_commented_jbuilder(content, properties, response_schema, operation, route)
381
+ lines = content.lines
382
+ new_lines = []
383
+ current_schema_stack = [response_schema]
384
+ indent_stack = [0]
385
+ in_block_stack = [] # Track if we're inside a do block
386
+
387
+ # Add operation comment if needed at the very beginning
388
+ if should_add_operation_comment(content, operation)
389
+ new_lines << generate_operation_comment(operation)
390
+ new_lines << "\n"
391
+ end
392
+
393
+ # Process each line
394
+ lines.each_with_index do |line, index|
395
+ current_indent = line.match(/^(\s*)/)[1].length
396
+
397
+ # Check for block end
398
+ if line.strip == 'end' && in_block_stack.any?
399
+ in_block_stack.pop
400
+ # Pop schema stack when exiting a block
401
+ if indent_stack.last && current_indent <= indent_stack.last
402
+ current_schema_stack.pop
403
+ indent_stack.pop
404
+ end
405
+ end
406
+
407
+ # Update schema stack based on indentation (for other cases)
408
+ while indent_stack.last && current_indent < indent_stack.last && in_block_stack.empty?
409
+ current_schema_stack.pop
410
+ indent_stack.pop
411
+ end
412
+
413
+ # Check for json.array! patterns first (before general json property check)
414
+ if line.strip.include?('json.array!')
415
+ # Handle json.array! @posts do |post| patterns
416
+ match = line.strip.match(/^json\.array!\s+@(\w+)\s+do\s+\|(\w+)\|/)
417
+ if match
418
+ collection_name = match[1] # e.g., "posts"
419
+ item_name = match[2] # e.g., "post"
420
+
421
+ # Add comment for the array itself if this is a root-level array
422
+ if current_schema_stack.size == 1 && current_schema_stack.first&.dig('type') == 'array'
423
+ unless has_openapi_comment?(lines, index)
424
+ array_comment = "# @openapi root:array items:object"
425
+ new_lines << (' ' * current_indent) + array_comment + "\n"
426
+ end
427
+ end
428
+
429
+ # Look for array item schema - try response_schema['items'] first
430
+ item_schema = current_schema_stack.last&.dig('items')
431
+ # For root-level arrays, use the response schema items directly
432
+ if !item_schema && current_schema_stack.size == 1 && current_schema_stack.first&.dig('type') == 'array'
433
+ item_schema = current_schema_stack.first['items']
434
+ end
435
+ item_schema ||= @partial_schemas[item_name.singularize]
436
+
437
+ if item_schema
438
+ current_schema_stack << item_schema
439
+ indent_stack << current_indent
440
+ in_block_stack << true
441
+ end
442
+ end
443
+ # Check for other array patterns like json.tags @post[:tags] do |tag|
444
+ elsif line.strip.match(/^json\.(\w+)\s+.*do\s+\|(\w+)\|/)
445
+ match = line.strip.match(/^json\.(\w+)\s+.*do\s+\|(\w+)\|/)
446
+ if match
447
+ property_name = match[1] # e.g., "tags"
448
+ item_name = match[2] # e.g., "tag"
449
+
450
+ # Look for this property in current schema
451
+ current_schema = current_schema_stack.last
452
+ if current_schema && current_schema['properties']
453
+ property_schema = find_property_in_schema(current_schema['properties'], property_name)
454
+
455
+ # Add comment for the array property itself if needed
456
+ if property_schema && !has_openapi_comment?(lines, index)
457
+ comment = generate_property_comment(property_name, property_schema)
458
+ new_lines << (' ' * current_indent) + comment + "\n" if comment
459
+ end
460
+
461
+ # If it's an array with items, push the items schema
462
+ if property_schema && property_schema['type'] == 'array' && property_schema['items'] && property_schema['items']['properties']
463
+ current_schema_stack << property_schema['items']
464
+ indent_stack << current_indent
465
+ in_block_stack << true
466
+ elif property_schema && property_schema['type'] == 'array' && property_schema['items']
467
+ # Try to find schema by item name
468
+ item_schema = @partial_schemas[item_name.singularize] || @partial_schemas[item_name]
469
+ if item_schema
470
+ current_schema_stack << item_schema
471
+ indent_stack << current_indent
472
+ in_block_stack << true
473
+ end
474
+ end
475
+ end
476
+ end
477
+ elsif line.strip.match(/^json\.partial!.*['"](\w+)\/_(\w+)['"]/)
478
+ # Handle partials - check if next line should have nested properties
479
+ match = line.strip.match(/^json\.partial!.*['"](\w+)\/_(\w+)['"]/)
480
+ if match
481
+ partial_dir = match[1]
482
+ partial_name = match[2]
483
+
484
+ # Check if this is inside a block (like json.author do)
485
+ if in_block_stack.any? && current_schema_stack.last
486
+ # We're inside a block, current schema should have the right context
487
+ current_schema = current_schema_stack.last
488
+ # The partial will handle its own properties
489
+ end
490
+ end
491
+ # Check if this line is a json property (general case)
492
+ elsif json_property_line?(line)
493
+ property_name = extract_property_name(line)
494
+
495
+ if property_name
496
+ current_schema = current_schema_stack.last
497
+
498
+ if current_schema && current_schema['properties']
499
+ property_schema = find_property_in_schema(current_schema['properties'], property_name)
500
+
501
+ if property_schema && !has_openapi_comment?(lines, index)
502
+ # Add comment before this line
503
+ comment = generate_property_comment(property_name, property_schema)
504
+ new_lines << (' ' * current_indent) + comment + "\n" if comment
505
+ end
506
+
507
+ # Check if this line opens a block
508
+ if line.include?(' do')
509
+ in_block_stack << true
510
+ if property_schema
511
+ # Push the nested schema onto the stack
512
+ if property_schema['type'] == 'object' && property_schema['properties']
513
+ current_schema_stack << property_schema
514
+ indent_stack << current_indent
515
+ elsif property_schema['type'] == 'array' && property_schema['items'] && property_schema['items']['properties']
516
+ current_schema_stack << property_schema['items']
517
+ indent_stack << current_indent
518
+ end
519
+ end
520
+ elsif line.include?(' do |') && property_schema && property_schema['type'] == 'array'
521
+ # Array iteration block
522
+ in_block_stack << true
523
+ if property_schema['items'] && property_schema['items']['properties']
524
+ current_schema_stack << property_schema['items']
525
+ indent_stack << current_indent
526
+ end
527
+ end
528
+ end
529
+ end
530
+ end
531
+
532
+ new_lines << line
533
+ end
534
+
535
+ new_lines.join
536
+ end
537
+
538
+ # Checks if operation comment should be added
539
+ # @param content [String] File content
540
+ # @param operation [Hash, nil] OpenAPI operation data
541
+ # @return [Boolean] True if operation comment should be added
542
+ def should_add_operation_comment(content, operation)
543
+ # Check if operation comment already exists
544
+ return false unless operation
545
+ !content.include?('@openapi_operation') &&
546
+ (operation['summary'] || operation['description'] || operation['tags'])
547
+ end
548
+
549
+ # Generates operation comment from OpenAPI data
550
+ # @param operation [Hash] OpenAPI operation data
551
+ # @return [String] Operation comment string
552
+ def generate_operation_comment(operation)
553
+ parts = []
554
+ parts << "summary:\"#{operation['summary']}\"" if operation['summary']
555
+ parts << "description:\"#{operation['description']}\"" if operation['description']
556
+
557
+ if operation['tags'] && operation['tags'].any?
558
+ tags = operation['tags'].map { |tag| tag.to_s }.join(',')
559
+ parts << "tags:[#{tags}]"
560
+ end
561
+
562
+ return nil if parts.empty?
563
+
564
+ "# @openapi_operation #{parts.join(' ')}"
565
+ end
566
+
567
+ # Checks if line contains a JSON property assignment
568
+ # @param line [String] Line to check
569
+ # @return [Boolean] True if JSON property line
570
+ def json_property_line?(line)
571
+ line.strip.match?(/^json\.\w+/)
572
+ end
573
+
574
+ # Extracts property name from JSON assignment line
575
+ # @param line [String] Line containing JSON property
576
+ # @return [String, nil] Property name or nil
577
+ def extract_property_name(line)
578
+ match = line.strip.match(/^json\.(\w+)/)
579
+ match ? match[1] : nil
580
+ end
581
+
582
+ # Checks if line already has an OpenAPI comment
583
+ # @param lines [Array<String>] All lines in the file
584
+ # @param current_index [Integer] Current line index
585
+ # @return [Boolean] True if OpenAPI comment exists
586
+ def has_openapi_comment?(lines, current_index)
587
+ # Check the previous line for @openapi comment
588
+ return false if current_index == 0
589
+
590
+ prev_line = lines[current_index - 1].strip
591
+ prev_line.include?('@openapi')
592
+ end
593
+
594
+ # Finds property in schema using exact match only
595
+ # @param properties [Hash] Properties hash from schema
596
+ # @param property_name [String] Property name from jbuilder
597
+ # @return [Hash, nil] Property schema or nil
598
+ def find_property_in_schema(properties, property_name)
599
+ properties[property_name]
600
+ end
601
+
602
+ # Generates property comment from schema
603
+ # @param property_name [String] Name of the property
604
+ # @param property_schema [Hash] Property schema from OpenAPI
605
+ # @return [String] Generated comment string
606
+ def generate_property_comment(property_name, property_schema)
607
+ return nil unless property_schema
608
+
609
+ type = property_schema['type'] || 'string'
610
+ parts = ["#{property_name}:#{type}"]
611
+
612
+ # Handle array items type
613
+ if type == 'array' && property_schema['items']
614
+ items_type = property_schema['items']['type'] || 'string'
615
+ parts[0] = "#{property_name}:array"
616
+ parts << "items:#{items_type}"
617
+ end
618
+
619
+ if property_schema['required'] == false
620
+ parts << "required:false"
621
+ end
622
+
623
+ if property_schema['description']
624
+ parts << "description:\"#{property_schema['description']}\""
625
+ end
626
+
627
+ if property_schema['enum']
628
+ enum_values = property_schema['enum'].map(&:to_s).join(',')
629
+ parts << "enum:[#{enum_values}]"
630
+ end
631
+
632
+ if property_schema['format']
633
+ parts << "format:#{property_schema['format']}"
634
+ end
635
+
636
+ if property_schema['minimum']
637
+ parts << "minimum:#{property_schema['minimum']}"
638
+ end
639
+
640
+ if property_schema['maximum']
641
+ parts << "maximum:#{property_schema['maximum']}"
642
+ end
643
+
644
+ "# @openapi #{parts.join(' ')}"
645
+ end
646
+ end
647
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'comment_parsers/conditional_parser'
4
+ require_relative 'comment_parsers/operation_parser'
5
+ require_relative 'comment_parsers/param_parser'
6
+ require_relative 'comment_parsers/query_parser'
7
+ require_relative 'comment_parsers/body_parser'
8
+ require_relative 'comment_parsers/attribute_parser'
9
+
10
+ module RailsOpenapiGen
11
+ module Parsers
12
+ class CommentParser
13
+ def initialize
14
+ @parsers = [
15
+ ConditionalParser.new,
16
+ OperationParser.new,
17
+ ParamParser.new,
18
+ QueryParser.new,
19
+ BodyParser.new,
20
+ AttributeParser.new
21
+ ]
22
+ end
23
+
24
+ def parse(comment_text)
25
+ parser = find_parser(comment_text)
26
+ return nil unless parser
27
+
28
+ parser.parse(comment_text)
29
+ end
30
+
31
+ private
32
+
33
+ def find_parser(comment_text)
34
+ @parsers.find do |parser|
35
+ comment_text.match?(parser.class::REGEX)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end