use std::path::Path;
use std::sync::OnceLock;
use std::time::Duration;
pub(crate) fn install_ring_provider() {
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
static MEDIA_HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
static IMAGE_GEN_HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
#[must_use]
pub fn bearer_auth_header() -> Option<String> {
let key = crate::config::CONFIG.provider_key()?;
if key.is_empty() {
return None;
}
Some(format!("Bearer {key}"))
}
pub(crate) async fn read_error_body(response: reqwest::Response, context: &str) -> String {
response.text().await.unwrap_or_else(|e| {
tracing::warn!(?e, "Failed to read {context} response body");
"failed to read response body".to_string()
})
}
async fn check_response(
response: reqwest::Response,
error_context: &str,
) -> anyhow::Result<reqwest::Response> {
if !response.status().is_success() {
let mut err = super::error::HttpError::from_response(response, error_context).await;
err.body = crate::util::truncate(&err.body, 500);
return Err(anyhow::Error::from(err));
}
Ok(response)
}
pub(crate) fn parse_json_response(
body_text: &str,
error_context: &str,
) -> anyhow::Result<serde_json::Value> {
serde_json::from_str(body_text).map_err(|e| {
anyhow::anyhow!(
"{error_context} response parse error: {e}\nraw response body ({}): {body_text:.500}",
body_text.len(),
)
})
}
async fn provider_request(
error_context: &str,
build_request: impl FnOnce(&reqwest::Client) -> reqwest::RequestBuilder,
) -> anyhow::Result<reqwest::Response> {
let auth = bearer_auth_header()
.ok_or_else(|| anyhow::anyhow!("{error_context}: provider API key is not configured"))?;
let client = media_http_client();
let response = build_request(client)
.header("Authorization", &auth)
.send()
.await
.map_err(|e| anyhow::anyhow!("{error_context} request failed: {e}"))?;
check_response(response, error_context).await
}
pub async fn post_json_to_provider(
url: &str,
body: &serde_json::Value,
error_context: &str,
) -> anyhow::Result<serde_json::Value> {
let response = provider_request(error_context, |client| client.post(url).json(body)).await?;
let body_text = response
.text()
.await
.map_err(|e| anyhow::anyhow!("{error_context} failed to read response body: {e}"))?;
parse_json_response(&body_text, error_context)
}
pub async fn get_json_from_provider(
url: &str,
error_context: &str,
) -> anyhow::Result<serde_json::Value> {
let response = provider_request(error_context, |client| client.get(url)).await?;
let body_text = response
.text()
.await
.map_err(|e| anyhow::anyhow!("{error_context} failed to read response body: {e}"))?;
parse_json_response(&body_text, error_context)
}
pub async fn get_bytes_from_provider(
url: &str,
error_context: &str,
request_timeout: Duration,
) -> anyhow::Result<Vec<u8>> {
let response = provider_request(error_context, |client| {
client.get(url).timeout(request_timeout)
})
.await?;
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| anyhow::anyhow!("{error_context} failed to read response body: {e}"))
}
#[must_use]
pub fn media_http_client() -> &'static reqwest::Client {
MEDIA_HTTP_CLIENT.get_or_init(|| build_http_client(Duration::from_mins(2)))
}
#[must_use]
pub fn image_gen_http_client() -> &'static reqwest::Client {
IMAGE_GEN_HTTP_CLIENT.get_or_init(|| build_http_client(Duration::from_mins(10)))
}
#[must_use]
pub fn build_http_client(timeout: Duration) -> reqwest::Client {
install_ring_provider();
reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(Duration::from_secs(10))
.build()
.expect("Failed to build HTTP client (rustls TLS initialization failure)")
}
pub(crate) fn build_download_client(total_timeout: Duration) -> anyhow::Result<reqwest::Client> {
install_ring_provider();
reqwest::Client::builder()
.timeout(total_timeout)
.connect_timeout(Duration::from_secs(30))
.build()
.map_err(anyhow::Error::from)
}
pub(crate) enum DownloadSizeCheck {
Exact,
Min(u64),
None,
}
pub(crate) async fn download_verified(
client: &reqwest::Client,
url: &str,
dest: &Path,
expected_sha256: &str,
timeout: Option<Duration>,
size_check: DownloadSizeCheck,
mut progress: impl FnMut(u64, u64),
) -> anyhow::Result<()> {
use anyhow::Context as _;
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
let mut request = client.get(url);
if let Some(t) = timeout {
request = request.timeout(t);
}
let response = request
.send()
.await
.context("Failed to send download request")?;
if !response.status().is_success() {
anyhow::bail!("HTTP {} from {url}", response.status());
}
let content_length = response.content_length();
let total_size = content_length.unwrap_or(0);
progress(0, total_size);
let tmp = dest.with_extension("tmp");
let mut file = tokio::fs::File::create(&tmp)
.await
.context("Failed to create temp file")?;
let mut hasher = (!expected_sha256.is_empty()).then_some(Sha256::new());
let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Download stream error")?;
let len = chunk.len() as u64;
downloaded += len;
if let Some(h) = &mut hasher {
h.update(&chunk);
}
file.write_all(&chunk)
.await
.context("Failed to write download chunk")?;
progress(downloaded, total_size);
}
file.flush().await?;
file.sync_all().await?;
drop(file);
match size_check {
DownloadSizeCheck::Exact => {
if let Some(expected) = content_length
&& downloaded != expected
{
let _ = tokio::fs::remove_file(&tmp).await;
anyhow::bail!(
"Download size mismatch: expected {expected} bytes, got {downloaded} bytes"
);
}
}
DownloadSizeCheck::Min(min) if downloaded < min => {
let _ = tokio::fs::remove_file(&tmp).await;
anyhow::bail!("Downloaded file too small: {downloaded} bytes");
}
DownloadSizeCheck::Min(_) | DownloadSizeCheck::None => {}
}
if let Some(h) = hasher {
let actual_hash = format!("{:x}", h.finalize());
if actual_hash != expected_sha256 {
let _ = tokio::fs::remove_file(&tmp).await;
anyhow::bail!(
"SHA256 mismatch for {}: expected {expected_sha256}, got {actual_hash}",
dest.display()
);
}
}
tokio::fs::rename(&tmp, dest)
.await
.with_context(|| format!("Failed to rename temp file to {}", dest.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn check_response_returns_http_error_on_non_2xx() {
let http_resp = http::Response::builder()
.status(402)
.body("Insufficient credits: please top up your account".to_string())
.unwrap();
let resp = reqwest::Response::from(http_resp);
let result = check_response(resp, "Video generation submission").await;
assert!(result.is_err(), "expected error for 402 status");
assert_eq!(
result
.unwrap_err()
.downcast_ref::<crate::util::error::HttpError>()
.map(|e| e.status),
Some(402),
);
}
#[tokio::test]
async fn check_response_truncates_long_body() {
let long_body = "x".repeat(1000);
let http_resp = http::Response::builder()
.status(400)
.body(long_body.clone())
.unwrap();
let resp = reqwest::Response::from(http_resp);
let result = check_response(resp, "test").await;
assert!(result.is_err());
let err = result.unwrap_err();
let http_err = err.downcast_ref::<crate::util::error::HttpError>().unwrap();
assert!(
http_err.body.len() <= 503,
"body should be truncated, got {} bytes",
http_err.body.len()
);
assert!(
http_err.body.len() < long_body.len(),
"truncated body ({}) should be shorter than original ({})",
http_err.body.len(),
long_body.len(),
);
assert!(
http_err.body.ends_with('…'),
"truncated body should end with ellipsis"
);
assert_eq!(http_err.status, 400);
}
#[tokio::test]
async fn check_response_returns_ok_on_2xx() {
let http_resp = http::Response::builder()
.status(200)
.body(r#"{"ok": true}"#.to_string())
.unwrap();
let resp = reqwest::Response::from(http_resp);
let result = check_response(resp, "test").await;
assert!(result.is_ok(), "expected success for 200 status");
}
#[test]
fn client_build_installs_ring_provider() {
install_ring_provider();
let _client = build_http_client(Duration::from_secs(5));
let _ = build_download_client(Duration::from_mins(1)).expect("download client builds");
}
}