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.
Files changed (31) hide show
  1. fastapi_voyager/__init__.py +2 -2
  2. fastapi_voyager/adapters/__init__.py +16 -0
  3. fastapi_voyager/adapters/base.py +44 -0
  4. fastapi_voyager/adapters/common.py +260 -0
  5. fastapi_voyager/adapters/django_ninja_adapter.py +299 -0
  6. fastapi_voyager/adapters/fastapi_adapter.py +165 -0
  7. fastapi_voyager/adapters/litestar_adapter.py +188 -0
  8. fastapi_voyager/er_diagram.py +15 -14
  9. fastapi_voyager/introspectors/__init__.py +34 -0
  10. fastapi_voyager/introspectors/base.py +81 -0
  11. fastapi_voyager/introspectors/detector.py +123 -0
  12. fastapi_voyager/introspectors/django_ninja.py +114 -0
  13. fastapi_voyager/introspectors/fastapi.py +83 -0
  14. fastapi_voyager/introspectors/litestar.py +166 -0
  15. fastapi_voyager/pydantic_resolve_util.py +4 -2
  16. fastapi_voyager/render.py +2 -2
  17. fastapi_voyager/render_style.py +0 -1
  18. fastapi_voyager/server.py +174 -295
  19. fastapi_voyager/type_helper.py +2 -2
  20. fastapi_voyager/version.py +1 -1
  21. fastapi_voyager/voyager.py +75 -47
  22. fastapi_voyager/web/graph-ui.js +102 -69
  23. fastapi_voyager/web/graphviz.svg.js +79 -30
  24. fastapi_voyager/web/index.html +11 -14
  25. fastapi_voyager/web/store.js +2 -0
  26. fastapi_voyager/web/vue-main.js +4 -0
  27. {fastapi_voyager-0.15.5.dist-info → fastapi_voyager-0.16.0a1.dist-info}/METADATA +133 -7
  28. {fastapi_voyager-0.15.5.dist-info → fastapi_voyager-0.16.0a1.dist-info}/RECORD +31 -19
  29. {fastapi_voyager-0.15.5.dist-info → fastapi_voyager-0.16.0a1.dist-info}/WHEEL +0 -0
  30. {fastapi_voyager-0.15.5.dist-info → fastapi_voyager-0.16.0a1.dist-info}/entry_points.txt +0 -0
  31. {fastapi_voyager-0.15.5.dist-info → fastapi_voyager-0.16.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,8 @@
1
1
  """fastapi_voyager
2
2
 
3
- Utilities to introspect a FastAPI application and visualize its routing tree.
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"