fastapi-voyager 0.4.1__py3-none-any.whl
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.
- fastapi_voyager/__init__.py +5 -0
- fastapi_voyager/cli.py +289 -0
- fastapi_voyager/filter.py +105 -0
- fastapi_voyager/graph.py +396 -0
- fastapi_voyager/module.py +44 -0
- fastapi_voyager/server.py +107 -0
- fastapi_voyager/type.py +48 -0
- fastapi_voyager/type_helper.py +232 -0
- fastapi_voyager/version.py +2 -0
- fastapi_voyager/web/component/route-code-display.js +73 -0
- fastapi_voyager/web/component/schema-code-display.js +152 -0
- fastapi_voyager/web/component/schema-field-filter.js +189 -0
- fastapi_voyager/web/graph-ui.js +137 -0
- fastapi_voyager/web/graphviz.svg.css +42 -0
- fastapi_voyager/web/graphviz.svg.js +580 -0
- fastapi_voyager/web/index.html +224 -0
- fastapi_voyager/web/quasar.min.css +1 -0
- fastapi_voyager/web/quasar.min.js +127 -0
- fastapi_voyager/web/vue-main.js +213 -0
- fastapi_voyager-0.4.1.dist-info/METADATA +175 -0
- fastapi_voyager-0.4.1.dist-info/RECORD +24 -0
- fastapi_voyager-0.4.1.dist-info/WHEEL +4 -0
- fastapi_voyager-0.4.1.dist-info/entry_points.txt +2 -0
- fastapi_voyager-0.4.1.dist-info/licenses/LICENSE +21 -0
fastapi_voyager/graph.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from fastapi import FastAPI, routing
|
|
4
|
+
from fastapi_voyager.type_helper import (
|
|
5
|
+
get_core_types,
|
|
6
|
+
full_class_name,
|
|
7
|
+
get_bases_fields,
|
|
8
|
+
is_inheritance_of_pydantic_base,
|
|
9
|
+
get_pydantic_fields,
|
|
10
|
+
get_vscode_link,
|
|
11
|
+
get_source
|
|
12
|
+
)
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
from fastapi_voyager.type import Route, SchemaNode, Link, Tag, ModuleNode
|
|
15
|
+
from fastapi_voyager.module import build_module_tree
|
|
16
|
+
from fastapi_voyager.filter import filter_graph
|
|
17
|
+
|
|
18
|
+
# support pydantic-resolve's ensure_subset
|
|
19
|
+
ENSURE_SUBSET_REFERENCE = '__pydantic_resolve_ensure_subset_reference__'
|
|
20
|
+
PK = "PK"
|
|
21
|
+
|
|
22
|
+
class Analytics:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
schema: str | None = None,
|
|
26
|
+
schema_field: str | None = None,
|
|
27
|
+
show_fields: Literal['single', 'object', 'all'] = 'single',
|
|
28
|
+
include_tags: list[str] | None = None,
|
|
29
|
+
module_color: dict[str, str] | None = None,
|
|
30
|
+
route_name: str | None = None,
|
|
31
|
+
load_meta: bool = False
|
|
32
|
+
):
|
|
33
|
+
|
|
34
|
+
self.routes: list[Route] = []
|
|
35
|
+
|
|
36
|
+
self.nodes: list[SchemaNode] = []
|
|
37
|
+
self.node_set: dict[str, SchemaNode] = {}
|
|
38
|
+
|
|
39
|
+
self.link_set: set[tuple[str, str]] = set()
|
|
40
|
+
self.links: list[Link] = []
|
|
41
|
+
|
|
42
|
+
# store Tag by id, and also keep a list for rendering order
|
|
43
|
+
self.tag_set: dict[str, Tag] = {}
|
|
44
|
+
self.tags: list[Tag] = []
|
|
45
|
+
|
|
46
|
+
self.include_tags = include_tags
|
|
47
|
+
self.schema = schema
|
|
48
|
+
self.schema_field = schema_field
|
|
49
|
+
self.show_fields = show_fields if show_fields in ('single','object','all') else 'object'
|
|
50
|
+
self.module_color = module_color or {}
|
|
51
|
+
self.route_name = route_name
|
|
52
|
+
self.load_meta = load_meta
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get_available_route(self, app: FastAPI):
|
|
56
|
+
for route in app.routes:
|
|
57
|
+
if isinstance(route, routing.APIRoute) and route.response_model:
|
|
58
|
+
yield route
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def analysis(self, app: FastAPI):
|
|
62
|
+
"""
|
|
63
|
+
1. get routes which return pydantic schema
|
|
64
|
+
1.1 collect tags and routes, add links tag-> route
|
|
65
|
+
1.2 collect response_model and links route -> response_model
|
|
66
|
+
|
|
67
|
+
2. iterate schemas, construct the schema/model nodes and their links
|
|
68
|
+
"""
|
|
69
|
+
schemas: list[type[BaseModel]] = []
|
|
70
|
+
|
|
71
|
+
for route in self._get_available_route(app):
|
|
72
|
+
# check tags
|
|
73
|
+
tags = getattr(route, 'tags', None)
|
|
74
|
+
route_tag = tags[0] if tags else '__default__'
|
|
75
|
+
if self.include_tags and route_tag not in self.include_tags:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# add tag if not exists
|
|
79
|
+
tag_id = f'tag__{route_tag}'
|
|
80
|
+
if tag_id not in self.tag_set:
|
|
81
|
+
tag_obj = Tag(id=tag_id, name=route_tag, routes=[])
|
|
82
|
+
self.tag_set[tag_id] = tag_obj
|
|
83
|
+
self.tags.append(tag_obj)
|
|
84
|
+
|
|
85
|
+
# add route and create links
|
|
86
|
+
route_id = f'{route.endpoint.__name__}_{route.path.replace("/", "_")}'
|
|
87
|
+
route_name = route.endpoint.__name__
|
|
88
|
+
|
|
89
|
+
# filter by route_name (route.id) if provided
|
|
90
|
+
if self.route_name is not None and route_id != self.route_name:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
route_obj = Route(
|
|
94
|
+
id=route_id,
|
|
95
|
+
name=route_name,
|
|
96
|
+
vscode_link=get_vscode_link(route.endpoint) if self.load_meta else None,
|
|
97
|
+
source_code=inspect.getsource(route.endpoint) if self.load_meta else None
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self.routes.append(route_obj)
|
|
101
|
+
# add route into current tag
|
|
102
|
+
self.tag_set[tag_id].routes.append(route_obj)
|
|
103
|
+
self.links.append(Link(
|
|
104
|
+
source=tag_id,
|
|
105
|
+
source_origin=tag_id,
|
|
106
|
+
target=route_id,
|
|
107
|
+
target_origin=route_id,
|
|
108
|
+
type='entry'
|
|
109
|
+
))
|
|
110
|
+
|
|
111
|
+
# add response_models and create links from route -> response_model
|
|
112
|
+
for schema in get_core_types(route.response_model):
|
|
113
|
+
if schema and issubclass(schema, BaseModel):
|
|
114
|
+
target_name = full_class_name(schema)
|
|
115
|
+
self.links.append(Link(
|
|
116
|
+
source=route_id,
|
|
117
|
+
source_origin=route_id,
|
|
118
|
+
target=self.generate_node_head(target_name),
|
|
119
|
+
target_origin=target_name,
|
|
120
|
+
type='entry'
|
|
121
|
+
))
|
|
122
|
+
|
|
123
|
+
schemas.append(schema)
|
|
124
|
+
|
|
125
|
+
for s in schemas:
|
|
126
|
+
self.analysis_schemas(s)
|
|
127
|
+
|
|
128
|
+
self.nodes = list(self.node_set.values())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def add_to_node_set(self, schema):
|
|
132
|
+
"""
|
|
133
|
+
1. calc full_path, add to node_set
|
|
134
|
+
2. if duplicated, do nothing, else insert
|
|
135
|
+
2. return the full_path
|
|
136
|
+
"""
|
|
137
|
+
full_name = full_class_name(schema)
|
|
138
|
+
bases_fields = get_bases_fields([s for s in schema.__bases__ if is_inheritance_of_pydantic_base(s)])
|
|
139
|
+
if full_name not in self.node_set:
|
|
140
|
+
# skip meta info for normal queries
|
|
141
|
+
self.node_set[full_name] = SchemaNode(
|
|
142
|
+
id=full_name,
|
|
143
|
+
module=schema.__module__,
|
|
144
|
+
name=schema.__name__,
|
|
145
|
+
source_code=get_source(schema) if self.load_meta else None,
|
|
146
|
+
vscode_link=get_vscode_link(schema) if self.load_meta else None,
|
|
147
|
+
fields=get_pydantic_fields(schema, bases_fields)
|
|
148
|
+
)
|
|
149
|
+
return full_name
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def add_to_link_set(
|
|
153
|
+
self,
|
|
154
|
+
source: str,
|
|
155
|
+
source_origin: str,
|
|
156
|
+
target: str,
|
|
157
|
+
target_origin: str,
|
|
158
|
+
type: Literal['child', 'parent', 'subset']):
|
|
159
|
+
"""
|
|
160
|
+
1. add link to link_set
|
|
161
|
+
2. if duplicated, do nothing, else insert
|
|
162
|
+
"""
|
|
163
|
+
pair = (source, target)
|
|
164
|
+
if result := pair not in self.link_set:
|
|
165
|
+
self.link_set.add(pair)
|
|
166
|
+
self.links.append(Link(
|
|
167
|
+
source=source,
|
|
168
|
+
source_origin=source_origin,
|
|
169
|
+
target=target,
|
|
170
|
+
target_origin=target_origin,
|
|
171
|
+
type=type
|
|
172
|
+
))
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def analysis_schemas(self, schema: type[BaseModel]):
|
|
177
|
+
"""
|
|
178
|
+
1. cls is the source, add schema
|
|
179
|
+
2. pydantic fields are targets, if annotation is subclass of BaseMode, add fields and add links
|
|
180
|
+
3. recursively run walk_schema
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
self.add_to_node_set(schema)
|
|
184
|
+
|
|
185
|
+
# handle schema inside ensure_subset(schema)
|
|
186
|
+
if subset_reference := getattr(schema, ENSURE_SUBSET_REFERENCE, None):
|
|
187
|
+
if is_inheritance_of_pydantic_base(subset_reference):
|
|
188
|
+
|
|
189
|
+
self.add_to_node_set(subset_reference)
|
|
190
|
+
self.add_to_link_set(
|
|
191
|
+
source=self.generate_node_head(full_class_name(schema)),
|
|
192
|
+
source_origin=full_class_name(schema),
|
|
193
|
+
target= self.generate_node_head(full_class_name(subset_reference)),
|
|
194
|
+
target_origin=full_class_name(subset_reference),
|
|
195
|
+
type='subset')
|
|
196
|
+
self.analysis_schemas(subset_reference)
|
|
197
|
+
|
|
198
|
+
# handle bases
|
|
199
|
+
for base_class in schema.__bases__:
|
|
200
|
+
if is_inheritance_of_pydantic_base(base_class):
|
|
201
|
+
self.add_to_node_set(base_class)
|
|
202
|
+
self.add_to_link_set(
|
|
203
|
+
source=self.generate_node_head(full_class_name(schema)),
|
|
204
|
+
source_origin=full_class_name(schema),
|
|
205
|
+
target=self.generate_node_head(full_class_name(base_class)),
|
|
206
|
+
target_origin=full_class_name(base_class),
|
|
207
|
+
type='parent')
|
|
208
|
+
self.analysis_schemas(base_class)
|
|
209
|
+
|
|
210
|
+
# handle fields
|
|
211
|
+
for k, v in schema.model_fields.items():
|
|
212
|
+
annos = get_core_types(v.annotation)
|
|
213
|
+
for anno in annos:
|
|
214
|
+
if anno and is_inheritance_of_pydantic_base(anno):
|
|
215
|
+
self.add_to_node_set(anno)
|
|
216
|
+
# add f prefix to fix highlight issue in vsc graphviz interactive previewer
|
|
217
|
+
source_name = f'{full_class_name(schema)}::f{k}'
|
|
218
|
+
if self.add_to_link_set(
|
|
219
|
+
source=source_name,
|
|
220
|
+
source_origin=full_class_name(schema),
|
|
221
|
+
target=self.generate_node_head(full_class_name(anno)),
|
|
222
|
+
target_origin=full_class_name(anno),
|
|
223
|
+
type='internal'):
|
|
224
|
+
self.analysis_schemas(anno)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def generate_node_head(self, link_name: str):
|
|
228
|
+
return f'{link_name}::{PK}'
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def generate_node_label(self, node: SchemaNode):
|
|
232
|
+
has_base_fields = any(f.from_base for f in node.fields)
|
|
233
|
+
|
|
234
|
+
fields = [n for n in node.fields if n.from_base is False]
|
|
235
|
+
|
|
236
|
+
name = node.name
|
|
237
|
+
fields_parts: list[str] = []
|
|
238
|
+
|
|
239
|
+
if self.show_fields == 'all':
|
|
240
|
+
_fields = fields
|
|
241
|
+
if has_base_fields:
|
|
242
|
+
fields_parts.append('<tr><td align="left" cellpadding="8"><font color="#999"> Inherited Fields ... </font></td></tr>')
|
|
243
|
+
elif self.show_fields == 'object':
|
|
244
|
+
_fields = [f for f in fields if f.is_object is True]
|
|
245
|
+
|
|
246
|
+
else: # 'single'
|
|
247
|
+
_fields = []
|
|
248
|
+
|
|
249
|
+
for field in _fields:
|
|
250
|
+
type_name = field.type_name[:25] + '..' if len(field.type_name) > 25 else field.type_name
|
|
251
|
+
display_xml = f'<s align="left">{field.name}: {type_name}</s>' if field.is_exclude else f'{field.name}: {type_name}'
|
|
252
|
+
field_str = f"""<tr><td align="left" port="f{field.name}" cellpadding="8"><font> {display_xml} </font></td></tr>"""
|
|
253
|
+
fields_parts.append(field_str)
|
|
254
|
+
|
|
255
|
+
header_color = 'tomato' if node.id == self.schema else '#009485'
|
|
256
|
+
header = f"""<tr><td cellpadding="1.5" bgcolor="{header_color}" align="center" colspan="1" port="{PK}"> <font color="white"> {name} </font> </td> </tr>"""
|
|
257
|
+
field_content = ''.join(fields_parts) if fields_parts else ''
|
|
258
|
+
|
|
259
|
+
return f"""<<table border="1" cellborder="0" cellpadding="0" bgcolor="white"> {header} {field_content} </table>>"""
|
|
260
|
+
|
|
261
|
+
def generate_dot(self):
|
|
262
|
+
|
|
263
|
+
def get_link_attributes(link: Link):
|
|
264
|
+
if link.type == 'child':
|
|
265
|
+
return 'style = "dashed", label = "", minlen=3'
|
|
266
|
+
elif link.type == 'parent':
|
|
267
|
+
return 'style = "solid", dir="back", minlen=3, taillabel = "< inherit >", color = "purple", tailport="n"'
|
|
268
|
+
elif link.type == 'entry':
|
|
269
|
+
return 'style = "solid", label = "", minlen=3'
|
|
270
|
+
elif link.type == 'subset':
|
|
271
|
+
return 'style = "solid", dir="back", minlen=3, taillabel = "< subset >", color = "orange", tailport="n"'
|
|
272
|
+
|
|
273
|
+
return 'style = "solid", arrowtail="odiamond", dir="back", minlen=3'
|
|
274
|
+
|
|
275
|
+
def render_module(mod: ModuleNode):
|
|
276
|
+
color = self.module_color.get(mod.fullname)
|
|
277
|
+
# render schema nodes inside this module
|
|
278
|
+
inner_nodes = [
|
|
279
|
+
f'''
|
|
280
|
+
"{node.id}" [
|
|
281
|
+
label = {self.generate_node_label(node)}
|
|
282
|
+
shape = "plain"
|
|
283
|
+
margin="0.5,0.1"
|
|
284
|
+
];''' for node in mod.schema_nodes
|
|
285
|
+
]
|
|
286
|
+
inner_nodes_str = '\n'.join(inner_nodes)
|
|
287
|
+
|
|
288
|
+
# render child modules recursively
|
|
289
|
+
child_str = '\n'.join(render_module(m) for m in mod.modules)
|
|
290
|
+
|
|
291
|
+
return f'''
|
|
292
|
+
subgraph cluster_module_{mod.fullname.replace('.', '_')} {{
|
|
293
|
+
color = "#666"
|
|
294
|
+
style="rounded"
|
|
295
|
+
label = " {mod.name}"
|
|
296
|
+
labeljust = "l"
|
|
297
|
+
{(f'pencolor = "{color}"' if color else 'pencolor=""')}
|
|
298
|
+
{(f'penwidth = 3' if color else 'penwidth=""')}
|
|
299
|
+
{inner_nodes_str}
|
|
300
|
+
{child_str}
|
|
301
|
+
}}'''
|
|
302
|
+
|
|
303
|
+
def handle_entry(source: str):
|
|
304
|
+
if '::' in source:
|
|
305
|
+
a, b = source.split('::', 1)
|
|
306
|
+
return f'"{a}":{b}'
|
|
307
|
+
return f'"{source}"'
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
_tags, _routes, _nodes, _links = filter_graph(
|
|
311
|
+
schema=self.schema,
|
|
312
|
+
schema_field=self.schema_field,
|
|
313
|
+
tags=self.tags,
|
|
314
|
+
routes=self.routes,
|
|
315
|
+
nodes=self.nodes,
|
|
316
|
+
links=self.links,
|
|
317
|
+
node_set=self.node_set,
|
|
318
|
+
)
|
|
319
|
+
_modules = build_module_tree(_nodes)
|
|
320
|
+
|
|
321
|
+
tags = [
|
|
322
|
+
f'''
|
|
323
|
+
"{t.id}" [
|
|
324
|
+
label = " {t.name} "
|
|
325
|
+
shape = "record"
|
|
326
|
+
margin="0.5,0.1"
|
|
327
|
+
];''' for t in _tags]
|
|
328
|
+
tag_str = '\n'.join(tags)
|
|
329
|
+
|
|
330
|
+
routes = [
|
|
331
|
+
f'''
|
|
332
|
+
"{r.id}" [
|
|
333
|
+
label = " {r.name} "
|
|
334
|
+
margin="0.5,0.1"
|
|
335
|
+
shape = "record"
|
|
336
|
+
];''' for r in _routes]
|
|
337
|
+
route_str = '\n'.join(routes)
|
|
338
|
+
|
|
339
|
+
modules_str = '\n'.join(render_module(m) for m in _modules)
|
|
340
|
+
|
|
341
|
+
links = [
|
|
342
|
+
f'''{handle_entry(link.source)} -> {handle_entry(link.target)} [ {get_link_attributes(link)} ];''' for link in _links
|
|
343
|
+
]
|
|
344
|
+
link_str = '\n'.join(links)
|
|
345
|
+
|
|
346
|
+
template = f'''
|
|
347
|
+
digraph world {{
|
|
348
|
+
pad="0.5"
|
|
349
|
+
nodesep=0.8
|
|
350
|
+
fontname="Helvetica,Arial,sans-serif"
|
|
351
|
+
node [fontname="Helvetica,Arial,sans-serif"]
|
|
352
|
+
edge [
|
|
353
|
+
fontname="Helvetica,Arial,sans-serif"
|
|
354
|
+
color="gray"
|
|
355
|
+
]
|
|
356
|
+
graph [
|
|
357
|
+
rankdir = "LR"
|
|
358
|
+
];
|
|
359
|
+
node [
|
|
360
|
+
fontsize = "16"
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
subgraph cluster_tags {{
|
|
364
|
+
color = "#aaa"
|
|
365
|
+
margin=18
|
|
366
|
+
style="dashed"
|
|
367
|
+
label = " Tags"
|
|
368
|
+
labeljust = "l"
|
|
369
|
+
fontsize = "20"
|
|
370
|
+
{tag_str}
|
|
371
|
+
}}
|
|
372
|
+
|
|
373
|
+
subgraph cluster_router {{
|
|
374
|
+
color = "#aaa"
|
|
375
|
+
margin=18
|
|
376
|
+
style="dashed"
|
|
377
|
+
label = " Route apis"
|
|
378
|
+
labeljust = "l"
|
|
379
|
+
fontsize = "20"
|
|
380
|
+
{route_str}
|
|
381
|
+
}}
|
|
382
|
+
|
|
383
|
+
subgraph cluster_schema {{
|
|
384
|
+
color = "#aaa"
|
|
385
|
+
margin=18
|
|
386
|
+
style="dashed"
|
|
387
|
+
label=" Schema"
|
|
388
|
+
labeljust="l"
|
|
389
|
+
fontsize="20"
|
|
390
|
+
{modules_str}
|
|
391
|
+
}}
|
|
392
|
+
|
|
393
|
+
{link_str}
|
|
394
|
+
}}
|
|
395
|
+
'''
|
|
396
|
+
return template
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from fastapi_voyager.type import SchemaNode, ModuleNode
|
|
2
|
+
|
|
3
|
+
def build_module_tree(schema_nodes: list[SchemaNode]) -> list[ModuleNode]:
|
|
4
|
+
"""
|
|
5
|
+
1. the name of module_node comes from schema_node's module field
|
|
6
|
+
2. split the module_name with '.' to create a tree structure
|
|
7
|
+
3. group schema_nodes under the correct module_node
|
|
8
|
+
4. return the top-level module_node list
|
|
9
|
+
"""
|
|
10
|
+
# Map from top-level module name to ModuleNode
|
|
11
|
+
top_modules: dict[str, ModuleNode] = {}
|
|
12
|
+
# For nodes without module path, collect separately
|
|
13
|
+
root_level_nodes = []
|
|
14
|
+
|
|
15
|
+
def get_or_create(child_name: str, parent: ModuleNode) -> ModuleNode:
|
|
16
|
+
for m in parent.modules:
|
|
17
|
+
if m.name == child_name:
|
|
18
|
+
return m
|
|
19
|
+
# derive fullname from parent
|
|
20
|
+
parent_full = parent.fullname
|
|
21
|
+
fullname = child_name if not parent_full or parent_full == "__root__" else f"{parent_full}.{child_name}"
|
|
22
|
+
new_node = ModuleNode(name=child_name, fullname=fullname, schema_nodes=[], modules=[])
|
|
23
|
+
parent.modules.append(new_node)
|
|
24
|
+
return new_node
|
|
25
|
+
|
|
26
|
+
for sn in schema_nodes:
|
|
27
|
+
module_path = sn.module or ""
|
|
28
|
+
if not module_path:
|
|
29
|
+
root_level_nodes.append(sn)
|
|
30
|
+
continue
|
|
31
|
+
parts = module_path.split('.')
|
|
32
|
+
top_name = parts[0]
|
|
33
|
+
if top_name not in top_modules:
|
|
34
|
+
top_modules[top_name] = ModuleNode(name=top_name, fullname=top_name, schema_nodes=[], modules=[])
|
|
35
|
+
current = top_modules[top_name]
|
|
36
|
+
for part in parts[1:]:
|
|
37
|
+
current = get_or_create(part, current)
|
|
38
|
+
current.schema_nodes.append(sn)
|
|
39
|
+
|
|
40
|
+
# If there are root-level nodes, add a pseudo-module named "__root__"
|
|
41
|
+
result = list(top_modules.values())
|
|
42
|
+
if root_level_nodes:
|
|
43
|
+
result.append(ModuleNode(name="__root__", fullname="__root__", schema_nodes=root_level_nodes, modules=[]))
|
|
44
|
+
return result
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional, Dict
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from fastapi.responses import HTMLResponse, PlainTextResponse
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
from fastapi_voyager.graph import Analytics
|
|
9
|
+
from fastapi_voyager.type import Tag, FieldInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
WEB_DIR = Path(__file__).parent / "web"
|
|
13
|
+
WEB_DIR.mkdir(exist_ok=True)
|
|
14
|
+
|
|
15
|
+
class SchemaType(BaseModel):
|
|
16
|
+
name: str
|
|
17
|
+
fullname: str
|
|
18
|
+
source_code: str
|
|
19
|
+
vscode_link: str
|
|
20
|
+
fields: list[FieldInfo]
|
|
21
|
+
|
|
22
|
+
class OptionParam(BaseModel):
|
|
23
|
+
tags: list[Tag]
|
|
24
|
+
schemas: list[SchemaType]
|
|
25
|
+
dot: str
|
|
26
|
+
|
|
27
|
+
class Payload(BaseModel):
|
|
28
|
+
tags: Optional[list[str]] = None
|
|
29
|
+
schema_name: Optional[str] = None
|
|
30
|
+
schema_field: Optional[str] = None
|
|
31
|
+
route_name: Optional[str] = None
|
|
32
|
+
show_fields: str = 'object'
|
|
33
|
+
show_meta: bool = False
|
|
34
|
+
|
|
35
|
+
def create_app_with_fastapi(
|
|
36
|
+
target_app: FastAPI,
|
|
37
|
+
module_color: dict[str, str] | None = None,
|
|
38
|
+
gzip_minimum_size: int | None = 500,
|
|
39
|
+
) -> FastAPI:
|
|
40
|
+
"""Create a FastAPI server that serves DOT computed via Analytics.
|
|
41
|
+
|
|
42
|
+
This avoids module-level globals by keeping state in closures.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
app = FastAPI(title="fastapi-voyager demo server")
|
|
46
|
+
|
|
47
|
+
# Enable gzip compression for larger responses (e.g. DOT / schemas payload)
|
|
48
|
+
if gzip_minimum_size is not None and gzip_minimum_size >= 0:
|
|
49
|
+
app.add_middleware(GZipMiddleware, minimum_size=gzip_minimum_size)
|
|
50
|
+
|
|
51
|
+
@app.get("/dot", response_model=OptionParam)
|
|
52
|
+
def get_dot() -> str:
|
|
53
|
+
analytics = Analytics(module_color=module_color, load_meta=True)
|
|
54
|
+
analytics.analysis(target_app)
|
|
55
|
+
dot = analytics.generate_dot()
|
|
56
|
+
|
|
57
|
+
# include tags and their routes
|
|
58
|
+
tags = analytics.tags
|
|
59
|
+
|
|
60
|
+
schemas = [
|
|
61
|
+
SchemaType(
|
|
62
|
+
name=s.name,
|
|
63
|
+
fullname=s.id,
|
|
64
|
+
fields=s.fields,
|
|
65
|
+
source_code=s.source_code,
|
|
66
|
+
vscode_link=s.vscode_link
|
|
67
|
+
) for s in analytics.nodes
|
|
68
|
+
]
|
|
69
|
+
schemas.sort(key=lambda s: s.name)
|
|
70
|
+
|
|
71
|
+
return OptionParam(tags=tags, schemas=schemas, dot=dot)
|
|
72
|
+
|
|
73
|
+
@app.post("/dot", response_class=PlainTextResponse)
|
|
74
|
+
def get_filtered_dot(payload: Payload) -> str:
|
|
75
|
+
analytics = Analytics(
|
|
76
|
+
include_tags=payload.tags,
|
|
77
|
+
schema=payload.schema_name,
|
|
78
|
+
schema_field=payload.schema_field,
|
|
79
|
+
show_fields=payload.show_fields,
|
|
80
|
+
module_color=module_color,
|
|
81
|
+
route_name=payload.route_name,
|
|
82
|
+
load_meta=False,
|
|
83
|
+
)
|
|
84
|
+
analytics.analysis(target_app)
|
|
85
|
+
return analytics.generate_dot()
|
|
86
|
+
|
|
87
|
+
@app.get("/", response_class=HTMLResponse)
|
|
88
|
+
def index():
|
|
89
|
+
index_file = WEB_DIR / "index.html"
|
|
90
|
+
if index_file.exists():
|
|
91
|
+
return index_file.read_text(encoding="utf-8")
|
|
92
|
+
# fallback simple page if index.html missing
|
|
93
|
+
return """
|
|
94
|
+
<!doctype html>
|
|
95
|
+
<html>
|
|
96
|
+
<head><meta charset=\"utf-8\"><title>Graphviz Preview</title></head>
|
|
97
|
+
<body>
|
|
98
|
+
<p>index.html not found. Create one under src/fastapi_voyager/web/index.html</p>
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# Serve static files under /static
|
|
104
|
+
app.mount("/static", StaticFiles(directory=str(WEB_DIR)), name="static")
|
|
105
|
+
|
|
106
|
+
return app
|
|
107
|
+
|
fastapi_voyager/type.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class FieldInfo:
|
|
7
|
+
name: str
|
|
8
|
+
type_name: str
|
|
9
|
+
from_base: bool = False
|
|
10
|
+
is_object: bool = False
|
|
11
|
+
is_exclude: bool = False
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Tag:
|
|
15
|
+
id: str
|
|
16
|
+
name: str
|
|
17
|
+
routes: list['Route'] # route.id
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Route:
|
|
21
|
+
id: str
|
|
22
|
+
name: str
|
|
23
|
+
source_code: str = ''
|
|
24
|
+
vscode_link: str = '' # optional vscode deep link
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SchemaNode:
|
|
28
|
+
id: str
|
|
29
|
+
module: str
|
|
30
|
+
name: str
|
|
31
|
+
source_code: str = '' # optional for tests / backward compatibility
|
|
32
|
+
vscode_link: str = '' # optional vscode deep link
|
|
33
|
+
fields: list[FieldInfo] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ModuleNode:
|
|
37
|
+
name: str
|
|
38
|
+
fullname: str
|
|
39
|
+
schema_nodes: list[SchemaNode]
|
|
40
|
+
modules: list['ModuleNode']
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Link:
|
|
44
|
+
source: str
|
|
45
|
+
source_origin: str # internal relationship
|
|
46
|
+
target: str
|
|
47
|
+
target_origin: str
|
|
48
|
+
type: Literal['child', 'parent', 'entry', 'subset']
|