Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .openapi-generator-templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
Fixes a limitation in openapi-generator's rust/reqwest target: the stock
templates model `Configuration.api_key` as a single `Option<ApiKey>` and
emit the same value for every `apiKey` security scheme on an operation.
For the Hotdata API — which declares distinct `X-Workspace-Id`,
`X-Sandbox-Id`, and `X-Session-Id` schemes — that means a sandbox id
would be sent as the workspace id and vice versa.
For an API that declares more than one `apiKey` header scheme, that means
one scheme's value would be sent under another scheme's header name.

These templates replace that field with `api_keys: HashMap<String, ApiKey>`
keyed by header name, and change the per-operation header-emission code
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Removed

- **Breaking:** session scoping is gone from the API. `ClientBuilder::session_id`,
the `client::SESSION_ID_HEADER` constant, and the `HOTDATA_SESSION_ID`
environment variable are removed, and no request sends the `X-Session-Id`
header any more. Drop the `.session_id(..)` builder call; nothing replaces it.

### Changed

- feat(databases): add search parameter to list endpoint
Expand Down
2 changes: 1 addition & 1 deletion docs/QueryApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Name | Type | Description | Required | 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

Expand Down
4 changes: 2 additions & 2 deletions docs/ResultsApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Name | Type | Description | Required | 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

Expand Down Expand Up @@ -65,7 +65,7 @@ Name | Type | Description | Required | 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

Expand Down
8 changes: 0 additions & 8 deletions src/apis/query_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,6 @@ pub async fn query(
};
req_builder = req_builder.header("X-Workspace-Id", value);
};
if let Some(apikey) = configuration.api_keys.get("X-Session-Id") {
let key = apikey.key.clone();
let value = match apikey.prefix {
Some(ref prefix) => format!("{} {}", prefix, key),
None => key,
};
req_builder = req_builder.header("X-Session-Id", value);
};
if let Some(token) = configuration.resolve_bearer_token().await {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping the X-Session-Id block here (and in results_api::get_result / list_results) leaves the SDK internally inconsistent, because the hand-written mirrors of these same requests still send it:

  • src/query.rs:618 (send_query, the path Client::query actually uses) → sends X-Session-Id
  • src/client.rs:468 (Client::submit_query) → sends X-Session-Id
  • src/arrow.rs:305 (apply_apikey_headers, used by get_result_arrow / stream_result_arrow) → sends X-Session-Id

After this change there is no X-Session-Id emission left anywhere in src/apis, so a single Client::query() call now submits with the session header (send_query) and then polls GET /v1/results/{id} without it (wait_for_result → generated get_result). Same for JSON vs Arrow result fetches: the Arrow path is session-scoped, the generated path is not.

This also silently narrows public API surface: ClientBuilder::session_id (src/client.rs:126) and the HOTDATA_SESSION_ID env var still install the key into configuration.api_keys and their docs still say "Installed as the X-Session-Id header when present" — which is now only true for the hand-written paths. If the backend still scopes results by session, a session-scoped client's get_result / list_results calls are no longer scoped.

Please resolve one way or the other before merge:

  1. If session auth is genuinely gone from the API — finish the removal in the same PR: drop ClientBuilder::session_id, SESSION_ID_HEADER, the HOTDATA_SESSION_ID lookup, the SESSION_ID_HEADER entries in src/query.rs / src/arrow.rs / src/client.rs::submit_query, the now-wrong arrow.rs tests and doc comments that say "mirroring the generated get_result" (src/arrow.rs:301, src/arrow.rs:496, src/query.rs:639), and the X-Session-Id rationale in .openapi-generator-templates/README.md:7. Record it in the changelog as Breaking since session_id() is public API.
  2. If session scoping is still enforced server-side — the spec change is wrong and this regen should not ship.

req_builder = req_builder.bearer_auth(token);
};
Expand Down
16 changes: 0 additions & 16 deletions src/apis/results_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,6 @@ pub async fn get_result(
};
req_builder = req_builder.header("X-Workspace-Id", value);
};
if let Some(apikey) = configuration.api_keys.get("X-Session-Id") {
let key = apikey.key.clone();
let value = match apikey.prefix {
Some(ref prefix) => format!("{} {}", prefix, key),
None => key,
};
req_builder = req_builder.header("X-Session-Id", value);
};
if let Some(token) = configuration.resolve_bearer_token().await {
req_builder = req_builder.bearer_auth(token);
};
Expand Down Expand Up @@ -158,14 +150,6 @@ pub async fn list_results(
};
req_builder = req_builder.header("X-Workspace-Id", value);
};
if let Some(apikey) = configuration.api_keys.get("X-Session-Id") {
let key = apikey.key.clone();
let value = match apikey.prefix {
Some(ref prefix) => format!("{} {}", prefix, key),
None => key,
};
req_builder = req_builder.header("X-Session-Id", value);
};
if let Some(token) = configuration.resolve_bearer_token().await {
req_builder = req_builder.bearer_auth(token);
};
Expand Down
71 changes: 16 additions & 55 deletions src/arrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
//!
//! This module mirrors `hotdata/arrow.py` from the Python SDK. It builds the
//! request exactly like the generated `get_result` (same URL, user-agent,
//! `X-Workspace-Id`/`X-Session-Id` API keys, and transparent JWT-exchanged
//! bearer token via
//! `X-Workspace-Id` API key, and transparent JWT-exchanged bearer token via
//! [`crate::apis::configuration::Configuration::resolve_bearer_token`]), adds
//! `Accept: application/vnd.apache.arrow.stream` plus `?format=arrow`, and
//! decodes the resulting IPC stream with `arrow-ipc`.
Expand Down Expand Up @@ -39,7 +38,7 @@ use arrow_schema::{ArrowError as IpcArrowError, SchemaRef};
use bytes::Bytes;

use crate::apis::configuration::Configuration;
use crate::client::{SESSION_ID_HEADER, WORKSPACE_ID_HEADER};
use crate::client::WORKSPACE_ID_HEADER;

/// The Arrow IPC stream media type negotiated with the results endpoint.
pub const ARROW_STREAM_MEDIA_TYPE: &str = "application/vnd.apache.arrow.stream";
Expand Down Expand Up @@ -235,8 +234,8 @@ impl Iterator for ArrowBatchStream {
/// without materializing them all at once.
///
/// The request is built to match the generated `get_result`: same URL, the
/// `X-Workspace-Id`/`X-Session-Id` API key headers, the user-agent, and a
/// transparently JWT-exchanged bearer token via
/// `X-Workspace-Id` API key header, the user-agent, and a transparently
/// JWT-exchanged bearer token via
/// [`Configuration::resolve_bearer_token`](crate::apis::configuration::Configuration::resolve_bearer_token).
/// `Accept` and `?format=arrow` are added on top.
///
Expand Down Expand Up @@ -298,23 +297,19 @@ pub async fn stream_result_arrow(
})
}

/// Apply the `X-Workspace-Id` and `X-Session-Id` API-key headers, mirroring the
/// generated `get_result` `isKeyInHeader` blocks so a session-scoped client
/// (one built via [`crate::client::ClientBuilder::session_id`]) behaves
/// identically on the Arrow path.
/// Apply the `X-Workspace-Id` API-key header, mirroring the generated
/// `get_result` `isKeyInHeader` block so the Arrow path is scoped identically.
fn apply_apikey_headers(
mut req_builder: reqwest::RequestBuilder,
configuration: &Configuration,
) -> reqwest::RequestBuilder {
for header in [WORKSPACE_ID_HEADER, SESSION_ID_HEADER] {
if let Some(apikey) = configuration.api_keys.get(header) {
let key = apikey.key.clone();
let value = match apikey.prefix {
Some(ref prefix) => format!("{prefix} {key}"),
None => key,
};
req_builder = req_builder.header(header, value);
}
if let Some(apikey) = configuration.api_keys.get(WORKSPACE_ID_HEADER) {
let key = apikey.key.clone();
let value = match apikey.prefix {
Some(ref prefix) => format!("{prefix} {key}"),
None => key,
};
req_builder = req_builder.header(WORKSPACE_ID_HEADER, value);
}
req_builder
}
Expand Down Expand Up @@ -492,43 +487,10 @@ mod tests {
use crate::apis::configuration::ApiKey;
use std::sync::Arc;

/// A session-scoped client must send *both* `X-Workspace-Id` and
/// `X-Session-Id` on the Arrow path, matching the generated `get_result`.
/// Guards against silently dropping the session header (the prior behavior).
#[test]
fn apikey_headers_forward_workspace_and_session() {
let mut configuration = Configuration::new();
configuration.api_keys.insert(
WORKSPACE_ID_HEADER.to_owned(),
ApiKey {
prefix: None,
key: "ws-123".to_owned(),
},
);
configuration.api_keys.insert(
SESSION_ID_HEADER.to_owned(),
ApiKey {
prefix: None,
key: "sess-456".to_owned(),
},
);

let req_builder = configuration
.client
.request(reqwest::Method::GET, "https://api.hotdata.dev/v1/results/abc");
let req = apply_apikey_headers(req_builder, &configuration)
.build()
.unwrap();
let headers = req.headers();

assert_eq!(headers.get(WORKSPACE_ID_HEADER).unwrap(), "ws-123");
assert_eq!(headers.get(SESSION_ID_HEADER).unwrap(), "sess-456");
}

/// Without a session id installed, only `X-Workspace-Id` is sent — the
/// session header is omitted rather than sent empty.
/// The Arrow path forwards `X-Workspace-Id`, matching the generated
/// `get_result`.
#[test]
fn apikey_headers_omit_absent_session() {
fn apikey_headers_forward_workspace() {
let mut configuration = Configuration::new();
configuration.api_keys.insert(
WORKSPACE_ID_HEADER.to_owned(),
Expand All @@ -547,7 +509,6 @@ mod tests {
let headers = req.headers();

assert_eq!(headers.get(WORKSPACE_ID_HEADER).unwrap(), "ws-123");
assert!(headers.get(SESSION_ID_HEADER).is_none());
}

use arrow_array::{Int64Array, StringArray};
Expand Down
65 changes: 5 additions & 60 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,6 @@ pub const DEFAULT_BASE_URL: &str = "https://api.hotdata.dev";
/// `Configuration::api_keys` so the generated apiKey-auth blocks emit it.
pub const WORKSPACE_ID_HEADER: &str = "X-Workspace-Id";

/// Header name used to scope requests to a session (optional).
pub const SESSION_ID_HEADER: &str = "X-Session-Id";

/// Environment variable holding the API token used for transparent JWT exchange.
/// Mirrors the Python SDK's `HOTDATA_API_KEY`.
pub const ENV_API_KEY: &str = "HOTDATA_API_KEY";
Expand Down Expand Up @@ -99,7 +96,6 @@ impl std::error::Error for ClientError {}
pub struct ClientBuilder {
api_token: Option<String>,
workspace_id: Option<String>,
session_id: Option<String>,
base_url: Option<String>,
user_agent: Option<String>,
client_id: Option<String>,
Expand All @@ -123,13 +119,6 @@ impl ClientBuilder {
self
}

/// Set an optional session id. Installed as the `X-Session-Id` header when
/// present.
pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}

/// Override the base URL. Defaults to [`DEFAULT_BASE_URL`], or the
/// `HOTDATA_SDK_TEST_API_URL` / `HOTDATA_API_URL` environment variables when
/// set (in that order of precedence).
Expand Down Expand Up @@ -217,28 +206,15 @@ impl ClientBuilder {
..Configuration::default()
};

// Scope every request to the workspace (and optionally the session) via
// the generated apiKey-header auth blocks.
// Scope every request to the workspace via the generated apiKey-header
// auth blocks.
configuration.api_keys.insert(
WORKSPACE_ID_HEADER.to_owned(),
ApiKey {
prefix: None,
key: workspace_id,
},
);
if let Some(session_id) = self
.session_id
.clone()
.or_else(|| non_empty_env("HOTDATA_SESSION_ID"))
{
configuration.api_keys.insert(
SESSION_ID_HEADER.to_owned(),
ApiKey {
prefix: None,
key: session_id,
},
);
}

// Install the transparent api_token -> JWT exchange. The TokenManager
// reuses the same reqwest client (so TLS/proxy/timeout settings are
Expand Down Expand Up @@ -434,9 +410,9 @@ impl Client {
/// `database_id` selects the database via the `X-Database-Id` header (the
/// spec lets database scope come from that header OR the `database_id` body
/// field; pass `None` to use the body field or rely on the default). The
/// request is built wire-identically to the generated op (same workspace /
/// session scope headers, same bearer auth, same `base_path`/`/v1` join,
/// same JSON body) plus the `202` handling.
/// request is built wire-identically to the generated op (same workspace
/// scope header, same bearer auth, same `base_path`/`/v1` join, same JSON
/// body) plus the `202` handling.
pub async fn submit_query(
&self,
request: models::QueryRequest,
Expand Down Expand Up @@ -465,14 +441,6 @@ impl Client {
};
req_builder = req_builder.header("X-Workspace-Id", value);
};
if let Some(apikey) = configuration.api_keys.get("X-Session-Id") {
let key = apikey.key.clone();
let value = match apikey.prefix {
Some(ref prefix) => format!("{} {}", prefix, key),
None => key,
};
req_builder = req_builder.header("X-Session-Id", value);
};
if let Some(token) = configuration.resolve_bearer_token().await {
req_builder = req_builder.bearer_auth(token);
};
Expand Down Expand Up @@ -1012,7 +980,6 @@ mod tests {
ENV_WORKSPACE_ID,
ENV_API_URL,
ENV_TEST_API_URL,
"HOTDATA_SESSION_ID",
"HOTDATA_DISABLE_JWT_EXCHANGE",
] {
env::remove_var(key);
Expand Down Expand Up @@ -1487,28 +1454,6 @@ mod tests {
clear_env();
}

#[test]
fn session_id_installed_when_set() {
let _g = env_guard();
clear_env();

let client = Client::builder()
.api_token("hd_x")
.workspace_id("ws_x")
.session_id("sess_123")
.build()
.expect("build ok");

assert_eq!(
client
.configuration()
.api_keys
.get(SESSION_ID_HEADER)
.map(|k| k.key.as_str()),
Some("sess_123")
);
}

#[test]
fn default_user_agent_uses_crate_version() {
let _g = env_guard();
Expand Down
26 changes: 12 additions & 14 deletions src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ use crate::apis::configuration::Configuration;
use crate::apis::query_api::QueryError as GeneratedQueryError;
use crate::apis::results_api::GetResultError;
use crate::apis::{query_runs_api, results_api, Error, ResponseContent};
use crate::client::{SESSION_ID_HEADER, WORKSPACE_ID_HEADER};
use crate::client::WORKSPACE_ID_HEADER;
use crate::http::{backoff_delay, is_pre_response_transport_error, parse_retry_after};
use crate::models::{AsyncQueryResponse, QueryRequest, QueryResponse, ResultsFormatQuery};
use crate::status::ResultStatus;
Expand Down Expand Up @@ -470,8 +470,8 @@ struct RawResponse {
/// truncation per `qc`.
///
/// Mirrors `hotdata.query.QueryApi.query` from the Python SDK. The request is
/// built wire-identically to the generated `query` op (same workspace/session
/// scope headers, bearer auth, `base_path`/`/v1` join, JSON body); building it
/// built wire-identically to the generated `query` op (same workspace scope
/// header, bearer auth, `base_path`/`/v1` join, JSON body); building it
/// here rather than calling the generated op lets the retry path read the
/// `Retry-After` header (the generated op discards response headers).
pub(crate) async fn execute_query(
Expand Down Expand Up @@ -636,21 +636,19 @@ async fn send_query(
})
}

/// Apply the `X-Workspace-Id` / `X-Session-Id` API-key headers, mirroring the
/// generated op's `isKeyInHeader` blocks.
/// Apply the `X-Workspace-Id` API-key header, mirroring the generated op's

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: (not blocking) two doc comments elsewhere still describe session scoping and were missed by this sweep:

  • src/query.rs:473execute_query's doc still says the request is built wire-identically to the generated op with the "same workspace/session scope headers".
  • src/uploads.rs:33 — the module doc still says put_to_storage sends "NONE of the SDK's bearer / workspace / session headers". The matching wording in tests/presigned_uploads.rs was updated in this PR, so these two now disagree.

Both are just s|workspace/session|workspace| / dropping the / session clause.

/// `isKeyInHeader` block.
fn apply_apikey_headers(
mut req_builder: reqwest::RequestBuilder,
config: &Configuration,
) -> reqwest::RequestBuilder {
for header in [WORKSPACE_ID_HEADER, SESSION_ID_HEADER] {
if let Some(apikey) = config.api_keys.get(header) {
let key = apikey.key.clone();
let value = match apikey.prefix {
Some(ref prefix) => format!("{prefix} {key}"),
None => key,
};
req_builder = req_builder.header(header, value);
}
if let Some(apikey) = config.api_keys.get(WORKSPACE_ID_HEADER) {
let key = apikey.key.clone();
let value = match apikey.prefix {
Some(ref prefix) => format!("{prefix} {key}"),
None => key,
};
req_builder = req_builder.header(WORKSPACE_ID_HEADER, value);
}
req_builder
}
Expand Down
Loading
Loading