Skip to content

Commit 315d078

Browse files
authored
feat: add custom headers to search api requests (#943)
### Description This PR adds a `--header` option to the search subcommand which are passed to the `reqwest::ClientBuilder::default_headers` method. The intended use case is to enable the passing of custom headers to requests made by the rustac STAC api client e.g. Authorization headers. This adds a new `ApiClientBuilder` struct which wraps `reqwests::ClientBuilder` in order to manage adding creating a Client instance with default headers. I used copilot to help me in my approach. ### Checklist Delete any checklist items that do not apply (e.g. if your change is minor, it may not require documentation updates). - [x] Unit tests - [x] Documentation, including doctests - [x] Pull request title follows [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) - [x] Pre-commit hooks pass (`prek run --all-files`)
1 parent 0f07a4d commit 315d078

2 files changed

Lines changed: 119 additions & 11 deletions

File tree

crates/cli/src/lib.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,18 @@ pub enum Command {
232232
/// The page size to be returned from the server.
233233
#[arg(long = "limit")]
234234
limit: Option<String>,
235+
236+
/// Request headers to include in STAC API Search.
237+
///
238+
/// Headers should be provided in `KEY=VALUE` format. Can be specified multiple
239+
/// times or as a comma-delimited string.
240+
/// e.g.: `rustac search --header "x-my-header=value" --header "x-my-other-header=this"`
241+
#[arg(
242+
long = "header",
243+
value_delimiter = ',',
244+
value_parser = |s: &str| KeyValue::from_str(s).map(|kv| (kv.0, kv.1))
245+
)]
246+
headers: Vec<(String, String)>,
235247
},
236248

237249
/// Serves a STAC API.
@@ -398,6 +410,7 @@ impl Rustac {
398410
ref sortby,
399411
ref filter,
400412
ref limit,
413+
ref headers,
401414
} => {
402415
// Infer the search implementation from the href if not explicitly provided
403416
let search_impl = search_with.unwrap_or_else(|| {
@@ -440,7 +453,7 @@ impl Rustac {
440453
}
441454
SearchImplementation::Duckdb => stac_duckdb::search(href, search, *max_items)?,
442455
SearchImplementation::Api => {
443-
stac_io::api::search(href, search, *max_items).await?
456+
stac_io::api::search(href, search, *max_items, &headers).await?
444457
}
445458
};
446459
self.put(

crates/io/src/api.rs

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::{Error, Result};
44
use async_stream::try_stream;
55
use futures::{Stream, StreamExt, pin_mut};
66
use http::header::{HeaderName, USER_AGENT};
7-
use reqwest::{ClientBuilder, IntoUrl, Method, StatusCode, header::HeaderMap};
7+
use reqwest::{ClientBuilder, IntoUrl, Method, StatusCode, header::HeaderMap, header::HeaderValue};
88
use serde::{Serialize, de::DeserializeOwned};
99
use serde_json::{Map, Value};
1010
use stac::api::{GetItems, Item, ItemCollection, Items, Search, UrlBuilder};
@@ -23,8 +23,13 @@ pub async fn search(
2323
href: &str,
2424
mut search: Search,
2525
max_items: Option<usize>,
26+
headers: &[(String, String)],
2627
) -> Result<ItemCollection> {
27-
let client = Client::new(href)?;
28+
let mut builder = ApiClientBuilder::new(href)?;
29+
if !headers.is_empty() {
30+
builder = builder.with_headers(headers)?;
31+
}
32+
let client = builder.build()?;
2833
if search.limit.is_none()
2934
&& let Some(max_items) = max_items
3035
{
@@ -72,24 +77,91 @@ pub struct BlockingIterator {
7277
stream: Pin<Box<dyn Stream<Item = Result<Item>>>>,
7378
}
7479

75-
impl Client {
76-
/// Creates a new API client.
80+
/// Builder for configuring and constructing a [`Client`] for STAC APIs.
81+
///
82+
/// This type is used to create a `Client` with a specific base URL and a set
83+
/// of default HTTP headers. A typical usage pattern is:
84+
///
85+
/// - Call [`ApiClientBuilder::new`] with the API root URL.
86+
/// - Optionally call [`ApiClientBuilder::with_headers`] to add extra headers.
87+
/// - Call [`ApiClientBuilder::build`] to obtain a configured [`Client`].
88+
///
89+
/// Internally, `ApiClientBuilder` prepares a `reqwest::Client` and then
90+
/// delegates to [`Client::with_client`] to produce the final STAC API client.
91+
pub struct ApiClientBuilder {
92+
url: String,
93+
headers: HeaderMap,
94+
}
95+
96+
impl ApiClientBuilder {
97+
/// Create ApiClientBuilder
7798
///
7899
/// # Examples
79100
///
80101
/// ```
81-
/// # use stac_io::api::Client;
82-
/// let client = Client::new("https://planetarycomputer.microsoft.com/api/stac/v1").unwrap();
102+
/// # use stac_io::api::ApiClientBuilder;
103+
/// let builder = ApiClientBuilder::new("https://planetarycomputer.microsoft.com/api/stac/v1").unwrap();
83104
/// ```
84-
pub fn new(url: &str) -> Result<Client> {
85-
// TODO support HATEOS (aka look up the urls from the root catalog)
105+
pub fn new(url: &str) -> Result<Self> {
86106
let mut headers = HeaderMap::new();
87107
let _ = headers.insert(
88108
USER_AGENT,
89109
format!("rustac/{}", env!("CARGO_PKG_VERSION")).parse()?,
90110
);
91-
let client = ClientBuilder::new().default_headers(headers).build()?;
92-
Client::with_client(client, url)
111+
Ok(Self {
112+
url: url.to_string(),
113+
headers,
114+
})
115+
}
116+
117+
/// Additional headers to pass to default_headers
118+
///
119+
/// # Examples
120+
///
121+
/// ```
122+
/// # use stac_io::api::ApiClientBuilder;
123+
/// let headers = vec![("x-my-header".to_string(), "value".to_string())];
124+
/// let builder = ApiClientBuilder::new("https://planetarycomputer.microsoft.com/api/stac/v1")
125+
/// .unwrap()
126+
/// .with_headers(&headers)
127+
/// .unwrap();
128+
/// ```
129+
pub fn with_headers(mut self, headers: &[(String, String)]) -> Result<Self> {
130+
for (key, val) in headers.iter() {
131+
let header_name = key.parse::<HeaderName>()?;
132+
let header_value = HeaderValue::from_str(val)?;
133+
self.headers.insert(header_name, header_value);
134+
}
135+
Ok(self)
136+
}
137+
138+
/// Builds a [`Client`] from this builder.
139+
///
140+
/// This finalizes the configuration (including any default and custom headers)
141+
/// and constructs the underlying `reqwest::Client`.
142+
///
143+
/// # Examples
144+
///
145+
/// ```
146+
/// # use stac_io::api::ApiClientBuilder;
147+
/// let client = ApiClientBuilder::new("https://planetarycomputer.microsoft.com/api/stac/v1").unwrap().build().unwrap();
148+
pub fn build(self) -> Result<Client> {
149+
let client = ClientBuilder::new().default_headers(self.headers).build()?;
150+
Client::with_client(client, &self.url)
151+
}
152+
}
153+
154+
impl Client {
155+
/// Creates a new API client.
156+
///
157+
/// # Examples
158+
///
159+
/// ```
160+
/// # use stac_io::api::Client;
161+
/// let client = Client::new("https://planetarycomputer.microsoft.com/api/stac/v1").unwrap();
162+
/// ```
163+
pub fn new(url: &str) -> Result<Client> {
164+
ApiClientBuilder::new(url)?.build()
93165
}
94166

95167
/// Creates a new API client with the given [Client].
@@ -407,6 +479,8 @@ fn not_found_to_none<T>(result: Result<T>) -> Result<Option<T>> {
407479

408480
#[cfg(test)]
409481
mod tests {
482+
use crate::api::ApiClientBuilder;
483+
410484
use super::Client;
411485
use futures::StreamExt;
412486
use mockito::{Matcher, Server};
@@ -586,4 +660,25 @@ mod tests {
586660
let client = Client::new(&server.url()).unwrap();
587661
let _ = client.search(Default::default()).await.unwrap();
588662
}
663+
#[tokio::test]
664+
async fn custom_header() {
665+
let mut server = Server::new_async().await;
666+
let _ = server
667+
.mock("POST", "/search")
668+
.with_body_from_file("mocks/items-page-1.json")
669+
.match_header("x-my-header", "value")
670+
.match_header("x-my-other-header", "othervalue")
671+
.create_async()
672+
.await;
673+
let headers = vec![
674+
("x-my-header".to_string(), "value".to_string()),
675+
("x-my-other-header".to_string(), "othervalue".to_string()),
676+
];
677+
let builder = ApiClientBuilder::new(&server.url())
678+
.unwrap()
679+
.with_headers(&headers)
680+
.unwrap();
681+
let client = builder.build().unwrap();
682+
let _ = client.search(Default::default()).await.unwrap();
683+
}
589684
}

0 commit comments

Comments
 (0)