Skip to content

Commit 2952c87

Browse files
committed
feat: Add Api Docs
1 parent 5f4904d commit 2952c87

File tree

7 files changed

+182
-10
lines changed

7 files changed

+182
-10
lines changed

backend/apps/datasource/api/datasource.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88

99
import orjson
1010
import pandas as pd
11-
from fastapi import APIRouter, File, UploadFile, HTTPException
11+
from fastapi import APIRouter, File, UploadFile, HTTPException, Path
1212

1313
from apps.db.db import get_schema
1414
from apps.db.engine import get_engine_conn
15+
from apps.swagger.i18n import PLACEHOLDER_PREFIX
1516
from common.core.config import settings
1617
from common.core.deps import SessionDep, CurrentUser, Trans
1718
from common.utils.utils import SQLBotLogUtil
@@ -22,7 +23,7 @@
2223
from ..crud.table import get_tables_by_ds_id
2324
from ..models.datasource import CoreDatasource, CreateDatasource, TableObj, CoreTable, CoreField, FieldObj
2425

25-
router = APIRouter(tags=["datasource"], prefix="/datasource")
26+
router = APIRouter(tags=["Datasource"], prefix="/datasource")
2627
path = settings.EXCEL_PATH
2728

2829

@@ -33,13 +34,14 @@ async def query_by_oid(session: SessionDep, user: CurrentUser, oid: int) -> List
3334
return get_datasource_list(session=session, user=user, oid=oid)
3435

3536

36-
@router.get("/list")
37+
@router.get("/list", response_model=List[CoreDatasource], summary=f"{PLACEHOLDER_PREFIX}ds_list",
38+
description=f"{PLACEHOLDER_PREFIX}ds_list_description")
3739
async def datasource_list(session: SessionDep, user: CurrentUser):
3840
return get_datasource_list(session=session, user=user)
3941

4042

41-
@router.post("/get/{id}")
42-
async def get_datasource(session: SessionDep, id: int):
43+
@router.post("/get/{id}", response_model=CoreDatasource, summary=f"{PLACEHOLDER_PREFIX}ds_get")
44+
async def get_datasource(session: SessionDep, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")):
4345
return get_ds(session, id)
4446

4547

backend/apps/swagger/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Author: Junjun
2+
# Date: 2025/12/11

backend/apps/swagger/i18n.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Author: Junjun
2+
# Date: 2025/12/11
3+
# i18n.py
4+
import json
5+
from pathlib import Path
6+
from typing import Dict
7+
8+
# placeholder prefix(trans key prefix)
9+
PLACEHOLDER_PREFIX = "PLACEHOLDER_"
10+
11+
# default lang
12+
DEFAULT_LANG = "en"
13+
14+
LOCALES_DIR = Path(__file__).parent / "locales"
15+
_translations_cache: Dict[str, Dict[str, str]] = {}
16+
17+
18+
def load_translation(lang: str) -> Dict[str, str]:
19+
"""Load translations for the specified language from a JSON file"""
20+
if lang in _translations_cache:
21+
return _translations_cache[lang]
22+
23+
file_path = LOCALES_DIR / f"{lang}.json"
24+
if not file_path.exists():
25+
if lang == DEFAULT_LANG:
26+
raise FileNotFoundError(f"Default language file not found: {file_path}")
27+
# If the non-default language is missing, fall back to the default language
28+
return load_translation(DEFAULT_LANG)
29+
30+
try:
31+
with open(file_path, "r", encoding="utf-8") as f:
32+
data = json.load(f)
33+
if not isinstance(data, dict):
34+
raise ValueError(f"Translation file {file_path} must be a JSON object")
35+
_translations_cache[lang] = data
36+
return data
37+
except json.JSONDecodeError as e:
38+
raise ValueError(f"Invalid JSON in {file_path}: {e}")
39+
40+
41+
# group tags
42+
tags_metadata = [
43+
{
44+
"name": "Datasource",
45+
"description": f"{PLACEHOLDER_PREFIX}ds_api"
46+
}
47+
]
48+
49+
50+
def get_translation(lang: str) -> Dict[str, str]:
51+
return load_translation(lang)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"ds_api": "Datasource API",
3+
"ds_list": "Datasource list",
4+
"ds_list_description": "Retrieve all data sources under the current workspace",
5+
"ds_get": "Get Datasource",
6+
"ds_id": "Datasource ID"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"ds_api": "数据源接口",
3+
"ds_list": "数据源列表",
4+
"ds_list_description": "获取当前工作空间下所有数据源",
5+
"ds_get": "获取数据源",
6+
"ds_id": "数据源 ID"
7+
}

backend/common/core/response_middleware.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ async def dispatch(self, request, call_next):
1818

1919
direct_paths = [
2020
f"{settings.API_V1_STR}/mcp/mcp_question",
21-
f"{settings.API_V1_STR}/mcp/mcp_assistant"
21+
f"{settings.API_V1_STR}/mcp/mcp_assistant",
22+
"/openapi.json",
23+
"/docs",
24+
"/redoc"
2225
]
2326

2427
route = request.scope.get("route")

backend/main.py

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import os
2+
from typing import Dict, Any
23

34
import sqlbot_xpack
45
from alembic.config import Config
5-
from fastapi import FastAPI
6+
from fastapi import FastAPI, Request
67
from fastapi.concurrency import asynccontextmanager
8+
from fastapi.openapi.utils import get_openapi
9+
from fastapi.responses import JSONResponse
710
from fastapi.routing import APIRoute
811
from fastapi.staticfiles import StaticFiles
912
from fastapi_mcp import FastApiMCP
@@ -12,14 +15,16 @@
1215

1316
from alembic import command
1417
from apps.api import api_router
15-
from common.utils.embedding_threads import fill_empty_table_and_ds_embeddings
18+
from apps.swagger.i18n import PLACEHOLDER_PREFIX, tags_metadata
19+
from apps.swagger.i18n import get_translation, DEFAULT_LANG
1620
from apps.system.crud.aimodel_manage import async_model_info
1721
from apps.system.crud.assistant import init_dynamic_cors
1822
from apps.system.middleware.auth import TokenMiddleware
1923
from common.core.config import settings
2024
from common.core.response_middleware import ResponseMiddleware, exception_handler
2125
from common.core.sqlbot_cache import init_sqlbot_cache
22-
from common.utils.embedding_threads import fill_empty_terminology_embeddings, fill_empty_data_training_embeddings
26+
from common.utils.embedding_threads import fill_empty_terminology_embeddings, fill_empty_data_training_embeddings, \
27+
fill_empty_table_and_ds_embeddings
2328
from common.utils.utils import SQLBotLogUtil
2429

2530

@@ -65,9 +70,104 @@ def custom_generate_unique_id(route: APIRoute) -> str:
6570
title=settings.PROJECT_NAME,
6671
openapi_url=f"{settings.API_V1_STR}/openapi.json",
6772
generate_unique_id_function=custom_generate_unique_id,
68-
lifespan=lifespan
73+
lifespan=lifespan,
74+
docs_url=None,
75+
redoc_url=None
6976
)
7077

78+
# cache docs for different text
79+
_openapi_cache: Dict[str, Dict[str, Any]] = {}
80+
81+
# replace placeholder
82+
def replace_placeholders_in_schema(schema: Dict[str, Any], trans: Dict[str, str]) -> None:
83+
"""
84+
search OpenAPI schema,replace PLACEHOLDER_xxx to text。
85+
"""
86+
if isinstance(schema, dict):
87+
for key, value in schema.items():
88+
if isinstance(value, str) and value.startswith(PLACEHOLDER_PREFIX):
89+
placeholder_key = value[len(PLACEHOLDER_PREFIX):]
90+
schema[key] = trans.get(placeholder_key, value)
91+
else:
92+
replace_placeholders_in_schema(value, trans)
93+
elif isinstance(schema, list):
94+
for item in schema:
95+
replace_placeholders_in_schema(item, trans)
96+
97+
98+
99+
# OpenAPI build
100+
def get_language_from_request(request: Request) -> str:
101+
# get param from query ?lang=zh
102+
lang = request.query_params.get("lang")
103+
if lang in ["en", "zh"]:
104+
return lang
105+
# get lang from Accept-Language Header
106+
accept_lang = request.headers.get("accept-language", "")
107+
if "zh" in accept_lang.lower():
108+
return "zh"
109+
return DEFAULT_LANG
110+
111+
112+
def generate_openapi_for_lang(lang: str) -> Dict[str, Any]:
113+
if lang in _openapi_cache:
114+
return _openapi_cache[lang]
115+
116+
# tags metadata
117+
trans = get_translation(lang)
118+
localized_tags = []
119+
for tag in tags_metadata:
120+
desc = tag["description"]
121+
if desc.startswith(PLACEHOLDER_PREFIX):
122+
key = desc[len(PLACEHOLDER_PREFIX):]
123+
desc = trans.get(key, desc)
124+
localized_tags.append({
125+
"name": tag["name"],
126+
"description": desc
127+
})
128+
129+
# 1. create OpenAPI
130+
openapi_schema = get_openapi(
131+
title="SQLBot API Document" if lang == "en" else "SQLBot API 文档",
132+
version="1.0.0",
133+
routes=app.routes,
134+
tags=localized_tags
135+
)
136+
137+
# openapi version
138+
openapi_schema.setdefault("openapi", "3.1.0")
139+
140+
# 2. get trans for lang
141+
trans = get_translation(lang)
142+
143+
# 3. replace placeholder
144+
replace_placeholders_in_schema(openapi_schema, trans)
145+
146+
# 4. cache
147+
_openapi_cache[lang] = openapi_schema
148+
return openapi_schema
149+
150+
151+
152+
# custom /openapi.json and /docs
153+
@app.get("/openapi.json", include_in_schema=False)
154+
async def custom_openapi(request: Request):
155+
lang = get_language_from_request(request)
156+
schema = generate_openapi_for_lang(lang)
157+
return JSONResponse(schema)
158+
159+
160+
@app.get("/docs", include_in_schema=False)
161+
async def custom_swagger_ui(request: Request):
162+
lang = get_language_from_request(request)
163+
from fastapi.openapi.docs import get_swagger_ui_html
164+
return get_swagger_ui_html(
165+
openapi_url=f"/openapi.json?lang={lang}",
166+
title="SQLBot API Docs",
167+
swagger_favicon_url="https://fastapi.tiangolo.com/img/favicon.png",
168+
)
169+
170+
71171
mcp_app = FastAPI()
72172
# mcp server, images path
73173
images_path = settings.MCP_IMAGE_PATH

0 commit comments

Comments
 (0)