use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use reqwest::header::{HeaderName, HeaderValue};
use serde::Serialize;
use serde::de::DeserializeOwned;
use uuid::Uuid;
use wyrd_spec::error::WyrdError;
use wyrd_spec::operator_connection::HttpsOrigin;
use wyrd_spec::request_id::RequestId;
use crate::auth::{AuthError, AuthMiddleware};
use crate::error::{WyrdClientError, from_problem_json};
use crate::transport::config::HttpConfig;
use crate::transport::trace;
use reqwest::Method;
use reqwest::StatusCode;
use reqwest::header::InvalidHeaderValue;
pub struct ArrowResponse {
pub frames: Vec<u8>,
pub schema_fingerprint: Option<String>,
pub row_count: Option<u64>,
}
const HEADER_REQUEST_ID: &str = "wyrd-request-id";
const HEADER_IDEMPOTENCY_KEY: &str = "Idempotency-Key";
const HEADER_SCHEMA_FINGERPRINT: &str = "X-Wyrd-Schema-Fingerprint";
const HEADER_ROW_COUNT: &str = "X-Wyrd-Row-Count";
const QUERY_STREAM_CONTENT_TYPE: &str = "application/vnd.wyrd.bifrost-query-stream";
const HEADER_WYRD_ACCESS_TOKEN: &str = "x-wyrd-access-token";
fn access_token_header() -> HeaderName {
HeaderName::from_static(HEADER_WYRD_ACCESS_TOKEN)
}
fn request_id_header() -> HeaderName {
HeaderName::from_static(HEADER_REQUEST_ID)
}
#[derive(Debug, thiserror::Error)]
pub enum AuthenticatedReplayError<E> {
#[error("wyrd credential unavailable: {0}")]
Credential(WyrdError),
#[error("authenticated operation failed")]
Operation(E),
}
impl<E> AuthenticatedReplayError<E> {
fn header(error: &InvalidHeaderValue) -> Self {
Self::Credential(WyrdError::Internal {
message: format!("wyrd header value is not valid for transport: {error}"),
details: serde_json::json!({}),
})
}
}
#[derive(Clone)]
pub struct HttpTransport {
client: reqwest::Client,
request_timeout: Duration,
auth: Arc<AuthMiddleware>,
origin: HttpsOrigin,
}
impl std::fmt::Debug for HttpTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpTransport")
.field("origin", &self.origin)
.finish_non_exhaustive()
}
}
impl HttpTransport {
#[must_use]
pub fn base_url(&self) -> &str {
self.origin.as_str()
}
#[must_use]
pub fn client(&self) -> reqwest::Client {
self.client.clone()
}
pub fn new(config: &HttpConfig, auth: Arc<AuthMiddleware>) -> Result<Self, WyrdClientError> {
let origin = config.validate()?;
Ok(Self {
client: build_http_client(config)?,
request_timeout: Duration::from_millis(config.timeout_ms),
auth,
origin,
})
}
#[must_use]
pub(crate) const fn request_timeout(&self) -> Duration {
self.request_timeout
}
#[must_use]
pub(crate) fn with_min_request_timeout(&self, floor: Duration) -> Self {
Self {
client: self.client.clone(),
request_timeout: self.request_timeout.max(floor),
auth: Arc::clone(&self.auth),
origin: self.origin.clone(),
}
}
#[must_use]
pub(crate) fn with_auth(&self, auth: Arc<AuthMiddleware>) -> Self {
Self {
client: self.client.clone(),
request_timeout: self.request_timeout,
auth,
origin: self.origin.clone(),
}
}
pub async fn authenticated_replay<T, E, F, O>(
&self,
mut custom_headers: HashMap<HeaderName, HeaderValue>,
status_of: impl Fn(&E) -> Option<StatusCode>,
operation: F,
) -> Result<T, AuthenticatedReplayError<E>>
where
F: Fn(HashMap<HeaderName, HeaderValue>) -> O,
O: Future<Output = Result<T, E>>,
{
custom_headers.insert(access_token_header(), self.bearer_header(false).await?);
if let Entry::Vacant(slot) = custom_headers.entry(request_id_header()) {
slot.insert(
HeaderValue::from_str(&self.auth.request_id(None))
.map_err(|error| AuthenticatedReplayError::header(&error))?,
);
}
if !custom_headers.contains_key(&HeaderName::from_static(trace::TRACEPARENT)) {
for (name, value) in trace::headers() {
custom_headers.insert(
HeaderName::from_static(name),
HeaderValue::from_str(&value)
.map_err(|error| AuthenticatedReplayError::header(&error))?,
);
}
}
let first = operation(custom_headers.clone()).await;
let refused = match &first {
Err(error) => status_of(error) == Some(reqwest::StatusCode::UNAUTHORIZED),
Ok(_) => false,
};
if !refused {
return first.map_err(AuthenticatedReplayError::Operation);
}
custom_headers.insert(access_token_header(), self.bearer_header(true).await?);
operation(custom_headers)
.await
.map_err(AuthenticatedReplayError::Operation)
}
async fn bearer_header<E>(
&self,
force: bool,
) -> Result<HeaderValue, AuthenticatedReplayError<E>> {
let bearer = if force {
self.auth.force_refresh().await
} else {
self.auth.bearer().await
}
.map_err(|error| AuthenticatedReplayError::Credential(AuthError::into_wyrd(error)))?;
HeaderValue::from_str(&format!("Bearer {}", bearer.expose()))
.map_err(|error| AuthenticatedReplayError::header(&error))
}
pub async fn request_json<S, D>(
&self,
method: Method,
path: &str,
body: Option<&S>,
) -> Result<D, WyrdError>
where
S: Serialize,
D: DeserializeOwned,
{
self.request_json_with_headers(method, path, body, &[])
.await
}
pub(crate) async fn request_json_with_headers<S, D>(
&self,
method: Method,
path: &str,
body: Option<&S>,
headers: &[(&str, &str)],
) -> Result<D, WyrdError>
where
S: Serialize,
D: DeserializeOwned,
{
let url = self.authenticated_url(path)?;
let request_id = self.auth.request_id(None);
let body_bytes = serialize_body(body)?;
let resp = self
.send_with_retry(
&method,
&url,
&request_id,
body_bytes.as_deref(),
None,
headers,
)
.await?;
let bytes = resp.bytes().await.map_err(body_read_err)?;
let bytes: &[u8] = if bytes.is_empty() { b"null" } else { &bytes };
serde_json::from_slice(bytes).map_err(|err| WyrdError::Internal {
message: format!("response deserialization failed: {err}"),
details: serde_json::json!({}),
})
}
pub async fn request_arrow<S>(
&self,
method: Method,
path: &str,
body: Option<&S>,
) -> Result<ArrowResponse, WyrdError>
where
S: Serialize,
{
let url = self.authenticated_url(path)?;
let request_id = self.auth.request_id(None);
let body_bytes = serialize_body(body)?;
let resp = self
.send_with_retry(&method, &url, &request_id, body_bytes.as_deref(), None, &[])
.await?;
let schema_fingerprint = header_str(&resp, HEADER_SCHEMA_FINGERPRINT).map(str::to_owned);
let row_count = header_str(&resp, HEADER_ROW_COUNT).and_then(|s| s.parse::<u64>().ok());
let frames = resp.bytes().await.map_err(body_read_err)?.to_vec();
Ok(ArrowResponse {
frames,
schema_fingerprint,
row_count,
})
}
pub async fn request_stream(
&self,
method: Method,
path: &str,
body: reqwest::Body,
) -> Result<reqwest::Response, WyrdError> {
let url = self.authenticated_url(path)?;
let bearer = self.auth.bearer().await.map_err(AuthError::into_wyrd)?;
let request = self
.authenticated(method, url, bearer.expose(), &self.auth.request_id(None))
.body(body);
let response = request.send().await.map_err(|err| WyrdError::Internal {
message: format!("transport error: {err}"),
details: serde_json::json!({"transport": "http"}),
})?;
if response.status().is_success() {
Ok(response)
} else {
let body = response
.json::<serde_json::Value>()
.await
.unwrap_or_else(|_| serde_json::json!({}));
Err(from_problem_json(&body))
}
}
pub async fn request_external_stream(
&self,
method: Method,
url: &str,
body: Option<reqwest::Body>,
headers: &[(&str, &str)],
) -> Result<reqwest::Response, WyrdError> {
let mut request = self.client.request(method, url);
for (name, value) in headers {
request = request.header(*name, *value);
}
if let Some(body) = body {
request = request.body(body);
}
request.send().await.map_err(|err| WyrdError::Internal {
message: format!("external transport error: {err}"),
details: serde_json::json!({"transport": "http-external"}),
})
}
pub async fn request_raw(
&self,
method: Method,
path: &str,
) -> Result<reqwest::Response, WyrdError> {
let url = self.authenticated_url(path)?;
let bearer = self.auth.bearer().await.map_err(AuthError::into_wyrd)?;
let response = self
.authenticated(method, url, bearer.expose(), &self.auth.request_id(None))
.send()
.await
.map_err(|err| WyrdError::Internal {
message: format!("transport error: {err}"),
details: serde_json::json!({"transport": "http"}),
})?;
if response.status().is_success() {
Ok(response)
} else {
let body = response
.json::<serde_json::Value>()
.await
.unwrap_or_else(|_| serde_json::json!({}));
Err(from_problem_json(&body))
}
}
pub async fn request_json_stream<S>(
&self,
method: Method,
path: &str,
body: &S,
) -> Result<reqwest::Response, WyrdError>
where
S: Serialize,
{
let request_id = self.auth.request_id(None);
self.request_json_stream_inner(method, path, body, &request_id, false)
.await
}
pub async fn request_json_stream_with_id<S>(
&self,
method: Method,
path: &str,
body: &S,
request_id: &RequestId,
) -> Result<reqwest::Response, WyrdError>
where
S: Serialize,
{
self.request_json_stream_inner(method, path, body, request_id.as_str(), true)
.await
}
async fn request_json_stream_inner<S>(
&self,
method: Method,
path: &str,
body: &S,
request_id: &str,
verify_request_id: bool,
) -> Result<reqwest::Response, WyrdError>
where
S: Serialize,
{
let url = self.authenticated_url(path)?;
let bearer = self.auth.bearer().await.map_err(AuthError::into_wyrd)?;
let payload = serde_json::to_vec(body).map_err(|error| WyrdError::Internal {
message: format!("request serialization failed: {error}"),
details: serde_json::json!({}),
})?;
let response = self
.authenticated(method, url, bearer.expose(), request_id)
.header("content-type", "application/json")
.header("accept", QUERY_STREAM_CONTENT_TYPE)
.body(payload)
.send()
.await
.map_err(|error| WyrdError::Internal {
message: format!("transport error: {error}"),
details: serde_json::json!({"transport": "http"}),
})?;
if response.status().is_success() {
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.map(str::trim);
if content_type == Some(QUERY_STREAM_CONTENT_TYPE) {
let echoed = header_str(&response, HEADER_REQUEST_ID);
if !verify_request_id || echoed == Some(request_id) {
Ok(response)
} else {
Err(WyrdError::UpstreamFailure {
message: "query response request identity did not match".to_owned(),
details: serde_json::json!({"reason": "request_id_mismatch"}),
})
}
} else {
Err(WyrdError::UpstreamFailure {
message: "query response used an unsupported content type".to_owned(),
details: serde_json::json!({
"expected": QUERY_STREAM_CONTENT_TYPE,
"actual": content_type
}),
})
}
} else {
let body = response
.json::<serde_json::Value>()
.await
.unwrap_or_else(|_| serde_json::json!({}));
Err(from_problem_json(&body))
}
}
pub async fn submit_idempotent<S, D>(
&self,
method: Method,
path: &str,
body: &S,
) -> Result<D, WyrdError>
where
S: Serialize,
D: DeserializeOwned,
{
self.submit_with_optional_idempotency_key(method, path, body, None)
.await
}
pub async fn submit_with_idempotency_key<S, D>(
&self,
method: Method,
path: &str,
body: &S,
key: &str,
) -> Result<D, WyrdError>
where
S: Serialize,
D: DeserializeOwned,
{
self.submit_with_optional_idempotency_key(method, path, body, Some(key))
.await
}
async fn submit_with_optional_idempotency_key<S, D>(
&self,
method: Method,
path: &str,
body: &S,
key: Option<&str>,
) -> Result<D, WyrdError>
where
S: Serialize,
D: DeserializeOwned,
{
let url = self.authenticated_url(path)?;
let request_id = self.auth.request_id(None);
let generated_key;
let idempotency_key = if let Some(key) = key {
key
} else {
generated_key = Uuid::now_v7().to_string();
&generated_key
};
let body_bytes = serde_json::to_vec(body).map_err(|err| WyrdError::Internal {
message: format!("request serialization failed: {err}"),
details: serde_json::json!({}),
})?;
let resp = self
.send_with_retry(
&method,
&url,
&request_id,
Some(&body_bytes),
Some(idempotency_key),
&[],
)
.await?;
let bytes = resp.bytes().await.map_err(body_read_err)?;
serde_json::from_slice(&bytes).map_err(|err| WyrdError::Internal {
message: format!("response deserialization failed: {err}"),
details: serde_json::json!({}),
})
}
fn authenticated(
&self,
method: Method,
url: impl reqwest::IntoUrl,
bearer: &str,
request_id: &str,
) -> reqwest::RequestBuilder {
let request = self
.client
.request(method, url)
.header(HEADER_WYRD_ACCESS_TOKEN, format!("Bearer {bearer}"))
.header(HEADER_REQUEST_ID, request_id);
trace::headers()
.into_iter()
.fold(request, |request, (name, value)| {
request.header(name, value)
})
}
fn authenticated_url(&self, path: &str) -> Result<String, WyrdError> {
if path.starts_with("http://") || path.starts_with("https://") {
let origin = reqwest::Url::parse(path)
.ok()
.and_then(|url| HttpsOrigin::of_url(&url).ok());
if origin.as_ref() == Some(&self.origin) {
Ok(path.to_owned())
} else {
Err(WyrdError::Validation {
message: "authenticated request URL must match the configured Wyrd origin"
.to_owned(),
details: serde_json::json!({
"reason": "cross_origin_url_rejected",
"transport": "http",
}),
})
}
} else {
Ok(format!("{}/{}", self.origin, path.trim_start_matches('/')))
}
}
async fn send_with_retry(
&self,
method: &reqwest::Method,
url: &str,
request_id: &str,
body: Option<&[u8]>,
idempotency_key: Option<&str>,
extra_headers: &[(&str, &str)],
) -> Result<reqwest::Response, WyrdError> {
let replay_safe = method.is_idempotent() || idempotency_key.is_some();
let mut attempt = 0u32;
let mut auth_retried = false;
loop {
let bearer = self.auth.bearer().await.map_err(AuthError::into_wyrd)?;
let mut req = self
.authenticated(method.clone(), url, bearer.expose(), request_id)
.timeout(self.request_timeout);
if let Some(key) = idempotency_key {
req = req.header(HEADER_IDEMPOTENCY_KEY, key);
}
for (name, value) in extra_headers {
req = req.header(*name, *value);
}
if let Some(bytes) = body {
req = req
.header("content-type", "application/json")
.body(bytes.to_vec());
}
let result = req.send().await;
match result {
Err(err) if err.is_connect() && attempt < 2 => {
tokio::time::sleep(Duration::from_millis(crate::transport::backoff_ms(
attempt,
)))
.await;
attempt += 1;
}
Err(err) if err.is_timeout() && replay_safe && attempt < 2 => {
tokio::time::sleep(Duration::from_millis(crate::transport::backoff_ms(
attempt,
)))
.await;
attempt += 1;
}
Err(err) => {
return Err(WyrdError::Internal {
message: format!("transport error: {err}"),
details: serde_json::json!({"transport": "http"}),
});
}
Ok(resp) => {
let status = resp.status().as_u16();
if status == 401 && !auth_retried {
auth_retried = true;
let _ = self.auth.force_refresh().await;
continue;
}
if (status == 408 || status == 429 || status >= 500)
&& attempt < 2
&& replay_safe
{
tokio::time::sleep(Duration::from_millis(crate::transport::backoff_ms(
attempt,
)))
.await;
attempt += 1;
continue;
}
if !resp.status().is_success() {
let body_val = resp
.json::<serde_json::Value>()
.await
.unwrap_or(serde_json::json!({}));
return Err(from_problem_json(&body_val));
}
return Ok(resp);
}
}
}
}
}
fn build_http_client(config: &HttpConfig) -> Result<reqwest::Client, WyrdClientError> {
wyrd_tls::install_crypto_provider().map_err(|error| WyrdClientError::TransportDown {
transport: "http".to_owned(),
message: error.to_string(),
})?;
let mut builder =
reqwest::Client::builder().connect_timeout(Duration::from_millis(config.timeout_ms));
if config.compression {
builder = builder.gzip(true);
}
builder
.build()
.map_err(|err| WyrdClientError::TransportDown {
transport: "http".to_owned(),
message: format!("failed to build HTTP client: {err}"),
})
}
fn serialize_body<S: Serialize>(body: Option<&S>) -> Result<Option<Vec<u8>>, WyrdError> {
body.map(serde_json::to_vec)
.transpose()
.map_err(|err| WyrdError::Internal {
message: format!("request serialization failed: {err}"),
details: serde_json::json!({}),
})
}
fn body_read_err(err: reqwest::Error) -> WyrdError {
WyrdError::Internal {
message: format!("transport error reading response body: {err}"),
details: serde_json::json!({"transport": "http"}),
}
}
fn header_str<'a>(resp: &'a reqwest::Response, name: &str) -> Option<&'a str> {
resp.headers().get(name)?.to_str().ok()
}
#[cfg(test)]
mod tls_tests {
#[test]
fn https_client_initializes_provider_standalone() {
let client = super::build_http_client(&crate::transport::config::HttpConfig::default())
.expect("standalone HTTPS client builds");
client
.get("https://localhost/health")
.build()
.expect("HTTPS request builds");
wyrd_tls::install_crypto_provider().expect("HTTP boundary retained AWS-LC ownership");
}
}