Skip to main content

specmock_runtime/http/
negotiate.rs

1//! Content negotiation: `Prefer` header parsing and `Accept` media-type matching.
2
3use http::HeaderMap;
4
5/// Directives extracted from the `Prefer` and `Accept` request headers.
6#[derive(Debug, Clone, Default)]
7pub struct PreferDirectives {
8    /// Desired HTTP status code (`Prefer: code=404`).
9    pub code: Option<u16>,
10    /// Desired named example (`Prefer: example=notFound`).
11    pub example: Option<String>,
12    /// Desired media type from `Accept` header.
13    pub media_type: Option<String>,
14    /// Whether dynamic faker mode was requested (`Prefer: dynamic=true`).
15    pub dynamic: bool,
16}
17
18impl PreferDirectives {
19    /// Parse directives from HTTP request headers.
20    ///
21    /// Recognizes:
22    /// - `Prefer: code=<status>` — select a response by status code
23    /// - `Prefer: example=<name>` — select a named example
24    /// - `Prefer: dynamic=true`  — force faker-generated dynamic data
25    /// - `Accept` header         — preferred response media type
26    pub fn from_headers(headers: &HeaderMap) -> Self {
27        let mut directives = Self::default();
28
29        // Parse all `Prefer` header values (may appear multiple times).
30        for value in headers.get_all("prefer") {
31            let Ok(text) = value.to_str() else {
32                continue;
33            };
34            parse_prefer_value(text, &mut directives);
35        }
36
37        // Extract media type from `Accept` header.
38        if let Some(accept) = headers.get("accept").and_then(|v| v.to_str().ok()) {
39            directives.media_type = best_media_type(accept);
40        }
41
42        directives
43    }
44}
45
46/// Parse a single `Prefer` header value which may contain comma-separated directives.
47fn parse_prefer_value(text: &str, directives: &mut PreferDirectives) {
48    for segment in text.split(',') {
49        let trimmed = segment.trim();
50        if let Some((key, value)) = trimmed.split_once('=') {
51            let key = key.trim();
52            let value = value.trim();
53            match key {
54                "code" => {
55                    directives.code = value.parse::<u16>().ok();
56                }
57                "example" if !value.is_empty() => {
58                    directives.example = Some(value.to_owned());
59                }
60                "dynamic" => {
61                    directives.dynamic = value.eq_ignore_ascii_case("true");
62                }
63                _ => {}
64            }
65        }
66    }
67}
68
69/// Parsed entry from an `Accept` header (e.g. `application/json;q=0.9`).
70#[derive(Debug)]
71struct MediaEntry {
72    media_type: String,
73    quality: f32,
74}
75
76/// Select the best media type from an `Accept` header value.
77///
78/// Returns the highest-quality non-wildcard type, or `None` when the header is
79/// empty/unparseable.
80fn best_media_type(accept: &str) -> Option<String> {
81    let mut entries: Vec<MediaEntry> = accept
82        .split(',')
83        .filter_map(|segment| {
84            let segment = segment.trim();
85            if segment.is_empty() {
86                return None;
87            }
88
89            let (media_type, params) = segment.split_once(';').unwrap_or((segment, ""));
90
91            let quality = params
92                .split(';')
93                .chain(std::iter::once(params))
94                .find_map(|part| {
95                    let part = part.trim();
96                    part.strip_prefix("q=").and_then(|q| q.trim().parse::<f32>().ok())
97                })
98                .unwrap_or(1.0);
99
100            Some(MediaEntry { media_type: media_type.trim().to_owned(), quality })
101        })
102        .collect();
103
104    entries.sort_by(|a, b| b.quality.partial_cmp(&a.quality).unwrap_or(std::cmp::Ordering::Equal));
105    entries.into_iter().map(|e| e.media_type).find(|mt| mt != "*/*")
106}
107
108use super::openapi::ResponseSpec;
109
110/// Select the best `ResponseSpec` given the caller's preferences.
111///
112/// Priority order:
113/// 1. Exact status-code match when `prefer.code` is set.
114/// 2. First 2xx response.
115/// 3. `default` response.
116/// 4. First declared response.
117pub fn select_response<'a>(
118    responses: &'a [ResponseSpec],
119    prefer: &PreferDirectives,
120) -> Option<&'a ResponseSpec> {
121    if let Some(code) = prefer.code {
122        let code_str = code.to_string();
123        if let Some(found) = responses.iter().find(|r| r.status == code_str) {
124            return Some(found);
125        }
126    }
127
128    responses
129        .iter()
130        .find(|r| r.status == "200")
131        .or_else(|| responses.iter().find(|r| r.status.starts_with('2')))
132        .or_else(|| responses.iter().find(|r| r.status == "default"))
133        .or_else(|| responses.first())
134}
135
136/// Pick the best media type from a set of available types given an `Accept`
137/// header.
138///
139/// Returns `None` when no acceptable match is found.
140pub fn negotiate_media_type(available: &[String], accept_header: Option<&str>) -> Option<String> {
141    let Some(accept) = accept_header else {
142        return available.first().cloned();
143    };
144
145    // Build priority-sorted list of requested types.
146    let mut requested: Vec<MediaEntry> = accept
147        .split(',')
148        .filter_map(|segment| {
149            let segment = segment.trim();
150            if segment.is_empty() {
151                return None;
152            }
153            let (media_type, params) = segment.split_once(';').unwrap_or((segment, ""));
154            let quality = params
155                .split(';')
156                .chain(std::iter::once(params))
157                .find_map(|part| {
158                    let part = part.trim();
159                    part.strip_prefix("q=").and_then(|q| q.trim().parse::<f32>().ok())
160                })
161                .unwrap_or(1.0);
162            Some(MediaEntry { media_type: media_type.trim().to_owned(), quality })
163        })
164        .collect();
165
166    requested
167        .sort_by(|a, b| b.quality.partial_cmp(&a.quality).unwrap_or(std::cmp::Ordering::Equal));
168
169    for entry in &requested {
170        if entry.media_type == "*/*" {
171            return available.first().cloned();
172        }
173        if available.iter().any(|a| a == &entry.media_type) {
174            return Some(entry.media_type.clone());
175        }
176    }
177
178    None
179}
180
181#[cfg(test)]
182mod tests {
183    use std::collections::HashMap;
184
185    use http::HeaderMap;
186
187    use super::*;
188
189    // ── PreferDirectives::from_headers ─────────────────────────────────
190
191    #[test]
192    fn parses_empty_headers() {
193        let headers = HeaderMap::new();
194        let prefer = PreferDirectives::from_headers(&headers);
195        assert!(prefer.code.is_none());
196        assert!(prefer.example.is_none());
197        assert!(prefer.media_type.is_none());
198        assert!(!prefer.dynamic);
199    }
200
201    #[test]
202    #[expect(clippy::unwrap_used, reason = "test code: header value is a valid str literal")]
203    fn parses_single_prefer_code() {
204        let mut headers = HeaderMap::new();
205        headers.insert("prefer", "code=404".parse().unwrap());
206        let prefer = PreferDirectives::from_headers(&headers);
207        assert_eq!(prefer.code, Some(404));
208    }
209
210    #[test]
211    #[expect(clippy::unwrap_used, reason = "test code: header value is a valid str literal")]
212    fn parses_prefer_example() {
213        let mut headers = HeaderMap::new();
214        headers.insert("prefer", "example=notFound".parse().unwrap());
215        let prefer = PreferDirectives::from_headers(&headers);
216        assert_eq!(prefer.example.as_deref(), Some("notFound"));
217    }
218
219    #[test]
220    #[expect(clippy::unwrap_used, reason = "test code: header value is a valid str literal")]
221    fn parses_prefer_dynamic() {
222        let mut headers = HeaderMap::new();
223        headers.insert("prefer", "dynamic=true".parse().unwrap());
224        let prefer = PreferDirectives::from_headers(&headers);
225        assert!(prefer.dynamic);
226    }
227
228    #[test]
229    #[expect(clippy::unwrap_used, reason = "test code: header value is a valid str literal")]
230    fn parses_combined_prefer_directives() {
231        let mut headers = HeaderMap::new();
232        headers.insert("prefer", "code=500, example=serverError, dynamic=true".parse().unwrap());
233        let prefer = PreferDirectives::from_headers(&headers);
234        assert_eq!(prefer.code, Some(500));
235        assert_eq!(prefer.example.as_deref(), Some("serverError"));
236        assert!(prefer.dynamic);
237    }
238
239    #[test]
240    #[expect(clippy::unwrap_used, reason = "test code: header value is a valid str literal")]
241    fn parses_accept_header() {
242        let mut headers = HeaderMap::new();
243        headers.insert("accept", "application/xml;q=0.5, application/json".parse().unwrap());
244        let prefer = PreferDirectives::from_headers(&headers);
245        assert_eq!(prefer.media_type.as_deref(), Some("application/json"));
246    }
247
248    // ── select_response ────────────────────────────────────────────────
249
250    fn make_responses() -> Vec<ResponseSpec> {
251        vec![
252            ResponseSpec {
253                status: "200".into(),
254                schema: None,
255                example: None,
256                named_examples: HashMap::new(),
257            },
258            ResponseSpec {
259                status: "404".into(),
260                schema: None,
261                example: None,
262                named_examples: HashMap::new(),
263            },
264            ResponseSpec {
265                status: "500".into(),
266                schema: None,
267                example: None,
268                named_examples: HashMap::new(),
269            },
270        ]
271    }
272
273    #[test]
274    fn select_response_prefers_requested_code() {
275        let responses = make_responses();
276        let prefer = PreferDirectives { code: Some(404), ..Default::default() };
277        let selected = select_response(&responses, &prefer);
278        assert_eq!(selected.map(|r| r.status.as_str()), Some("404"));
279    }
280
281    #[test]
282    fn select_response_falls_back_to_200() {
283        let responses = make_responses();
284        let prefer = PreferDirectives::default();
285        let selected = select_response(&responses, &prefer);
286        assert_eq!(selected.map(|r| r.status.as_str()), Some("200"));
287    }
288
289    #[test]
290    fn select_response_falls_back_when_code_missing() {
291        let responses = make_responses();
292        let prefer = PreferDirectives { code: Some(418), ..Default::default() };
293        let selected = select_response(&responses, &prefer);
294        // No 418, falls back to 200
295        assert_eq!(selected.map(|r| r.status.as_str()), Some("200"));
296    }
297
298    #[test]
299    fn select_response_default_fallback() {
300        let responses = vec![ResponseSpec {
301            status: "default".into(),
302            schema: None,
303            example: None,
304            named_examples: HashMap::new(),
305        }];
306        let prefer = PreferDirectives::default();
307        let selected = select_response(&responses, &prefer);
308        assert_eq!(selected.map(|r| r.status.as_str()), Some("default"));
309    }
310
311    // ── negotiate_media_type ───────────────────────────────────────────
312
313    #[test]
314    fn negotiate_returns_first_when_no_accept() {
315        let available = vec!["application/json".into(), "text/plain".into()];
316        let result = negotiate_media_type(&available, None);
317        assert_eq!(result.as_deref(), Some("application/json"));
318    }
319
320    #[test]
321    fn negotiate_matches_exact_type() {
322        let available = vec!["application/json".into(), "application/xml".into()];
323        let result = negotiate_media_type(&available, Some("application/xml"));
324        assert_eq!(result.as_deref(), Some("application/xml"));
325    }
326
327    #[test]
328    fn negotiate_respects_quality_values() {
329        let available = vec!["application/json".into(), "application/xml".into()];
330        let result =
331            negotiate_media_type(&available, Some("application/xml;q=0.5, application/json;q=0.9"));
332        assert_eq!(result.as_deref(), Some("application/json"));
333    }
334
335    #[test]
336    fn negotiate_wildcard_returns_first_available() {
337        let available = vec!["application/json".into()];
338        let result = negotiate_media_type(&available, Some("*/*"));
339        assert_eq!(result.as_deref(), Some("application/json"));
340    }
341
342    #[test]
343    fn negotiate_returns_none_for_unsupported() {
344        let available = vec!["application/json".into()];
345        let result = negotiate_media_type(&available, Some("text/html"));
346        assert!(result.is_none());
347    }
348}