Add hierarchical key-scoped catalog API for machine consumers
GET /catalog walks agents through progressive disclosure: available APIs with descriptions, then one API's tags, then a tag's endpoints, then full detail for a single operation with $refs resolved inline. Oversized tags (tag-poor upstreams) fall back to path-prefix groups, drillable with ?prefix= and compressed through single-child chains. Responses are filtered to the key's grants, carry ETags for cheap revalidation, and reuse the discovery spec cache. Key auth is shared with the docs portal via portal.resolve_api_key; 'catalog' and 'mcp' are now reserved slugs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b90ecaf61c
commit
9a3feccc90
@@ -38,6 +38,12 @@ with a built-in web management console.
|
|||||||
key holds a grant on, across all services, rewritten to the gateway's
|
key holds a grant on, across all services, rewritten to the gateway's
|
||||||
routes. `GET /docs` serves a Swagger UI portal around it — paste a key,
|
routes. `GET /docs` serves a Swagger UI portal around it — paste a key,
|
||||||
browse and try exactly what that key can call.
|
browse and try exactly what that key can call.
|
||||||
|
- **Machine catalog** — a hierarchical, key-scoped catalog for agents/MCP
|
||||||
|
clients: `GET /catalog` (available APIs with descriptions), then
|
||||||
|
`/catalog/{slug}` (tags), then `/catalog/{slug}/tags/{tag}` (endpoints;
|
||||||
|
oversized tags group by path prefix, drill with `?prefix=`), then
|
||||||
|
`/catalog/{slug}/operation?method=&path=` (full schema, `$ref`s resolved
|
||||||
|
inline). Authenticated with `X-API-Key`; responses carry ETags.
|
||||||
- **Retention** — a background purge runs every 6 hours: request/response
|
- **Retention** — a background purge runs every 6 hours: request/response
|
||||||
payloads are blanked after 7 days (the log row stays inspectable), whole log
|
payloads are blanked after 7 days (the log row stays inspectable), whole log
|
||||||
rows are deleted after 90 days, and freed pages are returned to the
|
rows are deleted after 90 days, and freed pages are returned to the
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@ router = APIRouter(prefix="/admin")
|
|||||||
templates = Jinja2Templates(directory=str(config.BASE_DIR / "app" / "templates"))
|
templates = Jinja2Templates(directory=str(config.BASE_DIR / "app" / "templates"))
|
||||||
|
|
||||||
# Slugs the proxy catch-all must never claim
|
# Slugs the proxy catch-all must never claim
|
||||||
RESERVED_SLUGS = {"admin", "static", "health", "docs", "redoc", "openapi.json"}
|
RESERVED_SLUGS = {"admin", "static", "health", "docs", "redoc", "openapi.json",
|
||||||
|
"catalog", "mcp"}
|
||||||
|
|
||||||
|
|
||||||
def build_tree(endpoints: list[Endpoint]) -> dict:
|
def build_tree(endpoints: list[Endpoint]) -> dict:
|
||||||
|
|||||||
+331
@@ -0,0 +1,331 @@
|
|||||||
|
"""Hierarchical, key-scoped endpoint catalog for machine consumers (MCP).
|
||||||
|
|
||||||
|
Designed for progressive disclosure so an agent is never flooded:
|
||||||
|
|
||||||
|
GET /catalog -> the APIs this key can reach
|
||||||
|
GET /catalog/{slug} -> one API's tags (logical sections)
|
||||||
|
GET /catalog/{slug}/tags/{tag} -> endpoints of one tag; when a tag
|
||||||
|
is too large the response groups
|
||||||
|
by path prefix (?prefix= drills)
|
||||||
|
GET /catalog/{slug}/operation?method=&path=
|
||||||
|
-> full detail for one operation,
|
||||||
|
with $refs resolved inline
|
||||||
|
|
||||||
|
Everything is authenticated with X-API-Key and filtered to the key's grants.
|
||||||
|
Responses carry ETags so a polling MCP can revalidate cheaply.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import discovery
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import ApiKey, Endpoint, Service
|
||||||
|
from app.portal import _HTTP_METHODS, _grant_matches, resolve_api_key
|
||||||
|
from app.proxy import _error
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
MAX_LISTING = 50 # above this, a tag listing groups by path prefix
|
||||||
|
MAX_REF_DEPTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _respond(request: Request, payload: dict) -> Response:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode()
|
||||||
|
etag = '"' + hashlib.md5(body).hexdigest() + '"'
|
||||||
|
if request.headers.get("if-none-match") == etag:
|
||||||
|
return Response(status_code=304, headers={"ETag": etag})
|
||||||
|
return Response(body, media_type="application/json",
|
||||||
|
headers={"ETag": etag, "Cache-Control": "private, must-revalidate"})
|
||||||
|
|
||||||
|
|
||||||
|
def _grants_by_service(api_key: ApiKey) -> dict[int, list[Endpoint]]:
|
||||||
|
grouped: dict[int, list[Endpoint]] = {}
|
||||||
|
for grant in api_key.endpoints:
|
||||||
|
if grant.service.is_active:
|
||||||
|
grouped.setdefault(grant.service_id, []).append(grant)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def _granted_operations(db: Session, service: Service,
|
||||||
|
grants: list[Endpoint]) -> tuple[list[dict], dict | None]:
|
||||||
|
"""The operations this key may call on one service, as
|
||||||
|
[{method, path (gateway form), tags, summary, operationId}]. Falls back to
|
||||||
|
the endpoint catalog when the upstream has no OpenAPI document."""
|
||||||
|
spec = discovery.get_spec(db, service)
|
||||||
|
ops: list[dict] = []
|
||||||
|
if spec:
|
||||||
|
for path, operations in spec.get("paths", {}).items():
|
||||||
|
if not isinstance(operations, dict):
|
||||||
|
continue
|
||||||
|
for method, op in operations.items():
|
||||||
|
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):
|
||||||
|
ops.append({
|
||||||
|
"method": method.upper(),
|
||||||
|
"path": f"/{service.slug}{path}",
|
||||||
|
"tags": list(op.get("tags") or ["General"]),
|
||||||
|
"summary": op.get("summary") or op.get("description", "")[:200],
|
||||||
|
"operationId": op.get("operationId"),
|
||||||
|
})
|
||||||
|
return ops, spec
|
||||||
|
|
||||||
|
for g in grants:
|
||||||
|
ops.append({
|
||||||
|
"method": "ANY" if g.method == "*" else g.method,
|
||||||
|
"path": f"/{service.slug}{g.path}",
|
||||||
|
"tags": ["General"],
|
||||||
|
"summary": g.description or "",
|
||||||
|
"operationId": None,
|
||||||
|
})
|
||||||
|
return ops, None
|
||||||
|
|
||||||
|
|
||||||
|
def _service_summary(service: Service, spec: dict | None) -> dict:
|
||||||
|
info = (spec or {}).get("info") or {}
|
||||||
|
return {
|
||||||
|
"slug": service.slug,
|
||||||
|
"name": service.name,
|
||||||
|
"title": info.get("title", ""),
|
||||||
|
# The admin-entered description overrides the upstream's own.
|
||||||
|
"description": service.description or info.get("description", ""),
|
||||||
|
"route_prefix": f"/{service.slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_summaries(ops: list[dict], spec: dict | None, slug: str) -> list[dict]:
|
||||||
|
order: list[str] = []
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for op in ops:
|
||||||
|
for tag in op["tags"]:
|
||||||
|
if tag not in counts:
|
||||||
|
order.append(tag)
|
||||||
|
counts[tag] = counts.get(tag, 0) + 1
|
||||||
|
descriptions = {}
|
||||||
|
for tag in (spec or {}).get("tags") or []:
|
||||||
|
if isinstance(tag, dict) and tag.get("name"):
|
||||||
|
descriptions[tag["name"]] = tag.get("description", "")
|
||||||
|
return [{"name": name,
|
||||||
|
"description": descriptions.get(name, ""),
|
||||||
|
"endpoints": counts[name],
|
||||||
|
"url": f"/catalog/{slug}/tags/{quote(name, safe='')}"}
|
||||||
|
for name in order]
|
||||||
|
|
||||||
|
|
||||||
|
def _group_by_prefix(ops: list[dict], prefix: str) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Bucket operations by the next path segment after `prefix`, compressing
|
||||||
|
single-child chains (/api + /v1 -> /api/v1). Returns (groups, operations
|
||||||
|
that sit exactly at the prefix)."""
|
||||||
|
at_prefix: list[dict] = []
|
||||||
|
buckets: dict[str, list[dict]] = {}
|
||||||
|
base = prefix.rstrip("/")
|
||||||
|
for op in ops:
|
||||||
|
rest = op["path"][len(base):].lstrip("/")
|
||||||
|
if not rest:
|
||||||
|
at_prefix.append(op)
|
||||||
|
continue
|
||||||
|
buckets.setdefault(rest.split("/")[0], []).append(op)
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
for segment, items in sorted(buckets.items()):
|
||||||
|
group_prefix = f"{base}/{segment}"
|
||||||
|
while True: # chain compression
|
||||||
|
rests = {op["path"][len(group_prefix):].lstrip("/").split("/")[0]
|
||||||
|
for op in items}
|
||||||
|
rests.discard("")
|
||||||
|
if len(rests) == 1 and all(len(op["path"]) > len(group_prefix) for op in items):
|
||||||
|
group_prefix = f"{group_prefix}/{rests.pop()}"
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
groups.append({"prefix": group_prefix, "endpoints": len(items)})
|
||||||
|
return groups, at_prefix
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_entry(op: dict, slug: str) -> dict:
|
||||||
|
detail = urlencode({"method": op["method"], "path": op["path"]})
|
||||||
|
return {"method": op["method"], "path": op["path"], "summary": op["summary"],
|
||||||
|
**({"operationId": op["operationId"]} if op["operationId"] else {}),
|
||||||
|
"url": f"/catalog/{slug}/operation?{detail}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_refs(node, spec: dict, depth: int = 0, seen: frozenset = frozenset()):
|
||||||
|
"""Inline every local $ref so a consumer never has to chase references.
|
||||||
|
Cycles and excessive depth degrade to a named placeholder."""
|
||||||
|
if depth > MAX_REF_DEPTH:
|
||||||
|
return {"$truncated": True}
|
||||||
|
if isinstance(node, dict):
|
||||||
|
ref = node.get("$ref")
|
||||||
|
if isinstance(ref, str) and ref.startswith("#/"):
|
||||||
|
if ref in seen:
|
||||||
|
return {"$circular": ref.rsplit("/", 1)[-1]}
|
||||||
|
target = spec
|
||||||
|
for part in ref[2:].split("/"):
|
||||||
|
target = target.get(part) if isinstance(target, dict) else None
|
||||||
|
if target is None:
|
||||||
|
return node # dangling ref: leave as-is
|
||||||
|
resolved = _resolve_refs(target, spec, depth + 1, seen | {ref})
|
||||||
|
if isinstance(resolved, dict):
|
||||||
|
extras = {k: v for k, v in node.items() if k != "$ref"}
|
||||||
|
return {**resolved, **extras}
|
||||||
|
return resolved
|
||||||
|
return {k: _resolve_refs(v, spec, depth + 1, seen) for k, v in node.items()}
|
||||||
|
if isinstance(node, list):
|
||||||
|
return [_resolve_refs(item, spec, depth + 1, seen) for item in node]
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _find_service(db: Session, api_key: ApiKey, slug: str):
|
||||||
|
grouped = _grants_by_service(api_key)
|
||||||
|
for service_id, grants in grouped.items():
|
||||||
|
service = grants[0].service
|
||||||
|
if service.slug == slug:
|
||||||
|
return service, grants
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- level 1: available APIs ----------
|
||||||
|
|
||||||
|
@router.get("/catalog", include_in_schema=False)
|
||||||
|
def catalog_root(request: Request, db: Session = Depends(get_db)):
|
||||||
|
api_key = resolve_api_key(request, db)
|
||||||
|
if isinstance(api_key, JSONResponse):
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
apis = []
|
||||||
|
for grants in sorted(_grants_by_service(api_key).values(),
|
||||||
|
key=lambda g: g[0].service.name):
|
||||||
|
service = grants[0].service
|
||||||
|
ops, spec = _granted_operations(db, service, grants)
|
||||||
|
if not ops:
|
||||||
|
continue
|
||||||
|
tags = _tag_summaries(ops, spec, service.slug)
|
||||||
|
apis.append({**_service_summary(service, spec),
|
||||||
|
"tags": len(tags), "endpoints": len(ops),
|
||||||
|
"url": f"/catalog/{service.slug}"})
|
||||||
|
return _respond(request, {
|
||||||
|
"key": api_key.name,
|
||||||
|
"apis": apis,
|
||||||
|
"hint": "GET the api url for its tags, then a tag url for its endpoints, "
|
||||||
|
"then an endpoint url for the full schema. Call endpoints through "
|
||||||
|
"this gateway with the X-API-Key header.",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- level 2: one API's tags ----------
|
||||||
|
|
||||||
|
@router.get("/catalog/{slug}", include_in_schema=False)
|
||||||
|
def catalog_service(slug: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
api_key = resolve_api_key(request, db)
|
||||||
|
if isinstance(api_key, JSONResponse):
|
||||||
|
return api_key
|
||||||
|
service, grants = _find_service(db, api_key, slug)
|
||||||
|
if service is None:
|
||||||
|
return _error(404, "unknown_api",
|
||||||
|
f"No API '{slug}' is available to this key. GET /catalog for the list.")
|
||||||
|
|
||||||
|
ops, spec = _granted_operations(db, service, grants)
|
||||||
|
return _respond(request, {
|
||||||
|
**_service_summary(service, spec),
|
||||||
|
"endpoints": len(ops),
|
||||||
|
"tags": _tag_summaries(ops, spec, slug),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- level 3: one tag's endpoints ----------
|
||||||
|
|
||||||
|
@router.get("/catalog/{slug}/tags/{tag}", include_in_schema=False)
|
||||||
|
def catalog_tag(slug: str, tag: str, request: Request,
|
||||||
|
prefix: str | None = None, db: Session = Depends(get_db)):
|
||||||
|
api_key = resolve_api_key(request, db)
|
||||||
|
if isinstance(api_key, JSONResponse):
|
||||||
|
return api_key
|
||||||
|
service, grants = _find_service(db, api_key, slug)
|
||||||
|
if service is None:
|
||||||
|
return _error(404, "unknown_api",
|
||||||
|
f"No API '{slug}' is available to this key. GET /catalog for the list.")
|
||||||
|
|
||||||
|
ops, _ = _granted_operations(db, service, grants)
|
||||||
|
ops = [op for op in ops if tag in op["tags"]]
|
||||||
|
if not ops:
|
||||||
|
return _error(404, "unknown_tag",
|
||||||
|
f"No tag '{tag}' on '{slug}'. GET /catalog/{slug} for its tags.")
|
||||||
|
if prefix:
|
||||||
|
ops = [op for op in ops if op["path"].startswith(prefix)]
|
||||||
|
|
||||||
|
payload = {"api": slug, "tag": tag, "total": len(ops)}
|
||||||
|
if prefix:
|
||||||
|
payload["prefix"] = prefix
|
||||||
|
if len(ops) > MAX_LISTING:
|
||||||
|
groups, at_prefix = _group_by_prefix(ops, prefix or f"/{slug}")
|
||||||
|
# A single group would force a pointless extra round trip — split it.
|
||||||
|
while len(groups) == 1 and not at_prefix:
|
||||||
|
groups, at_prefix = _group_by_prefix(ops, groups[0]["prefix"])
|
||||||
|
payload["endpoints"] = [_endpoint_entry(op, slug) for op in at_prefix]
|
||||||
|
payload["groups"] = [{**g, "url": f"/catalog/{slug}/tags/{quote(tag, safe='')}"
|
||||||
|
f"?{urlencode({'prefix': g['prefix']})}"}
|
||||||
|
for g in groups]
|
||||||
|
payload["hint"] = (f"{len(ops)} endpoints — too many to list. "
|
||||||
|
"GET a group url to drill into that path prefix.")
|
||||||
|
else:
|
||||||
|
payload["endpoints"] = [_endpoint_entry(op, slug) for op in ops]
|
||||||
|
return _respond(request, payload)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- level 4: one operation, full detail ----------
|
||||||
|
|
||||||
|
@router.get("/catalog/{slug}/operation", include_in_schema=False)
|
||||||
|
def catalog_operation(slug: str, method: str, path: str, request: Request,
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
api_key = resolve_api_key(request, db)
|
||||||
|
if isinstance(api_key, JSONResponse):
|
||||||
|
return api_key
|
||||||
|
service, grants = _find_service(db, api_key, slug)
|
||||||
|
if service is None:
|
||||||
|
return _error(404, "unknown_api",
|
||||||
|
f"No API '{slug}' is available to this key. GET /catalog for the list.")
|
||||||
|
|
||||||
|
# Accept the gateway form (/slug/...) as listed, or the upstream path.
|
||||||
|
upstream_path = path[len(f"/{slug}"):] if path.startswith(f"/{slug}/") else path
|
||||||
|
method = method.upper()
|
||||||
|
|
||||||
|
spec = discovery.get_spec(db, service)
|
||||||
|
op = None
|
||||||
|
if spec:
|
||||||
|
operations = spec.get("paths", {}).get(upstream_path)
|
||||||
|
if isinstance(operations, dict):
|
||||||
|
candidate = operations.get(method.lower())
|
||||||
|
if isinstance(candidate, dict):
|
||||||
|
op = candidate
|
||||||
|
|
||||||
|
if not any(_grant_matches(g, method, upstream_path) for g in grants):
|
||||||
|
return _error(404, "unknown_operation",
|
||||||
|
f"{method} {path} is not available to this key on '{slug}'.")
|
||||||
|
|
||||||
|
gateway_path = f"/{slug}{upstream_path}"
|
||||||
|
detail = {
|
||||||
|
"method": method,
|
||||||
|
"path": gateway_path,
|
||||||
|
"api": slug,
|
||||||
|
"auth": "Send the API key in the X-API-Key header.",
|
||||||
|
}
|
||||||
|
if op:
|
||||||
|
for field in ("operationId", "summary", "description", "tags",
|
||||||
|
"parameters", "requestBody", "responses", "deprecated"):
|
||||||
|
if field in op:
|
||||||
|
detail[field] = _resolve_refs(op[field], spec)
|
||||||
|
# path-level parameters apply to every operation of the path
|
||||||
|
shared = spec["paths"][upstream_path].get("parameters")
|
||||||
|
if shared:
|
||||||
|
detail.setdefault("parameters", [])
|
||||||
|
detail["parameters"] = _resolve_refs(shared, spec) + detail["parameters"]
|
||||||
|
else:
|
||||||
|
grant = next(g for g in grants if _grant_matches(g, method, upstream_path))
|
||||||
|
detail["summary"] = grant.description or ""
|
||||||
|
detail["note"] = ("The upstream publishes no OpenAPI document; "
|
||||||
|
"no schema information is available.")
|
||||||
|
return _respond(request, detail)
|
||||||
+2
-1
@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app import config, portal, proxy, retention, security
|
from app import catalog, config, portal, proxy, retention, security
|
||||||
from app.admin import routes as admin_routes
|
from app.admin import routes as admin_routes
|
||||||
from app.admin import stats as admin_stats
|
from app.admin import stats as admin_stats
|
||||||
from app.admin.deps import LoginRequired, login_redirect_handler
|
from app.admin.deps import LoginRequired, login_redirect_handler
|
||||||
@@ -131,6 +131,7 @@ def health():
|
|||||||
app.include_router(admin_routes.router)
|
app.include_router(admin_routes.router)
|
||||||
app.include_router(admin_stats.router)
|
app.include_router(admin_stats.router)
|
||||||
app.include_router(portal.router)
|
app.include_router(portal.router)
|
||||||
|
app.include_router(catalog.router)
|
||||||
app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static")
|
app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static")
|
||||||
# The proxy catch-all (/{slug}/...) must come last so it never shadows
|
# The proxy catch-all (/{slug}/...) must come last so it never shadows
|
||||||
# /admin, /static, /docs or /health.
|
# /admin, /static, /docs or /health.
|
||||||
|
|||||||
+11
-2
@@ -183,8 +183,9 @@ def build_key_spec(db: Session, api_key: ApiKey, gateway_url: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/openapi.json", include_in_schema=False)
|
def resolve_api_key(request: Request, db: Session) -> ApiKey | JSONResponse:
|
||||||
def key_scoped_openapi(request: Request, db: Session = Depends(get_db)):
|
"""Authenticate a consumer-facing request by X-API-Key. Returns the key,
|
||||||
|
or the error response to send back."""
|
||||||
plain_key = request.headers.get(config.API_KEY_HEADER)
|
plain_key = request.headers.get(config.API_KEY_HEADER)
|
||||||
if not plain_key:
|
if not plain_key:
|
||||||
return _error(401, "missing_api_key",
|
return _error(401, "missing_api_key",
|
||||||
@@ -194,6 +195,14 @@ def key_scoped_openapi(request: Request, db: Session = Depends(get_db)):
|
|||||||
.one_or_none())
|
.one_or_none())
|
||||||
if api_key is None or not api_key.is_active or not api_key.user.is_active:
|
if api_key is None or not api_key.is_active or not api_key.user.is_active:
|
||||||
return _error(403, "invalid_api_key", "API key is unknown or has been revoked.")
|
return _error(403, "invalid_api_key", "API key is unknown or has been revoked.")
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/openapi.json", include_in_schema=False)
|
||||||
|
def key_scoped_openapi(request: Request, db: Session = Depends(get_db)):
|
||||||
|
api_key = resolve_api_key(request, db)
|
||||||
|
if isinstance(api_key, JSONResponse):
|
||||||
|
return api_key
|
||||||
|
|
||||||
spec = build_key_spec(db, api_key, str(request.base_url).rstrip("/"))
|
spec = build_key_spec(db, api_key, str(request.base_url).rstrip("/"))
|
||||||
return JSONResponse(spec, headers={"Cache-Control": "no-store"})
|
return JSONResponse(spec, headers={"Cache-Control": "no-store"})
|
||||||
|
|||||||
Reference in New Issue
Block a user