rails-flow-map 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,557 @@
1
+ module RailsFlowMap
2
+ # Generates OpenAPI 3.0 specification from Rails application routes
3
+ #
4
+ # This formatter analyzes Rails routes and generates a complete OpenAPI/Swagger
5
+ # specification that can be used with tools like Swagger UI, Postman, or Insomnia.
6
+ #
7
+ # @example Basic usage
8
+ # formatter = OpenapiFormatter.new(graph)
9
+ # spec = formatter.format
10
+ # File.write('openapi.yaml', spec)
11
+ #
12
+ # @example With custom configuration
13
+ # formatter = OpenapiFormatter.new(graph, {
14
+ # api_version: '2.0.0',
15
+ # title: 'My API',
16
+ # description: 'Custom API documentation',
17
+ # servers: [{ url: 'https://api.myapp.com', description: 'Production' }]
18
+ # })
19
+ #
20
+ class OpenapiFormatter
21
+ # Creates a new OpenAPI formatter instance
22
+ #
23
+ # @param graph [FlowGraph] The graph containing route information
24
+ # @param options [Hash] Configuration options
25
+ # @option options [String] :api_version The API version (default: '1.0.0')
26
+ # @option options [String] :title The API title
27
+ # @option options [String] :description The API description
28
+ # @option options [Array<Hash>] :servers Custom server definitions
29
+ def initialize(graph, options = {})
30
+ @graph = graph
31
+ @options = options
32
+ @api_version = options[:api_version] || '1.0.0'
33
+ @title = options[:title] || 'Rails API Documentation'
34
+ @description = options[:description] || 'Auto-generated API documentation by RailsFlowMap'
35
+ end
36
+
37
+ # Generates the OpenAPI specification
38
+ #
39
+ # @param graph [FlowGraph] Optional graph to format (uses instance graph by default)
40
+ # @return [String] OpenAPI 3.0 specification in YAML format
41
+ def format(graph = @graph)
42
+ {
43
+ openapi: '3.0.0',
44
+ info: generate_info,
45
+ servers: generate_servers,
46
+ paths: generate_paths,
47
+ components: {
48
+ schemas: generate_schemas,
49
+ securitySchemes: generate_security_schemes
50
+ }
51
+ }.to_yaml
52
+ end
53
+
54
+ private
55
+
56
+ def generate_info
57
+ {
58
+ title: @title,
59
+ description: @description,
60
+ version: @api_version,
61
+ contact: {
62
+ name: 'API Support',
63
+ email: 'api@example.com'
64
+ }
65
+ }
66
+ end
67
+
68
+ def generate_servers
69
+ [
70
+ {
71
+ url: 'http://localhost:3000',
72
+ description: 'Development server'
73
+ },
74
+ {
75
+ url: 'https://api.example.com',
76
+ description: 'Production server'
77
+ }
78
+ ]
79
+ end
80
+
81
+ def generate_paths
82
+ paths = {}
83
+
84
+ # Build edge index for O(1) lookups
85
+ @edge_index = build_edge_index
86
+
87
+ # ルートノードから情報を収集
88
+ route_nodes = @graph.nodes_by_type(:route)
89
+
90
+ route_nodes.each do |route_node|
91
+ path = route_node.attributes[:path]
92
+ verb = route_node.attributes[:verb]&.downcase || 'get'
93
+
94
+ # パスパラメータを OpenAPI 形式に変換
95
+ openapi_path = path.gsub(/:(\w+)/, '{\1}')
96
+
97
+ paths[openapi_path] ||= {}
98
+ paths[openapi_path][verb] = generate_operation(route_node)
99
+ end
100
+
101
+ paths
102
+ end
103
+
104
+ def build_edge_index
105
+ index = Hash.new { |h, k| h[k] = [] }
106
+ @graph.edges.each do |edge|
107
+ index[[edge.from, edge.type]] << edge
108
+ end
109
+ index
110
+ end
111
+
112
+ def generate_operation(route_node)
113
+ # ルートに接続されているアクションを探す
114
+ action_edge = @edge_index[[route_node.id, :routes_to]]&.first
115
+ action_node = action_edge ? @graph.find_node(action_edge.to) : nil
116
+
117
+ controller_name = extract_controller_name(route_node)
118
+ action_name = action_node&.name || 'index'
119
+
120
+ operation = {
121
+ summary: generate_summary(controller_name, action_name),
122
+ description: generate_description(route_node, action_node),
123
+ operationId: "#{controller_name}_#{action_name}".gsub(/[^a-zA-Z0-9_]/, '_'),
124
+ tags: [controller_name],
125
+ parameters: generate_parameters(route_node),
126
+ responses: generate_responses(route_node, action_node)
127
+ }
128
+
129
+ # POSTやPUTの場合はリクエストボディを追加
130
+ if ['post', 'put', 'patch'].include?(route_node.attributes[:verb]&.downcase)
131
+ operation[:requestBody] = generate_request_body(route_node, controller_name)
132
+ end
133
+
134
+ operation
135
+ end
136
+
137
+ def extract_controller_name(route_node)
138
+ controller = route_node.attributes[:controller] || 'application'
139
+ camelize(controller.split('/').last.gsub('_controller', ''))
140
+ end
141
+
142
+ def generate_summary(controller_name, action_name)
143
+ case action_name
144
+ when 'index'
145
+ "List all #{pluralize(controller_name)}"
146
+ when 'show'
147
+ "Get a specific #{singularize(controller_name)}"
148
+ when 'create'
149
+ "Create a new #{singularize(controller_name)}"
150
+ when 'update'
151
+ "Update a #{singularize(controller_name)}"
152
+ when 'destroy'
153
+ "Delete a #{singularize(controller_name)}"
154
+ else
155
+ "#{humanize(action_name)} #{controller_name}"
156
+ end
157
+ end
158
+
159
+ def generate_description(route_node, action_node)
160
+ desc = "Endpoint: #{route_node.attributes[:verb]} #{route_node.attributes[:path]}\n"
161
+
162
+ if action_node
163
+ # アクションに接続されているサービスを探す
164
+ service_edges = @graph.edges.select { |e| e.from == action_node.id && e.type == :calls_service }
165
+ if service_edges.any?
166
+ desc += "\nServices used:\n"
167
+ service_edges.each do |edge|
168
+ service_node = @graph.find_node(edge.to)
169
+ desc += "- #{service_node.name}"
170
+ desc += " (#{edge.label})" if edge.label
171
+ desc += "\n"
172
+ end
173
+ end
174
+ end
175
+
176
+ desc
177
+ end
178
+
179
+ def generate_parameters(route_node)
180
+ parameters = []
181
+ path = route_node.attributes[:path]
182
+
183
+ # パスパラメータを抽出
184
+ path.scan(/:(\w+)/).each do |param|
185
+ parameters << {
186
+ name: param[0],
187
+ in: 'path',
188
+ required: true,
189
+ description: "ID of the #{singularize(param[0])}",
190
+ schema: {
191
+ type: 'integer',
192
+ format: 'int64'
193
+ }
194
+ }
195
+ end
196
+
197
+ # クエリパラメータを追加(index アクションの場合)
198
+ if route_node.name.include?('index') || route_node.attributes[:action] == 'index'
199
+ parameters.concat([
200
+ {
201
+ name: 'page',
202
+ in: 'query',
203
+ description: 'Page number for pagination',
204
+ schema: {
205
+ type: 'integer',
206
+ default: 1
207
+ }
208
+ },
209
+ {
210
+ name: 'per_page',
211
+ in: 'query',
212
+ description: 'Number of items per page',
213
+ schema: {
214
+ type: 'integer',
215
+ default: 20,
216
+ maximum: 100
217
+ }
218
+ },
219
+ {
220
+ name: 'sort',
221
+ in: 'query',
222
+ description: 'Sort field',
223
+ schema: {
224
+ type: 'string',
225
+ enum: ['created_at', 'updated_at', 'name']
226
+ }
227
+ },
228
+ {
229
+ name: 'order',
230
+ in: 'query',
231
+ description: 'Sort order',
232
+ schema: {
233
+ type: 'string',
234
+ enum: ['asc', 'desc'],
235
+ default: 'desc'
236
+ }
237
+ }
238
+ ])
239
+ end
240
+
241
+ parameters
242
+ end
243
+
244
+ def generate_responses(route_node, action_node)
245
+ responses = {}
246
+
247
+ case route_node.attributes[:action] || action_node&.name
248
+ when 'index'
249
+ responses['200'] = {
250
+ description: 'Successful response',
251
+ content: {
252
+ 'application/json' => {
253
+ schema: {
254
+ type: 'array',
255
+ items: {
256
+ '$ref' => "#/components/schemas/#{extract_model_name(route_node)}"
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+ when 'show'
263
+ responses['200'] = {
264
+ description: 'Successful response',
265
+ content: {
266
+ 'application/json' => {
267
+ schema: {
268
+ '$ref' => "#/components/schemas/#{extract_model_name(route_node)}"
269
+ }
270
+ }
271
+ }
272
+ }
273
+ responses['404'] = {
274
+ description: 'Resource not found'
275
+ }
276
+ when 'create'
277
+ responses['201'] = {
278
+ description: 'Resource created successfully',
279
+ content: {
280
+ 'application/json' => {
281
+ schema: {
282
+ '$ref' => "#/components/schemas/#{extract_model_name(route_node)}"
283
+ }
284
+ }
285
+ }
286
+ }
287
+ responses['422'] = {
288
+ description: 'Validation errors',
289
+ content: {
290
+ 'application/json' => {
291
+ schema: {
292
+ '$ref' => '#/components/schemas/ValidationError'
293
+ }
294
+ }
295
+ }
296
+ }
297
+ when 'update'
298
+ responses['200'] = {
299
+ description: 'Resource updated successfully',
300
+ content: {
301
+ 'application/json' => {
302
+ schema: {
303
+ '$ref' => "#/components/schemas/#{extract_model_name(route_node)}"
304
+ }
305
+ }
306
+ }
307
+ }
308
+ responses['404'] = {
309
+ description: 'Resource not found'
310
+ }
311
+ responses['422'] = {
312
+ description: 'Validation errors'
313
+ }
314
+ when 'destroy'
315
+ responses['204'] = {
316
+ description: 'Resource deleted successfully'
317
+ }
318
+ responses['404'] = {
319
+ description: 'Resource not found'
320
+ }
321
+ else
322
+ responses['200'] = {
323
+ description: 'Successful response'
324
+ }
325
+ end
326
+
327
+ # 共通のエラーレスポンス
328
+ responses['401'] = {
329
+ description: 'Unauthorized'
330
+ }
331
+ responses['500'] = {
332
+ description: 'Internal server error'
333
+ }
334
+
335
+ responses
336
+ end
337
+
338
+ def generate_request_body(route_node, controller_name)
339
+ model_name = extract_model_name(route_node)
340
+
341
+ {
342
+ required: true,
343
+ content: {
344
+ 'application/json' => {
345
+ schema: {
346
+ type: 'object',
347
+ properties: {
348
+ model_name.downcase => {
349
+ '$ref' => "#/components/schemas/#{model_name}Input"
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+ end
357
+
358
+ def extract_model_name(route_node)
359
+ controller = route_node.attributes[:controller] || ''
360
+ camelize(singularize(controller.split('/').last.gsub('_controller', '')))
361
+ end
362
+
363
+ def generate_schemas
364
+ schemas = {}
365
+
366
+ # モデルノードからスキーマを生成
367
+ model_nodes = @graph.nodes_by_type(:model)
368
+
369
+ model_nodes.each do |model|
370
+ schemas[model.name] = generate_model_schema(model)
371
+ schemas["#{model.name}Input"] = generate_input_schema(model)
372
+ end
373
+
374
+ # 共通スキーマ
375
+ schemas['ValidationError'] = {
376
+ type: 'object',
377
+ properties: {
378
+ errors: {
379
+ type: 'object',
380
+ additionalProperties: {
381
+ type: 'array',
382
+ items: {
383
+ type: 'string'
384
+ }
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ schemas['Pagination'] = {
391
+ type: 'object',
392
+ properties: {
393
+ current_page: { type: 'integer' },
394
+ total_pages: { type: 'integer' },
395
+ total_count: { type: 'integer' },
396
+ per_page: { type: 'integer' }
397
+ }
398
+ }
399
+
400
+ schemas
401
+ end
402
+
403
+ def generate_model_schema(model)
404
+ properties = {
405
+ id: {
406
+ type: 'integer',
407
+ format: 'int64',
408
+ readOnly: true
409
+ },
410
+ created_at: {
411
+ type: 'string',
412
+ format: 'date-time',
413
+ readOnly: true
414
+ },
415
+ updated_at: {
416
+ type: 'string',
417
+ format: 'date-time',
418
+ readOnly: true
419
+ }
420
+ }
421
+
422
+ # モデルの関連から属性を推測
423
+ if model.attributes[:associations]
424
+ model.attributes[:associations].each do |assoc|
425
+ if assoc.include?('belongs_to')
426
+ foreign_key = assoc.split(' ').last.downcase + '_id'
427
+ properties[foreign_key.to_sym] = {
428
+ type: 'integer',
429
+ format: 'int64',
430
+ description: "Foreign key for #{assoc}"
431
+ }
432
+ end
433
+ end
434
+ end
435
+
436
+ # モデル固有の属性を追加(推測)
437
+ case model.name
438
+ when 'User'
439
+ properties.merge!({
440
+ name: { type: 'string' },
441
+ email: { type: 'string', format: 'email' },
442
+ avatar_url: { type: 'string', format: 'uri', nullable: true }
443
+ })
444
+ when 'Post'
445
+ properties.merge!({
446
+ title: { type: 'string' },
447
+ body: { type: 'string' },
448
+ published: { type: 'boolean', default: false },
449
+ published_at: { type: 'string', format: 'date-time', nullable: true }
450
+ })
451
+ when 'Comment'
452
+ properties.merge!({
453
+ body: { type: 'string' },
454
+ approved: { type: 'boolean', default: true }
455
+ })
456
+ end
457
+
458
+ {
459
+ type: 'object',
460
+ properties: properties,
461
+ required: generate_required_fields(model)
462
+ }
463
+ end
464
+
465
+ def generate_input_schema(model)
466
+ schema = deep_dup(generate_model_schema(model))
467
+
468
+ # 読み取り専用フィールドを削除
469
+ schema[:properties].delete(:id)
470
+ schema[:properties].delete(:created_at)
471
+ schema[:properties].delete(:updated_at)
472
+
473
+ # 入力時のみのフィールドを追加
474
+ if model.name == 'User'
475
+ schema[:properties][:password] = {
476
+ type: 'string',
477
+ format: 'password',
478
+ minLength: 8
479
+ }
480
+ schema[:properties][:password_confirmation] = {
481
+ type: 'string',
482
+ format: 'password'
483
+ }
484
+ end
485
+
486
+ schema
487
+ end
488
+
489
+ def generate_required_fields(model)
490
+ case model.name
491
+ when 'User'
492
+ ['name', 'email']
493
+ when 'Post'
494
+ ['title', 'body']
495
+ when 'Comment'
496
+ ['body']
497
+ else
498
+ []
499
+ end
500
+ end
501
+
502
+ def generate_security_schemes
503
+ {
504
+ bearerAuth: {
505
+ type: 'http',
506
+ scheme: 'bearer',
507
+ bearerFormat: 'JWT'
508
+ },
509
+ apiKey: {
510
+ type: 'apiKey',
511
+ in: 'header',
512
+ name: 'X-API-Key'
513
+ }
514
+ }
515
+ end
516
+
517
+ # String manipulation helpers (avoid monkey-patching)
518
+ def camelize(str)
519
+ str.to_s.split('_').map(&:capitalize).join
520
+ end
521
+
522
+ def singularize(str)
523
+ str = str.to_s
524
+ case str
525
+ when /ies$/
526
+ str.sub(/ies$/, 'y')
527
+ when /ses$/, /xes$/, /zes$/, /ches$/, /shes$/
528
+ str.sub(/es$/, '')
529
+ when /s$/
530
+ str.sub(/s$/, '')
531
+ else
532
+ str
533
+ end
534
+ end
535
+
536
+ def pluralize(str)
537
+ str = str.to_s
538
+ case str
539
+ when /y$/
540
+ str.sub(/y$/, 'ies')
541
+ when /s$/, /x$/, /z$/, /ch$/, /sh$/
542
+ str + 'es'
543
+ else
544
+ str + 's'
545
+ end
546
+ end
547
+
548
+ def humanize(str)
549
+ str.to_s.gsub('_', ' ').capitalize
550
+ end
551
+
552
+ def deep_dup(hash)
553
+ Marshal.load(Marshal.dump(hash))
554
+ end
555
+ end
556
+ end
557
+
@@ -0,0 +1,92 @@
1
+ module RailsFlowMap
2
+ class PlantUMLFormatter
3
+ def format(graph)
4
+ lines = ["@startuml"]
5
+ lines << "!define MODEL_COLOR #FFCCFF"
6
+ lines << "!define CONTROLLER_COLOR #CCCCFF"
7
+ lines << "!define ACTION_COLOR #CCFFCC"
8
+ lines << ""
9
+
10
+ # Group nodes by type
11
+ models = graph.nodes_by_type(:model)
12
+ controllers = graph.nodes_by_type(:controller)
13
+ actions = graph.nodes_by_type(:action)
14
+
15
+ # Add models
16
+ unless models.empty?
17
+ lines << "package \"Models\" <<Database>> {"
18
+ models.each do |node|
19
+ lines << " class #{sanitize_id(node.name)} <<Model>> {"
20
+ lines << " .."
21
+ lines << " }"
22
+ end
23
+ lines << "}"
24
+ lines << ""
25
+ end
26
+
27
+ # Add controllers and their actions
28
+ unless controllers.empty?
29
+ lines << "package \"Controllers\" <<Frame>> {"
30
+ controllers.each do |controller|
31
+ lines << " class #{sanitize_id(controller.name)} <<Controller>> {"
32
+
33
+ # Find actions for this controller
34
+ controller_actions = graph.edges
35
+ .select { |e| e.from == controller.id && e.type == :has_action }
36
+ .map { |e| graph.find_node(e.to) }
37
+ .compact
38
+
39
+ controller_actions.each do |action|
40
+ lines << " +#{action.name}()"
41
+ end
42
+
43
+ lines << " }"
44
+ end
45
+ lines << "}"
46
+ lines << ""
47
+ end
48
+
49
+ # Add relationships
50
+ graph.edges.each do |edge|
51
+ next if edge.type == :has_action # Skip controller-action relationships
52
+
53
+ from_node = graph.find_node(edge.from)
54
+ to_node = graph.find_node(edge.to)
55
+
56
+ next unless from_node && to_node
57
+
58
+ relationship = format_relationship(edge, from_node, to_node)
59
+ lines << relationship if relationship
60
+ end
61
+
62
+ lines << ""
63
+ lines << "@enduml"
64
+
65
+ lines.join("\n")
66
+ end
67
+
68
+ private
69
+
70
+ def sanitize_id(name)
71
+ name.gsub(/[^a-zA-Z0-9_]/, '_')
72
+ end
73
+
74
+ def format_relationship(edge, from_node, to_node)
75
+ from_name = sanitize_id(from_node.name)
76
+ to_name = sanitize_id(to_node.name)
77
+
78
+ case edge.type
79
+ when :belongs_to
80
+ "#{from_name} --> \"1\" #{to_name} : #{edge.label || 'belongs_to'}"
81
+ when :has_one
82
+ "#{from_name} --> \"1\" #{to_name} : #{edge.label || 'has_one'}"
83
+ when :has_many
84
+ "#{from_name} --> \"*\" #{to_name} : #{edge.label || 'has_many'}"
85
+ when :has_and_belongs_to_many
86
+ "#{from_name} \"*\" <--> \"*\" #{to_name} : #{edge.label || 'has_and_belongs_to_many'}"
87
+ else
88
+ "#{from_name} --> #{to_name} : #{edge.label || edge.type}"
89
+ end
90
+ end
91
+ end
92
+ end