rikka 0.2.0

Async HTTP client for native & WASM.
Documentation
pub struct Response {
    /// HTTP status code.
    pub status: u16,
    /// Lowercased names; one entry per value, so `set-cookie` repeats.
    headers: Vec<(String, String)>,
    body: Vec<u8>,
}

impl Response {
    pub(crate) fn new(status: u16, headers: Vec<(String, String)>, body: Vec<u8>) -> Self {
        Self {
            status,
            headers,
            body,
        }
    }

    /// The first header with the given name, case-insensitively.
    #[must_use]
    pub fn header(&self, name: &str) -> Option<String> {
        self.headers
            .iter()
            .find(|(key, _)| key.eq_ignore_ascii_case(name))
            .map(|(_, value)| value.clone())
    }

    /// The `Location` header, if present.
    #[must_use]
    pub fn location(&self) -> Option<String> {
        self.header("location")
    }

    /// All `Set-Cookie` header values.
    #[must_use]
    pub fn set_cookies(&self) -> Vec<String> {
        self.headers
            .iter()
            .filter(|(key, _)| key.eq_ignore_ascii_case("set-cookie"))
            .map(|(_, value)| value.clone())
            .collect()
    }

    /// Extract the value of a cookie by name from the `Set-Cookie` headers,
    /// i.e. the `v` of the first `name=v; ...` entry.
    #[must_use]
    pub fn set_cookie_value(&self, name: &str) -> Option<String> {
        let prefix = format!("{name}=");
        self.set_cookies()
            .into_iter()
            .find(|cookie| cookie.starts_with(&prefix))
            .and_then(|cookie| {
                cookie
                    .split(';')
                    .next()
                    .and_then(|pair| pair.split_once('='))
                    .map(|(_, value)| value.to_owned())
            })
    }

    /// The raw response body.
    #[must_use]
    pub fn bytes(&self) -> &[u8] {
        &self.body
    }

    /// The response body decoded as UTF-8, lossily.
    #[must_use]
    pub fn text(&self) -> String {
        String::from_utf8_lossy(&self.body).into_owned()
    }

    /// Deserialize the response body as JSON.
    #[cfg(feature = "json")]
    pub fn json<T: serde::de::DeserializeOwned>(&self) -> crate::error::Result<T> {
        Ok(serde_json::from_slice(&self.body)?)
    }
}

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

    fn response(headers: &[(&str, &str)]) -> Response {
        Response::new(
            200,
            headers
                .iter()
                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
                .collect(),
            Vec::new(),
        )
    }

    #[test]
    fn collects_every_set_cookie_entry() {
        let response = response(&[
            ("content-type", "text/html"),
            ("set-cookie", "a=1; Path=/"),
            ("set-cookie", "b=2; HttpOnly"),
        ]);
        assert_eq!(response.set_cookies(), vec!["a=1; Path=/", "b=2; HttpOnly"]);
    }

    #[test]
    fn cookie_value_stops_at_attributes() {
        let response = response(&[("set-cookie", "lemonldap=abc; Path=/; HttpOnly")]);
        assert_eq!(
            response.set_cookie_value("lemonldap").as_deref(),
            Some("abc")
        );
    }

    #[test]
    fn cookie_value_keeps_embedded_equals() {
        let response = response(&[("set-cookie", "token=a=b; Secure")]);
        assert_eq!(response.set_cookie_value("token").as_deref(), Some("a=b"));
    }

    #[test]
    fn missing_cookie_is_none() {
        let response = response(&[("set-cookie", "other=1")]);
        assert_eq!(response.set_cookie_value("lemonldap"), None);
    }

    #[test]
    fn header_lookup_is_case_insensitive() {
        let response = response(&[("location", "https://example.org/next")]);
        assert_eq!(
            response.header("Location").as_deref(),
            Some("https://example.org/next")
        );
        assert_eq!(
            response.location().as_deref(),
            Some("https://example.org/next")
        );
    }
}