use crate::{ClientError, Result};
use http::Uri;
use serde::Deserialize;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientConfig {
pub server_url: String,
pub auth_token: Option<String>,
#[serde(default)]
pub request_timeout_ms: Option<u64>,
#[serde(default)]
pub disable_transient_retry: bool,
#[serde(default)]
pub ca_cert_path: Option<String>,
}
impl ClientConfig {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let bytes =
fs::read(path.as_ref()).map_err(|err| ClientError::ConfigIo(err.to_string()))?;
let config: Self = toml::from_str(
std::str::from_utf8(&bytes)
.map_err(|err| ClientError::ConfigDecode(err.to_string()))?,
)
.map_err(|err| ClientError::ConfigDecode(err.to_string()))?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
validate_absolute_http_url("server_url", &self.server_url)?;
if let Some(token) = &self.auth_token {
if token.trim().is_empty() {
return Err(ClientError::ConfigValidation {
field: "auth_token",
reason: "must not be empty".to_owned(),
});
}
}
if self.request_timeout_ms == Some(0) {
return Err(ClientError::ConfigValidation {
field: "request_timeout_ms",
reason: "must be greater than zero; omit it for no deadline".to_owned(),
});
}
if let Some(path) = &self.ca_cert_path {
if path.trim().is_empty() {
return Err(ClientError::ConfigValidation {
field: "ca_cert_path",
reason: "must not be empty; omit it to trust only the platform roots"
.to_owned(),
});
}
}
Ok(())
}
pub(crate) fn extra_root_certificates(&self) -> Result<Vec<reqwest::Certificate>> {
let Some(path) = &self.ca_cert_path else {
return Ok(Vec::new());
};
let path = path.trim();
let pem = fs::read(path).map_err(|err| ClientError::ConfigValidation {
field: "ca_cert_path",
reason: format!("failed to read `{path}`: {err}"),
})?;
let certificates = reqwest::Certificate::from_pem_bundle(&pem).map_err(|err| {
ClientError::ConfigValidation {
field: "ca_cert_path",
reason: format!("`{path}` is not a PEM certificate bundle: {err}"),
}
})?;
if certificates.is_empty() {
return Err(ClientError::ConfigValidation {
field: "ca_cert_path",
reason: format!("`{path}` holds no CERTIFICATE section"),
});
}
Ok(certificates)
}
}
fn validate_absolute_http_url(field: &'static str, value: &str) -> Result<()> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ClientError::MissingConfigField { field });
}
let uri: Uri =
trimmed
.parse()
.map_err(|err: http::uri::InvalidUri| ClientError::ConfigValidation {
field,
reason: err.to_string(),
})?;
match uri.scheme_str() {
Some("http" | "https") => {}
Some(other) => {
return Err(ClientError::ConfigValidation {
field,
reason: format!("scheme must be http or https, got `{other}`"),
});
}
None => {
return Err(ClientError::ConfigValidation {
field,
reason: "must be an absolute http or https URL".to_owned(),
});
}
}
if uri.authority().is_none() {
return Err(ClientError::ConfigValidation {
field,
reason: "must be an absolute http or https URL".to_owned(),
});
}
Ok(())
}