indieweb 0.10.0

A collection of utilities for working with the IndieWeb.
Documentation
use std::collections::HashMap;

use http::{header::ACCEPT, HeaderValue};
use url::Url;

use crate::mf2;

// NOTE: Expand to allow direct deserialization of this from a response.
// NOTE: Add support for parsing from a string into this type.
/// Provides a wrapper of what relations look like.
#[derive(Debug, Clone)]
pub struct RelMap(HashMap<String, Vec<url::Url>>);

impl From<HashMap<String, Vec<url::Url>>> for RelMap {
    fn from(v: HashMap<String, Vec<url::Url>>) -> Self {
        Self(v)
    }
}

impl TryInto<HeaderValue> for RelMap {
    type Error = crate::Error;

    fn try_into(self) -> Result<HeaderValue, Self::Error> {
        let link_value = self
            .iter()
            .flat_map(|(name, urls)| {
                urls.iter()
                    .map(move |url| format!("<{url}>; rel=\"{name}\""))
            })
            .collect::<Vec<String>>()
            .join(" , ");

        Ok(HeaderValue::from_str(&link_value)?)
    }
}

impl std::ops::Deref for RelMap {
    type Target = HashMap<String, Vec<url::Url>>;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

pub(crate) fn get_rels_for_header(header_value: &HeaderValue, rel: &str, base_url: &Url) -> Vec<url::Url> {
    let header_value = String::from_utf8(header_value.as_bytes().to_vec()).unwrap_or_default();
    header_value
        .split(',')
        .collect::<Vec<&str>>()
        .into_iter()
        .map(|link_rel: &str| {
            let mut parameters = link_rel.split(';').collect::<Vec<&str>>();
            let uri_value = parameters.remove(0).to_owned().trim().to_string();
            (uri_value, parameters)
        })
        .filter(|(_uri, parameters)| {
            parameters
                .iter()
                .map(|s| s.to_lowercase())
                .map(|s| s.trim().to_string())
                .filter(|r| r.starts_with("rel="))
                .flat_map(|rv| {
                    let rel_value = rv.strip_prefix("rel=").unwrap_or("");
                    rel_value
                        .trim_matches('"')
                        .trim_matches('\'')
                        .split_whitespace()
                        .map(|s| s.to_string())
                        .collect::<Vec<_>>()
                })
                .any(|s| s == rel)
        })
        .filter_map(|(uri, _parameters)| {
            uri.strip_prefix('<')
                .map(|u| u.to_string())
                .unwrap_or_default()
                .strip_suffix('>')
                .and_then(|u| u.parse().or_else(|_| base_url.join(u)).ok())
        })
        .collect::<Vec<Url>>()
}

fn get_rels_from_headers(headers: &http::HeaderMap, rel: &str, base_url: &Url) -> Vec<url::Url> {
    headers
        .get_all("link")
        .into_iter()
        .flat_map(|uri_header_value| get_rels_for_header(uri_header_value, rel, base_url))
        .collect::<Vec<_>>()
}

/// Resolves all of the relating links for a particular URL.
// TODO : Send a HEAD request to get headers instead
// TODO: Return the response's status code.
// FIXME: Refactor this to use the mf2 library.
#[tracing::instrument(skip(client))]
pub async fn for_url(
    client: &impl crate::http::Client,
    url: &Url,
    rels: &[&str],
    method: &str,
) -> Result<RelMap, crate::Error> {
    let req = http::Request::builder()
        .uri(url.as_str())
        .header(ACCEPT, "text/html, text/plain, */*")
        .method(method)
        .body(Default::default())
        .map_err(crate::Error::Http)?;

    let resp = client.send_request(req).await?;
    let headers = resp.headers().clone();
    let header_rels: HashMap<String, Vec<_>> = HashMap::from_iter(
        rels.iter()
            .map(|rel| (rel.to_string(), get_rels_from_headers(&headers, rel, url))),
    );
    let mut rels_map = HashMap::from_iter(header_rels);

    if method == "GET" {
        // FIXME: Confirm that we got back a HTML document via content-type.
        let document = mf2::http::to_mf2_document(
            resp.map(|body| body.as_bytes().to_vec()),
            url.as_str(),
        )
        .map_err(crate::Error::from)?;
        let body_rels: HashMap<String, Vec<_>> =
            HashMap::from_iter(document.rels.by_rels().into_iter());

        for (header, rels) in body_rels {
            if !rels_map.contains_key(&header) {
                rels_map.insert(header.clone(), Vec::default());
            }

            if let Some(r) = rels_map.get_mut(&header) {
                r.extend(rels)
            }
        }
    }

    rels_map = HashMap::from_iter(rels_map.into_iter().filter(|(_, v)| !v.is_empty()));

    if !rels_map.is_empty() {
        tracing::trace!(rels = format!("{:?}", rels_map), "Found relations.");
    } else {
        tracing::trace!(rels = format!("{:?}", rels), "No relations found.");
    }

    Ok(rels_map.into())
}

#[cfg(test)]
mod test;