Skip to main content

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}