docs: docs更新

main
NoahLan 2 weeks ago
parent b4cba77401
commit fde94665fb

@ -37,7 +37,8 @@ iTi-Flask 是 FastAPI 后端框架基座。
- 先读现有代码,再改文档或行为。 - 先读现有代码,再改文档或行为。
- 配置继续使用当前 dataclass 风格。 - 配置继续使用当前 dataclass 风格。
- JSON API 默认兼容 envelope除非路由明确 raw。 - 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` - 模块元数据使用 `ModulePermission``ModuleMenuSeed`
- migration 归生成项目所有。框架不要静默接管业务项目 migration 流。 - migration 归生成项目所有。框架不要静默接管业务项目 migration 流。
- 审计保持异步、非阻塞。框架只发事件,接收方在框架外。 - 审计保持异步、非阻塞。框架只发事件,接收方在框架外。

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

@ -68,7 +68,10 @@ iTi-Flask 是 FastAPI 框架基座。
- `/ready` - `/ready`
- `/docs` - `/docs`
- `/openapi.json` - `/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 | | `ready_check_db` | `/ready` 是否执行 DB ping |
| `response_envelope_http_status` | envelope 响应使用的 HTTP 状态码,默认 200 | | `response_envelope_http_status` | envelope 响应使用的 HTTP 状态码,默认 200 |
| `response_envelope_enabled` | 是否启用自动 envelope | | `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` | 是否启用内存限流 | | `ratelimit_enabled` | 是否启用内存限流 |
| `cache_default_timeout` | 默认缓存秒数 | | `cache_default_timeout` | 默认缓存秒数 |
| `file_storage` | 文件存储配置 | | `file_storage` | 文件存储配置 |

@ -3,6 +3,8 @@ from __future__ import annotations
import logging import logging
import time import time
import uuid import uuid
from html import escape
from importlib import resources
from http import HTTPStatus from http import HTTPStatus
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from contextvars import ContextVar from contextvars import ContextVar
@ -22,9 +24,10 @@ from fastapi.dependencies.utils import (
) )
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware 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 APIRoute
from fastapi.routing import request_response from fastapi.routing import request_response
from fastapi.responses import JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import Response from starlette.responses import Response
@ -48,6 +51,8 @@ from iti.tasks import init_task_runner
logger = logging.getLogger("iti") logger = logging.getLogger("iti")
error_logger = logging.getLogger("iti.error") error_logger = logging.getLogger("iti.error")
_current_request: ContextVar[Request | None] = ContextVar("iti_current_request", default=None) _current_request: ContextVar[Request | None] = ContextVar("iti_current_request", default=None)
DOCS_PICKER_TEMPLATE = "docs-picker.html"
SCALAR_TEMPLATE = "scalar.html"
def create_app( def create_app(
@ -80,7 +85,10 @@ def create_app(
title=config.app_name, title=config.app_name,
debug=config.debug, debug=config.debug,
lifespan=lifespan, lifespan=lifespan,
docs_url=None,
redoc_url=None,
) )
install_docs(app)
app.state.config = config app.state.config = config
app.state.cache = CacheManager(default_timeout=config.cache_default_timeout) app.state.cache = CacheManager(default_timeout=config.cache_default_timeout)
app.state.limiter = SimpleLimiter(enabled=config.ratelimit_enabled) app.state.limiter = SimpleLimiter(enabled=config.ratelimit_enabled)
@ -120,6 +128,104 @@ def create_app(
return 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: def init_middlewares(app: FastAPI) -> None:
@app.middleware("http") @app.middleware("http")
async def request_context_middleware(request: Request, call_next): async def request_context_middleware(request: Request, call_next):

@ -75,7 +75,10 @@ class BaseConfig:
response_envelope_http_status: int = 200 response_envelope_http_status: int = 200
response_envelope_enabled: bool = True response_envelope_enabled: bool = True
raw_response_paths: list[str] = field( 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 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] [tool.setuptools.package-data]
iti = [ iti = [
"templates/*.html",
] ]
[project.scripts] [project.scripts]

@ -63,6 +63,56 @@ def test_framework_health_routes():
assert client.get("/ready").json() == {"status": "ok"} 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(): def test_envelope_and_error_handlers():
client = TestClient(make_app()) client = TestClient(make_app())

Loading…
Cancel
Save