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
15 changes: 14 additions & 1 deletion crates/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,18 @@ pub enum Command {
/// The page size to be returned from the server.
#[arg(long = "limit")]
limit: Option<String>,

/// Request headers to include in STAC API Search.
///
/// Headers should be provided in `KEY=VALUE` format. Can be specified multiple
/// times or as a comma-delimited string.
/// e.g.: `rustac search --header "x-my-header=value" --header "x-my-other-header=this"`
#[arg(
long = "header",
value_delimiter = ',',
value_parser = |s: &str| KeyValue::from_str(s).map(|kv| (kv.0, kv.1))
)]
headers: Vec<(String, String)>,
},

/// Serves a STAC API.
Expand Down Expand Up @@ -398,6 +410,7 @@ impl Rustac {
ref sortby,
ref filter,
ref limit,
ref headers,
} => {
// Infer the search implementation from the href if not explicitly provided
let search_impl = search_with.unwrap_or_else(|| {
Expand Down Expand Up @@ -440,7 +453,7 @@ impl Rustac {
}
SearchImplementation::Duckdb => stac_duckdb::search(href, search, *max_items)?,
SearchImplementation::Api => {
stac_io::api::search(href, search, *max_items).await?
stac_io::api::search(href, search, *max_items, &headers).await?
}
};
self.put(
Expand Down
115 changes: 105 additions & 10 deletions crates/io/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::{Error, Result};
use async_stream::try_stream;
use futures::{Stream, StreamExt, pin_mut};
use http::header::{HeaderName, USER_AGENT};
use reqwest::{ClientBuilder, IntoUrl, Method, StatusCode, header::HeaderMap};
use reqwest::{ClientBuilder, IntoUrl, Method, StatusCode, header::HeaderMap, header::HeaderValue};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::{Map, Value};
use stac::api::{GetItems, Item, ItemCollection, Items, Search, UrlBuilder};
Expand All @@ -23,8 +23,13 @@ pub async fn search(
href: &str,
mut search: Search,
max_items: Option<usize>,
headers: &[(String, String)],
) -> Result<ItemCollection> {
let client = Client::new(href)?;
let mut builder = ApiClientBuilder::new(href)?;
if !headers.is_empty() {
builder = builder.with_headers(headers)?;
}
let client = builder.build()?;
if search.limit.is_none()
&& let Some(max_items) = max_items
{
Expand Down Expand Up @@ -72,24 +77,91 @@ pub struct BlockingIterator {
stream: Pin<Box<dyn Stream<Item = Result<Item>>>>,
}

impl Client {
/// Creates a new API client.
/// Builder for configuring and constructing a [`Client`] for STAC APIs.
///
/// This type is used to create a `Client` with a specific base URL and a set
/// of default HTTP headers. A typical usage pattern is:
///
/// - Call [`ApiClientBuilder::new`] with the API root URL.
/// - Optionally call [`ApiClientBuilder::with_headers`] to add extra headers.
/// - Call [`ApiClientBuilder::build`] to obtain a configured [`Client`].
///
/// Internally, `ApiClientBuilder` prepares a `reqwest::Client` and then
/// delegates to [`Client::with_client`] to produce the final STAC API client.
pub struct ApiClientBuilder {
url: String,
headers: HeaderMap,
}

impl ApiClientBuilder {
/// Create ApiClientBuilder
///
/// # Examples
///
/// ```
/// # use stac_io::api::Client;
/// let client = Client::new("https://planetarycomputer.microsoft.com/api/stac/v1").unwrap();
/// # use stac_io::api::ApiClientBuilder;
/// let builder = ApiClientBuilder::new("https://planetarycomputer.microsoft.com/api/stac/v1").unwrap();
/// ```
pub fn new(url: &str) -> Result<Client> {
// TODO support HATEOS (aka look up the urls from the root catalog)
pub fn new(url: &str) -> Result<Self> {
let mut headers = HeaderMap::new();
let _ = headers.insert(
USER_AGENT,
format!("rustac/{}", env!("CARGO_PKG_VERSION")).parse()?,
);
let client = ClientBuilder::new().default_headers(headers).build()?;
Client::with_client(client, url)
Ok(Self {
url: url.to_string(),
headers,
})
}

/// Additional headers to pass to default_headers
///
/// # Examples
///
/// ```
/// # use stac_io::api::ApiClientBuilder;
/// let headers = vec![("x-my-header".to_string(), "value".to_string())];
/// let builder = ApiClientBuilder::new("https://planetarycomputer.microsoft.com/api/stac/v1")
/// .unwrap()
/// .with_headers(&headers)
/// .unwrap();
/// ```
pub fn with_headers(mut self, headers: &[(String, String)]) -> Result<Self> {
for (key, val) in headers.iter() {
let header_name = key.parse::<HeaderName>()?;
let header_value = HeaderValue::from_str(val)?;
self.headers.insert(header_name, header_value);
}
Ok(self)
}

/// Builds a [`Client`] from this builder.
///
/// This finalizes the configuration (including any default and custom headers)
/// and constructs the underlying `reqwest::Client`.
///
/// # Examples
///
/// ```
/// # use stac_io::api::ApiClientBuilder;
/// let client = ApiClientBuilder::new("https://planetarycomputer.microsoft.com/api/stac/v1").unwrap().build().unwrap();
pub fn build(self) -> Result<Client> {
let client = ClientBuilder::new().default_headers(self.headers).build()?;
Client::with_client(client, &self.url)
}
}

impl Client {
/// Creates a new API client.
///
/// # Examples
///
/// ```
/// # use stac_io::api::Client;
/// let client = Client::new("https://planetarycomputer.microsoft.com/api/stac/v1").unwrap();
/// ```
pub fn new(url: &str) -> Result<Client> {
ApiClientBuilder::new(url)?.build()
}

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

#[cfg(test)]
mod tests {
use crate::api::ApiClientBuilder;

use super::Client;
use futures::StreamExt;
use mockito::{Matcher, Server};
Expand Down Expand Up @@ -586,4 +660,25 @@ mod tests {
let client = Client::new(&server.url()).unwrap();
let _ = client.search(Default::default()).await.unwrap();
}
#[tokio::test]
async fn custom_header() {
let mut server = Server::new_async().await;
let _ = server
.mock("POST", "/search")
.with_body_from_file("mocks/items-page-1.json")
.match_header("x-my-header", "value")
.match_header("x-my-other-header", "othervalue")
.create_async()
.await;
let headers = vec![
("x-my-header".to_string(), "value".to_string()),
("x-my-other-header".to_string(), "othervalue".to_string()),
];
let builder = ApiClientBuilder::new(&server.url())
.unwrap()
.with_headers(&headers)
.unwrap();
let client = builder.build().unwrap();
let _ = client.search(Default::default()).await.unwrap();
}
}