diff --git a/app/portal.py b/app/portal.py index 5a68930..161fdd1 100644 --- a/app/portal.py +++ b/app/portal.py @@ -45,7 +45,9 @@ def _grant_matches(grant: Endpoint, method: str, path: str) -> bool: def _service_paths(service: Service, grants: list[Endpoint], spec: dict | None) -> dict: - """The slug-prefixed path entries of one service, filtered to the grants.""" + """The slug-prefixed path entries of one service, filtered to the grants. + Operations keep their upstream tags — they become the second grouping + level; untagged operations fall into 'General'.""" slug = service.slug if spec: included = {} @@ -57,7 +59,7 @@ def _service_paths(service: Service, grants: list[Endpoint], spec: dict | None) if method.lower() not in _HTTP_METHODS or not isinstance(op, dict): continue if any(_grant_matches(g, method.upper(), path) for g in grants): - kept[method] = {**op, "tags": [service.name]} + kept[method] = {**op, "tags": list(op.get("tags") or ["General"])} if kept: entry = {k: v for k, v in operations.items() if k.lower() not in _HTTP_METHODS} entry.update(kept) @@ -72,33 +74,95 @@ def _service_paths(service: Service, grants: list[Endpoint], spec: dict | None) for method in methods: entry[method] = { "summary": g.description or ("(any method)" if g.method == "*" else ""), - "tags": [service.name], + "tags": ["General"], "responses": {"default": {"description": "Proxied upstream response"}}, } return included def build_key_spec(db: Session, api_key: ApiKey, gateway_url: str) -> dict: + """Two-level structure: each service is an `x-tagGroups` entry (first + level, with its description in the intro), containing the upstream's own + operation tags (second level). Tag names shared by several services are + disambiguated with the service name.""" by_service: dict[int, list[Endpoint]] = {} for grant in api_key.endpoints: by_service.setdefault(grant.service_id, []).append(grant) - paths: dict = {} + merged: list[tuple[Service, dict]] = [] # (service, its filtered paths) + used_tags: dict[int, list[str]] = {} # service id -> upstream tags, in order + tag_descriptions: dict[tuple[int, str], str] = {} + upstream_infos: dict[int, dict] = {} # service id -> upstream spec's `info` components: dict = {} - tags: list[dict] = [] - for service_id, grants in by_service.items(): + + ordered = sorted(by_service.values(), key=lambda g: g[0].service.name) + for grants in ordered: service = grants[0].service if not service.is_active: continue spec = discovery.get_spec(db, service) - paths.update(_service_paths(service, grants, spec)) + paths = _service_paths(service, grants, spec) + if not paths: + continue + order: list[str] = [] + for entry in paths.values(): + for method, op in entry.items(): + if method.lower() in _HTTP_METHODS and isinstance(op, dict): + for tag in op.get("tags", []): + if tag not in order: + order.append(tag) + used_tags[service.id] = order if spec: + info = spec.get("info") + upstream_infos[service.id] = info if isinstance(info, dict) else {} + for tag in spec.get("tags") or []: + if isinstance(tag, dict) and tag.get("name") in order: + tag_descriptions[(service.id, tag["name"])] = tag.get("description", "") for section, items in spec.get("components", {}).items(): if section == "securitySchemes" or not isinstance(items, dict): continue renamed = {f"{service.slug}_{name}": schema for name, schema in items.items()} components.setdefault(section, {}).update(_rewrite_refs(renamed, service.slug)) - tags.append({"name": service.name, "description": service.description or f"/{service.slug}"}) + merged.append((service, paths)) + + # A tag name used by several services must stay unique in the flat tag list. + tag_counts: dict[str, int] = {} + for names in used_tags.values(): + for name in names: + tag_counts[name] = tag_counts.get(name, 0) + 1 + + def final_name(service: Service, tag: str) -> str: + return tag if tag_counts[tag] == 1 else f"{tag} ({service.name})" + + paths_out: dict = {} + tags_out: list[dict] = [] + tag_groups: list[dict] = [] + intro = [f"Operations available to API key **{api_key.name}**. " + f"Send the key in the `{config.API_KEY_HEADER}` header."] + for service, paths in merged: + finals = [] + for tag in used_tags[service.id]: + name = final_name(service, tag) + finals.append(name) + tags_out.append({"name": name, + "description": tag_descriptions.get((service.id, tag), "")}) + for entry in paths.values(): + for method, op in entry.items(): + if method.lower() in _HTTP_METHODS and isinstance(op, dict): + op["tags"] = [final_name(service, t) for t in op.get("tags", ["General"])] + paths_out.update(paths) + tag_groups.append({"name": service.name, "tags": finals}) + info = upstream_infos.get(service.id, {}) + section = f"## {service.name}\n\n" + title = info.get("title", "") + if title and title.lower() != service.name.lower(): + section += f"*{title}*\n\n" + # The admin-entered description overrides the upstream's own. + description = service.description or info.get("description") or "" + if description: + section += description.strip() + "\n\n" + section += f"Routes under `/{service.slug}/…`" + intro.append(section) components["securitySchemes"] = { "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": config.API_KEY_HEADER}, @@ -108,12 +172,12 @@ def build_key_spec(db: Session, api_key: ApiKey, gateway_url: str) -> dict: "info": { "title": "API Gateway", "version": "1.0.0", - "description": f"Operations available to API key **{api_key.name}**. " - f"Send the key in the `{config.API_KEY_HEADER}` header.", + "description": "\n\n".join(intro), }, "servers": [{"url": gateway_url}], - "tags": sorted(tags, key=lambda t: t["name"]), - "paths": dict(sorted(paths.items())), + "tags": tags_out, + "x-tagGroups": tag_groups, + "paths": dict(sorted(paths_out.items())), "components": components, "security": [{"ApiKeyAuth": []}], } @@ -141,7 +205,6 @@ _DOCS_PAGE = """