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