docs: docs更新

main
NoahLan 2 weeks ago
parent b4cba77401
commit fde94665fb

@ -37,7 +37,8 @@ iTi-Flask 是 FastAPI 后端框架基座。
- 先读现有代码,再改文档或行为。
- 配置继续使用当前 dataclass 风格。
- JSON API 默认兼容 envelope除非路由明确 raw。
- 保留 raw 默认值:`/health`、`/ready`、`/docs`、`/openapi.json`、`/redoc`。
- 保留 raw 默认值:`/health`、`/ready`、`/docs`、`/openapi.json`。
- `/docs` 是文档入口,按 `docs_ui_enabled` 展示 Swagger、Scalar、ReDoc 等已启用 UI。
- 模块元数据使用 `ModulePermission``ModuleMenuSeed`
- migration 归生成项目所有。框架不要静默接管业务项目 migration 流。
- 审计保持异步、非阻塞。框架只发事件,接收方在框架外。

@ -57,6 +57,7 @@ description: "{{ project_name }} 业务项目 skill。用于当前由 iTi-Flask
- 默认使用框架 envelope除非路由明确 raw。
- 服务间内部 API 使用 service token。
- 项目级测试使用 `fastapi.testclient.TestClient`。
- 请求体使用 Pydantic schema。
{{ "- seed 前先同步 iTi-System migration。\n" if include_system else "" }}
## 命令

@ -68,7 +68,10 @@ iTi-Flask 是 FastAPI 框架基座。
- `/ready`
- `/docs`
- `/openapi.json`
- `/redoc`
`/docs` 是文档入口。
它按 `docs_ui_enabled` 展示已启用的文档 UI。
默认包含 Swagger、Scalar 和 ReDoc。
## 鉴权

@ -66,7 +66,8 @@ MYSQL_DATABASE=iti_dev
| `ready_check_db` | `/ready` 是否执行 DB ping |
| `response_envelope_http_status` | envelope 响应使用的 HTTP 状态码,默认 200 |
| `response_envelope_enabled` | 是否启用自动 envelope |
| `raw_response_paths` | 跳过自动 envelope 的路径,支持 `*` |
| `raw_response_paths` | 跳过自动 envelope 的路径,支持 `*`,默认包含 `/health`、`/ready`、`/docs`、`/openapi.json` |
| `docs_ui_enabled` | `/docs` 选择页展示的文档 UI默认 `swagger`、`scalar`、`redoc` |
| `ratelimit_enabled` | 是否启用内存限流 |
| `cache_default_timeout` | 默认缓存秒数 |
| `file_storage` | 文件存储配置 |

@ -3,6 +3,8 @@ from __future__ import annotations
import logging
import time
import uuid
from html import escape
from importlib import resources
from http import HTTPStatus
from collections.abc import Iterable, Mapping
from contextvars import ContextVar
@ -22,9 +24,10 @@ from fastapi.dependencies.utils import (
)
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.routing import APIRoute
from fastapi.routing import request_response
from fastapi.responses import JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import Response
@ -48,6 +51,8 @@ from iti.tasks import init_task_runner
logger = logging.getLogger("iti")
error_logger = logging.getLogger("iti.error")
_current_request: ContextVar[Request | None] = ContextVar("iti_current_request", default=None)
DOCS_PICKER_TEMPLATE = "docs-picker.html"
SCALAR_TEMPLATE = "scalar.html"
def create_app(
@ -80,7 +85,10 @@ def create_app(
title=config.app_name,
debug=config.debug,
lifespan=lifespan,
docs_url=None,
redoc_url=None,
)
install_docs(app)
app.state.config = config
app.state.cache = CacheManager(default_timeout=config.cache_default_timeout)
app.state.limiter = SimpleLimiter(enabled=config.ratelimit_enabled)
@ -120,6 +128,104 @@ def create_app(
return app
def install_docs(app: FastAPI) -> None:
@app.get("/docs", include_in_schema=False)
def docs(ui: str | None = None) -> HTMLResponse:
doc_options = _enabled_doc_options(app)
if ui == "swagger" and "swagger" in doc_options:
return get_swagger_ui_html(
openapi_url=app.openapi_url or "/openapi.json",
title=f"{app.title} - Swagger UI",
)
if ui == "scalar" and "scalar" in doc_options:
return _scalar_docs_html(app)
if ui == "redoc" and "redoc" in doc_options:
return get_redoc_html(
openapi_url=app.openapi_url or "/openapi.json",
title=f"{app.title} - ReDoc",
)
return _docs_picker_html(app, doc_options)
def _enabled_doc_options(app: FastAPI) -> dict[str, dict[str, str]]:
configured = getattr(app.state.config, "docs_ui_enabled", ["swagger", "scalar", "redoc"])
all_options = {
"swagger": {
"class": "swagger",
"label": "Swagger",
"abbr": "SW",
"description": "传统交互文档,适合快速试接口。",
},
"scalar": {
"class": "scalar",
"label": "Scalar",
"abbr": "SC",
"description": "现代接口参考,适合阅读和调试。",
},
"redoc": {
"class": "redoc",
"label": "ReDoc",
"abbr": "RD",
"description": "结构化阅读文档,适合查看模型关系。",
},
}
return {name: all_options[name] for name in configured if name in all_options}
def _docs_picker_html(app: FastAPI, doc_options: Mapping[str, dict[str, str]]) -> HTMLResponse:
title = escape(app.title)
option_cards = "\n".join(
_doc_option_card(name, option) for name, option in doc_options.items()
)
html = _render_template(
DOCS_PICKER_TEMPLATE,
{
"title": title,
"option_cards": option_cards,
},
)
return HTMLResponse(html)
def _doc_option_card(name: str, option: Mapping[str, str]) -> str:
label = escape(option["label"])
class_name = escape(option["class"])
abbr = escape(option["abbr"])
description = escape(option["description"])
href = escape(f"?ui={name}")
return f"""<a class="doc-option {class_name}" href="{href}" aria-label="打开 {label} 文档">
<span class="mark" aria-hidden="true">{abbr}</span>
<span class="copy">
<strong>{label}</strong>
<span>{description}</span>
</span>
<span class="arrow" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M5 12h14"></path>
<path d="m13 6 6 6-6 6"></path>
</svg>
</span>
</a>"""
def _scalar_docs_html(app: FastAPI) -> HTMLResponse:
html = _render_template(
SCALAR_TEMPLATE,
{
"title": escape(app.title),
"openapi_url": escape(app.openapi_url or "/openapi.json"),
},
)
return HTMLResponse(html)
def _render_template(name: str, values: Mapping[str, str]) -> str:
template = resources.files("iti.templates").joinpath(name).read_text(encoding="utf-8")
for key, value in values.items():
template = template.replace("{{ " + key + " }}", value)
return template
def init_middlewares(app: FastAPI) -> None:
@app.middleware("http")
async def request_context_middleware(request: Request, call_next):

@ -75,7 +75,10 @@ class BaseConfig:
response_envelope_http_status: int = 200
response_envelope_enabled: bool = True
raw_response_paths: list[str] = field(
default_factory=lambda: ["/health", "/ready", "/docs", "/openapi.json", "/redoc"]
default_factory=lambda: ["/health", "/ready", "/docs", "/openapi.json"]
)
docs_ui_enabled: list[str] = field(
default_factory=lambda: ["swagger", "scalar", "redoc"]
)
output_camel_case: bool = True

@ -0,0 +1 @@
"""Packaged HTML templates."""

@ -0,0 +1,202 @@
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{ title }} - API Docs</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
color-scheme: light;
--bg: #f7f8fb;
--panel: #ffffff;
--ink: #111827;
--muted: #5b6472;
--line: #d9dee7;
--line-strong: #aab4c3;
--blue: #2563eb;
--blue-ink: #173ea7;
--green: #16a34a;
--green-ink: #116134;
--orange: #ea580c;
--orange-ink: #9a3412;
--shadow: 0 18px 45px rgba(17, 24, 39, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Aptos", "Segoe UI", sans-serif;
color: var(--ink);
background:
linear-gradient(90deg, rgba(17, 24, 39, 0.035) 1px, transparent 1px),
linear-gradient(180deg, rgba(17, 24, 39, 0.035) 1px, transparent 1px),
var(--bg);
background-size: 40px 40px;
}
main {
width: min(920px, calc(100vw - 40px));
min-height: 100vh;
margin: 0 auto;
display: grid;
align-content: center;
gap: 28px;
padding: 56px 0;
}
h1 {
margin: 0;
font-size: 34px;
line-height: 1.15;
font-weight: 680;
text-wrap: pretty;
}
.eyebrow {
display: inline-flex;
width: fit-content;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: rgba(255, 255, 255, 0.72);
font-size: 13px;
font-weight: 600;
}
.intro {
display: grid;
gap: 12px;
}
.choices {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 18px;
}
.doc-option {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 18px;
min-height: 132px;
padding: 24px;
border: 1px solid var(--line);
border-radius: 8px;
color: inherit;
text-decoration: none;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 1px 0 rgba(17, 24, 39, 0.04);
transition:
border-color 180ms ease,
box-shadow 180ms ease,
background-color 180ms ease;
}
.doc-option:hover {
border-color: var(--line-strong);
background: var(--panel);
box-shadow: var(--shadow);
}
.doc-option:focus-visible {
outline: 3px solid rgba(37, 99, 235, 0.22);
outline-offset: 3px;
}
.mark {
display: grid;
width: 52px;
height: 52px;
place-items: center;
border-radius: 8px;
font-size: 14px;
font-weight: 750;
}
.swagger .mark {
color: var(--green-ink);
background: #dcfce7;
}
.scalar .mark {
color: var(--blue-ink);
background: #dbeafe;
}
.redoc .mark {
color: var(--orange-ink);
background: #ffedd5;
}
.copy {
display: grid;
gap: 7px;
}
.copy strong {
display: block;
font-size: 21px;
line-height: 1.2;
}
.copy span {
color: var(--muted);
font-size: 15px;
line-height: 1.55;
text-wrap: pretty;
}
.arrow {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
transition:
border-color 180ms ease,
color 180ms ease,
background-color 180ms ease;
}
.doc-option:hover .arrow {
border-color: transparent;
color: #fff;
background: #111827;
}
.arrow svg {
width: 17px;
height: 17px;
stroke: currentColor;
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (max-width: 700px) {
main {
width: min(100vw - 28px, 920px);
padding: 34px 0;
}
h1 {
font-size: 28px;
}
.choices {
grid-template-columns: 1fr;
}
.doc-option {
min-height: 118px;
padding: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.doc-option,
.arrow {
transition: none;
}
}
</style>
</head>
<body>
<main>
<div class="intro">
<span class="eyebrow">API Docs</span>
<h1>{{ title }}</h1>
</div>
<div class="choices">
{{ option_cards }}
</div>
</main>
</body>
</html>

@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<title>{{ title }} - Scalar API Reference</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script id="api-reference" data-url="{{ openapi_url }}"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>

@ -71,6 +71,7 @@ exclude = ["iti.runtime*"]
[tool.setuptools.package-data]
iti = [
"templates/*.html",
]
[project.scripts]

@ -63,6 +63,56 @@ def test_framework_health_routes():
assert client.get("/ready").json() == {"status": "ok"}
def test_docs_picker_and_ui_variants_are_available():
client = TestClient(make_app())
picker = client.get("/docs")
swagger = client.get("/docs?ui=swagger")
scalar = client.get("/docs?ui=scalar")
redoc = client.get("/docs?ui=redoc")
assert picker.status_code == 200
assert "Swagger" in picker.text
assert "Scalar" in picker.text
assert "ReDoc" in picker.text
assert "<svg viewBox" in picker.text
assert "-&gt;" not in picker.text
assert swagger.status_code == 200
assert "swagger-ui" in swagger.text
assert scalar.status_code == 200
assert "@scalar/api-reference" in scalar.text
assert 'data-url="/openapi.json"' in scalar.text
assert redoc.status_code == 200
assert "redoc" in redoc.text.lower()
def test_docs_templates_are_packaged_resources():
client = TestClient(make_app())
picker = client.get("/docs")
scalar = client.get("/docs?ui=scalar")
assert "<title>iTi - API Docs</title>" in picker.text
assert "<title>iTi - Scalar API Reference</title>" in scalar.text
def test_docs_picker_only_shows_enabled_ui():
client = TestClient(make_app(docs_ui_enabled=["swagger"]))
picker = client.get("/docs")
scalar = client.get("/docs?ui=scalar")
redoc = client.get("/docs?ui=redoc")
assert picker.status_code == 200
assert "Swagger" in picker.text
assert "Scalar" not in picker.text
assert "ReDoc" not in picker.text
assert scalar.status_code == 200
assert "@scalar/api-reference" not in scalar.text
assert redoc.status_code == 200
assert ">ReDoc<" not in redoc.text
def test_envelope_and_error_handlers():
client = TestClient(make_app())

Loading…
Cancel
Save