Skip to content

Commit c80e768

Browse files
generallCursor Agentdancixx
authored
feat: add custom headers support via .header() on client builder (#273)
* feat: add custom headers support via .header() on client builder - QdrantConfig: add custom_headers field and .header(key, value) builder method - TokenInterceptor: inject custom headers into all gRPC request metadata - download_snapshot: send api_key and custom headers on REST snapshot download Allows setting one or multiple custom headers when building the client, e.g.: Qdrant::from_url("http://localhost:6334") .header("x-custom-id", "my-client") .header("x-request-source", "batch-job") .build() Made-with: Cursor * style: apply cargo +nightly fmt --all Made-with: Cursor * fix CI * refactor: move api-key header name into API_KEY_HEADER const Made-with: Cursor * feat: add more headers unit tests * feat: add ext. api-keys tests --------- Co-authored-by: Cursor Agent <agent@cursor.com> Co-authored-by: Daniel Boros <dancixx@gmail.com>
1 parent 981ae0a commit c80e768

File tree

10 files changed

+402
-24
lines changed

10 files changed

+402
-24
lines changed

.github/workflows/lint.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ jobs:
1313
with:
1414
components: rustfmt, clippy
1515
- uses: actions/checkout@v3
16-
- name: Install dependencies
17-
run: sudo apt-get install protobuf-compiler
16+
- name: Install Protoc
17+
uses: arduino/setup-protoc@v3
18+
with:
19+
repo-token: ${{ secrets.GITHUB_TOKEN }}
1820
- name: Check code formatting
1921
run: cargo fmt --all -- --check
2022
- name: Check cargo clippy warnings

src/auth.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,42 @@
1+
use tonic::metadata::MetadataKey;
12
use tonic::service::Interceptor;
23
use tonic::{Request, Status};
34

4-
pub struct TokenInterceptor {
5+
/// Header name used for API key / token authentication.
6+
pub const API_KEY_HEADER: &str = "api-key";
7+
8+
pub struct MetadataInterceptor {
59
api_key: Option<String>,
10+
custom_headers: Vec<(String, String)>,
611
}
712

8-
impl TokenInterceptor {
9-
pub fn new(api_key: Option<String>) -> Self {
10-
Self { api_key }
13+
impl MetadataInterceptor {
14+
pub fn new(api_key: Option<String>, custom_headers: Vec<(String, String)>) -> Self {
15+
Self {
16+
api_key,
17+
custom_headers,
18+
}
1119
}
1220
}
1321

14-
impl Interceptor for TokenInterceptor {
22+
impl Interceptor for MetadataInterceptor {
1523
fn call(&mut self, mut req: Request<()>) -> anyhow::Result<Request<()>, Status> {
1624
if let Some(api_key) = &self.api_key {
1725
req.metadata_mut().insert(
18-
"api-key",
26+
API_KEY_HEADER,
1927
api_key.parse().map_err(|_| {
2028
Status::invalid_argument(format!("Malformed API key or token: {api_key}"))
2129
})?,
2230
);
2331
}
32+
for (key, value) in &self.custom_headers {
33+
let key = MetadataKey::from_bytes(key.as_bytes())
34+
.map_err(|_| Status::invalid_argument(format!("Malformed header name: {key}")))?;
35+
let value = value.parse().map_err(|_| {
36+
Status::invalid_argument(format!("Malformed header value for {key}: {value}"))
37+
})?;
38+
req.metadata_mut().insert(key, value);
39+
}
2440
Ok(req)
2541
}
2642
}

src/qdrant_client/collection.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use tonic::codegen::InterceptedService;
44
use tonic::transport::Channel;
55
use tonic::Status;
66

7-
use crate::auth::TokenInterceptor;
7+
use crate::auth::MetadataInterceptor;
88
use crate::qdrant::collections_client::CollectionsClient;
99
use crate::qdrant::{
1010
alias_operations, AliasOperations, ChangeAliases, CollectionClusterInfoRequest,
@@ -25,7 +25,7 @@ use crate::qdrant_client::{Qdrant, QdrantResult};
2525
impl Qdrant {
2626
pub(super) async fn with_collections_client<T, O: Future<Output = Result<T, Status>>>(
2727
&self,
28-
f: impl Fn(CollectionsClient<InterceptedService<Channel, TokenInterceptor>>) -> O,
28+
f: impl Fn(CollectionsClient<InterceptedService<Channel, MetadataInterceptor>>) -> O,
2929
) -> QdrantResult<T> {
3030
let result = self
3131
.channel

src/qdrant_client/config.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ pub struct QdrantConfig {
4242
/// Amount of concurrent connections.
4343
/// If set to 0 or 1, connection pools will be disabled.
4444
pub pool_size: usize,
45+
46+
/// Optional custom headers to send with every request (both gRPC and REST).
47+
pub custom_headers: Vec<(String, String)>,
4548
}
4649

4750
impl QdrantConfig {
@@ -56,10 +59,31 @@ impl QdrantConfig {
5659
pub fn from_url(url: &str) -> Self {
5760
QdrantConfig {
5861
uri: url.to_string(),
62+
custom_headers: Vec::new(),
5963
..Self::default()
6064
}
6165
}
6266

67+
/// Add a custom header to send with every request.
68+
///
69+
/// Can be called multiple times to add multiple headers. The same header name can be
70+
/// set multiple times; all values will be sent.
71+
///
72+
/// # Examples
73+
///
74+
/// ```rust,no_run
75+
/// use qdrant_client::Qdrant;
76+
///
77+
/// let client = Qdrant::from_url("http://localhost:6334")
78+
/// .header("x-custom-id", "my-client")
79+
/// .header("x-request-source", "batch-job")
80+
/// .build();
81+
/// ```
82+
pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
83+
self.custom_headers.push((key.into(), value.into()));
84+
self
85+
}
86+
6387
/// Set an optional API key
6488
///
6589
/// # Examples
@@ -204,6 +228,7 @@ impl Default for QdrantConfig {
204228
compression: None,
205229
check_compatibility: true,
206230
pool_size: 3,
231+
custom_headers: Vec::new(),
207232
}
208233
}
209234
}

src/qdrant_client/mod.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use tonic::codegen::InterceptedService;
2020
use tonic::transport::{Channel, Uri};
2121
use tonic::Status;
2222

23-
use crate::auth::TokenInterceptor;
23+
use crate::auth::MetadataInterceptor;
2424
use crate::channel_pool::ChannelPool;
2525
use crate::qdrant::{qdrant_client, HealthCheckReply, HealthCheckRequest};
2626
use crate::qdrant_client::config::QdrantConfig;
@@ -178,16 +178,19 @@ impl Qdrant {
178178
QdrantBuilder::from_url(url)
179179
}
180180

181-
/// Wraps a channel with a token interceptor
182-
fn with_api_key(&self, channel: Channel) -> InterceptedService<Channel, TokenInterceptor> {
183-
let interceptor = TokenInterceptor::new(self.config.api_key.clone());
181+
/// Wraps a channel with a metadata interceptor (api key + custom headers)
182+
fn with_api_key(&self, channel: Channel) -> InterceptedService<Channel, MetadataInterceptor> {
183+
let interceptor = MetadataInterceptor::new(
184+
self.config.api_key.clone(),
185+
self.config.custom_headers.clone(),
186+
);
184187
InterceptedService::new(channel, interceptor)
185188
}
186189

187190
// Access to raw root qdrant API
188191
async fn with_root_qdrant_client<T, O: Future<Output = Result<T, Status>>>(
189192
&self,
190-
f: impl Fn(qdrant_client::QdrantClient<InterceptedService<Channel, TokenInterceptor>>) -> O,
193+
f: impl Fn(qdrant_client::QdrantClient<InterceptedService<Channel, MetadataInterceptor>>) -> O,
191194
) -> QdrantResult<T> {
192195
let result = self
193196
.channel

src/qdrant_client/points.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use tonic::codegen::InterceptedService;
44
use tonic::transport::Channel;
55
use tonic::Status;
66

7-
use crate::auth::TokenInterceptor;
7+
use crate::auth::MetadataInterceptor;
88
use crate::qdrant::points_client::PointsClient;
99
use crate::qdrant::{
1010
CountPoints, CountResponse, DeletePointVectors, DeletePoints, FacetCounts, FacetResponse,
@@ -22,7 +22,7 @@ use crate::qdrant_client::{Qdrant, QdrantResult};
2222
impl Qdrant {
2323
pub(crate) async fn with_points_client<T, O: Future<Output = Result<T, Status>>>(
2424
&self,
25-
f: impl Fn(PointsClient<InterceptedService<Channel, TokenInterceptor>>) -> O,
25+
f: impl Fn(PointsClient<InterceptedService<Channel, MetadataInterceptor>>) -> O,
2626
) -> QdrantResult<T> {
2727
let result = self
2828
.channel

src/qdrant_client/snapshot.rs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use tonic::codegen::InterceptedService;
44
use tonic::transport::Channel;
55
use tonic::Status;
66

7-
use crate::auth::TokenInterceptor;
7+
use crate::auth::{MetadataInterceptor, API_KEY_HEADER};
88
use crate::qdrant::snapshots_client::SnapshotsClient;
99
use crate::qdrant::{
1010
CreateFullSnapshotRequest, CreateSnapshotRequest, CreateSnapshotResponse,
@@ -21,7 +21,7 @@ use crate::qdrant_client::{Qdrant, QdrantResult};
2121
impl Qdrant {
2222
async fn with_snapshot_client<T, O: Future<Output = Result<T, Status>>>(
2323
&self,
24-
f: impl Fn(SnapshotsClient<InterceptedService<Channel, TokenInterceptor>>) -> O,
24+
f: impl Fn(SnapshotsClient<InterceptedService<Channel, MetadataInterceptor>>) -> O,
2525
) -> QdrantResult<T> {
2626
let result = self
2727
.channel
@@ -154,17 +154,25 @@ impl Qdrant {
154154
},
155155
};
156156

157-
let mut stream = reqwest::get(format!(
157+
let url = format!(
158158
"{}/collections/{}/snapshots/{snapshot_name}",
159159
options
160160
.rest_api_uri
161161
.as_ref()
162162
.map(|uri| uri.to_string())
163163
.unwrap_or_else(|| String::from("http://localhost:6333")),
164164
options.collection_name,
165-
))
166-
.await?
167-
.bytes_stream();
165+
);
166+
167+
let client = reqwest::Client::new();
168+
let mut request = client.get(&url);
169+
if let Some(api_key) = &self.config.api_key {
170+
request = request.header(API_KEY_HEADER, api_key.as_str());
171+
}
172+
for (key, value) in &self.config.custom_headers {
173+
request = request.header(key.as_str(), value.as_str());
174+
}
175+
let mut stream = request.send().await?.bytes_stream();
168176

169177
let _ = std::fs::remove_file(&options.out_path);
170178
let mut file = std::fs::OpenOptions::new()

tests/snippet_tests/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod test_batch_update;
22
mod test_clear_payload;
33
mod test_collection_exists;
4+
mod test_config_headers;
45
mod test_count_points;
56
mod test_create_collection;
67
mod test_create_collection_with_bq;
@@ -20,6 +21,7 @@ mod test_delete_snapshot;
2021
mod test_delete_vectors;
2122
mod test_discover_batch_points;
2223
mod test_discover_points;
24+
mod test_external_api_keys;
2325
mod test_facets;
2426
mod test_get_collection;
2527
mod test_get_collection_aliases;
@@ -56,4 +58,4 @@ mod test_upsert_image;
5658
mod test_upsert_points;
5759
mod test_upsert_points_fallback_shard_key;
5860
mod test_upsert_points_insert_only;
59-
mod test_upsert_points_with_condition;
61+
mod test_upsert_points_with_condition;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
use qdrant_client::config::{CompressionEncoding, QdrantConfig};
2+
3+
#[test]
4+
fn header_adds_single_header() {
5+
let config = QdrantConfig::from_url("http://localhost:6334").header("x-custom-id", "my-client");
6+
7+
assert_eq!(
8+
config.custom_headers,
9+
vec![("x-custom-id".to_string(), "my-client".to_string())]
10+
);
11+
}
12+
13+
#[test]
14+
fn header_chain_preserves_order() {
15+
let config = QdrantConfig::from_url("http://localhost:6334")
16+
.header("x-a", "1")
17+
.header("x-b", "2")
18+
.header("x-a", "3");
19+
20+
assert_eq!(
21+
config.custom_headers,
22+
vec![
23+
("x-a".to_string(), "1".to_string()),
24+
("x-b".to_string(), "2".to_string()),
25+
("x-a".to_string(), "3".to_string()),
26+
]
27+
);
28+
}
29+
30+
#[test]
31+
fn header_allows_duplicate_keys() {
32+
let config = QdrantConfig::from_url("http://localhost:6334")
33+
.header("openai-api-key", "k1")
34+
.header("openai-api-key", "k2");
35+
36+
assert_eq!(config.custom_headers.len(), 2);
37+
assert_eq!(
38+
config.custom_headers,
39+
vec![
40+
("openai-api-key".to_string(), "k1".to_string()),
41+
("openai-api-key".to_string(), "k2".to_string()),
42+
]
43+
);
44+
}
45+
46+
#[test]
47+
fn header_does_not_mutate_other_config() {
48+
let base = QdrantConfig::from_url("http://localhost:6334")
49+
.api_key("secret")
50+
.timeout(10u64)
51+
.connect_timeout(20u64)
52+
.compression(Some(CompressionEncoding::Gzip))
53+
.skip_compatibility_check();
54+
55+
let with_header = base.clone().header("x-feature", "on");
56+
57+
assert_eq!(with_header.uri, base.uri);
58+
assert_eq!(with_header.timeout, base.timeout);
59+
assert_eq!(with_header.connect_timeout, base.connect_timeout);
60+
assert_eq!(
61+
with_header.keep_alive_while_idle,
62+
base.keep_alive_while_idle
63+
);
64+
assert_eq!(with_header.api_key, base.api_key);
65+
assert_eq!(with_header.compression, base.compression);
66+
assert_eq!(with_header.check_compatibility, base.check_compatibility);
67+
assert_eq!(with_header.pool_size, base.pool_size);
68+
69+
assert_eq!(
70+
with_header.custom_headers,
71+
vec![("x-feature".to_string(), "on".to_string())]
72+
);
73+
assert!(base.custom_headers.is_empty());
74+
}

0 commit comments

Comments
 (0)