Skip to main content

mobiler_core/
http.rs

1//! The HTTP capability's payload types.
2//!
3//! These ride *inside* `PluginResponse.output` as bincode, rather than as fields on
4//! `PluginResponse` itself: that struct is shared by every plugin, and HTTP-specific
5//! columns on it would be a domain leak into the fixed plugin ABI.
6
7use facet::Facet;
8use serde::{Deserialize, Serialize};
9
10use crate::{Cx, PluginResponse};
11
12/// One HTTP header.
13///
14/// A named struct rather than a `(String, String)` tuple: the shared types contain no
15/// tuples today, and tuple codegen into Swift/Kotlin is the least reliable corner of
16/// serde-reflection.
17#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
18#[repr(C)]
19pub struct HttpHeader {
20    pub name: String,
21    pub value: String,
22}
23
24/// The result of an HTTP request.
25#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
26#[repr(C)]
27pub enum HttpOutcome {
28    /// The server answered. `status` is the real HTTP status — 409 is distinguishable
29    /// from 500.
30    Response { status: u16, headers: Vec<HttpHeader>, body: Vec<u8> },
31    /// No HTTP response was obtained, for any reason: chiefly network failure
32    /// (offline, DNS, TLS, connection refused, timeout), but also a request that could
33    /// not be attempted at all (unknown verb, malformed envelope). The defining
34    /// property is that the server never answered, so no status exists.
35    TransportError { message: String },
36}
37
38impl HttpOutcome {
39    /// The HTTP status, or `None` when the server never answered.
40    pub fn status(&self) -> Option<u16> {
41        match self {
42            Self::Response { status, .. } => Some(*status),
43            Self::TransportError { .. } => None,
44        }
45    }
46
47    /// True only for a 2xx response.
48    pub fn is_success(&self) -> bool {
49        matches!(self, Self::Response { status, .. } if (200..300).contains(status))
50    }
51
52    /// The raw response body; empty for a transport error.
53    pub fn body(&self) -> &[u8] {
54        match self {
55            Self::Response { body, .. } => body,
56            Self::TransportError { .. } => &[],
57        }
58    }
59
60    /// The body as text, or `None` if it is not valid UTF-8 or this is a
61    /// `TransportError` (which has no body at all). An empty-but-present body
62    /// returns `Some("")`, not `None`.
63    pub fn text(&self) -> Option<&str> {
64        std::str::from_utf8(self.body()).ok().filter(|_| matches!(self, Self::Response { .. }))
65    }
66
67    /// Look up a response header by name, case-insensitively.
68    pub fn header(&self, name: &str) -> Option<&str> {
69        match self {
70            Self::Response { headers, .. } => headers
71                .iter()
72                .find(|h| h.name.eq_ignore_ascii_case(name))
73                .map(|h| h.value.as_str()),
74            Self::TransportError { .. } => None,
75        }
76    }
77
78    /// Serialize for transport in `PluginResponse.output`.
79    ///
80    /// Uses crux's own FFI format rather than calling bincode directly. This is not
81    /// incidental: crux pins bincode `=1.3` with `with_fixint_encoding()`, whereas
82    /// bincode 2.x's `config::standard()` is *varint*. The two produce different
83    /// bytes, and the Swift/Kotlin decoders generated by serde-generate expect crux's
84    /// encoding — so hand-rolling this would silently yield garbage across the FFI.
85    pub fn encode(&self) -> Vec<u8> {
86        use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
87        let mut buffer = Vec::new();
88        BincodeFfiFormat::serialize(&mut buffer, self).expect("encode HttpOutcome");
89        buffer
90    }
91
92    /// Decode what a shell placed in `PluginResponse.output`.
93    pub fn decode(bytes: &[u8]) -> Result<Self, String> {
94        use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
95        BincodeFfiFormat::deserialize(bytes).map_err(|e| e.to_string())
96    }
97}
98
99/// Wire shape of the HTTP request envelope, serialized into `PluginCall.input`.
100/// Unknown fields are ignored by older shells, so this can grow without an ABI bump.
101#[derive(Serialize)]
102struct HttpReq {
103    url: String,
104    headers: Vec<HttpHeader>,
105    body: Option<String>,
106}
107
108/// Builds one HTTP request. Obtained from [`Cx::request`]; finished with
109/// [`send`](Self::send).
110///
111/// A builder rather than more arguments on `http()`: it lets later additions
112/// (timeouts, query params) arrive as new links in the chain instead of bumping the
113/// arity of every existing call site.
114#[must_use = "a RequestBuilder does nothing until you call .send()"]
115pub struct RequestBuilder<'a, E> {
116    cx: &'a mut Cx<E>,
117    method: String,
118    url: String,
119    headers: Vec<HttpHeader>,
120    body: Option<String>,
121}
122
123impl<'a, E> RequestBuilder<'a, E> {
124    pub(crate) fn new(cx: &'a mut Cx<E>, method: String, url: String) -> Self {
125        Self { cx, method, url, headers: Vec::new(), body: None }
126    }
127
128    /// Add a request header. Order is preserved and names may repeat.
129    ///
130    /// No `#[must_use]` here: it would be redundant with (and, per clippy's
131    /// `double_must_use`, a warning against) the one already on `RequestBuilder`
132    /// itself, which covers every chained call returning `Self`.
133    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
134        self.headers.push(HttpHeader { name: name.into(), value: value.into() });
135        self
136    }
137
138    /// Sugar for `header("Authorization", format!("Bearer {token}"))`.
139    pub fn bearer(self, token: impl AsRef<str>) -> Self {
140        self.header("Authorization", format!("Bearer {}", token.as_ref()))
141    }
142
143    /// Set the request body.
144    pub fn body(mut self, body: impl Into<String>) -> Self {
145        self.body = Some(body.into());
146        self
147    }
148
149    /// Dispatch the request. `then(outcome)` produces the typed event delivered back
150    /// to `update` once the shell replies.
151    pub fn send(self, then: impl FnOnce(HttpOutcome) -> E + Send + 'static) {
152        let input = serde_json::to_string(&HttpReq {
153            url: self.url,
154            headers: self.headers,
155            body: self.body,
156        })
157        .expect("serialize http request");
158
159        self.cx.plugin("http", self.method, input, move |r: PluginResponse| {
160            then(decode_outcome(&r))
161        });
162    }
163}
164
165/// Decode what the shell put in `PluginResponse.output`. A shell that returns
166/// something undecodable is a bug, but it must not panic the app — surface it as a
167/// transport error instead.
168///
169/// When the payload is valid UTF-8 text rather than a bincode `HttpOutcome` — e.g. an
170/// old shell that predates this capability, or a "plugin not available" message from a
171/// shell with no `http` plugin registered — surface that text verbatim instead of the
172/// bincode decode error, so version skew is self-diagnosing rather than reading as an
173/// opaque "malformed http response".
174fn decode_outcome(r: &PluginResponse) -> HttpOutcome {
175    HttpOutcome::decode(&r.output).unwrap_or_else(|e| {
176        // An empty `output` decodes as `Some("")` via `as_text()`, which is not a
177        // diagnostic message worth preferring over the bincode error — only fall back
178        // to the text when there is actually text to show.
179        let message = r
180            .as_text()
181            .filter(|t| !t.is_empty())
182            .map(str::to_string)
183            .unwrap_or_else(|| format!("malformed http response: {e}"));
184        HttpOutcome::TransportError { message }
185    })
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn resp(status: u16, body: &str) -> HttpOutcome {
193        HttpOutcome::Response {
194            status,
195            headers: vec![HttpHeader { name: "Content-Type".into(), value: "application/json".into() }],
196            body: body.as_bytes().to_vec(),
197        }
198    }
199
200    #[test]
201    fn status_is_some_for_response_and_none_for_transport_error() {
202        assert_eq!(resp(200, "").status(), Some(200));
203        assert_eq!(HttpOutcome::TransportError { message: "offline".into() }.status(), None);
204    }
205
206    #[test]
207    fn is_success_covers_exactly_2xx() {
208        assert!(!resp(199, "").is_success());
209        assert!(resp(200, "").is_success());
210        assert!(resp(299, "").is_success());
211        assert!(!resp(300, "").is_success());
212        assert!(!resp(409, "").is_success());
213        assert!(!HttpOutcome::TransportError { message: "x".into() }.is_success());
214    }
215
216    #[test]
217    fn body_and_text_behave_on_valid_and_invalid_utf8() {
218        assert_eq!(resp(200, "hi").body(), b"hi");
219        assert_eq!(resp(200, "hi").text(), Some("hi"));
220
221        let binary = HttpOutcome::Response { status: 200, headers: vec![], body: vec![0xff, 0xfe] };
222        assert_eq!(binary.body(), &[0xff, 0xfe]);
223        assert_eq!(binary.text(), None, "invalid UTF-8 must not panic or lossily convert");
224
225        let err = HttpOutcome::TransportError { message: "x".into() };
226        assert_eq!(err.body(), b"");
227        assert_eq!(err.text(), None);
228    }
229
230    #[test]
231    fn header_lookup_is_case_insensitive() {
232        assert_eq!(resp(200, "").header("content-type"), Some("application/json"));
233        assert_eq!(resp(200, "").header("CONTENT-TYPE"), Some("application/json"));
234        assert_eq!(resp(200, "").header("missing"), None);
235    }
236
237    #[test]
238    fn bincode_round_trips_both_variants() {
239        for original in [
240            resp(409, "conflict"),
241            HttpOutcome::TransportError { message: "connection refused".into() },
242        ] {
243            let bytes = original.encode();
244            assert_eq!(HttpOutcome::decode(&bytes).unwrap(), original);
245        }
246    }
247
248    #[test]
249    fn decode_rejects_garbage_without_panicking() {
250        assert!(HttpOutcome::decode(&[0xff, 0xff, 0xff]).is_err());
251    }
252
253    #[test]
254    fn decode_outcome_falls_back_to_plain_text_for_undecodable_utf8_payloads() {
255        // A shell that returns a plain-text message instead of a bincode HttpOutcome
256        // (old shell after a core upgrade, or "plugin not available") should have that
257        // message surface verbatim, not get replaced by an opaque bincode error.
258        let r = PluginResponse::text(false, "plugin 'http' not available");
259        match decode_outcome(&r) {
260            HttpOutcome::TransportError { message } => {
261                assert_eq!(message, "plugin 'http' not available");
262            }
263            other => panic!("expected TransportError, got {other:?}"),
264        }
265
266        // Genuinely undecodable, non-UTF-8 bytes still produce a diagnosable message
267        // rather than panicking.
268        let r = PluginResponse { ok: false, output: vec![0xff, 0xfe, 0xfd] };
269        match decode_outcome(&r) {
270            HttpOutcome::TransportError { message } => {
271                assert!(message.starts_with("malformed http response:"), "got: {message}");
272            }
273            other => panic!("expected TransportError, got {other:?}"),
274        }
275    }
276
277    #[test]
278    fn decode_outcome_keeps_the_bincode_error_for_an_empty_payload() {
279        // An empty `output` is valid UTF-8 text ("") but that's not a diagnosable
280        // message — `TransportError { message: "" }` would be undebuggable. Preferring
281        // the bincode decode error keeps the failure self-diagnosing.
282        let r = PluginResponse { ok: false, output: Vec::new() };
283        match decode_outcome(&r) {
284            HttpOutcome::TransportError { message } => {
285                assert!(!message.is_empty(), "expected a diagnostic message, got empty string");
286                assert!(message.starts_with("malformed http response:"), "got: {message}");
287            }
288            other => panic!("expected TransportError, got {other:?}"),
289        }
290    }
291}