diff --git a/.codex/skills/iti-flask-framework/SKILL.md b/.codex/skills/iti-flask-framework/SKILL.md index 5455223..dcd384d 100644 --- a/.codex/skills/iti-flask-framework/SKILL.md +++ b/.codex/skills/iti-flask-framework/SKILL.md @@ -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 流。 - 审计保持异步、非阻塞。框架只发事件,接收方在框架外。 diff --git a/copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja b/copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja index 2abed3e..9638a9e 100644 --- a/copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja +++ b/copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja @@ -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 "" }} ## 命令 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8af3447..8f458f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -68,7 +68,10 @@ iTi-Flask 是 FastAPI 框架基座。 - `/ready` - `/docs` - `/openapi.json` -- `/redoc` + +`/docs` 是文档入口。 +它按 `docs_ui_enabled` 展示已启用的文档 UI。 +默认包含 Swagger、Scalar 和 ReDoc。 ## 鉴权 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1e4d283..2346a8a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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` | 文件存储配置 | diff --git a/iti/app.py b/iti/app.py index 8378b91..b222acf 100644 --- a/iti/app.py +++ b/iti/app.py @@ -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""" + + + {label} + {description} + + + """ + + +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): diff --git a/iti/config.py b/iti/config.py index d00d9c2..5d8b4e1 100644 --- a/iti/config.py +++ b/iti/config.py @@ -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 diff --git a/iti/templates/__init__.py b/iti/templates/__init__.py new file mode 100644 index 0000000..af85593 --- /dev/null +++ b/iti/templates/__init__.py @@ -0,0 +1 @@ +"""Packaged HTML templates.""" diff --git a/iti/templates/docs-picker.html b/iti/templates/docs-picker.html new file mode 100644 index 0000000..faadfb5 --- /dev/null +++ b/iti/templates/docs-picker.html @@ -0,0 +1,202 @@ + + +
+