Skip to main content

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}