From 077ec81e54a65b6b314caa9444c8f83d07d1d405 Mon Sep 17 00:00:00 2001 From: "hotdata-automation[bot]" <267177015+hotdata-automation[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:00:59 +0000 Subject: [PATCH 1/2] chore(api): stop injecting SessionId into merged spec --- CHANGELOG.md | 1 + docs/QueryApi.md | 9 +-------- docs/ResultsApi.md | 18 ++---------------- hotdata/api/query_api.py | 1 - hotdata/api/results_api.py | 2 -- hotdata/configuration.py | 10 ---------- 6 files changed, 4 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e7c1f..e2427e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- chore(api): stop injecting SessionId into merged spec - feat(databases): add search parameter to list endpoint - chore(databases): make pagination fields nullable diff --git a/docs/QueryApi.md b/docs/QueryApi.md index 2a1607b..f769189 100644 --- a/docs/QueryApi.md +++ b/docs/QueryApi.md @@ -19,7 +19,6 @@ Set `async: true` to execute asynchronously — returns a query run ID for polli ### Example * Api Key Authentication (WorkspaceId): -* Api Key Authentication (SessionId): * Bearer Authentication (BearerAuth): ```python @@ -46,12 +45,6 @@ configuration.api_key['WorkspaceId'] = os.environ["API_KEY"] # Uncomment below to setup prefix (e.g. Bearer) for API key, if needed # configuration.api_key_prefix['WorkspaceId'] = 'Bearer' -# Configure API key authorization: SessionId -configuration.api_key['SessionId'] = os.environ["API_KEY"] - -# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed -# configuration.api_key_prefix['SessionId'] = 'Bearer' - # Configure Bearer authorization: BearerAuth configuration = hotdata.Configuration( access_token = os.environ["BEARER_TOKEN"] @@ -89,7 +82,7 @@ Name | Type | Description | Notes ### Authorization -[WorkspaceId](../README.md#WorkspaceId), [SessionId](../README.md#SessionId), [BearerAuth](../README.md#BearerAuth) +[WorkspaceId](../README.md#WorkspaceId), [BearerAuth](../README.md#BearerAuth) ### HTTP request headers diff --git a/docs/ResultsApi.md b/docs/ResultsApi.md index b4c933d..bcc28b1 100644 --- a/docs/ResultsApi.md +++ b/docs/ResultsApi.md @@ -35,7 +35,6 @@ IEEE special floats (`±Inf`, `NaN`) have no canonical JSON representation. For ### Example * Api Key Authentication (WorkspaceId): -* Api Key Authentication (SessionId): * Bearer Authentication (BearerAuth): ```python @@ -62,12 +61,6 @@ configuration.api_key['WorkspaceId'] = os.environ["API_KEY"] # Uncomment below to setup prefix (e.g. Bearer) for API key, if needed # configuration.api_key_prefix['WorkspaceId'] = 'Bearer' -# Configure API key authorization: SessionId -configuration.api_key['SessionId'] = os.environ["API_KEY"] - -# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed -# configuration.api_key_prefix['SessionId'] = 'Bearer' - # Configure Bearer authorization: BearerAuth configuration = hotdata.Configuration( access_token = os.environ["BEARER_TOKEN"] @@ -111,7 +104,7 @@ Name | Type | Description | Notes ### Authorization -[WorkspaceId](../README.md#WorkspaceId), [SessionId](../README.md#SessionId), [BearerAuth](../README.md#BearerAuth) +[WorkspaceId](../README.md#WorkspaceId), [BearerAuth](../README.md#BearerAuth) ### HTTP request headers @@ -140,7 +133,6 @@ List stored results for the database named by the required X-Database-Id header. ### Example * Api Key Authentication (WorkspaceId): -* Api Key Authentication (SessionId): * Bearer Authentication (BearerAuth): ```python @@ -166,12 +158,6 @@ configuration.api_key['WorkspaceId'] = os.environ["API_KEY"] # Uncomment below to setup prefix (e.g. Bearer) for API key, if needed # configuration.api_key_prefix['WorkspaceId'] = 'Bearer' -# Configure API key authorization: SessionId -configuration.api_key['SessionId'] = os.environ["API_KEY"] - -# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed -# configuration.api_key_prefix['SessionId'] = 'Bearer' - # Configure Bearer authorization: BearerAuth configuration = hotdata.Configuration( access_token = os.environ["BEARER_TOKEN"] @@ -211,7 +197,7 @@ Name | Type | Description | Notes ### Authorization -[WorkspaceId](../README.md#WorkspaceId), [SessionId](../README.md#SessionId), [BearerAuth](../README.md#BearerAuth) +[WorkspaceId](../README.md#WorkspaceId), [BearerAuth](../README.md#BearerAuth) ### HTTP request headers diff --git a/hotdata/api/query_api.py b/hotdata/api/query_api.py index f23589c..0719e0e 100644 --- a/hotdata/api/query_api.py +++ b/hotdata/api/query_api.py @@ -327,7 +327,6 @@ def _query_serialize( # authentication setting _auth_settings: List[str] = [ 'WorkspaceId', - 'SessionId', 'BearerAuth' ] diff --git a/hotdata/api/results_api.py b/hotdata/api/results_api.py index da1a78e..18e873d 100644 --- a/hotdata/api/results_api.py +++ b/hotdata/api/results_api.py @@ -364,7 +364,6 @@ def _get_result_serialize( # authentication setting _auth_settings: List[str] = [ 'WorkspaceId', - 'SessionId', 'BearerAuth' ] @@ -667,7 +666,6 @@ def _list_results_serialize( # authentication setting _auth_settings: List[str] = [ 'WorkspaceId', - 'SessionId', 'BearerAuth' ] diff --git a/hotdata/configuration.py b/hotdata/configuration.py index eb36bd8..af2f6fb 100644 --- a/hotdata/configuration.py +++ b/hotdata/configuration.py @@ -115,7 +115,6 @@ { "BearerAuth": BearerAuthSetting, "WorkspaceId": APIKeyAuthSetting, - "SessionId": APIKeyAuthSetting, }, total=False, ) @@ -599,15 +598,6 @@ def auth_settings(self)-> AuthSettings: 'WorkspaceId', ), } - if 'SessionId' in self.api_keys: - auth['SessionId'] = { - 'type': 'api_key', - 'in': 'header', - 'key': 'X-Session-Id', - 'value': self.get_api_key_with_prefix( - 'SessionId', - ), - } return auth def to_debug_report(self) -> str: From a9f22f67f13582dc97122596c922c0e9dfad8166 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Wed, 29 Jul 2026 18:49:12 -0700 Subject: [PATCH 2/2] fix(config): drop dead session_id from the template surface --- .openapi-generator-templates/README.md | 20 ++- .../configuration.mustache | 31 +--- CHANGELOG.md | 9 +- hotdata/configuration.py | 31 +--- tests/test_config_auth_surface.py | 138 ++++++++++++++++++ 5 files changed, 166 insertions(+), 63 deletions(-) create mode 100644 tests/test_config_auth_surface.py diff --git a/.openapi-generator-templates/README.md b/.openapi-generator-templates/README.md index 2cdc750..6c08d42 100644 --- a/.openapi-generator-templates/README.md +++ b/.openapi-generator-templates/README.md @@ -8,13 +8,12 @@ Two Hotdata-specific DX tweaks applied to the Python `configuration.mustache`: with `api_key`. Internal `auth_settings()` is patched to read `self.api_key`. -2. **`workspace_id` and `session_id` are first-class kwargs and - attributes.** Stock openapi-generator exposes apiKey security schemes - only via an opaque `api_key: Dict[str, str]` dict keyed by scheme name. - That's a footgun — callers have to know the generator's - `apiKey`-security machinery to set a workspace scope. The template - adds typed `workspace_id` / `session_id` kwargs (and properties) that - store into a renamed `self.api_keys` dict. +2. **`workspace_id` is a first-class kwarg and attribute.** Stock + openapi-generator exposes apiKey security schemes only via an opaque + `api_key: Dict[str, str]` dict keyed by scheme name. That's a footgun + — callers have to know the generator's `apiKey`-security machinery to + set a workspace scope. The template adds a typed `workspace_id` kwarg + (and property) that stores into a renamed `self.api_keys` dict. Net caller DX: @@ -22,10 +21,15 @@ Net caller DX: cfg = hotdata.Configuration( api_key="sk_live_...", workspace_id="ws_abc", - session_id="sb_xyz", ) ``` +Because these kwargs are hand-added, they don't disappear when a security +scheme leaves the spec — `auth_settings()` and the per-operation +`_auth_settings` lists do, leaving the kwarg behind as a silent no-op. When +a scheme is dropped upstream, drop its kwarg/property here in the same +change. `tests/test_config_auth_surface.py` fails if the two drift apart. + ## Drift tripwire The regenerate workflow runs `import hotdata` and relies on diff --git a/.openapi-generator-templates/configuration.mustache b/.openapi-generator-templates/configuration.mustache index 8bf64be..346b265 100644 --- a/.openapi-generator-templates/configuration.mustache +++ b/.openapi-generator-templates/configuration.mustache @@ -163,11 +163,8 @@ class Configuration: :param api_key: Hotdata API key, sent as `Authorization: Bearer `. :param workspace_id: Public id of the target workspace, sent as `X-Workspace-Id`. - :param session_id: Public id of an active sandbox, sent as `X-Session-Id`. - Scopes reads and writes to the sandbox for per-run isolation. Obtain - one by calling `SandboxesApi.create_sandbox`. :param api_keys: Escape hatch for raw apiKey-scheme values, keyed by - security scheme name. Prefer `workspace_id` / `session_id`. + security scheme name. Prefer `workspace_id`. :param api_key_prefix: Dict to store API prefix (e.g. Bearer). The dict key is the name of the security scheme in the OAS specification. The dict value is an API key prefix when generating the auth data. @@ -204,12 +201,11 @@ class Configuration: :Example: {{#hasApiKeyMethods}} - Workspace / sandbox scoping example: + Workspace scoping example: conf = {{packageName}}.Configuration( api_key='sk_live_...', workspace_id='ws_abc', - session_id='sb_xyz', ) {{/hasApiKeyMethods}} {{#hasHttpBasicMethods}} @@ -281,7 +277,6 @@ conf = {{packageName}}.Configuration( host: Optional[str]=None, api_key: Optional[str]=None, workspace_id: Optional[str]=None, - session_id: Optional[str]=None, api_keys: Optional[Dict[str, str]]=None, api_key_prefix: Optional[Dict[str, str]]=None, username: Optional[str]=None, @@ -328,13 +323,11 @@ conf = {{packageName}}.Configuration( from {{packageName}}._auth import _TokenManager self._token_manager = _TokenManager(api_key, self) if api_key is not None else None """Hotdata API key, sent as `Authorization: Bearer `.""" - # apiKey-security values (X-Workspace-Id, X-Session-Id), keyed by - # scheme name. Read by the generated `auth_settings()` below. + # apiKey-security values (X-Workspace-Id), keyed by scheme name. Read by + # the generated `auth_settings()` below. self.api_keys = dict(api_keys) if api_keys else {} if workspace_id is not None: self.api_keys["WorkspaceId"] = workspace_id - if session_id is not None: - self.api_keys["SessionId"] = session_id self.api_key_prefix = {} if api_key_prefix: self.api_key_prefix = api_key_prefix @@ -659,22 +652,6 @@ conf = {{packageName}}.Configuration( else: self.api_keys["WorkspaceId"] = value - @property - def session_id(self) -> Optional[str]: - """Public id of the active sandbox (sent as `X-Session-Id`). - - Scopes reads and writes to the sandbox for per-run isolation. - Obtain a sandbox id by calling `SandboxesApi.create_sandbox`. - """ - return self.api_keys.get("SessionId") - - @session_id.setter - def session_id(self, value: Optional[str]) -> None: - if value is None: - self.api_keys.pop("SessionId", None) - else: - self.api_keys["SessionId"] = value - def get_basic_auth_token(self) -> Optional[str]: """Gets HTTP basic authentication header (string). diff --git a/CHANGELOG.md b/CHANGELOG.md index e2427e5..73cc94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- chore(api): stop injecting SessionId into merged spec - feat(databases): add search parameter to list endpoint - chore(databases): make pagination fields nullable ### Removed +- **Breaking:** sandbox session scoping is gone. The `SessionId` security scheme + no longer exists in the Hotdata OpenAPI spec, so `X-Session-Id` is no longer + sent on `/v1/query` or the results endpoints. `Configuration`'s `session_id` + keyword argument and property are removed along with it — passing + `session_id=` now raises `TypeError` instead of silently setting a header + nothing reads. The server stopped enforcing session scoping before this + release, so requests behave the same with or without the value; drop it from + your `Configuration(...)` calls. - The legacy `POST /v1/files` upload endpoints have been removed in favor of the presigned upload flow. This drops `hotdata.UploadsApi.upload_stream`, the raw `upload_file(body=...)` / `list_uploads` operations, and the `UploadResponse`, diff --git a/hotdata/configuration.py b/hotdata/configuration.py index af2f6fb..eadfd4d 100644 --- a/hotdata/configuration.py +++ b/hotdata/configuration.py @@ -142,11 +142,8 @@ class Configuration: :param api_key: Hotdata API key, sent as `Authorization: Bearer `. :param workspace_id: Public id of the target workspace, sent as `X-Workspace-Id`. - :param session_id: Public id of an active sandbox, sent as `X-Session-Id`. - Scopes reads and writes to the sandbox for per-run isolation. Obtain - one by calling `SandboxesApi.create_sandbox`. :param api_keys: Escape hatch for raw apiKey-scheme values, keyed by - security scheme name. Prefer `workspace_id` / `session_id`. + security scheme name. Prefer `workspace_id`. :param api_key_prefix: Dict to store API prefix (e.g. Bearer). The dict key is the name of the security scheme in the OAS specification. The dict value is an API key prefix when generating the auth data. @@ -172,12 +169,11 @@ class Configuration: :Example: - Workspace / sandbox scoping example: + Workspace scoping example: conf = hotdata.Configuration( api_key='sk_live_...', workspace_id='ws_abc', - session_id='sb_xyz', ) """ @@ -188,7 +184,6 @@ def __init__( host: Optional[str]=None, api_key: Optional[str]=None, workspace_id: Optional[str]=None, - session_id: Optional[str]=None, api_keys: Optional[Dict[str, str]]=None, api_key_prefix: Optional[Dict[str, str]]=None, username: Optional[str]=None, @@ -232,13 +227,11 @@ def __init__( from hotdata._auth import _TokenManager self._token_manager = _TokenManager(api_key, self) if api_key is not None else None """Hotdata API key, sent as `Authorization: Bearer `.""" - # apiKey-security values (X-Workspace-Id, X-Session-Id), keyed by - # scheme name. Read by the generated `auth_settings()` below. + # apiKey-security values (X-Workspace-Id), keyed by scheme name. Read by + # the generated `auth_settings()` below. self.api_keys = dict(api_keys) if api_keys else {} if workspace_id is not None: self.api_keys["WorkspaceId"] = workspace_id - if session_id is not None: - self.api_keys["SessionId"] = session_id self.api_key_prefix = {} if api_key_prefix: self.api_key_prefix = api_key_prefix @@ -540,22 +533,6 @@ def workspace_id(self, value: Optional[str]) -> None: else: self.api_keys["WorkspaceId"] = value - @property - def session_id(self) -> Optional[str]: - """Public id of the active sandbox (sent as `X-Session-Id`). - - Scopes reads and writes to the sandbox for per-run isolation. - Obtain a sandbox id by calling `SandboxesApi.create_sandbox`. - """ - return self.api_keys.get("SessionId") - - @session_id.setter - def session_id(self, value: Optional[str]) -> None: - if value is None: - self.api_keys.pop("SessionId", None) - else: - self.api_keys["SessionId"] = value - def get_basic_auth_token(self) -> Optional[str]: """Gets HTTP basic authentication header (string). diff --git a/tests/test_config_auth_surface.py b/tests/test_config_auth_surface.py new file mode 100644 index 0000000..6dfb354 --- /dev/null +++ b/tests/test_config_auth_surface.py @@ -0,0 +1,138 @@ +"""Guards that `Configuration`'s auth surface matches what goes on the wire. + +`Configuration` is generated from `.openapi-generator-templates/configuration.mustache`, +which hand-adds typed kwargs/properties (`workspace_id`, ...) on top of the +generator's `api_keys` dict. `auth_settings()` and each operation's +`_auth_settings` list, by contrast, come straight from the spec. When a security +scheme leaves the spec but its convenience kwarg stays in the template, callers +keep setting a value that is silently never sent -- which is how `session_id` +(`X-Session-Id`) went dead: the server stopped enforcing it, the spec dropped the +`SessionId` scheme, and the kwarg would have lingered as a no-op that quietly +unscoped every request. + +The tests below pin that invariant generally, so the next scheme removal fails +here instead of shipping a silent no-op: + +* every typed apiKey kwarg on `Configuration` reaches `auth_settings()`; +* `session_id` / `X-Session-Id` are gone from the config surface, the template, + and the operations that used to carry them. +""" + +from __future__ import annotations + +import inspect +from pathlib import Path + +import pytest + +from hotdata import ApiClient, Configuration +from hotdata.api.query_api import QueryApi +from hotdata.api.results_api import ResultsApi + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONFIGURATION_TEMPLATE = REPO_ROOT / ".openapi-generator-templates" / "configuration.mustache" + +# A JWT-shaped credential is returned as-is by _TokenManager, so no exchange +# request is ever made from these tests. +FAKE_JWT = "eyJfake.payload.signature" + + +def _config(**kwargs) -> Configuration: + return Configuration(host="https://api.example.test", api_key=FAKE_JWT, **kwargs) + + +def _typed_api_key_kwargs() -> list[str]: + """Kwargs the template adds for apiKey schemes (everything but the generic ones).""" + generic = { + "self", + "host", + "api_key", + "api_keys", + "api_key_prefix", + "username", + "password", + "server_index", + "server_variables", + "server_operation_index", + "server_operation_variables", + "ignore_operation_servers", + "ssl_ca_cert", + "retries", + "ca_cert_data", + "cert_file", + "key_file", + "debug", + } + params = inspect.signature(Configuration.__init__).parameters + return [ + name + for name in params + if name not in generic and isinstance(getattr(Configuration, name, None), property) + ] + + +def test_typed_api_key_kwargs_are_discoverable() -> None: + """Keeps the parametrized test below from silently degenerating to zero cases.""" + assert "workspace_id" in _typed_api_key_kwargs() + + +@pytest.mark.parametrize("kwarg", _typed_api_key_kwargs()) +def test_typed_api_key_kwarg_reaches_auth_settings(kwarg: str) -> None: + """A convenience kwarg that never shows up in auth_settings() is a no-op.""" + config = _config(**{kwarg: "value-for-" + kwarg}) + + settings = config.auth_settings() + wire_values = {setting["value"] for setting in settings.values()} + assert "value-for-" + kwarg in wire_values, ( + f"Configuration(...{kwarg}=...) is accepted but auth_settings() never emits it, " + "so the value is silently dropped instead of being sent." + ) + + +def test_configuration_no_longer_exposes_session_id() -> None: + """The SessionId scheme left the spec; the convenience surface must go with it.""" + assert not hasattr(Configuration, "session_id") + assert "session_id" not in inspect.signature(Configuration.__init__).parameters + + with pytest.raises(TypeError): + _config(session_id="s_abcd1234") + + +def test_configuration_template_has_no_session_id() -> None: + """Editing only the generated file would be undone by the next regen.""" + template = CONFIGURATION_TEMPLATE.read_text() + assert "session_id" not in template + assert "SessionId" not in template + assert "X-Session-Id" not in template + + +@pytest.mark.parametrize( + "api_cls, serialize, kwargs", + [ + ( + QueryApi, + "_query_serialize", + {"query_request": {"sql": "select 1"}, "x_database_id": "db_abc"}, + ), + ( + ResultsApi, + "_list_results_serialize", + {"x_database_id": "db_abc", "limit": None, "offset": None}, + ), + ], +) +def test_session_header_is_not_sent(api_cls, serialize: str, kwargs: dict) -> None: + """Even a raw api_keys['SessionId'] escape hatch must not reach the wire.""" + config = _config(workspace_id="ws_abc", api_keys={"SessionId": "s_abcd1234"}) + api = api_cls(ApiClient(config)) + + _method, _url, headers, *_rest = getattr(api, serialize)( + _request_auth=None, + _content_type=None, + _headers=None, + _host_index=0, + **kwargs, + ) + + assert "X-Session-Id" not in headers + assert headers["X-Workspace-Id"] == "ws_abc"