icap_rs/client/builder.rs
1use crate::client::options_cache::{OptionsCache, OptionsCacheConfig};
2use crate::client::timeouts::ClientTimeouts;
3use crate::client::{Client, ClientRef, parse_authority_with_scheme};
4
5#[cfg(feature = "tls-rustls")]
6use crate::tls::ClientTlsConfig;
7use crate::{Error, IcapResult};
8use http::{HeaderMap, HeaderName, HeaderValue};
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::Mutex;
12
13/// Credentials for `Proxy-Authorization: Basic` authentication (RFC 3507 §7.1).
14///
15/// Supply via [`ClientBuilder::proxy_auth`]. When the ICAP server responds
16/// with `407 Proxy Authentication Required`, the client retries the request
17/// once with a `Proxy-Authorization: Basic <base64(username:password)>` header.
18///
19/// # Examples
20///
21/// ```
22/// use icap_rs::{Client, ProxyAuth};
23///
24/// let client = Client::builder()
25/// .host("127.0.0.1")
26/// .proxy_auth("user", "secret")
27/// .build();
28/// ```
29#[derive(Debug, Clone)]
30pub struct ProxyAuth {
31 pub(crate) username: String,
32 pub(crate) password: String,
33}
34
35/// Policy for connection lifetime management.
36///
37/// - [`ConnectionPolicy::Close`] — close the TCP connection after every request.
38/// - [`ConnectionPolicy::KeepAlive`] — keep a single idle connection and reuse it.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub enum ConnectionPolicy {
41 #[default]
42 Close,
43 KeepAlive,
44}
45
46/// Builder for [`Client`]. Use it to configure host/port, headers, keep-alive,
47/// timeouts, and other options before creating a client instance.
48///
49/// By default:
50/// - `ConnectionPolicy` is `Close`;
51/// - no host/port are set until you call [`ClientBuilder::host`] / [`ClientBuilder::port`]
52/// or [`ClientBuilder::with_uri`].
53/// - no operation timeouts are applied unless configured explicitly.
54#[derive(Debug, Default)]
55#[must_use]
56pub struct ClientBuilder {
57 host: Option<String>,
58 port: Option<u16>,
59 host_override: Option<String>,
60 default_headers: HeaderMap,
61 connection_policy: ConnectionPolicy,
62 timeouts: ClientTimeouts,
63 max_response_header_bytes: Option<usize>,
64
65 // OPTIONS cache. `None` keeps the legacy behavior (no automatic OPTIONS).
66 options_cache: Option<OptionsCacheConfig>,
67
68 // Proxy authentication credentials. `None` → no automatic 407 retry.
69 proxy_auth: Option<ProxyAuth>,
70
71 // TLS state. `tls` is the user-supplied config (explicit `with_tls`),
72 // `auto_tls` records whether `with_uri("icaps://...")` requested TLS.
73 // At build time, an explicit config always wins; otherwise auto-TLS
74 // defaults to native roots.
75 #[cfg(feature = "tls-rustls")]
76 tls: Option<ClientTlsConfig>,
77 #[cfg(feature = "tls-rustls")]
78 auto_tls: bool,
79}
80
81impl ClientBuilder {
82 /// Create a new `ClientBuilder` with default settings.
83 pub fn new() -> Self {
84 Self::default()
85 }
86
87 /// Set ICAP server host (hostname or IP).
88 pub fn host(mut self, host: &str) -> Self {
89 self.host = Some(host.to_string());
90 self
91 }
92
93 /// Set ICAP server TCP port (default 1344 if not set).
94 pub const fn port(mut self, port: u16) -> Self {
95 self.port = Some(port);
96 self
97 }
98
99 /// Override the `Host:` header value sent in ICAP requests.
100 ///
101 /// This does not change the actual remote address used for the TCP connection,
102 /// only the value of the `Host` ICAP header.
103 pub fn host_override(mut self, host: &str) -> Self {
104 self.host_override = Some(host.to_string());
105 self
106 }
107
108 /// Insert a default ICAP header that will be sent with every request.
109 pub fn default_header(mut self, name: &str, value: &str) -> IcapResult<Self> {
110 let n: HeaderName = name.parse()?;
111 let v: HeaderValue = HeaderValue::from_str(value)?;
112 self.default_headers.insert(n, v);
113 Ok(self)
114 }
115
116 /// Tries to set the ICAP `User-Agent` header for all requests created by this client.
117 ///
118 /// A per-request override via `Request::icap_header("User-Agent", "...")`
119 /// takes precedence over the value set here.
120 /// Prefer this fallible variant when the value comes from user input.
121 pub fn try_user_agent(mut self, user_agent: &str) -> IcapResult<Self> {
122 self.default_headers.insert(
123 HeaderName::from_static("user-agent"),
124 HeaderValue::from_str(user_agent)?,
125 );
126 Ok(self)
127 }
128
129 /// Sets the ICAP `User-Agent` header for all requests created by this client.
130 ///
131 /// A per-request override via `Request::icap_header("User-Agent", "...")`
132 /// takes precedence over the value set here.
133 ///
134 /// # Example
135 /// ```
136 /// use icap_rs::Client;
137 /// let client = Client::builder()
138 /// .host("icap.example")
139 /// .port(1344)
140 /// .user_agent("my-app/1.2.3")
141 /// .build();
142 /// ```
143 ///
144 /// Invalid header values are silently dropped; use
145 /// [`ClientBuilder::try_user_agent`] when validation is required.
146 pub fn user_agent(mut self, user_agent: &str) -> Self {
147 if let Ok(v) = HeaderValue::from_str(user_agent) {
148 self.default_headers
149 .insert(HeaderName::from_static("user-agent"), v);
150 }
151 self
152 }
153
154 /// Enable or disable connection reuse (keep-alive).
155 pub const fn keep_alive(mut self, yes: bool) -> Self {
156 self.connection_policy = if yes {
157 ConnectionPolicy::KeepAlive
158 } else {
159 ConnectionPolicy::Close
160 };
161 self
162 }
163
164 /// Install a full [`ClientTimeouts`] configuration in one shot.
165 ///
166 /// Replaces any per-field timeouts set via [`Self::timeout`],
167 /// [`Self::connect_timeout`], [`Self::write_timeout`], or
168 /// [`Self::continue_timeout`]. Per-field setters called *after*
169 /// `with_timeouts` continue to mutate the same struct, so
170 ///
171 /// ```
172 /// use std::time::Duration;
173 /// use icap_rs::{Client, ClientTimeouts};
174 ///
175 /// let tos = ClientTimeouts::default();
176 /// let d = Duration::from_secs(3);
177 /// let _client = Client::builder()
178 /// .host("icap.example")
179 /// .with_timeouts(tos)
180 /// .connect_timeout(Some(d))
181 /// .build();
182 /// ```
183 ///
184 /// is equivalent to mutating `tos.connect` before passing it in.
185 pub const fn with_timeouts(mut self, timeouts: ClientTimeouts) -> Self {
186 self.timeouts = timeouts;
187 self
188 }
189
190 /// Set a timeout for the whole client send operation.
191 ///
192 /// This is an outer deadline around connect, optional TLS handshake, writes,
193 /// Preview negotiation, and final response reads. More specific timeouts may
194 /// still fire first.
195 pub const fn timeout(mut self, dur: Option<Duration>) -> Self {
196 self.timeouts.operation = dur;
197 self
198 }
199
200 /// Set a timeout for establishing the TCP connection.
201 ///
202 /// For `icaps://`, this covers TCP connect only. TLS handshakes are governed
203 /// by `ClientTlsConfig::with_handshake_timeout`.
204 pub const fn connect_timeout(mut self, dur: Option<Duration>) -> Self {
205 self.timeouts.connect = dur;
206 self
207 }
208
209 /// Set a timeout for writing ICAP request bytes to the network.
210 ///
211 /// This covers request headers, preview markers, body chunks, and flushes.
212 /// It does not limit reading from the caller-provided body source.
213 pub const fn write_timeout(mut self, dur: Option<Duration>) -> Self {
214 self.timeouts.write = dur;
215 self
216 }
217
218 /// Set a timeout for Preview decision responses.
219 ///
220 /// When a request uses ICAP Preview, the client waits for either
221 /// `100 Continue` or an early final response before sending the remainder.
222 /// If this timeout is not set, only the outer [`timeout`](Self::timeout)
223 /// applies when configured.
224 pub const fn continue_timeout(mut self, dur: Option<Duration>) -> Self {
225 self.timeouts.continue_after_preview = dur;
226 self
227 }
228
229 /// Set the maximum ICAP response header block size, in bytes.
230 ///
231 /// The limit includes the status line, all ICAP header lines, and the
232 /// terminating `CRLFCRLF`. The default is 64 KiB. Oversized response
233 /// headers are reported as protocol header errors instead of generic I/O
234 /// failures.
235 pub const fn with_response_header_limit(mut self, bytes: usize) -> Self {
236 self.max_response_header_bytes = Some(bytes);
237 self
238 }
239
240 /// Enable client-side caching of `OPTIONS` responses (RFC 3507 §4.10 / §5).
241 ///
242 /// When enabled, the client fetches `OPTIONS` for a service once and reuses
243 /// it for subsequent `REQMOD`/`RESPMOD` requests until it expires. The
244 /// lifetime comes from the server's `Options-TTL` header, falling back to
245 /// [`OptionsCacheConfig::default_ttl`] when the header is absent; with
246 /// neither, the response is not cached. A changed `ISTag` on a later
247 /// modification response invalidates the cached entry.
248 ///
249 /// Caching is opt-in: without this call the client never sends `OPTIONS`
250 /// automatically.
251 ///
252 /// # Examples
253 ///
254 /// ```
255 /// use std::time::Duration;
256 /// use icap_rs::{Client, OptionsCacheConfig};
257 ///
258 /// let client = Client::builder()
259 /// .host("127.0.0.1")
260 /// .with_options_cache(OptionsCacheConfig::new().with_default_ttl(Duration::from_secs(60)))
261 /// .build();
262 /// ```
263 pub const fn with_options_cache(mut self, config: OptionsCacheConfig) -> Self {
264 self.options_cache = Some(config);
265 self
266 }
267
268 /// Configure proxy authentication credentials (RFC 3507 §7.1).
269 ///
270 /// When the ICAP server responds with `407 Proxy Authentication Required`,
271 /// the client retries the request exactly once with a
272 /// `Proxy-Authorization: Basic <base64(username:password)>` header.
273 ///
274 /// If the retry also yields a `407` (wrong credentials), the error response
275 /// is returned to the caller as-is.
276 ///
277 /// # Examples
278 ///
279 /// ```
280 /// use icap_rs::Client;
281 ///
282 /// let client = Client::builder()
283 /// .host("proxy.example.com")
284 /// .proxy_auth("alice", "hunter2")
285 /// .build();
286 /// ```
287 pub fn proxy_auth(mut self, username: &str, password: &str) -> Self {
288 self.proxy_auth = Some(ProxyAuth {
289 username: username.to_string(),
290 password: password.to_string(),
291 });
292 self
293 }
294
295 /// Configure the builder from an ICAP URI (`icap://...` or `icaps://...`).
296 ///
297 /// This extracts `host` and `port` for use in the TCP connection. The service
298 /// path, if present in the URI, is ignored here and should be set on the
299 /// request itself. `icaps://` implicitly enables TLS using
300 /// `ClientTlsConfig::with_native_roots`; call `with_tls` before or after
301 /// `with_uri` to override the default TLS configuration.
302 ///
303 /// The default port is `1344` for `icap://` and `11344` for `icaps://`.
304 pub fn with_uri(mut self, uri: &str) -> IcapResult<Self> {
305 let (host, port, tls) = parse_authority_with_scheme(uri)?;
306 self.host = Some(host);
307 self.port = Some(port);
308 if tls {
309 #[cfg(feature = "tls-rustls")]
310 {
311 self.auto_tls = true;
312 }
313 #[cfg(not(feature = "tls-rustls"))]
314 {
315 return Err(Error::service(
316 "`icaps://` requested but crate built without TLS features",
317 ));
318 }
319 }
320 Ok(self)
321 }
322
323 /// Enable TLS using the supplied [`ClientTlsConfig`].
324 ///
325 /// Always wins over the implicit configuration enabled by
326 /// [`with_uri("icaps://…")`](Self::with_uri).
327 #[cfg(feature = "tls-rustls")]
328 pub fn with_tls(mut self, config: ClientTlsConfig) -> Self {
329 self.tls = Some(config);
330 self
331 }
332
333 /// Build a [`Client`], returning an error when required configuration is missing.
334 ///
335 /// # Errors
336 ///
337 /// Returns an error if `host` was not set via [`ClientBuilder::host`] or
338 /// [`ClientBuilder::with_uri`].
339 pub fn try_build(self) -> IcapResult<Client> {
340 let host = self
341 .host
342 .clone()
343 .ok_or_else(|| Error::service("ClientBuilder: host is required"))?;
344 Ok(self.finish_with_host(host))
345 }
346
347 /// Build a [`Client`], defaulting `host` to `"127.0.0.1"` when unset.
348 ///
349 /// Use [`ClientBuilder::try_build`] when missing configuration should be
350 /// reported as an error instead of being silently defaulted.
351 pub fn build(self) -> Client {
352 let host = self.host.clone().unwrap_or_else(|| "127.0.0.1".to_string());
353 self.finish_with_host(host)
354 }
355
356 fn finish_with_host(self, host: String) -> Client {
357 let port = self.port.unwrap_or(1344);
358
359 #[cfg(feature = "tls-rustls")]
360 let tls = {
361 let cfg = match (self.tls, self.auto_tls) {
362 (Some(cfg), _) => Some(cfg),
363 (None, true) => Some(ClientTlsConfig::with_native_roots()),
364 (None, false) => None,
365 };
366 cfg.map(ClientTlsConfig::into_connector)
367 };
368
369 let options_cache = self.options_cache.map(OptionsCache::new);
370
371 Client {
372 inner: Arc::new(ClientRef {
373 host,
374 port,
375 host_override: self.host_override,
376 default_headers: self.default_headers,
377 connection_policy: self.connection_policy,
378 timeouts: self.timeouts,
379 max_response_header_bytes: self
380 .max_response_header_bytes
381 .unwrap_or(crate::DEFAULT_ICAP_HEADER_BYTES),
382 #[cfg(feature = "tls-rustls")]
383 tls,
384 idle_conn: Mutex::new(None),
385 options_cache,
386 proxy_auth: self.proxy_auth,
387 }),
388 }
389 }
390}