specmock_runtime/http/
negotiate.rs1use http::HeaderMap;
4
5#[derive(Debug, Clone, Default)]
7pub struct PreferDirectives {
8 pub code: Option<u16>,
10 pub example: Option<String>,
12 pub media_type: Option<String>,
14 pub dynamic: bool,
16}
17
18impl PreferDirectives {
19 pub fn from_headers(headers: &HeaderMap) -> Self {
27 let mut directives = Self::default();
28
29 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 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
46fn 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#[derive(Debug)]
71struct MediaEntry {
72 media_type: String,
73 quality: f32,
74}
75
76fn 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
110pub 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
136pub 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 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 #[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 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 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 #[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}