346 lines
8.9 KiB
Python
346 lines
8.9 KiB
Python
"""
|
||
API文档生成器 - 离线生成Swagger文档
|
||
"""
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
from ..utils import get_logger
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
# 文档缓存目录
|
||
STATIC_DIR = Path("static")
|
||
CACHE_FILE = STATIC_DIR / ".openapi_cache"
|
||
DOC_FILE = STATIC_DIR / "doc.html"
|
||
|
||
|
||
def get_openapi_hash(openapi_spec: dict) -> str:
|
||
"""计算OpenAPI规范的哈希值"""
|
||
spec_str = json.dumps(openapi_spec, sort_keys=True, ensure_ascii=False)
|
||
return hashlib.md5(spec_str.encode()).hexdigest()
|
||
|
||
|
||
def get_cached_hash() -> Optional[str]:
|
||
"""获取缓存的哈希值"""
|
||
try:
|
||
if CACHE_FILE.exists():
|
||
return CACHE_FILE.read_text().strip()
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def save_cache_hash(hash_value: str):
|
||
"""保存哈希值到缓存"""
|
||
try:
|
||
CACHE_FILE.write_text(hash_value)
|
||
except Exception as e:
|
||
logger.warning(f"无法保存文档缓存: {e}")
|
||
|
||
|
||
def generate_doc_html(openapi_spec: dict) -> str:
|
||
"""生成文档HTML内容"""
|
||
# 将OpenAPI规范内嵌到HTML中
|
||
spec_json = json.dumps(openapi_spec, ensure_ascii=False, indent=2)
|
||
api_version = openapi_spec.get("info", {}).get("version", "1.0.0")
|
||
api_title = openapi_spec.get("info", {}).get("title", "API文档")
|
||
|
||
html_content = f'''<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{api_title} - 接口文档</title>
|
||
<!-- 本地Swagger UI资源 -->
|
||
<link rel="stylesheet" href="/vendor/swagger-ui.css">
|
||
<script src="/vendor/swagger-ui-bundle.js"></script>
|
||
<style>
|
||
* {{
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}}
|
||
|
||
body {{
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
background: #f8fafc;
|
||
}}
|
||
|
||
.nav {{
|
||
background: white;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
height: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 24px;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
}}
|
||
|
||
.nav-brand {{
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
}}
|
||
|
||
.nav-brand i {{
|
||
color: #3b82f6;
|
||
margin-right: 8px;
|
||
}}
|
||
|
||
.nav-links {{
|
||
display: flex;
|
||
margin-left: 24px;
|
||
gap: 8px;
|
||
}}
|
||
|
||
.nav-link {{
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
text-decoration: none;
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
transition: all 0.2s;
|
||
}}
|
||
|
||
.nav-link:hover {{
|
||
background: rgba(59, 130, 246, 0.1);
|
||
color: #3b82f6;
|
||
}}
|
||
|
||
.nav-link.active {{
|
||
background: rgba(59, 130, 246, 0.1);
|
||
color: #3b82f6;
|
||
border-bottom: 2px solid #3b82f6;
|
||
}}
|
||
|
||
.nav-right {{
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}}
|
||
|
||
.api-version {{
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
}}
|
||
|
||
.api-version span {{
|
||
font-weight: 500;
|
||
color: #1f2937;
|
||
}}
|
||
|
||
.main {{
|
||
padding: 16px 24px;
|
||
}}
|
||
|
||
.doc-container {{
|
||
background: white;
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
min-height: calc(100vh - 120px);
|
||
}}
|
||
|
||
/* Swagger UI 样式覆盖 */
|
||
.swagger-ui .topbar {{
|
||
display: none;
|
||
}}
|
||
|
||
.swagger-ui .info {{
|
||
margin: 20px 0;
|
||
}}
|
||
|
||
.swagger-ui .scheme-container {{
|
||
background: #f8fafc;
|
||
padding: 15px;
|
||
box-shadow: none;
|
||
}}
|
||
|
||
/* Font Awesome 图标 (内联) */
|
||
.fa {{
|
||
display: inline-block;
|
||
font-style: normal;
|
||
}}
|
||
.fa-file-text-o:before {{ content: "📄"; }}
|
||
.fa-file-text:before {{ content: "📝"; }}
|
||
.fa-book:before {{ content: "📖"; }}
|
||
.fa-home:before {{ content: "🏠"; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav class="nav">
|
||
<div class="nav-brand">
|
||
<i class="fa fa-file-text-o"></i>
|
||
X-Request 管理系统
|
||
</div>
|
||
<div class="nav-links">
|
||
<a href="/log.html" class="nav-link">
|
||
<i class="fa fa-file-text"></i> 日志管理
|
||
</a>
|
||
<a href="/doc.html" class="nav-link active">
|
||
<i class="fa fa-book"></i> 接口文档
|
||
</a>
|
||
<a href="/" class="nav-link">
|
||
<i class="fa fa-home"></i> 首页
|
||
</a>
|
||
</div>
|
||
<div class="nav-right">
|
||
<div class="api-version">
|
||
API版本: <span>{api_version}</span>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<main class="main">
|
||
<div class="doc-container">
|
||
<div id="swagger-ui"></div>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
// 内嵌的OpenAPI规范
|
||
const spec = {spec_json};
|
||
|
||
window.onload = function() {{
|
||
SwaggerUIBundle({{
|
||
spec: spec,
|
||
dom_id: '#swagger-ui',
|
||
deepLinking: true,
|
||
presets: [
|
||
SwaggerUIBundle.presets.apis,
|
||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||
],
|
||
layout: "BaseLayout",
|
||
defaultModelsExpandDepth: 1,
|
||
defaultModelExpandDepth: 1,
|
||
docExpansion: "list",
|
||
filter: true,
|
||
showExtensions: true,
|
||
showCommonExtensions: true
|
||
}});
|
||
}};
|
||
</script>
|
||
</body>
|
||
</html>'''
|
||
|
||
return html_content
|
||
|
||
|
||
def generate_loading_html() -> str:
|
||
"""生成加载中的HTML页面"""
|
||
return '''<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>生成文档中...</title>
|
||
<style>
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
min-height: 100vh;
|
||
margin: 0;
|
||
background: #f8fafc;
|
||
}
|
||
.loading {
|
||
text-align: center;
|
||
}
|
||
.spinner {
|
||
width: 50px;
|
||
height: 50px;
|
||
border: 4px solid #e5e7eb;
|
||
border-top-color: #3b82f6;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 20px;
|
||
}
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
h2 {
|
||
color: #1f2937;
|
||
margin-bottom: 10px;
|
||
}
|
||
p {
|
||
color: #6b7280;
|
||
}
|
||
</style>
|
||
<meta http-equiv="refresh" content="2">
|
||
</head>
|
||
<body>
|
||
<div class="loading">
|
||
<div class="spinner"></div>
|
||
<h2>正在生成API文档</h2>
|
||
<p>请稍候,页面将自动刷新...</p>
|
||
</div>
|
||
</body>
|
||
</html>'''
|
||
|
||
|
||
def should_regenerate(openapi_spec: dict) -> bool:
|
||
"""检查是否需要重新生成文档"""
|
||
# 检查文档文件是否存在
|
||
if not DOC_FILE.exists():
|
||
return True
|
||
|
||
# 检查本地资源是否存在
|
||
vendor_dir = STATIC_DIR / "vendor"
|
||
if not (vendor_dir / "swagger-ui.css").exists() or not (vendor_dir / "swagger-ui-bundle.js").exists():
|
||
return True
|
||
|
||
# 比较哈希值
|
||
current_hash = get_openapi_hash(openapi_spec)
|
||
cached_hash = get_cached_hash()
|
||
|
||
return current_hash != cached_hash
|
||
|
||
|
||
def generate_docs(app) -> bool:
|
||
"""
|
||
生成API文档
|
||
|
||
Args:
|
||
app: FastAPI应用实例
|
||
|
||
Returns:
|
||
bool: 是否重新生成了文档
|
||
"""
|
||
try:
|
||
# 获取OpenAPI规范
|
||
openapi_spec = app.openapi()
|
||
|
||
# 检查是否需要重新生成
|
||
if not should_regenerate(openapi_spec):
|
||
logger.info("API文档未变化,使用缓存")
|
||
return False
|
||
|
||
logger.info("检测到API变化,正在生成文档...")
|
||
|
||
# 先写入加载页面
|
||
DOC_FILE.write_text(generate_loading_html(), encoding='utf-8')
|
||
|
||
# 生成文档HTML
|
||
doc_html = generate_doc_html(openapi_spec)
|
||
|
||
# 写入文档文件
|
||
DOC_FILE.write_text(doc_html, encoding='utf-8')
|
||
|
||
# 保存哈希值
|
||
current_hash = get_openapi_hash(openapi_spec)
|
||
save_cache_hash(current_hash)
|
||
|
||
logger.info("API文档生成完成")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"生成API文档失败: {e}")
|
||
return False
|