From 7b153a8006bbbfbeb97cd21ceeffb0806214c908 Mon Sep 17 00:00:00 2001 From: Nicholas Date: Mon, 28 Sep 2026 17:47:55 +0200 Subject: [PATCH] feat: configurable API-key header (api_key_header) --- plane/api/base_resource.py | 2 +- plane/api/v2/_kernel/transport.py | 2 +- plane/client/plane_client.py | 2 ++ plane/config.py | 6 +++++ tests/unit/test_api_key_header.py | 44 +++++++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_api_key_header.py diff --git a/plane/api/base_resource.py b/plane/api/base_resource.py index 2f89e1e..b24925e 100644 --- a/plane/api/base_resource.py +++ b/plane/api/base_resource.py @@ -91,7 +91,7 @@ def _build_url(self, endpoint: str) -> str: def _headers(self) -> dict[str, str]: headers: dict[str, str] = {"Content-Type": "application/json"} if self.config.api_key: - headers["X-Api-Key"] = self.config.api_key + headers[self.config.api_key_header] = self.config.api_key if self.config.access_token: headers["Authorization"] = f"Bearer {self.config.access_token}" return headers diff --git a/plane/api/v2/_kernel/transport.py b/plane/api/v2/_kernel/transport.py index 869394a..609cefb 100644 --- a/plane/api/v2/_kernel/transport.py +++ b/plane/api/v2/_kernel/transport.py @@ -53,7 +53,7 @@ def request( def _headers(self) -> dict[str, str]: headers = {"Content-Type": "application/json", "Accept": "application/json"} if self.config.api_key: - headers["X-Api-Key"] = self.config.api_key + headers[self.config.api_key_header] = self.config.api_key if self.config.access_token: headers["Authorization"] = f"Bearer {self.config.access_token}" return headers diff --git a/plane/client/plane_client.py b/plane/client/plane_client.py index ac8b765..459fd85 100644 --- a/plane/client/plane_client.py +++ b/plane/client/plane_client.py @@ -44,6 +44,7 @@ def __init__( base_url: str, api_key: str | None = None, access_token: str | None = None, + api_key_header: str = "X-Api-Key", ) -> None: if not api_key and not access_token: raise ConfigurationError( @@ -58,6 +59,7 @@ def __init__( base_path=base_url, api_key=api_key, access_token=access_token, + api_key_header=api_key_header, ) # api_v2 surface. v1 resources below are unchanged. diff --git a/plane/config.py b/plane/config.py index dd09fe2..21f08af 100644 --- a/plane/config.py +++ b/plane/config.py @@ -23,6 +23,7 @@ def __init__( access_token: str | None = None, timeout: float | tuple[float, float] | None = 30.0, retry: RetryConfig | None = None, + api_key_header: str = "X-Api-Key", ) -> None: if not api_key and not access_token: raise ConfigurationError( @@ -41,3 +42,8 @@ def __init__( self.access_token = access_token self.timeout = timeout self.retry = retry + # Header that carries api_key. Plane reads X-Api-Key; an API gateway in front + # of Plane may expect its own consumer-key header instead (e.g. X-Gravitee-Api-Key). + if not api_key_header or not api_key_header.strip(): + raise ConfigurationError("'api_key_header' must be a non-empty header name") + self.api_key_header = api_key_header.strip() diff --git a/tests/unit/test_api_key_header.py b/tests/unit/test_api_key_header.py new file mode 100644 index 0000000..e35fec1 --- /dev/null +++ b/tests/unit/test_api_key_header.py @@ -0,0 +1,44 @@ +"""Unit tests for the configurable API-key header (no network, no env).""" + +from __future__ import annotations + +import pytest + +from plane.api.base_resource import BaseResource +from plane.api.v2._kernel.transport import V2Transport +from plane.client import PlaneClient +from plane.config import Configuration +from plane.errors.errors import ConfigurationError + + +def test_default_header_is_x_api_key() -> None: + config = Configuration(base_path="https://api.plane.so", api_key="k") + headers = BaseResource(config, "/workspaces/")._headers() + assert headers["X-Api-Key"] == "k" + + +def test_custom_header_replaces_x_api_key_in_v1_and_v2() -> None: + config = Configuration( + base_path="http://gateway/plane-api", api_key="k", api_key_header="X-Gravitee-Api-Key" + ) + for headers in (BaseResource(config, "/workspaces/")._headers(), V2Transport(config)._headers()): + assert headers["X-Gravitee-Api-Key"] == "k" + assert "X-Api-Key" not in headers + + +def test_plane_client_passes_the_header_through() -> None: + client = PlaneClient(base_url="http://gateway", api_key="k", api_key_header="X-Custom-Key") + assert client.config.api_key_header == "X-Custom-Key" + assert client.work_items._headers()["X-Custom-Key"] == "k" + + +def test_blank_header_is_refused() -> None: + with pytest.raises(ConfigurationError): + Configuration(base_path="https://api.plane.so", api_key="k", api_key_header=" ") + + +def test_access_token_ignores_the_header_setting() -> None: + config = Configuration(base_path="https://api.plane.so", access_token="t", api_key_header="X-Other") + headers = BaseResource(config, "/workspaces/")._headers() + assert headers["Authorization"] == "Bearer t" + assert "X-Other" not in headers