cratestack_client_rust/client/response.rs
1use reqwest::StatusCode;
2
3use crate::runtime::wire::RuntimeHeader;
4
5/// A decoded typed body paired with the status and headers it arrived
6/// with (issue #493).
7///
8/// `CratestackClient::{get,post,patch,delete}` decode straight to
9/// `Output`, discarding everything else about the response — which
10/// makes an `@version` model's optimistic-locking contract
11/// unreachable through the typed surface: the required round trip is
12/// `GET` → read `ETag` → `PATCH` with `If-Match`, and the middle step
13/// needs a header that plain `get` throws away. `TypedResponse` exists
14/// so a caller can opt into the metadata via the `*_with_response`
15/// methods without touching the original signatures at all — every
16/// existing call site keeps compiling unchanged.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TypedResponse<Output> {
19 pub value: Output,
20 pub status: StatusCode,
21 pub headers: Vec<RuntimeHeader>,
22}
23
24impl<Output> TypedResponse<Output> {
25 /// Case-insensitive header lookup — HTTP header names are
26 /// case-insensitive per RFC 7230 §3.2, but `RuntimeHeader` stores
27 /// them as plain strings (the wire type is also used across the
28 /// FFI boundary, where a `HeaderMap` isn't available), so a caller
29 /// doing `.header("ETag")` against a server that sent `etag` must
30 /// not silently miss it.
31 ///
32 /// **Returns only the first matching value.** A header name may
33 /// legitimately repeat (e.g. multiple `Set-Cookie` lines) — this
34 /// method silently drops every value after the first, with no
35 /// signal that more exist. Every header this crate's own docs
36 /// point callers at (`ETag`, `Idempotency-Replayed`, `Retry-After`)
37 /// is single-valued in practice, so this is a fine default, but a
38 /// caller who might be talking to a header that repeats should use
39 /// [`Self::header_values`] instead.
40 pub fn header(&self, name: &str) -> Option<&str> {
41 self.headers
42 .iter()
43 .find(|header| header.name.eq_ignore_ascii_case(name))
44 .map(|header| header.value.as_str())
45 }
46
47 /// Every value sent under `name` (case-insensitive), in the order
48 /// the server sent them — the honest counterpart to [`Self::header`]
49 /// for a header that may repeat (`Set-Cookie` being the canonical
50 /// example). Returns an empty iterator, not `None`, when `name`
51 /// wasn't sent at all.
52 pub fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> {
53 self.headers
54 .iter()
55 .filter(move |header| header.name.eq_ignore_ascii_case(name))
56 .map(|header| header.value.as_str())
57 }
58}
59
60#[cfg(test)]
61mod tests {
62 use super::*;
63
64 #[test]
65 fn header_lookup_is_case_insensitive() {
66 let response = TypedResponse {
67 value: (),
68 status: StatusCode::OK,
69 headers: vec![RuntimeHeader {
70 name: "ETag".to_owned(),
71 value: "\"0\"".to_owned(),
72 }],
73 };
74
75 assert_eq!(response.header("etag"), Some("\"0\""));
76 assert_eq!(response.header("ETAG"), Some("\"0\""));
77 assert_eq!(response.header("If-Match"), None);
78 }
79
80 #[test]
81 fn header_returns_only_the_first_of_a_repeated_header() {
82 let response = TypedResponse {
83 value: (),
84 status: StatusCode::OK,
85 headers: vec![
86 RuntimeHeader {
87 name: "Set-Cookie".to_owned(),
88 value: "a=1".to_owned(),
89 },
90 RuntimeHeader {
91 name: "Set-Cookie".to_owned(),
92 value: "b=2".to_owned(),
93 },
94 ],
95 };
96
97 // Documents the deliberate first-match-only behavior of `.header`.
98 assert_eq!(response.header("set-cookie"), Some("a=1"));
99 }
100
101 #[test]
102 fn header_values_returns_every_value_of_a_repeated_header() {
103 let response = TypedResponse {
104 value: (),
105 status: StatusCode::OK,
106 headers: vec![
107 RuntimeHeader {
108 name: "Set-Cookie".to_owned(),
109 value: "a=1".to_owned(),
110 },
111 RuntimeHeader {
112 name: "Set-Cookie".to_owned(),
113 value: "b=2".to_owned(),
114 },
115 ],
116 };
117
118 let values: Vec<&str> = response.header_values("set-cookie").collect();
119 assert_eq!(values, vec!["a=1", "b=2"]);
120 assert_eq!(response.header_values("x-missing").count(), 0);
121 }
122}