rift_types/wire.rs
1//! Mountebank wire-shape tolerance, shared across crates (issue #936).
2//!
3//! Mountebank accepts several spellings of the same value — a status code as a number *or* a
4//! numeric string, a header as one value *or* an array, a header value as any JSON scalar. Those
5//! rules are a property of the wire format, not of any one subsystem, so they live here rather
6//! than inside the imposter types: the imposter stub path and the intercept rule schema both
7//! parse the same JSON and must agree about what it means. They previously lived in
8//! `rift-mock-core` as `pub(crate)`, which is exactly why the intercept path drifted (issue #933
9//! for `body`, this issue for `statusCode`/`headers`).
10
11/// Serde for multi-value headers (issue #238). Accepts the Mountebank-style `"k": "v"` *and*
12/// `"k": ["v1", "v2"]` on the wire; serializes a single value back as a plain string and multiple
13/// values as an array, so existing single-value consumers are unaffected.
14///
15/// **Invariant on every map this module produces (issue #1039): one entry per case-folded header
16/// name.** No two keys `eq_ignore_ascii_case`-match each other; the surviving key is the *first*
17/// spelling the deserializer presents, and its values are every case-matching entry's values in
18/// that same order. HTTP header names are case-insensitive, so a document that spells one name two
19/// ways describes one header — but a `HashMap` keyed by the literal spelling would hold it as two.
20/// That split is what the eleven case-insensitive find-first lookups downstream (form parsing,
21/// predicate fields, `copy`, the JS/Rhai engines, the verify CLI) resolve nondeterministically, and
22/// what makes `deepEquals` on headers compare against the wrong name count. Holding the invariant
23/// here — at the single deserializer all five header fields that parse through it share — makes
24/// those sites correct by construction rather than by eleven separate case-folding patches. (Two
25/// further fields name this module for `serialize_with` only and never reach the deserializer.)
26///
27/// "First the deserializer presents" is deliberately not "first in the document". Which one you get
28/// depends on how the caller reached this function, and **both shapes are live in production**:
29///
30/// - Deserializing **straight from JSON text or bytes** into the target type streams entries in
31/// document order, so the first spelling written wins. (`POST /imposters` and
32/// `POST /intercept/rules` take this path, as does `--configfile`'s bare-array form.)
33/// - Deserializing from an **already-parsed `serde_json::Value`** walks a `serde_json::Map`, which
34/// is a `BTreeMap` unless the `preserve_order` feature is on — it is not enabled in this
35/// workspace — so entries arrive sorted by key bytes and the lexicographically smallest spelling
36/// wins. (`--configfile`'s `{"imposters": […]}` wrapper and single-object forms both parse to a
37/// `Value` first, as do `rift_apply_config` and the scenarios stub path.)
38///
39/// Note how little those two lists correlate with the user-facing feature: one CLI flag spans both,
40/// depending only on the document's outermost punctuation. That is precisely why **nothing outside
41/// this module should depend on which spelling wins** — and why the user-facing documentation of
42/// this behaviour promises one entry and stops there. What the fix guarantees, and all it needs to
43/// guarantee, is that the result is a *function of the document*: the defect was never the choice
44/// of spelling but that the choice varied between runs of the same input.
45pub mod multi_value_headers {
46 use serde::Deserialize;
47 use serde::de::{Deserializer, MapAccess, Visitor};
48 use serde::ser::{SerializeMap, Serializer};
49 use std::collections::HashMap;
50 use std::fmt;
51
52 pub fn serialize<S: Serializer>(
53 headers: &HashMap<String, Vec<String>>,
54 serializer: S,
55 ) -> Result<S::Ok, S::Error> {
56 let mut map = serializer.serialize_map(Some(headers.len()))?;
57 for (key, values) in headers {
58 match values.as_slice() {
59 [] => continue, // a key with no values would emit no header line; omit it
60 [single] => map.serialize_entry(key, single)?,
61 many => map.serialize_entry(key, many)?,
62 }
63 }
64 map.end()
65 }
66
67 /// A single header value on the wire. Mountebank tolerates non-string scalars — its recorders
68 /// routinely emit `"Content-Length": 124` (a JSON number) and `"X-Flag": true` — and coerces
69 /// them to their string form. Rift matches that so real recorded imposters load unchanged
70 /// (issue #754); previously a numeric/bool value failed both `OneOrMany` variants and rejected
71 /// the whole imposter with a 400.
72 #[derive(Deserialize)]
73 #[serde(untagged)]
74 enum Scalar {
75 Str(String),
76 Num(serde_json::Number),
77 Bool(bool),
78 }
79
80 impl Scalar {
81 fn into_string(self) -> String {
82 match self {
83 Scalar::Str(s) => s,
84 Scalar::Num(n) => n.to_string(),
85 Scalar::Bool(b) => b.to_string(),
86 }
87 }
88 }
89
90 /// Order matters for `#[serde(untagged)]`: a scalar can never match `Many` and an array can
91 /// never match `One`, so either order is sound — `One` first keeps the common case first.
92 #[derive(Deserialize)]
93 #[serde(untagged)]
94 enum OneOrMany {
95 One(Scalar),
96 Many(Vec<Scalar>),
97 }
98
99 impl OneOrMany {
100 /// An empty array yields no values rather than being rejected, which keeps the map
101 /// byte-identical to what the pre-#1039 code produced. It is not load-bearing beyond that:
102 /// `serialize` omits such an entry, and `RequestHeaders` filters it out of both `entries()`
103 /// and `len()`, so a name with no values already reads as absent everywhere downstream.
104 fn into_strings(self) -> Vec<String> {
105 match self {
106 OneOrMany::One(s) => vec![s.into_string()],
107 OneOrMany::Many(v) => v.into_iter().map(Scalar::into_string).collect(),
108 }
109 }
110 }
111
112 struct FoldingVisitor;
113
114 impl<'de> Visitor<'de> for FoldingVisitor {
115 type Value = HashMap<String, Vec<String>>;
116
117 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.write_str("a map of header names to a scalar or an array of scalars")
119 }
120
121 fn visit_map<M: MapAccess<'de>>(self, mut access: M) -> Result<Self::Value, M::Error> {
122 // Accumulated in a `Vec` rather than a `HashMap` so the fold preserves the order the
123 // deserializer presents: which spelling survives and what order the values end up in
124 // are then properties of the document, not of a hash iteration order that varies per
125 // process. Deserializing straight into a `HashMap` also loses a repeated key silently,
126 // since serde's map impl is last-wins.
127 //
128 // `size_hint` is attacker-influenced in the general case — serde's own map impl clamps
129 // it for exactly this reason — so cap the preallocation rather than trusting it. Real
130 // header maps are far below the cap, so this never costs a realistic document a
131 // reallocation.
132 let mut folded: Vec<(String, Vec<String>)> =
133 Vec::with_capacity(access.size_hint().unwrap_or(0).min(1024));
134 while let Some((name, value)) = access.next_entry::<String, OneOrMany>()? {
135 let values = value.into_strings();
136 match folded
137 .iter_mut()
138 .find(|(k, _)| k.eq_ignore_ascii_case(&name))
139 {
140 // `eq_ignore_ascii_case` is exactly the HTTP rule: names differing outside
141 // ASCII are different names and must not be folded together.
142 Some((_, existing)) => existing.extend(values),
143 None => folded.push((name, values)),
144 }
145 }
146 Ok(folded.into_iter().collect())
147 }
148 }
149
150 pub fn deserialize<'de, D: Deserializer<'de>>(
151 deserializer: D,
152 ) -> Result<HashMap<String, Vec<String>>, D::Error> {
153 // The linear scan is O(k²) in the number of distinct names. Header maps are small and this
154 // runs on config load / the admin API, never on the request path.
155 deserializer.deserialize_map(FoldingVisitor)
156 }
157}
158
159/// Serde for header objects that hold **one** value per name (issue #1050) — `proxy.injectHeaders`
160/// and `_rift.fault.error.headers`.
161///
162/// Deserializing rejects a document that names one header twice, case-insensitively, instead of
163/// accepting it and picking a winner. That is deliberately the *opposite* of what
164/// [`multi_value_headers`] does, and the difference is not an inconsistency:
165///
166/// - A multi-value map has a lossless merge — keep both values — so folding costs nothing and
167/// #1039 folds.
168/// - A single-valued map has none *in general*. Two different values for one name have no correct
169/// combination, so a fold would have to pick a winner — and the tie-break cannot even be made
170/// deterministic, because which spelling the deserializer presents first depends on whether the
171/// document was streamed from text or routed through a `serde_json::Value` first, exactly as
172/// documented on [`multi_value_headers`].
173///
174/// The rule is therefore the simple one — *a single-valued header object names each header once* —
175/// and it is applied uniformly. Note that this refuses `{"x": "a", "X": "a"}` too, where a fold
176/// *would* be lossless: the case does not rest on the two values conflicting, and the code does not
177/// check whether they do. One rule that is always true beats two rules that need the reader to work
178/// out which applies.
179///
180/// Nothing that worked stops working. A document this rejects was previously emitting **two header
181/// lines** for that name, ordered by `HashMap` iteration and therefore differently per process:
182/// `RequestBuilder::header` and `http::response::Builder::header` both append rather than replace,
183/// so both spellings went out on the wire.
184///
185/// Values stay `String`-only. `multi_value_headers` also coerces JSON numbers and bools (issue
186/// #754); widening these two fields to match is a separate compatibility question, not a
187/// side effect of this one.
188pub mod single_value_headers {
189 use serde::de::{Deserializer, Error, MapAccess, Visitor};
190 use std::collections::HashMap;
191 use std::fmt;
192
193 struct OneEachVisitor;
194
195 impl<'de> Visitor<'de> for OneEachVisitor {
196 type Value = HashMap<String, String>;
197
198 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199 f.write_str("a map of header names to single string values, each name given once")
200 }
201
202 fn visit_map<M: MapAccess<'de>>(self, mut access: M) -> Result<Self::Value, M::Error> {
203 // A `Vec` so the scan sees the spelling actually written, and so the error can quote
204 // it. The order is only used for the diagnostic; the rejection itself does not depend
205 // on which of the two the deserializer happened to present first.
206 let mut seen: Vec<(String, String)> =
207 Vec::with_capacity(access.size_hint().unwrap_or(0).min(1024));
208 while let Some((name, value)) = access.next_entry::<String, String>()? {
209 if let Some((existing, _)) =
210 seen.iter().find(|(k, _)| k.eq_ignore_ascii_case(&name))
211 {
212 return Err(M::Error::custom(format!(
213 "header `{name}` is already given as `{existing}`; a single-valued header \
214 object names each header once"
215 )));
216 }
217 seen.push((name, value));
218 }
219 Ok(seen.into_iter().collect())
220 }
221 }
222
223 pub fn deserialize<'de, D: Deserializer<'de>>(
224 deserializer: D,
225 ) -> Result<HashMap<String, String>, D::Error> {
226 deserializer.deserialize_map(OneEachVisitor)
227 }
228}
229
230use serde::Deserialize;
231
232/// Parse a JSON `statusCode` value that may be a number or a (numeric) string.
233fn parse_status_code_value<E: serde::de::Error>(value: serde_json::Value) -> Result<u16, E> {
234 match value {
235 serde_json::Value::Number(n) => n
236 .as_u64()
237 .and_then(|n| u16::try_from(n).ok())
238 .ok_or_else(|| E::custom("invalid status code number")),
239 serde_json::Value::String(s) => s
240 .parse::<u16>()
241 .map_err(|_| E::custom(format!("invalid status code string: {s}"))),
242 _ => Err(E::custom("statusCode must be a number or string")),
243 }
244}
245
246/// Deserialize statusCode from either a number or a string
247pub fn deserialize_status_code<'de, D>(deserializer: D) -> Result<u16, D::Error>
248where
249 D: serde::Deserializer<'de>,
250{
251 parse_status_code_value(serde_json::Value::deserialize(deserializer)?)
252}
253
254/// Deserialize an optional top-level `statusCode` (flat response form, issue #304), reusing the
255/// number-or-string parsing. Only invoked when the field is present; a `null` is treated as
256/// absent (`None`) so a stray null on a non-flat response stays accepted as before.
257pub fn deserialize_optional_status_code<'de, D>(deserializer: D) -> Result<Option<u16>, D::Error>
258where
259 D: serde::Deserializer<'de>,
260{
261 match serde_json::Value::deserialize(deserializer)? {
262 serde_json::Value::Null => Ok(None),
263 value => parse_status_code_value(value).map(Some),
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use serde::Serialize;
271 use std::collections::HashMap;
272
273 /// Exercises the helpers through serde attributes, the only way they are ever reached — a
274 /// direct call would not prove the `#[serde(with = ...)]` wiring still resolves.
275 #[derive(Deserialize)]
276 struct HeadersIn {
277 #[serde(default, deserialize_with = "multi_value_headers::deserialize")]
278 headers: HashMap<String, Vec<String>>,
279 }
280
281 #[derive(Serialize)]
282 struct HeadersOut {
283 #[serde(serialize_with = "multi_value_headers::serialize")]
284 headers: HashMap<String, Vec<String>>,
285 }
286
287 #[derive(Deserialize)]
288 struct StatusIn {
289 #[serde(deserialize_with = "deserialize_status_code")]
290 status: u16,
291 }
292
293 #[derive(Deserialize)]
294 struct OptionalStatusIn {
295 #[serde(default, deserialize_with = "deserialize_optional_status_code")]
296 status: Option<u16>,
297 }
298
299 #[test]
300 fn headers_accept_a_bare_string_or_an_array() {
301 let one: HeadersIn = serde_json::from_str(r#"{"headers":{"X-One":"v"}}"#).unwrap();
302 assert_eq!(one.headers["X-One"], vec!["v".to_string()]);
303
304 let many: HeadersIn =
305 serde_json::from_str(r#"{"headers":{"Set-Cookie":["a","b"]}}"#).unwrap();
306 assert_eq!(
307 many.headers["Set-Cookie"],
308 vec!["a".to_string(), "b".to_string()]
309 );
310 }
311
312 // Issue #754: recorded imposters carry non-string scalars; they coerce rather than 400.
313 #[test]
314 fn headers_coerce_numeric_and_bool_scalars() {
315 let r: HeadersIn = serde_json::from_str(
316 r#"{"headers":{"Content-Length":124,"X-Flag":true,"X-Ratio":1.5,"X-Multi":[200,"x",false]}}"#,
317 )
318 .expect("numeric/bool header values must be accepted (mb parity)");
319 assert_eq!(r.headers["Content-Length"], vec!["124".to_string()]);
320 assert_eq!(r.headers["X-Flag"], vec!["true".to_string()]);
321 assert_eq!(r.headers["X-Ratio"], vec!["1.5".to_string()]);
322 assert_eq!(
323 r.headers["X-Multi"],
324 vec!["200".to_string(), "x".to_string(), "false".to_string()]
325 );
326 }
327
328 // Issue #1039: one key per case-folded name. Before this, the deserializer built a
329 // `HashMap<String, OneOrMany>`, which both split `content-type` from `Content-Type` into two
330 // entries and silently last-wins on a repeated key. Two entries for one header name make every
331 // case-insensitive find-first lookup downstream (form parsing, predicates, `copy`, the
332 // scripting engines) answer nondeterministically, and make `deepEquals` on headers compare the
333 // wrong count.
334 #[test]
335 fn headers_merge_case_variant_keys_under_the_first_spelling() {
336 let r: HeadersIn =
337 serde_json::from_str(r#"{"headers":{"content-type":"a","Content-Type":"b"}}"#)
338 .expect("case-variant keys are a valid document, not an error");
339 assert_eq!(
340 r.headers.len(),
341 1,
342 "two spellings of one name is one header"
343 );
344 assert_eq!(
345 r.headers["content-type"],
346 vec!["a".to_string(), "b".to_string()],
347 "first spelling survives; values follow document order"
348 );
349 assert!(
350 !r.headers.contains_key("Content-Type"),
351 "the later spelling must not survive as a second key"
352 );
353 }
354
355 #[test]
356 fn headers_merge_byte_identical_duplicate_keys() {
357 let r: HeadersIn = serde_json::from_str(r#"{"headers":{"X-Dup":"a","X-Dup":"b"}}"#)
358 .expect("a repeated key is accepted");
359 assert_eq!(
360 r.headers["X-Dup"],
361 vec!["a".to_string(), "b".to_string()],
362 "a repeated key keeps both values instead of serde's silent last-wins"
363 );
364 }
365
366 #[test]
367 fn headers_preserve_a_lone_keys_spelling_byte_exact() {
368 // The served header spelling is a contract (`oddly_cased_content_type_not_duplicated`,
369 // and the `Content-type` fixture in the SDK corpus whose replay must deep-equal).
370 let r: HeadersIn = serde_json::from_str(r#"{"headers":{"Content-type":"x"}}"#).unwrap();
371 assert_eq!(r.headers["Content-type"], vec!["x".to_string()]);
372 assert!(!r.headers.contains_key("Content-Type"));
373 assert!(!r.headers.contains_key("content-type"));
374
375 let shouty: HeadersIn =
376 serde_json::from_str(r#"{"headers":{"CONTENT-TYPE":"y"}}"#).unwrap();
377 assert_eq!(shouty.headers["CONTENT-TYPE"], vec!["y".to_string()]);
378
379 // The contract is parse *and serve*, so go the whole way back out to the wire — it is the
380 // served spelling those guards pin, and asserting only the parsed key leaves that half of
381 // the round trip untested.
382 let served = serde_json::to_value(HeadersOut { headers: r.headers }).expect("serialize");
383 assert_eq!(served["headers"], serde_json::json!({"Content-type": "x"}));
384 }
385
386 #[test]
387 fn headers_merge_three_way_variants_with_mixed_scalars_and_arrays() {
388 let r: HeadersIn =
389 serde_json::from_str(r#"{"headers":{"X-A":1,"x-a":[true,"z"],"X-a":2.5}}"#)
390 .expect("#754 scalar coercion still applies to every merged entry");
391 assert_eq!(r.headers.len(), 1);
392 assert_eq!(
393 r.headers["X-A"],
394 vec![
395 "1".to_string(),
396 "true".to_string(),
397 "z".to_string(),
398 "2.5".to_string()
399 ]
400 );
401 }
402
403 #[test]
404 fn headers_merge_is_deterministic_across_repeated_parses() {
405 // The issue's actual symptom: with a `HashMap` intermediate the surviving key depended on
406 // iteration order, so the same stored document answered differently across runs.
407 for _ in 0..200 {
408 let r: HeadersIn = serde_json::from_str(
409 r#"{"headers":{"content-type":"a","Content-Type":"b","CONTENT-TYPE":"c"}}"#,
410 )
411 .unwrap();
412 assert_eq!(r.headers.len(), 1);
413 assert_eq!(
414 r.headers["content-type"],
415 vec!["a".to_string(), "b".to_string(), "c".to_string()]
416 );
417 }
418 }
419
420 // The two deserializer inputs present entries in different orders, and both reach production:
421 // streaming from text or bytes gives document order, while going through a `serde_json::Value`
422 // first walks a `Map` and gives byte order. See the module doc for which callers take which —
423 // `--configfile` alone takes both, depending on the document's outermost punctuation, which is
424 // why nothing outside this module may depend on the answer.
425 //
426 // The byte-order half asserts that `preserve_order` is OFF workspace-wide. If a future
427 // dependency turns it on, this is the test that should fail — deliberately, and first.
428 #[test]
429 fn headers_fold_deterministically_from_a_value_too_even_though_the_order_differs() {
430 const TEXT: &str = r#"{"headers":{"set-cookie":"a","Set-Cookie":"b"}}"#;
431
432 let from_text: HeadersIn = serde_json::from_str(TEXT).unwrap();
433 assert_eq!(
434 from_text.headers["set-cookie"],
435 vec!["a".to_string(), "b".to_string()],
436 "streaming from text keeps document order, so the first spelling written wins"
437 );
438
439 let value: serde_json::Value = serde_json::from_str(TEXT).unwrap();
440 let from_value: HeadersIn = serde_json::from_value(value).unwrap();
441 assert_eq!(from_value.headers.len(), 1, "still one header either way");
442 assert_eq!(
443 from_value.headers["Set-Cookie"],
444 vec!["b".to_string(), "a".to_string()],
445 "a `Map` is key-sorted, so `Set-Cookie` (0x53) precedes `set-cookie` (0x73)"
446 );
447 }
448
449 // Asserts that `float_roundtrip` is ON workspace-wide (issue #1085). Without it serde_json's
450 // float parse is not correctly rounded: each literal below comes back one representable double
451 // away, and is written with different digits (`7e23` as `6.999999999999999e23`). Every JSON
452 // number rift parses — stub bodies, request bodies, lint input — goes through this parser.
453 #[test]
454 fn serde_json_parses_floats_correctly_rounded() {
455 for literal in [
456 "7e23",
457 "1e-23",
458 "1.23e-30",
459 "1.2299999999999999e-30",
460 "0.10018513143495411",
461 ] {
462 let value: serde_json::Value = serde_json::from_str(literal).unwrap();
463 assert_eq!(value.to_string(), literal);
464 }
465 }
466
467 #[test]
468 fn headers_fold_only_ascii_case_and_keep_empty_shapes() {
469 let empty: HeadersIn = serde_json::from_str(r#"{"headers":{}}"#).unwrap();
470 assert!(empty.headers.is_empty());
471
472 // A key present with an empty array stays present with no values — the pre-#1039 map shape,
473 // preserved so the fold changes nothing it does not have to. Downstream it is invisible
474 // either way: `serialize` omits it and `RequestHeaders` filters it out of `entries()` and
475 // `len()`, so such a name already reads as absent (`header_name_with_no_values_reads_as_absent`).
476 let no_values: HeadersIn = serde_json::from_str(r#"{"headers":{"X-None":[]}}"#).unwrap();
477 assert_eq!(no_values.headers["X-None"], Vec::<String>::new());
478
479 // `eq_ignore_ascii_case` is exactly the HTTP rule: names differing outside ASCII are
480 // different names and must not be folded together.
481 let non_ascii: HeadersIn =
482 serde_json::from_str(r#"{"headers":{"X-Kä":"1","X-KÄ":"2"}}"#).unwrap();
483 assert_eq!(non_ascii.headers.len(), 2);
484 }
485
486 #[test]
487 fn headers_serialize_single_as_string_many_as_array_and_omit_empty() {
488 let out = HeadersOut {
489 headers: HashMap::from([
490 ("X-One".to_string(), vec!["v".to_string()]),
491 (
492 "Set-Cookie".to_string(),
493 vec!["a".to_string(), "b".to_string()],
494 ),
495 ("X-Empty".to_string(), vec![]),
496 ]),
497 };
498 let v = serde_json::to_value(&out).unwrap();
499 assert_eq!(v["headers"]["X-One"], serde_json::json!("v"));
500 assert_eq!(v["headers"]["Set-Cookie"], serde_json::json!(["a", "b"]));
501 assert!(
502 v["headers"].get("X-Empty").is_none(),
503 "a key with no values emits no header line, so it is omitted"
504 );
505 }
506
507 /// Exercises `single_value_headers` through a serde attribute, the only way it is reached.
508 #[derive(Deserialize, Debug)]
509 struct SingleIn {
510 #[serde(default, deserialize_with = "single_value_headers::deserialize")]
511 headers: HashMap<String, String>,
512 }
513
514 // Issue #1050. Before this, both spellings survived as distinct keys and BOTH went out on the
515 // wire — `RequestBuilder::header` and `response::Builder::header` append rather than replace —
516 // so the peer received two lines for one header, ordered by `HashMap` iteration and therefore
517 // differently per process. Refusing costs nothing that was working.
518 #[test]
519 fn a_name_given_twice_in_different_case_is_rejected() {
520 let err = serde_json::from_str::<SingleIn>(
521 r#"{"headers":{"content-type":"a","Content-Type":"b"}}"#,
522 )
523 .expect_err("a single-valued header object names each header once");
524 let msg = err.to_string();
525 assert!(
526 msg.contains("content-type") && msg.contains("Content-Type"),
527 "the error must name BOTH spellings so the author can find them: {msg}"
528 );
529 }
530
531 // The `Value`-mediated path must reject too. Case variants are distinct keys in a
532 // `serde_json::Map`, so they both survive to here — unlike a byte-identical duplicate, which
533 // the JSON parser has already collapsed by this point (the same two-path split documented on
534 // `multi_value_headers`).
535 #[test]
536 fn a_case_variant_name_is_rejected_through_a_value_too() {
537 let value: serde_json::Value =
538 serde_json::from_str(r#"{"headers":{"x-id":"a","X-Id":"b"}}"#).unwrap();
539 assert!(
540 serde_json::from_value::<SingleIn>(value).is_err(),
541 "routing through a Value must not launder a duplicate past the check"
542 );
543 }
544
545 #[test]
546 fn a_byte_identical_duplicate_is_rejected_on_the_text_path() {
547 assert!(
548 serde_json::from_str::<SingleIn>(r#"{"headers":{"X-Id":"a","X-Id":"b"}}"#).is_err(),
549 "serde's default map visitor would have silently kept the last one"
550 );
551 }
552
553 #[test]
554 fn an_ordinary_single_valued_header_object_is_unchanged() {
555 let ok: SingleIn =
556 serde_json::from_str(r#"{"headers":{"X-Id":"a","Content-Type":"text/plain"}}"#)
557 .expect("every name given once");
558 assert_eq!(ok.headers["X-Id"], "a");
559 assert_eq!(ok.headers["Content-Type"], "text/plain");
560 assert_eq!(ok.headers.len(), 2);
561
562 let empty: SingleIn = serde_json::from_str(r#"{"headers":{}}"#).unwrap();
563 assert!(empty.headers.is_empty());
564
565 // Absent is not an error — both fields carry `#[serde(default)]`.
566 let missing: SingleIn = serde_json::from_str("{}").unwrap();
567 assert!(missing.headers.is_empty());
568 }
569
570 // `eq_ignore_ascii_case` is the HTTP rule, as in `multi_value_headers`: names differing outside
571 // ASCII are different names, and refusing them would reject a document that is fine.
572 #[test]
573 fn names_differing_outside_ascii_are_not_treated_as_duplicates() {
574 let ok: SingleIn =
575 serde_json::from_str(r#"{"headers":{"X-Kä":"1","X-KÄ":"2"}}"#).expect("distinct names");
576 assert_eq!(ok.headers.len(), 2);
577 }
578
579 #[test]
580 fn status_code_accepts_a_number_or_a_numeric_string() {
581 assert_eq!(
582 serde_json::from_str::<StatusIn>(r#"{"status":404}"#)
583 .unwrap()
584 .status,
585 404
586 );
587 assert_eq!(
588 serde_json::from_str::<StatusIn>(r#"{"status":"404"}"#)
589 .unwrap()
590 .status,
591 404
592 );
593 }
594
595 #[test]
596 fn status_code_rejects_junk_rather_than_defaulting() {
597 for junk in [
598 r#"{"status":"abc"}"#,
599 r#"{"status":true}"#,
600 // One past `u16::MAX`, in both spellings — the boundary the two parse paths
601 // (`str::parse::<u16>` and `u16::try_from(u64)`) have to agree on.
602 r#"{"status":65536}"#,
603 r#"{"status":"65536"}"#,
604 r#"{"status":-1}"#,
605 // A non-integer number: `Number::as_u64` yields `None` rather than truncating.
606 r#"{"status":200.5}"#,
607 ] {
608 assert!(
609 serde_json::from_str::<StatusIn>(junk).is_err(),
610 "{junk} must be an error, not a silent default"
611 );
612 }
613
614 // …and the boundary itself is still accepted, so the rejections above are landing on the
615 // right side of it.
616 assert_eq!(
617 serde_json::from_str::<StatusIn>(r#"{"status":65535}"#)
618 .unwrap()
619 .status,
620 65535
621 );
622 }
623
624 #[test]
625 fn optional_status_code_treats_null_and_absent_as_none() {
626 assert_eq!(
627 serde_json::from_str::<OptionalStatusIn>(r#"{}"#)
628 .unwrap()
629 .status,
630 None
631 );
632 assert_eq!(
633 serde_json::from_str::<OptionalStatusIn>(r#"{"status":null}"#)
634 .unwrap()
635 .status,
636 None
637 );
638 assert_eq!(
639 serde_json::from_str::<OptionalStatusIn>(r#"{"status":"201"}"#)
640 .unwrap()
641 .status,
642 Some(201)
643 );
644 }
645}