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": {