1use crate::{ClientError, Result};
5use http::Uri;
6use serde::Deserialize;
7use std::fs;
8use std::path::Path;
9
10#[derive(Debug, Clone, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct ClientConfig {
18 pub server_url: String,
20 pub auth_token: Option<String>,
22 #[serde(default)]
27 pub request_timeout_ms: Option<u64>,
28 #[serde(default)]
38 pub disable_transient_retry: bool,
39 #[serde(default)]
44 pub ca_cert_path: Option<String>,
45}
46
47impl ClientConfig {
48 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
50 let bytes =
51 fs::read(path.as_ref()).map_err(|err| ClientError::ConfigIo(err.to_string()))?;
52 let config: Self = toml::from_str(
53 std::str::from_utf8(&bytes)
54 .map_err(|err| ClientError::ConfigDecode(err.to_string()))?,
55 )
56 .map_err(|err| ClientError::ConfigDecode(err.to_string()))?;
57 config.validate()?;
58 Ok(config)
59 }
60
61 pub fn validate(&self) -> Result<()> {
65 validate_absolute_http_url("server_url", &self.server_url)?;
66 if let Some(token) = &self.auth_token {
67 if token.trim().is_empty() {
68 return Err(ClientError::ConfigValidation {
69 field: "auth_token",
70 reason: "must not be empty".to_owned(),
71 });
72 }
73 }
74 if self.request_timeout_ms == Some(0) {
75 return Err(ClientError::ConfigValidation {
76 field: "request_timeout_ms",
77 reason: "must be greater than zero; omit it for no deadline".to_owned(),
78 });
79 }
80 if let Some(path) = &self.ca_cert_path {
81 if path.trim().is_empty() {
82 return Err(ClientError::ConfigValidation {
83 field: "ca_cert_path",
84 reason: "must not be empty; omit it to trust only the platform roots"
85 .to_owned(),
86 });
87 }
88 }
89 Ok(())
90 }
91
92 pub(crate) fn extra_root_certificates(&self) -> Result<Vec<reqwest::Certificate>> {
97 let Some(path) = &self.ca_cert_path else {
98 return Ok(Vec::new());
99 };
100 let path = path.trim();
101 let pem = fs::read(path).map_err(|err| ClientError::ConfigValidation {
102 field: "ca_cert_path",
103 reason: format!("failed to read `{path}`: {err}"),
104 })?;
105 let certificates = reqwest::Certificate::from_pem_bundle(&pem).map_err(|err| {
106 ClientError::ConfigValidation {
107 field: "ca_cert_path",
108 reason: format!("`{path}` is not a PEM certificate bundle: {err}"),
109 }
110 })?;
111 if certificates.is_empty() {
112 return Err(ClientError::ConfigValidation {
113 field: "ca_cert_path",
114 reason: format!("`{path}` holds no CERTIFICATE section"),
115 });
116 }
117 Ok(certificates)
118 }
119}
120
121fn validate_absolute_http_url(field: &'static str, value: &str) -> Result<()> {
122 let trimmed = value.trim();
123 if trimmed.is_empty() {
124 return Err(ClientError::MissingConfigField { field });
125 }
126
127 let uri: Uri =
128 trimmed
129 .parse()
130 .map_err(|err: http::uri::InvalidUri| ClientError::ConfigValidation {
131 field,
132 reason: err.to_string(),
133 })?;
134
135 match uri.scheme_str() {
136 Some("http" | "https") => {}
137 Some(other) => {
138 return Err(ClientError::ConfigValidation {
139 field,
140 reason: format!("scheme must be http or https, got `{other}`"),
141 });
142 }
143 None => {
144 return Err(ClientError::ConfigValidation {
145 field,
146 reason: "must be an absolute http or https URL".to_owned(),
147 });
148 }
149 }
150
151 if uri.authority().is_none() {
152 return Err(ClientError::ConfigValidation {
153 field,
154 reason: "must be an absolute http or https URL".to_owned(),
155 });
156 }
157
158 Ok(())
159}