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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI adapter for fastapi-voyager.
|
|
3
|
+
|
|
4
|
+
This module provides the FastAPI-specific implementation of the voyager server.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, FastAPI
|
|
9
|
+
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
|
10
|
+
from fastapi.staticfiles import StaticFiles
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
13
|
+
|
|
14
|
+
from fastapi_voyager.adapters.base import VoyagerAdapter
|
|
15
|
+
from fastapi_voyager.adapters.common import STATIC_FILES_PATH, VoyagerContext
|
|
16
|
+
from fastapi_voyager.type import CoreData, SchemaNode, Tag
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OptionParam(BaseModel):
|
|
20
|
+
tags: list[Tag]
|
|
21
|
+
schemas: list[SchemaNode]
|
|
22
|
+
dot: str
|
|
23
|
+
enable_brief_mode: bool
|
|
24
|
+
version: str
|
|
25
|
+
initial_page_policy: Literal["first", "full", "empty"]
|
|
26
|
+
swagger_url: str | None = None
|
|
27
|
+
has_er_diagram: bool = False
|
|
28
|
+
enable_pydantic_resolve_meta: bool = False
|
|
29
|
+
framework_name: str = "API"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Payload(BaseModel):
|
|
33
|
+
tags: list[str] | None = None
|
|
34
|
+
schema_name: str | None = None
|
|
35
|
+
schema_field: str | None = None
|
|
36
|
+
route_name: str | None = None
|
|
37
|
+
show_fields: str = "object"
|
|
38
|
+
brief: bool = False
|
|
39
|
+
hide_primitive_route: bool = False
|
|
40
|
+
show_module: bool = True
|
|
41
|
+
show_pydantic_resolve_meta: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SearchResultOptionParam(BaseModel):
|
|
45
|
+
tags: list[Tag]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SchemaSearchPayload(BaseModel):
|
|
49
|
+
schema_name: str | None = None
|
|
50
|
+
schema_field: str | None = None
|
|
51
|
+
show_fields: str = "object"
|
|
52
|
+
brief: bool = False
|
|
53
|
+
hide_primitive_route: bool = False
|
|
54
|
+
show_module: bool = True
|
|
55
|
+
show_pydantic_resolve_meta: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ErDiagramPayload(BaseModel):
|
|
59
|
+
show_fields: str = "object"
|
|
60
|
+
show_module: bool = True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SourcePayload(BaseModel):
|
|
64
|
+
schema_name: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FastAPIAdapter(VoyagerAdapter):
|
|
68
|
+
"""
|
|
69
|
+
FastAPI-specific implementation of VoyagerAdapter.
|
|
70
|
+
|
|
71
|
+
Creates a FastAPI application with voyager endpoints.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
target_app: Any,
|
|
77
|
+
module_color: dict[str, str] | None = None,
|
|
78
|
+
gzip_minimum_size: int | None = 500,
|
|
79
|
+
module_prefix: str | None = None,
|
|
80
|
+
swagger_url: str | None = None,
|
|
81
|
+
online_repo_url: str | None = None,
|
|
82
|
+
initial_page_policy: str = "first",
|
|
83
|
+
ga_id: str | None = None,
|
|
84
|
+
er_diagram: Any = None,
|
|
85
|
+
enable_pydantic_resolve_meta: bool = False,
|
|
86
|
+
):
|
|
87
|
+
self.ctx = VoyagerContext(
|
|
88
|
+
target_app=target_app,
|
|
89
|
+
module_color=module_color,
|
|
90
|
+
module_prefix=module_prefix,
|
|
91
|
+
swagger_url=swagger_url,
|
|
92
|
+
online_repo_url=online_repo_url,
|
|
93
|
+
initial_page_policy=initial_page_policy,
|
|
94
|
+
ga_id=ga_id,
|
|
95
|
+
er_diagram=er_diagram,
|
|
96
|
+
enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,
|
|
97
|
+
framework_name="FastAPI",
|
|
98
|
+
)
|
|
99
|
+
self.gzip_minimum_size = gzip_minimum_size
|
|
100
|
+
|
|
101
|
+
def create_app(self) -> FastAPI:
|
|
102
|
+
"""Create and return a FastAPI application with voyager endpoints."""
|
|
103
|
+
router = APIRouter(tags=["fastapi-voyager"])
|
|
104
|
+
|
|
105
|
+
@router.post("/er-diagram", response_class=PlainTextResponse)
|
|
106
|
+
def get_er_diagram(payload: ErDiagramPayload) -> str:
|
|
107
|
+
return self.ctx.get_er_diagram_dot(payload.model_dump())
|
|
108
|
+
|
|
109
|
+
@router.get("/dot", response_model=OptionParam)
|
|
110
|
+
def get_dot() -> OptionParam:
|
|
111
|
+
data = self.ctx.get_option_param()
|
|
112
|
+
return OptionParam(**data)
|
|
113
|
+
|
|
114
|
+
@router.post("/dot-search", response_model=SearchResultOptionParam)
|
|
115
|
+
def get_search_dot(payload: SchemaSearchPayload) -> SearchResultOptionParam:
|
|
116
|
+
tags = self.ctx.get_search_dot(payload.model_dump())
|
|
117
|
+
return SearchResultOptionParam(tags=tags)
|
|
118
|
+
|
|
119
|
+
@router.post("/dot", response_class=PlainTextResponse)
|
|
120
|
+
def get_filtered_dot(payload: Payload) -> str:
|
|
121
|
+
return self.ctx.get_filtered_dot(payload.model_dump())
|
|
122
|
+
|
|
123
|
+
@router.post("/dot-core-data", response_model=CoreData)
|
|
124
|
+
def get_filtered_dot_core_data(payload: Payload) -> CoreData:
|
|
125
|
+
return self.ctx.get_core_data(payload.model_dump())
|
|
126
|
+
|
|
127
|
+
@router.post("/dot-render-core-data", response_class=PlainTextResponse)
|
|
128
|
+
def render_dot_from_core_data(core_data: CoreData) -> str:
|
|
129
|
+
return self.ctx.render_dot_from_core_data(core_data)
|
|
130
|
+
|
|
131
|
+
@router.get("/", response_class=HTMLResponse)
|
|
132
|
+
def index() -> str:
|
|
133
|
+
return self.ctx.get_index_html()
|
|
134
|
+
|
|
135
|
+
@router.post("/source")
|
|
136
|
+
def get_object_by_module_name(payload: SourcePayload) -> JSONResponse:
|
|
137
|
+
result = self.ctx.get_source_code(payload.schema_name)
|
|
138
|
+
status_code = 200 if "error" not in result else 400
|
|
139
|
+
if "error" in result and "not found" in result["error"]:
|
|
140
|
+
status_code = 404
|
|
141
|
+
return JSONResponse(content=result, status_code=status_code)
|
|
142
|
+
|
|
143
|
+
@router.post("/vscode-link")
|
|
144
|
+
def get_vscode_link_by_module_name(payload: SourcePayload) -> JSONResponse:
|
|
145
|
+
result = self.ctx.get_vscode_link(payload.schema_name)
|
|
146
|
+
status_code = 200 if "error" not in result else 400
|
|
147
|
+
if "error" in result and "not found" in result["error"]:
|
|
148
|
+
status_code = 404
|
|
149
|
+
return JSONResponse(content=result, status_code=status_code)
|
|
150
|
+
|
|
151
|
+
app = FastAPI(title="fastapi-voyager demo server")
|
|
152
|
+
|
|
153
|
+
if self.gzip_minimum_size is not None and self.gzip_minimum_size >= 0:
|
|
154
|
+
app.add_middleware(GZipMiddleware, minimum_size=self.gzip_minimum_size)
|
|
155
|
+
|
|
156
|
+
from fastapi_voyager.adapters.common import WEB_DIR
|
|
157
|
+
|
|
158
|
+
app.mount(STATIC_FILES_PATH, StaticFiles(directory=str(WEB_DIR)), name="static")
|
|
159
|
+
app.include_router(router)
|
|
160
|
+
|
|
161
|
+
return app
|
|
162
|
+
|
|
163
|
+
def get_mount_path(self) -> str:
|
|
164
|
+
"""Get the recommended mount path for voyager."""
|
|
165
|
+
return "/voyager"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Litestar adapter for fastapi-voyager.
|
|
3
|
+
|
|
4
|
+
This module provides the Litestar-specific implementation of the voyager server.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from litestar import Litestar, MediaType, Request, Response, get, post
|
|
9
|
+
from litestar.static_files import create_static_files_router
|
|
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 LitestarAdapter(VoyagerAdapter):
|
|
17
|
+
"""
|
|
18
|
+
Litestar-specific implementation of VoyagerAdapter.
|
|
19
|
+
|
|
20
|
+
Creates a Litestar application with voyager endpoints.
|
|
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="Litestar",
|
|
47
|
+
)
|
|
48
|
+
self.gzip_minimum_size = gzip_minimum_size
|
|
49
|
+
|
|
50
|
+
def create_app(self) -> Litestar:
|
|
51
|
+
"""Create and return a Litestar application with voyager endpoints."""
|
|
52
|
+
|
|
53
|
+
@get("/er-diagram")
|
|
54
|
+
async def get_er_diagram(request: Request) -> str:
|
|
55
|
+
payload = await request.json()
|
|
56
|
+
return self.ctx.get_er_diagram_dot(payload)
|
|
57
|
+
|
|
58
|
+
@get("/dot")
|
|
59
|
+
async def get_dot(request: Request) -> dict:
|
|
60
|
+
data = self.ctx.get_option_param()
|
|
61
|
+
# Convert tags and schemas to dicts for JSON serialization
|
|
62
|
+
return {
|
|
63
|
+
"tags": [self._tag_to_dict(t) for t in data["tags"]],
|
|
64
|
+
"schemas": [self._schema_to_dict(s) for s in data["schemas"]],
|
|
65
|
+
"dot": data["dot"],
|
|
66
|
+
"enable_brief_mode": data["enable_brief_mode"],
|
|
67
|
+
"version": data["version"],
|
|
68
|
+
"initial_page_policy": data["initial_page_policy"],
|
|
69
|
+
"swagger_url": data["swagger_url"],
|
|
70
|
+
"has_er_diagram": data["has_er_diagram"],
|
|
71
|
+
"enable_pydantic_resolve_meta": data["enable_pydantic_resolve_meta"],
|
|
72
|
+
"framework_name": data["framework_name"],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@post("/dot-search")
|
|
76
|
+
async def get_search_dot(request: Request) -> dict:
|
|
77
|
+
payload = await request.json()
|
|
78
|
+
tags = self.ctx.get_search_dot(payload)
|
|
79
|
+
return {"tags": [self._tag_to_dict(t) for t in tags]}
|
|
80
|
+
|
|
81
|
+
@post("/dot")
|
|
82
|
+
async def get_filtered_dot(request: Request) -> str:
|
|
83
|
+
payload = await request.json()
|
|
84
|
+
return self.ctx.get_filtered_dot(payload)
|
|
85
|
+
|
|
86
|
+
@post("/dot-core-data")
|
|
87
|
+
async def get_filtered_dot_core_data(request: Request) -> CoreData:
|
|
88
|
+
payload = await request.json()
|
|
89
|
+
return self.ctx.get_core_data(payload)
|
|
90
|
+
|
|
91
|
+
@post("/dot-render-core-data")
|
|
92
|
+
async def render_dot_from_core_data(request: Request) -> str:
|
|
93
|
+
payload = await request.json()
|
|
94
|
+
core_data = CoreData(**payload)
|
|
95
|
+
return self.ctx.render_dot_from_core_data(core_data)
|
|
96
|
+
|
|
97
|
+
@get("/", media_type=MediaType.HTML)
|
|
98
|
+
async def index() -> str:
|
|
99
|
+
return self.ctx.get_index_html()
|
|
100
|
+
|
|
101
|
+
@post("/source")
|
|
102
|
+
async def get_object_by_module_name(request: Request) -> dict:
|
|
103
|
+
payload = await request.json()
|
|
104
|
+
result = self.ctx.get_source_code(payload.get("schema_name", ""))
|
|
105
|
+
status_code = 200 if "error" not in result else 400
|
|
106
|
+
if "error" in result and "not found" in result["error"]:
|
|
107
|
+
status_code = 404
|
|
108
|
+
return Response(
|
|
109
|
+
content=result,
|
|
110
|
+
status_code=status_code,
|
|
111
|
+
media_type=MediaType.JSON,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@post("/vscode-link")
|
|
115
|
+
async def get_vscode_link_by_module_name(request: Request) -> dict:
|
|
116
|
+
payload = await request.json()
|
|
117
|
+
result = self.ctx.get_vscode_link(payload.get("schema_name", ""))
|
|
118
|
+
status_code = 200 if "error" not in result else 400
|
|
119
|
+
if "error" in result and "not found" in result["error"]:
|
|
120
|
+
status_code = 404
|
|
121
|
+
return Response(
|
|
122
|
+
content=result,
|
|
123
|
+
status_code=status_code,
|
|
124
|
+
media_type=MediaType.JSON,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Create static files router using the new API (replaces deprecated StaticFilesConfig)
|
|
128
|
+
static_files_router = create_static_files_router(
|
|
129
|
+
path=STATIC_FILES_PATH,
|
|
130
|
+
directories=[str(WEB_DIR)],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Create Litestar app
|
|
134
|
+
app = Litestar(
|
|
135
|
+
route_handlers=[
|
|
136
|
+
get_er_diagram,
|
|
137
|
+
get_dot,
|
|
138
|
+
get_search_dot,
|
|
139
|
+
get_filtered_dot,
|
|
140
|
+
get_filtered_dot_core_data,
|
|
141
|
+
render_dot_from_core_data,
|
|
142
|
+
index,
|
|
143
|
+
get_object_by_module_name,
|
|
144
|
+
get_vscode_link_by_module_name,
|
|
145
|
+
static_files_router,
|
|
146
|
+
],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return app
|
|
150
|
+
|
|
151
|
+
def _tag_to_dict(self, tag: Tag) -> dict:
|
|
152
|
+
"""Convert Tag object to dict."""
|
|
153
|
+
return {
|
|
154
|
+
"id": tag.id,
|
|
155
|
+
"name": tag.name,
|
|
156
|
+
"routes": [
|
|
157
|
+
{
|
|
158
|
+
"id": r.id,
|
|
159
|
+
"name": r.name,
|
|
160
|
+
"module": r.module,
|
|
161
|
+
"unique_id": r.unique_id,
|
|
162
|
+
"response_schema": r.response_schema,
|
|
163
|
+
"is_primitive": r.is_primitive,
|
|
164
|
+
}
|
|
165
|
+
for r in tag.routes
|
|
166
|
+
],
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
def _schema_to_dict(self, schema: SchemaNode) -> dict:
|
|
170
|
+
"""Convert SchemaNode to dict."""
|
|
171
|
+
return {
|
|
172
|
+
"id": schema.id,
|
|
173
|
+
"module": schema.module,
|
|
174
|
+
"name": schema.name,
|
|
175
|
+
"fields": [
|
|
176
|
+
{
|
|
177
|
+
"name": f.name,
|
|
178
|
+
"type_name": f.type_name,
|
|
179
|
+
"is_object": f.is_object,
|
|
180
|
+
"is_exclude": f.is_exclude,
|
|
181
|
+
}
|
|
182
|
+
for f in schema.fields
|
|
183
|
+
],
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def get_mount_path(self) -> str:
|
|
187
|
+
"""Get the recommended mount path for voyager."""
|
|
188
|
+
return "/voyager"
|
fastapi_voyager/er_diagram.py
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from pydantic_resolve import Entity, ErDiagram, MultipleRelationship, Relationship
|
|
7
|
+
|
|
8
|
+
from fastapi_voyager.render import Renderer
|
|
9
|
+
from fastapi_voyager.render_style import RenderConfig
|
|
9
10
|
from fastapi_voyager.type import (
|
|
10
|
-
FieldInfo,
|
|
11
11
|
PK,
|
|
12
|
+
FieldInfo,
|
|
12
13
|
FieldType,
|
|
13
|
-
LinkType,
|
|
14
14
|
Link,
|
|
15
|
-
|
|
15
|
+
LinkType,
|
|
16
16
|
SchemaNode,
|
|
17
17
|
)
|
|
18
|
-
from fastapi_voyager.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
from fastapi_voyager.type_helper import (
|
|
19
|
+
full_class_name,
|
|
20
|
+
get_core_types,
|
|
21
|
+
get_type_name,
|
|
22
|
+
update_forward_refs,
|
|
23
|
+
)
|
|
23
24
|
|
|
24
25
|
logger = getLogger(__name__)
|
|
25
26
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Introspectors for different web frameworks.
|
|
3
|
+
|
|
4
|
+
This package contains built-in introspector implementations for various frameworks.
|
|
5
|
+
"""
|
|
6
|
+
from .base import AppIntrospector, RouteInfo
|
|
7
|
+
from .detector import FrameworkType, detect_framework, get_introspector
|
|
8
|
+
|
|
9
|
+
# Try to import each introspector, but don't fail if the framework isn't installed
|
|
10
|
+
try:
|
|
11
|
+
from .fastapi import FastAPIIntrospector
|
|
12
|
+
except ImportError:
|
|
13
|
+
FastAPIIntrospector = None # type: ignore
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from .django_ninja import DjangoNinjaIntrospector
|
|
17
|
+
except ImportError:
|
|
18
|
+
DjangoNinjaIntrospector = None # type: ignore
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from .litestar import LitestarIntrospector
|
|
22
|
+
except ImportError:
|
|
23
|
+
LitestarIntrospector = None # type: ignore
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AppIntrospector",
|
|
27
|
+
"RouteInfo",
|
|
28
|
+
"FastAPIIntrospector",
|
|
29
|
+
"DjangoNinjaIntrospector",
|
|
30
|
+
"LitestarIntrospector",
|
|
31
|
+
"FrameworkType",
|
|
32
|
+
"detect_framework",
|
|
33
|
+
"get_introspector",
|
|
34
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Introspection abstraction layer for framework-agnostic route analysis.
|
|
3
|
+
|
|
4
|
+
This module provides the abstraction that allows fastapi-voyager to work with
|
|
5
|
+
different web frameworks that support OpenAPI and Pydantic, such as:
|
|
6
|
+
- FastAPI
|
|
7
|
+
- Django Ninja
|
|
8
|
+
- Litestar
|
|
9
|
+
- Flask-OpenAPI
|
|
10
|
+
"""
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from collections.abc import Callable, Iterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RouteInfo:
|
|
19
|
+
"""
|
|
20
|
+
Standardized route information that works across different frameworks.
|
|
21
|
+
|
|
22
|
+
This data class encapsulates the essential information needed by voyager
|
|
23
|
+
to analyze and visualize routes, independent of the underlying framework.
|
|
24
|
+
"""
|
|
25
|
+
# Unique identifier for the route (function path)
|
|
26
|
+
id: str
|
|
27
|
+
|
|
28
|
+
# Human-readable name (function name)
|
|
29
|
+
name: str
|
|
30
|
+
|
|
31
|
+
# Module where the route handler is defined
|
|
32
|
+
module: str
|
|
33
|
+
|
|
34
|
+
# Operation ID from OpenAPI spec
|
|
35
|
+
operation_id: str | None
|
|
36
|
+
|
|
37
|
+
# List of tags associated with this route
|
|
38
|
+
tags: list[str]
|
|
39
|
+
|
|
40
|
+
# The route handler function/endpoint
|
|
41
|
+
endpoint: Callable
|
|
42
|
+
|
|
43
|
+
# Response model (should be a Pydantic BaseModel)
|
|
44
|
+
response_model: type[Any]
|
|
45
|
+
|
|
46
|
+
# Any additional framework-specific data
|
|
47
|
+
extra: dict[str, Any] | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AppIntrospector(ABC):
|
|
51
|
+
"""
|
|
52
|
+
Abstract base class for app introspection.
|
|
53
|
+
|
|
54
|
+
Implement this class to add support for different web frameworks.
|
|
55
|
+
The introspector is responsible for extracting route information
|
|
56
|
+
from the framework's internal structure.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def get_routes(self) -> Iterator[RouteInfo]:
|
|
61
|
+
"""
|
|
62
|
+
Iterate over all available routes in the application.
|
|
63
|
+
|
|
64
|
+
Yields:
|
|
65
|
+
RouteInfo: Standardized route information
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
>>> for route in introspector.get_routes():
|
|
69
|
+
... print(f"{route.id}: {route.tags}")
|
|
70
|
+
"""
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def get_swagger_url(self) -> str | None:
|
|
75
|
+
"""
|
|
76
|
+
Get the URL to the Swagger/OpenAPI documentation.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The URL path or None if not available
|
|
80
|
+
"""
|
|
81
|
+
pass
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Framework detection utility for fastapi-voyager.
|
|
3
|
+
|
|
4
|
+
This module provides a centralized framework detection mechanism that is used
|
|
5
|
+
by both introspectors and adapters to avoid code duplication.
|
|
6
|
+
"""
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi_voyager.introspectors.base import AppIntrospector
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FrameworkType(Enum):
|
|
14
|
+
"""Supported framework types."""
|
|
15
|
+
FASTAPI = "fastapi"
|
|
16
|
+
DJANGO_NINJA = "django_ninja"
|
|
17
|
+
LITESTAR = "litestar"
|
|
18
|
+
UNKNOWN = "unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def detect_framework(app: Any) -> FrameworkType:
|
|
22
|
+
"""
|
|
23
|
+
Detect the framework type of the given application.
|
|
24
|
+
|
|
25
|
+
This function uses the same detection logic as the introspector system,
|
|
26
|
+
ensuring consistency across the codebase.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
app: A web application instance
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
FrameworkType: The detected framework type
|
|
33
|
+
|
|
34
|
+
Note:
|
|
35
|
+
The detection order matters: Litestar is checked before Django Ninja
|
|
36
|
+
to avoid Django import issues.
|
|
37
|
+
"""
|
|
38
|
+
# If it's already an introspector, try to determine framework from it
|
|
39
|
+
if isinstance(app, AppIntrospector):
|
|
40
|
+
app_class_name = type(app).__name__
|
|
41
|
+
if "FastAPI" in app_class_name:
|
|
42
|
+
return FrameworkType.FASTAPI
|
|
43
|
+
elif "DjangoNinja" in app_class_name or "Ninja" in app_class_name:
|
|
44
|
+
return FrameworkType.DJANGO_NINJA
|
|
45
|
+
elif "Litestar" in app_class_name:
|
|
46
|
+
return FrameworkType.LITESTAR
|
|
47
|
+
return FrameworkType.UNKNOWN
|
|
48
|
+
|
|
49
|
+
# Get the class name for type checking
|
|
50
|
+
app_class_name = type(app).__name__
|
|
51
|
+
|
|
52
|
+
# Try FastAPI
|
|
53
|
+
try:
|
|
54
|
+
from fastapi import FastAPI
|
|
55
|
+
if isinstance(app, FastAPI):
|
|
56
|
+
return FrameworkType.FASTAPI
|
|
57
|
+
except ImportError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
# Try Litestar (check before Django Ninja to avoid Django import issues)
|
|
61
|
+
try:
|
|
62
|
+
from litestar import Litestar
|
|
63
|
+
if isinstance(app, Litestar):
|
|
64
|
+
return FrameworkType.LITESTAR
|
|
65
|
+
except ImportError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# Try Django Ninja (check by class name first to avoid import if not needed)
|
|
69
|
+
try:
|
|
70
|
+
if app_class_name == "NinjaAPI":
|
|
71
|
+
from ninja import NinjaAPI
|
|
72
|
+
if isinstance(app, NinjaAPI):
|
|
73
|
+
return FrameworkType.DJANGO_NINJA
|
|
74
|
+
except ImportError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
return FrameworkType.UNKNOWN
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_introspector(app: Any) -> AppIntrospector | None:
|
|
81
|
+
"""
|
|
82
|
+
Get the appropriate introspector for the given app.
|
|
83
|
+
|
|
84
|
+
This is a centralized function that uses the framework detection logic
|
|
85
|
+
to return the correct introspector instance.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
app: A web application instance or AppIntrospector
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
An AppIntrospector instance, or None if framework not supported
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
TypeError: If the app type is not supported
|
|
95
|
+
"""
|
|
96
|
+
# If it's already an introspector, return it
|
|
97
|
+
if isinstance(app, AppIntrospector):
|
|
98
|
+
return app
|
|
99
|
+
|
|
100
|
+
framework = detect_framework(app)
|
|
101
|
+
|
|
102
|
+
if framework == FrameworkType.FASTAPI:
|
|
103
|
+
from fastapi_voyager.introspectors import FastAPIIntrospector
|
|
104
|
+
if FastAPIIntrospector:
|
|
105
|
+
return FastAPIIntrospector(app)
|
|
106
|
+
|
|
107
|
+
elif framework == FrameworkType.LITESTAR:
|
|
108
|
+
from fastapi_voyager.introspectors import LitestarIntrospector
|
|
109
|
+
if LitestarIntrospector:
|
|
110
|
+
return LitestarIntrospector(app)
|
|
111
|
+
|
|
112
|
+
elif framework == FrameworkType.DJANGO_NINJA:
|
|
113
|
+
from fastapi_voyager.introspectors import DjangoNinjaIntrospector
|
|
114
|
+
if DjangoNinjaIntrospector:
|
|
115
|
+
return DjangoNinjaIntrospector(app)
|
|
116
|
+
|
|
117
|
+
# If we get here, the app type is not supported
|
|
118
|
+
raise TypeError(
|
|
119
|
+
f"Unsupported app type: {type(app).__name__}. "
|
|
120
|
+
f"Supported types: FastAPI, Django Ninja API, Litestar, or any AppIntrospector implementation. "
|
|
121
|
+
f"If you're using a different framework, please implement AppIntrospector for that framework. "
|
|
122
|
+
f"See ADAPTER_EXAMPLE.md for instructions."
|
|
123
|
+
)
|