From 3402d90a88780057ca07cb5656e7a7f401f032c3 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Thu, 10 Sep 2026 15:23:01 +1000 Subject: [PATCH] fxmacrodata: send the API key as a header, not a query parameter FXMacroData accepts the key three ways and documents X-API-Key as the preferred transport for server-side clients. The provider was appending it to the query string instead, so the key was written verbatim into every proxy, CDN and server access log along the request path, and into any local HTTP debug output. Switch to a per-request header so the behaviour still tracks self.api_key rather than freezing it into the session at construction, and leave headers unset entirely when no key is configured. Covered by three tests: the existing request test now asserts the header and the absence of an api_key parameter, plus one that no parameter value carries the key and one that no auth header is sent without a key. The keyless test clears the environment, since otherwise an ambient FXMACRODATA_API_KEY would be picked up and the assertion would pass for the wrong reason. Co-Authored-By: Claude Opus 5 --- src/ml4t/data/providers/fxmacrodata.py | 9 +++++--- tests/test_fxmacrodata_provider.py | 29 +++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/ml4t/data/providers/fxmacrodata.py b/src/ml4t/data/providers/fxmacrodata.py index 918948a..e5460eb 100644 --- a/src/ml4t/data/providers/fxmacrodata.py +++ b/src/ml4t/data/providers/fxmacrodata.py @@ -593,14 +593,17 @@ def fetch_endpoint( def _request_json(self, path: str, params: dict[str, Any]) -> Any: clean_params = {key: value for key, value in params.items() if value is not None} - if self.api_key: - clean_params["api_key"] = self.api_key + # Send the key as a header rather than a query parameter. FXMacroData + # accepts both, but documents the header as the preferred transport for + # server-side clients: a key in the query string is recorded verbatim by + # proxies, CDNs and server access logs along the whole request path. + headers = {"X-API-Key": self.api_key} if self.api_key else None url = f"{self.base_url}/{path.lstrip('/')}" self._acquire_rate_limit() try: - response = self.session.get(url, params=clean_params) + response = self.session.get(url, params=clean_params, headers=headers) except httpx.RequestError as err: raise NetworkError(provider=self.name, message=f"Request failed: {err}") from err diff --git a/tests/test_fxmacrodata_provider.py b/tests/test_fxmacrodata_provider.py index 3666377..cd0a619 100644 --- a/tests/test_fxmacrodata_provider.py +++ b/tests/test_fxmacrodata_provider.py @@ -117,12 +117,39 @@ def test_fetch_announcements_preserves_key_fields_and_metadata(self, provider): url = get.call_args.args[0] params = get.call_args.kwargs["params"] + headers = get.call_args.kwargs["headers"] assert url == "https://api.fxmacrodata.com/v1/announcements/usd/inflation" - assert params["api_key"] == "test_key" + assert headers["X-API-Key"] == "test_key" + assert "api_key" not in params assert params["start_date"] == "2025-01-01" assert params["limit"] == 1 assert "end_date" not in params + def test_api_key_is_never_sent_as_a_query_parameter(self, provider): + response = _response(payload=ANNOUNCEMENT_PAYLOAD) + + with patch.object(provider.session, "get", return_value=response) as get: + provider.fetch_announcements("USD", "inflation", limit=1) + + params = get.call_args.kwargs["params"] + url = get.call_args.args[0] + assert "test_key" not in url + assert not any(value == "test_key" for value in params.values()) + + def test_no_auth_header_is_sent_without_a_key(self): + # clear=True matches the init tests: without it an ambient + # FXMACRODATA_API_KEY/FXMD_API_KEY in the developer's environment would + # be picked up and the assertion would pass for the wrong reason. + with patch.dict("os.environ", {}, clear=True): + provider = FXMacroDataProvider(rate_limit=(1000, 1.0)) + try: + response = _response(payload=ANNOUNCEMENT_PAYLOAD) + with patch.object(provider.session, "get", return_value=response) as get: + provider.fetch_announcements("USD", "inflation", limit=1) + assert get.call_args.kwargs["headers"] is None + finally: + provider.close() + def test_fetch_catalogue_reshapes_indicator_mapping(self, provider): payload = { "gdp": {