fastapi-voyager 0.15.5__py3-none-any.whl → 0.16.0a1__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 +2 -2
- fastapi_voyager/adapters/__init__.py +16 -0
- fastapi_voyager/adapters/base.py +44 -0
- fastapi_voyager/adapters/common.py +260 -0
- fastapi_voyager/adapters/django_ninja_adapter.py +299 -0
- fastapi_voyager/adapters/fastapi_adapter.py +165 -0
- fastapi_voyager/adapters/litestar_adapter.py +188 -0
- fastapi_voyager/er_diagram.py +15 -14
- fastapi_voyager/introspectors/__init__.py +34 -0
- fastapi_voyager/introspectors/base.py +81 -0
- fastapi_voyager/introspectors/detector.py +123 -0
- fastapi_voyager/introspectors/django_ninja.py +114 -0
- fastapi_voyager/introspectors/fastapi.py +83 -0
- fastapi_voyager/introspectors/litestar.py +166 -0
- fastapi_voyager/pydantic_resolve_util.py +4 -2
- fastapi_voyager/render.py +2 -2
- fastapi_voyager/render_style.py +0 -1
- fastapi_voyager/server.py +174 -295
- fastapi_voyager/type_helper.py +2 -2
- fastapi_voyager/version.py +1 -1
- fastapi_voyager/voyager.py +75 -47
- fastapi_voyager/web/graph-ui.js +102 -69
- fastapi_voyager/web/graphviz.svg.js +79 -30
- fastapi_voyager/web/index.html +11 -14
- fastapi_voyager/web/store.js +2 -0
- fastapi_voyager/web/vue-main.js +4 -0
- {fastapi_voyager-0.15.5.dist-info → fastapi_voyager-0.16.0a1.dist-info}/METADATA +133 -7
- {fastapi_voyager-0.15.5.dist-info → fastapi_voyager-0.16.0a1.dist-info}/RECORD +31 -19
- {fastapi_voyager-0.15.5.dist-info → fastapi_voyager-0.16.0a1.dist-info}/WHEEL +0 -0
- {fastapi_voyager-0.15.5.dist-info → fastapi_voyager-0.16.0a1.dist-info}/entry_points.txt +0 -0
- {fastapi_voyager-0.15.5.dist-info → fastapi_voyager-0.16.0a1.dist-info}/licenses/LICENSE +0 -0
fastapi_voyager/__init__.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""fastapi_voyager
|
|
2
2
|
|
|
3
|
-
Utilities to introspect
|
|
3
|
+
Utilities to introspect web applications and visualize their routing tree.
|
|
4
4
|
"""
|
|
5
5
|
from .server import create_voyager
|
|
6
6
|
from .version import __version__ # noqa: F401
|
|
7
7
|
|
|
8
|
-
__all__ = ["__version__", "create_voyager"]
|
|
8
|
+
__all__ = [ "__version__", "create_voyager" ]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Framework adapters for fastapi-voyager.
|
|
3
|
+
|
|
4
|
+
This module provides adapters that allow voyager to work with different web frameworks.
|
|
5
|
+
"""
|
|
6
|
+
from fastapi_voyager.adapters.base import VoyagerAdapter
|
|
7
|
+
from fastapi_voyager.adapters.django_ninja_adapter import DjangoNinjaAdapter
|
|
8
|
+
from fastapi_voyager.adapters.fastapi_adapter import FastAPIAdapter
|
|
9
|
+
from fastapi_voyager.adapters.litestar_adapter import LitestarAdapter
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"VoyagerAdapter",
|
|
13
|
+
"FastAPIAdapter",
|
|
14
|
+
"DjangoNinjaAdapter",
|
|
15
|
+
"LitestarAdapter",
|
|
16
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base adapter interface for framework-agnostic voyager server.
|
|
3
|
+
|
|
4
|
+
This module defines the abstract interface that all framework adapters must implement.
|
|
5
|
+
"""
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VoyagerAdapter(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Abstract base class for framework-specific voyager adapters.
|
|
13
|
+
|
|
14
|
+
Each adapter is responsible for:
|
|
15
|
+
1. Creating routes/endpoints for the voyager UI
|
|
16
|
+
2. Handling HTTP requests and responses in a framework-specific way
|
|
17
|
+
3. Returning an object that can be mounted/integrated with the target app
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def create_app(self) -> Any:
|
|
22
|
+
"""
|
|
23
|
+
Create and return a framework-specific application object.
|
|
24
|
+
|
|
25
|
+
The returned object should be mountable/integrable with the target framework.
|
|
26
|
+
For example:
|
|
27
|
+
- FastAPI: returns a FastAPI app
|
|
28
|
+
- Django Ninja: returns an ASGI application
|
|
29
|
+
- Litestar: returns a Litestar app
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A framework-specific application object
|
|
33
|
+
"""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def get_mount_path(self) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Get the recommended mount path for the voyager UI.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The path where voyager should be mounted (e.g., "/voyager")
|
|
43
|
+
"""
|
|
44
|
+
pass
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared business logic for voyager endpoints.
|
|
3
|
+
|
|
4
|
+
This module contains the core logic that is reused across all framework adapters.
|
|
5
|
+
"""
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic_resolve import ErDiagram
|
|
10
|
+
|
|
11
|
+
from fastapi_voyager.er_diagram import VoyagerErDiagram
|
|
12
|
+
from fastapi_voyager.render import Renderer
|
|
13
|
+
from fastapi_voyager.type import CoreData, SchemaNode, Tag
|
|
14
|
+
from fastapi_voyager.type_helper import get_source, get_vscode_link
|
|
15
|
+
from fastapi_voyager.version import __version__
|
|
16
|
+
from fastapi_voyager.voyager import Voyager
|
|
17
|
+
|
|
18
|
+
WEB_DIR = Path(__file__).parent.parent / "web"
|
|
19
|
+
WEB_DIR.mkdir(exist_ok=True)
|
|
20
|
+
|
|
21
|
+
STATIC_FILES_PATH = "/fastapi-voyager-static"
|
|
22
|
+
|
|
23
|
+
GA_PLACEHOLDER = "<!-- GA_SNIPPET -->"
|
|
24
|
+
VERSION_PLACEHOLDER = "<!-- VERSION_PLACEHOLDER -->"
|
|
25
|
+
STATIC_PATH_PLACEHOLDER = "<!-- STATIC_PATH -->"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_ga_snippet(ga_id: str | None) -> str:
|
|
29
|
+
"""Build Google Analytics snippet."""
|
|
30
|
+
if not ga_id:
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
return f""" <script async src="https://www.googletagmanager.com/gtag/js?id={ga_id}"></script>
|
|
34
|
+
<script>
|
|
35
|
+
window.dataLayer = window.dataLayer || [];
|
|
36
|
+
function gtag(){{dataLayer.push(arguments);}}
|
|
37
|
+
gtag('js', new Date());
|
|
38
|
+
|
|
39
|
+
gtag('config', '{ga_id}');
|
|
40
|
+
</script>
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class VoyagerContext:
|
|
45
|
+
"""
|
|
46
|
+
Context object that holds configuration and provides business logic methods.
|
|
47
|
+
|
|
48
|
+
This is shared across all framework adapters to avoid code duplication.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
target_app: Any,
|
|
54
|
+
module_color: dict[str, str] | None = None,
|
|
55
|
+
module_prefix: str | None = None,
|
|
56
|
+
swagger_url: str | None = None,
|
|
57
|
+
online_repo_url: str | None = None,
|
|
58
|
+
initial_page_policy: str = 'first',
|
|
59
|
+
ga_id: str | None = None,
|
|
60
|
+
er_diagram: ErDiagram | None = None,
|
|
61
|
+
enable_pydantic_resolve_meta: bool = False,
|
|
62
|
+
framework_name: str | None = None,
|
|
63
|
+
):
|
|
64
|
+
self.target_app = target_app
|
|
65
|
+
self.module_color = module_color or {}
|
|
66
|
+
self.module_prefix = module_prefix
|
|
67
|
+
self.swagger_url = swagger_url
|
|
68
|
+
self.online_repo_url = online_repo_url
|
|
69
|
+
self.initial_page_policy = initial_page_policy
|
|
70
|
+
self.ga_id = ga_id
|
|
71
|
+
self.er_diagram = er_diagram
|
|
72
|
+
self.enable_pydantic_resolve_meta = enable_pydantic_resolve_meta
|
|
73
|
+
self.framework_name = framework_name or "API"
|
|
74
|
+
|
|
75
|
+
def get_voyager(self, **kwargs) -> Voyager:
|
|
76
|
+
"""Create a Voyager instance with common configuration."""
|
|
77
|
+
config = {
|
|
78
|
+
"module_color": self.module_color,
|
|
79
|
+
"show_pydantic_resolve_meta": self.enable_pydantic_resolve_meta,
|
|
80
|
+
}
|
|
81
|
+
config.update(kwargs)
|
|
82
|
+
return Voyager(**config)
|
|
83
|
+
|
|
84
|
+
def analyze_and_get_dot(self) -> tuple[str, list[Tag], list[SchemaNode]]:
|
|
85
|
+
"""
|
|
86
|
+
Analyze the target app and return dot graph, tags, and schemas.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Tuple of (dot_graph, tags, schemas)
|
|
90
|
+
"""
|
|
91
|
+
voyager = self.get_voyager()
|
|
92
|
+
voyager.analysis(self.target_app)
|
|
93
|
+
dot = voyager.render_dot()
|
|
94
|
+
|
|
95
|
+
# include tags and their routes
|
|
96
|
+
tags = voyager.tags
|
|
97
|
+
for t in tags:
|
|
98
|
+
t.routes.sort(key=lambda r: r.name)
|
|
99
|
+
tags.sort(key=lambda t: t.name)
|
|
100
|
+
|
|
101
|
+
schemas = voyager.nodes[:]
|
|
102
|
+
schemas.sort(key=lambda s: s.name)
|
|
103
|
+
|
|
104
|
+
return dot, tags, schemas
|
|
105
|
+
|
|
106
|
+
def get_option_param(self) -> dict:
|
|
107
|
+
"""Get the option parameter for the voyager UI."""
|
|
108
|
+
dot, tags, schemas = self.analyze_and_get_dot()
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"tags": tags,
|
|
112
|
+
"schemas": schemas,
|
|
113
|
+
"dot": dot,
|
|
114
|
+
"enable_brief_mode": bool(self.module_prefix),
|
|
115
|
+
"version": __version__,
|
|
116
|
+
"swagger_url": self.swagger_url,
|
|
117
|
+
"initial_page_policy": self.initial_page_policy,
|
|
118
|
+
"has_er_diagram": self.er_diagram is not None,
|
|
119
|
+
"enable_pydantic_resolve_meta": self.enable_pydantic_resolve_meta,
|
|
120
|
+
"framework_name": self.framework_name,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def get_search_dot(self, payload: dict) -> list[Tag]:
|
|
124
|
+
"""Get filtered tags for search."""
|
|
125
|
+
voyager = self.get_voyager(
|
|
126
|
+
schema=payload.get("schema_name"),
|
|
127
|
+
schema_field=payload.get("schema_field"),
|
|
128
|
+
show_fields=payload.get("show_fields", "object"),
|
|
129
|
+
hide_primitive_route=payload.get("hide_primitive_route", False),
|
|
130
|
+
show_module=payload.get("show_module", True),
|
|
131
|
+
show_pydantic_resolve_meta=payload.get("show_pydantic_resolve_meta", False),
|
|
132
|
+
)
|
|
133
|
+
voyager.analysis(self.target_app)
|
|
134
|
+
tags = voyager.calculate_filtered_tag_and_route()
|
|
135
|
+
|
|
136
|
+
for t in tags:
|
|
137
|
+
t.routes.sort(key=lambda r: r.name)
|
|
138
|
+
tags.sort(key=lambda t: t.name)
|
|
139
|
+
|
|
140
|
+
return tags
|
|
141
|
+
|
|
142
|
+
def get_filtered_dot(self, payload: dict) -> str:
|
|
143
|
+
"""Get filtered dot graph."""
|
|
144
|
+
voyager = self.get_voyager(
|
|
145
|
+
include_tags=payload.get("tags"),
|
|
146
|
+
schema=payload.get("schema_name"),
|
|
147
|
+
schema_field=payload.get("schema_field"),
|
|
148
|
+
show_fields=payload.get("show_fields", "object"),
|
|
149
|
+
route_name=payload.get("route_name"),
|
|
150
|
+
hide_primitive_route=payload.get("hide_primitive_route", False),
|
|
151
|
+
show_module=payload.get("show_module", True),
|
|
152
|
+
show_pydantic_resolve_meta=payload.get("show_pydantic_resolve_meta", False),
|
|
153
|
+
)
|
|
154
|
+
voyager.analysis(self.target_app)
|
|
155
|
+
|
|
156
|
+
if payload.get("brief"):
|
|
157
|
+
if payload.get("tags"):
|
|
158
|
+
return voyager.render_tag_level_brief_dot(module_prefix=self.module_prefix)
|
|
159
|
+
else:
|
|
160
|
+
return voyager.render_overall_brief_dot(module_prefix=self.module_prefix)
|
|
161
|
+
else:
|
|
162
|
+
return voyager.render_dot()
|
|
163
|
+
|
|
164
|
+
def get_core_data(self, payload: dict) -> CoreData:
|
|
165
|
+
"""Get core data for the graph."""
|
|
166
|
+
voyager = self.get_voyager(
|
|
167
|
+
include_tags=payload.get("tags"),
|
|
168
|
+
schema=payload.get("schema_name"),
|
|
169
|
+
schema_field=payload.get("schema_field"),
|
|
170
|
+
show_fields=payload.get("show_fields", "object"),
|
|
171
|
+
route_name=payload.get("route_name"),
|
|
172
|
+
)
|
|
173
|
+
voyager.analysis(self.target_app)
|
|
174
|
+
return voyager.dump_core_data()
|
|
175
|
+
|
|
176
|
+
def render_dot_from_core_data(self, core_data: CoreData) -> str:
|
|
177
|
+
"""Render dot graph from core data."""
|
|
178
|
+
renderer = Renderer(
|
|
179
|
+
show_fields=core_data.show_fields,
|
|
180
|
+
module_color=core_data.module_color,
|
|
181
|
+
schema=core_data.schema,
|
|
182
|
+
)
|
|
183
|
+
return renderer.render_dot(
|
|
184
|
+
core_data.tags, core_data.routes, core_data.nodes, core_data.links
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def get_er_diagram_dot(self, payload: dict) -> str:
|
|
188
|
+
"""Get ER diagram dot graph."""
|
|
189
|
+
if self.er_diagram:
|
|
190
|
+
return VoyagerErDiagram(
|
|
191
|
+
self.er_diagram,
|
|
192
|
+
show_fields=payload.get("show_fields", "object"),
|
|
193
|
+
show_module=payload.get("show_module", True),
|
|
194
|
+
).render_dot()
|
|
195
|
+
return ""
|
|
196
|
+
|
|
197
|
+
def get_index_html(self) -> str:
|
|
198
|
+
"""Get the index HTML content."""
|
|
199
|
+
index_file = WEB_DIR / "index.html"
|
|
200
|
+
if index_file.exists():
|
|
201
|
+
content = index_file.read_text(encoding="utf-8")
|
|
202
|
+
content = content.replace(GA_PLACEHOLDER, build_ga_snippet(self.ga_id))
|
|
203
|
+
content = content.replace(VERSION_PLACEHOLDER, f"?v={__version__}")
|
|
204
|
+
# Replace static files path placeholder with actual path (without leading slash)
|
|
205
|
+
content = content.replace(STATIC_PATH_PLACEHOLDER, STATIC_FILES_PATH.lstrip("/"))
|
|
206
|
+
return content
|
|
207
|
+
# fallback simple page if index.html missing
|
|
208
|
+
return """
|
|
209
|
+
<!doctype html>
|
|
210
|
+
<html>
|
|
211
|
+
<head><meta charset="utf-8"><title>Graphviz Preview</title></head>
|
|
212
|
+
<body>
|
|
213
|
+
<p>index.html not found. Create one under src/fastapi_voyager/web/index.html</p>
|
|
214
|
+
</body>
|
|
215
|
+
</html>
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def get_source_code(self, schema_name: str) -> dict:
|
|
219
|
+
"""Get source code for a schema."""
|
|
220
|
+
try:
|
|
221
|
+
components = schema_name.split(".")
|
|
222
|
+
if len(components) < 2:
|
|
223
|
+
return {"error": "Invalid schema name format. Expected format: module.ClassName"}
|
|
224
|
+
|
|
225
|
+
module_name = ".".join(components[:-1])
|
|
226
|
+
class_name = components[-1]
|
|
227
|
+
|
|
228
|
+
mod = __import__(module_name, fromlist=[class_name])
|
|
229
|
+
obj = getattr(mod, class_name)
|
|
230
|
+
source_code = get_source(obj)
|
|
231
|
+
|
|
232
|
+
return {"source_code": source_code}
|
|
233
|
+
except ImportError as e:
|
|
234
|
+
return {"error": f"Module not found: {e}"}
|
|
235
|
+
except AttributeError as e:
|
|
236
|
+
return {"error": f"Class not found: {e}"}
|
|
237
|
+
except Exception as e:
|
|
238
|
+
return {"error": f"Internal error: {str(e)}"}
|
|
239
|
+
|
|
240
|
+
def get_vscode_link(self, schema_name: str) -> dict:
|
|
241
|
+
"""Get VSCode link for a schema."""
|
|
242
|
+
try:
|
|
243
|
+
components = schema_name.split(".")
|
|
244
|
+
if len(components) < 2:
|
|
245
|
+
return {"error": "Invalid schema name format. Expected format: module.ClassName"}
|
|
246
|
+
|
|
247
|
+
module_name = ".".join(components[:-1])
|
|
248
|
+
class_name = components[-1]
|
|
249
|
+
|
|
250
|
+
mod = __import__(module_name, fromlist=[class_name])
|
|
251
|
+
obj = getattr(mod, class_name)
|
|
252
|
+
link = get_vscode_link(obj, online_repo_url=self.online_repo_url)
|
|
253
|
+
|
|
254
|
+
return {"link": link}
|
|
255
|
+
except ImportError as e:
|
|
256
|
+
return {"error": f"Module not found: {e}"}
|
|
257
|
+
except AttributeError as e:
|
|
258
|
+
return {"error": f"Class not found: {e}"}
|
|
259
|
+
except Exception as e:
|
|
260
|
+
return {"error": f"Internal error: {str(e)}"}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django Ninja adapter for fastapi-voyager.
|
|
3
|
+
|
|
4
|
+
This module provides the Django Ninja-specific implementation of the voyager server.
|
|
5
|
+
It creates an ASGI application that can be integrated with Django.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import mimetypes
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi_voyager.adapters.base import VoyagerAdapter
|
|
12
|
+
from fastapi_voyager.adapters.common import STATIC_FILES_PATH, WEB_DIR, VoyagerContext
|
|
13
|
+
from fastapi_voyager.type import CoreData, SchemaNode, Tag
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DjangoNinjaAdapter(VoyagerAdapter):
|
|
17
|
+
"""
|
|
18
|
+
Django Ninja-specific implementation of VoyagerAdapter.
|
|
19
|
+
|
|
20
|
+
Creates an ASGI application with voyager endpoints that can be integrated with Django.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
target_app: Any,
|
|
26
|
+
module_color: dict[str, str] | None = None,
|
|
27
|
+
gzip_minimum_size: int | None = 500,
|
|
28
|
+
module_prefix: str | None = None,
|
|
29
|
+
swagger_url: str | None = None,
|
|
30
|
+
online_repo_url: str | None = None,
|
|
31
|
+
initial_page_policy: str = "first",
|
|
32
|
+
ga_id: str | None = None,
|
|
33
|
+
er_diagram: Any = None,
|
|
34
|
+
enable_pydantic_resolve_meta: bool = False,
|
|
35
|
+
):
|
|
36
|
+
self.ctx = VoyagerContext(
|
|
37
|
+
target_app=target_app,
|
|
38
|
+
module_color=module_color,
|
|
39
|
+
module_prefix=module_prefix,
|
|
40
|
+
swagger_url=swagger_url,
|
|
41
|
+
online_repo_url=online_repo_url,
|
|
42
|
+
initial_page_policy=initial_page_policy,
|
|
43
|
+
ga_id=ga_id,
|
|
44
|
+
er_diagram=er_diagram,
|
|
45
|
+
enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,
|
|
46
|
+
framework_name="Django Ninja",
|
|
47
|
+
)
|
|
48
|
+
# Note: gzip should be handled by Django's middleware, not here
|
|
49
|
+
|
|
50
|
+
async def _handle_request(self, scope, receive, send):
|
|
51
|
+
"""ASGI request handler."""
|
|
52
|
+
if scope["type"] != "http":
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
# Parse the request
|
|
56
|
+
method = scope["method"]
|
|
57
|
+
path = scope["path"]
|
|
58
|
+
# Remove /voyager prefix for internal routing
|
|
59
|
+
if path.startswith("/voyager"):
|
|
60
|
+
path = path[8:] # Remove '/voyager'
|
|
61
|
+
if path == "":
|
|
62
|
+
path = "/"
|
|
63
|
+
|
|
64
|
+
# Handle static files
|
|
65
|
+
if method == "GET" and path.startswith(f"{STATIC_FILES_PATH}/"):
|
|
66
|
+
await self._handle_static_file(path, send)
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
# Route the request
|
|
70
|
+
if method == "GET" and path == "/":
|
|
71
|
+
await self._handle_index(send)
|
|
72
|
+
elif method == "GET" and path == "/dot":
|
|
73
|
+
await self._handle_get_dot(send)
|
|
74
|
+
elif method == "POST" and path == "/er-diagram":
|
|
75
|
+
await self._handle_post_request(receive, send, self._handle_er_diagram)
|
|
76
|
+
elif method == "POST" and path == "/dot-search":
|
|
77
|
+
await self._handle_post_request(receive, send, self._handle_search_dot)
|
|
78
|
+
elif method == "POST" and path == "/dot":
|
|
79
|
+
await self._handle_post_request(receive, send, self._handle_filtered_dot)
|
|
80
|
+
elif method == "POST" and path == "/dot-core-data":
|
|
81
|
+
await self._handle_post_request(receive, send, self._handle_core_data)
|
|
82
|
+
elif method == "POST" and path == "/dot-render-core-data":
|
|
83
|
+
await self._handle_post_request(receive, send, self._handle_render_core_data)
|
|
84
|
+
elif method == "POST" and path == "/source":
|
|
85
|
+
await self._handle_post_request(receive, send, self._handle_source)
|
|
86
|
+
elif method == "POST" and path == "/vscode-link":
|
|
87
|
+
await self._handle_post_request(receive, send, self._handle_vscode_link)
|
|
88
|
+
else:
|
|
89
|
+
await self._send_404(send)
|
|
90
|
+
|
|
91
|
+
async def _handle_post_request(self, receive, send, handler):
|
|
92
|
+
"""Helper to handle POST requests with JSON body."""
|
|
93
|
+
body = b""
|
|
94
|
+
more_body = True
|
|
95
|
+
|
|
96
|
+
while more_body:
|
|
97
|
+
message = await receive()
|
|
98
|
+
if message["type"] == "http.request":
|
|
99
|
+
body += message.get("body", b"")
|
|
100
|
+
more_body = message.get("more_body", False)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
payload = json.loads(body.decode())
|
|
104
|
+
await handler(payload, send)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
await self._send_json({"error": str(e)}, send, status_code=400)
|
|
107
|
+
|
|
108
|
+
async def _handle_static_file(self, path: str, send):
|
|
109
|
+
"""Handle GET {STATIC_FILES_PATH}/* - serve static files."""
|
|
110
|
+
# Remove /fastapi-voyager-static/ prefix
|
|
111
|
+
prefix = f"{STATIC_FILES_PATH}/"
|
|
112
|
+
file_path = path[len(prefix):]
|
|
113
|
+
full_path = WEB_DIR / file_path
|
|
114
|
+
|
|
115
|
+
# Security check: ensure the path is within WEB_DIR
|
|
116
|
+
try:
|
|
117
|
+
full_path = full_path.resolve()
|
|
118
|
+
web_dir_resolved = WEB_DIR.resolve()
|
|
119
|
+
if not str(full_path).startswith(str(web_dir_resolved)):
|
|
120
|
+
await self._send_404(send)
|
|
121
|
+
return
|
|
122
|
+
except Exception:
|
|
123
|
+
await self._send_404(send)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if not full_path.exists() or not full_path.is_file():
|
|
127
|
+
await self._send_404(send)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Read file content
|
|
131
|
+
try:
|
|
132
|
+
with open(full_path, "rb") as f:
|
|
133
|
+
content = f.read()
|
|
134
|
+
|
|
135
|
+
# Determine content type
|
|
136
|
+
content_type, _ = mimetypes.guess_type(str(full_path))
|
|
137
|
+
if content_type is None:
|
|
138
|
+
content_type = "application/octet-stream"
|
|
139
|
+
|
|
140
|
+
await self._send_response(content_type, content, send)
|
|
141
|
+
except Exception:
|
|
142
|
+
await self._send_404(send)
|
|
143
|
+
|
|
144
|
+
async def _handle_index(self, send):
|
|
145
|
+
"""Handle GET / - return the index HTML."""
|
|
146
|
+
html = self.ctx.get_index_html()
|
|
147
|
+
await self._send_html(html, send)
|
|
148
|
+
|
|
149
|
+
async def _handle_get_dot(self, send):
|
|
150
|
+
"""Handle GET /dot - return options and initial dot graph."""
|
|
151
|
+
data = self.ctx.get_option_param()
|
|
152
|
+
# Convert tags and schemas to dicts for JSON serialization
|
|
153
|
+
response_data = {
|
|
154
|
+
"tags": [self._tag_to_dict(t) for t in data["tags"]],
|
|
155
|
+
"schemas": [self._schema_to_dict(s) for s in data["schemas"]],
|
|
156
|
+
"dot": data["dot"],
|
|
157
|
+
"enable_brief_mode": data["enable_brief_mode"],
|
|
158
|
+
"version": data["version"],
|
|
159
|
+
"initial_page_policy": data["initial_page_policy"],
|
|
160
|
+
"swagger_url": data["swagger_url"],
|
|
161
|
+
"has_er_diagram": data["has_er_diagram"],
|
|
162
|
+
"enable_pydantic_resolve_meta": data["enable_pydantic_resolve_meta"],
|
|
163
|
+
"framework_name": data["framework_name"],
|
|
164
|
+
}
|
|
165
|
+
await self._send_json(response_data, send)
|
|
166
|
+
|
|
167
|
+
async def _handle_er_diagram(self, payload, send):
|
|
168
|
+
"""Handle POST /er-diagram."""
|
|
169
|
+
dot = self.ctx.get_er_diagram_dot(payload)
|
|
170
|
+
await self._send_text(dot, send)
|
|
171
|
+
|
|
172
|
+
async def _handle_search_dot(self, payload, send):
|
|
173
|
+
"""Handle POST /dot-search."""
|
|
174
|
+
tags = self.ctx.get_search_dot(payload)
|
|
175
|
+
response_data = {"tags": [self._tag_to_dict(t) for t in tags]}
|
|
176
|
+
await self._send_json(response_data, send)
|
|
177
|
+
|
|
178
|
+
async def _handle_filtered_dot(self, payload, send):
|
|
179
|
+
"""Handle POST /dot."""
|
|
180
|
+
dot = self.ctx.get_filtered_dot(payload)
|
|
181
|
+
await self._send_text(dot, send)
|
|
182
|
+
|
|
183
|
+
async def _handle_core_data(self, payload, send):
|
|
184
|
+
"""Handle POST /dot-core-data."""
|
|
185
|
+
core_data = self.ctx.get_core_data(payload)
|
|
186
|
+
await self._send_json(core_data.model_dump(), send)
|
|
187
|
+
|
|
188
|
+
async def _handle_render_core_data(self, payload, send):
|
|
189
|
+
"""Handle POST /dot-render-core-data."""
|
|
190
|
+
core_data = CoreData(**payload)
|
|
191
|
+
dot = self.ctx.render_dot_from_core_data(core_data)
|
|
192
|
+
await self._send_text(dot, send)
|
|
193
|
+
|
|
194
|
+
async def _handle_source(self, payload, send):
|
|
195
|
+
"""Handle POST /source."""
|
|
196
|
+
result = self.ctx.get_source_code(payload.get("schema_name", ""))
|
|
197
|
+
status_code = 200 if "error" not in result else 400
|
|
198
|
+
if "error" in result and "not found" in result["error"]:
|
|
199
|
+
status_code = 404
|
|
200
|
+
await self._send_json(result, send, status_code=status_code)
|
|
201
|
+
|
|
202
|
+
async def _handle_vscode_link(self, payload, send):
|
|
203
|
+
"""Handle POST /vscode-link."""
|
|
204
|
+
result = self.ctx.get_vscode_link(payload.get("schema_name", ""))
|
|
205
|
+
status_code = 200 if "error" not in result else 400
|
|
206
|
+
if "error" in result and "not found" in result["error"]:
|
|
207
|
+
status_code = 404
|
|
208
|
+
await self._send_json(result, send, status_code=status_code)
|
|
209
|
+
|
|
210
|
+
async def _send_html(self, html: str, send):
|
|
211
|
+
"""Send HTML response."""
|
|
212
|
+
await self._send_response(
|
|
213
|
+
"text/html; charset=utf-8",
|
|
214
|
+
html.encode("utf-8"),
|
|
215
|
+
send,
|
|
216
|
+
status_code=200,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def _send_json(self, data: dict, send, status_code: int = 200):
|
|
220
|
+
"""Send JSON response."""
|
|
221
|
+
body = json.dumps(data).encode("utf-8")
|
|
222
|
+
await self._send_response("application/json", body, send, status_code=status_code)
|
|
223
|
+
|
|
224
|
+
async def _send_text(self, text: str, send):
|
|
225
|
+
"""Send plain text response."""
|
|
226
|
+
await self._send_response("text/plain; charset=utf-8", text.encode("utf-8"), send)
|
|
227
|
+
|
|
228
|
+
async def _send_404(self, send):
|
|
229
|
+
"""Send 404 response."""
|
|
230
|
+
await self._send_response("text/plain", b"Not Found", send, status_code=404)
|
|
231
|
+
|
|
232
|
+
async def _send_response(
|
|
233
|
+
self, content_type: str, body: bytes, send, status_code: int = 200
|
|
234
|
+
):
|
|
235
|
+
"""Send ASGI response."""
|
|
236
|
+
await send(
|
|
237
|
+
{
|
|
238
|
+
"type": "http.response.start",
|
|
239
|
+
"status": status_code,
|
|
240
|
+
"headers": [
|
|
241
|
+
[b"content-type", content_type.encode()],
|
|
242
|
+
[b"content-length", str(len(body)).encode()],
|
|
243
|
+
],
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
await send({"type": "http.response.body", "body": body})
|
|
247
|
+
|
|
248
|
+
def _tag_to_dict(self, tag: Tag) -> dict:
|
|
249
|
+
"""Convert Tag object to dict."""
|
|
250
|
+
return {
|
|
251
|
+
"id": tag.id,
|
|
252
|
+
"name": tag.name,
|
|
253
|
+
"routes": [
|
|
254
|
+
{
|
|
255
|
+
"id": r.id,
|
|
256
|
+
"name": r.name,
|
|
257
|
+
"module": r.module,
|
|
258
|
+
"unique_id": r.unique_id,
|
|
259
|
+
"response_schema": r.response_schema,
|
|
260
|
+
"is_primitive": r.is_primitive,
|
|
261
|
+
}
|
|
262
|
+
for r in tag.routes
|
|
263
|
+
],
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
def _schema_to_dict(self, schema: SchemaNode) -> dict:
|
|
267
|
+
"""Convert SchemaNode to dict."""
|
|
268
|
+
return {
|
|
269
|
+
"id": schema.id,
|
|
270
|
+
"module": schema.module,
|
|
271
|
+
"name": schema.name,
|
|
272
|
+
"fields": [
|
|
273
|
+
{
|
|
274
|
+
"name": f.name,
|
|
275
|
+
"type_name": f.type_name,
|
|
276
|
+
"is_object": f.is_object,
|
|
277
|
+
"is_exclude": f.is_exclude,
|
|
278
|
+
}
|
|
279
|
+
for f in schema.fields
|
|
280
|
+
],
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
def create_app(self):
|
|
284
|
+
"""Create and return an ASGI application."""
|
|
285
|
+
|
|
286
|
+
async def asgi_app(scope, receive, send):
|
|
287
|
+
# Route /voyager/* to voyager handler
|
|
288
|
+
if scope["type"] == "http" and scope["path"].startswith("/voyager"):
|
|
289
|
+
await self._handle_request(scope, receive, send)
|
|
290
|
+
else:
|
|
291
|
+
# Return 404 for non-voyager paths
|
|
292
|
+
# (Django should handle these before they reach here)
|
|
293
|
+
await self._send_404(send)
|
|
294
|
+
|
|
295
|
+
return asgi_app
|
|
296
|
+
|
|
297
|
+
def get_mount_path(self) -> str:
|
|
298
|
+
"""Get the recommended mount path for voyager."""
|
|
299
|
+
return "/voyager"
|