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.
- checksums.yaml +7 -0
- data/CLAUDE.md +160 -0
- data/README.md +164 -0
- data/lib/rails_openapi_gen/configuration.rb +157 -0
- data/lib/rails_openapi_gen/engine.rb +11 -0
- data/lib/rails_openapi_gen/generators/yaml_generator.rb +302 -0
- data/lib/rails_openapi_gen/importer.rb +647 -0
- data/lib/rails_openapi_gen/parsers/comment_parser.rb +40 -0
- data/lib/rails_openapi_gen/parsers/comment_parsers/attribute_parser.rb +57 -0
- data/lib/rails_openapi_gen/parsers/comment_parsers/base_attribute_parser.rb +42 -0
- data/lib/rails_openapi_gen/parsers/comment_parsers/body_parser.rb +62 -0
- data/lib/rails_openapi_gen/parsers/comment_parsers/conditional_parser.rb +13 -0
- data/lib/rails_openapi_gen/parsers/comment_parsers/operation_parser.rb +50 -0
- data/lib/rails_openapi_gen/parsers/comment_parsers/param_parser.rb +62 -0
- data/lib/rails_openapi_gen/parsers/comment_parsers/query_parser.rb +62 -0
- data/lib/rails_openapi_gen/parsers/controller_parser.rb +153 -0
- data/lib/rails_openapi_gen/parsers/jbuilder_parser.rb +529 -0
- data/lib/rails_openapi_gen/parsers/routes_parser.rb +33 -0
- data/lib/rails_openapi_gen/parsers/template_processors/jbuilder_template_processor.rb +147 -0
- data/lib/rails_openapi_gen/parsers/template_processors/response_template_processor.rb +17 -0
- data/lib/rails_openapi_gen/railtie.rb +11 -0
- data/lib/rails_openapi_gen/tasks/openapi.rake +30 -0
- data/lib/rails_openapi_gen/version.rb +5 -0
- data/lib/rails_openapi_gen.rb +267 -0
- data/lib/tasks/openapi_import.rake +126 -0
- data/rails-openapi-gen.gemspec +30 -0
- 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
|