Skip to main content

conjure_http/client/
runtime.rs

1// Copyright 2025 Palantir Technologies, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//! Runtime configuration for Conjure clients.
15
16use crate::client::encoding::{JsonEncoding, SmileEncoding};
17use crate::encoding::Encoding;
18use conjure_error::Error;
19use conjure_object::log_safety::AssertLogSafe;
20use http::header::CONTENT_TYPE;
21use http::{HeaderMap, HeaderValue};
22use mediatype::MediaType;
23use std::fmt;
24use std::io::Write;
25
26/// A type providing client logic that is configured at runtime.
27#[derive(Debug)]
28pub struct ConjureRuntime {
29    request_encoding: DebugEncoding,
30    accept_encodings: Vec<DebugEncoding>,
31    accept: HeaderValue,
32}
33
34struct DebugEncoding(Box<dyn Encoding + Sync + Send>);
35
36impl fmt::Debug for DebugEncoding {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        fmt::Debug::fmt(&self.0.content_type(), f)
39    }
40}
41
42impl ConjureRuntime {
43    /// Creates a new runtime with default settings.
44    pub fn new() -> Self {
45        Self::builder().build()
46    }
47
48    /// Creates a new builder.
49    pub fn builder() -> Builder {
50        Builder {
51            request_encoding: None,
52            accept_encodings: vec![],
53        }
54    }
55
56    /// Returns an `Accept` header value based on the configured accept encodings.
57    pub fn accept(&self) -> HeaderValue {
58        self.accept.clone()
59    }
60
61    /// Returns the configured request body [`Encoding`].
62    pub fn request_body_encoding(&self) -> &(dyn Encoding + Sync + Send) {
63        &*self.request_encoding.0
64    }
65
66    /// Returns the appropriate [`Encoding`] to deserialize the response body.
67    ///
68    /// The implementation currently compares the response's `Content-Type` header against [`Encoding::content_type`],
69    /// ignoring parameters.
70    pub fn response_body_encoding(
71        &self,
72        headers: &HeaderMap,
73    ) -> Result<&(dyn Encoding + Sync + Send), Error> {
74        let content_mime = headers
75            .get(CONTENT_TYPE)
76            .ok_or_else(|| Error::internal_safe("response missing Content-Type header"))
77            .and_then(|h| h.to_str().map_err(Error::internal_safe))
78            .and_then(|s| MediaType::parse(s).map_err(Error::internal_safe))?;
79
80        for encoding in &self.accept_encodings {
81            let encoding_type = encoding.0.content_type();
82            let Some(encoding_mime) = encoding_type
83                .to_str()
84                .ok()
85                .and_then(|s| MediaType::parse(s).ok())
86            else {
87                continue;
88            };
89
90            // We're ignoring parameters for now
91            if content_mime.essence() == encoding_mime.essence() {
92                return Ok(&*encoding.0);
93            }
94        }
95
96        Err(
97            Error::internal_safe("encoding not found for response body Content-Type")
98                .with_safe_param("Content-Type", AssertLogSafe(content_mime.to_string())),
99        )
100    }
101}
102
103impl Default for ConjureRuntime {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109/// A builder for [`ConjureRuntime`].
110pub struct Builder {
111    request_encoding: Option<Box<dyn Encoding + Sync + Send>>,
112    accept_encodings: Vec<(Box<dyn Encoding + Sync + Send>, f32)>,
113}
114
115impl Builder {
116    /// Sets the encoding for serializable request bodies.
117    ///
118    /// The runtime defaults to using [`JsonEncoding`].
119    pub fn request_encoding(mut self, encoding: impl Encoding + 'static + Sync + Send) -> Self {
120        self.request_encoding = Some(Box::new(encoding));
121        self
122    }
123
124    /// Adds an encoding used for serializable response bodies with the specified weight.
125    ///
126    /// The runtime defaults to using [`SmileEncoding`] with weight 1 and [`JsonEncoding`] with weight 0.9 if none are
127    /// explicitly registered.
128    ///
129    /// # Panics
130    ///
131    /// Panics if the weight is not between 0 and 1, inclusive.
132    pub fn accept_encoding(
133        mut self,
134        encoding: impl Encoding + 'static + Sync + Send,
135        weight: f32,
136    ) -> Self {
137        assert!(
138            (0. ..=1.).contains(&weight),
139            "weight must be between 0 and 1",
140        );
141        self.accept_encodings.push((Box::new(encoding), weight));
142        self
143    }
144
145    /// Builds the [`ConjureRuntime`].
146    pub fn build(self) -> ConjureRuntime {
147        let request_encoding = DebugEncoding(
148            self.request_encoding
149                .unwrap_or_else(|| Box::new(JsonEncoding)),
150        );
151
152        let mut accept_encodings = if self.accept_encodings.is_empty() {
153            vec![
154                (Box::new(SmileEncoding) as _, 1.),
155                (Box::new(JsonEncoding) as _, 0.9),
156            ]
157        } else {
158            self.accept_encodings
159        };
160
161        // Sort descending by weight
162        accept_encodings.sort_by(|a, b| a.1.total_cmp(&b.1).reverse());
163
164        let mut accept = vec![];
165        for (i, (encoding, weight)) in accept_encodings.iter().enumerate() {
166            if i != 0 {
167                accept.extend_from_slice(b", ");
168            }
169
170            accept.extend_from_slice(encoding.content_type().as_bytes());
171
172            if *weight == 0. {
173                accept.extend_from_slice(b"; q=0");
174            } else if *weight != 1. {
175                write!(accept, "; q={weight:.3}").unwrap();
176
177                // `{weight:.3}` will always output 3 decimal digits, so pop off trailing 0s
178                while accept.pop_if(|b| *b == b'0').is_some() {}
179            }
180        }
181
182        let accept_encodings = accept_encodings
183            .into_iter()
184            .map(|(e, _)| DebugEncoding(e))
185            .collect();
186        let accept = HeaderValue::try_from(accept).unwrap();
187
188        ConjureRuntime {
189            request_encoding,
190            accept_encodings,
191            accept,
192        }
193    }
194}
195
196#[cfg(test)]
197mod test {
198    use super::*;
199
200    #[test]
201    fn basics() {
202        let runtime = ConjureRuntime::new();
203
204        assert_eq!(
205            runtime.accept(),
206            "application/x-jackson-smile, application/json; q=0.9"
207        );
208
209        let cases = [
210            (None, Err(())),
211            (Some("application/json"), Ok("application/json")),
212            (
213                Some("application/json; encoding=utf-8"),
214                Ok("application/json"),
215            ),
216            (
217                Some("application/x-jackson-smile"),
218                Ok("application/x-jackson-smile"),
219            ),
220            (Some("application/cbor"), Err(())),
221            (Some("application/*"), Err(())),
222            (Some("*/*"), Err(())),
223        ];
224
225        for (content_type, result) in cases {
226            let mut headers = HeaderMap::new();
227            if let Some(content_type) = content_type {
228                headers.insert(CONTENT_TYPE, HeaderValue::from_str(content_type).unwrap());
229            }
230
231            match (result, runtime.response_body_encoding(&headers)) {
232                (Ok(expected), Ok(encoding)) => assert_eq!(encoding.content_type(), expected),
233                (Ok(expected), Err(e)) => panic!("expected Ok({expected}), got Err({e:?})"),
234                (Err(()), Err(_)) => {}
235                (Err(()), Ok(encoding)) => {
236                    panic!("expected Err(), got Ok({:?}", encoding.content_type())
237                }
238            }
239        }
240    }
241
242    #[test]
243    fn q_values() {
244        let runtime = ConjureRuntime::builder()
245            .accept_encoding(SmileEncoding, 0.)
246            .accept_encoding(JsonEncoding, 1. / 3.)
247            .build();
248
249        assert_eq!(
250            runtime.accept(),
251            "application/json; q=0.333, application/x-jackson-smile; q=0"
252        )
253    }
254}