diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f458f7..5c8119f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,6 +72,8 @@ iTi-Flask 是 FastAPI 框架基座。 `/docs` 是文档入口。 它按 `docs_ui_enabled` 展示已启用的文档 UI。 默认包含 Swagger、Scalar 和 ReDoc。 +`/openapi.json` 会按 tag 前缀生成 `x-tagGroups`。 +例如 `system.user` 会归入 `system` 分组。 ## 鉴权 diff --git a/docs/MODULES.md b/docs/MODULES.md index b7fd6d3..2dffcbc 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -58,6 +58,9 @@ def ping(): return ok({"pong": True}) ``` +API 文档会按 tag 前缀生成分组。 +例如 `tags=["system.user"]` 会归入 `system` 分组,显示名为 `user`。 + ## 权限元数据 ```python diff --git a/iti/app.py b/iti/app.py index f61b5b0..5589115 100644 --- a/iti/app.py +++ b/iti/app.py @@ -55,6 +55,7 @@ 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" +OPENAPI_HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} def create_app( @@ -91,6 +92,7 @@ def create_app( redoc_url=None, ) install_docs(app) + install_openapi_tag_groups(app) app.state.config = config app.state.cache = CacheManager(default_timeout=config.cache_default_timeout) app.state.limiter = SimpleLimiter(enabled=config.ratelimit_enabled) @@ -158,6 +160,17 @@ def install_docs(app: FastAPI) -> None: return _docs_picker_html(app, doc_options) +def install_openapi_tag_groups(app: FastAPI) -> None: + default_openapi = app.openapi + + def openapi_with_tag_groups() -> dict[str, Any]: + schema = default_openapi() + _apply_openapi_tag_groups(schema) + return schema + + app.openapi = openapi_with_tag_groups + + def _enabled_doc_options(app: FastAPI) -> dict[str, dict[str, str]]: configured = getattr(app.state.config, "docs_ui_enabled", ["swagger", "scalar", "redoc"]) all_options = { @@ -237,6 +250,84 @@ def _render_template(name: str, values: Mapping[str, str]) -> str: return template +def _apply_openapi_tag_groups(schema: dict[str, Any]) -> None: + tag_names = _openapi_tag_names(schema) + if not tag_names or not any(_openapi_tag_display_name(tag) for tag in tag_names): + return + + schema["tags"] = _openapi_tag_objects(schema, tag_names) + groups: list[dict[str, Any]] = [] + group_index: dict[str, dict[str, Any]] = {} + for tag in tag_names: + group_name = _openapi_tag_group_name(tag) + group = group_index.get(group_name) + if group is None: + group = {"name": group_name, "tags": []} + groups.append(group) + group_index[group_name] = group + group["tags"].append(tag) + schema["x-tagGroups"] = groups + + +def _openapi_tag_names(schema: Mapping[str, Any]) -> list[str]: + names: list[str] = [] + seen: set[str] = set() + + def append_tag(value: Any) -> None: + if isinstance(value, str) and value not in seen: + names.append(value) + seen.add(value) + + for tag in schema.get("tags") or []: + if isinstance(tag, Mapping): + append_tag(tag.get("name")) + + paths = schema.get("paths") or {} + if not isinstance(paths, Mapping): + return names + for path_item in paths.values(): + if not isinstance(path_item, Mapping): + continue + for method, operation in path_item.items(): + if method.lower() not in OPENAPI_HTTP_METHODS or not isinstance(operation, Mapping): + continue + for tag in operation.get("tags") or []: + append_tag(tag) + return names + + +def _openapi_tag_objects(schema: Mapping[str, Any], tag_names: list[str]) -> list[dict[str, Any]]: + existing: dict[str, dict[str, Any]] = {} + for tag in schema.get("tags") or []: + if not isinstance(tag, Mapping) or not isinstance(tag.get("name"), str): + continue + existing.setdefault(tag["name"], dict(tag)) + + tag_objects: list[dict[str, Any]] = [] + for tag_name in tag_names: + tag = dict(existing.get(tag_name, {"name": tag_name})) + tag["name"] = tag_name + display_name = _openapi_tag_display_name(tag_name) + if display_name and "x-displayName" not in tag: + tag["x-displayName"] = display_name + tag_objects.append(tag) + return tag_objects + + +def _openapi_tag_group_name(tag: str) -> str: + prefix, separator, suffix = tag.partition(".") + if separator and prefix and suffix: + return prefix + return tag + + +def _openapi_tag_display_name(tag: str) -> str | None: + prefix, separator, suffix = tag.partition(".") + if separator and prefix and suffix: + return suffix + return None + + def init_middlewares(app: FastAPI) -> None: @app.middleware("http") async def request_context_middleware(request: Request, call_next): diff --git a/tests/test_app.py b/tests/test_app.py index 266b75f..e91ad4b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -113,6 +113,47 @@ def test_docs_picker_only_shows_enabled_ui(): assert ">ReDoc<" not in redoc.text +def test_openapi_tags_are_grouped_by_prefix(): + class GroupedRoutesModule: + name = "grouped-routes" + + def register_routes(self, app): + system_router = APIRouter(prefix="/system", tags=["system.user"]) + common_router = APIRouter(prefix="/common", tags=["common.file"]) + plain_router = APIRouter(prefix="/plain", tags=["exchange"]) + + @system_router.get("/users") + def users(): + return ok([]) + + @common_router.get("/files") + def files(): + return ok([]) + + @plain_router.get("/exports") + def exports(): + return ok([]) + + app.include_router(system_router) + app.include_router(common_router) + app.include_router(plain_router) + + config = BaseConfig(database_url="sqlite+pysqlite:///:memory:", testing=True) + app = create_app(modules=[GroupedRoutesModule()], config_mapping=config) + schema = TestClient(app).get("/openapi.json").json() + + assert schema["x-tagGroups"] == [ + {"name": "system", "tags": ["system.user"]}, + {"name": "common", "tags": ["common.file"]}, + {"name": "exchange", "tags": ["exchange"]}, + ] + assert schema["tags"] == [ + {"name": "system.user", "x-displayName": "user"}, + {"name": "common.file", "x-displayName": "file"}, + {"name": "exchange"}, + ] + + def test_envelope_and_error_handlers(): client = TestClient(make_app())