From cef42d8154221761953dc46b86318d8c73418e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Tue, 31 Mar 2026 10:34:04 +0200 Subject: [PATCH] feat(api): warn on unknown request parameters via X-Ignored-Params header (#802) Add middleware that detects unknown query params and JSON body fields, logs a server-side warning, and returns an X-Ignored-Params response header listing the ignored parameters. This surfaces silent parameter ignoring (e.g. tag=source:slack on /memories/list) without breaking forward compatibility between client and server versions. Closes #792 --- hindsight-api-slim/hindsight_api/api/http.py | 71 ++++++++ .../tests/test_http_api_integration.py | 45 +++++ .../tests/test_unknown_params.py | 162 ++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 hindsight-api-slim/tests/test_unknown_params.py diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index 24080396..89681012 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -2153,6 +2153,77 @@ def create_app( app.openapi = _patched_openapi # type: ignore[assignment] + # Add unknown parameters detection middleware + @app.middleware("http") + async def unknown_params_middleware(request, call_next): + """Detect unknown query params and body fields, log warning and set response header.""" + import inspect + + from starlette.routing import Match + + ignored_params: list[str] = [] + + # --- Query parameters --- + if request.query_params: + for route in app.routes: + match, _ = route.matches(request.scope) + if match == Match.FULL: + endpoint = getattr(route, "endpoint", None) + if endpoint: + sig = inspect.signature(endpoint) + declared = set(sig.parameters.keys()) + path_params = set(getattr(route, "param_convertors", {}).keys()) | set( + request.path_params.keys() + ) + known_query = declared - path_params + for name in request.query_params: + if name not in known_query and name not in path_params: + ignored_params.append(name) + break + + # --- Body fields --- + body_ignored: list[str] = [] + content_type = request.headers.get("content-type", "") + if request.method in ("POST", "PUT", "PATCH") and "application/json" in content_type: + try: + body_bytes = await request.body() + if body_bytes: + body_json = json.loads(body_bytes) + if isinstance(body_json, dict): + for route in app.routes: + match, _ = route.matches(request.scope) + if match == Match.FULL: + endpoint = getattr(route, "endpoint", None) + if endpoint: + sig = inspect.signature(endpoint) + for param in sig.parameters.values(): + ann = param.annotation + if isinstance(ann, type) and issubclass(ann, BaseModel): + known_fields = set(ann.model_fields.keys()) + for key in body_json: + if key not in known_fields: + body_ignored.append(key) + break + break + except (json.JSONDecodeError, UnicodeDecodeError): + pass + + all_ignored = ignored_params + body_ignored + + response = await call_next(request) + + if all_ignored: + ignored_str = ", ".join(all_ignored) + logger.warning( + "Unknown parameters ignored: [%s] for %s %s", + ignored_str, + request.method, + request.url.path, + ) + response.headers["X-Ignored-Params"] = ignored_str + + return response + # Add HTTP metrics middleware @app.middleware("http") async def http_metrics_middleware(request, call_next): diff --git a/hindsight-api-slim/tests/test_http_api_integration.py b/hindsight-api-slim/tests/test_http_api_integration.py index 0a34ea13..edf261d2 100644 --- a/hindsight-api-slim/tests/test_http_api_integration.py +++ b/hindsight-api-slim/tests/test_http_api_integration.py @@ -1271,3 +1271,48 @@ async def test_http_recall_preserves_metadata(api_client, test_bank_id): assert fact["metadata"]["source"] == "slack" assert fact["metadata"]["channel"] == "engineering" assert fact["metadata"]["importance"] == "high" + + +@pytest.mark.asyncio +async def test_unknown_params_not_rejected(api_client): + """Unknown query params and body fields should not cause a rejection (no 400). + + The server should return 200 with an X-Ignored-Params header listing the + unknown parameters instead of rejecting the request. This ensures forward + compatibility when a newer client talks to an older server. + """ + test_bank_id = f"unknown_params_test_{datetime.now().timestamp()}" + + # Ensure bank exists + await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") + + # Unknown query params on GET endpoint + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/memories/list", + params={"limit": 1, "tag": "source:slack", "created_after": "2026-01-01"}, + ) + assert response.status_code == 200 + assert "X-Ignored-Params" in response.headers + ignored = response.headers["X-Ignored-Params"] + assert "tag" in ignored + assert "created_after" in ignored + + # Unknown body fields on POST endpoint + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories", + json={ + "items": [{"content": "test memory", "context": "test"}], + "unknown_future_field": True, + }, + ) + assert response.status_code == 200 + assert "X-Ignored-Params" in response.headers + assert "unknown_future_field" in response.headers["X-Ignored-Params"] + + # Known params only — no header + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/memories/list", + params={"limit": 1, "type": "world"}, + ) + assert response.status_code == 200 + assert "X-Ignored-Params" not in response.headers diff --git a/hindsight-api-slim/tests/test_unknown_params.py b/hindsight-api-slim/tests/test_unknown_params.py new file mode 100644 index 00000000..737ad91b --- /dev/null +++ b/hindsight-api-slim/tests/test_unknown_params.py @@ -0,0 +1,162 @@ +"""Tests for unknown parameter detection middleware (X-Ignored-Params header).""" + +import pytest +from fastapi import FastAPI, Query +from fastapi.testclient import TestClient +from pydantic import BaseModel + + +def _make_test_app() -> FastAPI: + """Create a minimal FastAPI app with the unknown params middleware.""" + import json + import logging + + app = FastAPI() + logger = logging.getLogger(__name__) + + @app.middleware("http") + async def unknown_params_middleware(request, call_next): + from starlette.routing import Match + + ignored_params: list[str] = [] + + if request.query_params: + for route in app.routes: + match, _ = route.matches(request.scope) + if match == Match.FULL: + endpoint = getattr(route, "endpoint", None) + if endpoint: + import inspect + + sig = inspect.signature(endpoint) + declared = set(sig.parameters.keys()) + path_params = set(getattr(route, "param_convertors", {}).keys()) | set( + request.path_params.keys() + ) + known_query = declared - path_params + for name in request.query_params: + if name not in known_query and name not in path_params: + ignored_params.append(name) + break + + body_ignored: list[str] = [] + content_type = request.headers.get("content-type", "") + if request.method in ("POST", "PUT", "PATCH") and "application/json" in content_type: + try: + body_bytes = await request.body() + if body_bytes: + body_json = json.loads(body_bytes) + if isinstance(body_json, dict): + for route in app.routes: + match, _ = route.matches(request.scope) + if match == Match.FULL: + endpoint = getattr(route, "endpoint", None) + if endpoint: + import inspect + + sig = inspect.signature(endpoint) + for param in sig.parameters.values(): + ann = param.annotation + if isinstance(ann, type) and issubclass(ann, BaseModel): + known_fields = set(ann.model_fields.keys()) + for key in body_json: + if key not in known_fields: + body_ignored.append(key) + break + break + except (json.JSONDecodeError, UnicodeDecodeError): + pass + + all_ignored = ignored_params + body_ignored + response = await call_next(request) + + if all_ignored: + ignored_str = ", ".join(all_ignored) + logger.warning( + "Unknown parameters ignored: [%s] for %s %s", + ignored_str, + request.method, + request.url.path, + ) + response.headers["X-Ignored-Params"] = ignored_str + + return response + + class ItemRequest(BaseModel): + name: str + value: int = 0 + + @app.get("/items") + async def list_items(limit: int = 10, offset: int = 0): + return {"items": [], "limit": limit, "offset": offset} + + @app.get("/items/{item_id}") + async def get_item(item_id: str, details: bool = False): + return {"id": item_id, "details": details} + + @app.post("/items") + async def create_item(request: ItemRequest): + return {"name": request.name, "value": request.value} + + return app + + +@pytest.fixture +def client(): + return TestClient(_make_test_app()) + + +class TestUnknownQueryParams: + def test_known_params_no_header(self, client): + resp = client.get("/items", params={"limit": 5, "offset": 0}) + assert resp.status_code == 200 + assert "X-Ignored-Params" not in resp.headers + + def test_unknown_query_param_sets_header(self, client): + resp = client.get("/items", params={"limit": 5, "tag": "foo"}) + assert resp.status_code == 200 + assert "X-Ignored-Params" in resp.headers + assert "tag" in resp.headers["X-Ignored-Params"] + + def test_multiple_unknown_query_params(self, client): + resp = client.get("/items", params={"limit": 5, "tag": "foo", "created_after": "2024-01-01"}) + assert resp.status_code == 200 + ignored = resp.headers["X-Ignored-Params"] + assert "tag" in ignored + assert "created_after" in ignored + + def test_path_params_not_flagged(self, client): + resp = client.get("/items/abc123", params={"details": "true"}) + assert resp.status_code == 200 + assert "X-Ignored-Params" not in resp.headers + + def test_unknown_with_path_param(self, client): + resp = client.get("/items/abc123", params={"details": "true", "unknown": "x"}) + assert resp.status_code == 200 + assert "X-Ignored-Params" in resp.headers + assert "unknown" in resp.headers["X-Ignored-Params"] + + def test_no_query_params_no_header(self, client): + resp = client.get("/items") + assert resp.status_code == 200 + assert "X-Ignored-Params" not in resp.headers + + +class TestUnknownBodyFields: + def test_known_body_fields_no_header(self, client): + resp = client.post("/items", json={"name": "test", "value": 42}) + assert resp.status_code == 200 + assert "X-Ignored-Params" not in resp.headers + + def test_unknown_body_field_sets_header(self, client): + resp = client.post("/items", json={"name": "test", "value": 42, "extra_field": "surprise"}) + assert resp.status_code == 200 + assert "X-Ignored-Params" in resp.headers + assert "extra_field" in resp.headers["X-Ignored-Params"] + + def test_multiple_unknown_body_fields(self, client): + resp = client.post("/items", json={"name": "test", "foo": 1, "bar": 2}) + assert resp.status_code == 200 + ignored = resp.headers["X-Ignored-Params"] + assert "foo" in ignored + assert "bar" in ignored