mobiler-core 0.36.0

Mobiler runtime: the MobilerApp trait + Crux shell adapter over the fixed UI ABI
Documentation
//! The HTTP capability's payload types.
//!
//! These ride *inside* `PluginResponse.output` as bincode, rather than as fields on
//! `PluginResponse` itself: that struct is shared by every plugin, and HTTP-specific
//! columns on it would be a domain leak into the fixed plugin ABI.

use facet::Facet;
use serde::{Deserialize, Serialize};

use crate::{Cx, PluginResponse};

/// One HTTP header.
///
/// A named struct rather than a `(String, String)` tuple: the shared types contain no
/// tuples today, and tuple codegen into Swift/Kotlin is the least reliable corner of
/// serde-reflection.
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[repr(C)]
pub struct HttpHeader {
    pub name: String,
    pub value: String,
}

/// The result of an HTTP request.
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum HttpOutcome {
    /// The server answered. `status` is the real HTTP status — 409 is distinguishable
    /// from 500.
    Response { status: u16, headers: Vec<HttpHeader>, body: Vec<u8> },
    /// No HTTP response was obtained, for any reason: chiefly network failure
    /// (offline, DNS, TLS, connection refused, timeout), but also a request that could
    /// not be attempted at all (unknown verb, malformed envelope). The defining
    /// property is that the server never answered, so no status exists.
    TransportError { message: String },
}

impl HttpOutcome {
    /// The HTTP status, or `None` when the server never answered.
    pub fn status(&self) -> Option<u16> {
        match self {
            Self::Response { status, .. } => Some(*status),
            Self::TransportError { .. } => None,
        }
    }

    /// True only for a 2xx response.
    pub fn is_success(&self) -> bool {
        matches!(self, Self::Response { status, .. } if (200..300).contains(status))
    }

    /// The raw response body; empty for a transport error.
    pub fn body(&self) -> &[u8] {
        match self {
            Self::Response { body, .. } => body,
            Self::TransportError { .. } => &[],
        }
    }

    /// The body as text, or `None` if it is not valid UTF-8 or this is a
    /// `TransportError` (which has no body at all). An empty-but-present body
    /// returns `Some("")`, not `None`.
    pub fn text(&self) -> Option<&str> {
        std::str::from_utf8(self.body()).ok().filter(|_| matches!(self, Self::Response { .. }))
    }

    /// Look up a response header by name, case-insensitively.
    pub fn header(&self, name: &str) -> Option<&str> {
        match self {
            Self::Response { headers, .. } => headers
                .iter()
                .find(|h| h.name.eq_ignore_ascii_case(name))
                .map(|h| h.value.as_str()),
            Self::TransportError { .. } => None,
        }
    }

    /// Serialize for transport in `PluginResponse.output`.
    ///
    /// Uses crux's own FFI format rather than calling bincode directly. This is not
    /// incidental: crux pins bincode `=1.3` with `with_fixint_encoding()`, whereas
    /// bincode 2.x's `config::standard()` is *varint*. The two produce different
    /// bytes, and the Swift/Kotlin decoders generated by serde-generate expect crux's
    /// encoding — so hand-rolling this would silently yield garbage across the FFI.
    pub fn encode(&self) -> Vec<u8> {
        use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
        let mut buffer = Vec::new();
        BincodeFfiFormat::serialize(&mut buffer, self).expect("encode HttpOutcome");
        buffer
    }

    /// Decode what a shell placed in `PluginResponse.output`.
    pub fn decode(bytes: &[u8]) -> Result<Self, String> {
        use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
        BincodeFfiFormat::deserialize(bytes).map_err(|e| e.to_string())
    }
}

/// Wire shape of the HTTP request envelope, serialized into `PluginCall.input`.
/// Unknown fields are ignored by older shells, so this can grow without an ABI bump.
#[derive(Serialize)]
struct HttpReq {
    url: String,
    headers: Vec<HttpHeader>,
    body: Option<String>,
}

/// Builds one HTTP request. Obtained from [`Cx::request`]; finished with
/// [`send`](Self::send).
///
/// A builder rather than more arguments on `http()`: it lets later additions
/// (timeouts, query params) arrive as new links in the chain instead of bumping the
/// arity of every existing call site.
#[must_use = "a RequestBuilder does nothing until you call .send()"]
pub struct RequestBuilder<'a, E> {
    cx: &'a mut Cx<E>,
    method: String,
    url: String,
    headers: Vec<HttpHeader>,
    body: Option<String>,
}

impl<'a, E> RequestBuilder<'a, E> {
    pub(crate) fn new(cx: &'a mut Cx<E>, method: String, url: String) -> Self {
        Self { cx, method, url, headers: Vec::new(), body: None }
    }

    /// Add a request header. Order is preserved and names may repeat.
    ///
    /// No `#[must_use]` here: it would be redundant with (and, per clippy's
    /// `double_must_use`, a warning against) the one already on `RequestBuilder`
    /// itself, which covers every chained call returning `Self`.
    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push(HttpHeader { name: name.into(), value: value.into() });
        self
    }

    /// Sugar for `header("Authorization", format!("Bearer {token}"))`.
    pub fn bearer(self, token: impl AsRef<str>) -> Self {
        self.header("Authorization", format!("Bearer {}", token.as_ref()))
    }

    /// Set the request body.
    pub fn body(mut self, body: impl Into<String>) -> Self {
        self.body = Some(body.into());
        self
    }

    /// Dispatch the request. `then(outcome)` produces the typed event delivered back
    /// to `update` once the shell replies.
    pub fn send(self, then: impl FnOnce(HttpOutcome) -> E + Send + 'static) {
        let input = serde_json::to_string(&HttpReq {
            url: self.url,
            headers: self.headers,
            body: self.body,
        })
        .expect("serialize http request");

        self.cx.plugin("http", self.method, input, move |r: PluginResponse| {
            then(decode_outcome(&r))
        });
    }
}

/// Decode what the shell put in `PluginResponse.output`. A shell that returns
/// something undecodable is a bug, but it must not panic the app — surface it as a
/// transport error instead.
///
/// When the payload is valid UTF-8 text rather than a bincode `HttpOutcome` — e.g. an
/// old shell that predates this capability, or a "plugin not available" message from a
/// shell with no `http` plugin registered — surface that text verbatim instead of the
/// bincode decode error, so version skew is self-diagnosing rather than reading as an
/// opaque "malformed http response".
fn decode_outcome(r: &PluginResponse) -> HttpOutcome {
    HttpOutcome::decode(&r.output).unwrap_or_else(|e| {
        // An empty `output` decodes as `Some("")` via `as_text()`, which is not a
        // diagnostic message worth preferring over the bincode error — only fall back
        // to the text when there is actually text to show.
        let message = r
            .as_text()
            .filter(|t| !t.is_empty())
            .map(str::to_string)
            .unwrap_or_else(|| format!("malformed http response: {e}"));
        HttpOutcome::TransportError { message }
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn resp(status: u16, body: &str) -> HttpOutcome {
        HttpOutcome::Response {
            status,
            headers: vec![HttpHeader { name: "Content-Type".into(), value: "application/json".into() }],
            body: body.as_bytes().to_vec(),
        }
    }

    #[test]
    fn status_is_some_for_response_and_none_for_transport_error() {
        assert_eq!(resp(200, "").status(), Some(200));
        assert_eq!(HttpOutcome::TransportError { message: "offline".into() }.status(), None);
    }

    #[test]
    fn is_success_covers_exactly_2xx() {
        assert!(!resp(199, "").is_success());
        assert!(resp(200, "").is_success());
        assert!(resp(299, "").is_success());
        assert!(!resp(300, "").is_success());
        assert!(!resp(409, "").is_success());
        assert!(!HttpOutcome::TransportError { message: "x".into() }.is_success());
    }

    #[test]
    fn body_and_text_behave_on_valid_and_invalid_utf8() {
        assert_eq!(resp(200, "hi").body(), b"hi");
        assert_eq!(resp(200, "hi").text(), Some("hi"));

        let binary = HttpOutcome::Response { status: 200, headers: vec![], body: vec![0xff, 0xfe] };
        assert_eq!(binary.body(), &[0xff, 0xfe]);
        assert_eq!(binary.text(), None, "invalid UTF-8 must not panic or lossily convert");

        let err = HttpOutcome::TransportError { message: "x".into() };
        assert_eq!(err.body(), b"");
        assert_eq!(err.text(), None);
    }

    #[test]
    fn header_lookup_is_case_insensitive() {
        assert_eq!(resp(200, "").header("content-type"), Some("application/json"));
        assert_eq!(resp(200, "").header("CONTENT-TYPE"), Some("application/json"));
        assert_eq!(resp(200, "").header("missing"), None);
    }

    #[test]
    fn bincode_round_trips_both_variants() {
        for original in [
            resp(409, "conflict"),
            HttpOutcome::TransportError { message: "connection refused".into() },
        ] {
            let bytes = original.encode();
            assert_eq!(HttpOutcome::decode(&bytes).unwrap(), original);
        }
    }

    #[test]
    fn decode_rejects_garbage_without_panicking() {
        assert!(HttpOutcome::decode(&[0xff, 0xff, 0xff]).is_err());
    }

    #[test]
    fn decode_outcome_falls_back_to_plain_text_for_undecodable_utf8_payloads() {
        // A shell that returns a plain-text message instead of a bincode HttpOutcome
        // (old shell after a core upgrade, or "plugin not available") should have that
        // message surface verbatim, not get replaced by an opaque bincode error.
        let r = PluginResponse::text(false, "plugin 'http' not available");
        match decode_outcome(&r) {
            HttpOutcome::TransportError { message } => {
                assert_eq!(message, "plugin 'http' not available");
            }
            other => panic!("expected TransportError, got {other:?}"),
        }

        // Genuinely undecodable, non-UTF-8 bytes still produce a diagnosable message
        // rather than panicking.
        let r = PluginResponse { ok: false, output: vec![0xff, 0xfe, 0xfd] };
        match decode_outcome(&r) {
            HttpOutcome::TransportError { message } => {
                assert!(message.starts_with("malformed http response:"), "got: {message}");
            }
            other => panic!("expected TransportError, got {other:?}"),
        }
    }

    #[test]
    fn decode_outcome_keeps_the_bincode_error_for_an_empty_payload() {
        // An empty `output` is valid UTF-8 text ("") but that's not a diagnosable
        // message — `TransportError { message: "" }` would be undebuggable. Preferring
        // the bincode decode error keeps the failure self-diagnosing.
        let r = PluginResponse { ok: false, output: Vec::new() };
        match decode_outcome(&r) {
            HttpOutcome::TransportError { message } => {
                assert!(!message.is_empty(), "expected a diagnostic message, got empty string");
                assert!(message.starts_with("malformed http response:"), "got: {message}");
            }
            other => panic!("expected TransportError, got {other:?}"),
        }
    }
}