indieweb 0.10.0

A collection of utilities for working with the IndieWeb.
Documentation
use crate::{algorithms::Properties, traits::as_string_or_list};
use std::string::ToString;

pub mod action;
pub mod extension;
pub mod paging;
pub mod query;
pub mod known_properties;

/// A collection of the kind of properties one can expect in an incoming Micropub creation request.
///
/// These properties represent all of the kind of fields one can expect as extensions to Micropub.
/// Conventionally, these are considered 'server' directives in some cases in and other just
/// properties with specific intentions on the result of the content's presentation or storage.
#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct Parameters {
    /// Provides an optional value of a [post's status][PostStatus].
    #[serde(
        default,
        alias = "mp-post-status",
        alias = "post-status",
        skip_serializing_if = "Option::is_none"
    )]
    pub status: Option<extension::PostStatus>,

    #[serde(
        default,
        alias = "mp-category",
        with = "as_string_or_list",
        skip_serializing_if = "Vec::is_empty"
    )]
    pub category: Vec<String>,

    /// The slug to use for this content.
    ///
    /// See <https://indieweb.org/Micropub-extensions#Slug>
    #[serde(alias = "mp-slug", default, skip_serializing_if = "Option::is_none")]
    pub slug: Option<String>,

    /// Associated channels to this content.
    ///
    /// See <https://github.com/indieweb/micropub-extensions/issues/40>
    #[serde(
        default,
        alias = "mp-channel",
        alias = "channel",
        with = "as_string_or_list",
        skip_serializing_if = "Vec::is_empty"
    )]
    pub channels: Vec<String>,

    /// A list of values representing syndication targets that this post should also be sent to.
    #[serde(
        default,
        alias = "mp-syndicate-to",
        with = "as_string_or_list",
        skip_serializing_if = "Vec::is_empty"
    )]
    pub syndicate_to: Vec<String>,

    /// The destination to send this content to.
    ///
    /// See <https://indieweb.org/Micropub-extensions#Destination>
    #[serde(
        default,
        alias = "mp-destination",
        skip_serializing_if = "Option::is_none"
    )]
    pub destination: Option<url::Url>,

    /// A list of the audiences that can view this post.
    ///
    /// See <https://indieweb.org/Micropub-extensions#Audience>
    #[serde(
        default,
        alias = "audience",
        alias = "mp-audience",
        with = "as_string_or_list",
        skip_serializing_if = "Vec::is_empty"
    )]
    pub audience: Vec<String>,

    /// What kind of content visibility this post has.
    ///
    /// See <https://indieweb.org/Micropub-extensions#Visibility> for more information.
    #[serde(
        alias = "visibility",
        alias = "mp-visibility",
        default,
        skip_serializing_if = "Option::is_none"
    )]
    pub visibility: Option<extension::Visibility>,
}

impl TryFrom<Properties> for Parameters {
    type Error = serde_json::Error;

    fn try_from(props: Properties) -> Result<Self, Self::Error> {
        let obj = props
            .0
            .into_iter()
            .map(|(k, v)| {
                (
                    k,
                    if let serde_json::Value::Array(ref va) = v {
                        if va.len() == 1 {
                            va.first().unwrap().clone()
                        } else {
                            v
                        }
                    } else {
                        v
                    },
                )
            })
            .collect::<_>();

        serde_json::from_value(serde_json::Value::Object(obj))
    }
}

#[test]
fn deser_to_params() {
    assert_eq!(
        serde_json::from_value(serde_json::json!({
            "category": ["tag", "tag2"]
        }))
        .ok(),
        Some(Parameters {
            category: vec!["tag".into(), "tag2".into()],
            ..Default::default()
        }),
        "deserializes from direct JSON"
    );
}

/// A general representation of errors from Micropub.
///
/// Pulled from <https://micropub.spec.indieweb.org/#error-response>
#[derive(thiserror::Error, Debug, serde::Deserialize, PartialEq, Eq, Clone, serde::Serialize)]
#[error("Failed to process a request from the Micropub server: {error:?}")]
pub struct Error {
    pub error: ErrorKind,
    pub error_description: String,

    #[serde(flatten, default)]
    pub fields: serde_json::Map<String, serde_json::Value>,
}

impl Error {
    /// Crafts an error about a missing header in a response.
    ///
    /// * `header_name`: [std::fmt::Display] The name of the header that's missing.
    pub fn missing_header(header_name: impl std::fmt::Display) -> Self {
        Self {
            error: ErrorKind::InvalidRequest,
            error_description: format!("The header {} was missing in the response.", header_name),
            fields: Default::default(),
        }
    }

    pub fn bad_request(message: impl std::fmt::Display) -> Self {
        Self {
            error: ErrorKind::InvalidRequest,
            error_description: format!("{}", message),
            fields: Default::default(),
        }
    }

    pub fn unexpected_status_code(
        status: http::StatusCode,
        accepted: &[http::StatusCode],
    ) -> Error {
        Self {
            error: ErrorKind::InvalidRequest,
            error_description: format!(
                "The response's status code was {} and not one of the expected {}",
                status.as_u16(),
                accepted
                    .iter()
                    .map(|s| s.as_u16().to_string())
                    .collect::<Vec<_>>()
                    .join(", ")
            ),
            fields: Default::default(),
        }
    }
}

/// A representation of the kind of failures that can occur.
///
/// Pulled from <https://micropub.spec.indieweb.org/#error-response>
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq, Clone)]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
    /// <https://micropub.spec.indieweb.org/#error-response-li-1>
    Forbidden,

    /// <https://micropub.spec.indieweb.org/#error-response-li-2>
    InsufficentScope,

    /// <https://micropub.spec.indieweb.org/#error-response-li-3>
    Unauthorized,

    /// <https://micropub.spec.indieweb.org/#error-response-li-4>
    InvalidRequest,
}

#[test]
fn error_from_json() {
    assert_eq!(
        Some(Error {
            error: ErrorKind::Forbidden,
            error_description: "Welp".to_string(),
            fields: Default::default()
        }),
        serde_json::from_value(serde_json::json!({
            "error": "forbidden",
            "error_description": "Welp"
        }))
        .ok(),
        "converts safely"
    );
}

fn convert_error(e: crate::Error) -> crate::Error {
    e
}

#[test]
fn deser_params() {
    let params_json = serde_json::json!({
        "slug": ["wow"]
    });

    let props: Properties = serde_json::from_value(params_json).expect("failed to deser json");
    let params: Parameters = props.try_into().expect("failed to convert");

    assert_eq!(params.slug, Some("wow".to_string()));
}