Repository: allmonday/fastapi-voyager Branch: main Commit: 7db1a94cfae5 Files: 136 Total size: 513.5 KB Directory structure: gitextract_xdnctyxh/ ├── .githooks/ │ ├── README.md │ └── pre-commit ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .python-version ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs/ │ ├── changelog.md │ ├── claude/ │ │ └── 0_REFACTORING_RENDER_NOTES.md │ └── idea.md ├── pyproject.toml ├── release.md ├── setup-django-ninja.sh ├── setup-fastapi.sh ├── setup-hooks.sh ├── setup-litestar.sh ├── src/ │ └── fastapi_voyager/ │ ├── __init__.py │ ├── adapters/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── common.py │ │ ├── django_ninja_adapter.py │ │ ├── fastapi_adapter.py │ │ └── litestar_adapter.py │ ├── cli.py │ ├── er_diagram.py │ ├── filter.py │ ├── introspectors/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── detector.py │ │ ├── django_ninja.py │ │ ├── fastapi.py │ │ └── litestar.py │ ├── module.py │ ├── pydantic_resolve_util.py │ ├── render.py │ ├── render_style.py │ ├── server.py │ ├── templates/ │ │ ├── dot/ │ │ │ ├── cluster.j2 │ │ │ ├── cluster_container.j2 │ │ │ ├── digraph.j2 │ │ │ ├── er_diagram.j2 │ │ │ ├── link.j2 │ │ │ ├── route_node.j2 │ │ │ ├── schema_node.j2 │ │ │ └── tag_node.j2 │ │ └── html/ │ │ ├── colored_text.j2 │ │ ├── pydantic_meta.j2 │ │ ├── schema_field_row.j2 │ │ ├── schema_header.j2 │ │ └── schema_table.j2 │ ├── type.py │ ├── type_helper.py │ ├── version.py │ ├── voyager.py │ └── web/ │ ├── component/ │ │ ├── demo.js │ │ ├── loader-code-display.js │ │ ├── render-graph.js │ │ ├── route-code-display.js │ │ └── schema-code-display.js │ ├── graph-ui.js │ ├── graphviz.svg.css │ ├── graphviz.svg.js │ ├── icon/ │ │ └── site.webmanifest │ ├── index.html │ ├── magnifying-glass.js │ ├── package.json │ ├── src/ │ │ ├── App.vue │ │ ├── component/ │ │ │ ├── LoaderCodeDisplay.vue │ │ │ ├── RenderGraph.vue │ │ │ ├── RouteCodeDisplay.vue │ │ │ └── SchemaCodeDisplay.vue │ │ ├── graph-ui.js │ │ ├── magnifying-glass.js │ │ ├── main.js │ │ └── store.js │ ├── store.js │ ├── sw.js │ └── vite.config.js └── tests/ ├── README.md ├── __init__.py ├── django_ninja/ │ ├── __init__.py │ ├── demo.py │ ├── embedding.py │ ├── settings.py │ └── urls.py ├── embedding_test_utils.py ├── fastapi/ │ ├── __init__.py │ ├── demo.py │ ├── demo_anno.py │ └── embedding.py ├── litestar/ │ ├── __init__.py │ ├── demo.py │ └── embedding.py ├── service/ │ ├── __init__.py │ └── schema/ │ ├── __init__.py │ ├── base_entity.py │ ├── db.py │ ├── dto/ │ │ ├── __init__.py │ │ ├── attribute.py │ │ ├── inventory.py │ │ ├── marketing.py │ │ ├── order.py │ │ ├── product.py │ │ ├── shipment.py │ │ ├── store.py │ │ ├── tag.py │ │ └── user.py │ ├── extra.py │ ├── orm/ │ │ ├── __init__.py │ │ ├── attribute.py │ │ ├── inventory.py │ │ ├── marketing.py │ │ ├── order.py │ │ ├── product.py │ │ ├── shipment.py │ │ ├── store.py │ │ ├── tables.py │ │ └── user.py │ └── schema.py ├── test_adapter_interface.py ├── test_analysis.py ├── test_embedding_django_ninja.py ├── test_embedding_fastapi.py ├── test_embedding_litestar.py ├── test_filter.py ├── test_generic.py ├── test_import.py ├── test_module.py ├── test_resolve_util_impl.py └── test_type_helper.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .githooks/README.md ================================================ # Git Hooks Setup This repository uses Git hooks to automatically format code with Prettier before each commit. ## One-time Setup After cloning the repository, run this command to enable the hooks: ```bash git config core.hooksPath .githooks ``` That's it! The hooks will now run automatically before each commit. ## What it does The `pre-commit` hook will: - Automatically run `npx prettier --write .` before each commit - Format all supported files (JS, CSS, HTML, JSON, Markdown) - Stage the formatted files automatically - Continue with the commit ## Skip the hook (if needed) If you need to skip the formatting for a particular commit: ```bash git commit --no-verify -m "your message" ``` ## Troubleshooting ### Hook not running? Check if the hooks path is set correctly: ```bash git config core.hooksPath # Should output: .githooks ``` ### npx not found? Make sure Node.js and npm are installed: ```bash node --version npm --version ``` ================================================ FILE: .githooks/pre-commit ================================================ #!/bin/sh # Git pre-commit hook to run Prettier on staged files # Get the project root directory using Git command (works in all shells) PROJECT_ROOT="$(git rev-parse --show-toplevel)" cd "$PROJECT_ROOT" || exit 1 # Check if npx is available if ! command -v npx >/dev/null 2>&1; then echo "Warning: npx not found. Skipping Prettier formatting." echo "Please install Node.js and npm to use pre-commit formatting." exit 0 fi echo "Running Prettier on staged files..." # Check if .prettierignore exists and run prettier if [ -f "$PROJECT_ROOT/.prettierignore" ]; then echo "Found .prettierignore, applying rules..." npx prettier --write . --log-level=warn --ignore-path="$PROJECT_ROOT/.prettierignore" else echo "No .prettierignore found, formatting all files..." npx prettier --write . --log-level=warn fi # Add any newly formatted files to the staging area git add . echo "Prettier formatting complete." ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish to PyPI via uv on: workflow_dispatch: push: tags: - "v*" jobs: publish: runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 # 需要完整的 git 历史来获取 tag 信息 - name: Set up uv uses: astral-sh/setup-uv@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.13" - name: Extract version from tag id: version run: | # 从 tag 中提取版本号(去掉 v 前缀) VERSION=${GITHUB_REF#refs/tags/v} echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Version: $VERSION" - name: Extract release notes from CHANGELOG id: extract_notes run: | VERSION=${{ steps.version.outputs.version }} # 从 CHANGELOG.md 提取该版本的说明 # 匹配 "## VERSION" 或 "## VERSION," 但不匹配 "## VERSION.X" awk -v ver="$VERSION" ' /^## / { if ($0 ~ "^## " ver "([, \t]|$)") { flag=1 next } else if ($0 ~ /^## [0-9]/) { flag=0 } } flag {print} ' docs/changelog.md > release_notes.md # 如果 CHANGELOG 中没有找到,使用 git tag 消息 if [ ! -s release_notes.md ]; then echo "No CHANGELOG entry found, using tag message..." git tag -l --format='%(contents)' ${{ github.ref_name }} > release_notes.md || echo "Release $VERSION" > release_notes.md fi # 显示提取的内容(用于调试) echo "Release notes content:" head -20 release_notes.md - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Build frontend run: cd src/fastapi_voyager/web && npm install && npm run build - name: Build the package run: uv build - name: Publish to PyPI run: uv publish --token ${{ secrets.PYPI_PUBLISHER }} - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: body_path: release_notes.md draft: false prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }} files: | dist/*.tar.gz dist/*.whl - name: Cleanup if: always() run: rm -f release_notes.md ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py.cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # UV # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. #uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock #poetry.toml # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. # https://pdm-project.org/en/latest/usage/project/#working-with-version-control #pdm.lock #pdm.toml .pdm-python .pdm-build/ # pixi # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. #pixi.lock # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one # in the .venv directory. It is recommended not to include this directory in version control. .pixi # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .envrc .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ # Abstra # Abstra is an AI-powered process automation framework. # Ignore directories containing user credentials, local state, and settings. # Learn more at https://abstra.io/docs .abstra/ # Visual Studio Code # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ # Ruff stuff: .ruff_cache/ # PyPI configuration file .pypirc # Cursor # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data # refer to https://docs.cursor.com/context/ignore-files .cursorignore .cursorindexingignore # Marimo marimo/_static/ marimo/_lsp/ __marimo__/ *.dot node_modules/ src/fastapi_voyager/web/node_modules/ ================================================ FILE: .prettierignore ================================================ # Dependencies node_modules/ .venv/ __pycache__/ *.pyc # Build outputs dist/ build/ *.egg-info/ # Static assets *.min.js *.min.css # Generated files package-lock.json yarn.lock pnpm-lock.yaml # Cache .ruff_cache/ .pytest_cache/ .vscode/ # Git .git/ .github/ # Misc *.md .env .env.* ================================================ FILE: .prettierrc ================================================ { "semi": false, "singleQuote": false, "tabWidth": 2, "useTabs": false, "trailingComma": "es5", "printWidth": 100, "arrowParens": "always", "endOfLine": "lf", "htmlWhitespaceSensitivity": "css" } ================================================ FILE: .python-version ================================================ 3.12 ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md - fastapi-voyager ## 项目概述 FastAPI Voyager 是一个 Python 包,提供 API 路由树和依赖关系的可视化。前端使用 Vue 3 + Naive UI,通过 Vite 构建。 ## 前端构建 前端源码位于 `src/fastapi_voyager/web/`,构建产物为 `src/fastapi_voyager/web/dist/`。 ```bash # 安装依赖(首次或 package.json 变更后) . "$HOME/.nvm/nvm.sh" && nvm use 20 npm --prefix src/fastapi_voyager/web install # 构建(修改前端代码后执行) npm --prefix src/fastapi_voyager/web run build ``` 构建产物 `dist/` 已在 `.gitignore` 中,通过 `pyproject.toml` 的 `force-include` 在 CI 打包时包含。 ## 开发模式 ```bash # 终端 1:启动 Python 后端(任选一个 demo app) uv run uvicorn demo_app:app --port 8000 # 或 . .venv/bin/activate && uvicorn demo_app:app --port 8000 # 终端 2(可选):Vite dev server,支持 HMR cd src/fastapi_voyager/web && npm run dev # 浏览器打开 http://localhost:5173,API 请求自动代理到 localhost:8000 ``` 不启动 Vite dev server 时,直接访问 http://localhost:8000/voyager/ 即可使用构建后的版本。 ## 关键文件 | 路径 | 说明 | |------|------| | `src/fastapi_voyager/web/src/App.vue` | 主组件(Naive UI) | | `src/fastapi_voyager/web/src/store.js` | 前端状态管理 | | `src/fastapi_voyager/web/src/main.js` | Vue 入口 | | `src/fastapi_voyager/web/src/component/*.vue` | 子组件 | | `src/fastapi_voyager/web/src/graph-ui.js` | D3 Graphviz 渲染 | | `src/fastapi_voyager/web/src/magnifying-glass.js` | 放大镜功能 | | `src/fastapi_voyager/web/index.html` | Vite 入口模板(含 Python 占位符) | | `src/fastapi_voyager/web/vite.config.js` | Vite 配置 | | `src/fastapi_voyager/adapters/common.py` | Python 端读取 dist/index.html 并替换占位符 | | `pyproject.toml` | 含 force-include 配置 | | `.github/workflows/publish.yml` | CI 含 Node.js 构建步骤 | ## Python 占位符 `dist/index.html` 中的占位符由 Python 在 serve 时替换: - `` → 静态文件路径 - `` → 版本号 - `` → 框架主题色 - `` → Google Analytics 代码 ================================================ FILE: CONTRIBUTING.md ================================================ # How to develop & contribute? fork, clone. install uv. ```shell uv venv source .venv/bin/activate uv pip install ".[dev]" uvicorn tests.programatic:app --reload ``` open `localhost:8000/voyager` frontend: - `src/web/vue-main.js`: main js backend: - `voyager.py`: main entry - `render.py`: generate dot file - `server.py`: serve mode ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 tangkikodo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ [![pypi](https://img.shields.io/pypi/v/fastapi-voyager.svg)](https://pypi.python.org/pypi/fastapi-voyager) ![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-voyager) [![PyPI Downloads](https://static.pepy.tech/badge/fastapi-voyager/month)](https://pepy.tech/projects/fastapi-voyager) # FastAPI Voyager Visualize your API endpoints and explore them interactively. Its vision is to make code easier to read and understand, serving as an ideal documentation tool. **Now supports multiple frameworks:** FastAPI, Django Ninja, and Litestar. > This repo is still in early stage, it supports Pydantic v2 only. > **Breaking Change**: Since v0.19, `fastapi-voyager` depends on `pydantic-resolve>=4.0`. If you use `pydantic-resolve` v3, please pin `fastapi-voyager<=0.18`. - **Live Demo**: https://www.newsyeah.fun/voyager/ - **Example Source**: [composition-oriented-development-pattern](https://github.com/allmonday/composition-oriented-development-pattern) fastapi-voyager overview ## Table of Contents - [Quick Start](#quick-start) - [Installation](#installation) - [Supported Frameworks](#supported-frameworks) - [Features](#features) - [Command Line Usage](#command-line-usage) - [About pydantic-resolve](#about-pydantic-resolve) - [Development](#development) - [Dependencies](#dependencies) - [Credits](#credits) ## Quick Start With simple configuration, fastapi-voyager can be embedded into your web application: ```python from fastapi import FastAPI from fastapi_voyager import create_voyager app = FastAPI() # ... define your routes ... app.mount('/voyager', create_voyager( app, module_color={'src.services': 'tomato'}, module_prefix='src.services', swagger_url="/docs", ga_id="G-XXXXXXXXVL", initial_page_policy='first', online_repo_url='https://github.com/your-org/your-repo/blob/master', enable_pydantic_resolve_meta=True)) ``` Visit `http://localhost:8000/voyager` to explore your API visually. For framework-specific examples (Django Ninja, Litestar), see [Supported Frameworks](#supported-frameworks). [View full example](https://github.com/allmonday/composition-oriented-development-pattern/blob/master/src/main.py#L48) ## Installation ### Install via pip ```bash pip install fastapi-voyager ``` ### Install via uv ```bash uv add fastapi-voyager ``` ### Run with CLI ```bash voyager -m path.to.your.app.module --server ``` For sub-application scenarios (e.g., `app.mount("/api", api)`), specify the app name: ```bash voyager -m path.to.your.app.module --server --app api ``` > **Note**: [Sub-Application mounts](https://fastapi.tiangolo.com/advanced/sub-applications/) are not supported yet, but you can specify the name of the FastAPI application with `--app`. Only a single application (default: `app`) can be selected. ## Supported Frameworks fastapi-voyager automatically detects your framework and provides the appropriate integration. Currently supported frameworks: ### FastAPI ```python from fastapi import FastAPI from fastapi_voyager import create_voyager app = FastAPI() @app.get("/hello") def hello(): return {"message": "Hello World"} # Mount voyager app.mount("/voyager", create_voyager(app)) ``` Start with: ```bash uvicorn your_app:app --reload # Visit http://localhost:8000/voyager ``` ### Django Ninja ```python import os import django from django.core.asgi import get_asgi_application from ninja import NinjaAPI from fastapi_voyager import create_voyager # Configure Django os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myapp.settings") django.setup() # Create Django Ninja API api = NinjaAPI() @api.get("/hello") def hello(request): return {"message": "Hello World"} # Create voyager ASGI app voyager_app = create_voyager(api) # Create ASGI application that routes between Django and voyager async def application(scope, receive, send): if scope["type"] == "http" and scope["path"].startswith("/voyager"): await voyager_app(scope, receive, send) else: django_app = get_asgi_application() await django_app(scope, receive, send) ``` Start with: ```bash uvicorn your_app:application --reload # Visit http://localhost:8000/voyager ``` ### Litestar Litestar doesn't support mounting to an existing app like FastAPI. The recommended pattern is to export `ROUTE_HANDLERS` from your main app: ```python # In your main app file (e.g., app.py) from litestar import Litestar, Controller class MyController(Controller): # ... your routes ... ROUTE_HANDLERS = [MyController] # Export for extension app = Litestar(route_handlers=ROUTE_HANDLERS) ``` Then create voyager by reusing `ROUTE_HANDLERS`: ```python # In your voyager embedding file from typing import Any, Awaitable, Callable from litestar import Litestar, asgi from fastapi_voyager import create_voyager from your_app import ROUTE_HANDLERS, app as your_app voyager_app = create_voyager(your_app) @asgi("/voyager", is_mount=True, copy_scope=True) async def voyager_mount( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]] ) -> None: await voyager_app(scope, receive, send) app = Litestar(route_handlers=ROUTE_HANDLERS + [voyager_mount]) ``` Start with: ```bash uvicorn your_app:app --reload # Visit http://localhost:8000/voyager ``` ## Features fastapi-voyager is designed for scenarios using web frameworks with Pydantic models (FastAPI, Django Ninja, Litestar). It helps visualize dependencies and serves as an architecture tool to identify implementation issues such as wrong relationships, overfetching, and more. **Best Practice**: When building view models following the ER model pattern, fastapi-voyager can fully realize its potential - quickly identifying which APIs use specific entities and vice versa. ### Highlight Nodes and Links Click a node to highlight its upstream and downstream nodes. Figure out the related models of one page, or how many pages are related with one model. highlight nodes and dependencies ### View Source Code Double-click a node or route to show source code or open the file in VSCode. view source code ### Quick Search Search schemas by name and display their upstream and downstream dependencies. Use `Shift + Click` on any node to quickly search for it. quick search functionality ### Display ER Diagram ER diagram is a feature from pydantic-resolve which provides a solid expression for business descriptions. You can visualize application-level entity relationship diagrams. ```python from pydantic_resolve import ErDiagram, Entity, Relationship diagram = ErDiagram( entities=[ Entity( kls=Team, relationships=[ Relationship(fk='id', name='sprints', target=list[Sprint], loader=sprint_loader.team_to_sprint_loader), Relationship(fk='id', name='users', target=list[User], loader=user_loader.team_to_user_loader) ] ), Entity( kls=Sprint, relationships=[ Relationship(fk='id', name='stories', target=list[Story], loader=story_loader.sprint_to_story_loader) ] ), Entity( kls=Story, relationships=[ Relationship(fk='id', name='tasks', target=list[Task], loader=task_loader.story_to_task_loader), Relationship(fk='owner_id', name='owner', target=User, loader=user_loader.user_batch_loader) ] ), Entity( kls=Task, relationships=[ Relationship(fk='owner_id', name='owner', target=User, loader=user_loader.user_batch_loader) ] ) ] ) # Display in voyager app.mount('/voyager', create_voyager(app, er_diagram=diagram)) ``` ER diagram visualization ### Show Pydantic Resolve Meta Info Set `enable_pydantic_resolve_meta=True` in `create_voyager`, then toggle the "pydantic resolve meta" button to visualize resolve/post/expose/collect operations. pydantic resolve meta information ## Command Line Usage ### Start Server ```bash # FastAPI voyager -m tests.demo --server --web fastapi # Django Ninja voyager -m tests.demo --server --web django-ninja # Litestar voyager -m tests.demo --server --web litestar # Custom port voyager -m tests.demo --server --port=8002 # Specify app name voyager -m tests.demo --server --app my_app ``` > **Note**: Server mode does not support ER diagram or pydantic-resolve metadata configuration. Use `create_voyager()` in your code with `er_diagram` and `enable_pydantic_resolve_meta` parameters to enable these features. ### Generate DOT File ```bash # Generate .dot file voyager -m tests.demo # Specify app voyager -m tests.demo --app my_app # Filter by schema voyager -m tests.demo --schema Task # Show all fields voyager -m tests.demo --show_fields all # Custom module colors voyager -m tests.demo --module_color=tests.demo:red --module_color=tests.service:tomato # Output to file voyager -m tests.demo -o my_visualization.dot # Version and help voyager --version voyager --help ``` ## About pydantic-resolve pydantic-resolve is a lightweight tool designed to build complex, nested data in a simple, declarative way. It provides `resolve_*` for loading associated data and `post_*` for computing derived fields, with automatic batch loading to eliminate N+1 queries. When relationship definitions start repeating across multiple models, use ER Diagram with `base_entity()` and `__relationships__` to centralize relationship declarations. `DefineSubset` helps safely pick fields from entity classes while preserving ER diagram references. Developers can use fastapi-voyager without needing to know anything about pydantic-resolve, but I still highly recommend everyone to give it a try. ## Development ### Setup Development Environment ```bash # Fork and clone the repository git clone https://github.com/your-username/fastapi-voyager.git cd fastapi-voyager # Install uv curl -LsSf https://astral.sh/uv/install.sh | sh # Create virtual environment and install dependencies uv venv source .venv/bin/activate uv pip install ".[dev]" # Run development server uvicorn tests.programatic:app --reload ``` ### Test Different Frameworks You can test the framework-specific examples: ```bash # FastAPI example uvicorn tests.fastapi.embedding:app --reload # Django Ninja example uvicorn tests.django_ninja.embedding:app --reload # Litestar example uvicorn tests.litestar.embedding:asgi_app --reload ``` Visit `http://localhost:8000/voyager` to see changes. ### Setup Git Hooks (Optional) Enable automatic code formatting before commits: ```bash ./setup-hooks.sh # or manually: git config core.hooksPath .githooks ``` This will run Prettier automatically before each commit. See [`.githooks/README.md`](./.githooks/README.md) for details. ### Project Structure **Frontend:** - `src/fastapi_voyager/web/vue-main.js` - Main JavaScript entry **Backend:** - `voyager.py` - Main entry point - `render.py` - Generate DOT files - `server.py` - Server mode ## Roadmap - [Ideas](./docs/idea.md) - [Changelog & Roadmap](./docs/changelog.md) ## Dependencies - [pydantic-resolve](https://github.com/allmonday/pydantic-resolve) - [Quasar Framework](https://quasar.dev/) ### Dev dependencies - [FastAPI](https://fastapi.tiangolo.com/) - [Django Ninja](https://django-ninja.rest-framework.com/) - [Litestar](https://litestar.dev/) ## Credits - [graphql-voyager](https://apis.guru/graphql-voyager/) - Thanks for inspiration - [vscode-interactive-graphviz](https://github.com/tintinweb/vscode-interactive-graphviz) - Thanks for web visualization ## License MIT License ================================================ FILE: docs/changelog.md ================================================ # Changelog & plan ## <0.9: - [x] group schemas by module hierarchy - [x] module-based coloring via Analytics(module_color={...}) - [x] view in web browser - [x] config params - [x] make a explorer dashboard, provide list of routes, schemas, to make it easy to switch and search - [x] support programmatic usage - [x] better schema /router node appearance - [x] hide fields duplicated with parent's (show `parent fields` instead) - [x] refactor the frontend to vue, and tweak the build process - [x] find dependency based on picked schema and it's field. - [x] optimize static resource (cdn -> local) - [x] add configuration for highlight (optional) - [x] alt+click to show field details - [x] display source code of routes (including response_model) - [x] handle excluded field - [x] add tooltips - [x] route - [x] group routes by module hierarchy - [x] add response_model in route - [x] fixed left bar show tag/ route - [x] export voyager core data into json (for better debugging) - [x] add api to rebuild core data from json, and render it - [x] fix Generic case `test_generic.py` - [x] show tips for routes not return pydantic type. - [x] fix duplicated link from class and parent class, it also break clicking highlight - [x] refactor: abstract render module ## 0.9 - [x] refactor: server.py - [x] rename create_app_with_fastapi -> create_voyager - [x] add doc for parameters - [x] improve initialization time cost - [x] query route / schema info through realtime api - [x] adjust fe - 0.9.3 - [x] adjust layout - [x] show field detail in right panel - [x] show route info in bottom - 0.9.4 - [x] close schema sidebar when switch tag/route - [x] schema detail panel show fields by default - [x] adjust schema panel's height - [x] show from base information in subset case - 0.9.5 - [x] route list should have a max height ## 0.10 - 0.10.1 - [x] refactor voyager.py tag -> route structure - [x] fix missing route (tag has only one route which return primitive value) - [x] make right panel resizable by dragging - [x] allow closing tag expansion item - [x] hide brief mode if not configured - [x] add focus button to only show related nodes under current route/tag graph in dialog - 0.10.2 - [x] fix graph height - [x] show version in title - 0.10.3 - [x] fix focus in brief-mode - [x] ui: adjust focus position - [x] refactor naming - [x] fix layout issue when rendering huge graph - 0.10.4 - [x] fix: when focus is on, should ensure changes from other params not broken. - 0.10.5 - [x] double click to show details, and highlight as tomato ## 0.11 - 0.11.1 - [x] support opening route in swagger - [x] config docs path - [x] provide option to hide routes in brief mode (auto hide in full graph mode) - 0.11.2 - [x] enable/disable module cluster (to save space) - 0.11.3 - [x] support online repo url - 0.11.4 - [x] add loading for field detail panel - 0.11.5 - [x] optimize open in swagger link - [x] change jquery cdn - 0.11.6 - [x] flag of loading full graph in first render or not - [x] optimize loading static resource - 0.11.7 - [x] fix swagger link - 0.11.8 - [x] fix swagger link in another way - 0.11.9 - [x] replace issubclass with safe_issubclass to prevent exception. - 0.11.10 - [x] fix bug during updating forward refs - 0.11.11 - [x] replace print with logging and add `--log-level` in cli, by default info - [x] fill node title color with module color - [x] optimize cluster render logic ## 0.12 - 0.12.1 - [x] sort tag / route names in left panel - [x] display schema name on top of detail panel - [x] optimize dbclick style - [x] persist the tag/ route in url - 0.12.2 - [x] add google analytics - 0.12.3 - [x] fix bug in `update_forward_refs`, class should not be skipped if it's parent class has been visited. - 0.12.4 - [x] fix logger exception - 0.12.5 - [x] fix nested cluster with same color - [x] refactor fe with store based on reactive - [x] fix duplicated focus toggle - 0.12.6 - [x] fix overlapped edges - [x] click link(edge) to highlight related nodes - [x] on hover cursor effect - 0.12.7 - [x] remove search component, integrated into main page - 0.12.8 - [x] optimize ui elements, change icons, update reset behavior - 0.12.9 - [x] fix: handle logging exception for forward ref info, preventing crash - 0.12.10 - [x] fix: double trigger on reset search - 0.12.11 - [x] better ui for schema select - [x] fix: pick tag and then pick route directly from another tag will render nothing - [x] feat: cancel search schema triggered by shift click will redirect back to previous tag, route selection - [x] optimize the node style - 0.12.12 - [x] disable `show module cluster` by default ## 0.13 - 0.13.0 - [x] if er diagram is provided, show it first. - 0.13.1 - [x] show more details in er diagram - 0.13.2 - [x] show dashed line for link without dataloader - 0.13.3 - [x] show field description ## 0.14, integration with pydantic-resolve - 0.14.0 - [x] show hint for resolve (>), post fields (<), post default handler (* at title) - [x] show expose and collect info - 0.14.1 - [x] minor ui enhancement ## 0.15, internal refactor - 0.15.0 - [x] refactor render.py - 0.15.1 - [x] add prettier (npx prettier --write .) and pre-commit hooks - [x] add localstorage for toggle items - [x] refactor er diagram renderer - [x] fix error in search function - 0.15.2 - [x] fix resetSearch issue: fail to go back previous tag/router after reset. - [x] left panel can be toggled. - 0.15.3 - [x] refactor vue-main.js, move methods to store - [x] optimize search flow - 0.15.4 - [x] static files cache buster - [x] store voyager/erd toggle value in url query string - [x] set highlight style - 0.15.5 - [x] fix loadInitial bug - 0.15.6 - [x] internal refactor: graph-ui.js - [x] enhance the selected and unselected node & edges ## 0.16 - 0.16.0alpha-1 - [x] support django ninja and litestar - 0.16.0alpha-2 - [x] fix import error - 0.16.0alpha-3 - [x] fix voyager cli, add web parameter - 0.16.1 - [x] improve litestar support ## 0.17, enhance er diagram - 0.17.0 - [x] 1.different theme color for frameworks - fastapi, keep current - django-ninja, #4cae4f - litestar, rgb(237, 182, 65) - [x] 2.highight entity classes - enable if er diagram is enabled - entities in er diagram should be labeled as "Entity" after the title, and title should be bold - [x] 3.click esc to cancel search - 0.17.1 - [x] add magnification slider to adjust magnifying glass zoom level (2x-5x) - [x] refactor magnifying glass module - fix magnification offset issue when value changes - optimize performance with content caching (reduce 90%+ DOM operations) - add parameter validation and error handling - extract constants and eliminate code redundancy - add configurable debug logging - [x] change double-click highlight color to orange (#FF8C00) - [x] set minimum width for schema nodes (100px) to prevent narrow display - 0.17.2 - [x] enable PWA - 0.17.3 - [x] fix unstable size of magnification effect. - [x] 1.show loader name ## 0.18 - 0.18.0 - [x] show query and mutation method info in er diagram. ## 0.19 - 0.19.0 - **Breaking Change**: migrate pydantic-resolve v4.0. If you use pydantic-resolve v3, please pin `fastapi-voyager<=0.18`. - show relationship name on ER diagram edges. - 0.19.1 - [x] fix: handle value type in diagram relationship. ## 0.20 - 0.20.0 - [x] migrate pydantic resolve from v4 to v5 ## 0.21 - 0.21.0 - [x] add dataloader info in side bar ## 0.22 - 0.22.0 - [x] optimize er diagram ineraction and highlight ## 0.23 - 0.23.0 - [x] refactor query and mutation methods to standalone functions and integrate with ER diagram - [x] enhance ER diagram data structure and update highlight modes in GraphUI - [x] add edge length configuration for ER diagram (Small/Middle/Large) - [x] preserve highlight state of nodes and edges after re-render - [x] preserve zoom level after re-render (e.g. adjusting edge length) - [x] add toggle to show/hide query and mutation methods in ER diagram ## 0.24 - 0.24.0 - [x] simplify highlight method by removing tooltip handling in GraphUI and GraphvizSvg - [x] update edge click handling in GraphUI and modify onGenerate action in store - [x] upgrade deps and init db - 0.24.1 - [x] fix: use `safe_issubclass` to prevent `TypeError: issubclass() arg 1 must be a class` on Python 3.13 - Python 3.13 raises TypeError when `issubclass()` receives a `types.GenericAlias` (e.g. `dict[X, set[Y]]`), while Python 3.12 silently returns False - Typical trigger: route with PEP 695 type alias as response_model (e.g. `type ResourceActionDict = dict[K, set[V]]`) ## 0.25 - 0.25.0 - [x] migrate frontend from Vue 3 + Quasar (CDN, ~692KB) to Vue 3 + Naive UI (Vite build, tree-shaken ~120KB) - [x] add Vite build pipeline with dev server + HMR and API proxy - [x] add CI Node.js build step in publish workflow - [x] fix NCollapse tag expansion with v-model and accordion mode - [x] fix NSelect schema/field display (remove render-tag, fix filterable conflict) - [x] fix route item icon vertical alignment (flex layout) - [x] fix drawer close button display (use built-in closable prop) - [x] remove SchemaCodeDisplay outer border - [x] switch toggle style to label + switch separated layout - [x] remove edge :e/:w port anchors in DOT template ## 0.26 - 0.26.0 - [x] replace Material Icons with @vicons/ionicons5 (Naive UI native icon solution) - [x] remove Google Fonts (Roboto + Material Icons) dependency, eliminate external font loading - [x] rename CSS variable `--q-primary` to `--primary-color` (remove Quasar legacy naming) - [x] defer Google Analytics script to post-load to avoid blocking page render - [x] remove PWA manifest and Service Worker registration (not needed for dev-tool usage) ## 0.27 - 0.27.0 - [x] fix: include `web/dist/` in wheel via hatch artifacts config (was missing from PyPI wheel) ## unrelease - x.x.x - [ ] 2.show relationship list when double click entity in er diagram - [ ] 3.highlight entity in use case - [ ] 4.change cli -m param, use `path.to.module:app` instead. ## 1.0, release - [ ] add tests ## 1.1 future ================================================ FILE: docs/claude/0_REFACTORING_RENDER_NOTES.md ================================================ # Jinja2 模板引擎重构说明 ## 概述 已成功将 `render.py` 从硬编码的模板字符串重构为使用 Jinja2 模板引擎的架构。 ## 变更内容 ### 1. 新增文件 #### `src/fastapi_voyager/render_style.py` - **ColorScheme**: 颜色配置类(节点、链接、文本颜色) - **GraphvizStyle**: Graphviz 样式配置类(字体、布局、链接样式) - **RenderConfig**: 完整的渲染配置类 #### 模板文件 ``` templates/ ├── dot/ # DOT 格式模板 │ ├── digraph.j2 # 主图模板 │ ├── tag_node.j2 # 标签节点 │ ├── schema_node.j2 # Schema 节点 │ ├── route_node.j2 # 路由节点 │ ├── cluster.j2 # 集群模板 │ ├── cluster_container.j2 # 容器集群 │ └── link.j2 # 链接模板 └── html/ # HTML 格式模板 ├── schema_table.j2 # Schema 表格 ├── schema_header.j2 # 表格头部 ├── schema_field_row.j2 # 字段行 ├── pydantic_meta.j2 # Pydantic 元数据 └── colored_text.j2 # 彩色文本 ``` ### 2. 重构文件 #### `src/fastapi_voyager/render.py` - **新增 TemplateRenderer 类**: Jinja2 环境管理和模板渲染 - **重构 Renderer 类**: - 使用模板渲染替代字符串拼接 - 分离关注点(格式化、渲染、配置) - 保持公共 API 不变,向后兼容 ### 3. 依赖更新 #### `pyproject.toml` ```toml dependencies = [ "fastapi>=0.110", "pydantic-resolve>=2.4.3", "jinja2>=3.0.0" # 新增 ] ``` ## 架构优势 ### 1. **关注点分离** - **逻辑层**: Renderer 类处理业务逻辑 - **视图层**: Jinja2 模板处理格式化 - **配置层**: render_style.py 管理样式常量 ### 2. **可维护性提升** - ✅ 模板集中管理,易于查找和修改 - ✅ 样式常量集中定义 - ✅ 代码结构更清晰 ### 3. **可扩展性** - ✅ 支持主题切换(修改 ColorScheme) - ✅ 支持自定义配置(注入 RenderConfig) - ✅ 易于添加新的节点类型或样式 ### 4. **可测试性** - ✅ 模板可独立测试 - ✅ 样式配置可单独验证 - ✅ 渲染逻辑更清晰 ## 向后兼容性 ✅ **完全兼容**: Renderer 类的公共接口保持不变: - `__init__()` 参数未变(新增可选的 `config` 参数) - `render_dot()` 方法签名未变 - 所有渲染方法保持原有行为 ## 使用示例 ### 基础使用(无变化) ```python from fastapi_voyager.render import Renderer renderer = Renderer( show_fields='all', module_color={'myapp.services': 'tomato'} ) dot_output = renderer.render_dot(tags, routes, nodes, links) ``` ### 高级使用(新功能) ```python from fastapi_voyager.render import Renderer from fastapi_voyager.render_style import RenderConfig, ColorScheme, GraphvizStyle # 自定义颜色主题 custom_colors = ColorScheme( primary='#ff6b6b', highlight='#ffd93d' ) # 自定义样式 custom_style = GraphvizStyle( font='Arial', node_fontsize='14' ) # 使用自定义配置 config = RenderConfig(colors=custom_colors, style=custom_style) renderer = Renderer(config=config) dot_output = renderer.render_dot(tags, routes, nodes, links) ``` ## 测试验证 ✅ 所有现有测试通过 (18/18) ✅ 模板渲染正确 ✅ 向后兼容性验证通过 ✅ 实际应用场景测试通过 ## 未来改进建议 1. **模板继承**: 使用 Jinja2 模板继承减少重复 2. **主题系统**: 预定义多个主题(深色、浅色、高对比度) 3. **自定义模板**: 支持用户覆盖默认模板 4. **模板验证**: 添加模板语法检查 5. **性能优化**: 缓存编译后的模板 ## 迁移指南 ### 对于项目维护者 无需修改现有代码,但可选地: 1. **自定义样式**: ```python from fastapi_voyager.render_style import RenderConfig, ColorScheme config = RenderConfig( colors=ColorScheme(primary='#custom-color') ) renderer = Renderer(config=config) ``` 2. **修改模板**: 编辑 `templates/dot/*.j2` 或 `templates/html/*.j2` 文件 3. **添加新样式**: 在 `render_style.py` 中扩展配置类 ## 技术细节 ### Jinja2 环境配置 ```python Environment( loader=FileSystemLoader(template_dir), autoescape=select_autoescape(), trim_blocks=True, # 移除尾随换行符 lstrip_blocks=True # 移除前导空白 ) ``` ### 模板路径解析 ```python TEMPLATE_DIR = Path(__file__).parent / "templates" ``` 自动定位到 `src/fastapi_voyager/templates/` ## 常见问题 **Q: 为什么要引入 Jinja2?** A: 将视图模板从业务逻辑中分离,提高代码的可维护性和可扩展性。 **Q: 会影响性能吗?** A: Jinja2 会编译并缓存模板,性能影响可忽略不计。 **Q: 如何自定义样式?** A: 使用 RenderConfig 注入自定义配置,或直接修改 render_style.py。 **Q: 模板语法错误如何调试?** A: Jinja2 会提供详细的错误信息,包括行号和上下文。 ## 总结 此次重构成功地将散乱的模板字符串集中管理到 Jinja2 模板文件中,并提取了样式配置到专门的模块。这不仅提高了代码的可维护性,也为未来的功能扩展(如主题系统、自定义模板等)奠定了基础。 ✅ **任务完成**: 所有计划任务已完成,测试通过,代码已准备就绪。 ================================================ FILE: docs/idea.md ================================================ # Idea ## backlog - [ ] user can generate nodes/edges manually and connect to generated ones - [ ] eg: add owner - [ ] add extra info for schema - [ ] optimize static resource (allow manually config url) - [ ] improve search dialog - [ ] add route/tag list - [ ] type alias should not be kept as node instead of compiling to original type - [ ] how to correctly handle the generic type ? - for example `Page[Student]` of `Page[T]` will be marked in `Page[T]`'s module - [ ] sort field name in nodes (only table inside right panel) - [ ] set max limit for fields in nodes (? need further thinking) - [ ] minimap (good to have) - ref: https://observablehq.com/@rabelais/d3-js-zoom-minimap - [ ] ~~debug mode~~ - [ ] export dot content, load dot content - [ ] abstract voyager-core - [ ] support fastapi-voyager - [ ] support django-ninja-voyager ## in analysis - [ ] upgrade network algorithm (optional, for example networkx) - [ ] click field to highlight links or click link to highlight related nodes - [ ] animation effect for edges - [ ] display standard ER diagram spec. `hard but important` - [ ] display potential invalid links - [ ] highlight relationship belongs to ER diagram ================================================ FILE: pyproject.toml ================================================ [project] name = "fastapi-voyager" dynamic = ["version"] description = "Visualize FastAPI application's routing tree and dependencies" authors = [ { name = "Tangkikodo", email = "allmonday@126.com" } ] license = { text = "MIT" } readme = "README.md" requires-python = ">=3.10" keywords = ["fastapi", "visualization", "routing", "openapi"] dependencies = [ "pydantic-resolve>=5.1.0", "jinja2>=3.0.0", ] classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Framework :: FastAPI", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License" ] [project.scripts] voyager = "fastapi_voyager.cli:main" [project.urls] Homepage = "https://github.com/allmonday/fastapi-voyager" Source = "https://github.com/allmonday/fastapi-voyager" [project.optional-dependencies] dev = ["ruff", "pytest", "pytest-asyncio", "httpx"] fastapi = ["fastapi>=0.110", "uvicorn"] django-ninja = ["django>=4.2", "django-ninja>=1.5.3", "uvicorn"] litestar = ["litestar>=2.19.0", "pydantic>=2.0", "uvicorn"] all = ["fastapi-voyager[dev,fastapi,django-ninja,litestar]"] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.version] path = "src/fastapi_voyager/version.py" [tool.hatch.build.targets.sdist] force-include."src/fastapi_voyager/web/dist" = "src/fastapi_voyager/web/dist" artifacts = ["src/fastapi_voyager/web/dist/"] [tool.hatch.build.targets.wheel] artifacts = ["src/fastapi_voyager/web/dist/"] [tool.uv] # You can pin resolution or indexes here later. [tool.ruff] line-length = 100 [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] [dependency-groups] dev = [ "aiosqlite>=0.22.1", "greenlet>=3.4.0", "httpx>=0.28.1", "pytest-asyncio>=1.3.0", "pytest>=8.0.0", "ruff>=0.9.0", "sqlalchemy>=2.0.49", ] fastapi = [ "fastapi>=0.116.1", "uvicorn>=0.34.0", ] django-ninja = [ "django>=4.2", "django-ninja>=1.5.3", "uvicorn>=0.34.0", ] litestar = [ "litestar>=2.19.0", "pydantic>=2.0", "uvicorn>=0.34.0", ] all = [ "django>=4.2", "django-ninja>=1.5.3", "fastapi>=0.116.1", "litestar>=2.19.0", "pydantic>=2.0", "uvicorn>=0.34.0", ] ================================================ FILE: release.md ================================================ release by pushing the tag ```shell git tag v1.0.0 git push origin v1.0.0 ``` ================================================ FILE: setup-django-ninja.sh ================================================ #!/bin/bash # Django Ninja Development Setup Script # Usage: ./setup-django-ninja.sh [--no-sync] set -e echo "🚀 Setting up Django Ninja development environment..." echo "" # Parse arguments SYNC=true for arg in "$@"; do case $arg in --no-sync) SYNC=false shift ;; esac done # Sync dependencies if [ "$SYNC" = true ]; then echo "📦 Syncing dependencies..." uv sync --group dev --group django-ninja echo "✅ Dependencies synced" echo "" fi # Check if uvicorn is installed echo "🔍 Checking uvicorn installation..." if uv run which uvicorn > /dev/null 2>&1; then UVICORN_PATH=$(uv run which uvicorn) echo "✅ Uvicorn found at: $UVICORN_PATH" else echo "❌ Uvicorn not found in project environment" exit 1 fi echo "" # Start Django Ninja server echo "🌟 Starting Django Ninja Voyager server..." echo " App: tests.django_ninja.embedding:application" echo " URL: http://127.0.0.1:8000" echo "" echo "Press Ctrl+C to stop the server" echo "" uv run uvicorn tests.django_ninja.embedding:application --reload --host 127.0.0.1 --port 8000 ================================================ FILE: setup-fastapi.sh ================================================ #!/bin/bash # FastAPI Development Setup Script # Usage: ./setup-fastapi.sh [--no-sync] set -e echo "🚀 Setting up FastAPI development environment..." echo "" # Parse arguments SYNC=true for arg in "$@"; do case $arg in --no-sync) SYNC=false shift ;; esac done # Sync dependencies if [ "$SYNC" = true ]; then echo "📦 Syncing dependencies..." uv sync --group dev --group fastapi echo "✅ Dependencies synced" echo "" fi # Check if uvicorn is installed echo "🔍 Checking uvicorn installation..." if uv run which uvicorn > /dev/null 2>&1; then UVICORN_PATH=$(uv run which uvicorn) echo "✅ Uvicorn found at: $UVICORN_PATH" else echo "❌ Uvicorn not found in project environment" exit 1 fi echo "" # Start FastAPI server echo "🌟 Starting FastAPI Voyager server..." echo " App: tests.fastapi.embedding:app" echo " URL: http://127.0.0.1:8000" echo "" echo "Press Ctrl+C to stop the server" echo "" uv run uvicorn tests.fastapi.embedding:app --reload --host 127.0.0.1 --port 8000 ================================================ FILE: setup-hooks.sh ================================================ #!/bin/bash # Setup script for Git hooks echo "Setting up Git hooks..." # Check if we're in a git repository if ! git rev-parse --git-dir > /dev/null 2>&1; then echo "Error: Not a Git repository" exit 1 fi # Set the hooks path git config core.hooksPath .githooks # Make hooks executable chmod +x .githooks/* echo "✓ Git hooks configured successfully!" echo "" echo "Hooks are now enabled. Prettier will run automatically before each commit." echo "" echo "To verify:" echo " git config core.hooksPath" ================================================ FILE: setup-litestar.sh ================================================ #!/bin/bash # Litestar Development Setup Script # Usage: ./setup-litestar.sh [--no-sync] set -e echo "🚀 Setting up Litestar development environment..." echo "" # Parse arguments SYNC=true for arg in "$@"; do case $arg in --no-sync) SYNC=false shift ;; esac done # Sync dependencies if [ "$SYNC" = true ]; then echo "📦 Syncing dependencies..." uv sync --group dev --group litestar echo "✅ Dependencies synced" echo "" fi # Check if uvicorn is installed echo "🔍 Checking uvicorn installation..." if uv run which uvicorn > /dev/null 2>&1; then UVICORN_PATH=$(uv run which uvicorn) echo "✅ Uvicorn found at: $UVICORN_PATH" else echo "❌ Uvicorn not found in project environment" exit 1 fi echo "" # Start Litestar server echo "🌟 Starting Litestar Voyager server..." echo " App: tests.litestar.embedding:app" echo " URL: http://127.0.0.1:8000" echo "" echo "Press Ctrl+C to stop the server" echo "" uv run uvicorn tests.litestar.embedding:app --reload --host 127.0.0.1 --port 8000 ================================================ FILE: src/fastapi_voyager/__init__.py ================================================ """fastapi_voyager Utilities to introspect web applications and visualize their routing tree. """ from .server import create_voyager from .version import __version__ # noqa: F401 __all__ = [ "__version__", "create_voyager" ] ================================================ FILE: src/fastapi_voyager/adapters/__init__.py ================================================ """ Framework adapters for fastapi-voyager. This module provides adapters that allow voyager to work with different web frameworks. """ from fastapi_voyager.adapters.base import VoyagerAdapter from fastapi_voyager.adapters.django_ninja_adapter import DjangoNinjaAdapter from fastapi_voyager.adapters.fastapi_adapter import FastAPIAdapter from fastapi_voyager.adapters.litestar_adapter import LitestarAdapter __all__ = [ "VoyagerAdapter", "FastAPIAdapter", "DjangoNinjaAdapter", "LitestarAdapter", ] ================================================ FILE: src/fastapi_voyager/adapters/base.py ================================================ """ Base adapter interface for framework-agnostic voyager server. This module defines the abstract interface that all framework adapters must implement. """ from abc import ABC, abstractmethod from typing import Any class VoyagerAdapter(ABC): """ Abstract base class for framework-specific voyager adapters. Each adapter is responsible for: 1. Creating routes/endpoints for the voyager UI 2. Handling HTTP requests and responses in a framework-specific way 3. Returning an object that can be mounted/integrated with the target app """ @abstractmethod def create_app(self) -> Any: """ Create and return a framework-specific application object. The returned object should be mountable/integrable with the target framework. For example: - FastAPI: returns a FastAPI app - Django Ninja: returns an ASGI application - Litestar: returns a Litestar app Returns: A framework-specific application object """ pass ================================================ FILE: src/fastapi_voyager/adapters/common.py ================================================ """ Shared business logic for voyager endpoints. This module contains the core logic that is reused across all framework adapters. """ from pathlib import Path from typing import Any from pydantic_resolve import ErDiagram from fastapi_voyager.er_diagram import VoyagerErDiagram from fastapi_voyager.introspectors.detector import FrameworkType, detect_framework from fastapi_voyager.render import Renderer from fastapi_voyager.render_style import RenderConfig from fastapi_voyager.type import CoreData, SchemaNode, Tag from fastapi_voyager.type_helper import get_source, get_vscode_link from fastapi_voyager.version import __version__ from fastapi_voyager.voyager import Voyager WEB_DIR = Path(__file__).parent.parent / "web" WEB_DIR.mkdir(exist_ok=True) STATIC_FILES_PATH = "/fastapi-voyager-static" GA_PLACEHOLDER = "" VERSION_PLACEHOLDER = "" STATIC_PATH_PLACEHOLDER = "" THEME_COLOR_PLACEHOLDER = "" VOYAGER_PATH_PLACEHOLDER = "" def build_ga_snippet(ga_id: str | None) -> str: """Build Google Analytics snippet.""" if not ga_id: return "" return f""" """ class VoyagerContext: """ Context object that holds configuration and provides business logic methods. This is shared across all framework adapters to avoid code duplication. """ def __init__( self, target_app: Any, module_color: dict[str, str] | None = None, module_prefix: str | None = None, swagger_url: str | None = None, online_repo_url: str | None = None, initial_page_policy: str = 'first', ga_id: str | None = None, er_diagram: ErDiagram | None = None, enable_pydantic_resolve_meta: bool = False, framework_name: str | None = None, ): self.target_app = target_app self.module_color = module_color or {} self.module_prefix = module_prefix self.swagger_url = swagger_url self.online_repo_url = online_repo_url self.initial_page_policy = initial_page_policy self.ga_id = ga_id self.er_diagram = er_diagram self.enable_pydantic_resolve_meta = enable_pydantic_resolve_meta # Detect and store framework type (single source of truth) self._framework_type = detect_framework(target_app) # Display name for frontend (backward compatible) self.framework_name = framework_name or self._get_display_name() def _get_display_name(self) -> str: """Get display name for the detected framework type.""" display_names = { FrameworkType.FASTAPI: "FastAPI", FrameworkType.DJANGO_NINJA: "Django Ninja", FrameworkType.LITESTAR: "Litestar", } return display_names.get(self._framework_type, "API") def _get_theme_color(self) -> str: """Get theme color for the current framework.""" config = RenderConfig() return config.colors.get_framework_color(self._framework_type) def _get_entity_class_names(self) -> set[str] | None: """Extract entity class names from er_diagram.""" if not self.er_diagram: return None from fastapi_voyager.type_helper import full_class_name return { full_class_name(entity.kls) for entity in self.er_diagram.entities } def get_voyager(self, **kwargs) -> Voyager: """Create a Voyager instance with common configuration.""" config = { "module_color": self.module_color, "show_pydantic_resolve_meta": self.enable_pydantic_resolve_meta, "theme_color": self._get_theme_color(), "entity_class_names": self._get_entity_class_names(), } config.update(kwargs) return Voyager(**config) def analyze_and_get_dot(self) -> tuple[str, list[Tag], list[SchemaNode]]: """ Analyze the target app and return dot graph, tags, and schemas. Returns: Tuple of (dot_graph, tags, schemas) """ voyager = self.get_voyager() voyager.analysis(self.target_app) dot = voyager.render_dot() # include tags and their routes tags = voyager.tags for t in tags: t.routes.sort(key=lambda r: r.name) tags.sort(key=lambda t: t.name) schemas = voyager.nodes[:] schemas.sort(key=lambda s: s.name) return dot, tags, schemas def get_option_param(self) -> dict: """Get the option parameter for the voyager UI.""" dot, tags, schemas = self.analyze_and_get_dot() return { "tags": tags, "schemas": schemas, "dot": dot, "enable_brief_mode": bool(self.module_prefix), "version": __version__, "swagger_url": self.swagger_url, "initial_page_policy": self.initial_page_policy, "has_er_diagram": self.er_diagram is not None, "enable_pydantic_resolve_meta": self.enable_pydantic_resolve_meta, "framework_name": self.framework_name, } def get_search_dot(self, payload: dict) -> list[Tag]: """Get filtered tags for search.""" voyager = self.get_voyager( schema=payload.get("schema_name"), schema_field=payload.get("schema_field"), show_fields=payload.get("show_fields", "object"), hide_primitive_route=payload.get("hide_primitive_route", False), show_module=payload.get("show_module", True), show_pydantic_resolve_meta=payload.get("show_pydantic_resolve_meta", False), ) voyager.analysis(self.target_app) tags = voyager.calculate_filtered_tag_and_route() for t in tags: t.routes.sort(key=lambda r: r.name) tags.sort(key=lambda t: t.name) return tags def get_filtered_dot(self, payload: dict) -> str: """Get filtered dot graph.""" voyager = self.get_voyager( include_tags=payload.get("tags"), schema=payload.get("schema_name"), schema_field=payload.get("schema_field"), show_fields=payload.get("show_fields", "object"), route_name=payload.get("route_name"), hide_primitive_route=payload.get("hide_primitive_route", False), show_module=payload.get("show_module", True), show_pydantic_resolve_meta=payload.get("show_pydantic_resolve_meta", False), ) voyager.analysis(self.target_app) if payload.get("brief"): if payload.get("tags"): return voyager.render_tag_level_brief_dot(module_prefix=self.module_prefix) else: return voyager.render_overall_brief_dot(module_prefix=self.module_prefix) else: return voyager.render_dot() def get_core_data(self, payload: dict) -> CoreData: """Get core data for the graph.""" voyager = self.get_voyager( include_tags=payload.get("tags"), schema=payload.get("schema_name"), schema_field=payload.get("schema_field"), show_fields=payload.get("show_fields", "object"), route_name=payload.get("route_name"), ) voyager.analysis(self.target_app) return voyager.dump_core_data() def render_dot_from_core_data(self, core_data: CoreData) -> str: """Render dot graph from core data.""" renderer = Renderer( show_fields=core_data.show_fields, module_color=core_data.module_color, schema=core_data.schema, theme_color=self._get_theme_color(), ) return renderer.render_dot( core_data.tags, core_data.routes, core_data.nodes, core_data.links ) def get_er_diagram_dot(self, payload: dict) -> str: """Get ER diagram dot graph.""" if self.er_diagram: return VoyagerErDiagram( self.er_diagram, show_fields=payload.get("show_fields", "object"), show_module=payload.get("show_module", True), theme_color=self._get_theme_color(), ).render_dot() return "" def get_er_diagram_data(self, payload: dict) -> dict: """Get ER diagram dot graph and link metadata.""" if not self.er_diagram: return {"dot": "", "links": [], "schemas": []} edge_minlen = max(3, min(10, payload.get("edge_minlen", 3))) diagram = VoyagerErDiagram( self.er_diagram, show_fields=payload.get("show_fields", "object"), show_module=payload.get("show_module", True), theme_color=self._get_theme_color(), edge_minlen=edge_minlen, show_methods=payload.get("show_methods", True), ) dot = diagram.render_dot() links_meta = [ { "source_origin": link.source_origin, "target_origin": link.target_origin, "label": link.label, "loader_fullname": link.loader_fullname, } for link in diagram.links ] schemas_meta = [ { "id": node.id, "name": node.name, "module": node.module, "fields": [ { "name": f.name, "type_name": f.type_name, "from_base": f.from_base, "is_object": f.is_object, "is_exclude": f.is_exclude, "desc": f.desc, } for f in node.fields ], } for node in diagram.node_set.values() ] return {"dot": dot, "links": links_meta, "schemas": schemas_meta} def get_index_html(self) -> str: """Get the index HTML content.""" # Prefer built (dist) version, fall back to source index.html index_file = WEB_DIR / "dist" / "index.html" if not index_file.exists(): index_file = WEB_DIR / "index.html" if index_file.exists(): content = index_file.read_text(encoding="utf-8") content = content.replace(GA_PLACEHOLDER, build_ga_snippet(self.ga_id)) content = content.replace(VERSION_PLACEHOLDER, f"?v={__version__}") # Replace static files path placeholder with actual path (without leading slash) content = content.replace(STATIC_PATH_PLACEHOLDER, STATIC_FILES_PATH.lstrip("/")) # Fix Vite absolute asset paths to be relative (for sub-app mounting) content = content.replace(f"{STATIC_FILES_PATH}/dist/", f"{STATIC_FILES_PATH.lstrip('/')}/dist/") # Replace theme color placeholder with framework-specific color content = content.replace(THEME_COLOR_PLACEHOLDER, self._get_theme_color()) return content # fallback simple page if index.html missing return """ Graphviz Preview

index.html not found. Create one under src/fastapi_voyager/web/index.html

""" def get_source_code(self, schema_name: str) -> dict: """Get source code for a schema.""" try: components = schema_name.split(".") if len(components) < 2: return {"error": "Invalid schema name format. Expected format: module.ClassName"} module_name = ".".join(components[:-1]) class_name = components[-1] mod = __import__(module_name, fromlist=[class_name]) obj = getattr(mod, class_name) source_code = get_source(obj) return {"source_code": source_code} except ImportError as e: return {"error": f"Module not found: {e}"} except AttributeError as e: return {"error": f"Class not found: {e}"} except Exception as e: return {"error": f"Internal error: {str(e)}"} def get_vscode_link(self, schema_name: str) -> dict: """Get VSCode link for a schema.""" try: components = schema_name.split(".") if len(components) < 2: return {"error": "Invalid schema name format. Expected format: module.ClassName"} module_name = ".".join(components[:-1]) class_name = components[-1] mod = __import__(module_name, fromlist=[class_name]) obj = getattr(mod, class_name) link = get_vscode_link(obj, online_repo_url=self.online_repo_url) return {"link": link} except ImportError as e: return {"error": f"Module not found: {e}"} except AttributeError as e: return {"error": f"Class not found: {e}"} except Exception as e: return {"error": f"Internal error: {str(e)}"} def get_service_worker(self) -> str: """Get the Service Worker JavaScript content with placeholders replaced.""" sw_file = WEB_DIR / "sw.js" if sw_file.exists(): content = sw_file.read_text(encoding="utf-8") content = content.replace(VERSION_PLACEHOLDER, __version__) content = content.replace(STATIC_PATH_PLACEHOLDER, STATIC_FILES_PATH.lstrip("/")) return content return "" def get_manifest(self) -> str: """Get the PWA manifest JSON content with placeholders replaced.""" manifest_file = WEB_DIR / "icon" / "site.webmanifest" if manifest_file.exists(): content = manifest_file.read_text(encoding="utf-8") # VOYAGER_PATH will be replaced with the voyager mount path (e.g., "/voyager/") # This is set by adapters based on how they are mounted content = content.replace(THEME_COLOR_PLACEHOLDER, self._get_theme_color()) return content return "{}" ================================================ FILE: src/fastapi_voyager/adapters/django_ninja_adapter.py ================================================ """ Django Ninja adapter for fastapi-voyager. This module provides the Django Ninja-specific implementation of the voyager server. It creates an ASGI application that can be integrated with Django. """ import json import mimetypes from typing import Any from fastapi_voyager.adapters.base import VoyagerAdapter from fastapi_voyager.adapters.common import STATIC_FILES_PATH, VOYAGER_PATH_PLACEHOLDER, WEB_DIR, VoyagerContext from fastapi_voyager.type import CoreData, SchemaNode, Tag class DjangoNinjaAdapter(VoyagerAdapter): """ Django Ninja-specific implementation of VoyagerAdapter. Creates an ASGI application with voyager endpoints that can be integrated with Django. """ def __init__( self, target_app: Any, module_color: dict[str, str] | None = None, gzip_minimum_size: int | None = 500, module_prefix: str | None = None, swagger_url: str | None = None, online_repo_url: str | None = None, initial_page_policy: str = "first", ga_id: str | None = None, er_diagram: Any = None, enable_pydantic_resolve_meta: bool = False, server_mode: bool = False, ): self.ctx = VoyagerContext( target_app=target_app, module_color=module_color, module_prefix=module_prefix, swagger_url=swagger_url, online_repo_url=online_repo_url, initial_page_policy=initial_page_policy, ga_id=ga_id, er_diagram=er_diagram, enable_pydantic_resolve_meta=enable_pydantic_resolve_meta, framework_name="Django Ninja", ) self.server_mode = server_mode # Note: gzip should be handled by Django's middleware, not here async def _handle_request(self, scope, receive, send): """ASGI request handler.""" if scope["type"] != "http": return # Parse the request method = scope["method"] path = scope["path"] # Remove /voyager prefix for internal routing (unless in server_mode) if not self.server_mode and path.startswith("/voyager"): path = path[8:] # Remove '/voyager' if path == "": path = "/" # Handle static files if method == "GET" and path.startswith(f"{STATIC_FILES_PATH}/"): await self._handle_static_file(path, send) return # Route the request if method == "GET" and path == "/": await self._handle_index(send) elif method == "GET" and path == "/sw.js": await self._handle_service_worker(send) elif method == "GET" and path == "/manifest.webmanifest": await self._handle_manifest(send) elif method == "GET" and path == "/dot": await self._handle_get_dot(send) elif method == "POST" and path == "/er-diagram": await self._handle_post_request(receive, send, self._handle_er_diagram) elif method == "POST" and path == "/dot-search": await self._handle_post_request(receive, send, self._handle_search_dot) elif method == "POST" and path == "/dot": await self._handle_post_request(receive, send, self._handle_filtered_dot) elif method == "POST" and path == "/dot-core-data": await self._handle_post_request(receive, send, self._handle_core_data) elif method == "POST" and path == "/dot-render-core-data": await self._handle_post_request(receive, send, self._handle_render_core_data) elif method == "POST" and path == "/source": await self._handle_post_request(receive, send, self._handle_source) elif method == "POST" and path == "/vscode-link": await self._handle_post_request(receive, send, self._handle_vscode_link) else: await self._send_404(send) async def _handle_post_request(self, receive, send, handler): """Helper to handle POST requests with JSON body.""" body = b"" more_body = True while more_body: message = await receive() if message["type"] == "http.request": body += message.get("body", b"") more_body = message.get("more_body", False) try: payload = json.loads(body.decode()) await handler(payload, send) except Exception as e: await self._send_json({"error": str(e)}, send, status_code=400) async def _handle_static_file(self, path: str, send): """Handle GET {STATIC_FILES_PATH}/* - serve static files.""" # Remove /fastapi-voyager-static/ prefix prefix = f"{STATIC_FILES_PATH}/" file_path = path[len(prefix):] full_path = WEB_DIR / file_path # Security check: ensure the path is within WEB_DIR try: full_path = full_path.resolve() web_dir_resolved = WEB_DIR.resolve() if not str(full_path).startswith(str(web_dir_resolved)): await self._send_404(send) return except Exception: await self._send_404(send) return if not full_path.exists() or not full_path.is_file(): await self._send_404(send) return # Read file content try: with open(full_path, "rb") as f: content = f.read() # Determine content type content_type, _ = mimetypes.guess_type(str(full_path)) if content_type is None: content_type = "application/octet-stream" await self._send_response(content_type, content, send) except Exception: await self._send_404(send) async def _handle_index(self, send): """Handle GET / - return the index HTML.""" html = self.ctx.get_index_html() await self._send_html(html, send) async def _handle_service_worker(self, send): """Handle GET /sw.js - return the Service Worker.""" sw_content = self.ctx.get_service_worker() await self._send_response( "application/javascript", sw_content.encode("utf-8"), send, ) async def _handle_manifest(self, send): """Handle GET /manifest.webmanifest - return the PWA manifest.""" content = self.ctx.get_manifest() content = content.replace(VOYAGER_PATH_PLACEHOLDER, "./") await self._send_response( "application/manifest+json", content.encode("utf-8"), send, ) async def _handle_get_dot(self, send): """Handle GET /dot - return options and initial dot graph.""" data = self.ctx.get_option_param() # Convert tags and schemas to dicts for JSON serialization response_data = { "tags": [self._tag_to_dict(t) for t in data["tags"]], "schemas": [self._schema_to_dict(s) for s in data["schemas"]], "dot": data["dot"], "enable_brief_mode": data["enable_brief_mode"], "version": data["version"], "initial_page_policy": data["initial_page_policy"], "swagger_url": data["swagger_url"], "has_er_diagram": data["has_er_diagram"], "enable_pydantic_resolve_meta": data["enable_pydantic_resolve_meta"], "framework_name": data["framework_name"], } await self._send_json(response_data, send) async def _handle_er_diagram(self, payload, send): """Handle POST /er-diagram.""" data = self.ctx.get_er_diagram_data(payload) await self._send_json(data, send) async def _handle_search_dot(self, payload, send): """Handle POST /dot-search.""" tags = self.ctx.get_search_dot(payload) response_data = {"tags": [self._tag_to_dict(t) for t in tags]} await self._send_json(response_data, send) async def _handle_filtered_dot(self, payload, send): """Handle POST /dot.""" dot = self.ctx.get_filtered_dot(payload) await self._send_text(dot, send) async def _handle_core_data(self, payload, send): """Handle POST /dot-core-data.""" core_data = self.ctx.get_core_data(payload) await self._send_json(core_data.model_dump(), send) async def _handle_render_core_data(self, payload, send): """Handle POST /dot-render-core-data.""" core_data = CoreData(**payload) dot = self.ctx.render_dot_from_core_data(core_data) await self._send_text(dot, send) async def _handle_source(self, payload, send): """Handle POST /source.""" result = self.ctx.get_source_code(payload.get("schema_name", "")) status_code = 200 if "error" not in result else 400 if "error" in result and "not found" in result["error"]: status_code = 404 await self._send_json(result, send, status_code=status_code) async def _handle_vscode_link(self, payload, send): """Handle POST /vscode-link.""" result = self.ctx.get_vscode_link(payload.get("schema_name", "")) status_code = 200 if "error" not in result else 400 if "error" in result and "not found" in result["error"]: status_code = 404 await self._send_json(result, send, status_code=status_code) async def _send_html(self, html: str, send): """Send HTML response.""" await self._send_response( "text/html; charset=utf-8", html.encode("utf-8"), send, status_code=200, ) async def _send_json(self, data: dict, send, status_code: int = 200): """Send JSON response.""" body = json.dumps(data).encode("utf-8") await self._send_response("application/json", body, send, status_code=status_code) async def _send_text(self, text: str, send): """Send plain text response.""" await self._send_response("text/plain; charset=utf-8", text.encode("utf-8"), send) async def _send_404(self, send): """Send 404 response.""" await self._send_response("text/plain", b"Not Found", send, status_code=404) async def _send_response( self, content_type: str, body: bytes, send, status_code: int = 200 ): """Send ASGI response.""" await send( { "type": "http.response.start", "status": status_code, "headers": [ [b"content-type", content_type.encode()], [b"content-length", str(len(body)).encode()], ], } ) await send({"type": "http.response.body", "body": body}) def _tag_to_dict(self, tag: Tag) -> dict: """Convert Tag object to dict.""" return { "id": tag.id, "name": tag.name, "routes": [ { "id": r.id, "name": r.name, "module": r.module, "unique_id": r.unique_id, "response_schema": r.response_schema, "is_primitive": r.is_primitive, } for r in tag.routes ], } def _schema_to_dict(self, schema: SchemaNode) -> dict: """Convert SchemaNode to dict.""" return { "id": schema.id, "module": schema.module, "name": schema.name, "fields": [ { "name": f.name, "type_name": f.type_name, "is_object": f.is_object, "is_exclude": f.is_exclude, } for f in schema.fields ], } def create_app(self): """Create and return an ASGI application.""" async def asgi_app(scope, receive, send): # In server_mode, handle all paths; otherwise only handle /voyager/* if scope["type"] == "http": if self.server_mode or scope["path"].startswith("/voyager"): await self._handle_request(scope, receive, send) else: # Return 404 for non-voyager paths # (Django should handle these before they reach here) await self._send_404(send) else: await self._send_404(send) return asgi_app ================================================ FILE: src/fastapi_voyager/adapters/fastapi_adapter.py ================================================ """ FastAPI adapter for fastapi-voyager. This module provides the FastAPI-specific implementation of the voyager server. """ from typing import Any, Literal from pydantic import BaseModel from fastapi_voyager.adapters.base import VoyagerAdapter from fastapi_voyager.adapters.common import STATIC_FILES_PATH, VOYAGER_PATH_PLACEHOLDER, VoyagerContext from fastapi_voyager.type import CoreData, SchemaNode, Tag class OptionParam(BaseModel): tags: list[Tag] schemas: list[SchemaNode] dot: str enable_brief_mode: bool version: str initial_page_policy: Literal["first", "full", "empty"] swagger_url: str | None = None has_er_diagram: bool = False enable_pydantic_resolve_meta: bool = False framework_name: str = "API" class Payload(BaseModel): tags: list[str] | None = None schema_name: str | None = None schema_field: str | None = None route_name: str | None = None show_fields: str = "object" brief: bool = False hide_primitive_route: bool = False show_module: bool = True show_pydantic_resolve_meta: bool = False class SearchResultOptionParam(BaseModel): tags: list[Tag] class SchemaSearchPayload(BaseModel): schema_name: str | None = None schema_field: str | None = None show_fields: str = "object" brief: bool = False hide_primitive_route: bool = False show_module: bool = True show_pydantic_resolve_meta: bool = False class ErDiagramPayload(BaseModel): show_fields: str = "object" show_module: bool = True edge_minlen: int = 3 show_methods: bool = True class SourcePayload(BaseModel): schema_name: str class FastAPIAdapter(VoyagerAdapter): """ FastAPI-specific implementation of VoyagerAdapter. Creates a FastAPI application with voyager endpoints. """ def __init__( self, target_app: Any, module_color: dict[str, str] | None = None, gzip_minimum_size: int | None = 500, module_prefix: str | None = None, swagger_url: str | None = None, online_repo_url: str | None = None, initial_page_policy: str = "first", ga_id: str | None = None, er_diagram: Any = None, enable_pydantic_resolve_meta: bool = False, server_mode: bool = False, ): self.ctx = VoyagerContext( target_app=target_app, module_color=module_color, module_prefix=module_prefix, swagger_url=swagger_url, online_repo_url=online_repo_url, initial_page_policy=initial_page_policy, ga_id=ga_id, er_diagram=er_diagram, enable_pydantic_resolve_meta=enable_pydantic_resolve_meta, framework_name="FastAPI", ) self.gzip_minimum_size = gzip_minimum_size # Note: server_mode is accepted for API consistency but not used # since FastAPI apps are always standalone with routes at / def create_app(self) -> Any: """Create and return a FastAPI application with voyager endpoints.""" # Lazy import FastAPI to avoid import errors when framework is not installed from fastapi import APIRouter, FastAPI from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.gzip import GZipMiddleware router = APIRouter(tags=["fastapi-voyager"]) @router.post("/er-diagram") def get_er_diagram(payload: ErDiagramPayload): return self.ctx.get_er_diagram_data(payload.model_dump()) @router.get("/dot", response_model=OptionParam) def get_dot() -> OptionParam: data = self.ctx.get_option_param() return OptionParam(**data) @router.post("/dot-search", response_model=SearchResultOptionParam) def get_search_dot(payload: SchemaSearchPayload) -> SearchResultOptionParam: tags = self.ctx.get_search_dot(payload.model_dump()) return SearchResultOptionParam(tags=tags) @router.post("/dot", response_class=PlainTextResponse) def get_filtered_dot(payload: Payload) -> str: return self.ctx.get_filtered_dot(payload.model_dump()) @router.post("/dot-core-data", response_model=CoreData) def get_filtered_dot_core_data(payload: Payload) -> CoreData: return self.ctx.get_core_data(payload.model_dump()) @router.post("/dot-render-core-data", response_class=PlainTextResponse) def render_dot_from_core_data(core_data: CoreData) -> str: return self.ctx.render_dot_from_core_data(core_data) @router.get("/", response_class=HTMLResponse) def index() -> str: return self.ctx.get_index_html() @router.get("/sw.js") def get_service_worker(): """Serve the Service Worker with correct content type.""" from fastapi.responses import PlainTextResponse return PlainTextResponse( content=self.ctx.get_service_worker(), media_type="application/javascript" ) @router.get("/manifest.webmanifest") def get_manifest(): """Serve the PWA manifest with correct content type.""" from fastapi.responses import PlainTextResponse content = self.ctx.get_manifest() # Replace VOYAGER_PATH with root-relative path (works for any mount point) content = content.replace(VOYAGER_PATH_PLACEHOLDER, "./") return PlainTextResponse( content=content, media_type="application/manifest+json" ) @router.post("/source") def get_object_by_module_name(payload: SourcePayload) -> JSONResponse: result = self.ctx.get_source_code(payload.schema_name) status_code = 200 if "error" not in result else 400 if "error" in result and "not found" in result["error"]: status_code = 404 return JSONResponse(content=result, status_code=status_code) @router.post("/vscode-link") def get_vscode_link_by_module_name(payload: SourcePayload) -> JSONResponse: result = self.ctx.get_vscode_link(payload.schema_name) status_code = 200 if "error" not in result else 400 if "error" in result and "not found" in result["error"]: status_code = 404 return JSONResponse(content=result, status_code=status_code) app = FastAPI(title="fastapi-voyager demo server") if self.gzip_minimum_size is not None and self.gzip_minimum_size >= 0: app.add_middleware(GZipMiddleware, minimum_size=self.gzip_minimum_size) from fastapi_voyager.adapters.common import WEB_DIR app.mount(STATIC_FILES_PATH, StaticFiles(directory=str(WEB_DIR)), name="static") app.include_router(router) return app ================================================ FILE: src/fastapi_voyager/adapters/litestar_adapter.py ================================================ """ Litestar adapter for fastapi-voyager. This module provides the Litestar-specific implementation of the voyager server. """ from typing import Any from fastapi_voyager.adapters.base import VoyagerAdapter from fastapi_voyager.adapters.common import STATIC_FILES_PATH, VOYAGER_PATH_PLACEHOLDER, WEB_DIR, VoyagerContext from fastapi_voyager.type import CoreData, SchemaNode, Tag class LitestarAdapter(VoyagerAdapter): """ Litestar-specific implementation of VoyagerAdapter. Creates a Litestar application with voyager endpoints. """ def __init__( self, target_app: Any, module_color: dict[str, str] | None = None, gzip_minimum_size: int | None = 500, module_prefix: str | None = None, swagger_url: str | None = None, online_repo_url: str | None = None, initial_page_policy: str = "first", ga_id: str | None = None, er_diagram: Any = None, enable_pydantic_resolve_meta: bool = False, server_mode: bool = False, ): self.ctx = VoyagerContext( target_app=target_app, module_color=module_color, module_prefix=module_prefix, swagger_url=swagger_url, online_repo_url=online_repo_url, initial_page_policy=initial_page_policy, ga_id=ga_id, er_diagram=er_diagram, enable_pydantic_resolve_meta=enable_pydantic_resolve_meta, framework_name="Litestar", ) self.gzip_minimum_size = gzip_minimum_size # Note: server_mode is accepted for API consistency but not used # since Litestar apps are always standalone with routes at / def create_app(self) -> Any: """Create and return a Litestar application with voyager endpoints.""" # Lazy import Litestar to avoid import errors when framework is not installed from litestar import Litestar, MediaType, Request, Response, get, post from litestar.static_files import create_static_files_router @post("/er-diagram") async def get_er_diagram(request: Request) -> dict: payload = await request.json() return self.ctx.get_er_diagram_data(payload) @get("/dot") async def get_dot(request: Request) -> dict: data = self.ctx.get_option_param() # Convert tags and schemas to dicts for JSON serialization return { "tags": [self._tag_to_dict(t) for t in data["tags"]], "schemas": [self._schema_to_dict(s) for s in data["schemas"]], "dot": data["dot"], "enable_brief_mode": data["enable_brief_mode"], "version": data["version"], "initial_page_policy": data["initial_page_policy"], "swagger_url": data["swagger_url"], "has_er_diagram": data["has_er_diagram"], "enable_pydantic_resolve_meta": data["enable_pydantic_resolve_meta"], "framework_name": data["framework_name"], } @post("/dot-search") async def get_search_dot(request: Request) -> dict: payload = await request.json() tags = self.ctx.get_search_dot(payload) return {"tags": [self._tag_to_dict(t) for t in tags]} @post("/dot") async def get_filtered_dot(request: Request) -> str: payload = await request.json() return self.ctx.get_filtered_dot(payload) @post("/dot-core-data") async def get_filtered_dot_core_data(request: Request) -> CoreData: payload = await request.json() return self.ctx.get_core_data(payload) @post("/dot-render-core-data") async def render_dot_from_core_data(request: Request) -> str: payload = await request.json() core_data = CoreData(**payload) return self.ctx.render_dot_from_core_data(core_data) @get("/", media_type=MediaType.HTML) async def index() -> str: return self.ctx.get_index_html() @get("/sw.js", media_type="application/javascript") async def get_service_worker() -> str: """Serve the Service Worker.""" return self.ctx.get_service_worker() @get("/manifest.webmanifest", media_type="application/manifest+json") async def get_manifest() -> str: """Serve the PWA manifest.""" content = self.ctx.get_manifest() return content.replace(VOYAGER_PATH_PLACEHOLDER, "./") @post("/source") async def get_object_by_module_name(request: Request) -> dict: payload = await request.json() result = self.ctx.get_source_code(payload.get("schema_name", "")) status_code = 200 if "error" not in result else 400 if "error" in result and "not found" in result["error"]: status_code = 404 return Response( content=result, status_code=status_code, media_type=MediaType.JSON, ) @post("/vscode-link") async def get_vscode_link_by_module_name(request: Request) -> dict: payload = await request.json() result = self.ctx.get_vscode_link(payload.get("schema_name", "")) status_code = 200 if "error" not in result else 400 if "error" in result and "not found" in result["error"]: status_code = 404 return Response( content=result, status_code=status_code, media_type=MediaType.JSON, ) # Create static files router using the new API (replaces deprecated StaticFilesConfig) static_files_router = create_static_files_router( path=STATIC_FILES_PATH, directories=[str(WEB_DIR)], ) # Create Litestar app app = Litestar( route_handlers=[ get_er_diagram, get_dot, get_search_dot, get_filtered_dot, get_filtered_dot_core_data, render_dot_from_core_data, index, get_service_worker, get_manifest, get_object_by_module_name, get_vscode_link_by_module_name, static_files_router, ], ) return app def _tag_to_dict(self, tag: Tag) -> dict: """Convert Tag object to dict.""" return { "id": tag.id, "name": tag.name, "routes": [ { "id": r.id, "name": r.name, "module": r.module, "unique_id": r.unique_id, "response_schema": r.response_schema, "is_primitive": r.is_primitive, } for r in tag.routes ], } def _schema_to_dict(self, schema: SchemaNode) -> dict: """Convert SchemaNode to dict.""" return { "id": schema.id, "module": schema.module, "name": schema.name, "fields": [ { "name": f.name, "type_name": f.type_name, "is_object": f.is_object, "is_exclude": f.is_exclude, } for f in schema.fields ], } ================================================ FILE: src/fastapi_voyager/cli.py ================================================ """Command line interface for fastapi-voyager.""" import argparse import importlib import importlib.util import logging import os import sys from typing import Any from fastapi_voyager import server as viz_server from fastapi_voyager.version import __version__ from fastapi_voyager.voyager import Voyager logger = logging.getLogger(__name__) # Framework type constants SUPPORTED_FRAMEWORKS = ["fastapi", "litestar", "django-ninja"] def load_app_from_file(module_path: str, app_name: str = "app", framework: str | None = None) -> Any: """Load web framework app from a Python module file.""" try: # Convert relative path to absolute path if not os.path.isabs(module_path): module_path = os.path.abspath(module_path) # Load the module spec = importlib.util.spec_from_file_location("app_module", module_path) if spec is None or spec.loader is None: logger.error(f"Could not load module from {module_path}") return None module = importlib.util.module_from_spec(spec) sys.modules["app_module"] = module spec.loader.exec_module(module) # Get the app instance if not hasattr(module, app_name): logger.error(f"No attribute '{app_name}' found in the module") return None app = getattr(module, app_name) # Verify app type if framework is specified if framework is not None: if not _validate_app_framework(app, framework): logger.error(f"'{app_name}' is not a {framework} instance") return None return app except Exception as e: logger.error(f"Error loading app: {e}") return None def load_app_from_module(module_name: str, app_name: str = "app", framework: str | None = None) -> Any: """Load web framework app from a Python module name.""" try: # Temporarily add the current working directory to sys.path current_dir = os.getcwd() if current_dir not in sys.path: sys.path.insert(0, current_dir) path_added = True else: path_added = False try: # Import the module by name module = importlib.import_module(module_name) # Get the app instance if not hasattr(module, app_name): logger.error(f"No attribute '{app_name}' found in module '{module_name}'") return None app = getattr(module, app_name) # Verify app type if framework is specified if framework is not None: if not _validate_app_framework(app, framework): logger.error(f"'{app_name}' is not a {framework} instance") return None return app finally: # Cleanup: if we added the path, remove it if path_added and current_dir in sys.path: sys.path.remove(current_dir) except ImportError as e: logger.error(f"Could not import module '{module_name}': {e}") return None except Exception as e: logger.error(f"Error loading app from module '{module_name}': {e}") return None def _validate_app_framework(app: Any, framework: str) -> bool: """Validate that the app matches the expected framework type.""" try: if framework == "fastapi": from fastapi import FastAPI return isinstance(app, FastAPI) elif framework == "litestar": from litestar import Litestar return isinstance(app, Litestar) elif framework == "django-ninja": from ninja import NinjaAPI return isinstance(app, NinjaAPI) return False except ImportError as e: logger.error( f"The {framework} package is not installed. " f"Install it with: uv add fastapi-voyager[{framework}]" ) logger.debug(f"Import error details: {e}") return False def generate_visualization( app: Any, output_file: str = "router_viz.dot", tags: list[str] | None = None, schema: str | None = None, show_fields: bool = False, module_color: dict[str, str] | None = None, route_name: str | None = None, ): """Generate DOT file for API router visualization.""" analytics = Voyager( include_tags=tags, schema=schema, show_fields=show_fields, module_color=module_color, route_name=route_name, ) analytics.analysis(app) dot_content = analytics.render_dot() # Optionally write to file with open(output_file, 'w', encoding='utf-8') as f: f.write(dot_content) logger.info(f"DOT file generated: {output_file}") logger.info("To render the graph, use: dot -Tpng router_viz.dot -o router_viz.png") logger.info("Or view online: https://dreampuf.github.io/GraphvizOnline/") def main(): """Main CLI entry point.""" parser = argparse.ArgumentParser( description="Visualize web application's routing tree and dependencies (supports FastAPI, Litestar, Django-Ninja)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: voyager app.py --web fastapi # Load 'app' from app.py (FastAPI) voyager app.py --web litestar # Load 'app' from app.py (Litestar) voyager -m tests.demo --web django-ninja # Load 'app' from demo module (Django-Ninja) voyager -m tests.demo --app=api --web fastapi # Load 'api' from tests.demo voyager -m tests.demo --web fastapi --schema=NodeA # filter nodes by schema name voyager -m tests.demo --web fastapi --tags=page restful # filter routes by tags voyager -m tests.demo --web fastapi --module_color=tests.demo:red --module_color=tests.service:yellow voyager -m tests.demo --web fastapi -o my_graph.dot # Output to my_graph.dot voyager -m tests.demo --web fastapi --server # start a local server to preview voyager -m tests.demo --web fastapi --server --port=8001 # start a local server to preview """ ) # Create mutually exclusive group for module loading options group = parser.add_mutually_exclusive_group(required=False) group.add_argument( "module", nargs="?", help="Python file containing the web application" ) group.add_argument( "-m", "--module", dest="module_name", help="Python module name containing the web application (like python -m)" ) parser.add_argument( "--web", choices=SUPPORTED_FRAMEWORKS, help="Web framework type (required when using --server): fastapi, litestar, django-ninja" ) parser.add_argument( "--app", "-a", default="app", help="Name of the app variable (default: app)" ) parser.add_argument( "--output", "-o", default="router_viz.dot", help="Output DOT file name (default: router_viz.dot)" ) parser.add_argument( "--server", action="store_true", help="Start a local server to preview the generated DOT graph" ) parser.add_argument( "--port", type=int, default=8000, help="Port for the preview server when --server is used (default: 8000)" ) parser.add_argument( "--host", type=str, default="127.0.0.1", help="Host/IP for the preview server when --server is used (default: 127.0.0.1). Use 0.0.0.0 to listen on all interfaces." ) parser.add_argument( "--module_prefix", type=str, default=None, help="Prefix routes with module name when rendering brief view (only valid with --server)" ) parser.add_argument( "--version", "-v", action="version", version=f"fastapi-voyager {__version__}" ) parser.add_argument( "--tags", nargs="+", help="Only include routes whose first tag is in the provided list" ) parser.add_argument( "--module_color", action="append", metavar="KEY:VALUE", help="Module color mapping as key1:value1 key2:value2 (module name to Graphviz color)" ) # removed service_prefixes option parser.add_argument( "--schema", default=None, help="Filter schemas by name" ) parser.add_argument( "--show_fields", choices=["single", "object", "all"], default="object", help="Field display mode: single (no fields), object (only object-like fields), all (all fields). Default: object" ) parser.add_argument( "--route_name", type=str, default=None, help="Filter by route id (format: _)" ) parser.add_argument( "--log-level", dest="log_level", default="INFO", help="Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)" ) args = parser.parse_args() # Validate arguments if args.module_prefix and not args.server: parser.error("--module_prefix can only be used together with --server") if not (args.module_name or args.module): parser.error("You must provide a module file or -m module name") # When --server is used, --web is required if args.server and not args.web: parser.error("--web is required when using --server. Please specify: fastapi, litestar, or django-ninja") # Determine the framework (default to the one specified, or None for non-server mode) framework = args.web if args.server else None # Configure logging based on --log-level level_name = (args.log_level or "INFO").upper() logging.basicConfig(level=level_name) # Load app based on the input method (module_name takes precedence) if args.module_name: app = load_app_from_module(args.module_name, args.app, framework) else: if not os.path.exists(args.module): logger.error(f"File '{args.module}' not found") sys.exit(1) app = load_app_from_file(args.module, args.app, framework) if app is None: sys.exit(1) # helper: parse KEY:VALUE pairs into dict def parse_kv_pairs(pairs: list[str] | None) -> dict[str, str] | None: if not pairs: return None result: dict[str, str] = {} for item in pairs: if ":" in item: k, v = item.split(":", 1) k = k.strip() v = v.strip() if k: result[k] = v return result or None try: module_color = parse_kv_pairs(args.module_color) if args.server: # Build a preview server using the appropriate framework try: import uvicorn except ImportError: logger.info("uvicorn is required to run the server. Install via 'pip install uvicorn' or 'uv add uvicorn'.") sys.exit(1) # Create voyager app - it auto-detects framework and returns appropriate app type app_server = viz_server.create_voyager( app, module_color=module_color, module_prefix=args.module_prefix, server_mode=True, # Enable server mode to serve at root path ) logger.info(f"Starting {args.web} preview server at http://{args.host}:{args.port} ... (Ctrl+C to stop)") uvicorn.run(app_server, host=args.host, port=args.port, log_level=level_name.lower()) else: # Generate and write dot file locally generate_visualization( app, args.output, tags=args.tags, schema=args.schema, show_fields=args.show_fields, module_color=module_color, route_name=args.route_name, ) except Exception as e: logger.info(f"Error generating visualization: {e}") sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: src/fastapi_voyager/er_diagram.py ================================================ from __future__ import annotations from logging import getLogger from pydantic import BaseModel from pydantic_resolve import Entity, ErDiagram, Relationship from fastapi_voyager.pydantic_resolve_util import extract_query_mutation_methods from fastapi_voyager.render import Renderer from fastapi_voyager.render_style import RenderConfig from fastapi_voyager.type import ( FieldInfo, FieldType, Link, LinkType, MethodInfo, PK, SchemaNode, ) from fastapi_voyager.type_helper import ( full_class_name, get_core_types, get_type_name, is_list, safe_issubclass, update_forward_refs, ) ARROR = "=>" logger = getLogger(__name__) def _get_loader_name(loader) -> str | None: """Extract loader function name (without module path).""" if loader is None: return None # loader is a callable, get its __name__ or __qualname__ name = getattr(loader, '__name__', None) or getattr(loader, '__qualname__', None) if name and '.' in name: # Return only the function name, not the full path return name.split('.')[-1] return name class DiagramRenderer(Renderer): """ Renderer for Entity-Relationship diagrams. Inherits from Renderer to reuse template system and styling. ER diagrams have simpler structure (no tags/routes), so we only need to customize the top-level DOT structure. """ def __init__( self, *, show_fields: FieldType = 'single', show_module: bool = True, theme_color: str | None = None, edge_minlen: int = 3, show_methods: bool = True, ) -> None: # Initialize parent Renderer with shared config super().__init__( show_fields=show_fields, show_module=show_module, config=RenderConfig(), # Use unified style configuration theme_color=theme_color, show_methods=show_methods, ) self.edge_minlen = edge_minlen logger.info(f'show_module: {self.show_module}') def render_link(self, link: Link) -> str: """Override link rendering for ER diagrams.""" source = self._handle_schema_anchor(link.source) target = self._handle_schema_anchor(link.target) # Build link attributes if link.style is not None: attrs = {'style': link.style} if link.label: attrs['label'] = link.label attrs['minlen'] = self.edge_minlen else: attrs = self.style.get_link_attributes(link.type) if link.label: attrs['label'] = link.label return self.template_renderer.render_template( 'dot/link.j2', source=source, target=target, attributes=self._format_link_attributes(attrs) ) def render_dot(self, nodes: list[SchemaNode], links: list[Link], spline_line=False) -> str: """ Render ER diagram as DOT format. Reuses parent's render_module_schema_content and render_link methods. Only customizes the top-level digraph structure. """ # Reuse parent's module schema rendering module_schemas_str = self.render_module_schema_content(nodes) # Reuse parent's link rendering link_str = '\n'.join(self.render_link(link) for link in links) # Render using ER diagram template return self.template_renderer.render_template( 'dot/er_diagram.j2', pad=self.style.pad, nodesep=self.style.nodesep, font=self.style.font, node_fontsize=self.style.node_fontsize, spline='line' if spline_line else None, er_cluster=module_schemas_str, links=link_str ) class VoyagerErDiagram: def __init__(self, er_diagram: ErDiagram, show_fields: FieldType = 'single', show_module: bool = False, theme_color: str | None = None, edge_minlen: int = 3, show_methods: bool = True): self.er_diagram = er_diagram self.nodes: list[SchemaNode] = [] self.node_set: dict[str, SchemaNode] = {} self.links: list[Link] = [] self.link_set: set[tuple[str, str]] = set() self.fk_set: dict[str, set[str]] = {} self.show_field = show_fields self.show_module = show_module self.theme_color = theme_color self.edge_minlen = edge_minlen self.show_methods = show_methods def generate_node_head(self, link_name: str): return f'{link_name}::{PK}' def analysis_entity(self, entity: Entity): schema = entity.kls update_forward_refs(schema) self.add_to_node_set( schema, fk_set=self.fk_set.get(full_class_name(schema)), entity_queries=entity.queries, entity_mutations=entity.mutations, ) for relationship in entity.relationships: annos = get_core_types(relationship.target) for anno in annos: if not isinstance(anno, type) or not safe_issubclass(anno, BaseModel): continue self.add_to_node_set(anno, fk_set=self.fk_set.get(full_class_name(anno))) source_name = f'{full_class_name(schema)}::f{relationship.fk}' # Build label with cardinality and loader name cardinality = f'1 {ARROR} N' if is_list(relationship.target) else f'1 {ARROR} 1' loader_name = _get_loader_name(relationship.loader) loader_fullname = ( f"{relationship.loader.__module__}.{loader_name}" if relationship.loader and loader_name else None ) label = cardinality if relationship.name: label = f'{relationship.name}\n{label}' self.add_to_link_set( source=source_name, source_origin=full_class_name(schema), target=self.generate_node_head(full_class_name(anno)), target_origin=full_class_name(anno), type='schema', label=label, style='solid' if relationship.loader else 'solid, dashed', loader_fullname=loader_fullname ) def add_to_node_set( self, schema, fk_set: set[str] | None = None, entity_queries: list | None = None, entity_mutations: list | None = None, ) -> str: """ 1. calc full_path, add to node_set 2. if duplicated, do nothing, else insert 2. return the full_path """ full_name = full_class_name(schema) if full_name not in self.node_set: # Extract queries and mutations: prefer Entity-level configs, fallback to class decorators queries, mutations = get_queries_and_mutations( schema, entity_queries=entity_queries, entity_mutations=entity_mutations, ) # skip meta info for normal queries self.node_set[full_name] = SchemaNode( id=full_name, module=schema.__module__, name=schema.__name__, fields=get_fields(schema, fk_set), is_entity=False, # Don't mark in ER diagram queries=queries, mutations=mutations ) return full_name def add_to_link_set( self, source: str, source_origin: str, target: str, target_origin: str, type: LinkType, label: str, style: str, biz: str | None = None, loader_fullname: str | None = None ) -> bool: """ 1. add link to link_set 2. if duplicated, do nothing, else insert """ pair = (source, target, biz) if result := pair not in self.link_set: self.link_set.add(pair) self.links.append(Link( source=source, source_origin=source_origin, target=target, target_origin=target_origin, type=type, label=label, style=style, loader_fullname=loader_fullname )) return result def render_dot(self): self.fk_set = { full_class_name(entity.kls): set([rel.fk for rel in entity.relationships]) for entity in self.er_diagram.entities } for entity in self.er_diagram.entities: self.analysis_entity(entity) renderer = DiagramRenderer( show_fields=self.show_field, show_module=self.show_module, theme_color=self.theme_color, edge_minlen=self.edge_minlen, show_methods=self.show_methods, ) return renderer.render_dot(list(self.node_set.values()), self.links) def get_fields(schema: type[BaseModel], fk_set: set[str] | None = None) -> list[FieldInfo]: fields: list[FieldInfo] = [] for k, v in schema.model_fields.items(): anno = v.annotation fields.append(FieldInfo( is_object=k in fk_set if fk_set is not None else False, name=k, from_base=False, type_name=get_type_name(anno), is_exclude=bool(v.exclude) )) return fields def get_queries_and_mutations( schema: type[BaseModel], entity_queries: list | None = None, entity_mutations: list | None = None, ) -> tuple[list[MethodInfo], list[MethodInfo]]: """Extract @query and @mutation methods from an entity. Prefers Entity-level QueryConfig/MutationConfig when available, falls back to @query/@mutation decorators on the class. """ queries: list[MethodInfo] = [] mutations: list[MethodInfo] = [] if entity_queries: for qc in entity_queries: method = qc.method name = qc.name or method.__name__ return_type = _get_return_type_str(method) queries.append(MethodInfo(name=name, return_type=return_type)) elif entity_mutations is not None: # No queries configured at entity level, skip decorator extraction pass else: # Fallback: extract from class decorators query_dicts, _ = extract_query_mutation_methods(schema) queries = [MethodInfo(name=q['name'], return_type=q['return_type']) for q in query_dicts] if entity_mutations: for mc in entity_mutations: method = mc.method name = mc.name or method.__name__ return_type = _get_return_type_str(method) mutations.append(MethodInfo(name=name, return_type=return_type)) elif entity_queries is not None: # No mutations configured at entity level, skip decorator extraction pass else: # Fallback: extract from class decorators _, mutation_dicts = extract_query_mutation_methods(schema) mutations = [MethodInfo(name=m['name'], return_type=m['return_type']) for m in mutation_dicts] return queries, mutations def _get_return_type_str(method) -> str: """Extract return type annotation string from a method.""" import inspect sig = inspect.signature(method) if sig.return_annotation != inspect.Parameter.empty: ann = sig.return_annotation if isinstance(ann, str): return ann if hasattr(ann, '__origin__'): import typing return str(ann).replace('typing.', '') return getattr(ann, '__name__', str(ann)) return '' ================================================ FILE: src/fastapi_voyager/filter.py ================================================ from __future__ import annotations from collections import deque from fastapi_voyager.type import PK, Link, Route, SchemaNode, Tag def filter_graph( *, schema: str | None, schema_field: str | None, tags: list[Tag], routes: list[Route], nodes: list[SchemaNode], links: list[Link], node_set: dict[str, SchemaNode], ) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]: """Filter tags, routes, schema nodes and links based on a target schema and optional field. Behaviour summary (mirrors previous Analytics.filter_nodes_and_schemas_based_on_schemas): 1. If `schema` is None, return inputs unmodified. 2. Seed with the schema node id (full id match). If not found, return inputs. 3. If `schema_field` provided, prune parent/subset links so that only those whose *source* schema contains that field and whose *target* is already accepted remain, recursively propagating upward. 4. Perform two traversals on the (possibly pruned) links set: - Upstream: reverse walk (collect nodes that point to current frontier) -> brings in children & entry chain. - Downstream: forward walk (collect targets from current frontier) -> brings in ancestors. 5. Keep only objects (tags, routes, nodes, links) whose origin ids are in the collected set. """ if schema is None: return tags, routes, nodes, links seed_node_ids = {n.id for n in nodes if n.id == schema} if not seed_node_ids: return tags, routes, nodes, links # Step 1: schema_field pruning logic for parent/subset links if schema_field: current_targets = set(seed_node_ids) accepted_targets = set(seed_node_ids) accepted_links: list[Link] = [] parent_subset_links = [lk for lk in links if lk.type in ("parent", "subset")] other_links = [lk for lk in links if lk.type not in ("parent", "subset")] while current_targets: next_targets: set[str] = set() for lk in parent_subset_links: if ( lk.target_origin in current_targets and lk.source_origin not in accepted_targets and lk.source_origin in node_set and lk.target_origin in node_set ): src_node = node_set.get(lk.source_origin) if src_node and any(f.name == schema_field for f in src_node.fields): accepted_links.append(lk) next_targets.add(lk.source_origin) accepted_targets.add(lk.source_origin) elif lk.target_origin in current_targets and lk.source_origin in accepted_targets: src_node = node_set.get(lk.source_origin) if src_node and any(f.name == schema_field for f in src_node.fields): if lk not in accepted_links: accepted_links.append(lk) current_targets = next_targets filtered_links = other_links + accepted_links else: filtered_links = links # Step 2: build adjacency maps fwd: dict[str, set[str]] = {} rev: dict[str, set[str]] = {} for lk in filtered_links: fwd.setdefault(lk.source_origin, set()).add(lk.target_origin) rev.setdefault(lk.target_origin, set()).add(lk.source_origin) # Upstream (reverse) traversal upstream: set[str] = set() frontier = set(seed_node_ids) while frontier: new_layer: set[str] = set() for nid in frontier: for src in rev.get(nid, ()): # src points to nid if src not in upstream and src not in seed_node_ids: new_layer.add(src) upstream.update(new_layer) frontier = new_layer # Downstream (forward) traversal downstream: set[str] = set() frontier = set(seed_node_ids) while frontier: new_layer: set[str] = set() for nid in frontier: for tgt in fwd.get(nid, ()): # nid points to tgt if tgt not in downstream and tgt not in seed_node_ids: new_layer.add(tgt) downstream.update(new_layer) frontier = new_layer included_ids: set[str] = set(seed_node_ids) | upstream | downstream _nodes = [n for n in nodes if n.id in included_ids] _links = [l for l in filtered_links if l.source_origin in included_ids and l.target_origin in included_ids] _tags = [t for t in tags if t.id in included_ids] _routes = [r for r in routes if r.id in included_ids] return _tags, _routes, _nodes, _links def filter_subgraph_by_module_prefix( *, tags: list[Tag], routes: list[Route], links: list[Link], nodes: list[SchemaNode], module_prefix: str ) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]: """Collapse schema graph so routes link directly to nodes whose module matches ``module_prefix``. The routine keeps tag→route links untouched, prunes schema nodes whose module does not start with ``module_prefix``, and merges the remaining schema relationships so each route connects directly to the surviving schema nodes. Traversal stops once a qualifying node is reached and guards against cycles in the schema graph. """ if not module_prefix: # empty prefix keeps existing graph structure, so simply reuse incoming data return tags, routes, nodes, [lk for lk in links if lk.type in ("tag_route", "route_to_schema")] route_links = [lk for lk in links if lk.type == "route_to_schema"] schema_links = [lk for lk in links if lk.type in {"schema", "parent", "subset"}] tag_route_links = [lk for lk in links if lk.type == "tag_route"] node_lookup: dict[str, SchemaNode] = {node.id: node for node in nodes} filtered_nodes = [node for node in nodes if node_lookup[node.id].module.startswith(module_prefix)] filtered_node_ids = {node.id for node in filtered_nodes} adjacency: dict[str, list[str]] = {} for link in schema_links: if link.source_origin not in node_lookup or link.target_origin not in node_lookup: continue adjacency.setdefault(link.source_origin, []) if link.target_origin not in adjacency[link.source_origin]: adjacency[link.source_origin].append(link.target_origin) merged_links: list[Link] = [] seen_pairs: set[tuple[str, str]] = set() for link in route_links: route_id = link.source_origin start_node_id = link.target_origin if route_id is None or start_node_id is None: continue if start_node_id not in node_lookup: continue visited: set[str] = set() queue: deque[str] = deque([start_node_id]) while queue: current = queue.popleft() if current in visited: continue visited.add(current) if current in filtered_node_ids: key = (route_id, current) if key not in seen_pairs: seen_pairs.add(key) merged_links.append( Link( source=link.source, source_origin=route_id, target=f"{current}::{PK}", target_origin=current, type="route_to_schema", ) ) # stop traversing past a qualifying node continue for next_node in adjacency.get(current, () ): if next_node not in visited: queue.append(next_node) module_prefix_links = [ lk for lk in links if (lk.source_origin or "").startswith(module_prefix) and (lk.target_origin or "").startswith(module_prefix) ] filtered_links = tag_route_links + merged_links + module_prefix_links return tags, routes, filtered_nodes, filtered_links def filter_subgraph_from_tag_to_schema_by_module_prefix( *, tags: list[Tag], routes: list[Route], links: list[Link], nodes: list[SchemaNode], module_prefix: str ) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]: """Collapse schema graph so routes link directly to nodes whose module matches ``module_prefix``. The routine keeps tag→route links untouched, prunes schema nodes whose module does not start with ``module_prefix``, and merges the remaining schema relationships so each route connects directly to the surviving schema nodes. Traversal stops once a qualifying node is reached and guards against cycles in the schema graph. """ if not module_prefix: # empty prefix keeps existing graph structure, so simply reuse incoming data return tags, routes, nodes, [lk for lk in links if lk.type in ("tag_route", "route_to_schema")] route_links = [lk for lk in links if lk.type == "route_to_schema"] schema_links = [lk for lk in links if lk.type in {"schema", "parent", "subset"}] tag_route_links = [lk for lk in links if lk.type == "tag_route"] node_lookup: dict[str, SchemaNode] = {node.id: node for node in (nodes + routes)} filtered_nodes = [node for node in nodes if node_lookup[node.id].module.startswith(module_prefix)] filtered_node_ids = {node.id for node in filtered_nodes} adjacency: dict[str, list[str]] = {} for link in (schema_links + route_links): if link.source_origin not in node_lookup or link.target_origin not in node_lookup: continue adjacency.setdefault(link.source_origin, []) if link.target_origin not in adjacency[link.source_origin]: adjacency[link.source_origin].append(link.target_origin) merged_links: list[Link] = [] seen_pairs: set[tuple[str, str]] = set() for link in tag_route_links: tag_id = link.source_origin start_node_id = link.target_origin if tag_id is None or start_node_id is None: continue if start_node_id not in node_lookup: continue visited: set[str] = set() queue: deque[str] = deque([start_node_id]) while queue: current = queue.popleft() if current in visited: continue visited.add(current) if current in filtered_node_ids: key = (tag_id, current) if key not in seen_pairs: seen_pairs.add(key) merged_links.append( Link( source=link.source, source_origin=tag_id, target=f"{current}::{PK}", target_origin=current, type="tag_to_schema", ) ) # stop traversing past a qualifying node continue for next_node in adjacency.get(current, () ): if next_node not in visited: queue.append(next_node) module_prefix_links = [ lk for lk in links if (lk.source_origin or "").startswith(module_prefix) and (lk.target_origin or "").startswith(module_prefix) ] filtered_links = merged_links + module_prefix_links return tags, [], filtered_nodes, filtered_links # route is skipped ================================================ FILE: src/fastapi_voyager/introspectors/__init__.py ================================================ """ Introspectors for different web frameworks. This package contains built-in introspector implementations for various frameworks. """ from .base import AppIntrospector, RouteInfo from .detector import FrameworkType, detect_framework, get_introspector # Try to import each introspector, but don't fail if the framework isn't installed try: from .fastapi import FastAPIIntrospector except ImportError: FastAPIIntrospector = None # type: ignore try: from .django_ninja import DjangoNinjaIntrospector except ImportError: DjangoNinjaIntrospector = None # type: ignore try: from .litestar import LitestarIntrospector except ImportError: LitestarIntrospector = None # type: ignore __all__ = [ "AppIntrospector", "RouteInfo", "FastAPIIntrospector", "DjangoNinjaIntrospector", "LitestarIntrospector", "FrameworkType", "detect_framework", "get_introspector", ] ================================================ FILE: src/fastapi_voyager/introspectors/base.py ================================================ """ Introspection abstraction layer for framework-agnostic route analysis. This module provides the abstraction that allows fastapi-voyager to work with different web frameworks that support OpenAPI and Pydantic, such as: - FastAPI - Django Ninja - Litestar - Flask-OpenAPI """ from abc import ABC, abstractmethod from collections.abc import Callable, Iterator from dataclasses import dataclass from typing import Any @dataclass class RouteInfo: """ Standardized route information that works across different frameworks. This data class encapsulates the essential information needed by voyager to analyze and visualize routes, independent of the underlying framework. """ # Unique identifier for the route (function path) id: str # Human-readable name (function name) name: str # Module where the route handler is defined module: str # Operation ID from OpenAPI spec operation_id: str | None # List of tags associated with this route tags: list[str] # The route handler function/endpoint endpoint: Callable # Response model (should be a Pydantic BaseModel) response_model: type[Any] # Any additional framework-specific data extra: dict[str, Any] | None = None class AppIntrospector(ABC): """ Abstract base class for app introspection. Implement this class to add support for different web frameworks. The introspector is responsible for extracting route information from the framework's internal structure. """ @abstractmethod def get_routes(self) -> Iterator[RouteInfo]: """ Iterate over all available routes in the application. Yields: RouteInfo: Standardized route information Example: >>> for route in introspector.get_routes(): ... print(f"{route.id}: {route.tags}") """ pass @abstractmethod def get_swagger_url(self) -> str | None: """ Get the URL to the Swagger/OpenAPI documentation. Returns: The URL path or None if not available """ pass ================================================ FILE: src/fastapi_voyager/introspectors/detector.py ================================================ """ Framework detection utility for fastapi-voyager. This module provides a centralized framework detection mechanism that is used by both introspectors and adapters to avoid code duplication. """ from enum import Enum from typing import Any from fastapi_voyager.introspectors.base import AppIntrospector class FrameworkType(Enum): """Supported framework types.""" FASTAPI = "fastapi" DJANGO_NINJA = "django_ninja" LITESTAR = "litestar" UNKNOWN = "unknown" def detect_framework(app: Any) -> FrameworkType: """ Detect the framework type of the given application. This function uses the same detection logic as the introspector system, ensuring consistency across the codebase. Args: app: A web application instance Returns: FrameworkType: The detected framework type Note: The detection order matters: Litestar is checked before Django Ninja to avoid Django import issues. """ # If it's already an introspector, try to determine framework from it if isinstance(app, AppIntrospector): app_class_name = type(app).__name__ if "FastAPI" in app_class_name: return FrameworkType.FASTAPI elif "DjangoNinja" in app_class_name or "Ninja" in app_class_name: return FrameworkType.DJANGO_NINJA elif "Litestar" in app_class_name: return FrameworkType.LITESTAR return FrameworkType.UNKNOWN # Get the class name for type checking app_class_name = type(app).__name__ # Try FastAPI try: from fastapi import FastAPI if isinstance(app, FastAPI): return FrameworkType.FASTAPI except ImportError: pass # Try Litestar (check before Django Ninja to avoid Django import issues) try: from litestar import Litestar if isinstance(app, Litestar): return FrameworkType.LITESTAR except ImportError: pass # Try Django Ninja (check by class name first to avoid import if not needed) try: if app_class_name == "NinjaAPI": from ninja import NinjaAPI if isinstance(app, NinjaAPI): return FrameworkType.DJANGO_NINJA except ImportError: pass return FrameworkType.UNKNOWN def get_introspector(app: Any) -> AppIntrospector | None: """ Get the appropriate introspector for the given app. This is a centralized function that uses the framework detection logic to return the correct introspector instance. Args: app: A web application instance or AppIntrospector Returns: An AppIntrospector instance, or None if framework not supported Raises: TypeError: If the app type is not supported """ # If it's already an introspector, return it if isinstance(app, AppIntrospector): return app framework = detect_framework(app) if framework == FrameworkType.FASTAPI: from fastapi_voyager.introspectors import FastAPIIntrospector if FastAPIIntrospector: return FastAPIIntrospector(app) elif framework == FrameworkType.LITESTAR: from fastapi_voyager.introspectors import LitestarIntrospector if LitestarIntrospector: return LitestarIntrospector(app) elif framework == FrameworkType.DJANGO_NINJA: from fastapi_voyager.introspectors import DjangoNinjaIntrospector if DjangoNinjaIntrospector: return DjangoNinjaIntrospector(app) # If we get here, the app type is not supported raise TypeError( f"Unsupported app type: {type(app).__name__}. " f"Supported types: FastAPI, Django Ninja API, Litestar, or any AppIntrospector implementation. " f"If you're using a different framework, please implement AppIntrospector for that framework. " f"See ADAPTER_EXAMPLE.md for instructions." ) ================================================ FILE: src/fastapi_voyager/introspectors/django_ninja.py ================================================ """ Django Ninja implementation of the AppIntrospector interface. This module provides the adapter that allows fastapi-voyager to work with Django Ninja applications. """ from collections.abc import Iterator from fastapi_voyager.introspectors.base import AppIntrospector, RouteInfo class DjangoNinjaIntrospector(AppIntrospector): """ Django Ninja-specific implementation of AppIntrospector. This class extracts route information from Django Ninja's internal structure and converts it to the framework-agnostic RouteInfo format. """ def __init__(self, ninja_api, swagger_url: str | None = None): """ Initialize the Django Ninja introspector. Args: ninja_api: The Django Ninja API instance swagger_url: Optional custom URL to Swagger documentation """ self.api = ninja_api self.swagger_url = swagger_url or "/api/docs" def get_routes(self) -> Iterator[RouteInfo]: """ Iterate over all API routes in the Django Ninja application. Yields: RouteInfo: Standardized route information for each API route """ # Access the internal router structure if not hasattr(self.api, "default_router"): return router = self.api.default_router # Iterate through all path operations registered in the router if not hasattr(router, "path_operations"): return for path, path_view in router.path_operations.items(): # path_view is a PathView object with a list of operations if not hasattr(path_view, "operations"): continue for operation in path_view.operations: try: yield RouteInfo( id=self._get_route_id(operation), name=operation.view_func.__name__, module=operation.view_func.__module__, operation_id=operation.operation_id or operation.view_func.__name__, tags=operation.tags or [], endpoint=operation.view_func, response_model=self._get_response_model(operation), extra={ "methods": operation.methods, # This is a list "path": path, }, ) except (AttributeError, TypeError): # Skip routes that don't have the expected structure continue def get_swagger_url(self) -> str | None: """ Get the URL to the Swagger UI documentation. Returns: The URL path to Swagger UI """ return self.swagger_url def _get_route_id(self, operation) -> str: """ Generate a unique identifier for the route. Uses the full class path of the view function. Args: operation: The Django Ninja operation object Returns: A unique identifier string """ # Import here to avoid circular dependency from fastapi_voyager.type_helper import full_class_name return full_class_name(operation.view_func) def _get_response_model(self, operation) -> type: """ Extract the response model from the operation. Django Ninja infers response model from function's return type annotation. Args: operation: The Django Ninja operation object Returns: The response model class, or type(None) if not found """ # Django Ninja uses type hints for response models # The response_models field is always NOT_SET_TYPE, so we only check __annotations__ if hasattr(operation.view_func, "__annotations__") and "return" in operation.view_func.__annotations__: return operation.view_func.__annotations__["return"] # No response model found return type(None) # type: ignore ================================================ FILE: src/fastapi_voyager/introspectors/fastapi.py ================================================ """ FastAPI implementation of the AppIntrospector interface. This module provides the adapter that allows fastapi-voyager to work with FastAPI applications. """ from collections.abc import Iterator from typing import TYPE_CHECKING, Any from fastapi_voyager.introspectors.base import AppIntrospector, RouteInfo if TYPE_CHECKING: from fastapi import FastAPI class FastAPIIntrospector(AppIntrospector): """ FastAPI-specific implementation of AppIntrospector. This class extracts route information from FastAPI's internal route structure and converts it to the framework-agnostic RouteInfo format. """ def __init__(self, app: "FastAPI", swagger_url: str | None = None): """ Initialize the FastAPI introspector. Args: app: The FastAPI application instance swagger_url: Optional custom URL to Swagger documentation """ # Lazy import to avoid import errors when FastAPI is not installed from fastapi import FastAPI if not isinstance(app, FastAPI): raise TypeError(f"Expected FastAPI instance, got {type(app)}") self.app = app self.swagger_url = swagger_url or "/docs" def get_routes(self) -> Iterator[RouteInfo]: """ Iterate over all API routes in the FastAPI application. Yields: RouteInfo: Standardized route information for each API route """ # Lazy import routing to avoid import errors when FastAPI is not installed from fastapi import routing for route in self.app.routes: # Only process APIRoute instances (not static files, etc.) if isinstance(route, routing.APIRoute): # Extract tags from the route tags = getattr(route, 'tags', None) or [] yield RouteInfo( id=self._get_route_id(route), name=route.endpoint.__name__, module=route.endpoint.__module__, operation_id=route.operation_id, tags=tags, endpoint=route.endpoint, response_model=route.response_model, extra={ 'unique_id': route.unique_id, 'methods': route.methods, 'path': route.path, } ) def get_swagger_url(self) -> str | None: """ Get the URL to the Swagger UI documentation. Returns: The URL path to Swagger UI """ return self.swagger_url def _get_route_id(self, route: Any) -> str: """ Generate a unique identifier for the route. Uses the full class path of the endpoint function. Args: route: The FastAPI route object Returns: A unique identifier string """ # Import here to avoid circular dependency from fastapi_voyager.type_helper import full_class_name return full_class_name(route.endpoint) ================================================ FILE: src/fastapi_voyager/introspectors/litestar.py ================================================ """ Litestar implementation of the AppIntrospector interface. This module provides the adapter that allows fastapi-voyager to work with Litestar applications. """ from collections.abc import Iterator from fastapi_voyager.introspectors.base import AppIntrospector, RouteInfo class LitestarIntrospector(AppIntrospector): """ Litestar-specific implementation of AppIntrospector. This class extracts route information from Litestar's internal structure and converts it to the framework-agnostic RouteInfo format. """ def __init__(self, app, swagger_url: str | None = None): """ Initialize the Litestar introspector. Args: app: The Litestar application instance swagger_url: Optional custom URL to Swagger/OpenAPI documentation """ self.app = app self.swagger_url = swagger_url or "/schema/swagger" def get_routes(self) -> Iterator[RouteInfo]: """ Iterate over all routes in the Litestar application. Yields: RouteInfo: Standardized route information for each route """ for route in self.app.routes: try: # Skip routes without path or methods if not hasattr(route, "path") or not hasattr(route, "methods"): continue # Skip Litestar's auto-generated schema routes if hasattr(route, "path") and route.path.startswith("/schema"): continue # Get the handler function from route_handlers handler = None handler_obj = None if hasattr(route, "route_handlers") and route.route_handlers: # Find the GET handler (or any non-OPTIONS handler) for route_handler in route.route_handlers: if hasattr(route_handler, "fn") and hasattr(route_handler.fn, "__name__"): # Store the route handler object for tags if hasattr(route_handler, "http_methods") and "GET" in route_handler.http_methods: handler_obj = route_handler handler = route_handler.fn if handler_obj: break if not handler: continue # Skip handlers with names starting with _ (internal/private) if hasattr(handler, "__name__") and handler.__name__.startswith("_"): continue # Extract tags from the route handler object tags = [] if handler_obj and hasattr(handler_obj, "tags") and handler_obj.tags: tags = list(handler_obj.tags) # Get return type from handler's annotations return_model = type(None) if hasattr(handler, "__annotations__") and "return" in handler.__annotations__: return_model = handler.__annotations__["return"] yield RouteInfo( id=self._get_route_id(handler), name=handler.__name__, module=handler.__module__, operation_id=self._get_operation_id(route, handler), tags=tags, endpoint=handler, response_model=return_model, extra={ "methods": list(route.methods) if hasattr(route, "methods") else [], "path": route.path, }, ) except (AttributeError, TypeError): # Skip routes that don't have the expected structure continue def get_swagger_url(self) -> str | None: """ Get the URL to the Swagger/OpenAPI documentation. Returns: The URL path to Swagger UI """ return self.swagger_url def _get_route_id(self, handler) -> str: """ Generate a unique identifier for the route. Uses the full module path of the handler function. Args: handler: The route handler function Returns: A unique identifier string """ # Import here to avoid circular dependency from fastapi_voyager.type_helper import full_class_name return full_class_name(handler) def _get_operation_id(self, route, handler) -> str: """ Extract or generate the operation ID for the route. Args: route: The Litestar route object handler: The handler function Returns: An operation ID string """ # Litestar might not have operation_id, so we generate one if hasattr(route, "operation_id"): return route.operation_id # Fallback to using the handler function name if hasattr(handler, "__name__"): return handler.__name__ # Fallback to using the path if hasattr(route, "path"): return route.path return "" def _get_response_model(self, route) -> type: """ Extract the response model from the route. Args: route: The Litestar route object Returns: The response model class """ # Try to get response model from route if hasattr(route, "responses"): responses = route.responses if responses and "200" in responses: response_200 = responses["200"] if hasattr(response_200, "model"): return response_200.model # Fallback: check if handler has return annotation handler = route.handler if hasattr(route, "handler") else None if handler and hasattr(handler, "__annotations__") and "return" in handler.__annotations__: return handler.__annotations__["return"] # Return None if no response model found return type(None) # type: ignore ================================================ FILE: src/fastapi_voyager/module.py ================================================ from collections.abc import Callable from typing import Any, TypeVar from fastapi_voyager.type import ModuleNode, ModuleRoute, Route, SchemaNode N = TypeVar('N') # Node type: ModuleNode or ModuleRoute I = TypeVar('I') # Item type: SchemaNode or Route def _build_module_tree( items: list[I], *, get_module_path: Callable[[I], str | None], NodeClass: type[N], item_list_attr: str, ) -> list[N]: """ Generic builder that groups items by dotted module path into a tree of NodeClass. NodeClass must accept kwargs: name, fullname, modules(list), and an item list via item_list_attr (e.g., 'schema_nodes' or 'routes'). """ # Map from top-level module name to node top_modules: dict[str, N] = {} # Items without module path root_level_items: list[I] = [] def make_node(name: str, fullname: str) -> N: kwargs: dict[str, Any] = { 'name': name, 'fullname': fullname, 'modules': [], item_list_attr: [], } return NodeClass(**kwargs) # type: ignore[arg-type] def get_or_create(child_name: str, parent: N) -> N: for m in parent.modules: if m.name == child_name: return m parent_full = parent.fullname fullname = child_name if not parent_full or parent_full == "__root__" else f"{parent_full}.{child_name}" new_node = make_node(child_name, fullname) parent.modules.append(new_node) return new_node # Build the tree for it in items: module_path = get_module_path(it) or "" if not module_path: root_level_items.append(it) continue parts = module_path.split('.') top_name = parts[0] if top_name not in top_modules: top_modules[top_name] = make_node(top_name, top_name) current = top_modules[top_name] for part in parts[1:]: current = get_or_create(part, current) getattr(current, item_list_attr).append(it) result: list[N] = list(top_modules.values()) if root_level_items: result.append(make_node("__root__", "__root__")) setattr(result[-1], item_list_attr, root_level_items) # Collapse linear chains: no items on node and exactly one child module def collapse(node: N) -> None: while len(node.modules) == 1 and len(getattr(node, item_list_attr)) == 0: child = node.modules[0] node.name = f"{node.name}.{child.name}" node.fullname = child.fullname setattr(node, item_list_attr, getattr(child, item_list_attr)) node.modules = child.modules for m in node.modules: collapse(m) for top in result: collapse(top) return result def build_module_schema_tree(schema_nodes: list[SchemaNode]) -> list[ModuleNode]: """Build a module tree for schema nodes, grouped by their module path.""" return _build_module_tree( schema_nodes, get_module_path=lambda sn: sn.module, NodeClass=ModuleNode, item_list_attr='schema_nodes', ) def build_module_route_tree(routes: list[Route]) -> list[ModuleRoute]: """Build a module tree for routes, grouped by their module path.""" return _build_module_tree( routes, get_module_path=lambda r: r.module, NodeClass=ModuleRoute, item_list_attr='routes', ) ================================================ FILE: src/fastapi_voyager/pydantic_resolve_util.py ================================================ import inspect import pydantic_resolve.constant as const from pydantic import BaseModel from pydantic.fields import FieldInfo from pydantic_resolve.utils.collector import ICollector, SendToInfo from pydantic_resolve.utils.er_diagram import LoaderInfo from pydantic_resolve.utils.expose import ExposeInfo def analysis_pydantic_resolve_fields(schema: type[BaseModel], field: str): """ get information for pydantic resolve specific info in future, this function will be provide by pydantic-resolve package is_resolve: bool = False - check existence of def resolve_{field} method - check existence of LoaderInfo in field.metadata is_post: bool = False - check existence of def post_{field} method expose_as_info: str | None = None - check ExposeInfo in field.metadata - check field in schema.__pydantic_resolve_expose__ (const.EXPOSE_TO_DESCENDANT) send_to_info: list[str] | None = None - check SendToInfo in field.metadata - check field in schema.__pydantic_resolve_collect__ (const.COLLECTOR_CONFIGURATION) collect_info: list[str] | None = None - 1. check existence of def post_{field} method - 2. get the signature of this method - 3. extrace the collector names from the parameters with ICollector metadata return dict in form of { "is_resolve": True, ... } """ has_meta = False field_info: FieldInfo = schema.model_fields.get(field) is_resolve = hasattr(schema, f'{const.RESOLVE_PREFIX}{field}') is_post = hasattr(schema, f'{const.POST_PREFIX}{field}') expose_as_info = None send_to_info = None post_collector = [] send_to_info_list = [] if field_info: # Check metadata for meta in field_info.metadata: if isinstance(meta, LoaderInfo): is_resolve = True if isinstance(meta, ExposeInfo): expose_as_info = meta.alias if isinstance(meta, SendToInfo): if isinstance(meta.collector_name, str): send_to_info_list.append(meta.collector_name) else: send_to_info_list.extend(list(meta.collector_name)) # Check class attributes expose_dict = getattr(schema, const.EXPOSE_TO_DESCENDANT, {}) if field in expose_dict: expose_as_info = expose_dict[field] collect_dict = getattr(schema, const.COLLECTOR_CONFIGURATION, {}) for keys, collectors in collect_dict.items(): target_keys = [keys] if isinstance(keys, str) else list(keys) if field in target_keys: if isinstance(collectors, str): send_to_info_list.append(collectors) else: send_to_info_list.extend(list(collectors)) if send_to_info_list: send_to_info = list(set(send_to_info_list)) # unique collectors if is_post: post_method = getattr(schema, f'{const.POST_PREFIX}{field}') for _, param in inspect.signature(post_method).parameters.items(): if isinstance(param.default, ICollector): post_collector.append(param.default.alias) has_meta = any([is_resolve, is_post, expose_as_info, send_to_info]) return { "has_pydantic_resolve_meta": has_meta, "is_resolve": is_resolve, "is_post": is_post, "expose_as_info": expose_as_info, "send_to_info": send_to_info, "collect_info": None if len(post_collector) == 0 else post_collector } def extract_query_mutation_methods(entity: type) -> tuple[list[dict], list[dict]]: """ Extract all @query and @mutation decorated methods from an Entity. Returns: A tuple of (queries, mutations), each is a list of dicts: - name: GraphQL name (from decorator or method name) - return_type: Return type annotation as string Each list is sorted alphabetically by name. """ # Lazy import to avoid circular dependency from fastapi_voyager.type_helper import get_type_name queries = [] mutations = [] for name, method in entity.__dict__.items(): # Handle classmethod - access underlying function actual_method = method if isinstance(method, classmethod): actual_method = method.__func__ is_query = hasattr(actual_method, '_pydantic_resolve_query') is_mutation = hasattr(actual_method, '_pydantic_resolve_mutation') if is_query or is_mutation: # Get GraphQL name if is_query: gql_name = getattr(actual_method, '_pydantic_resolve_query_name', None) else: gql_name = getattr(actual_method, '_pydantic_resolve_mutation_name', None) # Use method name if no GraphQL name specified display_name = gql_name or name # Get return type from signature return_type = 'Unknown' try: sig = inspect.signature(actual_method) if sig.return_annotation != inspect.Signature.empty: return_type = get_type_name(sig.return_annotation) except Exception: pass method_info = { 'name': display_name, 'return_type': return_type } if is_query: queries.append(method_info) else: mutations.append(method_info) # Sort each list alphabetically by name queries.sort(key=lambda m: m['name']) mutations.sort(key=lambda m: m['name']) return queries, mutations ================================================ FILE: src/fastapi_voyager/render.py ================================================ """ Render FastAPI application structure to DOT format using Jinja2 templates. """ from logging import getLogger from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape from fastapi_voyager.module import build_module_route_tree, build_module_schema_tree from fastapi_voyager.render_style import RenderConfig from fastapi_voyager.type import ( FieldInfo, FieldType, Link, MethodInfo, ModuleNode, ModuleRoute, PK, Route, SchemaNode, Tag, ) from typing import Literal logger = getLogger(__name__) # Get the template directory relative to this file TEMPLATE_DIR = Path(__file__).parent / "templates" class TemplateRenderer: """ Jinja2-based template renderer for DOT and HTML templates. """ def __init__(self, template_dir: Path = TEMPLATE_DIR): # Initialize Jinja2 environment self.env = Environment( loader=FileSystemLoader(template_dir), autoescape=select_autoescape(), trim_blocks=True, lstrip_blocks=True, ) def render_template(self, template_name: str, **context) -> str: """Render a template with the given context.""" template = self.env.get_template(template_name) return template.render(**context) class Renderer: """ Render FastAPI application structure to DOT format. This class handles the conversion of tags, routes, schemas, and links into Graphviz DOT format, with support for custom styling and filtering. """ def __init__( self, *, show_fields: FieldType = 'single', module_color: dict[str, str] | None = None, schema: str | None = None, show_module: bool = True, show_pydantic_resolve_meta: bool = False, config: RenderConfig | None = None, theme_color: str | None = None, show_methods: bool = True, ) -> None: self.show_fields = show_fields if show_fields in ('single', 'object', 'all') else 'single' self.module_color = module_color or {} self.schema = schema self.show_module = show_module self.show_pydantic_resolve_meta = show_pydantic_resolve_meta self.show_methods = show_methods # Use provided config or create default self.config = config or RenderConfig() self.colors = self.config.colors self.style = self.config.style # Framework theme color (overrides default primary color) self.theme_color = theme_color or self.colors.primary # Initialize template renderer self.template_renderer = TemplateRenderer() logger.info(f'show_module: {self.show_module}') logger.info(f'module_color: {self.module_color}') def _render_pydantic_meta_parts(self, field: FieldInfo) -> list[str]: """Render pydantic-resolve metadata as HTML parts.""" if not self.show_pydantic_resolve_meta: return [] parts = [] if field.is_resolve: parts.append( self.template_renderer.render_template( 'html/colored_text.j2', text='● resolve', color=self.colors.resolve ) ) if field.is_post: parts.append( self.template_renderer.render_template( 'html/colored_text.j2', text='● post', color=self.colors.post ) ) if field.expose_as_info: parts.append( self.template_renderer.render_template( 'html/colored_text.j2', text=f'● expose as: {field.expose_as_info}', color=self.colors.expose_as ) ) if field.send_to_info: to_collectors = ', '.join(field.send_to_info) parts.append( self.template_renderer.render_template( 'html/colored_text.j2', text=f'● send to: {to_collectors}', color=self.colors.send_to ) ) if field.collect_info: defined_collectors = ', '.join(field.collect_info) parts.append( self.template_renderer.render_template( 'html/colored_text.j2', text=f'● collectors: {defined_collectors}', color=self.colors.collector ) ) return parts def _render_schema_field( self, field: FieldInfo, max_type_length: int | None = None ) -> str: """Render a single schema field.""" max_len = max_type_length or self.config.max_type_length # Truncate type name if too long type_name = field.type_name if len(type_name) > max_len: type_name = type_name[:max_len] + self.config.type_suffix # Format field display field_text = f'{field.name}: {type_name}' # Render pydantic metadata meta_parts = self._render_pydantic_meta_parts(field) meta_html = self.template_renderer.render_template( 'html/pydantic_meta.j2', meta_parts=meta_parts ) # Render field text (with strikethrough if excluded) text_html = self.template_renderer.render_template( 'html/colored_text.j2', text=field_text, color='#000', # Default color strikethrough=field.is_exclude ) # Combine field text and metadata content = f' {text_html} {meta_html}' # Render the table row return self.template_renderer.render_template( 'html/schema_field_row.j2', port=field.name, align='left', content=content ) def _render_schema_method(self, method: MethodInfo, type: Literal['query', 'mutation']) -> str: """Render a single method row for @query or @mutation.""" # Format: [Q] name: type or [M] name: type prefix = '[Q]' if type == 'query' else '[M]' color = self.colors.query if type == 'query' else self.colors.mutation # Truncate return type if too long return_type = method.return_type if len(return_type) > self.config.max_type_length: return_type = return_type[:self.config.max_type_length] + self.config.type_suffix method_text = f'{prefix} {method.name}: {return_type}' # Render method text with color text_html = self.template_renderer.render_template( 'html/colored_text.j2', text=method_text, color=color ) content = f' {text_html} ' return self.template_renderer.render_template( 'html/schema_field_row.j2', port=None, # No port needed for methods align='left', content=content ) def _get_filtered_fields(self, node: SchemaNode) -> list[FieldInfo]: """Get fields filtered by show_fields and show_pydantic_resolve_meta settings.""" # Filter fields based on pydantic-resolve meta setting if self.show_pydantic_resolve_meta: fields = [n for n in node.fields if n.has_pydantic_resolve_meta or not n.from_base] else: fields = [n for n in node.fields if not n.from_base] # Further filter by show_fields setting if self.show_fields == 'all': return fields elif self.show_fields == 'object': if self.show_pydantic_resolve_meta: # Show object fields or fields with pydantic-resolve metadata return [f for f in fields if f.is_object or f.has_pydantic_resolve_meta] else: # Show only object fields return [f for f in fields if f.is_object] else: # 'single' return [] def render_schema_label(self, node: SchemaNode, color: str | None = None) -> str: """ Render a schema node's label as an HTML table. TODO: Improve logic with show_pydantic_resolve_meta """ fields = self._get_filtered_fields(node) # Render field rows rows = [] has_base_fields = any(f.from_base for f in node.fields) # Add inherited fields notice if needed if self.show_fields == 'all' and has_base_fields: notice = self.template_renderer.render_template( 'html/colored_text.j2', text=' Inherited Fields ... ', color=self.colors.text_gray ) rows.append( self.template_renderer.render_template( 'html/schema_field_row.j2', content=notice, align='left' ) ) # Render each field for field in fields: rows.append(self._render_schema_field(field)) # Add methods if present (in all show_fields modes) if self.show_methods and (node.queries or node.mutations): # Render queries for method in node.queries: rows.append(self._render_schema_method(method, type='query')) # Render mutations for method in node.mutations: rows.append(self._render_schema_method(method, type='mutation')) # Determine header color default_color = self.theme_color if color is None else color header_color = self.colors.highlight if node.id == self.schema else default_color # Render header header = self.template_renderer.render_template( 'html/schema_header.j2', text=node.name, bg_color=header_color, port=PK, is_entity=node.is_entity ) # Render complete table return self.template_renderer.render_template( 'html/schema_table.j2', header=header, rows=''.join(rows) ) def _handle_schema_anchor(self, source: str) -> str: """Handle schema anchor for DOT links.""" if '::' in source: a, b = source.split('::', 1) return f'"{a}":{b}' return f'"{source}"' def _format_link_attributes(self, attrs: dict) -> str: """Format link attributes for DOT format.""" return ', '.join(f'{k}="{v}"' for k, v in attrs.items()) def render_link(self, link: Link) -> str: """Render a link in DOT format.""" source = self._handle_schema_anchor(link.source) target = self._handle_schema_anchor(link.target) # Build link attributes # If link.style is explicitly set (e.g., 'solid, dashed' for ER diagrams), use it # Otherwise, get default style from configuration based on link.type if link.style is not None: attrs = {'style': link.style} if link.label: attrs['label'] = link.label # attrs['minlen'] = 3 else: attrs = self.style.get_link_attributes(link.type) if link.label: attrs['label'] = link.label return self.template_renderer.render_template( 'dot/link.j2', source=source, target=target, attributes=self._format_link_attributes(attrs) ) def render_schema_node(self, node: SchemaNode, color: str | None = None) -> str: """Render a schema node in DOT format.""" label = self.render_schema_label(node, color) return self.template_renderer.render_template( 'dot/schema_node.j2', id=node.id, label=label, margin=self.style.node_margin ) def render_tag_node(self, tag: Tag) -> str: """Render a tag node in DOT format.""" return self.template_renderer.render_template( 'dot/tag_node.j2', id=tag.id, name=tag.name, margin=self.style.node_margin ) def render_route_node(self, route: Route) -> str: """Render a route node in DOT format.""" # Truncate response schema if too long response_schema = route.response_schema if len(response_schema) > self.config.max_type_length: response_schema = response_schema[:self.config.max_type_length] + self.config.type_suffix return self.template_renderer.render_template( 'dot/route_node.j2', id=route.id, name=route.name, response_schema=response_schema, margin=self.style.node_margin ) def _render_module_schema( self, mod: ModuleNode, module_color_flag: set[str], inherit_color: str | None = None, show_cluster: bool = True ) -> str: """Render a module schema tree.""" color = inherit_color cluster_color: str | None = None # Check if this module has a custom color for k in module_color_flag: if mod.fullname.startswith(k): module_color_flag.remove(k) color = self.module_color[k] cluster_color = color if color != inherit_color else None break # Render inner schema nodes inner_nodes = [ self.render_schema_node(node, color) for node in mod.schema_nodes ] inner_nodes_str = '\n'.join(inner_nodes) # Recursively render child modules child_str = '\n'.join( self._render_module_schema( m, module_color_flag=module_color_flag, inherit_color=color, show_cluster=show_cluster ) for m in mod.modules ) if show_cluster: # Render as a cluster cluster_id = f'module_{mod.fullname.replace(".", "_")}' pen_style = '' if cluster_color: pen_style = f'pencolor = "{cluster_color}"' pen_style += '\n' + 'penwidth = 3' if color else '' else: pen_style = 'pencolor="#ccc"' return self.template_renderer.render_template( 'dot/cluster.j2', cluster_id=cluster_id, label=mod.name, tooltip=mod.fullname, border_color=self.colors.border, pen_color=cluster_color, pen_width=3 if color and not cluster_color else None, content=f'{inner_nodes_str}\n{child_str}' ) else: # Render without cluster return f'{inner_nodes_str}\n{child_str}' def render_module_schema_content(self, nodes: list[SchemaNode]) -> str: """Render all module schemas.""" module_schemas = build_module_schema_tree(nodes) module_color_flag = set(self.module_color.keys()) return '\n'.join( self._render_module_schema( m, module_color_flag=module_color_flag, show_cluster=self.show_module ) for m in module_schemas ) def _render_module_route(self, mod: ModuleRoute, show_cluster: bool = True) -> str: """Render a module route tree.""" # Render inner route nodes inner_nodes = [self.render_route_node(r) for r in mod.routes] inner_nodes_str = '\n'.join(inner_nodes) # Recursively render child modules child_str = '\n'.join( self._render_module_route(m, show_cluster=show_cluster) for m in mod.modules ) if show_cluster: cluster_id = f'route_module_{mod.fullname.replace(".", "_")}' return self.template_renderer.render_template( 'dot/cluster.j2', cluster_id=cluster_id, label=mod.name, tooltip=mod.fullname, border_color=self.colors.border, pen_color=None, pen_width=None, content=f'{inner_nodes_str}\n{child_str}' ) else: return f'{inner_nodes_str}\n{child_str}' def render_module_route_content(self, routes: list[Route]) -> str: """Render all module routes.""" module_routes = build_module_route_tree(routes) return '\n'.join( self._render_module_route(m, show_cluster=self.show_module) for m in module_routes ) def _render_cluster_container( self, name: str, label: str, content: str, fontsize: str | None = None ) -> str: """Render a cluster container (for tags, routes, schemas).""" return self.template_renderer.render_template( 'dot/cluster_container.j2', name=name, label=label, content=content, border_color=self.colors.border, margin=self.style.cluster_margin, fontsize=fontsize or self.style.cluster_fontsize ) def render_dot( self, tags: list[Tag], routes: list[Route], nodes: list[SchemaNode], links: list[Link], spline_line: bool = False ) -> str: """ Render the complete DOT graph. Args: tags: List of tags routes: List of routes nodes: List of schema nodes links: List of links spline_line: Whether to use spline lines Returns: Complete DOT graph as a string """ # Render tag nodes tag_str = '\n'.join(self.render_tag_node(t) for t in tags) # Render tags cluster tags_cluster = self._render_cluster_container( name='tags', label='Tags', content=tag_str ) # Render routes cluster module_routes_str = self.render_module_route_content(routes) routes_cluster = self._render_cluster_container( name='router', label='Routes', content=module_routes_str ) # Render schemas cluster module_schemas_str = self.render_module_schema_content(nodes) schemas_cluster = self._render_cluster_container( name='schema', label='Schema', content=module_schemas_str ) # Render links link_str = '\n'.join(self.render_link(link) for link in links) # Render complete digraph return self.template_renderer.render_template( 'dot/digraph.j2', pad=self.style.pad, nodesep=self.style.nodesep, spline='line' if spline_line else '', font=self.style.font, node_fontsize=self.style.node_fontsize, tags_cluster=tags_cluster, routes_cluster=routes_cluster, schemas_cluster=schemas_cluster, links=link_str ) ================================================ FILE: src/fastapi_voyager/render_style.py ================================================ """ Style constants and configuration for rendering DOT graphs and HTML tables. """ from dataclasses import dataclass, field from fastapi_voyager.introspectors.detector import FrameworkType @dataclass class ColorScheme: """Color scheme for graph visualization.""" # Framework-specific theme colors (single source of truth) FRAMEWORK_COLORS: dict[FrameworkType, str] = field(default_factory=lambda: { FrameworkType.FASTAPI: '#009485', FrameworkType.DJANGO_NINJA: '#4cae4f', FrameworkType.LITESTAR: '#edb641', }) # Node colors primary: str = '#009485' highlight: str = 'tomato' # Pydantic-resolve metadata colors resolve: str = '#47a80f' post: str = '#427fa4' expose_as: str = '#895cb9' send_to: str = '#ca6d6d' collector: str = '#777' # GraphQL method colors query: str = '#47a80f' # Green for @query methods mutation: str = '#ca6d6d' # Red/coral for @mutation methods # Link colors inherit: str = 'purple' subset: str = 'orange' # Border colors border: str = '#666' cluster_border: str = '#ccc' # Text colors text_gray: str = '#999' def get_framework_color(self, framework_type: FrameworkType) -> str: """Get theme color for a specific framework type.""" return self.FRAMEWORK_COLORS.get(framework_type, self.primary) @dataclass class GraphvizStyle: """Graphviz DOT style configuration.""" # Font settings font: str = 'Helvetica,Arial,sans-serif' node_fontsize: str = '16' cluster_fontsize: str = '20' # Layout settings nodesep: str = '0.8' pad: str = '0.5' node_margin: str = '0.5,0.1' cluster_margin: str = '18' # Link styles configuration LINK_STYLES: dict[str, dict] = field(default_factory=lambda: { 'tag_route': { 'style': 'solid', 'minlen': 3, }, 'route_to_schema': { 'style': 'solid', 'dir': 'back', 'arrowtail': 'odot', 'minlen': 3, }, 'schema': { 'style': 'solid', 'label': '', 'dir': 'back', 'minlen': 3, 'arrowtail': 'odot', }, 'parent': { 'style': 'solid,dashed', 'dir': 'back', 'minlen': 3, 'taillabel': '< inherit >', 'color': 'purple', 'tailport': 'n', }, 'subset': { 'style': 'solid,dashed', 'dir': 'back', 'minlen': 3, 'taillabel': '< subset >', 'color': 'orange', 'tailport': 'n', }, 'tag_to_schema': { 'style': 'solid', 'minlen': 3, }, }) def get_link_attributes(self, link_type: str) -> dict: """Get link style attributes for a given link type.""" return self.LINK_STYLES.get(link_type, {}) @dataclass class RenderConfig: """Complete rendering configuration.""" colors: ColorScheme = field(default_factory=ColorScheme) style: GraphvizStyle = field(default_factory=GraphvizStyle) # Field display settings max_type_length: int = 25 type_suffix: str = '..' ================================================ FILE: src/fastapi_voyager/server.py ================================================ """ FastAPI-voyager server module with framework adapter support. This module provides the main `create_voyager` function that automatically detects the framework type and returns an appropriately configured voyager UI. """ from typing import Any, Literal from pydantic_resolve import ErDiagram from fastapi_voyager.adapters import DjangoNinjaAdapter, FastAPIAdapter, LitestarAdapter from fastapi_voyager.introspectors import FrameworkType, detect_framework INITIAL_PAGE_POLICY = Literal["first", "full", "empty"] def _get_adapter( target_app: Any, module_color: dict[str, str] | None = None, gzip_minimum_size: int | None = 500, module_prefix: str | None = None, swagger_url: str | None = None, online_repo_url: str | None = None, initial_page_policy: INITIAL_PAGE_POLICY = "first", ga_id: str | None = None, er_diagram: ErDiagram | None = None, enable_pydantic_resolve_meta: bool = False, server_mode: bool = False, ) -> Any: """ Get the appropriate adapter for the given target app. Automatically detects the framework type and returns the matching adapter. Args: target_app: The web application instance to introspect module_color: Optional color mapping for modules gzip_minimum_size: Minimum size for gzip compression module_prefix: Optional module prefix for filtering swagger_url: Optional custom URL to Swagger documentation online_repo_url: Optional online repository URL for source links initial_page_policy: Initial page display policy ga_id: Optional Google Analytics ID er_diagram: Optional ER diagram from pydantic-resolve enable_pydantic_resolve_meta: Enable pydantic-resolve metadata display Returns: An adapter instance for the detected framework Raises: TypeError: If the app type is not supported """ # Use centralized framework detection from introspectors framework = detect_framework(target_app) if framework == FrameworkType.FASTAPI: return FastAPIAdapter( target_app=target_app, module_color=module_color, gzip_minimum_size=gzip_minimum_size, module_prefix=module_prefix, swagger_url=swagger_url, online_repo_url=online_repo_url, initial_page_policy=initial_page_policy, ga_id=ga_id, er_diagram=er_diagram, enable_pydantic_resolve_meta=enable_pydantic_resolve_meta, server_mode=server_mode, ) elif framework == FrameworkType.LITESTAR: return LitestarAdapter( target_app=target_app, module_color=module_color, gzip_minimum_size=gzip_minimum_size, module_prefix=module_prefix, swagger_url=swagger_url, online_repo_url=online_repo_url, initial_page_policy=initial_page_policy, ga_id=ga_id, er_diagram=er_diagram, enable_pydantic_resolve_meta=enable_pydantic_resolve_meta, server_mode=server_mode, ) elif framework == FrameworkType.DJANGO_NINJA: return DjangoNinjaAdapter( target_app=target_app, module_color=module_color, gzip_minimum_size=gzip_minimum_size, # Note: ignored for Django module_prefix=module_prefix, swagger_url=swagger_url, online_repo_url=online_repo_url, initial_page_policy=initial_page_policy, ga_id=ga_id, er_diagram=er_diagram, enable_pydantic_resolve_meta=enable_pydantic_resolve_meta, server_mode=server_mode, ) # If we get here, the app type is not supported raise TypeError( f"Unsupported app type: {type(target_app).__name__}. " f"Supported types: FastAPI, Django Ninja API, Litestar. " f"If you're using a different framework, please implement a VoyagerAdapter for that framework. " f"See fastapi_voyager/adapters/ for examples." ) def create_voyager( target_app: Any, module_color: dict[str, str] | None = None, gzip_minimum_size: int | None = 500, module_prefix: str | None = None, swagger_url: str | None = None, online_repo_url: str | None = None, initial_page_policy: INITIAL_PAGE_POLICY = "first", ga_id: str | None = None, er_diagram: ErDiagram | None = None, enable_pydantic_resolve_meta: bool = False, server_mode: bool = False, ) -> Any: """ Create a voyager UI application for the given target app. This function automatically detects the framework type (FastAPI, Django Ninja, or Litestar) and returns an appropriately configured voyager UI application. For FastAPI: Returns a FastAPI app that can be mounted For Django Ninja: Returns an ASGI application For Litestar: Returns a Litestar app Args: target_app: The web application to visualize module_color: Optional color mapping for modules (e.g., {"myapp": "blue"}) gzip_minimum_size: Minimum response size for gzip compression (set to <0 to disable) module_prefix: Optional module prefix for filtering/organization swagger_url: Optional custom URL to Swagger/OpenAPI documentation online_repo_url: Optional base URL for online repository source links initial_page_policy: Initial page display policy ('first', 'full', or 'empty') ga_id: Optional Google Analytics tracking ID er_diagram: Optional ER diagram from pydantic-resolve enable_pydantic_resolve_meta: Enable display of pydantic-resolve metadata server_mode: If True, serve voyager UI at root path (for standalone preview mode) Returns: A framework-specific application object that provides the voyager UI Example: # FastAPI from fastapi import FastAPI from fastapi_voyager import create_voyager app = FastAPI() voyager_app = create_voyager(app) app.mount("/voyager", voyager_app) # Django Ninja from ninja import NinjaAPI from fastapi_voyager import create_voyager api = NinjaAPI() voyager_asgi_app = create_voyager(api) # See django_ninja tests for integration examples # Litestar from litestar import Litestar from fastapi_voyager import create_voyager app = Litestar() voyager_app = create_voyager(app) # Mount or integrate as needed """ adapter = _get_adapter( target_app=target_app, module_color=module_color, gzip_minimum_size=gzip_minimum_size, module_prefix=module_prefix, swagger_url=swagger_url, online_repo_url=online_repo_url, initial_page_policy=initial_page_policy, ga_id=ga_id, er_diagram=er_diagram, enable_pydantic_resolve_meta=enable_pydantic_resolve_meta, server_mode=server_mode, ) return adapter.create_app() ================================================ FILE: src/fastapi_voyager/templates/dot/cluster.j2 ================================================ subgraph cluster_{{ cluster_id }} { tooltip="{{ tooltip }}" color = "{{ border_color }}" style="rounded" label = " {{ label }}" labeljust = "l" {% if pen_color %}pencolor = "{{ pen_color }}"{% endif %} {% if pen_width %}penwidth = {{ pen_width }}{% endif %} {{ content }} } ================================================ FILE: src/fastapi_voyager/templates/dot/cluster_container.j2 ================================================ subgraph cluster_{{ name }} { color = "{{ border_color }}" margin={{ margin }} style="dashed" label = " {{ label }}" labeljust = "l" fontsize = {{ fontsize }} {{ content }} } ================================================ FILE: src/fastapi_voyager/templates/dot/digraph.j2 ================================================ digraph world { pad="{{ pad }}" nodesep={{ nodesep }} {% if spline %}splines={{ spline }}{% endif %} fontname="{{ font }}" node [fontname="{{ font }}"] edge [ fontname="{{ font }}" color="gray" ] graph [ rankdir = "LR" ]; node [ fontsize = {{ node_fontsize }} ]; {{ tags_cluster }} {{ routes_cluster }} {{ schemas_cluster }} {{ links }} } ================================================ FILE: src/fastapi_voyager/templates/dot/er_diagram.j2 ================================================ digraph world { pad="{{ pad }}" nodesep={{ nodesep }} {% if spline %}splines={{ spline }}{% endif %} fontname="{{ font }}" node [fontname="{{ font }}"] edge [ fontname="{{ font }}" color="gray" ] graph [ rankdir = "LR" ]; node [ fontsize = {{ node_fontsize }} ]; subgraph cluster_schema { color = "#aaa" margin=18 style="dashed" label=" ER Diagram" labeljust="l" fontsize="20" {{ er_cluster }} } {{ links }} } ================================================ FILE: src/fastapi_voyager/templates/dot/link.j2 ================================================ {{ source }} -> {{ target }} [{{ attributes }}]; ================================================ FILE: src/fastapi_voyager/templates/dot/route_node.j2 ================================================ "{{ id }}" [ label = " {{ name }} | {{ response_schema }} " margin="{{ margin }}" shape = "record" ]; ================================================ FILE: src/fastapi_voyager/templates/dot/schema_node.j2 ================================================ "{{ id }}" [ label = {{ label }} shape = "plain" margin="{{ margin }}" ]; ================================================ FILE: src/fastapi_voyager/templates/dot/tag_node.j2 ================================================ "{{ id }}" [ label = " {{ name }} " shape = "record" margin="{{ margin }}" ]; ================================================ FILE: src/fastapi_voyager/templates/html/colored_text.j2 ================================================ {% if strikethrough %}{{ text }}{% else %}{{ text }}{% endif %} ================================================ FILE: src/fastapi_voyager/templates/html/pydantic_meta.j2 ================================================ {% if meta_parts %}

{{ meta_parts | join('
') }}
{% endif %} ================================================ FILE: src/fastapi_voyager/templates/html/schema_field_row.j2 ================================================ {{ content }} ================================================ FILE: src/fastapi_voyager/templates/html/schema_header.j2 ================================================ {% if is_entity %}{{ text }} (E){% else %}{{ text }}{% endif %} ================================================ FILE: src/fastapi_voyager/templates/html/schema_table.j2 ================================================ < {{ header }} {{ rows }}
> ================================================ FILE: src/fastapi_voyager/type.py ================================================ from dataclasses import field from typing import Literal from pydantic.dataclasses import dataclass @dataclass class NodeBase: id: str name: str @dataclass class FieldInfo: name: str type_name: str from_base: bool = False is_object: bool = False is_exclude: bool = False desc: str = '' # pydantic resolve specific fields has_pydantic_resolve_meta: bool = False # overall flag is_resolve: bool = False is_post: bool = False expose_as_info: str | None = None send_to_info: list[str] | None = None collect_info: list[str] | None = None @dataclass class MethodInfo: """@query 或 @mutation 方法信息""" name: str # GraphQL 名称(来自装饰器或方法名) return_type: str # 返回类型字符串 @dataclass class Tag(NodeBase): routes: list['Route'] # route.id @dataclass class Route(NodeBase): module: str unique_id: str = '' response_schema: str = '' is_primitive: bool = True @dataclass class ModuleRoute: name: str fullname: str routes: list[Route] modules: list['ModuleRoute'] @dataclass class SchemaNode(NodeBase): module: str fields: list[FieldInfo] = field(default_factory=list) is_entity: bool = False # Mark if this is an ER diagram entity queries: list[MethodInfo] = field(default_factory=list) # @query methods mutations: list[MethodInfo] = field(default_factory=list) # @mutation methods @dataclass class ModuleNode: name: str fullname: str schema_nodes: list[SchemaNode] modules: list['ModuleNode'] # type: # - tag_route: tag -> route # - route_to_schema: route -> response model # - subset: schema -> schema (subset) # - parent: schema -> schema (inheritance) # - schema: schema -> schema (field reference) # - tag_to_schema: tag -> schema (only happens in module prefix filtering, aka brief mode) LinkType = Literal['schema', 'parent', 'tag_route', 'subset', 'route_to_schema', 'tag_to_schema'] @dataclass class Link: # node + field level links source: str target: str # node level links source_origin: str target_origin: str type: LinkType label: str | None = None style: str | None = None loader_fullname: str | None = None FieldType = Literal['single', 'object', 'all'] PK = "PK" @dataclass class CoreData: tags: list[Tag] routes: list[Route] nodes: list[SchemaNode] links: list[Link] show_fields: FieldType module_color: dict[str, str] | None = None schema: str | None = None ================================================ FILE: src/fastapi_voyager/type_helper.py ================================================ import inspect import logging import os from types import UnionType from typing import Annotated, Any, ForwardRef, Generic, Union, get_args, get_origin import pydantic_resolve.constant as const from pydantic import BaseModel from fastapi_voyager.pydantic_resolve_util import analysis_pydantic_resolve_fields from fastapi_voyager.type import FieldInfo logger = logging.getLogger(__name__) # Python <3.12 compatibility: TypeAliasType exists only from 3.12 (PEP 695) try: # pragma: no cover - import guard from typing import TypeAliasType # type: ignore except Exception: # pragma: no cover class _DummyTypeAliasType: # minimal sentinel so isinstance checks are safe pass TypeAliasType = _DummyTypeAliasType # type: ignore def is_list(annotation): return getattr(annotation, "__origin__", None) == list def full_class_name(cls): return f"{cls.__module__}.{cls.__qualname__}" def is_base_entity_subclass(schema, entity_class_names: set[str] | None = None) -> bool: """ Check if a schema is a pydantic-resolve BaseEntity entity. Checks if the class's full name is in the entity_class_names set. Args: schema: The schema class to check entity_class_names: Optional set of full class names from er_diagram.entities Returns: True if the schema is an entity, False otherwise """ if not entity_class_names: return False schema_full_name = full_class_name(schema) return schema_full_name in entity_class_names def get_core_types(tp): """ - get the core type - always return a tuple of core types """ # Helpers def _unwrap_alias(t): """Unwrap PEP 695 type aliases by following __value__ repeatedly.""" while isinstance(t, TypeAliasType) or ( t.__class__.__name__ == 'TypeAliasType' and hasattr(t, '__value__') ): try: t = t.__value__ except Exception: # pragma: no cover - defensive break return t def _enqueue(items, q): for it in items: if it is not type(None): # skip None in unions q.append(it) # Queue-based shelling to reach concrete core types queue: list[object] = [tp] result: list[object] = [] while queue: cur = queue.pop(0) if cur is type(None): continue cur = _unwrap_alias(cur) # Handle Annotated[T, ...] as a shell if get_origin(cur) is Annotated: args = get_args(cur) if args: queue.append(args[0]) continue # Handle Union / Optional / PEP 604 UnionType orig = get_origin(cur) if orig in (Union, UnionType): args = get_args(cur) # push all non-None members back for further shelling _enqueue(args, queue) continue # Handle list shells if is_list(cur): args = getattr(cur, "__args__", ()) if args: queue.append(args[0]) continue # If still an alias-like wrapper, unwrap again and re-process _cur2 = _unwrap_alias(cur) if _cur2 is not cur: queue.append(_cur2) continue # Otherwise treat as a concrete core type (could be a class, typing.Final, etc.) result.append(cur) return tuple(result) def get_type_name(anno): def name_of(tp): origin = get_origin(tp) args = get_args(tp) # Annotated[T, ...] -> T if origin is Annotated: return name_of(args[0]) if args else 'Annotated' # Union / Optional if origin is Union: non_none = [a for a in args if a is not type(None)] if len(non_none) == 1 and len(args) == 2: return f"Optional[{name_of(non_none[0])}]" return f"Union[{', '.join(name_of(a) for a in args)}]" # Parametrized generics if origin is not None: origin_name_map = { list: 'List', dict: 'Dict', set: 'Set', tuple: 'Tuple', frozenset: 'FrozenSet', } origin_name = origin_name_map.get(origin) if origin_name is None: origin_name = getattr(origin, '__name__', None) or str(origin).replace('typing.', '') if args: return f"{origin_name}[{', '.join(name_of(a) for a in args)}]" return origin_name # Non-generic leaf types if tp is Any: return 'Any' if tp is None or tp is type(None): return 'None' if isinstance(tp, type): return tp.__name__ # ForwardRef fwd = getattr(tp, '__forward_arg__', None) or getattr(tp, 'arg', None) if fwd: return str(fwd) # Fallback clean string return str(tp).replace('typing.', '').replace('', '').replace("'", '') return name_of(anno) def is_inheritance_of_pydantic_base(cls): return safe_issubclass(cls, BaseModel) and cls is not BaseModel and not is_generic_container(cls) def get_bases_fields(schemas: list[type[BaseModel]]) -> set[str]: """Collect field names from a list of BaseModel subclasses (their model_fields keys).""" fields: set[str] = set() for schema in schemas: for k, _ in getattr(schema, 'model_fields', {}).items(): fields.add(k) return fields def get_pydantic_fields(schema: type[BaseModel], bases_fields: set[str]) -> list[FieldInfo]: """Extract pydantic model fields with metadata. Parameters: schema: The pydantic BaseModel subclass to inspect. bases_fields: Set of field names that come from base classes (for from_base marking). Returns: A list of FieldInfo objects describing the schema's direct fields. """ def _is_object(anno): # internal helper, previously a method on Analytics _types = get_core_types(anno) return any(is_inheritance_of_pydantic_base(t) for t in _types if t) fields: list[FieldInfo] = [] for k, v in schema.model_fields.items(): anno = v.annotation pydantic_resolve_specific_params = analysis_pydantic_resolve_fields(schema, k) fields.append(FieldInfo( is_object=_is_object(anno), name=k, from_base=k in bases_fields, type_name=get_type_name(anno), is_exclude=bool(v.exclude), desc=v.description or '', **pydantic_resolve_specific_params )) return fields def get_vscode_link(kls, online_repo_url: str | None = None) -> str: """Build a VSCode deep link to the class definition. Priority: 1. If running inside WSL and WSL_DISTRO_NAME is present, return a remote link: vscode://vscode-remote/wsl+/: (This opens directly in the VSCode WSL remote window.) 2. Else, if path is /mnt//..., translate to Windows drive and return vscode://file/C:\\...:line 3. Else, fallback to vscode://file/:line """ try: source_file = inspect.getfile(kls) _lines, start_line = inspect.getsourcelines(kls) distro = os.environ.get("WSL_DISTRO_NAME") if online_repo_url: cwd = os.getcwd() relative_path = os.path.relpath(source_file, cwd) return f"{online_repo_url}/{relative_path}#L{start_line}" if distro: # Ensure absolute path (it should already be under /) and build remote link return f"vscode://vscode-remote/wsl+{distro}{source_file}:{start_line}" # Non-remote scenario: maybe user wants to open via translated Windows path if source_file.startswith('/mnt/') and len(source_file) > 6: parts = source_file.split('/') if len(parts) >= 4 and len(parts[2]) == 1: # drive letter drive = parts[2].upper() rest = parts[3:] win_path = drive + ':\\' + '\\'.join(rest) return f"vscode://file/{win_path}:{start_line}" # Fallback plain unix path return f"vscode://file/{source_file}:{start_line}" except Exception: return "" def get_source(kls): try: source = inspect.getsource(kls) return source except Exception: return "failed to get source" def safe_issubclass(kls, target_kls): try: return issubclass(kls, target_kls) except TypeError: # if kls is ForwardRef, log it if isinstance(kls, ForwardRef): logger.error(f'{str(kls)} is a ForwardRef, not a subclass of {target_kls.__module__}:{target_kls.__qualname__}') elif isinstance(kls, type): logger.debug(f'{kls.__module__}:{kls.__qualname__} is not subclass of {target_kls.__module__}:{target_kls.__qualname__}') return False def update_forward_refs(kls): # TODO: refactor def update_pydantic_forward_refs(pydantic_kls: type[BaseModel]): """ recursively update refs. """ pydantic_kls.model_rebuild() setattr(pydantic_kls, const.PYDANTIC_FORWARD_REF_UPDATED, True) values = pydantic_kls.model_fields.values() for field in values: update_forward_refs(field.annotation) for shelled_type in get_core_types(kls): # Only treat as updated if the flag is set on the class itself, not via inheritance local_attrs = getattr(shelled_type, '__dict__', {}) if local_attrs.get(const.PYDANTIC_FORWARD_REF_UPDATED, False): logger.debug("%s visited", shelled_type.__qualname__) continue if safe_issubclass(shelled_type, BaseModel): update_pydantic_forward_refs(shelled_type) def is_generic_container(cls): """ T = TypeVar('T') class DataModel(BaseModel, Generic[T]): data: T id: int type DataModelPageStory = DataModel[PageStory] is_generic_container(DataModel) -> True is_generic_container(DataModel[PageStory]) -> False DataModel.__parameters__ == (T,) DataModelPageStory.__parameters__ == (,) """ try: return (hasattr(cls, '__bases__') and Generic in cls.__bases__ and (hasattr(cls, '__parameters__') and bool(cls.__parameters__))) except (TypeError, AttributeError): return False def is_non_pydantic_type(tp): for schema in get_core_types(tp): if schema and safe_issubclass(schema, BaseModel): return False return True if __name__ == "__main__": from tests.demo_anno import PageOverall, PageSprint update_forward_refs(PageOverall) update_forward_refs(PageSprint) ================================================ FILE: src/fastapi_voyager/version.py ================================================ __all__ = ["__version__"] __version__ = "0.27.0" ================================================ FILE: src/fastapi_voyager/voyager.py ================================================ import pydantic_resolve.constant as const from pydantic import BaseModel from fastapi_voyager.filter import ( filter_graph, filter_subgraph_by_module_prefix, filter_subgraph_from_tag_to_schema_by_module_prefix, ) from fastapi_voyager.introspectors import AppIntrospector, RouteInfo from fastapi_voyager.render import Renderer from fastapi_voyager.type import PK, CoreData, FieldType, Link, LinkType, Route, SchemaNode, Tag from fastapi_voyager.type_helper import ( full_class_name, get_bases_fields, get_core_types, get_pydantic_fields, get_type_name, is_base_entity_subclass, is_inheritance_of_pydantic_base, is_non_pydantic_type, safe_issubclass, update_forward_refs, ) class Voyager: def __init__( self, schema: str | None = None, schema_field: str | None = None, show_fields: FieldType = 'single', include_tags: list[str] | None = None, module_color: dict[str, str] | None = None, route_name: str | None = None, hide_primitive_route: bool = False, show_module: bool = True, show_pydantic_resolve_meta: bool = False, theme_color: str | None = None, entity_class_names: set[str] | None = None, ): self.routes: list[Route] = [] self.nodes: list[SchemaNode] = [] self.node_set: dict[str, SchemaNode] = {} self.link_set: set[tuple[str, str]] = set() self.links: list[Link] = [] # store Tag by id, and also keep a list for rendering order self.tag_set: dict[str, Tag] = {} self.tags: list[Tag] = [] self.include_tags = include_tags self.schema = schema self.schema_field = schema_field self.show_fields = show_fields if show_fields in ('single','object','all') else 'object' self.module_color = module_color or {} self.route_name = route_name self.hide_primitive_route = hide_primitive_route self.show_module = show_module self.show_pydantic_resolve_meta = show_pydantic_resolve_meta self.theme_color = theme_color self.entity_class_names = entity_class_names def _get_introspector(self, app) -> AppIntrospector: """ Get the appropriate introspector for the given app. Automatically detects the framework type and returns the matching introspector. Args: app: A web application instance or AppIntrospector Returns: An AppIntrospector instance Raises: TypeError: If the app type is not supported """ from fastapi_voyager.introspectors import get_introspector return get_introspector(app) def analysis(self, app): """ Analyze routes and schemas from a web application. This method automatically detects the framework type and uses the appropriate introspector. Supported frameworks: - FastAPI (built-in) - Any framework with a custom AppIntrospector implementation Args: app: A web application instance (FastAPI, Django Ninja API, etc.) or an AppIntrospector instance for custom frameworks. 1. get routes which return pydantic schema 1.1 collect tags and routes, add links tag-> route 1.2 collect response_model and links route -> response_model 2. iterate schemas, construct the schema/model nodes and their links """ introspector = self._get_introspector(app) schemas: list[type[BaseModel]] = [] # First, group all routes by tag routes_by_tag: dict[str, list[RouteInfo]] = {} for route_info in introspector.get_routes(): # using multiple tags is harmful, it's not recommended and will not be supported route_tag = route_info.tags[0] if route_info.tags else '__default__' routes_by_tag.setdefault(route_tag, []).append(route_info) # Then filter by include_tags if provided if self.include_tags: filtered_routes_by_tag = { tag: routes for tag, routes in routes_by_tag.items() if tag in self.include_tags } else: filtered_routes_by_tag = routes_by_tag # Process filtered routes for route_tag, route_infos in filtered_routes_by_tag.items(): tag_id = f'tag__{route_tag}' tag_obj = Tag(id=tag_id, name=route_tag, routes=[]) self.tags.append(tag_obj) for route_info in route_infos: # filter by route_name (route.id) if provided if self.route_name is not None and route_info.id != self.route_name: continue is_primitive_response = is_non_pydantic_type(route_info.response_model) # filter primitive route if needed if self.hide_primitive_route and is_primitive_response: continue self.links.append( Link( source=tag_id, source_origin=tag_id, target=route_info.id, target_origin=route_info.id, type='tag_route', ) ) # Get unique_id from extra data if available unique_id = route_info.operation_id if route_info.extra and 'unique_id' in route_info.extra: unique_id = unique_id or route_info.extra['unique_id'] route_obj = Route( id=route_info.id, name=route_info.name, module=route_info.module, unique_id=unique_id, response_schema=get_type_name(route_info.response_model), is_primitive=is_primitive_response, ) self.routes.append(route_obj) tag_obj.routes.append(route_obj) # add response_models and create links from route -> response_model for schema in get_core_types(route_info.response_model): if schema and safe_issubclass(schema, BaseModel): is_primitive_response = False target_name = full_class_name(schema) self.links.append( Link( source=route_info.id, source_origin=route_info.id, target=self.generate_node_head(target_name), target_origin=target_name, type='route_to_schema', ) ) schemas.append(schema) for s in schemas: self.analysis_schemas(s) self.nodes = list(self.node_set.values()) def add_to_node_set(self, schema): """ 1. calc full_path, add to node_set 2. if duplicated, do nothing, else insert 2. return the full_path """ full_name = full_class_name(schema) bases_fields = get_bases_fields([s for s in schema.__bases__ if is_inheritance_of_pydantic_base(s)]) subset_reference = getattr(schema, const.ENSURE_SUBSET_REFERENCE, None) if subset_reference and is_inheritance_of_pydantic_base(subset_reference): bases_fields.update(get_bases_fields([subset_reference])) if full_name not in self.node_set: # skip meta info for normal queries self.node_set[full_name] = SchemaNode( id=full_name, module=schema.__module__, name=schema.__name__, fields=get_pydantic_fields(schema, bases_fields), is_entity=is_base_entity_subclass(schema, self.entity_class_names) ) return full_name def add_to_link_set( self, source: str, source_origin: str, target: str, target_origin: str, type: LinkType ) -> bool: """ 1. add link to link_set 2. if duplicated, do nothing, else insert """ pair = (source, target) if result := pair not in self.link_set: self.link_set.add(pair) self.links.append(Link( source=source, source_origin=source_origin, target=target, target_origin=target_origin, type=type )) return result def analysis_schemas(self, schema: type[BaseModel]): """ 1. cls is the source, add schema 2. pydantic fields are targets, if annotation is subclass of BaseMode, add fields and add links 3. recursively run walk_schema """ update_forward_refs(schema) self.add_to_node_set(schema) base_fields = set() # handle schema inside ensure_subset(schema) if subset_reference := getattr(schema, const.ENSURE_SUBSET_REFERENCE, None): if is_inheritance_of_pydantic_base(subset_reference): self.add_to_node_set(subset_reference) self.add_to_link_set( source=self.generate_node_head(full_class_name(schema)), source_origin=full_class_name(schema), target= self.generate_node_head(full_class_name(subset_reference)), target_origin=full_class_name(subset_reference), type='subset') self.analysis_schemas(subset_reference) # handle bases for base_class in schema.__bases__: if is_inheritance_of_pydantic_base(base_class): # collect base class field names to avoid duplicating inherited fields try: base_fields.update(getattr(base_class, 'model_fields', {}).keys()) except Exception: # be defensive in case of unconventional BaseModel subclasses pass self.add_to_node_set(base_class) self.add_to_link_set( source=self.generate_node_head(full_class_name(schema)), source_origin=full_class_name(schema), target=self.generate_node_head(full_class_name(base_class)), target_origin=full_class_name(base_class), type='parent') self.analysis_schemas(base_class) # handle fields for k, v in schema.model_fields.items(): # skip fields inherited from base classes if k in base_fields: continue annos = get_core_types(v.annotation) for anno in annos: if anno and is_inheritance_of_pydantic_base(anno): self.add_to_node_set(anno) # add f prefix to fix highlight issue in vsc graphviz interactive previewer source_name = f'{full_class_name(schema)}::f{k}' if self.add_to_link_set( source=source_name, source_origin=full_class_name(schema), target=self.generate_node_head(full_class_name(anno)), target_origin=full_class_name(anno), type='schema'): self.analysis_schemas(anno) def generate_node_head(self, link_name: str): return f'{link_name}::{PK}' def dump_core_data(self): _tags, _routes, _nodes, _links = filter_graph( schema=self.schema, schema_field=self.schema_field, tags=self.tags, routes=self.routes, nodes=self.nodes, links=self.links, node_set=self.node_set, ) return CoreData( tags=_tags, routes=_routes, nodes=_nodes, links=_links, show_fields=self.show_fields, module_color=self.module_color, schema=self.schema ) def handle_hide(self, tags, routes, links): if self.include_tags: return [], routes, [lk for lk in links if lk.type != 'tag_route'] else: return tags, routes, links def calculate_filtered_tag_and_route(self): _tags, _routes, _, _ = filter_graph( schema=self.schema, schema_field=self.schema_field, tags=self.tags, routes=self.routes, nodes=self.nodes, links=self.links, node_set=self.node_set, ) # filter tag.routes based by _routes route_ids = {r.id for r in _routes} for t in _tags: t.routes = [r for r in t.routes if r.id in route_ids] return _tags def render_dot(self): _tags, _routes, _nodes, _links = filter_graph( schema=self.schema, schema_field=self.schema_field, tags=self.tags, routes=self.routes, nodes=self.nodes, links=self.links, node_set=self.node_set, ) renderer = Renderer( show_fields=self.show_fields, module_color=self.module_color, schema=self.schema, show_module=self.show_module, show_pydantic_resolve_meta=self.show_pydantic_resolve_meta, theme_color=self.theme_color) _tags, _routes, _links = self.handle_hide(_tags, _routes, _links) return renderer.render_dot(_tags, _routes, _nodes, _links) def render_tag_level_brief_dot(self, module_prefix: str | None = None): _tags, _routes, _nodes, _links = filter_graph( schema=self.schema, schema_field=self.schema_field, tags=self.tags, routes=self.routes, nodes=self.nodes, links=self.links, node_set=self.node_set, ) _tags, _routes, _nodes, _links = filter_subgraph_by_module_prefix( module_prefix=module_prefix, tags=_tags, routes=_routes, nodes=_nodes, links=_links, ) renderer = Renderer( show_fields=self.show_fields, module_color=self.module_color, schema=self.schema, show_module=self.show_module, theme_color=self.theme_color) _tags, _routes, _links = self.handle_hide(_tags, _routes, _links) return renderer.render_dot(_tags, _routes, _nodes, _links, True) def render_overall_brief_dot(self, module_prefix: str | None = None): _tags, _routes, _nodes, _links = filter_graph( schema=self.schema, schema_field=self.schema_field, tags=self.tags, routes=self.routes, nodes=self.nodes, links=self.links, node_set=self.node_set, ) _tags, _routes, _nodes, _links = filter_subgraph_from_tag_to_schema_by_module_prefix( module_prefix=module_prefix, tags=_tags, routes=_routes, nodes=_nodes, links=_links, ) renderer = Renderer( show_fields=self.show_fields, module_color=self.module_color, schema=self.schema, show_module=self.show_module, theme_color=self.theme_color) _tags, _routes, _links = self.handle_hide(_tags, _routes, _links) return renderer.render_dot(_tags, _routes, _nodes, _links, True) ================================================ FILE: src/fastapi_voyager/web/component/demo.js ================================================ const { defineComponent, computed } = window.Vue import { store } from "../store.js" export default defineComponent({ name: "Demo", emits: ["close"], setup() { return { store } }, template: `

Count: {{ store.state.item.count }}

`, }) ================================================ FILE: src/fastapi_voyager/web/component/loader-code-display.js ================================================ const { defineComponent, ref, watch, onMounted } = window.Vue export default defineComponent({ name: "LoaderCodeDisplay", props: { loaderFullname: { type: String, default: null }, sourceEntity: { type: String, default: null }, targetEntity: { type: String, default: null }, label: { type: String, default: null }, }, setup(props) { const code = ref("") const link = ref("") const error = ref("") const loading = ref(false) async function highlightLater() { requestAnimationFrame(() => { try { if (window.hljs) { const block = document.querySelector(".frv-loader-display pre code.language-python") if (block) { if (block.dataset && block.dataset.highlighted) { block.removeAttribute("data-highlighted") } window.hljs.highlightElement(block) } } } catch (e) { console.warn("highlight failed", e) } }) } function resetState() { code.value = "" link.value = "" error.value = null loading.value = true } async function loadSource() { if (!props.loaderFullname) return resetState() const payload = { schema_name: props.loaderFullname } try { const resp = await fetch(`source`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(payload), }) const data = await resp.json().catch(() => ({})) if (resp.ok) { code.value = data.source_code || "# no source code available" } else { error.value = (data && data.error) || "Failed to load source" } const resp2 = await fetch(`vscode-link`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(payload), }) const data2 = await resp2.json().catch(() => ({})) if (resp2.ok) { link.value = data2.link || "" } } catch (e) { error.value = "Failed to load source" } finally { loading.value = false highlightLater() } } watch( () => props.loaderFullname, () => { if (props.loaderFullname) { loadSource() } } ) onMounted(() => { if (props.loaderFullname) { loadSource() } }) function shortName(fullname) { if (!fullname) return "" const parts = fullname.split(".") return parts[parts.length - 1] } return { code, link, error, loading, shortName } }, template: `

{{ shortName(sourceEntity) }} → {{ shortName(targetEntity) }}

{{ label }}

{{ loaderFullname }}

Open in VSCode
{{ error }}
{{ code }}
`, }) ================================================ FILE: src/fastapi_voyager/web/component/render-graph.js ================================================ import { GraphUI } from "../graph-ui.js" const { defineComponent, ref, onMounted, nextTick } = window.Vue export default defineComponent({ name: "RenderGraph", props: { coreData: { type: [Object, Array], required: false, default: null }, }, emits: ["close"], setup(props, { emit }) { const containerId = `graph-render-${Math.random().toString(36).slice(2, 9)}` const hasRendered = ref(false) const loading = ref(false) let graphInstance = null async function ensureGraph() { await nextTick() if (!graphInstance) { graphInstance = new GraphUI(`#${containerId}`) } } async function renderFromDot(dotText) { if (!dotText) return await ensureGraph() await graphInstance.render(dotText) hasRendered.value = true } async function renderFromCoreData() { if (!props.coreData) return loading.value = true try { const res = await fetch("dot-render-core-data", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(props.coreData), }) const dotText = await res.text() await renderFromDot(dotText) if (window.Quasar?.Notify) { window.Quasar.Notify.create({ type: "positive", message: "Rendered" }) } } catch (e) { console.error("Render from core data failed", e) if (window.Quasar?.Notify) { window.Quasar.Notify.create({ type: "negative", message: "Render failed" }) } } finally { loading.value = false } } async function reload() { await renderFromCoreData() } onMounted(async () => { await reload() }) function close() { emit("close") } return { containerId, close, hasRendered, reload, loading } }, template: `
`, }) ================================================ FILE: src/fastapi_voyager/web/component/route-code-display.js ================================================ const { defineComponent, ref, watch, onMounted } = window.Vue // Component: RouteCodeDisplay // Props: // routeId: route id key in routeItems export default defineComponent({ name: "RouteCodeDisplay", props: { routeId: { type: String, required: true }, }, emits: ["close"], setup(props, { emit }) { const loading = ref(false) const code = ref("") const error = ref("") const link = ref("") function close() { emit("close") } function highlightLater() { requestAnimationFrame(() => { try { if (window.hljs) { const block = document.querySelector(".frv-route-code-display pre code.language-python") if (block) { window.hljs.highlightElement(block) } } } catch (e) { console.warn("highlight failed", e) } }) } async function load() { if (!props.routeId) { code.value = "" return } loading.value = true error.value = null code.value = "" link.value = "" // try to fetch from server: POST /source with { schema_name: routeId } const payload = { schema_name: props.routeId } try { const resp = await fetch(`source`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(payload), }) const data = await resp.json().catch(() => ({})) if (resp.ok) { code.value = data.source_code || "// no source code available" } else { error.value = (data && data.error) || "Failed to load source" } } catch (e) { error.value = e && e.message ? e.message : "Failed to load source" } finally { loading.value = false } try { const resp = await fetch(`vscode-link`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(payload), }) const data = await resp.json().catch(() => ({})) if (resp.ok) { link.value = data.link || "// no source code available" } else { error.value += (data && data.error) || "Failed to load vscode link" } } catch (e) { } finally { loading.value = false } if (!error.value) { highlightLater() } } watch( () => props.routeId, () => { load() } ) onMounted(() => { load() }) return { loading, code, error, close, link } }, template: `
Loading source...
{{ error }}
{{ code }}
`, }) ================================================ FILE: src/fastapi_voyager/web/component/schema-code-display.js ================================================ const { defineComponent, ref, watch, onMounted } = window.Vue // Component: SchemaCodeDisplay // Props: // schemaName: full qualified schema id (module.Class) // modelValue: boolean (dialog visibility from parent) // source: optional direct source code (if already resolved client side) // schemas: list of schema meta objects (each containing fullname & source_code) // Behavior: // - When dialog opens and schemaName changes, search schemas prop and display its source_code. // - No network / global cache side effects. export default defineComponent({ name: "SchemaCodeDisplay", props: { schemaName: { type: String, required: true }, schemas: { type: Object, default: () => ({}) }, // visibility from parent (e.g., dialog v-model) modelValue: { type: Boolean, default: true }, }, setup(props, { emit }) { const code = ref("") const link = ref("") const error = ref("") const fields = ref([]) // schema fields list const tab = ref("fields") const loading = ref(false) async function highlightLater() { // wait a tick for DOM update requestAnimationFrame(() => { try { if (window.hljs) { const block = document.querySelector(".frv-code-display pre code.language-python") if (block) { // If already highlighted by highlight.js, remove the flag so it can be highlighted again if (block.dataset && block.dataset.highlighted) { block.removeAttribute("data-highlighted") } window.hljs.highlightElement(block) } } } catch (e) { console.warn("highlight failed", e) } }) } function resetState() { code.value = "" link.value = "" error.value = null fields.value = [] // tab.value = "fields"; loading.value = true } async function loadSource() { if (!props.schemaName) return error.value = null code.value = "" link.value = "" loading.value = true // try to fetch from server: /source/{schema_name} const payload = { schema_name: props.schemaName } try { // validate input: ensure we have a non-empty schemaName const resp = await fetch(`source`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(payload), }) // surface server-side validation message for bad request const data = await resp.json().catch(() => ({})) if (resp.ok) { code.value = data.source_code || "// no source code available" } else { error.value = (data && data.error) || "Failed to load source" } const resp2 = await fetch(`vscode-link`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(payload), }) const data2 = await resp2.json().catch(() => ({})) if (resp2.ok) { link.value = data2.link || "// no vscode link available" } else { error.value = (error.value || "") + ((data2 && data2.error) || "Failed to load source") } } catch (e) { error.value = "Failed to load source" } finally { loading.value = false } const schema = props.schemas && props.schemas[props.schemaName] fields.value = Array.isArray(schema?.fields) ? schema.fields : [] if (tab.value === "source") { highlightLater() } } // re-highlight when switching back to source tab watch( () => tab.value, (val) => { if (val === "source") { highlightLater() } } ) watch( () => props.schemaName, () => { resetState() loadSource() } ) // respond to visibility changes: when shown, clear old data and reload watch( () => props.modelValue, (val) => { if (val) { resetState() loadSource() } } ) onMounted(() => { if (props.modelValue) { resetState() loadSource() } }) return { link, code, error, fields, tab, loading } }, template: `

{{ schemaName }}

Open in VSCode
{{ error }}
`, }) ================================================ FILE: src/fastapi_voyager/web/graph-ui.js ================================================ export class GraphUI { // ==================== // Constants // ==================== static HIGHLIGHT_COLOR = "#FF8C00" static HIGHLIGHT_STROKE_WIDTH = "3.0" // ==================== // Constructor // ==================== constructor(selector = "#graph", options = {}) { this.selector = selector this.options = options // e.g. { onSchemaClick: (name) => {} } this.graphviz = d3.select(this.selector).graphviz().zoom(false) this.gv = null this.currentSelection = [] this.magnifyingGlass = null this.highlightMode = options.highlightMode || "deep" // Magnifying glass magnification setting (radius is percentage of viewBox width) this._magnification = options.magnifyingGlassMagnification || 3.0 // Highlight state snapshot for restoring after re-render this._lastHighlight = null // { type: 'node', name } or { type: 'edge', source, target } this._init() } // ==================== // Highlight Methods // ==================== _highlight(mode = "bidirectional") { let highlightedNodes = $() for (const selection of this.currentSelection) { const nodes = this._getAffectedNodes(selection.set, mode) highlightedNodes = highlightedNodes.add(nodes) } if (this.gv) { this.gv.highlight(highlightedNodes) this.gv.bringToFront(highlightedNodes) } } _highlightEdgeNodes() { let highlightedNodes = $() const [up, down, edge] = this.currentSelection highlightedNodes = highlightedNodes.add(this._getAffectedNodes(up.set, up.direction)) highlightedNodes = highlightedNodes.add(this._getAffectedNodes(down.set, down.direction)) highlightedNodes = highlightedNodes.add(edge.set) if (this.gv) { this.gv.highlight(highlightedNodes) this.gv.bringToFront(highlightedNodes) } } _highlightEdgeOnly(edgeEl, sourceNodeName, targetNodeName) { const nodes = this.gv.nodesByName() let $set = $() $set = $set.add(edgeEl) if (nodes[sourceNodeName]) { $set = $set.add(nodes[sourceNodeName]) } if (nodes[targetNodeName]) { $set = $set.add(nodes[targetNodeName]) } if (this.gv) { this.gv.highlight($set) this.gv.bringToFront($set) } // Highlight node banners if (nodes[sourceNodeName]) { this.highlightSchemaBanner(nodes[sourceNodeName]) } if (nodes[targetNodeName]) { this.highlightSchemaBanner(nodes[targetNodeName]) } } _getAffectedNodes($set, mode = "bidirectional") { let $result = $().add($set) if (mode === "bidirectional" || mode === "downstream") { $set.each((i, el) => { if (el.className.baseVal === "edge") { const edge = $(el).data("name") const nodes = this.gv.nodesByName() const downStreamNode = edge.split("->")[1] if (downStreamNode) { $result.push(nodes[downStreamNode]) $result = $result.add(this.gv.linkedFrom(nodes[downStreamNode], true)) } } else { $result = $result.add(this.gv.linkedFrom(el, true)) } }) } if (mode === "bidirectional" || mode === "upstream") { $set.each((i, el) => { if (el.className.baseVal === "edge") { const edge = $(el).data("name") const nodes = this.gv.nodesByName() const upStreamNode = edge.split("->")[0] if (upStreamNode) { $result.push(nodes[upStreamNode]) $result = $result.add(this.gv.linkedTo(nodes[upStreamNode], true)) } } else { $result = $result.add(this.gv.linkedTo(el, true)) } }) } return $result } // ==================== // Schema Banner Methods // ==================== highlightSchemaBanner(node) { const polygons = node.querySelectorAll("polygon") const outerFrame = polygons[0] const titleBg = polygons[1] if (outerFrame) { this._saveOriginalAttributes(outerFrame) outerFrame.setAttribute("stroke", GraphUI.HIGHLIGHT_COLOR) outerFrame.setAttribute("stroke-width", GraphUI.HIGHLIGHT_STROKE_WIDTH) } if (titleBg) { this._saveOriginalAttributes(titleBg) titleBg.setAttribute("fill", GraphUI.HIGHLIGHT_COLOR) titleBg.setAttribute("stroke", GraphUI.HIGHLIGHT_COLOR) } } clearSchemaBanners() { if (this.gv) { this.gv.highlight() } this._lastHighlight = null const allPolygons = document.querySelectorAll("polygon[data-original-stroke]") allPolygons.forEach((polygon) => { polygon.removeAttribute("data-original-stroke") polygon.removeAttribute("data-original-stroke-width") polygon.removeAttribute("data-original-fill") }) } _saveOriginalAttributes(element) { if (!element.hasAttribute("data-original-stroke")) { element.setAttribute("data-original-stroke", element.getAttribute("stroke") || "") element.setAttribute( "data-original-stroke-width", element.getAttribute("stroke-width") || "1" ) element.setAttribute("data-original-fill", element.getAttribute("fill") || "") } } _highlightNodeShallow(node) { const nodeName = $(node).attr("data-name") const nodesByName = this.gv.nodesByName() let $set = $().add(node) // Find directly connected edges and their neighbor nodes (no recursion) for (const edgeName in this.gv._edgesByName) { const parts = edgeName.split("->") const srcNode = parts[0].split(":")[0] const tgtNode = parts[1] ? parts[1].split(":")[0] : null if (srcNode === nodeName || tgtNode === nodeName) { this.gv._edgesByName[edgeName].forEach((edge) => { $set = $set.add(edge) }) if (srcNode === nodeName && tgtNode && nodesByName[tgtNode]) { $set = $set.add(nodesByName[tgtNode]) } if (tgtNode === nodeName && nodesByName[srcNode]) { $set = $set.add(nodesByName[srcNode]) } } } this.gv.highlight($set) this.gv.bringToFront($set) this.highlightSchemaBanner(node) this._lastHighlight = { type: "node", name: nodeName } } _applyNodeHighlight(node) { const set = $() set.push(node) const obj = { set, direction: "bidirectional" } this.clearSchemaBanners() this.currentSelection = [obj] this._highlight() this._lastHighlight = { type: "node", name: $(node).attr("data-name") } return obj } setHighlightMode(mode) { this.highlightMode = mode } _restoreHighlight() { if (!this._lastHighlight || !this.gv) return if (this._lastHighlight.type === "node") { const nodes = this.gv.nodesByName() const node = nodes[this._lastHighlight.name] if (node) { if (this.highlightMode === "shallow") { this._highlightNodeShallow(node) } else { this._applyNodeHighlight(node) try { this.highlightSchemaBanner(node) } catch (e) { console.warn("[restore-highlight] banner error:", e) } } } } else if (this._lastHighlight.type === "edge") { const { source, target } = this._lastHighlight const edgeName = Object.keys(this.gv._edgesByName).find((name) => { const [s, t] = name.split("->") return s.split(":")[0] === source && t.split(":")[0] === target }) if (edgeName && this.gv._edgesByName[edgeName]?.[0]) { if (this.highlightMode === "shallow") { this._highlightEdgeOnly(this.gv._edgesByName[edgeName][0], source, target) } else { const nodes = this.gv.nodesByName() const up = $() const down = $() const edge = $() if (nodes[source]) up.push(nodes[source]) if (nodes[target]) down.push(nodes[target]) edge.push(this.gv._edgesByName[edgeName][0]) this.currentSelection = [ { set: up, direction: "upstream" }, { set: down, direction: "downstream" }, { set: edge, direction: "single" }, ] this._highlightEdgeNodes() } } } } _triggerCallback(callbackName, ...args) { const callback = this.options[callbackName] if (callback) { try { callback(...args) } catch (e) { console.warn(`${callbackName} callback failed`, e) } } } // ==================== // Magnifying Glass Methods // ==================== _initMagnifyingGlass() { // Destroy existing magnifier if any if (this.magnifyingGlass) { this.magnifyingGlass.destroy() this.magnifyingGlass = null } // Only initialize if enabled in options (default: true) if (this.options.enableMagnifyingGlass !== false) { const svgElement = document.querySelector(`${this.selector} svg`) if (svgElement) { import("./magnifying-glass.js") .then((module) => { const { MagnifyingGlass } = module this.magnifyingGlass = new MagnifyingGlass(svgElement, { magnification: this._magnification, }) }) .catch((err) => { console.warn("Failed to load magnifying glass module:", err) }) } } } // ==================== // Initialization & Events // ==================== _init() { const self = this $(this.selector).graphviz({ shrink: null, zoom: false, ready: function () { self.gv = this const nodes = self.gv.nodes() const edges = self.gv.edges() nodes.off(".graphui") edges.off(".graphui") nodes.on("dblclick.graphui", function (event) { event.stopPropagation() if (self.highlightMode === "shallow") { self.clearSchemaBanners() self._highlightNodeShallow(this) } else { self._applyNodeHighlight(this) try { self.highlightSchemaBanner(this) } catch (e) { console.log(e) } } self._triggerCallback("onSchemaClick", event.currentTarget.dataset.name) }) edges.on("click.graphui", function (event) { event.stopPropagation() const [upStreamNodeRaw, downStreamNodeRaw] = event.currentTarget.dataset.name.split("->") // Strip port info (e.g. "ClassA:f.owner_id" -> "ClassA") const upStreamNode = upStreamNodeRaw.split(":")[0] const downStreamNode = downStreamNodeRaw.split(":")[0] if (self.highlightMode === "shallow") { self.clearSchemaBanners() try { self._highlightEdgeOnly(this, upStreamNode, downStreamNode) } catch (e) { console.warn("[edge-click] highlight error:", e) } self._lastHighlight = { type: "edge", source: upStreamNode, target: downStreamNode } } else { const nodes = self.gv.nodesByName() const up = $() const down = $() const edge = $() if (nodes[upStreamNode]) up.push(nodes[upStreamNode]) if (nodes[downStreamNode]) down.push(nodes[downStreamNode]) edge.push(this) self.currentSelection = [ { set: up, direction: "upstream" }, { set: down, direction: "downstream" }, { set: edge, direction: "single" }, ] try { self._highlightEdgeNodes() } catch (e) { console.warn("[edge-click] highlight error:", e) } self._lastHighlight = { type: "edge", source: upStreamNode, target: downStreamNode } } }) edges.on("dblclick.graphui", function (event) { event.stopPropagation() self._triggerCallback("onEdgeClick", event.currentTarget.dataset.name) }) nodes.on("click.graphui", function (event) { if (event.shiftKey) { self._triggerCallback("onSchemaShiftClick", event.currentTarget.dataset.name) } else if (self.highlightMode === "shallow") { self.clearSchemaBanners() self._highlightNodeShallow(this) } else { self._applyNodeHighlight(this) } }) $(document) .off("click.graphui") .on("click.graphui", function (evt) { const graphContainer = $(self.selector)[0] if (!graphContainer || !evt.target || !graphContainer.contains(evt.target)) { return } const $everything = self.gv.$nodes.add(self.gv.$edges).add(self.gv.$clusters) // Walk up from click target to find if it's inside a node/edge/cluster let el = evt.target let isNode = false while (el && el !== graphContainer) { if ($everything.is(el)) { isNode = true break } el = el.parentNode } if (!isNode && self.gv) { self.clearSchemaBanners() if (self.options.resetCb) { self.options.resetCb() } } }) }, }) } // ==================== // Render Method // ==================== async render(dotSrc, resetZoom = true) { const height = this.options.height || "100%" // Save current zoom transform before re-render let savedTransform = null if (!resetZoom) { const svgEl = document.querySelector(`${this.selector} svg`) if (svgEl) { savedTransform = d3.zoomTransform(svgEl) } } return new Promise((resolve, reject) => { try { this.graphviz .engine("dot") .tweenPaths(false) .tweenShapes(false) .zoomScaleExtent([0, Infinity]) .zoom(true) .width("100%") .height(height) .fit(true) .renderDot(dotSrc) .on("end", () => { $(this.selector).data("graphviz.svg").setup() this._restoreHighlight() if (resetZoom) { this.graphviz.resetZoom() } else if (savedTransform) { this.graphviz .zoomSelection() .call(this.graphviz.zoomBehavior().transform, savedTransform) } // Initialize magnifying glass after render this._initMagnifyingGlass() resolve() }) } catch (err) { reject(err) } }) } } ================================================ FILE: src/fastapi_voyager/web/graphviz.svg.css ================================================ /* * Copyright (c) 2015 Mountainstorm * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ /* this element needs tooltip positioning to work */ .graphviz-svg { position: relative; } /* stop tooltips wrapping */ .graphviz-svg .tooltip-inner { white-space: nowrap; } /* stop people selecting text on nodes */ .graphviz-svg text { -webkit-touch-callout: none; -webkit-user-select: none; -khtml-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; cursor: default; } /* ==================== */ /* Magnifying Glass Styles */ /* ==================== */ .magnifying-lens { pointer-events: none; z-index: 9999; } .magnifying-lens .lens-border { filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4)); } .magnifying-lens .lens-content { shape-rendering: geometricPrecision; text-rendering: geometricPrecision; } svg.magnifier-active { cursor: none; } ================================================ FILE: src/fastapi_voyager/web/graphviz.svg.js ================================================ ;+(function ($) { "use strict" // GRAPHVIZSVG PUBLIC CLASS DEFINITION // =================================== var GraphvizSvg = function (element, options) { this.type = null this.options = null this.enabled = null this.$element = null this.init("graphviz.svg", element, options) } GraphvizSvg.VERSION = "1.0.1" GraphvizSvg.GVPT_2_PX = 32.5 // used to ease removal of extra space // SVG element selectors for color manipulation // NOTE: If you need to add more element types for highlighting/dimming, // update SHAPE_ELEMENTS and the code will automatically handle them GraphvizSvg.SHAPE_ELEMENTS = "polygon, ellipse, path, polyline" GraphvizSvg.TEXT_ELEMENTS = "text" GraphvizSvg.ALL_COLOR_ELEMENTS = GraphvizSvg.SHAPE_ELEMENTS + ", " + GraphvizSvg.TEXT_ELEMENTS GraphvizSvg.DEFAULTS = { url: null, svg: null, shrink: "0.125pt", edgeHitPadding: 12, pointerCursor: true, zoom: true, highlight: { selected: function (col, bg) { return col }, unselected: function (col, bg) { return jQuery.Color(col).transition(bg, 0.9) }, }, ready: null, } GraphvizSvg.prototype.init = function (type, element, options) { this.enabled = true this.type = type this.$element = $(element) this.options = this.getOptions(options) if (options.url) { var that = this $.get( options.url, null, function (data) { var svg = $("svg", data) that.$element.html(document.adoptNode(svg[0])) that.setup() }, "xml" ) } else { if (options.svg) { this.$element.html(options.svg) } this.setup() } } GraphvizSvg.prototype.getDefaults = function () { return GraphvizSvg.DEFAULTS } GraphvizSvg.prototype.getOptions = function (options) { options = $.extend({}, this.getDefaults(), this.$element.data(), options) if (options.shrink) { if (typeof options.shrink != "object") { options.shrink = { x: options.shrink, y: options.shrink, } } options.shrink.x = this.convertToPx(options.shrink.x) options.shrink.y = this.convertToPx(options.shrink.y) } return options } GraphvizSvg.prototype.setup = function () { var options = this.options // save key elements in the graph for easy access var $svg = $(this.$element.children("svg")) var $graph = $svg.children("g:first") this.$svg = $svg this.$graph = $graph this.$background = $graph.children("polygon:first") // might not exist this.$nodes = $graph.children(".node") this.$edges = $graph.children(".edge") this.$clusters = $graph.children(".cluster") this._nodesByName = {} this._edgesByName = {} this._clustersByName = {} // add top level class and copy background color to element this.$element.addClass("graphviz-svg") if (this.$background.length) { this.$element.css("background", this.$background.attr("fill")) } // setup all the nodes and edges var that = this this.$nodes.each(function () { $(this).attr({ "pointer-events": "visible", }) that.setupNodesEdges($(this), "node") }) this.$edges.each(function () { that.setupNodesEdges($(this), "edge") }) this.$clusters.each(function () { that.setupNodesEdges($(this), "cluster") }) // remove the graph title element var $title = this.$graph.children("title") this.$graph.attr("data-name", $title.text()) $title.remove() if (options.zoom) { this.setupZoom() } // tell people we're done if (options.ready) { options.ready.call(this) } } GraphvizSvg.prototype.setupNodesEdges = function ($el, type) { var that = this var options = this.options if (type === "edge" && options.edgeHitPadding) { this.ensureEdgeHitArea($el, options.edgeHitPadding) } if (options.pointerCursor && (type === "edge" || type === "node")) { this.setInteractiveCursor($el, type === "edge") } // Save the colors of shape elements (polygon, ellipse, path, polyline) $el.find(GraphvizSvg.SHAPE_ELEMENTS).each(function () { var $this = $(this) if ($this.attr("data-graphviz-hitbox") === "true") { return } // save original colors $this.data("graphviz.svg.color", { fill: $this.attr("fill"), stroke: $this.attr("stroke"), }) // shrink it if it's a node if (type === "node" && options.shrink) { that.scaleNode($this) } }) // Save the colors of text elements $el.find(GraphvizSvg.TEXT_ELEMENTS).each(function () { var $this = $(this) // text elements might not have explicit fill attribute, use black as default var fill = $this.attr("fill") if (!fill || fill === "none") { fill = "#000000" // default black color for text } $this.data("graphviz.svg.color", { fill: fill, stroke: $this.attr("stroke"), }) }) // save the node name and check if theres a comment above; save it var $title = $el.children("title") if ($title[0]) { // remove any compass points: var title = $title.text().replace(/:[snew][ew]?/g, "") $el.attr("data-name", title) $title.remove() if (type === "node") { this._nodesByName[title] = $el[0] } else if (type === "edge") { if (!this._edgesByName[title]) { this._edgesByName[title] = [] } this._edgesByName[title].push($el[0]) } else if (type === "cluster") { this._clustersByName[title] = $el[0] } // without a title we can't tell if its a user comment or not var previousSibling = $el[0].previousSibling while (previousSibling && previousSibling.nodeType != 8) { previousSibling = previousSibling.previousSibling } if (previousSibling != null && previousSibling.nodeType == 8) { var htmlDecode = function (input) { var e = document.createElement("div") e.innerHTML = input return e.childNodes[0].nodeValue } var value = htmlDecode(previousSibling.nodeValue.trim()) if (value != title) { // user added comment $el.attr("data-comment", value) } } } // remove namespace from a[xlink:title] $el .find("a") .filter(function () { return $(this).attr("xlink:title") }) .each(function () { var $a = $(this) $a.attr("title", $a.attr("xlink:title")) $a.removeAttr("xlink:title") }) } GraphvizSvg.prototype.setupZoom = function () { var that = this var $element = this.$element var $svg = this.$svg this.zoom = { width: $svg.attr("width"), height: $svg.attr("height"), percentage: null, } this.scaleView(100.0) $element.mousewheel(function (evt) { if (evt.shiftKey) { var percentage = that.zoom.percentage percentage -= evt.deltaY * evt.deltaFactor if (percentage < 100.0) { percentage = 100.0 } // get pointer offset in view // ratio offset within svg var dx = evt.pageX - $svg.offset().left var dy = evt.pageY - $svg.offset().top var rx = dx / $svg.width() var ry = dy / $svg.height() // offset within frame ($element) var px = evt.pageX - $element.offset().left var py = evt.pageY - $element.offset().top that.scaleView(percentage) // scroll so pointer is still in same place $element.scrollLeft(rx * $svg.width() + 0.5 - px) $element.scrollTop(ry * $svg.height() + 0.5 - py) return false // stop propogation } }) } GraphvizSvg.prototype.scaleView = function (percentage) { var $svg = this.$svg $svg.attr("width", percentage + "%") $svg.attr("height", percentage + "%") this.zoom.percentage = percentage } GraphvizSvg.prototype.scaleNode = function ($node) { var dx = this.options.shrink.x var dy = this.options.shrink.y var tagName = $node.prop("tagName") if (tagName == "ellipse") { $node.attr("rx", parseFloat($node.attr("rx")) - dx) $node.attr("ry", parseFloat($node.attr("ry")) - dy) } else if (tagName == "polygon") { // this is more complex - we need to scale it manually var bbox = $node[0].getBBox() var cx = bbox.x + bbox.width / 2 var cy = bbox.y + bbox.height / 2 var pts = $node.attr("points").split(" ") var points = "" // new value for (var i in pts) { var xy = pts[i].split(",") var ox = parseFloat(xy[0]) var oy = parseFloat(xy[1]) points += ((cx - ox) / (bbox.width / 2)) * dx + ox + "," + (((cy - oy) / (bbox.height / 2)) * dy + oy) + " " } $node.attr("points", points) } } GraphvizSvg.prototype.ensureEdgeHitArea = function ($edge, padding) { var width = parseFloat(padding) if (!isFinite(width) || width <= 0) { return } var $paths = $edge.children("path").filter(function () { return $(this).attr("data-graphviz-hitbox") !== "true" }) if (!$paths.length) { return } $paths.each(function () { var $path = $(this) var $existing = $path.prev('[data-graphviz-hitbox="true"]') if ($existing.length) { $existing.attr("stroke-width", width) return } var clone = this.cloneNode(false) /** * gtp-5-codex: * Cloning the edge paths without copying D3’s data binding caused those Cannot * read properties of undefined (reading 'key') errors when d3-graphviz re-rendered. * I now copy the original path’s bound datum (__data__) onto the transparent hitbox * clone inside ensureEdgeHitArea, so D3 still finds the expected metadata. */ if (this.__data__) { clone.__data__ = this.__data__ } var $clone = $(clone) $clone.attr({ "data-graphviz-hitbox": "true", stroke: "transparent", fill: "none", "stroke-width": width, }) $clone.attr("pointer-events", "stroke") $clone.css("pointer-events", "stroke") if (!$clone.attr("stroke-linecap")) { $clone.attr("stroke-linecap", $path.attr("stroke-linecap") || "round") } $clone.insertBefore($path) }) } GraphvizSvg.prototype.setInteractiveCursor = function ($el, isEdge) { $el.css("cursor", "pointer") var selectors = "path, polygon, ellipse, rect, text" $el.find(selectors).each(function () { $(this).css("cursor", "pointer") }) if (isEdge) { $el.children('[data-graphviz-hitbox="true"]').css("cursor", "pointer") } $el.find("a").each(function () { $(this).css("cursor", "pointer") }) } GraphvizSvg.prototype.convertToPx = function (val) { var retval = val if (typeof val == "string") { var end = val.length var factor = 1.0 if (val.endsWith("px")) { end -= 2 } else if (val.endsWith("pt")) { end -= 2 factor = GraphvizSvg.GVPT_2_PX } retval = parseFloat(val.substring(0, end)) * factor } return retval } // Helper function to apply color transformation to elements GraphvizSvg.prototype._applyColorToElements = function ( $elements, colorTransformer, bgColor, setStrokeWidth ) { var that = this $elements.each(function () { var $this = $(this) if ($this.attr("data-graphviz-hitbox") === "true") { return } var color = $this.data("graphviz.svg.color") if (color) { if (color.fill && color.fill != "none") { $this.attr("fill", colorTransformer(color.fill, bgColor)) } if (color.stroke && color.stroke != "none") { $this.attr("stroke", colorTransformer(color.stroke, bgColor)) } if (setStrokeWidth !== undefined) { $this.attr("stroke-width", setStrokeWidth) } } }) } // Helper function to restore original colors GraphvizSvg.prototype._restoreElementColors = function ($elements, setStrokeWidth) { var that = this $elements.each(function () { var $this = $(this) if ($this.attr("data-graphviz-hitbox") === "true") { return } var color = $this.data("graphviz.svg.color") if (color) { if (color.fill && color.fill != "none") { $this.attr("fill", color.fill) } if (color.stroke && color.stroke != "none") { $this.attr("stroke", color.stroke) } if (setStrokeWidth !== undefined) { $this.attr("stroke-width", setStrokeWidth) } } }) } GraphvizSvg.prototype.findEdge = function (nodeName, testEdge, $retval) { var retval = [] for (var name in this._edgesByName) { var match = testEdge(nodeName, name) if (match) { if ($retval) { this._edgesByName[name].forEach((edge) => { $retval.push(edge) }) } retval.push(match) } } return retval } GraphvizSvg.prototype.findLinked = function (node, includeEdges, testEdge, $retval) { var that = this var $node = $(node) var $edges = null if (includeEdges) { $edges = $retval } var names = this.findEdge($node.attr("data-name"), testEdge, $edges) for (var i in names) { var n = this._nodesByName[names[i]] if (!$retval.is(n)) { $retval.push(n) that.findLinked(n, includeEdges, testEdge, $retval) } } } GraphvizSvg.prototype.colorElement = function ($el, getColor) { var bg = this.$element.css("background") // Apply color transformation to all elements (shapes + text) this._applyColorToElements($el.find(GraphvizSvg.ALL_COLOR_ELEMENTS), getColor, bg) } GraphvizSvg.prototype.restoreElement = function ($el) { // Restore original colors for all elements (shapes + text) this._restoreElementColors($el.find(GraphvizSvg.ALL_COLOR_ELEMENTS), 1) } // methods users can actually call GraphvizSvg.prototype.nodes = function () { return this.$nodes } GraphvizSvg.prototype.edges = function () { return this.$edges } GraphvizSvg.prototype.clusters = function () { return this.$clusters } GraphvizSvg.prototype.nodesByName = function () { return this._nodesByName } GraphvizSvg.prototype.edgesByName = function () { return this._edgesByName } GraphvizSvg.prototype.clustersByName = function () { return this._clustersByName } GraphvizSvg.prototype.linkedTo = function (node, includeEdges) { var $retval = $() this.findLinked( node, includeEdges, function (nodeName, edgeName) { var other = null const connection = edgeName.split("->") if ( connection.length > 1 && (connection[1] === nodeName || connection[1].startsWith(nodeName + ":")) ) { return connection[0].split(":")[0] } return other }, $retval ) return $retval } GraphvizSvg.prototype.linkedFrom = function (node, includeEdges) { var $retval = $() this.findLinked( node, includeEdges, function (nodeName, edgeName) { var other = null const connection = edgeName.split("->") if ( connection.length > 1 && (connection[0] === nodeName || connection[0].startsWith(nodeName + ":")) ) { return connection[1].split(":")[0] } return other }, $retval ) return $retval } GraphvizSvg.prototype.linked = function (node, includeEdges) { var $retval = $() this.findLinked( node, includeEdges, function (nodeName, edgeName) { return "^" + name + "--(.*)$" }, $retval ) this.findLinked( node, includeEdges, function (nodeName, edgeName) { return "^(.*)--" + name + "$" }, $retval ) return $retval } GraphvizSvg.prototype.bringToFront = function ($elements) { $elements.detach().appendTo(this.$graph) } GraphvizSvg.prototype.sendToBack = function ($elements) { if (this.$background.length) { $element.insertAfter(this.$background) } else { $elements.detach().prependTo(this.$graph) } } GraphvizSvg.prototype.highlight = function ($nodesEdges) { var that = this var options = this.options var $everything = this.$nodes.add(this.$edges).add(this.$clusters) if ($nodesEdges && $nodesEdges.length > 0) { // create set of all other elements and dim them $everything.not($nodesEdges).each(function () { that.colorElement($(this), options.highlight.unselected) }) $nodesEdges.each(function () { that.colorElement($(this), options.highlight.selected) }) } else { $everything.each(function () { that.restoreElement($(this)) }) } } GraphvizSvg.prototype.destroy = function () { var that = this this.hide(function () { that.$element.off("." + that.type).removeData(that.type) }) } // GRAPHVIZSVG PLUGIN DEFINITION // ============================= function Plugin(option) { return this.each(function () { var $this = $(this) var data = $this.data("graphviz.svg") var options = typeof option == "object" && option if (!data && /destroy/.test(option)) return if (!data) $this.data("graphviz.svg", (data = new GraphvizSvg(this, options))) if (typeof option == "string") data[option]() }) } var old = $.fn.graphviz $.fn.graphviz = Plugin $.fn.graphviz.Constructor = GraphvizSvg // GRAPHVIZ NO CONFLICT // ==================== $.fn.graphviz.noConflict = function () { $.fn.graphviz = old return this } })(jQuery) ================================================ FILE: src/fastapi_voyager/web/icon/site.webmanifest ================================================ { "name": "FastAPI Voyager", "short_name": "Voyager", "description": "Visualize API routing tree and dependencies", "start_url": "", "scope": "", "icons": [ { "src": "android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } ], "theme_color": "#009485", "background_color": "#ffffff", "display": "standalone", "orientation": "landscape-primary", "categories": ["developer tools", "utilities"] } ================================================ FILE: src/fastapi_voyager/web/index.html ================================================ FastAPI Voyager
Loading…
================================================ FILE: src/fastapi_voyager/web/magnifying-glass.js ================================================ /** * Magnifying Glass for SVG Graph Visualization * * Provides a circular magnifying glass effect that follows the mouse cursor. * Activated by pressing the Space key. * * Usage: * const magnifier = new MagnifyingGlass(svgElement, { * magnification: 2.0 * }) * * The lens radius is automatically calculated based on viewBox width. */ export class MagnifyingGlass { // Class constants static DEFAULT_MAGNIFICATION = 2.0 static RADIUS_PERCENTAGE = 0.2 // Percentage of viewBox width static LENS_OFFSET = 10 // 放大镜相对于鼠标的偏移量 static BORDER_WIDTH = 2 // 边框宽度 static UPDATE_THROTTLE_MS = 16 // 更新节流(约60fps) /** * Extract viewBox dimensions from SVG element (called dynamically each time) * @private */ _getViewBoxDimensions() { const viewBoxAttr = this.svg.getAttribute("viewBox") if (viewBoxAttr) { const parts = viewBoxAttr.trim().split(/\s+/) if (parts.length === 4) { const [, , width, height] = parts.map(parseFloat) if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) { return { width, height } } } } // Fallback to getBoundingClientRect if no viewBox const rect = this.svg.getBoundingClientRect() return { width: rect.width || 1000, height: rect.height || 1000 } } /** * Get current radius (dynamically calculated based on current viewBox width) * @returns {number} Radius in SVG units */ get radius() { const { width } = this._getViewBoxDimensions() return Math.round(width * MagnifyingGlass.RADIUS_PERCENTAGE) } /** * @param {SVGElement} svgElement - The SVG element to magnify * @param {Object} options - Configuration options * @param {number} options.magnification - Zoom level (default: 2.0) * @param {boolean} options.debug - Enable debug logging (default: false) */ constructor(svgElement, options = {}) { // Validate SVG element if (!svgElement || !(svgElement instanceof SVGElement)) { throw new Error("[MagnifyingGlass] Invalid SVG element provided") } this.svg = svgElement // Calculate magnification this._magnification = this._validateNumber( options.magnification, MagnifyingGlass.DEFAULT_MAGNIFICATION, 0.1, 10 ) this.debug = options.debug || false this.active = false // Throttle updates for performance this._pendingUpdate = false this._lastPosition = null // Content caching for performance this._cachedContent = null this._contentDirty = true this._initLens() this._bindEvents() } /** * Get current magnification */ get magnification() { return this._magnification } /** * Set magnification and update lens if active * @param {number} value - New magnification value */ set magnification(value) { const validated = this._validateNumber(value, MagnifyingGlass.DEFAULT_MAGNIFICATION, 0.1, 10) if (validated !== this._magnification) { this._magnification = validated this._log("Magnification updated to:", validated) // 如果放大镜当前激活,立即更新显示 if (this.active && this._lastPosition) { this._updateTransform(this._lastPosition.x, this._lastPosition.y) } } } /** * Validate and sanitize number input * @param {*} value - Value to validate * @param {number} defaultValue - Default value if invalid * @param {number} min - Minimum allowed value * @param {number} max - Maximum allowed value * @returns {number} Validated number * @private */ _validateNumber(value, defaultValue, min, max) { if (typeof value !== "number" || isNaN(value)) { return defaultValue } return Math.max(min, Math.min(max, value)) } /** * Internal logging method * @private */ _log(...args) { if (this.debug) { console.log("[MagnifyingGlass]", ...args) } } /** * Initialize the lens SVG elements * @private */ _initLens() { this._log("Initializing lens...") // 1. Create defs and clipPath const defs = d3.select(this.svg).append("defs") this.clipPathId = `lens-clip-${Math.random().toString(36).substr(2, 9)}` this._log("clipPathId:", this.clipPathId) defs .append("clipPath") .attr("id", this.clipPathId) .append("circle") .attr("r", this.radius) .attr("cx", 0) .attr("cy", 0) // 2. Create lens group (initially hidden) this.lensGroup = d3 .select(this.svg) .append("g") .attr("class", "magnifying-lens") .style("display", "none") // 3. Create lens border circle this.lensGroup .append("circle") .attr("class", "lens-border") .attr("r", this.radius + MagnifyingGlass.BORDER_WIDTH) .attr("fill", "rgba(255,255,255,0.95)") .attr("stroke", "#999") .attr("stroke-width", MagnifyingGlass.BORDER_WIDTH) .attr("cx", 0) // Initialize at origin, will be updated on mouse move .attr("cy", 0) // 4. Create clipped content group this.lensContent = this.lensGroup .append("g") .attr("clip-path", `url(#${this.clipPathId})`) .append("g") .attr("class", "lens-content") this._log("Lens initialized successfully") } /** * Bind keyboard and mouse events * @private */ _bindEvents() { // Space key to toggle this._handleKeyDown = (e) => { if (e.code === "Space" && !e.repeat) { e.preventDefault() this._log("Space pressed, activating...") this.toggle() } } this._handleKeyUp = (e) => { if (e.code === "Space") { this._log("Space released, deactivating...") this.deactivate() } } this._handleMouseMove = (e) => { // 记录最后鼠标位置,用于第一次激活时的位置计算 const rect = this.svg.getBoundingClientRect() this._lastMousePos = { x: e.clientX - rect.left, y: e.clientY - rect.top, } if (this.active) { this._updatePosition(e) } } this._handleClick = (e) => { if (this.active) { this._log("Clicked, deactivating...") this.deactivate() } } document.addEventListener("keydown", this._handleKeyDown) document.addEventListener("keyup", this._handleKeyUp) this.svg.addEventListener("mousemove", this._handleMouseMove) this.svg.addEventListener("click", this._handleClick) this._log("Events bound successfully") } /** * Update lens position and content based on mouse position * @private */ _updatePosition(event) { // Use requestAnimationFrame for smooth performance if (this._pendingUpdate) return this._pendingUpdate = true requestAnimationFrame(() => { this._performUpdate(event) this._pendingUpdate = false }) } /** * Perform the actual position update * @private */ _performUpdate(event) { try { // 使用 SVG 标准的坐标转换方法,代替 getBoundingClientRect() const pt = this.svg.createSVGPoint() pt.x = event.clientX pt.y = event.clientY let svgP try { // 转换为 SVG 坐标(考虑 SVG 内部所有变换) const ctm = this.svg.getScreenCTM() if (!ctm || !ctm.inverse) { // 如果 getScreenCTM() 失败,退回到简单方法 const rect = this.svg.getBoundingClientRect() svgP = { x: event.clientX - rect.left, y: event.clientY - rect.top, } } else { svgP = pt.matrixTransform(ctm.inverse()) } } catch (e) { // 容错处理 const rect = this.svg.getBoundingClientRect() svgP = { x: event.clientX - rect.left, y: event.clientY - rect.top, } } // Get current radius (dynamically calculated from viewBox) const currentRadius = this.radius // 调整放大镜位置,使其在鼠标上方,靠近下方边缘外侧 // 偏移量:向下一点,让鼠标位于放大镜底部边缘外侧 const offsetX = 0 const offsetY = currentRadius + MagnifyingGlass.LENS_OFFSET // 放大镜半径 + 偏移量 const lensX = svgP.x + offsetX const lensY = svgP.y - offsetY // 向上偏移 // Move lens group to adjusted position this.lensGroup.attr("transform", `translate(${lensX}, ${lensY})`) // 计算相对于 lensGroup 的坐标 const relativeCX = svgP.x - lensX const relativeCY = svgP.y - lensY // Update clipPath circle radius and position (radius is dynamic now) d3.select(`#${this.clipPathId} circle`) .attr("r", currentRadius) .attr("cx", relativeCX) .attr("cy", relativeCY) // Update lens border circle radius and position this.lensGroup .select(".lens-border") .attr("r", currentRadius + MagnifyingGlass.BORDER_WIDTH) .attr("cx", relativeCX) .attr("cy", relativeCY) // Update magnified content with absolute coordinates this._updateContent(svgP.x, svgP.y) } catch (error) { this._log("Error in _performUpdate:", error) // 发生错误时停用放大镜,避免持续出错 this.deactivate() } } /** * Update the magnified content * @param {number} absoluteX - Absolute X coordinate in SVG space * @param {number} absoluteY - Absolute Y coordinate in SVG space * @private */ _updateContent(absoluteX, absoluteY) { // Use D3 selection (don't convert to DOM node) const mainGroup = d3.select(this.svg).select("g") if (mainGroup.empty()) return // 只在首次或内容变化时克隆 if (!this._cachedContent || this._contentDirty) { this.lensContent.html("") const clonedContent = mainGroup.clone(true).node() this.lensContent.node().appendChild(clonedContent) this._cachedContent = clonedContent this._contentDirty = false this._log("Content cloned and cached") } // 只更新 transform this._updateTransform(absoluteX, absoluteY) } /** * Update the transform of lens content * @param {number} absoluteX - Absolute X coordinate in SVG space * @param {number} absoluteY - Absolute Y coordinate in SVG space * @private */ _updateTransform(absoluteX, absoluteY) { const scale = this.magnification const currentRadius = this.radius const offsetY = currentRadius + MagnifyingGlass.LENS_OFFSET // 正确的公式: // tx = -scale * absoluteX (让 absoluteX 变换后对应 x=0) // ty = offsetY - scale * absoluteY (让 absoluteY 变换后对应 y=offsetY,即 clipPath 圆心) const transform = `translate(${-scale * absoluteX}, ${offsetY - scale * absoluteY}) scale(${scale})` this.lensContent.attr("transform", transform) this._lastPosition = { x: absoluteX, y: absoluteY } } /** * Activate the magnifying glass */ activate() { this._log("Activating magnifier...") this.active = true this._contentDirty = true // 标记内容为脏,激活时会重新克隆 this.lensGroup.style("display", null) d3.select(this.svg).classed("magnifier-active", true) // 解决第一次激活时的位置问题 // 获取当前鼠标位置并立即更新内容 this._updateContentFromCurrentMouse() } // 获取当前鼠标位置(跨浏览器兼容) _getCurrentMousePosition() { if (typeof this._lastMousePos !== "undefined") { return this._lastMousePos } // 作为备用方案,如果没有记录位置,返回 SVG 中心 const rect = this.svg.getBoundingClientRect() return { x: rect.width / 2, y: rect.height / 2 } } // 使用当前鼠标位置更新内容 _updateContentFromCurrentMouse() { const currentMousePos = this._getCurrentMousePosition() if (currentMousePos) { // 模拟事件对象 this._performUpdate({ clientX: currentMousePos.x + this.svg.getBoundingClientRect().left, clientY: currentMousePos.y + this.svg.getBoundingClientRect().top, }) } } /** * Deactivate the magnifying glass */ deactivate() { this._log("Deactivating magnifier...") this.active = false this.lensGroup.style("display", "none") d3.select(this.svg).classed("magnifier-active", false) this._lastPosition = null } /** * Toggle magnifying glass on/off */ toggle() { this.active ? this.deactivate() : this.activate() } /** * Clean up and remove lens elements */ destroy() { this._log("Destroying...") // Remove event listeners document.removeEventListener("keydown", this._handleKeyDown) document.removeEventListener("keyup", this._handleKeyUp) this.svg.removeEventListener("mousemove", this._handleMouseMove) this.svg.removeEventListener("click", this._handleClick) // Clean up DOM elements if (this.lensGroup) this.lensGroup.remove() const defs = d3.select(this.svg).select("defs") if (defs) defs.select(`#${this.clipPathId}`).remove() // Clean up references this._cachedContent = null this.lensGroup = null this.lensContent = null this.svg = null } } ================================================ FILE: src/fastapi_voyager/web/package.json ================================================ { "name": "fastapi-voyager-web", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "@vicons/ionicons5": "^0.13.0", "naive-ui": "^2.40", "vue": "^3.5" }, "devDependencies": { "@vitejs/plugin-vue": "^5", "vite": "^6" } } ================================================ FILE: src/fastapi_voyager/web/src/App.vue ================================================ ================================================ FILE: src/fastapi_voyager/web/src/component/LoaderCodeDisplay.vue ================================================ ================================================ FILE: src/fastapi_voyager/web/src/component/RenderGraph.vue ================================================ ================================================ FILE: src/fastapi_voyager/web/src/component/RouteCodeDisplay.vue ================================================ ================================================ FILE: src/fastapi_voyager/web/src/component/SchemaCodeDisplay.vue ================================================ ================================================ FILE: src/fastapi_voyager/web/src/graph-ui.js ================================================ export class GraphUI { // ==================== // Constants // ==================== static HIGHLIGHT_COLOR = "#FF8C00" static HIGHLIGHT_STROKE_WIDTH = "3.0" // ==================== // Constructor // ==================== constructor(selector = "#graph", options = {}) { this.selector = selector this.options = options // e.g. { onSchemaClick: (name) => {} } this.graphviz = d3.select(this.selector).graphviz().zoom(false) this.gv = null this.currentSelection = [] this.magnifyingGlass = null this.highlightMode = options.highlightMode || "deep" // Magnifying glass magnification setting (radius is percentage of viewBox width) this._magnification = options.magnifyingGlassMagnification || 3.0 // Highlight state snapshot for restoring after re-render this._lastHighlight = null // { type: 'node', name } or { type: 'edge', source, target } this._init() } // ==================== // Highlight Methods // ==================== _highlight(mode = "bidirectional") { let highlightedNodes = $() for (const selection of this.currentSelection) { const nodes = this._getAffectedNodes(selection.set, mode) highlightedNodes = highlightedNodes.add(nodes) } if (this.gv) { this.gv.highlight(highlightedNodes) this.gv.bringToFront(highlightedNodes) } } _highlightEdgeNodes() { let highlightedNodes = $() const [up, down, edge] = this.currentSelection highlightedNodes = highlightedNodes.add(this._getAffectedNodes(up.set, up.direction)) highlightedNodes = highlightedNodes.add(this._getAffectedNodes(down.set, down.direction)) highlightedNodes = highlightedNodes.add(edge.set) if (this.gv) { this.gv.highlight(highlightedNodes) this.gv.bringToFront(highlightedNodes) } } _highlightEdgeOnly(edgeEl, sourceNodeName, targetNodeName) { const nodes = this.gv.nodesByName() let $set = $() $set = $set.add(edgeEl) if (nodes[sourceNodeName]) { $set = $set.add(nodes[sourceNodeName]) } if (nodes[targetNodeName]) { $set = $set.add(nodes[targetNodeName]) } if (this.gv) { this.gv.highlight($set) this.gv.bringToFront($set) } // Highlight node banners if (nodes[sourceNodeName]) { this.highlightSchemaBanner(nodes[sourceNodeName]) } if (nodes[targetNodeName]) { this.highlightSchemaBanner(nodes[targetNodeName]) } } _getAffectedNodes($set, mode = "bidirectional") { let $result = $().add($set) if (mode === "bidirectional" || mode === "downstream") { $set.each((i, el) => { if (el.className.baseVal === "edge") { const edge = $(el).data("name") const nodes = this.gv.nodesByName() const downStreamNode = edge.split("->")[1] if (downStreamNode) { $result.push(nodes[downStreamNode]) $result = $result.add(this.gv.linkedFrom(nodes[downStreamNode], true)) } } else { $result = $result.add(this.gv.linkedFrom(el, true)) } }) } if (mode === "bidirectional" || mode === "upstream") { $set.each((i, el) => { if (el.className.baseVal === "edge") { const edge = $(el).data("name") const nodes = this.gv.nodesByName() const upStreamNode = edge.split("->")[0] if (upStreamNode) { $result.push(nodes[upStreamNode]) $result = $result.add(this.gv.linkedTo(nodes[upStreamNode], true)) } } else { $result = $result.add(this.gv.linkedTo(el, true)) } }) } return $result } // ==================== // Schema Banner Methods // ==================== highlightSchemaBanner(node) { const polygons = node.querySelectorAll("polygon") const outerFrame = polygons[0] const titleBg = polygons[1] if (outerFrame) { this._saveOriginalAttributes(outerFrame) outerFrame.setAttribute("stroke", GraphUI.HIGHLIGHT_COLOR) outerFrame.setAttribute("stroke-width", GraphUI.HIGHLIGHT_STROKE_WIDTH) } if (titleBg) { this._saveOriginalAttributes(titleBg) titleBg.setAttribute("fill", GraphUI.HIGHLIGHT_COLOR) titleBg.setAttribute("stroke", GraphUI.HIGHLIGHT_COLOR) } } clearSchemaBanners() { if (this.gv) { this.gv.highlight() } this._lastHighlight = null const allPolygons = document.querySelectorAll("polygon[data-original-stroke]") allPolygons.forEach((polygon) => { polygon.removeAttribute("data-original-stroke") polygon.removeAttribute("data-original-stroke-width") polygon.removeAttribute("data-original-fill") }) } _saveOriginalAttributes(element) { if (!element.hasAttribute("data-original-stroke")) { element.setAttribute("data-original-stroke", element.getAttribute("stroke") || "") element.setAttribute( "data-original-stroke-width", element.getAttribute("stroke-width") || "1" ) element.setAttribute("data-original-fill", element.getAttribute("fill") || "") } } _highlightNodeShallow(node) { const nodeName = $(node).attr("data-name") const nodesByName = this.gv.nodesByName() let $set = $().add(node) // Find directly connected edges and their neighbor nodes (no recursion) for (const edgeName in this.gv._edgesByName) { const parts = edgeName.split("->") const srcNode = parts[0].split(":")[0] const tgtNode = parts[1] ? parts[1].split(":")[0] : null if (srcNode === nodeName || tgtNode === nodeName) { this.gv._edgesByName[edgeName].forEach((edge) => { $set = $set.add(edge) }) if (srcNode === nodeName && tgtNode && nodesByName[tgtNode]) { $set = $set.add(nodesByName[tgtNode]) } if (tgtNode === nodeName && nodesByName[srcNode]) { $set = $set.add(nodesByName[srcNode]) } } } this.gv.highlight($set) this.gv.bringToFront($set) this.highlightSchemaBanner(node) this._lastHighlight = { type: "node", name: nodeName } } _applyNodeHighlight(node) { const set = $() set.push(node) const obj = { set, direction: "bidirectional" } this.clearSchemaBanners() this.currentSelection = [obj] this._highlight() this._lastHighlight = { type: "node", name: $(node).attr("data-name") } return obj } setHighlightMode(mode) { this.highlightMode = mode } _restoreHighlight() { if (!this._lastHighlight || !this.gv) return if (this._lastHighlight.type === "node") { const nodes = this.gv.nodesByName() const node = nodes[this._lastHighlight.name] if (node) { if (this.highlightMode === "shallow") { this._highlightNodeShallow(node) } else { this._applyNodeHighlight(node) try { this.highlightSchemaBanner(node) } catch (e) { console.warn("[restore-highlight] banner error:", e) } } } } else if (this._lastHighlight.type === "edge") { const { source, target } = this._lastHighlight const edgeName = Object.keys(this.gv._edgesByName).find((name) => { const [s, t] = name.split("->") return s.split(":")[0] === source && t.split(":")[0] === target }) if (edgeName && this.gv._edgesByName[edgeName]?.[0]) { if (this.highlightMode === "shallow") { this._highlightEdgeOnly(this.gv._edgesByName[edgeName][0], source, target) } else { const nodes = this.gv.nodesByName() const up = $() const down = $() const edge = $() if (nodes[source]) up.push(nodes[source]) if (nodes[target]) down.push(nodes[target]) edge.push(this.gv._edgesByName[edgeName][0]) this.currentSelection = [ { set: up, direction: "upstream" }, { set: down, direction: "downstream" }, { set: edge, direction: "single" }, ] this._highlightEdgeNodes() } } } } _triggerCallback(callbackName, ...args) { const callback = this.options[callbackName] if (callback) { try { callback(...args) } catch (e) { console.warn(`${callbackName} callback failed`, e) } } } // ==================== // Magnifying Glass Methods // ==================== _initMagnifyingGlass() { // Destroy existing magnifier if any if (this.magnifyingGlass) { this.magnifyingGlass.destroy() this.magnifyingGlass = null } // Only initialize if enabled in options (default: true) if (this.options.enableMagnifyingGlass !== false) { const svgElement = document.querySelector(`${this.selector} svg`) if (svgElement) { import("./magnifying-glass.js") .then((module) => { const { MagnifyingGlass } = module this.magnifyingGlass = new MagnifyingGlass(svgElement, { magnification: this._magnification, }) }) .catch((err) => { console.warn("Failed to load magnifying glass module:", err) }) } } } // ==================== // Initialization & Events // ==================== _init() { const self = this $(this.selector).graphviz({ shrink: null, zoom: false, ready: function () { self.gv = this const nodes = self.gv.nodes() const edges = self.gv.edges() nodes.off(".graphui") edges.off(".graphui") nodes.on("dblclick.graphui", function (event) { event.stopPropagation() if (self.highlightMode === "shallow") { self.clearSchemaBanners() self._highlightNodeShallow(this) } else { self._applyNodeHighlight(this) try { self.highlightSchemaBanner(this) } catch (e) { console.log(e) } } self._triggerCallback("onSchemaClick", event.currentTarget.dataset.name) }) edges.on("click.graphui", function (event) { event.stopPropagation() const [upStreamNodeRaw, downStreamNodeRaw] = event.currentTarget.dataset.name.split("->") // Strip port info (e.g. "ClassA:f.owner_id" -> "ClassA") const upStreamNode = upStreamNodeRaw.split(":")[0] const downStreamNode = downStreamNodeRaw.split(":")[0] if (self.highlightMode === "shallow") { self.clearSchemaBanners() try { self._highlightEdgeOnly(this, upStreamNode, downStreamNode) } catch (e) { console.warn("[edge-click] highlight error:", e) } self._lastHighlight = { type: "edge", source: upStreamNode, target: downStreamNode } } else { const nodes = self.gv.nodesByName() const up = $() const down = $() const edge = $() if (nodes[upStreamNode]) up.push(nodes[upStreamNode]) if (nodes[downStreamNode]) down.push(nodes[downStreamNode]) edge.push(this) self.currentSelection = [ { set: up, direction: "upstream" }, { set: down, direction: "downstream" }, { set: edge, direction: "single" }, ] try { self._highlightEdgeNodes() } catch (e) { console.warn("[edge-click] highlight error:", e) } self._lastHighlight = { type: "edge", source: upStreamNode, target: downStreamNode } } }) edges.on("dblclick.graphui", function (event) { event.stopPropagation() self._triggerCallback("onEdgeClick", event.currentTarget.dataset.name) }) nodes.on("click.graphui", function (event) { if (event.shiftKey) { self._triggerCallback("onSchemaShiftClick", event.currentTarget.dataset.name) } else if (self.highlightMode === "shallow") { self.clearSchemaBanners() self._highlightNodeShallow(this) } else { self._applyNodeHighlight(this) } }) $(document) .off("click.graphui") .on("click.graphui", function (evt) { const graphContainer = $(self.selector)[0] if (!graphContainer || !evt.target || !graphContainer.contains(evt.target)) { return } const $everything = self.gv.$nodes.add(self.gv.$edges).add(self.gv.$clusters) // Walk up from click target to find if it's inside a node/edge/cluster let el = evt.target let isNode = false while (el && el !== graphContainer) { if ($everything.is(el)) { isNode = true break } el = el.parentNode } if (!isNode && self.gv) { self.clearSchemaBanners() if (self.options.resetCb) { self.options.resetCb() } } }) }, }) } // ==================== // Render Method // ==================== async render(dotSrc, resetZoom = true) { const height = this.options.height || "100%" // Save current zoom transform before re-render let savedTransform = null if (!resetZoom) { const svgEl = document.querySelector(`${this.selector} svg`) if (svgEl) { savedTransform = d3.zoomTransform(svgEl) } } return new Promise((resolve, reject) => { try { this.graphviz .engine("dot") .tweenPaths(false) .tweenShapes(false) .zoomScaleExtent([0, Infinity]) .zoom(true) .width("100%") .height(height) .fit(true) .renderDot(dotSrc) .on("end", () => { $(this.selector).data("graphviz.svg").setup() this._restoreHighlight() if (resetZoom) { this.graphviz.resetZoom() } else if (savedTransform) { this.graphviz .zoomSelection() .call(this.graphviz.zoomBehavior().transform, savedTransform) } // Initialize magnifying glass after render this._initMagnifyingGlass() resolve() }) } catch (err) { reject(err) } }) } } ================================================ FILE: src/fastapi_voyager/web/src/magnifying-glass.js ================================================ /** * Magnifying Glass for SVG Graph Visualization * * Provides a circular magnifying glass effect that follows the mouse cursor. * Activated by pressing the Space key. * * Usage: * const magnifier = new MagnifyingGlass(svgElement, { * magnification: 2.0 * }) * * The lens radius is automatically calculated based on viewBox width. */ export class MagnifyingGlass { // Class constants static DEFAULT_MAGNIFICATION = 2.0 static RADIUS_PERCENTAGE = 0.2 // Percentage of viewBox width static LENS_OFFSET = 10 // 放大镜相对于鼠标的偏移量 static BORDER_WIDTH = 2 // 边框宽度 static UPDATE_THROTTLE_MS = 16 // 更新节流(约60fps) /** * Extract viewBox dimensions from SVG element (called dynamically each time) * @private */ _getViewBoxDimensions() { const viewBoxAttr = this.svg.getAttribute("viewBox") if (viewBoxAttr) { const parts = viewBoxAttr.trim().split(/\s+/) if (parts.length === 4) { const [, , width, height] = parts.map(parseFloat) if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) { return { width, height } } } } // Fallback to getBoundingClientRect if no viewBox const rect = this.svg.getBoundingClientRect() return { width: rect.width || 1000, height: rect.height || 1000 } } /** * Get current radius (dynamically calculated based on current viewBox width) * @returns {number} Radius in SVG units */ get radius() { const { width } = this._getViewBoxDimensions() return Math.round(width * MagnifyingGlass.RADIUS_PERCENTAGE) } /** * @param {SVGElement} svgElement - The SVG element to magnify * @param {Object} options - Configuration options * @param {number} options.magnification - Zoom level (default: 2.0) * @param {boolean} options.debug - Enable debug logging (default: false) */ constructor(svgElement, options = {}) { // Validate SVG element if (!svgElement || !(svgElement instanceof SVGElement)) { throw new Error("[MagnifyingGlass] Invalid SVG element provided") } this.svg = svgElement // Calculate magnification this._magnification = this._validateNumber( options.magnification, MagnifyingGlass.DEFAULT_MAGNIFICATION, 0.1, 10 ) this.debug = options.debug || false this.active = false // Throttle updates for performance this._pendingUpdate = false this._lastPosition = null // Content caching for performance this._cachedContent = null this._contentDirty = true this._initLens() this._bindEvents() } /** * Get current magnification */ get magnification() { return this._magnification } /** * Set magnification and update lens if active * @param {number} value - New magnification value */ set magnification(value) { const validated = this._validateNumber(value, MagnifyingGlass.DEFAULT_MAGNIFICATION, 0.1, 10) if (validated !== this._magnification) { this._magnification = validated this._log("Magnification updated to:", validated) // 如果放大镜当前激活,立即更新显示 if (this.active && this._lastPosition) { this._updateTransform(this._lastPosition.x, this._lastPosition.y) } } } /** * Validate and sanitize number input * @param {*} value - Value to validate * @param {number} defaultValue - Default value if invalid * @param {number} min - Minimum allowed value * @param {number} max - Maximum allowed value * @returns {number} Validated number * @private */ _validateNumber(value, defaultValue, min, max) { if (typeof value !== "number" || isNaN(value)) { return defaultValue } return Math.max(min, Math.min(max, value)) } /** * Internal logging method * @private */ _log(...args) { if (this.debug) { console.log("[MagnifyingGlass]", ...args) } } /** * Initialize the lens SVG elements * @private */ _initLens() { this._log("Initializing lens...") // 1. Create defs and clipPath const defs = d3.select(this.svg).append("defs") this.clipPathId = `lens-clip-${Math.random().toString(36).substr(2, 9)}` this._log("clipPathId:", this.clipPathId) defs .append("clipPath") .attr("id", this.clipPathId) .append("circle") .attr("r", this.radius) .attr("cx", 0) .attr("cy", 0) // 2. Create lens group (initially hidden) this.lensGroup = d3 .select(this.svg) .append("g") .attr("class", "magnifying-lens") .style("display", "none") // 3. Create lens border circle this.lensGroup .append("circle") .attr("class", "lens-border") .attr("r", this.radius + MagnifyingGlass.BORDER_WIDTH) .attr("fill", "rgba(255,255,255,0.95)") .attr("stroke", "#999") .attr("stroke-width", MagnifyingGlass.BORDER_WIDTH) .attr("cx", 0) // Initialize at origin, will be updated on mouse move .attr("cy", 0) // 4. Create clipped content group this.lensContent = this.lensGroup .append("g") .attr("clip-path", `url(#${this.clipPathId})`) .append("g") .attr("class", "lens-content") this._log("Lens initialized successfully") } /** * Bind keyboard and mouse events * @private */ _bindEvents() { // Space key to toggle this._handleKeyDown = (e) => { if (e.code === "Space" && !e.repeat) { e.preventDefault() this._log("Space pressed, activating...") this.toggle() } } this._handleKeyUp = (e) => { if (e.code === "Space") { this._log("Space released, deactivating...") this.deactivate() } } this._handleMouseMove = (e) => { // 记录最后鼠标位置,用于第一次激活时的位置计算 const rect = this.svg.getBoundingClientRect() this._lastMousePos = { x: e.clientX - rect.left, y: e.clientY - rect.top, } if (this.active) { this._updatePosition(e) } } this._handleClick = (e) => { if (this.active) { this._log("Clicked, deactivating...") this.deactivate() } } document.addEventListener("keydown", this._handleKeyDown) document.addEventListener("keyup", this._handleKeyUp) this.svg.addEventListener("mousemove", this._handleMouseMove) this.svg.addEventListener("click", this._handleClick) this._log("Events bound successfully") } /** * Update lens position and content based on mouse position * @private */ _updatePosition(event) { // Use requestAnimationFrame for smooth performance if (this._pendingUpdate) return this._pendingUpdate = true requestAnimationFrame(() => { this._performUpdate(event) this._pendingUpdate = false }) } /** * Perform the actual position update * @private */ _performUpdate(event) { try { // 使用 SVG 标准的坐标转换方法,代替 getBoundingClientRect() const pt = this.svg.createSVGPoint() pt.x = event.clientX pt.y = event.clientY let svgP try { // 转换为 SVG 坐标(考虑 SVG 内部所有变换) const ctm = this.svg.getScreenCTM() if (!ctm || !ctm.inverse) { // 如果 getScreenCTM() 失败,退回到简单方法 const rect = this.svg.getBoundingClientRect() svgP = { x: event.clientX - rect.left, y: event.clientY - rect.top, } } else { svgP = pt.matrixTransform(ctm.inverse()) } } catch (e) { // 容错处理 const rect = this.svg.getBoundingClientRect() svgP = { x: event.clientX - rect.left, y: event.clientY - rect.top, } } // Get current radius (dynamically calculated from viewBox) const currentRadius = this.radius // 调整放大镜位置,使其在鼠标上方,靠近下方边缘外侧 // 偏移量:向下一点,让鼠标位于放大镜底部边缘外侧 const offsetX = 0 const offsetY = currentRadius + MagnifyingGlass.LENS_OFFSET // 放大镜半径 + 偏移量 const lensX = svgP.x + offsetX const lensY = svgP.y - offsetY // 向上偏移 // Move lens group to adjusted position this.lensGroup.attr("transform", `translate(${lensX}, ${lensY})`) // 计算相对于 lensGroup 的坐标 const relativeCX = svgP.x - lensX const relativeCY = svgP.y - lensY // Update clipPath circle radius and position (radius is dynamic now) d3.select(`#${this.clipPathId} circle`) .attr("r", currentRadius) .attr("cx", relativeCX) .attr("cy", relativeCY) // Update lens border circle radius and position this.lensGroup .select(".lens-border") .attr("r", currentRadius + MagnifyingGlass.BORDER_WIDTH) .attr("cx", relativeCX) .attr("cy", relativeCY) // Update magnified content with absolute coordinates this._updateContent(svgP.x, svgP.y) } catch (error) { this._log("Error in _performUpdate:", error) // 发生错误时停用放大镜,避免持续出错 this.deactivate() } } /** * Update the magnified content * @param {number} absoluteX - Absolute X coordinate in SVG space * @param {number} absoluteY - Absolute Y coordinate in SVG space * @private */ _updateContent(absoluteX, absoluteY) { // Use D3 selection (don't convert to DOM node) const mainGroup = d3.select(this.svg).select("g") if (mainGroup.empty()) return // 只在首次或内容变化时克隆 if (!this._cachedContent || this._contentDirty) { this.lensContent.html("") const clonedContent = mainGroup.clone(true).node() this.lensContent.node().appendChild(clonedContent) this._cachedContent = clonedContent this._contentDirty = false this._log("Content cloned and cached") } // 只更新 transform this._updateTransform(absoluteX, absoluteY) } /** * Update the transform of lens content * @param {number} absoluteX - Absolute X coordinate in SVG space * @param {number} absoluteY - Absolute Y coordinate in SVG space * @private */ _updateTransform(absoluteX, absoluteY) { const scale = this.magnification const currentRadius = this.radius const offsetY = currentRadius + MagnifyingGlass.LENS_OFFSET // 正确的公式: // tx = -scale * absoluteX (让 absoluteX 变换后对应 x=0) // ty = offsetY - scale * absoluteY (让 absoluteY 变换后对应 y=offsetY,即 clipPath 圆心) const transform = `translate(${-scale * absoluteX}, ${offsetY - scale * absoluteY}) scale(${scale})` this.lensContent.attr("transform", transform) this._lastPosition = { x: absoluteX, y: absoluteY } } /** * Activate the magnifying glass */ activate() { this._log("Activating magnifier...") this.active = true this._contentDirty = true // 标记内容为脏,激活时会重新克隆 this.lensGroup.style("display", null) d3.select(this.svg).classed("magnifier-active", true) // 解决第一次激活时的位置问题 // 获取当前鼠标位置并立即更新内容 this._updateContentFromCurrentMouse() } // 获取当前鼠标位置(跨浏览器兼容) _getCurrentMousePosition() { if (typeof this._lastMousePos !== "undefined") { return this._lastMousePos } // 作为备用方案,如果没有记录位置,返回 SVG 中心 const rect = this.svg.getBoundingClientRect() return { x: rect.width / 2, y: rect.height / 2 } } // 使用当前鼠标位置更新内容 _updateContentFromCurrentMouse() { const currentMousePos = this._getCurrentMousePosition() if (currentMousePos) { // 模拟事件对象 this._performUpdate({ clientX: currentMousePos.x + this.svg.getBoundingClientRect().left, clientY: currentMousePos.y + this.svg.getBoundingClientRect().top, }) } } /** * Deactivate the magnifying glass */ deactivate() { this._log("Deactivating magnifier...") this.active = false this.lensGroup.style("display", "none") d3.select(this.svg).classed("magnifier-active", false) this._lastPosition = null } /** * Toggle magnifying glass on/off */ toggle() { this.active ? this.deactivate() : this.activate() } /** * Clean up and remove lens elements */ destroy() { this._log("Destroying...") // Remove event listeners document.removeEventListener("keydown", this._handleKeyDown) document.removeEventListener("keyup", this._handleKeyUp) this.svg.removeEventListener("mousemove", this._handleMouseMove) this.svg.removeEventListener("click", this._handleClick) // Clean up DOM elements if (this.lensGroup) this.lensGroup.remove() const defs = d3.select(this.svg).select("defs") if (defs) defs.select(`#${this.clipPathId}`).remove() // Clean up references this._cachedContent = null this.lensGroup = null this.lensContent = null this.svg = null } } ================================================ FILE: src/fastapi_voyager/web/src/main.js ================================================ import { createApp } from "vue" import App from "./App.vue" const app = createApp(App) app.mount("#app") ================================================ FILE: src/fastapi_voyager/web/src/store.js ================================================ import { reactive } from "vue" const state = reactive({ version: "", framework_name: "", config: { initial_page_policy: "first", has_er_diagram: false, enable_pydantic_resolve_meta: false, }, mode: "voyager", // voyager / er-diagram previousTagRoute: { hasValue: false, tag: null, routeId: null, }, swagger: { url: "", }, rightDrawer: { drawer: false, width: 300, }, fieldOptions: [ { label: "No field", value: "single" }, { label: "Object fields", value: "object" }, { label: "All fields", value: "all" }, ], leftPanel: { width: 300, previousWidth: 300, tags: null, fullTagsCache: null, tag: null, _tag: null, routeId: null, collapsed: false, }, graph: { schemaId: null, schemaKeys: new Set(), schemaMap: {}, routeItems: [], }, erDiagramLinks: [], erDiagramSchemas: {}, edgeDetail: { loaderFullname: null, sourceEntity: null, targetEntity: null, label: null, }, search: { mode: false, invisible: false, schemaName: null, fieldName: null, schemaOptions: [], fieldOptions: [], }, allSchemaOptions: [], routeDetail: { show: false, routeCodeId: "", }, schemaDetail: { show: false, schemaCodeName: "", }, searchDialog: { show: false, schema: null, }, status: { generating: false, loading: false, initializing: true, }, modeControl: { focus: false, briefModeEnabled: false, pydanticResolveMetaEnabled: false, }, filter: { hidePrimitiveRoute: false, showFields: "object", brief: false, showModule: false, magnification: 3.0, edgeMinlen: 3, showMethods: true, }, }) const getters = { findTagByRoute(routeId) { return ( state.leftPanel.tags.find((tag) => (tag.routes || []).some((route) => route.id === routeId)) ?.name || null ) }, } const actions = { readQuerySelection() { if (typeof window === "undefined") { return { tag: null, route: null, mode: null } } const params = new URLSearchParams(window.location.search) return { tag: params.get("tag") || null, route: params.get("route") || null, mode: params.get("mode") || null, } }, syncSelectionToUrl() { if (typeof window === "undefined") { return } const params = new URLSearchParams(window.location.search) if (state.leftPanel.tag) { params.set("tag", state.leftPanel.tag) } else { params.delete("tag") } if (state.leftPanel.routeId) { params.set("route", state.leftPanel.routeId) } else { params.delete("route") } if (state.mode) { params.set("mode", state.mode) } else { params.delete("mode") } const hash = window.location.hash || "" const search = params.toString() const base = window.location.pathname const newUrl = search ? `${base}?${search}${hash}` : `${base}${hash}` window.history.replaceState({}, "", newUrl) }, applySelectionFromQuery(selection) { let applied = false if (selection.tag && state.leftPanel.tags.some((tag) => tag.name === selection.tag)) { state.leftPanel.tag = selection.tag state.leftPanel._tag = selection.tag applied = true } if (selection.route && state.graph.routeItems?.[selection.route]) { state.leftPanel.routeId = selection.route applied = true const inferredTag = getters.findTagByRoute(selection.route) if (inferredTag) { state.leftPanel.tag = inferredTag state.leftPanel._tag = inferredTag } } if (selection.mode === "voyager" || selection.mode === "er-diagram") { state.mode = selection.mode applied = true } return applied }, loadFullTags() { state.leftPanel.tags = state.leftPanel.fullTagsCache }, populateFieldOptions(schemaId) { if (!schemaId) { state.search.fieldOptions = [] state.search.fieldName = null return } const schema = state.graph.schemaMap?.[schemaId] if (!schema) { state.search.fieldOptions = [] state.search.fieldName = null return } const fieldNames = Array.isArray(schema.fields) ? schema.fields.map((f) => f.name) : [] state.search.fieldOptions = fieldNames.map((f) => ({ label: f, value: f })) if (!fieldNames.includes(state.search.fieldName)) { state.search.fieldName = null } }, rebuildSchemaOptions() { const dict = state.graph.schemaMap || {} const opts = Object.values(dict).map((s) => ({ label: `${s.name} (${s.id})`, value: s.id, })) state.allSchemaOptions = opts state.search.schemaOptions = opts.slice() this.populateFieldOptions(state.search.schemaName) }, async loadSearchedTags() { try { const payload = { schema_name: state.search.schemaName, schema_field: state.search.fieldName || null, show_fields: state.filter.showFields, brief: state.filter.brief, hide_primitive_route: state.filter.hidePrimitiveRoute, show_module: state.filter.showModule, } const res = await fetch("dot-search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) if (res.ok) { const data = await res.json() const tags = Array.isArray(data.tags) ? data.tags : [] state.leftPanel.tags = tags } } catch (err) { console.error("dot-search failed", err) } }, async loadInitial(onGenerate, renderBasedOnInitialPolicy) { state.initializing = true try { const res = await fetch("dot") const data = await res.json() const tags = Array.isArray(data.tags) ? data.tags : [] state.leftPanel.tags = tags state.leftPanel.fullTagsCache = tags const schemasArr = Array.isArray(data.schemas) ? data.schemas : [] const schemaMap = Object.fromEntries(schemasArr.map((s) => [s.id, s])) state.graph.schemaMap = schemaMap state.graph.schemaKeys = new Set(Object.keys(schemaMap)) state.graph.routeItems = data.tags .map((t) => t.routes) .flat() .reduce((acc, r) => { acc[r.id] = r return acc }, {}) state.modeControl.briefModeEnabled = data.enable_brief_mode || false state.version = data.version || "" state.swagger.url = data.swagger_url || null state.config.has_er_diagram = data.has_er_diagram || false state.config.enable_pydantic_resolve_meta = data.enable_pydantic_resolve_meta || false state.framework_name = data.framework_name || "API" this.rebuildSchemaOptions() const querySelection = this.readQuerySelection() const restoredFromQuery = this.applySelectionFromQuery(querySelection) if (restoredFromQuery) { this.syncSelectionToUrl() onGenerate() return } else { state.config.initial_page_policy = data.initial_page_policy if ( querySelection.mode && (querySelection.mode === "voyager" || querySelection.mode === "er-diagram") ) { this.syncSelectionToUrl() onGenerate() return } renderBasedOnInitialPolicy(onGenerate) } } catch (e) { console.error("Initial load failed", e) } finally { state.initializing = false } }, onSearchSchemaChange(val, onSearch) { state.search.schemaName = val state.search.mode = false if (!val) { return } onSearch() }, resetDetailPanels() { state.rightDrawer.drawer = false state.routeDetail.show = false state.schemaDetail.schemaCodeName = "" state.edgeDetail.loaderFullname = null state.edgeDetail.sourceEntity = null state.edgeDetail.targetEntity = null state.edgeDetail.label = null }, onReset(onGenerate) { state.leftPanel.tag = null state.leftPanel._tag = null state.leftPanel.routeId = null this.syncSelectionToUrl() onGenerate() }, togglePydanticResolveMeta(val, onGenerate) { state.modeControl.pydanticResolveMetaEnabled = val try { localStorage.setItem("pydantic_resolve_meta", JSON.stringify(val)) } catch (e) { console.warn("Failed to save pydantic_resolve_meta to localStorage", e) } onGenerate() }, toggleShowModule(val, onGenerate) { state.filter.showModule = val try { localStorage.setItem("show_module_cluster", JSON.stringify(val)) } catch (e) { console.warn("Failed to save show_module_cluster to localStorage", e) } onGenerate() }, toggleShowField(field, onGenerate) { state.filter.showFields = field onGenerate(false) }, toggleBrief(val, onGenerate) { state.filter.brief = val try { localStorage.setItem("brief_mode", JSON.stringify(val)) } catch (e) { console.warn("Failed to save brief_mode to localStorage", e) } onGenerate() }, toggleHidePrimitiveRoute(val, onGenerate) { state.filter.hidePrimitiveRoute = val try { localStorage.setItem("hide_primitive", JSON.stringify(val)) } catch (e) { console.warn("Failed to save hide_primitive to localStorage", e) } onGenerate(false) }, updateMagnification(val) { const validatedValue = Math.max(2, Math.min(5, val)) state.filter.magnification = validatedValue try { localStorage.setItem("magnification", JSON.stringify(validatedValue)) } catch (e) { console.warn("Failed to save magnification to localStorage", e) } }, updateEdgeMinlen(val, onGenerate) { const validatedValue = Math.max(3, Math.min(10, val)) state.filter.edgeMinlen = validatedValue try { localStorage.setItem("edge_minlen", JSON.stringify(validatedValue)) } catch (e) { console.warn("Failed to save edge_minlen to localStorage", e) } onGenerate(true) }, toggleShowMethods(val, onGenerate) { state.filter.showMethods = val try { localStorage.setItem("show_methods", JSON.stringify(val)) } catch (e) { console.warn("Failed to save show_methods to localStorage", e) } onGenerate(false) }, renderBasedOnInitialPolicy(onGenerate) { switch (state.config.initial_page_policy) { case "full": onGenerate() return case "empty": return case "first": state.leftPanel.tag = state.leftPanel.tags.length > 0 ? state.leftPanel.tags[0].name : null state.leftPanel._tag = state.leftPanel.tag this.syncSelectionToUrl() onGenerate() return } }, buildVoyagerPayload() { const activeSchema = state.search.mode ? state.search.schemaName : null const activeField = state.search.mode ? state.search.fieldName : null return { tags: state.leftPanel.tag ? [state.leftPanel.tag] : null, schema_name: activeSchema || null, schema_field: activeField || null, route_name: state.leftPanel.routeId || null, show_fields: state.filter.showFields, brief: state.filter.brief, hide_primitive_route: state.filter.hidePrimitiveRoute, show_module: state.filter.showModule, show_pydantic_resolve_meta: state.modeControl.pydanticResolveMetaEnabled, } }, buildErDiagramPayload() { return { show_fields: state.filter.showFields, show_module: state.filter.showModule, edge_minlen: state.filter.edgeMinlen, show_methods: state.filter.showMethods, } }, resetSearchState() { state.search.mode = false state.search.schemaName = null state.search.fieldName = null state.search.fieldOptions = [] const hadPreviousValue = state.previousTagRoute.hasValue if (hadPreviousValue) { state.leftPanel.tag = state.previousTagRoute.tag state.leftPanel._tag = state.previousTagRoute.tag state.leftPanel.routeId = state.previousTagRoute.routeId state.previousTagRoute.hasValue = false } else { state.leftPanel.tag = null state.leftPanel._tag = null state.leftPanel.routeId = null } this.syncSelectionToUrl() this.loadFullTags() return hadPreviousValue }, } export const store = { state, getters, actions, } ================================================ FILE: src/fastapi_voyager/web/store.js ================================================ const { reactive } = window.Vue const state = reactive({ version: "", framework_name: "", config: { initial_page_policy: "first", has_er_diagram: false, enable_pydantic_resolve_meta: false, }, mode: "voyager", // voyager / er-diagram previousTagRoute: { // Store the last non-search tag/route selection for restoration when clearing search // Used by resetSearch to return to the state before entering search mode hasValue: false, tag: null, routeId: null, }, swagger: { url: "", }, rightDrawer: { drawer: false, width: 300, }, fieldOptions: [ { label: "No field", value: "single" }, { label: "Object fields", value: "object" }, { label: "All fields", value: "all" }, ], // tags and routes leftPanel: { width: 300, previousWidth: 300, tags: null, fullTagsCache: null, // Cache for full tags (before search) tag: null, _tag: null, routeId: null, collapsed: false, }, graph: { schemaId: null, schemaKeys: new Set(), schemaMap: {}, routeItems: [], }, // ER diagram edge metadata erDiagramLinks: [], // ER diagram schema metadata (id -> {id, name, module, fields}) erDiagramSchemas: {}, // edge detail sidebar state edgeDetail: { loaderFullname: null, sourceEntity: null, targetEntity: null, label: null, }, // schema options, schema, fields search: { mode: false, invisible: false, schemaName: null, fieldName: null, schemaOptions: [], fieldOptions: [], }, // cache all schema options for filtering allSchemaOptions: [], // route information routeDetail: { show: false, routeCodeId: "", }, // schema information schemaDetail: { show: false, schemaCodeName: "", }, searchDialog: { show: false, schema: null, }, // global status status: { generating: false, loading: false, initializing: true, }, // brief, hide primitive ... modeControl: { focus: false, // control the schema param briefModeEnabled: false, // show brief mode toggle pydanticResolveMetaEnabled: false, // show pydantic resolve meta toggle }, // api filters filter: { hidePrimitiveRoute: false, showFields: "object", brief: false, showModule: false, magnification: 3.0, // Magnifying glass zoom level (2-5) edgeMinlen: 3, // ER diagram edge minimum length (3-10) showMethods: true, // ER diagram show query/mutation methods }, }) const getters = { /** * Find tag name by route ID * Used to determine which tag a route belongs to */ findTagByRoute(routeId) { return ( state.leftPanel.tags.find((tag) => (tag.routes || []).some((route) => route.id === routeId)) ?.name || null ) }, } const actions = { /** * Read tag, route and mode from URL query parameters * @returns {{ tag: string|null, route: string|null, mode: string|null }} */ readQuerySelection() { if (typeof window === "undefined") { return { tag: null, route: null, mode: null } } const params = new URLSearchParams(window.location.search) return { tag: params.get("tag") || null, route: params.get("route") || null, mode: params.get("mode") || null, } }, /** * Sync current tag, route and mode selection to URL * Updates browser URL without reloading the page */ syncSelectionToUrl() { if (typeof window === "undefined") { return } const params = new URLSearchParams(window.location.search) if (state.leftPanel.tag) { params.set("tag", state.leftPanel.tag) } else { params.delete("tag") } if (state.leftPanel.routeId) { params.set("route", state.leftPanel.routeId) } else { params.delete("route") } // Always sync mode to URL for consistency if (state.mode) { params.set("mode", state.mode) } else { params.delete("mode") } const hash = window.location.hash || "" const search = params.toString() const base = window.location.pathname const newUrl = search ? `${base}?${search}${hash}` : `${base}${hash}` window.history.replaceState({}, "", newUrl) }, /** * Apply selection from URL query parameters to state * @param {{ tag: string|null, route: string|null, mode: string|null }} selection * @returns {boolean} - true if any selection was applied */ applySelectionFromQuery(selection) { let applied = false if (selection.tag && state.leftPanel.tags.some((tag) => tag.name === selection.tag)) { state.leftPanel.tag = selection.tag state.leftPanel._tag = selection.tag applied = true } if (selection.route && state.graph.routeItems?.[selection.route]) { state.leftPanel.routeId = selection.route applied = true const inferredTag = getters.findTagByRoute(selection.route) if (inferredTag) { state.leftPanel.tag = inferredTag state.leftPanel._tag = inferredTag } } // Apply mode from URL if it's valid if (selection.mode === "voyager" || selection.mode === "er-diagram") { state.mode = selection.mode applied = true } return applied }, /** * Restore full tags from cache * Used when resetting search mode */ loadFullTags() { state.leftPanel.tags = state.leftPanel.fullTagsCache }, /** * Populate field options based on selected schema * @param {string} schemaId - Schema ID */ populateFieldOptions(schemaId) { if (!schemaId) { state.search.fieldOptions = [] state.search.fieldName = null return } const schema = state.graph.schemaMap?.[schemaId] if (!schema) { state.search.fieldOptions = [] state.search.fieldName = null return } const fields = Array.isArray(schema.fields) ? schema.fields.map((f) => f.name) : [] state.search.fieldOptions = fields if (!fields.includes(state.search.fieldName)) { state.search.fieldName = null } }, /** * Rebuild schema options from schema map * Should be called when schema map changes */ rebuildSchemaOptions() { const dict = state.graph.schemaMap || {} const opts = Object.values(dict).map((s) => ({ label: s.name, desc: s.id, value: s.id, })) state.allSchemaOptions = opts state.search.schemaOptions = opts.slice() this.populateFieldOptions(state.search.schemaName) }, /** * Load tags based on search criteria * @returns {Promise} */ async loadSearchedTags() { try { const payload = { schema_name: state.search.schemaName, schema_field: state.search.fieldName || null, show_fields: state.filter.showFields, brief: state.filter.brief, hide_primitive_route: state.filter.hidePrimitiveRoute, show_module: state.filter.showModule, } const res = await fetch("dot-search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) if (res.ok) { const data = await res.json() const tags = Array.isArray(data.tags) ? data.tags : [] state.leftPanel.tags = tags } } catch (err) { console.error("dot-search failed", err) } }, /** * Load initial data from API * @param {Function} onGenerate - Callback to generate graph after load * @param {Function} renderBasedOnInitialPolicy - Callback to render based on policy * @returns {Promise} */ async loadInitial(onGenerate, renderBasedOnInitialPolicy) { state.initializing = true try { const res = await fetch("dot") const data = await res.json() const tags = Array.isArray(data.tags) ? data.tags : [] state.leftPanel.tags = tags // Cache the full tags for later use (e.g., resetSearch) state.leftPanel.fullTagsCache = tags const schemasArr = Array.isArray(data.schemas) ? data.schemas : [] // Build dict keyed by id for faster lookups and simpler prop passing const schemaMap = Object.fromEntries(schemasArr.map((s) => [s.id, s])) state.graph.schemaMap = schemaMap state.graph.schemaKeys = new Set(Object.keys(schemaMap)) state.graph.routeItems = data.tags .map((t) => t.routes) .flat() .reduce((acc, r) => { acc[r.id] = r return acc }, {}) state.modeControl.briefModeEnabled = data.enable_brief_mode || false state.version = data.version || "" state.swagger.url = data.swagger_url || null state.config.has_er_diagram = data.has_er_diagram || false state.config.enable_pydantic_resolve_meta = data.enable_pydantic_resolve_meta || false state.framework_name = data.framework_name || "API" this.rebuildSchemaOptions() const querySelection = this.readQuerySelection() const restoredFromQuery = this.applySelectionFromQuery(querySelection) if (restoredFromQuery) { this.syncSelectionToUrl() onGenerate() return } else { state.config.initial_page_policy = data.initial_page_policy // Check if mode was applied from URL even if tag/route wasn't if ( querySelection.mode && (querySelection.mode === "voyager" || querySelection.mode === "er-diagram") ) { this.syncSelectionToUrl() onGenerate() return } renderBasedOnInitialPolicy(onGenerate) } // default route options placeholder } catch (e) { console.error("Initial load failed", e) } finally { state.initializing = false } }, /** * Filter schema options based on search text * Used by Quasar select component's filter function * @param {string} val - Search text * @param {Function} update - Quasar update callback */ filterSearchSchemas(val, update) { const needle = (val || "").toLowerCase() update(() => { if (!needle) { state.search.schemaOptions = state.allSchemaOptions.slice() return } state.search.schemaOptions = state.allSchemaOptions.filter((option) => option.label.toLowerCase().includes(needle) ) }) }, /** * Handle schema selection change * Updates state and triggers search if a schema is selected * @param {string} val - Selected schema ID * @param {Function} onSearch - Callback to trigger search */ onSearchSchemaChange(val, onSearch) { state.search.schemaName = val state.search.mode = false if (!val) { // Clearing the select should only run resetSearch via @clear return } onSearch() }, /** * Reset detail panels (right drawer and route detail) */ resetDetailPanels() { state.rightDrawer.drawer = false state.routeDetail.show = false state.schemaDetail.schemaCodeName = "" state.edgeDetail.loaderFullname = null state.edgeDetail.sourceEntity = null state.edgeDetail.targetEntity = null state.edgeDetail.label = null }, /** * Reset left panel selection and regenerate * @param {Function} onGenerate - Callback to regenerate graph */ onReset(onGenerate) { state.leftPanel.tag = null state.leftPanel._tag = null state.leftPanel.routeId = null this.syncSelectionToUrl() onGenerate() }, /** * Toggle pydantic resolve meta visibility * @param {boolean} val - New value * @param {Function} onGenerate - Callback to regenerate graph */ togglePydanticResolveMeta(val, onGenerate) { state.modeControl.pydanticResolveMetaEnabled = val try { localStorage.setItem("pydantic_resolve_meta", JSON.stringify(val)) } catch (e) { console.warn("Failed to save pydantic_resolve_meta to localStorage", e) } onGenerate() }, /** * Toggle show module clustering * @param {boolean} val - New value * @param {Function} onGenerate - Callback to regenerate graph */ toggleShowModule(val, onGenerate) { state.filter.showModule = val try { localStorage.setItem("show_module_cluster", JSON.stringify(val)) } catch (e) { console.warn("Failed to save show_module_cluster to localStorage", e) } onGenerate() }, /** * Toggle show fields option * @param {string} field - Field display option ("single", "object", "all") * @param {Function} onGenerate - Callback to regenerate graph */ toggleShowField(field, onGenerate) { state.filter.showFields = field onGenerate(false) }, /** * Toggle brief mode * @param {boolean} val - New value * @param {Function} onGenerate - Callback to regenerate graph */ toggleBrief(val, onGenerate) { state.filter.brief = val try { localStorage.setItem("brief_mode", JSON.stringify(val)) } catch (e) { console.warn("Failed to save brief_mode to localStorage", e) } onGenerate() }, /** * Toggle hide primitive route * @param {boolean} val - New value * @param {Function} onGenerate - Callback to regenerate graph */ toggleHidePrimitiveRoute(val, onGenerate) { state.filter.hidePrimitiveRoute = val try { localStorage.setItem("hide_primitive", JSON.stringify(val)) } catch (e) { console.warn("Failed to save hide_primitive to localStorage", e) } onGenerate(false) }, /** * Update magnifying glass magnification * @param {number} val - New magnification value (2-5) */ updateMagnification(val) { const validatedValue = Math.max(2, Math.min(5, val)) state.filter.magnification = validatedValue try { localStorage.setItem("magnification", JSON.stringify(validatedValue)) } catch (e) { console.warn("Failed to save magnification to localStorage", e) } }, /** * Update ER diagram edge minimum length * @param {number} val - New edge length value (3-8) * @param {Function} onGenerate - Callback to regenerate graph */ updateEdgeMinlen(val, onGenerate) { const validatedValue = Math.max(3, Math.min(10, val)) state.filter.edgeMinlen = validatedValue try { localStorage.setItem("edge_minlen", JSON.stringify(validatedValue)) } catch (e) { console.warn("Failed to save edge_minlen to localStorage", e) } onGenerate(true) }, /** * Toggle show query/mutation methods in ER diagram * @param {boolean} val - New value * @param {Function} onGenerate - Callback to regenerate graph */ toggleShowMethods(val, onGenerate) { state.filter.showMethods = val try { localStorage.setItem("show_methods", JSON.stringify(val)) } catch (e) { console.warn("Failed to save show_methods to localStorage", e) } onGenerate(false) }, /** * Render based on initial page policy * @param {Function} onGenerate - Callback to regenerate graph */ renderBasedOnInitialPolicy(onGenerate) { switch (state.config.initial_page_policy) { case "full": onGenerate() return case "empty": return case "first": state.leftPanel.tag = state.leftPanel.tags.length > 0 ? state.leftPanel.tags[0].name : null state.leftPanel._tag = state.leftPanel.tag this.syncSelectionToUrl() onGenerate() return } }, /** * Build payload for Voyager rendering * @returns {Object} Payload for dot API */ buildVoyagerPayload() { const activeSchema = state.search.mode ? state.search.schemaName : null const activeField = state.search.mode ? state.search.fieldName : null return { tags: state.leftPanel.tag ? [state.leftPanel.tag] : null, schema_name: activeSchema || null, schema_field: activeField || null, route_name: state.leftPanel.routeId || null, show_fields: state.filter.showFields, brief: state.filter.brief, hide_primitive_route: state.filter.hidePrimitiveRoute, show_module: state.filter.showModule, show_pydantic_resolve_meta: state.modeControl.pydanticResolveMetaEnabled, } }, /** * Build payload for ER Diagram rendering * @returns {Object} Payload for er-diagram API */ buildErDiagramPayload() { return { show_fields: state.filter.showFields, show_module: state.filter.showModule, edge_minlen: state.filter.edgeMinlen, show_methods: state.filter.showMethods, } }, /** * Restore search state and return whether to regenerate * @returns {boolean} - true if should regenerate with previous selection */ resetSearchState() { state.search.mode = false // Clear search schema and field selection state.search.schemaName = null state.search.fieldName = null state.search.fieldOptions = [] const hadPreviousValue = state.previousTagRoute.hasValue if (hadPreviousValue) { state.leftPanel.tag = state.previousTagRoute.tag state.leftPanel._tag = state.previousTagRoute.tag state.leftPanel.routeId = state.previousTagRoute.routeId // Clear the saved state state.previousTagRoute.hasValue = false } else { state.leftPanel.tag = null state.leftPanel._tag = null state.leftPanel.routeId = null } this.syncSelectionToUrl() this.loadFullTags() return hadPreviousValue }, } const mutations = {} export const store = { state, getters, actions, mutations, } ================================================ FILE: src/fastapi_voyager/web/sw.js ================================================ /** * Service Worker for fastapi-voyager * * Provides caching for CDN and local static resources. * Uses version-based cache management - old caches are cleaned on version update. */ const CACHE_PREFIX = "fastapi-voyager-v" const VERSION = "" const CACHE_NAME = CACHE_PREFIX + VERSION const STATIC_PATH = "" // CDN resources to cache (cache-first strategy) const CDN_ASSETS = [ "https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js", "https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js", "https://unpkg.com/@hpcc-js/wasm@2.20.0/dist/graphviz.umd.js", "https://cdnjs.cloudflare.com/ajax/libs/d3-graphviz/5.6.0/d3-graphviz.min.js", "https://cdnjs.cloudflare.com/ajax/libs/jquery-mousewheel/3.1.13/jquery.mousewheel.min.js", "https://cdnjs.cloudflare.com/ajax/libs/jquery-color/2.1.2/jquery.color.min.js", "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css", ] // CDN domains for dynamic matching (catches all resources from these domains) const CDN_DOMAINS = [ "unpkg.com", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "fonts.googleapis.com", "fonts.gstatic.com", ] /** * Install event - pre-cache CDN resources * Uses Promise.allSettled to handle individual CDN failures gracefully */ self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { return Promise.allSettled( CDN_ASSETS.map((url) => cache.add(new Request(url, { mode: "cors" })).catch(() => { // Silently fail for individual CDN resources console.log("[Voyager SW] Failed to cache:", url) }) ) ) }) ) // Activate immediately without waiting for existing clients to close self.skipWaiting() }) /** * Activate event - clean up old version caches */ self.addEventListener("activate", (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME) .map((name) => { console.log("[Voyager SW] Deleting old cache:", name) return caches.delete(name) }) ) }) ) // Take control of all clients immediately self.clients.claim() }) /** * Fetch event - implement caching strategies */ self.addEventListener("fetch", (event) => { const url = new URL(event.request.url) // Local static resources: stale-while-revalidate // Returns cached version immediately, updates cache in background if (url.pathname.includes("fastapi-voyager-static")) { event.respondWith( caches.open(CACHE_NAME).then((cache) => { return cache.match(event.request).then((cachedResponse) => { const fetchPromise = fetch(event.request) .then((networkResponse) => { if (networkResponse.ok) { cache.put(event.request, networkResponse.clone()) } return networkResponse }) .catch(() => cachedResponse) return cachedResponse || fetchPromise }) }) ) return } // CDN resources: cache-first strategy // Match by domain to catch all CDN resources (including dynamic imports) const isCdnRequest = CDN_DOMAINS.some((domain) => url.hostname === domain) if (isCdnRequest) { event.respondWith( caches.match(event.request).then((cachedResponse) => { if (cachedResponse) { console.log("[Voyager SW] Cache hit:", event.request.url) return cachedResponse } console.log("[Voyager SW] Cache miss, fetching:", event.request.url) return fetch(event.request).then((networkResponse) => { if (networkResponse.ok) { caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, networkResponse.clone()) }) } return networkResponse }) }) ) return } // API requests: network-only (no caching) // These are dynamic and should always be fresh const apiEndpoints = [ "/dot", "/source", "/vscode-link", "/er-diagram", "/dot-search", "/dot-core-data", "/dot-render-core-data", ] if (apiEndpoints.some((p) => url.pathname.endsWith(p))) { return // Let the browser handle it normally } }) ================================================ FILE: src/fastapi_voyager/web/vite.config.js ================================================ import { defineConfig } from "vite" import vue from "@vitejs/plugin-vue" export default defineConfig({ plugins: [vue()], root: ".", base: process.env.VITE_BASE_PATH || "fastapi-voyager-static/dist/", build: { outDir: "dist", emptyOutDir: true, rollupOptions: { output: { manualChunks: { "vue-vendor": ["vue"], "naive-vendor": ["naive-ui"], }, }, }, }, server: { port: 5173, proxy: { "/dot": "http://localhost:8000", "/er-diagram": "http://localhost:8000", "/schema": "http://localhost:8000", "/source": "http://localhost:8000", "/vscode-link": "http://localhost:8000", "/route": "http://localhost:8000", "/voyager": "http://localhost:8000", }, }, }) ================================================ FILE: tests/README.md ================================================ # Tests Directory Structure This directory contains all tests for fastapi-voyager. ## Directory Structure ``` tests/ ├── __init__.py ├── test_*.py # Unit tests for individual modules ├── service/ # Shared test utilities (reused across frameworks) │ ├── __init__.py │ └── schema/ # Shared schema definitions ├── fastapi/ # FastAPI-specific test examples │ ├── __init__.py │ ├── demo.py # Demo FastAPI application │ ├── demo_anno.py # Demo with annotations │ └── embedding.py # Example of embedding voyager in FastAPI app ├── django_ninja/ # Django Ninja-specific test examples │ ├── __init__.py │ ├── demo.py # Demo Django Ninja application │ └── embedding.py # Example of embedding voyager in Django Ninja ├── litestar/ # Litestar-specific test examples │ ├── __init__.py │ ├── demo.py # Demo Litestar application │ └── embedding.py # Example of embedding voyager in Litestar └── README.md ``` ## Test Organization ### Unit Tests (`test_*.py`) - `test_analysis.py` - Core voyager analysis functionality - `test_filter.py` - Graph filtering logic - `test_generic.py` - Generic type handling - `test_import.py` - Import validation - `test_module.py` - Module tree building - `test_resolve_util_impl.py` - Pydantic resolve utilities - `test_type_helper.py` - Type extraction and analysis ### Shared Utilities (`service/`) - Reusable test utilities and schema definitions - Used across different framework tests - Contains shared Pydantic models and test data - Includes `Member`, `Sprint`, `Story`, `Task` models - Includes pydantic-resolve BaseEntity and diagram ### Framework-Specific Tests Each supported framework has its own directory with similar structure: #### FastAPI (`fastapi/`) - `demo.py` - Example FastAPI application with various route patterns - `embedding.py` - Shows how to mount voyager into FastAPI app - Demonstrates pydantic-resolve integration #### Django Ninja (`django_ninja/`) - `demo.py` - Django Ninja version of the demo application - Uses `NinjaAPI` instead of `FastAPI` - Shows similar functionality with framework-specific differences #### Litestar (`litestar/`) - `demo.py` - Litestar version using Controller pattern - Uses `@get` decorator and Controller classes - Demonstrates framework-specific patterns ## Running Tests Run all tests: ```bash uv run pytest tests/ ``` Run specific test file: ```bash uv run pytest tests/test_analysis.py ``` Run framework-specific demos: ```bash # FastAPI python tests/fastapi/embedding.py # Django Ninja (requires Django setup) # See tests/django_ninja/embedding.py for integration instructions # Litestar python tests/litestar/embedding.py ``` ## Key Differences Between Frameworks ### FastAPI ```python from fastapi import FastAPI app = FastAPI() @app.get("/path", response_model=Model) def route(): return Model() app.mount("/voyager", create_voyager(app)) ``` ### Django Ninja ```python from ninja import NinjaAPI api = NinjaAPI() @api.get("/path") def route(request) -> Model: return Model() # Integrated via Django urls.py # See embedding.py for details ``` ### Litestar ```python from litestar import Litestar, Controller class MyController(Controller): @get("/path") def path(self) -> Model: return Model() app = Litestar(route_handlers=[MyController]) app.mount("/voyager", voyager_app) ``` ## Adding Tests for New Frameworks When adding support for a new framework: 1. Create directory: `tests//` 2. Add `__init__.py` 3. Create `demo.py` with example routes - Reuse `tests.service.schema` models - Mirror FastAPI demo functionality - Use framework-specific patterns 4. Create `embedding.py` with voyager integration 5. Add introspector in `src/fastapi_voyager/introspectors/` 6. Update `Voyager._get_introspector()` to detect framework 7. Add framework-specific tests if needed All frameworks share the same: - Pydantic models from `tests.service.schema` - pydantic-resolve BaseEntity diagram - Testing patterns ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/django_ninja/__init__.py ================================================ """ Django Ninja test examples and utilities. This directory contains test applications and utilities specifically for Django Ninja framework testing. """ ================================================ FILE: tests/django_ninja/demo.py ================================================ import os import django # Configure Django settings before importing django-ninja os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.django_ninja.settings') django.setup() from dataclasses import dataclass from typing import Annotated, Generic, Optional, TypeVar from django.http import HttpResponse from ninja import NinjaAPI from pydantic import BaseModel, Field from pydantic_resolve import ( Collector, DefineSubset, ExposeAs, GraphQLHandler, Resolver, SchemaBuilder, SendTo, config_global_resolver, ) from tests.service.schema.extra import A from tests.service.schema.schema import Brand, Product, ProductVariant, User, diagram, init_db # 创建 AutoLoad 工厂(v4: 从 diagram 实例创建) AutoLoad = diagram.create_auto_load() # 配置全局 resolver config_global_resolver(diagram) # 创建 GraphQL handler 和 schema builder graphql_handler = GraphQLHandler(diagram, enable_from_attribute_in_type_adapter=True) schema_builder = SchemaBuilder(diagram) # Create Django Ninja API instance api = NinjaAPI(title="Demo API (Django Ninja)", description="A demo Django Ninja application for router visualization") @api.get("/products", tags=['for-restapi', 'group_a']) def get_products(request) -> list[Product]: return [] # ===================================== # GraphQL Support # ===================================== class GraphQLRequest(BaseModel): query: str operationName: Optional[str] = None # GraphiQL Playground HTML GRAPHIQL_HTML = """ GraphiQL - Django Ninja Demo
Loading…
""" # ===================================== # GraphQL 视图函数 (用于 urls.py 直接引用) # ===================================== def graphiql_playground(request) -> HttpResponse: """GraphiQL 交互式查询界面 (GET)""" return HttpResponse(GRAPHIQL_HTML, content_type="text/html") def graphql_endpoint(request): """GraphQL 查询端点 (POST)""" import json import asyncio body = json.loads(request.body) query = body.get('query', '') result = asyncio.run(graphql_handler.execute(query=query)) return HttpResponse(json.dumps(result), content_type="application/json") def graphql_schema(request) -> HttpResponse: """GraphQL Schema 端点 (GET)""" schema_sdl = schema_builder.build_schema() return HttpResponse(schema_sdl, content_type="text/plain; charset=utf-8") # ===================================== # Django Ninja API 路由 # ===================================== @api.get("/graphql", tags=['graphql']) def api_graphiql_playground(request) -> HttpResponse: """GraphiQL 交互式查询界面""" return HttpResponse(GRAPHIQL_HTML, content_type="text/html") @api.post("/graphql", tags=['graphql']) async def api_graphql_endpoint(request, req: GraphQLRequest): """GraphQL 查询端点""" result = await graphql_handler.execute(query=req.query) return result @api.get("/graphql/schema", tags=['graphql']) def api_graphql_schema(request) -> HttpResponse: """GraphQL Schema 端点(返回 SDL 格式)""" schema_sdl = schema_builder.build_schema() return HttpResponse(schema_sdl, content_type="text/plain; charset=utf-8") # ===================================== # Page Models # ===================================== class PageUser(User): display_name: str = '' def post_display_name(self): return self.username + ' (' + self.email + ')' sh: 'Something' # forward reference @dataclass class Something: id: int class VariantA(ProductVariant): variant_type: str = 'A' class VariantB(ProductVariant): variant_type: str = 'B' type VariantUnion = VariantA | VariantB class PageVariant(ProductVariant): owner: Annotated[PageUser | None, AutoLoad()] = None class MiddleProduct(DefineSubset): __subset__ = (Product, ('id', 'name', 'price', 'category_id')) class PageProduct(DefineSubset): __subset__ = (Product, ('id', 'name')) price: Annotated[float, ExposeAs('product_price')] = Field(exclude=True) def post_price_label(self): return f'¥{self.price}' desc: Annotated[str, ExposeAs('product_desc')] = '' def resolve_desc(self): return self.desc def post_desc(self): return self.desc + ' (processed........................)' variants: Annotated[list[PageVariant], AutoLoad(), SendTo("SomeCollector")] = [] coll: list[str] = [] def post_coll(self, c=Collector(alias="top_collector")): return c.values() class PageBrand(Brand): products: list[PageProduct] class PageOverall(BaseModel): brands: list[PageBrand] class PageOverallWrap(PageOverall): content: str all_variants: list[PageVariant] = [] def post_all_variants(self, collector=Collector(alias="SomeCollector")): return collector.values() @api.get("/page_overall", tags=['for-ui-page']) async def get_page_info(request) -> PageOverallWrap: page_overall = PageOverallWrap(content="Page Overall Content", brands=[]) return await Resolver().resolve(page_overall) class PageProducts(BaseModel): products: list[PageProduct] @api.get("/page_info/", tags=['for-ui-page']) def get_page_stories(request) -> PageProducts: return {} T = TypeVar('T') class DataModel(BaseModel, Generic[T]): data: T id: int type DataModelPageProduct = DataModel[PageProduct] @api.get("/page_test_1/", tags=['for-ui-page']) def get_page_test_1(request) -> DataModelPageProduct: return {} @api.get("/page_test_2/", tags=['for-ui-page']) def get_page_test_2(request) -> A: return {} @api.get("/page_test_3/", tags=['for-ui-page']) def get_page_test_3_long_long_long_name(request) -> bool: return True @api.get("/page_test_4/", tags=['for-ui-page']) def get_page_test_3_no_response_model(request): return True @api.get("/page_test_5/", tags=['long_long_long_tag_name', 'group_b']) def get_page_test_3_no_response_model_long_long_long_name(request): return True ================================================ FILE: tests/django_ninja/embedding.py ================================================ """ Django Ninja embedding example for fastapi-voyager. This module demonstrates how to integrate voyager with a Django Ninja application. """ import os import django from django.core.asgi import get_asgi_application # Configure Django settings before importing django-ninja os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.django_ninja.settings") django.setup() from fastapi_voyager import create_voyager from tests.django_ninja.demo import api, diagram # Create the voyager ASGI application # Note: create_voyager automatically detects Django Ninja and returns an ASGI app voyager_asgi_app = create_voyager( api, er_diagram=diagram, module_color={"tests.service": "purple"}, module_prefix="tests.service", swagger_url="/api/docs", # Django Ninja's swagger URL initial_page_policy="first", ga_id="G-R64S7Q49VL", online_repo_url="https://github.com/allmonday/fastapi-voyager/blob/main", enable_pydantic_resolve_meta=True, ) async def application(scope, receive, send): """ ASGI application that routes between Django and Voyager. This is a simple router that: - Sends /voyager/* requests to the voyager UI - Sends everything else to Django For production, you might want to use Django's URL routing instead. """ # Route /voyager/* to voyager_app if scope["type"] == "http" and scope["path"].startswith("/voyager"): return await voyager_asgi_app(scope, receive, send) else: # Pass everything else to Django's ASGI application django_asgi_app = get_asgi_application() return await django_asgi_app(scope, receive, send) # Export app for uvicorn app = application # ALTERNATIVE: Integration with Django URLs # ========================================== # If you prefer to integrate voyager through Django's URL system, # you can use the following approach in your Django project's urls.py: # # from django.urls import path # from tests.django_ninja.embedding import voyager_asgi_app # # def voyager_wrapper(request): # '''Wrap voyager ASGI app for Django''' # async def asgi_wrapper(receive, send): # scope = { # 'type': 'http', # 'asgi': {'version': '3.0'}, # 'http_method': request.method, # 'path': request.path.replace('/voyager', '') or '/', # 'query_string': request.META.get('QUERY_STRING', '').encode(), # 'headers': [ # (k.lower().encode(), v.encode()) # for k, v in request.META.items() # if k.startswith('HTTP_') # ], # } # await voyager_asgi_app(scope, receive, send) # # return asgi_wrapper # # urlpatterns = [ # path('voyager/', voyager_wrapper), # # ... other URL patterns # ] ================================================ FILE: tests/django_ninja/settings.py ================================================ """ Minimal Django settings for django-ninja test app. """ from pathlib import Path # Build paths BASE_DIR = Path(__file__).resolve().parent.parent # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = 'django-insecure-test-key-for-development-only' # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = ['*'] # Application definition INSTALLED_APPS = [ 'django.contrib.contenttypes', 'django.contrib.auth', 'ninja', # django-ninja ] MIDDLEWARE = [] ROOT_URLCONF = 'tests.django_ninja.urls' TEMPLATES = [] # Database DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', } } # Internationalization LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) STATIC_URL = 'static/' # Default primary key field type DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' ================================================ FILE: tests/django_ninja/urls.py ================================================ """ URL configuration for django-ninja test app. """ from django.urls import path from django.views.decorators.csrf import csrf_exempt from tests.django_ninja import demo def graphql_view(request): """统一处理 GET 和 POST 请求""" if request.method == 'GET': return demo.graphiql_playground(request) elif request.method == 'POST': return demo.graphql_endpoint(request) return demo.HttpResponse('Method not allowed', status=405) urlpatterns = [ path('api/', demo.api.urls), # GraphQL endpoints at root path path('graphql', csrf_exempt(graphql_view)), path('graphql/', csrf_exempt(graphql_view)), path('graphql/schema', demo.graphql_schema), ] ================================================ FILE: tests/embedding_test_utils.py ================================================ """ Shared utilities for testing embedding services across different frameworks. This module provides common test functions that can be reused across FastAPI, Django Ninja, and Litestar embedding tests. """ import httpx import pytest # Expected routes - same across all frameworks after standardization EXPECTED_ROUTES = [ "get_products", "get_page_info", "get_page_stories", "get_page_test_1", "get_page_test_2", "get_page_test_3_long_long_long_name", "get_page_test_3_no_response_model", "get_page_test_3_no_response_model_long_long_long_name", ] async def test_dot_endpoint_returns_success(async_client: httpx.AsyncClient): """Test that /voyager/dot endpoint returns 200 OK.""" response = await async_client.get("/voyager/dot") assert response.status_code == 200 async def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient): """Test that /voyager/dot endpoint returns tags data.""" response = await async_client.get("/voyager/dot") assert response.status_code == 200 data = response.json() # Check that tags key exists and is a list assert "tags" in data assert isinstance(data["tags"], list) # Should have tags defined in demo.py tags = data["tags"] tag_names = [tag["name"] for tag in tags] # Check expected tags from demo.py assert "for-restapi" in tag_names assert "for-ui-page" in tag_names assert "long_long_long_tag_name" in tag_names # Note: group_a and group_b tags might not be returned if they're not # properly recognized by the framework introspection # This is acceptable behavior as tag filtering varies by framework async def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncClient): """Test that tags have associated routes.""" response = await async_client.get("/voyager/dot") assert response.status_code == 200 data = response.json() tags = data["tags"] # Each tag should have routes for tag in tags: assert "routes" in tag assert isinstance(tag["routes"], list) # Routes should be sorted by name route_names = [r["name"] for r in tag["routes"]] assert route_names == sorted(route_names) async def test_dot_endpoint_routes_structure(async_client: httpx.AsyncClient): """Test that routes have correct structure.""" response = await async_client.get("/voyager/dot") assert response.status_code == 200 data = response.json() tags = data["tags"] # Find a tag with routes and check route structure for tag in tags: if tag["routes"]: route = tag["routes"][0] # Check required fields assert "id" in route assert "name" in route assert "module" in route assert "unique_id" in route # Check types assert isinstance(route["id"], str) assert isinstance(route["name"], str) assert isinstance(route["module"], str) assert isinstance(route["unique_id"], str) break else: pytest.fail("No routes found in any tag") async def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient, expected_framework_name: str): """Test other required fields in /dot response.""" response = await async_client.get("/voyager/dot") assert response.status_code == 200 data = response.json() # Check other required fields assert "schemas" in data assert isinstance(data["schemas"], list) assert "dot" in data assert isinstance(data["dot"], str) assert "version" in data assert isinstance(data["version"], str) assert "initial_page_policy" in data assert data["initial_page_policy"] in ["first", "full", "empty"] assert "framework_name" in data assert isinstance(data["framework_name"], str) assert data["framework_name"] == expected_framework_name assert "has_er_diagram" in data assert isinstance(data["has_er_diagram"], bool) assert "enable_pydantic_resolve_meta" in data assert isinstance(data["enable_pydantic_resolve_meta"], bool) assert data["enable_pydantic_resolve_meta"] is True async def test_dot_endpoint_expected_routes( async_client: httpx.AsyncClient, expected_routes: list[str] ): """Test that expected routes from demo.py are present.""" response = await async_client.get("/voyager/dot") assert response.status_code == 200 data = response.json() # Collect all route names all_routes = [] for tag in data["tags"]: for route in tag["routes"]: all_routes.append(route["name"]) # Check expected routes for expected_route in expected_routes: assert expected_route in all_routes, f"Expected route '{expected_route}' not found" ================================================ FILE: tests/fastapi/__init__.py ================================================ """ FastAPI test examples and utilities. This directory contains test applications and utilities specifically for FastAPI framework testing. These can be used as examples or for testing the introspector implementation. """ ================================================ FILE: tests/fastapi/demo.py ================================================ from contextlib import asynccontextmanager from dataclasses import dataclass from typing import Annotated, Generic, Optional, TypeVar from fastapi import FastAPI from fastapi.responses import HTMLResponse, PlainTextResponse from pydantic import BaseModel, Field from pydantic_resolve import ( Collector, DefineSubset, ExposeAs, GraphQLHandler, Resolver, SchemaBuilder, SendTo, config_global_resolver, ) from tests.service.schema.extra import A from tests.service.schema.schema import ( Brand, Order, Product, ProductVariant, User, diagram, init_db, ) # 创建 AutoLoad 工厂(v4: 从 diagram 实例创建) AutoLoad = diagram.create_auto_load() # 配置全局 resolver config_global_resolver(diagram) # 创建 GraphQL handler 和 schema builder graphql_handler = GraphQLHandler(diagram, enable_from_attribute_in_type_adapter=True) schema_builder = SchemaBuilder(diagram) @asynccontextmanager async def lifespan(app): await init_db() yield app = FastAPI(title="Demo API", description="A demo FastAPI application for router visualization", lifespan=lifespan) @app.get("/products", tags=['for-restapi', 'group_a'], response_model=list[Product]) def get_products(): return [] # ===================================== # GraphQL Support # ===================================== class GraphQLRequest(BaseModel): query: str operationName: Optional[str] = None # GraphiQL Playground HTML GRAPHIQL_HTML = """ GraphiQL - FastAPI Demo
Loading…
""" @app.get("/graphql", response_class=HTMLResponse, tags=['graphql']) async def graphiql_playground(): """GraphiQL 交互式查询界面""" return GRAPHIQL_HTML @app.post("/graphql", tags=['graphql']) async def graphql_endpoint(req: GraphQLRequest): """GraphQL 查询端点""" result = await graphql_handler.execute(query=req.query) return result @app.get("/schema", response_class=PlainTextResponse, tags=['graphql']) async def graphql_schema(): """GraphQL Schema 端点(返回 SDL 格式)""" schema_sdl = schema_builder.build_schema() return PlainTextResponse( content=schema_sdl, media_type="text/plain; charset=utf-8" ) # ===================================== # Page Models # ===================================== class PageUser(User): display_name: str = '' def post_display_name(self): return self.username + ' (' + self.email + ')' sh: 'Something' # forward reference @dataclass class Something: id: int class VariantA(ProductVariant): variant_type: str = 'A' class VariantB(ProductVariant): variant_type: str = 'B' type VariantUnion = VariantA | VariantB class PageVariant(ProductVariant): product: Annotated[Product | None, AutoLoad()] = None class MiddleProduct(DefineSubset): __subset__ = (Product, ('id', 'name', 'price', 'category_id')) class PageProduct(DefineSubset): __subset__ = (Product, ('id', 'name')) price: Annotated[float, ExposeAs('product_price')] = Field(exclude=True) def post_price_label(self): return f'¥{self.price}' desc: Annotated[str, ExposeAs('product_desc')] = '' def resolve_desc(self): return self.desc def post_desc(self): return self.name + ' (processed........................)' variants: Annotated[list[PageVariant], AutoLoad(), SendTo("SomeCollector")] = [] owner: PageUser | None = None # placeholder, not a real relationship union_variants: list[VariantUnion] = [] coll: list[str] = [] def post_coll(self, c=Collector(alias="top_collector")): return c.values() class PageBrand(Brand): products: list[PageProduct] owner: PageUser | None = None class PageOverall(BaseModel): brands: list[PageBrand] class PageOverallWrap(PageOverall): content: str all_variants: list[PageVariant] = [] def post_all_variants(self, collector=Collector(alias="SomeCollector")): return collector.values() @app.get("/page_overall", tags=['for-ui-page'], response_model=PageOverallWrap) async def get_page_info(): page_overall = PageOverallWrap(content="Page Overall Content", brands=[]) return await Resolver().resolve(page_overall) class PageProducts(BaseModel): products: list[PageProduct] @app.get("/page_info/", tags=['for-ui-page'], response_model=PageProducts) def get_page_stories(): return {} # no implementation T = TypeVar('T') class DataModel(BaseModel, Generic[T]): data: T id: int type DataModelPageProduct = DataModel[PageProduct] @app.get("/page_test_1/", tags=['for-ui-page'], response_model=DataModelPageProduct) def get_page_test_1(): return {} # no implementation @app.get("/page_test_2/", tags=['for-ui-page'], response_model=A) def get_page_test_2(): return {} @app.get("/page_test_3/", tags=['for-ui-page'], response_model=bool) def get_page_test_3_long_long_long_name(): return True @app.get("/page_test_4/", tags=['for-ui-page']) def get_page_test_3_no_response_model(): return True @app.get("/page_test_5/", tags=['long_long_long_tag_name', 'group_b']) def get_page_test_3_no_response_model_long_long_long_name(): return True for r in app.router.routes: r.operation_id = r.name ================================================ FILE: tests/fastapi/demo_anno.py ================================================ from __future__ import annotations from typing import Annotated from fastapi import FastAPI from pydantic import BaseModel, Field from pydantic_resolve import Resolver, ensure_subset from tests.service.schema.schema import Product, ProductVariant, User app = FastAPI(title="Demo API", description="A demo FastAPI application for router visualization") @app.get("/products", tags=['for-restapi'], response_model=list[Product]) def get_product(): return [] class PageUser(User): display_name: str = '' def post_display_name(self): return self.username + ' (' + self.email + ')' class VariantA(ProductVariant): variant_type: str = 'A' class VariantB(ProductVariant): variant_type: str = 'B' type VariantUnion = VariantA | VariantB class PageVariant(ProductVariant): product: PageUser | None class PageOverall(BaseModel): brands: Annotated[list[PageBrand], Field(description="List of brands")] class PageBrand(Product): products: Annotated[list[PageProduct], Field(description="List of products")] owner: Annotated[PageUser | None, Field(description="Owner of the brand")] = None @ensure_subset(Product) class PageProduct(BaseModel): id: int name: str price: float = Field(exclude=True) desc: str = '' def post_desc(self): return self.name + ' (processed)' variants: list[PageVariant] = [] owner: PageUser | None = None union_variants: list[VariantUnion] = [] @app.get("/page_overall", tags=['for-page'], response_model=PageOverall) async def get_page_info(): page_overall = PageOverall(brands=[]) # focus on schema only return await Resolver().resolve(page_overall) ================================================ FILE: tests/fastapi/embedding.py ================================================ from fastapi_voyager import create_voyager # from tests.fastapi.demo_anno import app from tests.fastapi.demo import app, diagram app.mount( '/voyager', create_voyager( app, er_diagram=diagram, module_color={"tests.service": "purple"}, module_prefix="tests.service", swagger_url="/docs", initial_page_policy='first', ga_id='G-R64S7Q49VL', online_repo_url="https://github.com/allmonday/fastapi-voyager/blob/main", enable_pydantic_resolve_meta=True)) ================================================ FILE: tests/litestar/__init__.py ================================================ """ Litestar test examples and utilities. This directory contains test applications and utilities specifically for Litestar framework testing. """ ================================================ FILE: tests/litestar/demo.py ================================================ from dataclasses import dataclass from typing import Annotated, Generic, Optional, TypeVar from litestar import Controller, Litestar, Request, Response, get, post from litestar.handlers import HTTPRouteHandler from pydantic import BaseModel, Field from pydantic_resolve import ( Collector, DefineSubset, ExposeAs, GraphQLHandler, Resolver, SchemaBuilder, SendTo, config_global_resolver, ) from tests.service.schema.extra import A from tests.service.schema.schema import Brand, Product, ProductVariant, User, diagram, init_db # 创建 AutoLoad 工厂(v4: 从 diagram 实例创建) AutoLoad = diagram.create_auto_load() # 配置全局 resolver config_global_resolver(diagram) # 创建 GraphQL handler 和 schema builder graphql_handler = GraphQLHandler(diagram, enable_from_attribute_in_type_adapter=True) schema_builder = SchemaBuilder(diagram) # ===================================== # GraphQL Support # ===================================== class GraphQLRequest(BaseModel): query: str operationName: Optional[str] = None # GraphiQL Playground HTML GRAPHIQL_HTML = """ GraphiQL - Litestar Demo
Loading…
""" # ===================================== # GraphQL Controllers (root path) # ===================================== class GraphQLController(Controller): """GraphQL endpoints at root path""" path = "" @get("/graphql", tags=['graphql']) async def graphiql_playground(self) -> Response[str]: """GraphiQL 交互式查询界面""" return Response(content=GRAPHIQL_HTML, media_type="text/html") @post("/graphql", tags=['graphql']) async def graphql_endpoint(self, data: GraphQLRequest) -> dict: """GraphQL 查询端点""" result = await graphql_handler.execute(query=data.query) return result @get("/graphql/schema", tags=['graphql']) async def graphql_schema(self) -> Response[str]: """GraphQL Schema 端点(返回 SDL 格式)""" schema_sdl = schema_builder.build_schema() return Response(content=schema_sdl, media_type="text/plain; charset=utf-8") # ===================================== # Page Models # ===================================== class PageUser(User): display_name: str = '' def post_display_name(self): return self.username + ' (' + self.email + ')' sh: 'Something' # forward reference @dataclass class Something: id: int class VariantA(ProductVariant): variant_type: str = 'A' class VariantB(ProductVariant): variant_type: str = 'B' type VariantUnion = VariantA | VariantB class PageVariant(ProductVariant): product: Annotated[Product | None, AutoLoad()] = None class MiddleProduct(DefineSubset): __subset__ = (Product, ('id', 'name', 'price', 'category_id')) class PageProduct(DefineSubset): __subset__ = (Product, ('id', 'name')) price: Annotated[float, ExposeAs('product_price')] = Field(exclude=True) def post_price_label(self): return f'¥{self.price}' desc: Annotated[str, ExposeAs('product_desc')] = '' def resolve_desc(self): return self.desc def post_desc(self): return self.name + ' (processed........................)' variants: Annotated[list[PageVariant], AutoLoad(), SendTo("SomeCollector")] = [] coll: list[str] = [] def post_coll(self, c=Collector(alias="top_collector")): return c.values() class PageBrand(Brand): products: list[PageProduct] class PageOverall(BaseModel): brands: list[PageBrand] class PageOverallWrap(PageOverall): content: str all_variants: list[PageVariant] = [] def post_all_variants(self, collector=Collector(alias="SomeCollector")): return collector.values() class PageProducts(BaseModel): products: list[PageProduct] T = TypeVar('T') class DataModel(BaseModel, Generic[T]): data: T id: int type DataModelPageProduct = DataModel[PageProduct] class DemoController(Controller): path = "/demo" @get("/products", tags=['for-restapi', 'group_a'], sync_to_thread=False) def get_products(self) -> list[Product]: return [] @get("/page_overall", tags=['for-ui-page']) async def get_page_info(self) -> PageOverallWrap: page_overall = PageOverallWrap(content="Page Overall Content", brands=[]) return await Resolver().resolve(page_overall) @get("/page_info/", tags=['for-ui-page'], sync_to_thread=False) def get_page_stories(self) -> PageProducts: return {} @get("/page_test_1/", tags=['for-ui-page'], sync_to_thread=False) def get_page_test_1(self) -> DataModelPageProduct: return {} @get("/page_test_2/", tags=['for-ui-page'], sync_to_thread=False) def get_page_test_2(self) -> A: return {} @get("/page_test_3/", tags=['for-ui-page'], sync_to_thread=False) def get_page_test_3_long_long_long_name(self) -> bool: return True @get("/page_test_4/", tags=['for-ui-page'], sync_to_thread=False) def get_page_test_3_no_response_model(self) -> bool: return True @get("/page_test_5/", tags=['long_long_long_tag_name', 'group_b'], sync_to_thread=False) def get_page_test_3_no_response_model_long_long_long_name(self) -> bool: return True # Export route handlers for extension (e.g., adding voyager) ROUTE_HANDLERS = [GraphQLController, DemoController] # Create a Litestar app instance - this is the main app that can be run directly app = Litestar( route_handlers=ROUTE_HANDLERS ) ================================================ FILE: tests/litestar/embedding.py ================================================ """ Litestar embedding example for fastapi-voyager. This module demonstrates how to integrate voyager with a Litestar application. Unlike FastAPI, Litestar doesn't support mounting to an existing app after creation. The recommended pattern is to reuse the ROUTE_HANDLERS from demo.py. """ from typing import Any, Awaitable, Callable from litestar import Litestar, asgi from fastapi_voyager import create_voyager from tests.litestar.demo import ROUTE_HANDLERS, app as demo_app, diagram # Create voyager app (returns a Litestar app) voyager_app = create_voyager( demo_app, er_diagram=diagram, module_color={"tests.service": "purple"}, module_prefix="tests.service", swagger_url="/schema/swagger", initial_page_policy='first', ga_id='G-R64S7Q49VL', online_repo_url="https://github.com/allmonday/fastapi-voyager/blob/main", enable_pydantic_resolve_meta=True ) # Mount voyager using Litestar's @asgi() decorator @asgi("/voyager", is_mount=True, copy_scope=True) async def voyager_mount( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]] ) -> None: await voyager_app(scope, receive, send) # Create combined app by reusing ROUTE_HANDLERS from demo.py # This is the recommended pattern for Litestar app = Litestar(route_handlers=ROUTE_HANDLERS + [voyager_mount]) # Exports # - Use `uvicorn tests.litestar.embedding:app --reload` for combined app # - Use `uvicorn tests.litestar.embedding:demo_app --reload` for demo only ================================================ FILE: tests/service/__init__.py ================================================ ================================================ FILE: tests/service/schema/__init__.py ================================================ from .schema import ( Attribute, AttributeValue, Brand, Category, Coupon, CouponUsage, Inventory, Order, OrderItem, Payment, Product, ProductImage, ProductVariant, Refund, Review, Shipment, ShipmentItem, Store, Tag, User, UserAddress, Warehouse, diagram, init_db, ) __all__ = [ "Attribute", "AttributeValue", "Brand", "Category", "Coupon", "CouponUsage", "Inventory", "Order", "OrderItem", "Payment", "Product", "ProductImage", "ProductVariant", "Refund", "Review", "Shipment", "ShipmentItem", "Store", "Tag", "User", "UserAddress", "Warehouse", "diagram", "init_db", ] ================================================ FILE: tests/service/schema/base_entity.py ================================================ from pydantic_resolve import base_entity BaseEntity = base_entity() ================================================ FILE: tests/service/schema/db.py ================================================ """ SQLAlchemy async engine and session factory for test schema. Uses SQLite in-memory for testing/demo purposes. """ from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, create_async_engine, ) from sqlalchemy.orm import DeclarativeBase engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) class OrmBase(DeclarativeBase): pass async def create_tables(): async with engine.begin() as conn: await conn.run_sync(OrmBase.metadata.create_all) ================================================ FILE: tests/service/schema/dto/__init__.py ================================================ from .attribute import Attribute, AttributeValue from .inventory import Inventory, Warehouse from .marketing import Coupon, CouponUsage from .order import Order, OrderItem, Payment, Refund from .product import Brand, Category, Product, ProductImage, ProductVariant, Review from .shipment import Shipment, ShipmentItem from .store import Store from .tag import Tag from .user import User, UserAddress __all__ = [ "Attribute", "AttributeValue", "Brand", "Category", "Coupon", "CouponUsage", "Inventory", "Order", "OrderItem", "Payment", "Product", "ProductImage", "ProductVariant", "Refund", "Review", "Shipment", "ShipmentItem", "Store", "Tag", "User", "UserAddress", "Warehouse", ] ================================================ FILE: tests/service/schema/dto/attribute.py ================================================ """ Attribute and AttributeValue DTOs. """ from pydantic import BaseModel, ConfigDict, Field class Attribute(BaseModel): """属性定义""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="属性 ID") name: str = Field(description="属性名称") class AttributeValue(BaseModel): """属性值""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="属性值 ID") attribute_id: int = Field(description="属性 ID") value: str = Field(description="属性值") ================================================ FILE: tests/service/schema/dto/inventory.py ================================================ """ Warehouse and Inventory DTOs. """ from pydantic import BaseModel, ConfigDict, Field class Warehouse(BaseModel): """仓库""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="仓库 ID") name: str = Field(description="仓库名称") location: str = Field(description="仓库位置") class Inventory(BaseModel): """库存""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="库存 ID") warehouse_id: int = Field(description="仓库 ID") variant_id: int = Field(description="商品规格 ID") quantity: int = Field(default=0, description="库存数量") ================================================ FILE: tests/service/schema/dto/marketing.py ================================================ """ Coupon and CouponUsage DTOs. """ from pydantic import BaseModel, ConfigDict, Field class Coupon(BaseModel): """优惠券""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="优惠券 ID") code: str = Field(description="优惠券代码") discount: float = Field(description="折扣金额") min_amount: float = Field(default=0, description="最低消费金额") status: str = Field(default="active", description="状态") class CouponUsage(BaseModel): """优惠券使用记录""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="使用记录 ID") coupon_id: int = Field(description="优惠券 ID") user_id: int = Field(description="用户 ID") order_id: int = Field(description="订单 ID") ================================================ FILE: tests/service/schema/dto/order.py ================================================ """ Order, OrderItem, Payment, Refund DTOs. """ from typing import Optional from pydantic import BaseModel, ConfigDict, Field class Order(BaseModel): """订单""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="订单 ID") user_id: int = Field(description="用户 ID") status: str = Field(default="pending", description="订单状态") total_amount: float = Field(default=0, description="订单总额") class OrderItem(BaseModel): """订单明细""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="明细 ID") order_id: int = Field(description="订单 ID") variant_id: int = Field(description="商品规格 ID") quantity: int = Field(description="数量") unit_price: float = Field(description="单价") class Payment(BaseModel): """支付记录""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="支付 ID") order_id: int = Field(description="订单 ID") method: str = Field(description="支付方式") amount: float = Field(description="支付金额") status: str = Field(default="pending", description="支付状态") class Refund(BaseModel): """退款""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="退款 ID") order_id: int = Field(description="订单 ID") amount: float = Field(description="退款金额") reason: Optional[str] = Field(default=None, description="退款原因") status: str = Field(default="pending", description="退款状态") ================================================ FILE: tests/service/schema/dto/product.py ================================================ """ Product, ProductVariant, ProductImage, Brand, Category, Review DTOs. """ from typing import Optional from pydantic import BaseModel, ConfigDict, Field class Category(BaseModel): """商品分类""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="分类 ID") name: str = Field(description="分类名称") parent_id: Optional[int] = Field(default=None, description="父分类 ID") class Brand(BaseModel): """品牌""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="品牌 ID") name: str = Field(description="品牌名称") logo: Optional[str] = Field(default=None, description="品牌 Logo URL") class Product(BaseModel): """商品""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="商品 ID") name: str = Field(description="商品名称") description: Optional[str] = Field(default=None, description="商品描述") price: float = Field(description="价格") brand_id: Optional[int] = Field(default=None, description="品牌 ID") category_id: Optional[int] = Field(default=None, description="分类 ID") store_id: Optional[int] = Field(default=None, description="店铺 ID") class ProductVariant(BaseModel): """商品规格 (SKU)""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="规格 ID") product_id: int = Field(description="商品 ID") sku: str = Field(description="SKU 编码") price: float = Field(description="规格价格") stock: int = Field(default=0, description="库存数量") class ProductImage(BaseModel): """商品图片""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="图片 ID") product_id: int = Field(description="商品 ID") url: str = Field(description="图片 URL") sort_order: int = Field(default=0, description="排序") class Review(BaseModel): """商品评价""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="评价 ID") product_id: int = Field(description="商品 ID") user_id: int = Field(description="用户 ID") rating: int = Field(description="评分 (1-5)") content: Optional[str] = Field(default=None, description="评价内容") ================================================ FILE: tests/service/schema/dto/shipment.py ================================================ """ Shipment and ShipmentItem DTOs. """ from typing import Optional from pydantic import BaseModel, ConfigDict, Field class Shipment(BaseModel): """发货单""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="发货单 ID") warehouse_id: int = Field(description="仓库 ID") store_id: Optional[int] = Field(default=None, description="店铺 ID") status: str = Field(default="pending", description="发货状态") tracking_no: Optional[str] = Field(default=None, description="物流单号") class ShipmentItem(BaseModel): """发货明细""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="发货明细 ID") shipment_id: int = Field(description="发货单 ID") order_item_id: int = Field(description="订单明细 ID") quantity: int = Field(description="发货数量") ================================================ FILE: tests/service/schema/dto/store.py ================================================ """ Store DTO. """ from typing import Optional from pydantic import BaseModel, ConfigDict, Field class Store(BaseModel): """店铺""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="店铺 ID") name: str = Field(description="店铺名称") description: Optional[str] = Field(default=None, description="店铺描述") ================================================ FILE: tests/service/schema/dto/tag.py ================================================ """ Tag DTO. """ from pydantic import BaseModel, ConfigDict, Field class Tag(BaseModel): """标签""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="标签 ID") name: str = Field(description="标签名称") ================================================ FILE: tests/service/schema/dto/user.py ================================================ """ User and UserAddress DTOs. """ from typing import Optional from pydantic import BaseModel, ConfigDict, Field class User(BaseModel): """用户""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="用户唯一标识 ID") username: str = Field(description="用户名") email: str = Field(description="邮箱") phone: Optional[str] = Field(default=None, description="手机号") class UserAddress(BaseModel): """用户地址""" model_config = ConfigDict(from_attributes=True) id: int = Field(description="地址 ID") user_id: int = Field(description="用户 ID") province: str = Field(description="省份") city: str = Field(description="城市") district: str = Field(description="区县") detail: str = Field(description="详细地址") is_default: bool = Field(default=False, description="是否默认地址") ================================================ FILE: tests/service/schema/extra.py ================================================ from pydantic import BaseModel class B(BaseModel): id: int class A(BaseModel): id: int b: B ================================================ FILE: tests/service/schema/orm/__init__.py ================================================ from .attribute import AttributeOrm, AttributeValueOrm from .inventory import InventoryOrm, WarehouseOrm from .marketing import CouponOrm, CouponUsageOrm from .order import OrderItemOrm, OrderOrm, PaymentOrm, RefundOrm from .product import ( BrandOrm, CategoryOrm, ProductImageOrm, ProductOrm, ProductVariantOrm, ReviewOrm, TagOrm, ) from .shipment import ShipmentItemOrm, ShipmentOrm from .store import StoreOrm from .tables import product_attribute, product_tag, store_staff from .user import UserAddressOrm, UserOrm __all__ = [ "AttributeOrm", "AttributeValueOrm", "BrandOrm", "CategoryOrm", "CouponOrm", "CouponUsageOrm", "InventoryOrm", "OrderItemOrm", "OrderOrm", "PaymentOrm", "ProductAttribute", "ProductImageOrm", "ProductOrm", "ProductTag", "ProductVariantOrm", "RefundOrm", "ReviewOrm", "ShipmentItemOrm", "ShipmentOrm", "StoreOrm", "TagOrm", "UserAddressOrm", "UserOrm", "WarehouseOrm", "product_attribute", "product_tag", "store_staff", ] ================================================ FILE: tests/service/schema/orm/attribute.py ================================================ """ Attribute and AttributeValue ORM models. """ from sqlalchemy import ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import OrmBase class AttributeOrm(OrmBase): __tablename__ = "attributes" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(100)) # Relationships values: Mapped[list["AttributeValueOrm"]] = relationship(back_populates="attribute") class AttributeValueOrm(OrmBase): __tablename__ = "attribute_values" id: Mapped[int] = mapped_column(Integer, primary_key=True) attribute_id: Mapped[int] = mapped_column(ForeignKey("attributes.id")) value: Mapped[str] = mapped_column(String(100)) # Relationships attribute: Mapped["AttributeOrm"] = relationship(back_populates="values") variants: Mapped[list["ProductVariantOrm"]] = relationship( secondary="product_attribute", back_populates="attribute_values", ) ================================================ FILE: tests/service/schema/orm/inventory.py ================================================ """ Warehouse and Inventory ORM models. """ from sqlalchemy import ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import OrmBase class WarehouseOrm(OrmBase): __tablename__ = "warehouses" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(100)) location: Mapped[str] = mapped_column(String(200)) # Relationships inventories: Mapped[list["InventoryOrm"]] = relationship(back_populates="warehouse") shipments: Mapped[list["ShipmentOrm"]] = relationship(back_populates="warehouse") class InventoryOrm(OrmBase): __tablename__ = "inventories" id: Mapped[int] = mapped_column(Integer, primary_key=True) warehouse_id: Mapped[int] = mapped_column(ForeignKey("warehouses.id")) variant_id: Mapped[int] = mapped_column(ForeignKey("product_variants.id")) quantity: Mapped[int] = mapped_column(Integer, default=0) # Relationships warehouse: Mapped["WarehouseOrm"] = relationship(back_populates="inventories") variant: Mapped["ProductVariantOrm"] = relationship(back_populates="inventories") ================================================ FILE: tests/service/schema/orm/marketing.py ================================================ """ Coupon and CouponUsage ORM models. """ from sqlalchemy import Float, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import OrmBase class CouponOrm(OrmBase): __tablename__ = "coupons" id: Mapped[int] = mapped_column(Integer, primary_key=True) code: Mapped[str] = mapped_column(String(50)) discount: Mapped[float] = mapped_column(Float) min_amount: Mapped[float] = mapped_column(Float, default=0) status: Mapped[str] = mapped_column(String(20), default="active") # Relationships usages: Mapped[list["CouponUsageOrm"]] = relationship(back_populates="coupon") class CouponUsageOrm(OrmBase): __tablename__ = "coupon_usages" id: Mapped[int] = mapped_column(Integer, primary_key=True) coupon_id: Mapped[int] = mapped_column(ForeignKey("coupons.id")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) order_id: Mapped[int] = mapped_column(ForeignKey("orders.id")) # Relationships coupon: Mapped["CouponOrm"] = relationship(back_populates="usages") user: Mapped["UserOrm"] = relationship(back_populates="coupon_usages") order: Mapped["OrderOrm"] = relationship(back_populates="coupon_usages") ================================================ FILE: tests/service/schema/orm/order.py ================================================ """ Order, OrderItem, Payment, Refund ORM models. """ from sqlalchemy import Float, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import OrmBase class OrderOrm(OrmBase): __tablename__ = "orders" id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) status: Mapped[str] = mapped_column(String(20), default="pending") total_amount: Mapped[float] = mapped_column(Float, default=0) # Relationships user: Mapped["UserOrm"] = relationship(back_populates="orders") items: Mapped[list["OrderItemOrm"]] = relationship(back_populates="order") payment: Mapped["PaymentOrm | None"] = relationship( back_populates="order", uselist=False, ) refunds: Mapped[list["RefundOrm"]] = relationship(back_populates="order") coupon_usages: Mapped[list["CouponUsageOrm"]] = relationship(back_populates="order") class OrderItemOrm(OrmBase): __tablename__ = "order_items" id: Mapped[int] = mapped_column(Integer, primary_key=True) order_id: Mapped[int] = mapped_column(ForeignKey("orders.id")) variant_id: Mapped[int] = mapped_column(ForeignKey("product_variants.id")) quantity: Mapped[int] = mapped_column(Integer) unit_price: Mapped[float] = mapped_column(Float) # Relationships order: Mapped["OrderOrm"] = relationship(back_populates="items") variant: Mapped["ProductVariantOrm"] = relationship(back_populates="order_items") class PaymentOrm(OrmBase): __tablename__ = "payments" id: Mapped[int] = mapped_column(Integer, primary_key=True) order_id: Mapped[int] = mapped_column(ForeignKey("orders.id")) method: Mapped[str] = mapped_column(String(20)) amount: Mapped[float] = mapped_column(Float) status: Mapped[str] = mapped_column(String(20), default="pending") # Relationships order: Mapped["OrderOrm"] = relationship(back_populates="payment") class RefundOrm(OrmBase): __tablename__ = "refunds" id: Mapped[int] = mapped_column(Integer, primary_key=True) order_id: Mapped[int] = mapped_column(ForeignKey("orders.id")) amount: Mapped[float] = mapped_column(Float) reason: Mapped[str | None] = mapped_column(Text) status: Mapped[str] = mapped_column(String(20), default="pending") # Relationships order: Mapped["OrderOrm"] = relationship(back_populates="refunds") ================================================ FILE: tests/service/schema/orm/product.py ================================================ """ Product, ProductVariant, ProductImage, Brand, Category ORM models. """ from sqlalchemy import Float, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import OrmBase class CategoryOrm(OrmBase): __tablename__ = "categories" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(100)) parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id")) # Relationships parent: Mapped["CategoryOrm | None"] = relationship( remote_side="CategoryOrm.id", back_populates="children", ) children: Mapped[list["CategoryOrm"]] = relationship(back_populates="parent") products: Mapped[list["ProductOrm"]] = relationship(back_populates="category") class BrandOrm(OrmBase): __tablename__ = "brands" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(100)) logo: Mapped[str | None] = mapped_column(String(500)) # Relationships products: Mapped[list["ProductOrm"]] = relationship(back_populates="brand") class ProductOrm(OrmBase): __tablename__ = "products" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(200)) description: Mapped[str | None] = mapped_column(Text) price: Mapped[float] = mapped_column(Float) brand_id: Mapped[int | None] = mapped_column(ForeignKey("brands.id")) category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id")) store_id: Mapped[int | None] = mapped_column(ForeignKey("stores.id")) # Relationships brand: Mapped["BrandOrm | None"] = relationship(back_populates="products") category: Mapped["CategoryOrm | None"] = relationship(back_populates="products") store: Mapped["StoreOrm | None"] = relationship(back_populates="products") variants: Mapped[list["ProductVariantOrm"]] = relationship(back_populates="product") images: Mapped[list["ProductImageOrm"]] = relationship(back_populates="product") reviews: Mapped[list["ReviewOrm"]] = relationship(back_populates="product") tags: Mapped[list["TagOrm"]] = relationship( secondary="product_tag", back_populates="products", ) class ProductVariantOrm(OrmBase): __tablename__ = "product_variants" id: Mapped[int] = mapped_column(Integer, primary_key=True) product_id: Mapped[int] = mapped_column(ForeignKey("products.id")) sku: Mapped[str] = mapped_column(String(100)) price: Mapped[float] = mapped_column(Float) stock: Mapped[int] = mapped_column(Integer, default=0) # Relationships product: Mapped["ProductOrm"] = relationship(back_populates="variants") order_items: Mapped[list["OrderItemOrm"]] = relationship(back_populates="variant") inventories: Mapped[list["InventoryOrm"]] = relationship(back_populates="variant") attribute_values: Mapped[list["AttributeValueOrm"]] = relationship( secondary="product_attribute", back_populates="variants", ) class ProductImageOrm(OrmBase): __tablename__ = "product_images" id: Mapped[int] = mapped_column(Integer, primary_key=True) product_id: Mapped[int] = mapped_column(ForeignKey("products.id")) url: Mapped[str] = mapped_column(String(500)) sort_order: Mapped[int] = mapped_column(Integer, default=0) # Relationships product: Mapped["ProductOrm"] = relationship(back_populates="images") class TagOrm(OrmBase): __tablename__ = "tags" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(100)) # Relationships products: Mapped[list["ProductOrm"]] = relationship( secondary="product_tag", back_populates="tags", ) class ReviewOrm(OrmBase): __tablename__ = "reviews" id: Mapped[int] = mapped_column(Integer, primary_key=True) product_id: Mapped[int] = mapped_column(ForeignKey("products.id")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) rating: Mapped[int] = mapped_column(Integer) content: Mapped[str | None] = mapped_column(Text) # Relationships product: Mapped["ProductOrm"] = relationship(back_populates="reviews") user: Mapped["UserOrm"] = relationship(back_populates="reviews") ================================================ FILE: tests/service/schema/orm/shipment.py ================================================ """ Shipment and ShipmentItem ORM models. """ from sqlalchemy import ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import OrmBase class ShipmentOrm(OrmBase): __tablename__ = "shipments" id: Mapped[int] = mapped_column(Integer, primary_key=True) warehouse_id: Mapped[int] = mapped_column(ForeignKey("warehouses.id")) store_id: Mapped[int | None] = mapped_column(ForeignKey("stores.id")) status: Mapped[str] = mapped_column(String(20), default="pending") tracking_no: Mapped[str | None] = mapped_column(String(100)) # Relationships warehouse: Mapped["WarehouseOrm"] = relationship(back_populates="shipments") store: Mapped["StoreOrm | None"] = relationship(back_populates="shipments") items: Mapped[list["ShipmentItemOrm"]] = relationship(back_populates="shipment") class ShipmentItemOrm(OrmBase): __tablename__ = "shipment_items" id: Mapped[int] = mapped_column(Integer, primary_key=True) shipment_id: Mapped[int] = mapped_column(ForeignKey("shipments.id")) order_item_id: Mapped[int] = mapped_column(ForeignKey("order_items.id")) quantity: Mapped[int] = mapped_column(Integer) # Relationships shipment: Mapped["ShipmentOrm"] = relationship(back_populates="items") order_item: Mapped["OrderItemOrm"] = relationship() ================================================ FILE: tests/service/schema/orm/store.py ================================================ """ Store ORM model. """ from sqlalchemy import Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import OrmBase class StoreOrm(OrmBase): __tablename__ = "stores" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(100)) description: Mapped[str | None] = mapped_column(String(500)) # Relationships products: Mapped[list["ProductOrm"]] = relationship(back_populates="store") staff_members: Mapped[list["UserOrm"]] = relationship( secondary="store_staff", back_populates="managed_stores", ) shipments: Mapped[list["ShipmentOrm"]] = relationship(back_populates="store") ================================================ FILE: tests/service/schema/orm/tables.py ================================================ """ M:N association tables for e-commerce schema. """ from sqlalchemy import Column, ForeignKey, Integer, Table from ..db import OrmBase # Product <-> Tag product_tag = Table( "product_tag", OrmBase.metadata, Column("id", Integer, primary_key=True), Column("product_id", Integer, ForeignKey("products.id"), nullable=False), Column("tag_id", Integer, ForeignKey("tags.id"), nullable=False), ) # Store <-> User (staff) store_staff = Table( "store_staff", OrmBase.metadata, Column("id", Integer, primary_key=True), Column("store_id", Integer, ForeignKey("stores.id"), nullable=False), Column("user_id", Integer, ForeignKey("users.id"), nullable=False), ) # ProductVariant <-> AttributeValue product_attribute = Table( "product_attribute", OrmBase.metadata, Column("id", Integer, primary_key=True), Column("variant_id", Integer, ForeignKey("product_variants.id"), nullable=False), Column("value_id", Integer, ForeignKey("attribute_values.id"), nullable=False), ) ================================================ FILE: tests/service/schema/orm/user.py ================================================ """ User and UserAddress ORM models. """ from sqlalchemy import Boolean, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import OrmBase class UserOrm(OrmBase): __tablename__ = "users" id: Mapped[int] = mapped_column(Integer, primary_key=True) username: Mapped[str] = mapped_column(String(100)) email: Mapped[str] = mapped_column(String(255)) phone: Mapped[str | None] = mapped_column(String(20)) # Relationships addresses: Mapped[list["UserAddressOrm"]] = relationship(back_populates="user") orders: Mapped[list["OrderOrm"]] = relationship(back_populates="user") reviews: Mapped[list["ReviewOrm"]] = relationship(back_populates="user") coupon_usages: Mapped[list["CouponUsageOrm"]] = relationship(back_populates="user") managed_stores: Mapped[list["StoreOrm"]] = relationship( secondary="store_staff", back_populates="staff_members", ) class UserAddressOrm(OrmBase): __tablename__ = "user_addresses" id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) province: Mapped[str] = mapped_column(String(50)) city: Mapped[str] = mapped_column(String(50)) district: Mapped[str] = mapped_column(String(50)) detail: Mapped[str] = mapped_column(String(255)) is_default: Mapped[bool] = mapped_column(Boolean, default=False) # Relationships user: Mapped["UserOrm"] = relationship(back_populates="addresses") ================================================ FILE: tests/service/schema/schema.py ================================================ """ 电商系统实体定义 - 用于 GraphQL 和 REST API 演示 使用 SQLAlchemy ORM + build_relationship 自动构建 relationships 和 loaders """ from typing import List, Optional from pydantic import BaseModel from pydantic_resolve import ErDiagram, MutationConfig, QueryConfig from pydantic_resolve.integration.mapping import Mapping from pydantic_resolve.integration.sqlalchemy import build_relationship from sqlalchemy import select from .db import async_session, create_tables from .dto.attribute import Attribute, AttributeValue from .dto.inventory import Inventory, Warehouse from .dto.marketing import Coupon, CouponUsage from .dto.order import Order, OrderItem, Payment, Refund from .dto.product import Brand, Category, Product, ProductImage, ProductVariant, Review from .dto.shipment import Shipment, ShipmentItem from .dto.store import Store from .dto.tag import Tag from .dto.user import User, UserAddress from .orm import ( AttributeOrm, AttributeValueOrm, BrandOrm, CategoryOrm, CouponOrm, CouponUsageOrm, InventoryOrm, OrderItemOrm, OrderOrm, PaymentOrm, ProductImageOrm, ProductOrm, ProductVariantOrm, RefundOrm, ReviewOrm, ShipmentItemOrm, ShipmentOrm, StoreOrm, TagOrm, UserAddressOrm, UserOrm, WarehouseOrm, ) # ===================================== # Input Types for Mutations # ===================================== class CreateProductInput(BaseModel): """创建商品的输入类型""" name: str description: str = "" price: float brand_id: Optional[int] = None category_id: Optional[int] = None store_id: Optional[int] = None class CreateOrderInput(BaseModel): """创建订单的输入类型""" user_id: int total_amount: float = 0 # ===================================== # Build Relationships from SQLAlchemy ORM # ===================================== _mappings = [ Mapping(entity=User, orm=UserOrm), Mapping(entity=UserAddress, orm=UserAddressOrm), Mapping(entity=Category, orm=CategoryOrm), Mapping(entity=Brand, orm=BrandOrm), Mapping(entity=Product, orm=ProductOrm), Mapping(entity=ProductVariant, orm=ProductVariantOrm), Mapping(entity=ProductImage, orm=ProductImageOrm), Mapping(entity=Review, orm=ReviewOrm), Mapping(entity=Tag, orm=TagOrm), Mapping(entity=Order, orm=OrderOrm), Mapping(entity=OrderItem, orm=OrderItemOrm), Mapping(entity=Payment, orm=PaymentOrm), Mapping(entity=Refund, orm=RefundOrm), Mapping(entity=Warehouse, orm=WarehouseOrm), Mapping(entity=Inventory, orm=InventoryOrm), Mapping(entity=Shipment, orm=ShipmentOrm), Mapping(entity=ShipmentItem, orm=ShipmentItemOrm), Mapping(entity=Coupon, orm=CouponOrm), Mapping(entity=CouponUsage, orm=CouponUsageOrm), Mapping(entity=Store, orm=StoreOrm), Mapping(entity=Attribute, orm=AttributeOrm), Mapping(entity=AttributeValue, orm=AttributeValueOrm), ] _entities = build_relationship( mappings=_mappings, session_factory=lambda: async_session(), ) # ===================================== # Query & Mutation Functions # ===================================== # --- User --- async def user_get_all(limit: int = 10, offset: int = 0) -> List[User]: """获取所有用户(分页)""" async with async_session() as session: stmt = select(UserOrm).offset(offset).limit(limit) rows = (await session.scalars(stmt)).all() return [User.model_validate(r) for r in rows] async def user_get_by_id(id: int) -> Optional[User]: """根据 ID 获取用户""" async with async_session() as session: row = await session.get(UserOrm, id) return User.model_validate(row) if row else None async def user_create(username: str, email: str, phone: str = "") -> User: """创建新用户""" async with async_session() as session: async with session.begin(): orm = UserOrm(username=username, email=email, phone=phone or None) session.add(orm) await session.flush() return User.model_validate(orm) # --- Product --- async def product_get_all( limit: int = 10, offset: int = 0, category_id: Optional[int] = None ) -> List[Product]: """获取所有商品(分页,可按分类筛选)""" async with async_session() as session: stmt = select(ProductOrm) if category_id: stmt = stmt.where(ProductOrm.category_id == category_id) stmt = stmt.offset(offset).limit(limit) rows = (await session.scalars(stmt)).all() return [Product.model_validate(r) for r in rows] async def product_get_by_id(id: int) -> Optional[Product]: """根据 ID 获取商品""" async with async_session() as session: row = await session.get(ProductOrm, id) return Product.model_validate(row) if row else None async def product_create( name: str, price: float, description: str = "", brand_id: Optional[int] = None, category_id: Optional[int] = None, store_id: Optional[int] = None, ) -> Product: """创建新商品""" async with async_session() as session: async with session.begin(): orm = ProductOrm( name=name, price=price, description=description or None, brand_id=brand_id, category_id=category_id, store_id=store_id, ) session.add(orm) await session.flush() return Product.model_validate(orm) # --- Order --- async def order_get_all( limit: int = 10, offset: int = 0, user_id: Optional[int] = None ) -> List[Order]: """获取所有订单(分页,可按用户筛选)""" async with async_session() as session: stmt = select(OrderOrm) if user_id: stmt = stmt.where(OrderOrm.user_id == user_id) stmt = stmt.offset(offset).limit(limit) rows = (await session.scalars(stmt)).all() return [Order.model_validate(r) for r in rows] async def order_get_by_id(id: int) -> Optional[Order]: """根据 ID 获取订单""" async with async_session() as session: row = await session.get(OrderOrm, id) return Order.model_validate(row) if row else None async def order_create(user_id: int, total_amount: float = 0) -> Order: """创建新订单""" async with async_session() as session: async with session.begin(): orm = OrderOrm(user_id=user_id, total_amount=total_amount) session.add(orm) await session.flush() return Order.model_validate(orm) async def order_update_status(id: int, status: str) -> Optional[Order]: """更新订单状态""" async with async_session() as session: async with session.begin(): row = await session.get(OrderOrm, id) if row: row.status = status await session.flush() return Order.model_validate(row) return None # --- Category --- async def category_get_all() -> List[Category]: """获取所有分类""" async with async_session() as session: stmt = select(CategoryOrm) rows = (await session.scalars(stmt)).all() return [Category.model_validate(r) for r in rows] async def category_create(name: str, parent_id: Optional[int] = None) -> Category: """创建分类""" async with async_session() as session: async with session.begin(): orm = CategoryOrm(name=name, parent_id=parent_id) session.add(orm) await session.flush() return Category.model_validate(orm) # --- Brand --- async def brand_get_all() -> List[Brand]: """获取所有品牌""" async with async_session() as session: stmt = select(BrandOrm) rows = (await session.scalars(stmt)).all() return [Brand.model_validate(r) for r in rows] async def brand_create(name: str, logo: str = "") -> Brand: """创建品牌""" async with async_session() as session: async with session.begin(): orm = BrandOrm(name=name, logo=logo or None) session.add(orm) await session.flush() return Brand.model_validate(orm) # --- Tag --- async def tag_get_all() -> List[Tag]: """获取所有标签""" async with async_session() as session: stmt = select(TagOrm) rows = (await session.scalars(stmt)).all() return [Tag.model_validate(r) for r in rows] async def tag_create(name: str) -> Tag: """创建标签""" async with async_session() as session: async with session.begin(): orm = TagOrm(name=name) session.add(orm) await session.flush() return Tag.model_validate(orm) # --- Coupon --- async def coupon_get_all() -> List[Coupon]: """获取所有优惠券""" async with async_session() as session: stmt = select(CouponOrm) rows = (await session.scalars(stmt)).all() return [Coupon.model_validate(r) for r in rows] async def coupon_create(code: str, discount: float, min_amount: float = 0) -> Coupon: """创建优惠券""" async with async_session() as session: async with session.begin(): orm = CouponOrm(code=code, discount=discount, min_amount=min_amount) session.add(orm) await session.flush() return Coupon.model_validate(orm) # --- Store --- async def store_get_all() -> List[Store]: """获取所有店铺""" async with async_session() as session: stmt = select(StoreOrm) rows = (await session.scalars(stmt)).all() return [Store.model_validate(r) for r in rows] async def store_create(name: str, description: str = "") -> Store: """创建店铺""" async with async_session() as session: async with session.begin(): orm = StoreOrm(name=name, description=description or None) session.add(orm) await session.flush() return Store.model_validate(orm) # --- Warehouse --- async def warehouse_get_all() -> List[Warehouse]: """获取所有仓库""" async with async_session() as session: stmt = select(WarehouseOrm) rows = (await session.scalars(stmt)).all() return [Warehouse.model_validate(r) for r in rows] # --- ProductVariant --- async def product_variant_get_by_product(product_id: int) -> List[ProductVariant]: """根据商品 ID 获取规格列表""" async with async_session() as session: stmt = select(ProductVariantOrm).where( ProductVariantOrm.product_id == product_id ) rows = (await session.scalars(stmt)).all() return [ProductVariant.model_validate(r) for r in rows] # --- OrderItem --- async def order_item_get_by_order(order_id: int) -> List[OrderItem]: """根据订单 ID 获取明细""" async with async_session() as session: stmt = select(OrderItemOrm).where(OrderItemOrm.order_id == order_id) rows = (await session.scalars(stmt)).all() return [OrderItem.model_validate(r) for r in rows] # ===================================== # Build ErDiagram with QueryConfig # ===================================== _entity_queries: dict[type, list] = { User: [ QueryConfig(method=user_get_all), QueryConfig(method=user_get_by_id), ], Product: [ QueryConfig(method=product_get_all), QueryConfig(method=product_get_by_id), ], Order: [ QueryConfig(method=order_get_all), QueryConfig(method=order_get_by_id), ], Category: [QueryConfig(method=category_get_all)], Brand: [QueryConfig(method=brand_get_all)], Tag: [QueryConfig(method=tag_get_all)], Coupon: [QueryConfig(method=coupon_get_all)], Store: [QueryConfig(method=store_get_all)], Warehouse: [QueryConfig(method=warehouse_get_all)], ProductVariant: [QueryConfig(method=product_variant_get_by_product)], OrderItem: [QueryConfig(method=order_item_get_by_order)], } _entity_mutations: dict[type, list] = { User: [MutationConfig(method=user_create)], Product: [MutationConfig(method=product_create)], Order: [MutationConfig(method=order_create), MutationConfig(method=order_update_status)], Category: [MutationConfig(method=category_create)], Brand: [MutationConfig(method=brand_create)], Tag: [MutationConfig(method=tag_create)], Coupon: [MutationConfig(method=coupon_create)], Store: [MutationConfig(method=store_create)], } # Attach QueryConfig/MutationConfig to entities for entity in _entities: kls = entity.kls if kls in _entity_queries: entity.queries = _entity_queries[kls] if kls in _entity_mutations: entity.mutations = _entity_mutations[kls] diagram = ErDiagram(entities=[]).add_relationship(_entities) # ===================================== # 初始化种子数据 # ===================================== async def init_db(): """创建表并插入种子数据""" await create_tables() async with async_session() as session: async with session.begin(): # Users session.add_all( [ UserOrm(id=1, username="zhangsan", email="zhangsan@example.com"), UserOrm(id=2, username="lisi", email="lisi@example.com"), UserOrm(id=3, username="wangwu", email="wangwu@example.com"), ] ) # Addresses session.add_all( [ UserAddressOrm( id=1, user_id=1, province="广东", city="深圳", district="南山区", detail="科技园路1号", is_default=True, ), UserAddressOrm( id=2, user_id=1, province="广东", city="深圳", district="福田区", detail="华强北路2号", ), UserAddressOrm( id=3, user_id=2, province="北京", city="北京", district="朝阳区", detail="望京路3号", is_default=True, ), UserAddressOrm( id=4, user_id=3, province="上海", city="上海", district="浦东新区", detail="张江路4号", is_default=True, ), ] ) # Brands session.add_all( [ BrandOrm(id=1, name="Apple"), BrandOrm(id=2, name="Nike"), ] ) # Categories (with nesting) session.add_all( [ CategoryOrm(id=1, name="电子产品"), CategoryOrm(id=2, name="手机", parent_id=1), CategoryOrm(id=3, name="电脑", parent_id=1), CategoryOrm(id=4, name="服装"), CategoryOrm(id=5, name="鞋子", parent_id=4), ] ) # Stores session.add_all( [ StoreOrm(id=1, name="Apple 官方旗舰店", description="Apple 官方授权"), StoreOrm(id=2, name="Nike 运动专营", description="Nike 品牌直营"), ] ) # Tags session.add_all( [ TagOrm(id=1, name="新品"), TagOrm(id=2, name="热卖"), TagOrm(id=3, name="折扣"), TagOrm(id=4, name="高端"), TagOrm(id=5, name="运动"), ] ) # Products session.add_all( [ ProductOrm( id=1, name="iPhone 15", price=5999.0, brand_id=1, category_id=2, store_id=1, ), ProductOrm( id=2, name="MacBook Pro", price=12999.0, brand_id=1, category_id=3, store_id=1, ), ProductOrm( id=3, name="Air Jordan 1", price=1299.0, brand_id=2, category_id=5, store_id=2, ), ProductOrm( id=4, name="iPad Air", price=4799.0, brand_id=1, category_id=2, store_id=1, ), ProductOrm( id=5, name="Nike Air Max", price=899.0, brand_id=2, category_id=5, store_id=2, ), ] ) # Product Variants session.add_all( [ ProductVariantOrm(id=1, product_id=1, sku="IP15-128-BLK", price=5999.0, stock=100), ProductVariantOrm(id=2, product_id=1, sku="IP15-256-WHT", price=6499.0, stock=50), ProductVariantOrm(id=3, product_id=2, sku="MBP14-512", price=12999.0, stock=30), ProductVariantOrm(id=4, product_id=2, sku="MBP16-1T", price=18999.0, stock=10), ProductVariantOrm(id=5, product_id=3, sku="AJ1-42-RED", price=1299.0, stock=80), ProductVariantOrm(id=6, product_id=3, sku="AJ1-43-BLK", price=1299.0, stock=60), ProductVariantOrm(id=7, product_id=4, sku="IPA-64-BLU", price=4799.0, stock=40), ProductVariantOrm(id=8, product_id=5, sku="NAM-42-WHT", price=899.0, stock=120), ] ) # Product Images session.add_all( [ ProductImageOrm(id=1, product_id=1, url="/img/iphone15-1.jpg", sort_order=1), ProductImageOrm(id=2, product_id=1, url="/img/iphone15-2.jpg", sort_order=2), ProductImageOrm(id=3, product_id=2, url="/img/macbook-1.jpg", sort_order=1), ProductImageOrm(id=4, product_id=2, url="/img/macbook-2.jpg", sort_order=2), ProductImageOrm(id=5, product_id=3, url="/img/aj1-1.jpg", sort_order=1), ProductImageOrm(id=6, product_id=3, url="/img/aj1-2.jpg", sort_order=2), ProductImageOrm(id=7, product_id=4, url="/img/ipad-1.jpg", sort_order=1), ProductImageOrm(id=8, product_id=4, url="/img/ipad-2.jpg", sort_order=2), ProductImageOrm(id=9, product_id=5, url="/img/airmax-1.jpg", sort_order=1), ProductImageOrm(id=10, product_id=5, url="/img/airmax-2.jpg", sort_order=2), ] ) # Product Tags (M:N) from .orm.tables import product_tag await session.execute( product_tag.insert(), [ {"product_id": 1, "tag_id": 1}, {"product_id": 1, "tag_id": 4}, {"product_id": 2, "tag_id": 2}, {"product_id": 2, "tag_id": 4}, {"product_id": 3, "tag_id": 1}, {"product_id": 3, "tag_id": 5}, {"product_id": 4, "tag_id": 2}, {"product_id": 5, "tag_id": 3}, {"product_id": 5, "tag_id": 5}, ], ) # Store Staff (M:N) from .orm.tables import store_staff await session.execute( store_staff.insert(), [ {"store_id": 1, "user_id": 1}, {"store_id": 1, "user_id": 2}, {"store_id": 2, "user_id": 2}, ], ) # Attributes & Values session.add_all( [ AttributeOrm(id=1, name="颜色"), AttributeOrm(id=2, name="尺寸"), AttributeOrm(id=3, name="材质"), AttributeOrm(id=4, name="容量"), AttributeOrm(id=5, name="款式"), ] ) session.add_all( [ AttributeValueOrm(id=1, attribute_id=1, value="黑色"), AttributeValueOrm(id=2, attribute_id=1, value="白色"), AttributeValueOrm(id=3, attribute_id=1, value="红色"), AttributeValueOrm(id=4, attribute_id=2, value="S"), AttributeValueOrm(id=5, attribute_id=2, value="M"), AttributeValueOrm(id=6, attribute_id=2, value="L"), AttributeValueOrm(id=7, attribute_id=3, value="皮革"), AttributeValueOrm(id=8, attribute_id=3, value="网布"), AttributeValueOrm(id=9, attribute_id=4, value="128GB"), AttributeValueOrm(id=10, attribute_id=4, value="256GB"), AttributeValueOrm(id=11, attribute_id=4, value="512GB"), AttributeValueOrm(id=12, attribute_id=4, value="1TB"), AttributeValueOrm(id=13, attribute_id=5, value="低帮"), AttributeValueOrm(id=14, attribute_id=5, value="高帮"), AttributeValueOrm(id=15, attribute_id=1, value="蓝色"), ] ) # Product Attribute Values (M:N) from .orm.tables import product_attribute await session.execute( product_attribute.insert(), [ {"variant_id": 1, "value_id": 1}, # iPhone 15 Black {"variant_id": 1, "value_id": 9}, # 128GB {"variant_id": 2, "value_id": 2}, # iPhone 15 White {"variant_id": 2, "value_id": 10}, # 256GB {"variant_id": 3, "value_id": 11}, # MBP 512GB {"variant_id": 4, "value_id": 12}, # MBP 1TB {"variant_id": 5, "value_id": 3}, # AJ1 Red {"variant_id": 5, "value_id": 14}, # 高帮 {"variant_id": 6, "value_id": 1}, # AJ1 Black {"variant_id": 6, "value_id": 14}, # 高帮 ], ) # Orders session.add_all( [ OrderOrm(id=1, user_id=1, status="completed", total_amount=7298.0), OrderOrm(id=2, user_id=1, status="shipped", total_amount=12999.0), OrderOrm(id=3, user_id=2, status="pending", total_amount=2598.0), OrderOrm(id=4, user_id=2, status="paid", total_amount=4799.0), OrderOrm(id=5, user_id=3, status="pending", total_amount=899.0), OrderOrm(id=6, user_id=3, status="refunded", total_amount=1299.0), ] ) # Order Items session.add_all( [ OrderItemOrm(id=1, order_id=1, variant_id=1, quantity=1, unit_price=5999.0), OrderItemOrm(id=2, order_id=1, variant_id=5, quantity=1, unit_price=1299.0), OrderItemOrm(id=3, order_id=2, variant_id=3, quantity=1, unit_price=12999.0), OrderItemOrm(id=4, order_id=3, variant_id=5, quantity=2, unit_price=1299.0), OrderItemOrm(id=5, order_id=4, variant_id=7, quantity=1, unit_price=4799.0), OrderItemOrm(id=6, order_id=5, variant_id=8, quantity=1, unit_price=899.0), OrderItemOrm(id=7, order_id=6, variant_id=6, quantity=1, unit_price=1299.0), OrderItemOrm(id=8, order_id=1, variant_id=2, quantity=1, unit_price=6499.0), OrderItemOrm(id=9, order_id=2, variant_id=4, quantity=1, unit_price=18999.0), OrderItemOrm(id=10, order_id=3, variant_id=6, quantity=1, unit_price=1299.0), OrderItemOrm(id=11, order_id=4, variant_id=1, quantity=1, unit_price=5999.0), OrderItemOrm(id=12, order_id=6, variant_id=8, quantity=1, unit_price=899.0), ] ) # Payments session.add_all( [ PaymentOrm(id=1, order_id=1, method="wechat", amount=7298.0, status="success"), PaymentOrm(id=2, order_id=2, method="alipay", amount=12999.0, status="success"), PaymentOrm(id=3, order_id=4, method="wechat", amount=4799.0, status="success"), PaymentOrm(id=4, order_id=6, method="alipay", amount=1299.0, status="refunded"), ] ) # Refunds session.add_all( [ RefundOrm(id=1, order_id=6, amount=1299.0, reason="不想要了", status="approved"), RefundOrm(id=2, order_id=2, amount=18999.0, reason="质量问题", status="pending"), ] ) # Warehouses session.add_all( [ WarehouseOrm(id=1, name="华南仓", location="深圳"), WarehouseOrm(id=2, name="华东仓", location="上海"), ] ) # Inventory session.add_all( [ InventoryOrm(id=1, warehouse_id=1, variant_id=1, quantity=60), InventoryOrm(id=2, warehouse_id=2, variant_id=1, quantity=40), InventoryOrm(id=3, warehouse_id=1, variant_id=2, quantity=30), InventoryOrm(id=4, warehouse_id=2, variant_id=2, quantity=20), InventoryOrm(id=5, warehouse_id=1, variant_id=3, quantity=20), InventoryOrm(id=6, warehouse_id=2, variant_id=3, quantity=10), InventoryOrm(id=7, warehouse_id=1, variant_id=5, quantity=50), InventoryOrm(id=8, warehouse_id=2, variant_id=5, quantity=30), ] ) # Reviews session.add_all( [ ReviewOrm(id=1, product_id=1, user_id=1, rating=5, content="很好用"), ReviewOrm(id=2, product_id=1, user_id=2, rating=4, content="不错"), ReviewOrm(id=3, product_id=2, user_id=1, rating=5, content="性能强劲"), ReviewOrm(id=4, product_id=3, user_id=2, rating=4, content="好看"), ] ) # Coupons session.add_all( [ CouponOrm(id=1, code="NEW100", discount=100.0, min_amount=500.0), CouponOrm(id=2, code="VIP500", discount=500.0, min_amount=3000.0), CouponOrm(id=3, code="SALE200", discount=200.0, min_amount=1000.0), ] ) # Coupon Usages session.add_all( [ CouponUsageOrm(id=1, coupon_id=1, user_id=1, order_id=1), CouponUsageOrm(id=2, coupon_id=2, user_id=1, order_id=2), CouponUsageOrm(id=3, coupon_id=3, user_id=2, order_id=3), CouponUsageOrm(id=4, coupon_id=1, user_id=3, order_id=5), ] ) # Shipments session.add_all( [ ShipmentOrm( id=1, warehouse_id=1, store_id=1, status="delivered", tracking_no="SF1234567890", ), ShipmentOrm( id=2, warehouse_id=2, store_id=1, status="shipped", tracking_no="SF0987654321", ), ShipmentOrm( id=3, warehouse_id=1, store_id=2, status="pending", tracking_no=None, ), ] ) # Shipment Items session.add_all( [ ShipmentItemOrm(id=1, shipment_id=1, order_item_id=1, quantity=1), ShipmentItemOrm(id=2, shipment_id=1, order_item_id=2, quantity=1), ShipmentItemOrm(id=3, shipment_id=2, order_item_id=3, quantity=1), ShipmentItemOrm(id=4, shipment_id=2, order_item_id=9, quantity=1), ShipmentItemOrm(id=5, shipment_id=3, order_item_id=4, quantity=1), ShipmentItemOrm(id=6, shipment_id=3, order_item_id=10, quantity=1), ] ) ================================================ FILE: tests/test_adapter_interface.py ================================================ """ Test adapter interface design to ensure clean and consistent API. This test validates: 1. Adapters don't have get_mount_path() method (mount path is user's choice) 2. Adapters have create_app() method 3. Adapters work correctly with user-defined mount paths """ import os import pytest from fastapi_voyager import create_voyager from fastapi_voyager.adapters.base import VoyagerAdapter def test_adapter_base_class_does_not_have_get_mount_path(): """Test that VoyagerAdapter base class does not define get_mount_path method.""" # The base class should only have create_app as abstract method abstract_methods = VoyagerAdapter.__abstractmethods__ # Should only have create_app assert "create_app" in abstract_methods, "create_app must be abstract" assert "get_mount_path" not in abstract_methods, "get_mount_path should not exist in base class" # Verify get_mount_path is not defined anywhere in the class assert not hasattr(VoyagerAdapter, "get_mount_path"), \ "VoyagerAdapter should not have get_mount_path method - mount path is user's choice" @pytest.mark.parametrize("app_factory", [ pytest.param(lambda: __import__("fastapi", fromlist=["FastAPI"]).FastAPI(), id="fastapi"), pytest.param(lambda: __import__("litestar", fromlist=["Litestar"]).Litestar(), id="litestar"), ]) def test_adapter_create_app_exists_and_works(app_factory): """Test that adapters have create_app method and it works correctly.""" # Import and setup app = app_factory() voyager_app = create_voyager(app) # Should return a valid app object assert voyager_app is not None, "create_voyager should return an app" # The returned app should be callable (ASGI interface) assert callable(voyager_app), "Voyager app must be callable (ASGI interface)" def test_django_ninja_adapter_create_app_works(): """Test that Django Ninja adapter works correctly.""" import django # Setup Django BEFORE importing NinjaAPI os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.django_ninja.settings") django.setup() from ninja import NinjaAPI # Create API and voyager api = NinjaAPI() voyager_app = create_voyager(api) # Should return a valid ASGI app assert voyager_app is not None, "create_voyager should return an app" assert callable(voyager_app), "Voyager app must be callable (ASGI interface)" def test_adapter_instances_do_not_have_get_mount_path(): """Test that adapter instances do not have get_mount_path method.""" from fastapi import FastAPI from litestar import Litestar import django # Test FastAPI adapter fastapi_app = FastAPI() voyager_app = create_voyager(fastapi_app) assert not hasattr(voyager_app, "get_mount_path"), \ "FastAPI voyager app should not have get_mount_path method" # Test Litestar adapter litestar_app = Litestar() voyager_app = create_voyager(litestar_app) assert not hasattr(voyager_app, "get_mount_path"), \ "Litestar voyager app should not have get_mount_path method" # Test Django Ninja adapter os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.django_ninja.settings") django.setup() from ninja import NinjaAPI ninja_api = NinjaAPI() voyager_app = create_voyager(ninja_api) assert not hasattr(voyager_app, "get_mount_path"), \ "Django Ninja voyager app should not have get_mount_path method" def test_mount_path_is_user_responsibility(): """ Test that mount path is completely under user control. This test documents and enforces the design principle that mount path should be decided by users in their embedding code, not hardcoded in adapters. """ from fastapi import FastAPI import httpx # Test multiple different mount paths - all should work test_paths = [ "/voyager", "/docs", "/api/viz", "/my-custom-path", ] for path in test_paths: app = FastAPI() voyager_app = create_voyager(app) app.mount(path, voyager_app) # Verify the path works using async client transport = httpx.ASGITransport(app=app) # Use loop.run_until_complete for sync context import asyncio async def check_path(): async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get(f"{path}/dot") assert response.status_code == 200, \ f"Mount path {path} should work - proves path is user's choice" asyncio.run(check_path()) def test_adapter_design_principles(): """ Test that adapter design follows correct principles. This test documents the design decision that: - Adapters create framework-specific apps - Users decide mount paths in their embedding code - Adapters should NOT hardcode mount paths """ from fastapi import FastAPI from fastapi_voyager.adapters.base import VoyagerAdapter import inspect # Check that VoyagerAdapter only has one abstract method abstract_methods = VoyagerAdapter.__abstractmethods__ assert len(abstract_methods) == 1, "Should only have one abstract method" assert "create_app" in abstract_methods, "Abstract method should be create_app" # Check that create_app signature is correct sig = inspect.signature(VoyagerAdapter.create_app) assert len(sig.parameters) == 1, "create_app should only take self parameter" assert sig.return_annotation != inspect.Signature.empty, "create_app should have return type annotation" # Verify the principle: voyager app doesn't impose mount path app = FastAPI() voyager_app = create_voyager(app) # The voyager app is just a standard ASGI app # It doesn't know or care about where it's mounted assert callable(voyager_app), "Voyager app must be callable ASGI interface" # User can mount it anywhere they want # This is verified by test_mount_path_is_user_responsibility assert True, "Design principle validated: mount path is user's responsibility" ================================================ FILE: tests/test_analysis.py ================================================ from fastapi import FastAPI from pydantic import BaseModel from fastapi_voyager.voyager import Voyager def test_analysis(): class B(BaseModel): id: int value: str class A(BaseModel): id: int name: str b: B class C(BaseModel): id: int name: str b: B app = FastAPI() @app.get("/test", response_model=A | None) def home(): return None @app.get("/test2", response_model=C | None) def home2(): return None analytics = Voyager() analytics.analysis(app) assert len(analytics.nodes) == 3 assert len(analytics.links) == 6 def test_analysis_with_non_class_response_model(): """Regression test for TypeError: issubclass() arg 1 must be a class. Real-world trigger: a route uses a PEP 695 type alias as response_model, e.g. type ResourceActionDict = dict[AccessResourceUnion, set[AccessActionUnion]] FastAPI infers response_model from the return annotation. get_core_types() unwraps the type alias to dict[X, set[Y]] (a types.GenericAlias), which is not a class. Python version behavior difference: - Python 3.12: issubclass(dict[X, Y], BaseModel) returns False (no error) - Python 3.13: issubclass(dict[X, Y], BaseModel) raises TypeError In Pydantic <= 2.11, ModelMetaclass.__subclasscheck__ (pure-Python) masks this via hasattr short-circuit. In Pydantic >= 2.13 (compiled Rust extension), the guard no longer catches all GenericAlias types, exposing the bug on Python 3.13. We patch out __subclasscheck__ to simulate the Python 3.13 + Pydantic 2.13 behavior. """ from unittest.mock import patch from typing import Callable from pydantic._internal._model_construction import ModelMetaclass from enum import Enum class ResourceEnum(str, Enum): FILE = "file" class ActionEnum(str, Enum): READ = "read" ResourceActionDict = dict[ResourceEnum, set[ActionEnum]] app = FastAPI() @app.get("/permissions", response_model=ResourceActionDict) def get_permissions(): return {} @app.get("/callback", response_model=Callable[[int], str]) def callback_endpoint(): pass with patch.object(ModelMetaclass, '__subclasscheck__', type.__subclasscheck__): voyager = Voyager() voyager.analysis(app) assert len(voyager.nodes) == 0 ================================================ FILE: tests/test_embedding_django_ninja.py ================================================ """ Test Django Ninja embedding service with /dot endpoint. This test starts the Django Ninja embedding service and validates the /dot endpoint. """ import asyncio from typing import AsyncGenerator, Generator import httpx import pytest import pytest_asyncio from tests import embedding_test_utils @pytest.fixture(scope="session") def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: """Create an instance of the default event loop for the test session.""" loop = asyncio.get_event_loop_policy().new_event_loop() yield loop loop.close() @pytest_asyncio.fixture async def async_client() -> AsyncGenerator[httpx.AsyncClient, None]: """Create an async HTTP client for testing Django Ninja embedding.""" # Import the ASGI application from tests.django_ninja.embedding from tests.django_ninja.embedding import application # Use ASGITransport for testing ASGI apps with httpx transport = httpx.ASGITransport(app=application) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: yield client @pytest.fixture def expected_framework_name() -> str: """Return the expected framework name for Django Ninja.""" return "Django Ninja" @pytest.fixture def expected_routes() -> list[str]: """Return expected route names for Django Ninja.""" return embedding_test_utils.EXPECTED_ROUTES # Reuse shared test functions @pytest.mark.asyncio async def test_dot_endpoint_returns_success(async_client: httpx.AsyncClient): """Test that /voyager/dot endpoint returns 200 OK.""" await embedding_test_utils.test_dot_endpoint_returns_success(async_client) @pytest.mark.asyncio async def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient): """Test that /voyager/dot endpoint returns tags data.""" await embedding_test_utils.test_dot_endpoint_has_tags(async_client) @pytest.mark.asyncio async def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncClient): """Test that tags have associated routes.""" await embedding_test_utils.test_dot_endpoint_tags_have_routes(async_client) @pytest.mark.asyncio async def test_dot_endpoint_routes_structure(async_client: httpx.AsyncClient): """Test that routes have correct structure.""" await embedding_test_utils.test_dot_endpoint_routes_structure(async_client) @pytest.mark.asyncio async def test_dot_endpoint_expected_routes(async_client: httpx.AsyncClient, expected_routes: list[str]): """Test that expected routes from demo.py are present.""" await embedding_test_utils.test_dot_endpoint_expected_routes(async_client, expected_routes) @pytest.mark.asyncio async def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient, expected_framework_name: str): """Test other required fields in /dot response.""" await embedding_test_utils.test_dot_endpoint_other_fields(async_client, expected_framework_name) ================================================ FILE: tests/test_embedding_fastapi.py ================================================ """ Test FastAPI embedding service with /dot endpoint. This test starts the FastAPI embedding service and validates the /dot endpoint. """ import asyncio from typing import AsyncGenerator, Generator import httpx import pytest import pytest_asyncio from tests.fastapi.embedding import app from tests import embedding_test_utils @pytest.fixture(scope="session") def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: """Create an instance of the default event loop for the test session.""" loop = asyncio.get_event_loop_policy().new_event_loop() yield loop loop.close() @pytest_asyncio.fixture async def async_client() -> AsyncGenerator[httpx.AsyncClient, None]: """Create an async HTTP client for testing.""" # Use ASGITransport for testing ASGI apps with httpx transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: yield client @pytest.fixture def expected_framework_name() -> str: """Return the expected framework name for FastAPI.""" return "FastAPI" @pytest.fixture def expected_routes() -> list[str]: """Return expected route names for FastAPI.""" return embedding_test_utils.EXPECTED_ROUTES # Reuse shared test functions @pytest.mark.asyncio async def test_dot_endpoint_returns_success(async_client: httpx.AsyncClient): """Test that /voyager/dot endpoint returns 200 OK.""" await embedding_test_utils.test_dot_endpoint_returns_success(async_client) @pytest.mark.asyncio async def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient): """Test that /voyager/dot endpoint returns tags data.""" await embedding_test_utils.test_dot_endpoint_has_tags(async_client) @pytest.mark.asyncio async def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncClient): """Test that tags have associated routes.""" await embedding_test_utils.test_dot_endpoint_tags_have_routes(async_client) @pytest.mark.asyncio async def test_dot_endpoint_routes_structure(async_client: httpx.AsyncClient): """Test that routes have correct structure.""" await embedding_test_utils.test_dot_endpoint_routes_structure(async_client) @pytest.mark.asyncio async def test_dot_endpoint_expected_routes(async_client: httpx.AsyncClient, expected_routes: list[str]): """Test that expected routes from demo.py are present.""" await embedding_test_utils.test_dot_endpoint_expected_routes(async_client, expected_routes) @pytest.mark.asyncio async def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient, expected_framework_name: str): """Test other required fields in /dot response.""" await embedding_test_utils.test_dot_endpoint_other_fields(async_client, expected_framework_name) ================================================ FILE: tests/test_embedding_litestar.py ================================================ """ Test Litestar embedding service with /dot endpoint. This test starts the Litestar embedding service and validates the /dot endpoint. """ import asyncio from typing import AsyncGenerator, Generator import httpx import pytest import pytest_asyncio from tests import embedding_test_utils @pytest.fixture(scope="session") def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: """Create an instance of the default event loop for the test session.""" loop = asyncio.get_event_loop_policy().new_event_loop() yield loop loop.close() @pytest_asyncio.fixture async def async_client() -> AsyncGenerator[httpx.AsyncClient, None]: """Create an async HTTP client for testing Litestar embedding.""" # Import the combined app from tests.litestar.embedding from tests.litestar.embedding import app # Use ASGITransport for testing ASGI apps with httpx transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: yield client @pytest.fixture def expected_framework_name() -> str: """Return the expected framework name for Litestar.""" return "Litestar" @pytest.fixture def expected_routes() -> list[str]: """Return expected route names for Litestar.""" return embedding_test_utils.EXPECTED_ROUTES # Reuse shared test functions @pytest.mark.asyncio async def test_dot_endpoint_returns_success(async_client: httpx.AsyncClient): """Test that /voyager/dot endpoint returns 200 OK.""" await embedding_test_utils.test_dot_endpoint_returns_success(async_client) @pytest.mark.asyncio async def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient): """Test that /voyager/dot endpoint returns tags data.""" await embedding_test_utils.test_dot_endpoint_has_tags(async_client) @pytest.mark.asyncio async def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncClient): """Test that tags have associated routes.""" await embedding_test_utils.test_dot_endpoint_tags_have_routes(async_client) @pytest.mark.asyncio async def test_dot_endpoint_routes_structure(async_client: httpx.AsyncClient): """Test that routes have correct structure.""" await embedding_test_utils.test_dot_endpoint_routes_structure(async_client) @pytest.mark.asyncio async def test_dot_endpoint_expected_routes(async_client: httpx.AsyncClient, expected_routes: list[str]): """Test that expected routes from demo.py are present.""" await embedding_test_utils.test_dot_endpoint_expected_routes(async_client, expected_routes) @pytest.mark.asyncio async def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient, expected_framework_name: str): """Test other required fields in /dot response.""" await embedding_test_utils.test_dot_endpoint_other_fields(async_client, expected_framework_name) ================================================ FILE: tests/test_filter.py ================================================ from fastapi_voyager.filter import filter_subgraph_by_module_prefix from fastapi_voyager.type import PK, Link, Route, SchemaNode, Tag def _make_tag_route_link(tag: Tag, route: Route) -> Link: return Link( source=tag.id, source_origin=tag.id, target=route.id, target_origin=route.id, type="tag_route", ) def test_filter_subgraph_filters_nodes_and_links(): tag = Tag(id="tag1", name="Tag 1", routes=[]) route = Route(id="route1", name="route1", module="api.routes") tag.routes.append(route) node_a = SchemaNode(id="pkg.ModelA", name="ModelA", module="pkg.moduleA") node_b = SchemaNode(id="pkg.ModelB", name="ModelB", module="target.moduleB") links = [ _make_tag_route_link(tag, route), Link( source=route.id, source_origin=route.id, target=f"{node_a.id}::{PK}", target_origin=node_a.id, type="route_to_schema", ), Link( source=f"{node_a.id}::ffield", source_origin=node_a.id, target=f"{node_b.id}::{PK}", target_origin=node_b.id, type="schema", ), ] tags = [tag] routes = [route] nodes = [node_a, node_b] _, _, filtered_nodes, filtered_links = filter_subgraph_by_module_prefix( tags=tags, routes=routes, links=links, nodes=nodes, module_prefix="target", ) assert filtered_nodes == [node_b] assert any( lk.type == "route_to_schema" and \ lk.source_origin == route.id and \ lk.target_origin == node_b.id for lk in filtered_links ) assert len(filtered_links) == 2 # tag -> route and merged route -> filtered node def test_filter_subgraph_handles_cycles_and_multiple_matches(): tag = Tag(id="tag-main", name="Tag", routes=[]) route = Route(id="route-main", name="route", module="api.routes") tag.routes.append(route) node_root = SchemaNode(id="pkg.Root", name="Root", module="pkg.root") node_mid = SchemaNode(id="pkg.Mid", name="Mid", module="pkg.mid") node_target1 = SchemaNode(id="pkg.Target1", name="Target1", module="target.mod.alpha") node_target2 = SchemaNode(id="pkg.Target2", name="Target2", module="target.mod.beta") links = [ _make_tag_route_link(tag, route), Link( source=route.id, source_origin=route.id, target=f"{node_root.id}::{PK}", target_origin=node_root.id, type="route_to_schema", ), Link( source=f"{node_root.id}::ffield", source_origin=node_root.id, target=f"{node_mid.id}::{PK}", target_origin=node_mid.id, type="schema", ), Link( source=f"{node_mid.id}::{PK}", source_origin=node_mid.id, target=f"{node_target1.id}::{PK}", target_origin=node_target1.id, type="parent", ), Link( source=f"{node_mid.id}::ffield", source_origin=node_mid.id, target=f"{node_target2.id}::{PK}", target_origin=node_target2.id, type="subset", ), Link( source=f"{node_target1.id}::ffield", source_origin=node_target1.id, target=f"{node_root.id}::{PK}", target_origin=node_root.id, type="schema", ), ] nodes = [node_root, node_mid, node_target1, node_target2] _, _, filtered_nodes, filtered_links = filter_subgraph_by_module_prefix( tags=[tag], routes=[route], links=links, nodes=nodes, module_prefix="target.mod", ) assert filtered_nodes == [node_target1, node_target2] route_to_schema_targets = { (lk.source_origin, lk.target_origin) for lk in filtered_links if lk.type == "route_to_schema" } assert route_to_schema_targets == { (route.id, node_target1.id), (route.id, node_target2.id), } assert all(lk.type in {"tag_route", "route_to_schema"} for lk in filtered_links) assert len(filtered_links) == 3 # 1 tag_route + 2 merged links ================================================ FILE: tests/test_generic.py ================================================ import sys from typing import Generic, TypeVar from pydantic import BaseModel from fastapi_voyager.type_helper import is_generic_container class PageStory(BaseModel): id: int title: str T = TypeVar('T') class DataModel(BaseModel, Generic[T]): data: T id: int DataModelPageStory: object # Stub declaration for static analysis if sys.version_info >= (3, 12): exec("type DataModelPageStory = DataModel[PageStory]") else: DataModelPageStory = DataModel[PageStory] def test_is_generic_container(): print(DataModelPageStory.__value__.__bases__) print(DataModelPageStory.__value__.model_fields.items()) assert is_generic_container(DataModel) is True assert is_generic_container(DataModelPageStory) is False ================================================ FILE: tests/test_import.py ================================================ def test_import(): import fastapi_voyager as pkg assert hasattr(pkg, "__version__") ================================================ FILE: tests/test_module.py ================================================ from fastapi_voyager.module import build_module_schema_tree from fastapi_voyager.type import SchemaNode def _sn(id: str, module: str, name: str) -> SchemaNode: return SchemaNode(id=id, module=module, name=name, fields=[]) def _find_child(module_node, name: str): return next((m for m in module_node.modules if m.name == name), None) def _find_top(top_modules, name: str): return next((m for m in top_modules if m.name == name), None) def test_build_module_tree_basic(): # Arrange: schema nodes in various module depths schema_nodes = [ _sn("A", "pkg", "A"), _sn("B", "pkg.sub", "B"), _sn("B2", "pkg.sub", "B2"), _sn("C", "pkg.other", "C"), _sn("D", "x.y.z", "D"), ] # Act top_modules = build_module_schema_tree(schema_nodes) from pprint import pprint pprint(top_modules) # Assert: top-level modules names = sorted(m.name for m in top_modules) assert names == ["pkg", "x.y.z"] # pkg level pkg = _find_top(top_modules, "pkg") assert pkg is not None assert [sn.name for sn in pkg.schema_nodes] == ["A"] assert sorted(m.name for m in pkg.modules) == ["other", "sub"] # pkg.sub level sub = _find_child(pkg, "sub") assert sub is not None assert sorted(sn.name for sn in sub.schema_nodes) == ["B", "B2"] assert sub.modules == [] # pkg.other level other = _find_child(pkg, "other") assert other is not None assert [sn.name for sn in other.schema_nodes] == ["C"] assert other.modules == [] # x.y.z chain should collapse to a single module named "x.y.z" x = _find_top(top_modules, "x.y.z") assert x is not None assert [sn.name for sn in x.schema_nodes] == ["D"] assert x.modules == [] def test_build_module_tree_empty_input(): top_modules = build_module_schema_tree([]) assert top_modules == [] def test_build_module_tree_root_level_nodes(): # Nodes without module path should be attached to __root__ schema_nodes = [ _sn("Root1", "", "Root1"), _sn("Root2", "", "Root2"), _sn("PkgA", "pkg", "PkgA"), ] top_modules = build_module_schema_tree(schema_nodes) names = sorted(m.name for m in top_modules) assert names == ["__root__", "pkg"] root = _find_top(top_modules, "__root__") assert root is not None assert sorted(sn.name for sn in root.schema_nodes) == ["Root1", "Root2"] pkg = _find_top(top_modules, "pkg") assert pkg is not None and [sn.name for sn in pkg.schema_nodes] == ["PkgA"] def test_collapse_single_child_empty_modules(): # Construct a deeper chain with empty intermediate modules that should collapse schema_nodes = [ _sn("Deep", "a.b.c.d", "Deep"), _sn("Peer", "a.b.x", "Peer"), ] top_modules = build_module_schema_tree(schema_nodes) print(top_modules) # 'a' should have one child path 'b', but due to branching at x, only a.b collapses into a.b # and below it, 'c.d' should collapse to 'c.d'. Final structure: # a # └── b # ├── c.d (holds Deep) # └── x (holds Peer) a = _find_top(top_modules, "a.b") assert a is not None assert a.schema_nodes == [] # b remains as child of a b = _find_child(a, "c.d") assert b is not None assert [sn.name for sn in b.schema_nodes] == ['Deep'] # collapsed node under b is named "c.d" x = _find_child(a, "x") assert x is not None assert [sn.name for sn in x.schema_nodes] == ["Peer"] ================================================ FILE: tests/test_resolve_util_impl.py ================================================ from typing import Annotated from pydantic import BaseModel from pydantic_resolve import Collector from pydantic_resolve.utils.er_diagram import LoaderInfo from fastapi_voyager.pydantic_resolve_util import analysis_pydantic_resolve_fields class SchemaA(BaseModel): __pydantic_resolve_expose__ = {"exposed_field": "alias_name"} __pydantic_resolve_collect__ = { "collected_field": "collector_name", ("collected_field_a", "collected_field_b"): "collector_x", ("collected_field_c", "collected_field_d"): ("collector_y", "collector_z"), ("collected_field", "collected_field_c"): ("collector_u", "collector_v"), } id: int resolved_field: Annotated[str, LoaderInfo(origin="id")] = "" exposed_field: str = "" collected_field: str = "" collected_field_a: str = "" collected_field_b: str = "" collected_field_c: str = "" collected_field_d: str = "" post_field: str = "" def resolve_resolved_field(self): return "resolved" def post_post_field(self): return "posted" collector: list[str] = [] def post_collector(self, collector=Collector(alias="top_collector")): return collector.values() def test_resolve_util(): # Test resolved field res = analysis_pydantic_resolve_fields(SchemaA, "resolved_field") assert res["is_resolve"] is True # Test exposed field res = analysis_pydantic_resolve_fields(SchemaA, "exposed_field") assert res["expose_as_info"] == "alias_name" # Test collected field res = analysis_pydantic_resolve_fields(SchemaA, "collected_field") assert set(res["send_to_info"]) == {"collector_name", "collector_u", "collector_v"} # Test collected field a (tuple key) res = analysis_pydantic_resolve_fields(SchemaA, "collected_field_a") assert set(res["send_to_info"]) == {"collector_x"} # Test collected field c (tuple key and tuple value) res = analysis_pydantic_resolve_fields(SchemaA, "collected_field_c") assert set(res["send_to_info"]) == {"collector_y", "collector_z", "collector_u", "collector_v"} # Test post field res = analysis_pydantic_resolve_fields(SchemaA, "post_field") assert res["is_post"] is True res = analysis_pydantic_resolve_fields(SchemaA, "collector") assert set(res["collect_info"]) == {"top_collector"} ================================================ FILE: tests/test_type_helper.py ================================================ import sys from typing import Annotated import pytest from fastapi_voyager.type_helper import get_core_types def test_optional_and_list_core_types(): class T: ... # Optional[T] -> (T,) opt = T | None core = get_core_types(opt) assert core == (T,) # list[T] -> (T,) lst = list[T] core2 = get_core_types(lst) assert core2 == (T,) def test_typing_union_core_types(): class A: ... class B: ... u = A | B core = get_core_types(u) # order preserved assert core == (A, B) @pytest.mark.skipif(sys.version_info < (3, 10), reason="PEP 604 union (|) requires Python 3.10+") def test_uniontype_pep604_core_types(): class A: ... class B: ... u = A | B core = get_core_types(u) assert core == (A, B) def test_mixed_optional_list(): class T: ... # Optional[list[T]] -> (T,) (list unwrapped after removing None) anno = list[T] | None core = get_core_types(anno) assert core == (T,) def test_nested_union_flattening(): class A: ... class B: ... class C: ... anno = A | (B | C) core = get_core_types(anno) # typing normalizes nested unions -> (A, B, C) assert core == (A, B, C) @pytest.mark.skipif(sys.version_info < (3, 10), reason="PEP 604 union (|) requires Python 3.10+") def test_uniontype_with_list_member(): class A: ... class B: ... anno = A | list[B] anno2 = A | list[list[B]] core = get_core_types(anno) core2 = get_core_types(anno2) assert core == (A, B) assert core2 == (A, B) # Only Python 3.12+ supports the PEP 695 `type` statement producing TypeAliasType @pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 type aliases require Python 3.12+") def test_union_type_alias_and_list(): # Dynamically exec a type alias using the new syntax # so test file stays valid on <3.12 (even though skipped) ns: dict = {} code = """ class A: ... class B: ... type MyAlias = A | B """ exec(code, ns, ns) MyAlias = ns['MyAlias'] A = ns['A'] B = ns['B'] # list[MyAlias] should yield (A, B) core = get_core_types(list[MyAlias]) assert set(core) == {A, B} # Direct alias should also work core2 = get_core_types(MyAlias) assert set(core2) == {A, B} def test_annotated(): class A: ... core = get_core_types(Annotated[A, 'hello']) assert set(core) == {A}