docs: tag auto group

main
NoahLan 1 week ago
parent f64b361d3b
commit 8b8a249d1b

@ -72,6 +72,8 @@ iTi-Flask 是 FastAPI 框架基座。
`/docs` 是文档入口。
它按 `docs_ui_enabled` 展示已启用的文档 UI。
默认包含 Swagger、Scalar 和 ReDoc。
`/openapi.json` 会按 tag 前缀生成 `x-tagGroups`
例如 `system.user` 会归入 `system` 分组。
## 鉴权

@ -58,6 +58,9 @@ def ping():
return ok({"pong": True})
```
API 文档会按 tag 前缀生成分组。
例如 `tags=["system.user"]` 会归入 `system` 分组,显示名为 `user`
## 权限元数据
```python

@ -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):

@ -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())

Loading…
Cancel
Save