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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE +21 -0
- data/README.md +314 -0
- data/README_ja.md +314 -0
- data/README_zh.md +314 -0
- data/lib/rails_flow_map/analyzers/controller_analyzer.rb +124 -0
- data/lib/rails_flow_map/analyzers/model_analyzer.rb +139 -0
- data/lib/rails_flow_map/configuration.rb +22 -0
- data/lib/rails_flow_map/engine.rb +16 -0
- data/lib/rails_flow_map/errors.rb +240 -0
- data/lib/rails_flow_map/formatters/d3js_formatter.rb +488 -0
- data/lib/rails_flow_map/formatters/erd_formatter.rb +64 -0
- data/lib/rails_flow_map/formatters/git_diff_formatter.rb +589 -0
- data/lib/rails_flow_map/formatters/graphviz_formatter.rb +111 -0
- data/lib/rails_flow_map/formatters/mermaid_formatter.rb +91 -0
- data/lib/rails_flow_map/formatters/metrics_formatter.rb +196 -0
- data/lib/rails_flow_map/formatters/openapi_formatter.rb +557 -0
- data/lib/rails_flow_map/formatters/plantuml_formatter.rb +92 -0
- data/lib/rails_flow_map/formatters/sequence_formatter.rb +288 -0
- data/lib/rails_flow_map/generators/install/templates/rails_flow_map.rb +34 -0
- data/lib/rails_flow_map/generators/install_generator.rb +32 -0
- data/lib/rails_flow_map/logging.rb +215 -0
- data/lib/rails_flow_map/models/flow_edge.rb +31 -0
- data/lib/rails_flow_map/models/flow_graph.rb +58 -0
- data/lib/rails_flow_map/models/flow_node.rb +37 -0
- data/lib/rails_flow_map/version.rb +3 -0
- data/lib/rails_flow_map.rb +310 -0
- data/lib/tasks/rails_flow_map.rake +70 -0
- metadata +156 -0
@@ -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
|