From 522d336e5a5a70efe827113505cef936d18e6a5e Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Thu, 14 Nov 2024 02:46:52 +0100 Subject: [PATCH] `hostmeta`: Refactor to version 0.2.0 --- acrate-hostmeta/Cargo.toml | 2 +- acrate-hostmeta/src/any.rs | 286 ++++++++++++++ acrate-hostmeta/src/descriptor.rs | 475 ------------------------ acrate-hostmeta/src/jrd.rs | 251 +++++++++++++ acrate-hostmeta/src/lib.rs | 13 +- acrate-hostmeta/src/link.rs | 64 ---- acrate-hostmeta/src/property.rs | 17 - acrate-hostmeta/src/xrd.rs | 292 +++++++++++++++ acrate-hostmeta/tests/hostmeta_tests.rs | 4 +- 9 files changed, 837 insertions(+), 567 deletions(-) create mode 100644 acrate-hostmeta/src/any.rs delete mode 100644 acrate-hostmeta/src/descriptor.rs create mode 100644 acrate-hostmeta/src/jrd.rs delete mode 100644 acrate-hostmeta/src/link.rs delete mode 100644 acrate-hostmeta/src/property.rs create mode 100644 acrate-hostmeta/src/xrd.rs diff --git a/acrate-hostmeta/Cargo.toml b/acrate-hostmeta/Cargo.toml index 14f9f1d..a95bd02 100644 --- a/acrate-hostmeta/Cargo.toml +++ b/acrate-hostmeta/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "acrate-hostmeta" -version = "0.1.0" +version = "0.2.0" edition = "2021" [dependencies] diff --git a/acrate-hostmeta/src/any.rs b/acrate-hostmeta/src/any.rs new file mode 100644 index 0000000..a9c7b37 --- /dev/null +++ b/acrate-hostmeta/src/any.rs @@ -0,0 +1,286 @@ +use thiserror::Error; +use crate::jrd::{GetJRDError, ResourceDescriptorJRD}; +use crate::xrd::{GetXRDError, ResourceDescriptorXRD}; + +#[derive(Debug, Clone)] +pub enum ResourceDescriptor { + /// The resource descriptor is in JRD format. + JRD(ResourceDescriptorJRD), + /// The resource descriptor is in XRD format. + XRD(ResourceDescriptorXRD), +} + +impl ResourceDescriptor { + /// Attempt to get a [`ResourceDescriptor`] at the given URL in various ways. + /// + /// In order, this method attempts: + /// + /// 1. HTTPS [XRD](ResourceDescriptorJRD::get) + /// 2. HTTPS [JRD](ResourceDescriptorJRD::get) + /// 3. HTTPS [JRD](ResourceDescriptorJRD::get) with .json path suffix + /// 4. HTTP [XRD](ResourceDescriptorJRD::get) + /// 5. HTTP [JRD](ResourceDescriptorJRD::get) + /// 6. HTTP [JRD](ResourceDescriptorJRD::get) with .json path suffix + /// + /// # Notes + /// + /// This follows redirects until the redirect chain is 10 hops; see [`reqwest::redirect`] for more info. + /// + /// # Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use acrate_hostmeta::any::ResourceDescriptor; + /// + /// let client = reqwest::Client::new(); + /// let url: reqwest::Url = "https://junimo.party/.well-known/host-meta".parse() + /// .expect("URL to be valid"); + /// + /// let rd = ResourceDescriptor::get(&client, url) + /// .await + /// .expect("resource descriptor to be discovered correctly"); + /// # }) + /// ``` + /// + pub async fn get(client: &reqwest::Client, mut url: reqwest::Url) -> Result { + use GetError::*; + + log::debug!("Discovering resource descriptor document at: {url}"); + + log::trace!("Unsetting URL query..."); + url.set_query(None); + + log::trace!("Unsetting URL fragment..."); + url.set_fragment(None); + + log::trace!("Setting URL scheme to HTTPS..."); + url.set_scheme("https") + .map_err(UrlManipulation)?; + + log::trace!("Cloning URL for HTTPS XRD retrieval..."); + let https_xrd_url = url.clone(); + + log::trace!("Attempting HTTPS XRD retrieval..."); + let https_xrd = match ResourceDescriptorXRD::get(client, https_xrd_url).await { + Ok(data) => { + log::trace!("HTTPS XRD retrieval was successful, returning..."); + return Ok(Self::XRD(data)) + } + Err(err) => { + log::warn!("HTTPS XRD retrieval failed."); + err + } + }; + + log::trace!("Cloning URL for HTTPS JRD retrieval..."); + let https_jrd_url = url.clone(); + + log::trace!("Attempting HTTPS JRD retrieval..."); + let https_jrd = match ResourceDescriptorJRD::get(client, https_jrd_url).await { + Ok(data) => { + log::trace!("HTTPS JRD retrieval was successful, returning..."); + return Ok(Self::JRD(data)) + } + Err(err) => { + log::warn!("HTTPS JRD retrieval failed."); + err + } + }; + + log::trace!("Cloning URL for HTTPS JRD .json retrieval..."); + let mut https_jrdj_url = url.clone(); + + log::trace!("Altering URL path for HTTPS JRD .json retrieval..."); + https_jrdj_url.set_path( + &format!("{}.json", https_jrdj_url.path()) + ); + + log::trace!("Attempting HTTPS JRD .json retrieval..."); + let https_jrdj = match ResourceDescriptorJRD::get(client, https_jrdj_url).await { + Ok(data) => { + log::trace!("HTTPS JRD .json retrieval was successful, returning..."); + return Ok(Self::JRD(data)) + } + Err(err) => { + log::warn!("HTTPS JRD .json retrieval failed."); + err + } + }; + + log::trace!("Setting URL scheme to HTTP..."); + url.set_scheme("http") + .map_err(UrlManipulation)?; + + log::trace!("Cloning URL for HTTP XRD retrieval..."); + let http_xrd_url = url.clone(); + + log::trace!("Attempting HTTP XRD retrieval..."); + let http_xrd = match ResourceDescriptorXRD::get(client, http_xrd_url).await { + Ok(data) => { + log::trace!("HTTP XRD retrieval was successful, returning..."); + return Ok(Self::XRD(data)) + } + Err(err) => { + log::warn!("HTTP XRD retrieval failed."); + err + } + }; + + log::trace!("Cloning URL for HTTP JRD retrieval..."); + let http_jrd_url = url.clone(); + + log::trace!("Attempting HTTP JRD retrieval..."); + let http_jrd = match ResourceDescriptorJRD::get(client, http_jrd_url).await { + Ok(data) => { + log::trace!("HTTP JRD retrieval was successful, returning..."); + return Ok(Self::JRD(data)) + } + Err(err) => { + log::warn!("HTTP JRD retrieval failed."); + err + } + }; + + log::trace!("Cloning URL for HTTP JRD .json retrieval..."); + let mut http_jrdj_url = url.clone(); + + log::trace!("Altering URL path for HTTPS JRD .json retrieval..."); + http_jrdj_url.set_path( + &format!("{}.json", http_jrdj_url.path()) + ); + + log::trace!("Attempting HTTP JRD .json retrieval..."); + let http_jrdj = match ResourceDescriptorJRD::get(client, http_jrdj_url).await { + Ok(data) => { + log::trace!("HTTP JRD .json retrieval was successful, returning..."); + return Ok(Self::JRD(data)) + } + Err(err) => { + log::warn!("HTTP JRD .json retrieval failed."); + err + } + }; + + Err(Fetch(GetFetchFailures { + https_xrd, + https_jrd, + https_jrdj, + http_xrd, + http_jrd, + http_jrdj, + })) + } + + /// Well-known path for host-meta documents. + /// + /// # Specification + /// + /// - + /// + pub const WELLKNOWN_HOSTMETA_PATH: &'static str = "/.well-known/host-meta"; + + /// Attempt to discover a host-meta document at the given base URL. + /// + /// # Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use acrate_hostmeta::any::ResourceDescriptor; + /// + /// let client = reqwest::Client::new(); + /// let base: reqwest::Url = "https://junimo.party".parse() + /// .expect("URL to be valid"); + /// + /// let rd = ResourceDescriptor::discover_hostmeta(&client, base) + /// .await + /// .expect("host-meta to be discovered correctly"); + /// }) + /// ``` + /// + pub async fn discover_hostmeta(client: &reqwest::Client, mut base: reqwest::Url) -> Result { + log::debug!("Attempting discovery of host-meta document of: {base}"); + + log::trace!("Adding well-known path to the given base URL: {}", Self::WELLKNOWN_HOSTMETA_PATH); + base.set_path(Self::WELLKNOWN_HOSTMETA_PATH); + + log::trace!("Starting discovery at: {base}"); + Self::get(client, base) + .await + } + + pub fn jrd(self) -> ResourceDescriptorJRD { + self.into() + } + + pub fn xrd(self) -> ResourceDescriptorXRD { + self.into() + } +} + +impl From for ResourceDescriptor { + fn from(value: ResourceDescriptorJRD) -> Self { + Self::JRD(value) + } +} + +impl From for ResourceDescriptor { + fn from(value: ResourceDescriptorXRD) -> Self { + Self::XRD(value) + } +} + +impl From for ResourceDescriptorJRD { + fn from(value: ResourceDescriptor) -> Self { + match value { + ResourceDescriptor::JRD(jrd) => jrd, + ResourceDescriptor::XRD(xrd) => xrd.into(), + } + } +} + +impl From for ResourceDescriptorXRD { + fn from(value: ResourceDescriptor) -> Self { + match value { + ResourceDescriptor::JRD(jrd) => jrd.into(), + ResourceDescriptor::XRD(xrd) => xrd, + } + } +} + + +/// Error occurred during [`ResourceDescriptor::discover`]. +#[derive(Debug, Error)] +pub enum GetError { + /// Manipulation of the provided base [`reqwest::Url`] failed. + /// + /// See [reqwest::Url::set_scheme] for possible causes. + #[error("manipulation of the provided URL failed")] + UrlManipulation(()), + + /// All attempts of fetching a resource descriptor document failed. + #[error("fetchign the resource descriptor document failed")] + Fetch(GetFetchFailures), +} + +/// Request errors occurred during [`ResourceDescriptor::discover`]. +#[derive(Debug, Error)] +#[error("all attempts of fetching the resource descriptor document failed")] +pub struct GetFetchFailures { + /// HTTPS XRD retrieval. + pub https_xrd: GetXRDError, + + /// HTTPS JRD retrieval. + pub https_jrd: GetJRDError, + + /// HTTPS JRD with .json extension retrieval. + pub https_jrdj: GetJRDError, + + /// HTTPS XRD retrieval. + pub http_xrd: GetXRDError, + + /// HTTP JRD retrieval. + pub http_jrd: GetJRDError, + + /// HTTP JRD with .json extension retrieval. + pub http_jrdj: GetJRDError, +} diff --git a/acrate-hostmeta/src/descriptor.rs b/acrate-hostmeta/src/descriptor.rs deleted file mode 100644 index 33216a7..0000000 --- a/acrate-hostmeta/src/descriptor.rs +++ /dev/null @@ -1,475 +0,0 @@ -use serde::Deserialize; -use thiserror::Error; -use crate::link::ResourceDescriptorLink; -use crate::property::ResourceDescriptorProperty; - -/// A resource descriptor object. -/// -/// # Specification -/// -/// - -/// - -/// -#[derive(Debug, Clone, Deserialize)] -pub struct ResourceDescriptor { - /// The resource this document refers to. - /// - /// # Specification - /// - /// - - /// - #[serde(alias = "Subject")] - pub subject: Option, - - /// Other names the resource described by this document can be referred to. - /// - /// # Specification - /// - /// - - /// - #[serde(alias = "Alias")] - pub aliases: Option>, - - /// Additional information about the resource described by this document. - /// - /// # Specification - /// - /// - - /// - #[serde(alias = "Property")] - pub properties: Option>, - - /// Links established between the [`Self::subject`] and other resources. - /// - /// # Specification - /// - /// - - /// - - /// - #[serde(alias = "Link")] - pub links: Option>, -} - -impl ResourceDescriptor { - /// Get a JRD (JSON [`ResourceDescriptor`]). - /// - /// # Notes - /// - /// This follows redirects until the redirect chain is 10 hops; see [`reqwest::redirect`] for more info. - /// - /// # Examples - /// - /// ``` - /// # tokio_test::block_on(async { - /// use acrate_hostmeta::ResourceDescriptor; - /// - /// let client = reqwest::Client::new(); - /// let url: reqwest::Url = "https://junimo.party/.well-known/nodeinfo".parse() - /// .expect("URL to be valid"); - /// - /// let rd = ResourceDescriptor::get_jrd(&client, url) - /// .await - /// .expect("JRD to be processed correctly"); - /// # }) - /// ``` - /// - pub async fn get_jrd(client: &reqwest::Client, url: reqwest::Url) -> Result { - use GetJRDError::*; - - log::debug!("Getting JRD document at: {url}"); - - log::trace!("Building request..."); - let request = { - log::trace!("Creating new request..."); - let mut request = reqwest::Request::new(reqwest::Method::GET, url); - - log::trace!("Setting request headers..."); - let headers = request.headers_mut(); - - log::trace!("Setting `Accept: application/json`..."); - let _ = headers.insert(reqwest::header::ACCEPT, "application/json".parse().unwrap()); - - request - }; - - log::trace!("Sending request..."); - let response = client.execute(request) - .await - .map_err(Request)?; - - log::trace!("Checking `Content-Type` of the response..."); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .ok_or(ContentTypeMissing)?; - - log::trace!("Extracting MIME type from the `Content-Type` header..."); - let mime_type = crate::utils::extract_mime_from_content_type(content_type) - .ok_or(ContentTypeInvalid)?; - - log::trace!("Ensuring MIME type is acceptable for JRD parsing..."); - if !(mime_type == "application/json" || mime_type == "application/jrd+json") { - log::error!("MIME type `{mime_type}` is not acceptable for JRD parsing."); - return Err(ContentTypeInvalid) - } - - log::trace!("Attempting to parse response as JSON..."); - let data = response.json::() - .await - .map_err(Parse)?; - - Ok(data) - } - - /// Get a XRD (Extensible [`ResourceDescriptor`]). - /// - /// # Notes - /// - /// This follows redirects until the redirect chain is 10 hops; see [`reqwest::redirect`] for more info. - /// - /// # Examples - /// - /// ``` - /// # tokio_test::block_on(async { - /// use acrate_hostmeta::ResourceDescriptor; - /// - /// let client = reqwest::Client::new(); - /// let url: reqwest::Url = "https://junimo.party/.well-known/host-meta".parse() - /// .expect("URL to be valid"); - /// - /// let rd = ResourceDescriptor::get_xrd(&client, url) - /// .await - /// .expect("XRD to be processed correctly"); - /// # }) - /// ``` - /// - pub async fn get_xrd(client: &reqwest::Client, url: reqwest::Url) -> Result { - use GetXRDError::*; - - log::debug!("Getting host-meta XRD document at: {url}"); - - log::trace!("Building request..."); - let request = { - log::trace!("Creating new request..."); - let mut request = reqwest::Request::new(reqwest::Method::GET, url); - - log::trace!("Setting request headers..."); - let headers = request.headers_mut(); - - log::trace!("Setting `Accept: application/xrd+xml`..."); - let _ = headers.insert(reqwest::header::ACCEPT, "application/xrd+xml".parse().unwrap()); - - request - }; - - log::trace!("Sending request..."); - let response = client.execute(request) - .await - .map_err(Request)?; - - log::trace!("Checking `Content-Type` of the response..."); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .ok_or(ContentTypeMissing)?; - - log::trace!("Extracting MIME type from the `Content-Type` header..."); - let mime_type = crate::utils::extract_mime_from_content_type(content_type) - .ok_or(ContentTypeInvalid)?; - - log::trace!("Ensuring MIME type is acceptable for XRD parsing..."); - if mime_type != "application/xrd+xml" { - log::error!("MIME type `{mime_type}` is not acceptable for XRD parsing."); - return Err(ContentTypeInvalid) - } - - log::trace!("Attempting to parse response as text..."); - let data = response.text() - .await - .map_err(Decode)?; - - log::trace!("Parsing response as XML..."); - let data = quick_xml::de::from_str::(&data) - .map_err(Parse)?; - - Ok(data) - } - - /// Attempt to discover a [`ResourceDescriptor`] at the given URL in various ways. - /// - /// In order, this method attempts: - /// - /// 1. HTTPS [XRD](Self::get_xrd) - /// 2. HTTPS [JRD](Self::get_jrd) - /// 3. HTTPS [JRD](Self::get_jrd) with .json path extension - /// 4. HTTP [XRD](Self::get_xrd) - /// 5. HTTP [JRD](Self::get_jrd) - /// 6. HTTP [JRD](Self::get_jrd) with .json path extension - /// - /// # Notes - /// - /// This follows redirects until the redirect chain is 10 hops; see [`reqwest::redirect`] for more info. - /// - /// # Examples - /// - /// ``` - /// # tokio_test::block_on(async { - /// use acrate_hostmeta::ResourceDescriptor; - /// - /// let client = reqwest::Client::new(); - /// let url: reqwest::Url = "https://junimo.party/.well-known/host-meta".parse() - /// .expect("URL to be valid"); - /// - /// let rd = ResourceDescriptor::discover(&client, url) - /// .await - /// .expect("resource descriptor to be discovered correctly"); - /// # }) - /// ``` - /// - pub async fn discover(client: &reqwest::Client, mut url: reqwest::Url) -> Result { - use ResourceDescriptorDiscoveryError::*; - - log::debug!("Discovering resource descriptor document at: {url}"); - - log::trace!("Unsetting URL query..."); - url.set_query(None); - - log::trace!("Unsetting URL fragment..."); - url.set_fragment(None); - - log::trace!("Setting URL scheme to HTTPS..."); - url.set_scheme("https") - .map_err(UrlManipulation)?; - - log::trace!("Cloning URL for HTTPS XRD retrieval..."); - let https_xrd_url = url.clone(); - - log::trace!("Attempting HTTPS XRD retrieval..."); - let https_xrd = match Self::get_xrd(client, https_xrd_url).await { - Ok(data) => { - log::trace!("HTTPS XRD retrieval was successful, returning..."); - return Ok(data) - } - Err(err) => { - log::warn!("HTTPS XRD retrieval failed."); - err - } - }; - - log::trace!("Cloning URL for HTTPS JRD retrieval..."); - let https_jrd_url = url.clone(); - - log::trace!("Attempting HTTPS JRD retrieval..."); - let https_jrd = match Self::get_jrd(client, https_jrd_url).await { - Ok(data) => { - log::trace!("HTTPS JRD retrieval was successful, returning..."); - return Ok(data) - } - Err(err) => { - log::warn!("HTTPS JRD retrieval failed."); - err - } - }; - - log::trace!("Cloning URL for HTTPS JRD .json retrieval..."); - let mut https_jrdj_url = url.clone(); - - log::trace!("Altering URL path for HTTPS JRD .json retrieval..."); - https_jrdj_url.set_path( - &format!("{}.json", https_jrdj_url.path()) - ); - - log::trace!("Attempting HTTPS JRD .json retrieval..."); - let https_jrdj = match Self::get_jrd(client, https_jrdj_url).await { - Ok(data) => { - log::trace!("HTTPS JRD .json retrieval was successful, returning..."); - return Ok(data) - } - Err(err) => { - log::warn!("HTTPS JRD .json retrieval failed."); - err - } - }; - - log::trace!("Setting URL scheme to HTTP..."); - url.set_scheme("http") - .map_err(UrlManipulation)?; - - log::trace!("Cloning URL for HTTP XRD retrieval..."); - let http_xrd_url = url.clone(); - - log::trace!("Attempting HTTP XRD retrieval..."); - let http_xrd = match Self::get_xrd(client, http_xrd_url).await { - Ok(data) => { - log::trace!("HTTP XRD retrieval was successful, returning..."); - return Ok(data) - } - Err(err) => { - log::warn!("HTTP XRD retrieval failed."); - err - } - }; - - log::trace!("Cloning URL for HTTP JRD retrieval..."); - let http_jrd_url = url.clone(); - - log::trace!("Attempting HTTP JRD retrieval..."); - let http_jrd = match Self::get_jrd(client, http_jrd_url).await { - Ok(data) => { - log::trace!("HTTP JRD retrieval was successful, returning..."); - return Ok(data) - } - Err(err) => { - log::warn!("HTTP JRD retrieval failed."); - err - } - }; - - log::trace!("Cloning URL for HTTP JRD .json retrieval..."); - let mut http_jrdj_url = url.clone(); - - log::trace!("Altering URL path for HTTPS JRD .json retrieval..."); - http_jrdj_url.set_path( - &format!("{}.json", http_jrdj_url.path()) - ); - - log::trace!("Attempting HTTP JRD .json retrieval..."); - let http_jrdj = match Self::get_jrd(client, http_jrdj_url).await { - Ok(data) => { - log::trace!("HTTP JRD .json retrieval was successful, returning..."); - return Ok(data) - } - Err(err) => { - log::warn!("HTTP JRD .json retrieval failed."); - err - } - }; - - Err( - ResourceDescriptorDiscoveryError::Fetch( - ResourceDescriptorDiscoveryFailures { - https_xrd, - https_jrd, - https_jrdj, - http_xrd, - http_jrd, - http_jrdj, - } - ) - ) - } - - /// Well-known path for host-meta documents. - /// - /// # Specification - /// - /// - - /// - pub const WELLKNOWN_HOSTMETA_PATH: &'static str = "/.well-known/host-meta"; - - /// Attempt to discover a host-meta document at the given base URL. - /// - /// # Examples - /// - /// ``` - /// # tokio_test::block_on(async { - /// use acrate_hostmeta::ResourceDescriptor; - /// - /// let client = reqwest::Client::new(); - /// let base: reqwest::Url = "https://junimo.party".parse() - /// .expect("URL to be valid"); - /// - /// let rd = ResourceDescriptor::discover_hostmeta(&client, base) - /// .await - /// .expect("host-meta to be discovered correctly"); - /// }) - /// ``` - /// - pub async fn discover_hostmeta(client: &reqwest::Client, mut base: reqwest::Url) -> Result { - base.set_path(Self::WELLKNOWN_HOSTMETA_PATH); - - Self::discover(client, base) - .await - } -} - -/// Error occurred during [`ResourceDescriptor::discover`]. -#[derive(Debug, Error)] -pub enum ResourceDescriptorDiscoveryError { - /// Manipulation of the provided base [`reqwest::Url`] failed. - /// - /// See [reqwest::Url::set_scheme] for possible causes. - #[error("manipulation of the provided URL failed")] - UrlManipulation(()), - - /// All attempts of fetching a resource descriptor document failed. - #[error("fetchign the resource descriptor document failed")] - Fetch(ResourceDescriptorDiscoveryFailures), -} - -/// Request errors occurred during [`ResourceDescriptor::discover`]. -#[derive(Debug, Error)] -#[error("all attempts of fetching the resource descriptor document failed")] -pub struct ResourceDescriptorDiscoveryFailures { - /// HTTPS XRD retrieval. - pub https_xrd: GetXRDError, - - /// HTTPS JRD retrieval. - pub https_jrd: GetJRDError, - - /// HTTPS JRD with .json extension retrieval. - pub https_jrdj: GetJRDError, - - /// HTTPS XRD retrieval. - pub http_xrd: GetXRDError, - - /// HTTP JRD retrieval. - pub http_jrd: GetJRDError, - - /// HTTP JRD with .json extension retrieval. - pub http_jrdj: GetJRDError, -} - -/// Error occurred during [`ResourceDescriptor::get_xrd`]. -#[derive(Debug, Error)] -pub enum GetXRDError { - /// The HTTP request failed. - #[error("the HTTP request failed")] - Request(reqwest::Error), - - /// The `Content-Type` header of the response is missing. - #[error("the Content-Type header of the response is missing")] - ContentTypeMissing, - - /// The `Content-Type` header of the response is invalid. - #[error("the Content-Type header of the response is invalid")] - ContentTypeInvalid, - - /// The document failed to be decoded as text. - #[error("the document failed to be decoded as text")] - Decode(reqwest::Error), - - /// The document failed to be parsed as XML by [`quick_xml`]. - #[error("the document failed to be parsed as XML")] - Parse(quick_xml::DeError), -} - -/// Error occurred during [`ResourceDescriptor::get_jrd`]. -#[derive(Debug, Error)] -pub enum GetJRDError { - /// The HTTP request failed. - #[error("the HTTP request failed")] - Request(reqwest::Error), - - /// The `Content-Type` header of the response is missing. - #[error("the Content-Type header of the response is missing")] - ContentTypeMissing, - - /// The `Content-Type` header of the response is invalid. - #[error("the Content-Type header of the response is invalid")] - ContentTypeInvalid, - - /// The document failed to be parsed as JSON by [`reqwest`]. - #[error("the document failed to be parsed as JSON")] - Parse(reqwest::Error), -} diff --git a/acrate-hostmeta/src/jrd.rs b/acrate-hostmeta/src/jrd.rs new file mode 100644 index 0000000..7662b80 --- /dev/null +++ b/acrate-hostmeta/src/jrd.rs @@ -0,0 +1,251 @@ +use std::collections::HashMap; +use serde::{Serialize, Deserialize}; +use thiserror::Error; +use crate::xrd::{ResourceDescriptorLinkXRD, ResourceDescriptorPropertyXRD, ResourceDescriptorTitleXRD, ResourceDescriptorXRD}; + +/// A resource descriptor object in JRD format. +/// +/// # Specification +/// +/// - +/// - +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceDescriptorJRD { + /// The resource this document refers to. + /// + /// # Specification + /// + /// - + /// + pub subject: Option, + + /// Other names the resource described by this document can be referred to. + /// + /// # Specification + /// + /// - + /// + #[serde(default)] + pub aliases: Vec, + + /// Additional information about the resource described by this document. + /// + /// # Specification + /// + /// - + /// + #[serde(default)] + pub properties: HashMap>, + + /// Links established between the [`Self::subject`] and other resources. + /// + /// # Specification + /// + /// - + /// - + /// + #[serde(default)] + pub links: Vec, +} + +/// A link element, which puts the subject resource in relation with another. +/// +/// # Specification +/// +/// - +/// +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceDescriptorLinkJRD { + /// The kind of relation established by the subject with the attached resource. + /// + /// # Specification + /// + /// - + /// + pub rel: String, + + /// The media type of the resource put in relation. + /// + /// # Specification + /// + /// - + /// + pub r#type: Option, + + /// URI to the resource put in relation. + /// + /// # Specification + /// + /// - + /// + pub href: Option, + + /// Titles of the resource put in relation in various languages. + /// + /// # Specification + /// + /// - + /// + #[serde(default)] + pub titles: HashMap, + + /// Additional information about the resource put in relation. + /// + /// # Specification + /// + /// - + /// + #[serde(default)] + pub properties: HashMap>, + + /// Template to fill to get the URL to resource-specific information. + /// + /// # Specification + /// + /// - + /// + pub template: Option, +} + + +impl ResourceDescriptorJRD { + /// Get a [`ResourceDescriptorJRD`] from an URL. + /// + /// # Notes + /// + /// This follows redirects until the redirect chain is 10 hops; see [`reqwest::redirect`] for more info. + /// + /// # Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use acrate_hostmeta::jrd::ResourceDescriptorJRD; + /// + /// let client = reqwest::Client::new(); + /// let url: reqwest::Url = "https://junimo.party/.well-known/nodeinfo".parse() + /// .expect("URL to be valid"); + /// + /// let rd = ResourceDescriptorJRD::get(&client, url) + /// .await + /// .expect("JRD to be processed correctly"); + /// # }) + /// ``` + /// + pub async fn get(client: &reqwest::Client, url: reqwest::Url) -> Result { + use GetJRDError::*; + + log::debug!("Getting JRD document at: {url}"); + + log::trace!("Building request..."); + let request = { + log::trace!("Creating new request..."); + let mut request = reqwest::Request::new(reqwest::Method::GET, url); + + log::trace!("Setting request headers..."); + let headers = request.headers_mut(); + + log::trace!("Setting `Accept: application/jrd+json, application/json`..."); + let _ = headers.insert( + reqwest::header::ACCEPT, + "application/jrd+json, application/json".parse().unwrap() + ); + + request + }; + + log::trace!("Sending request..."); + let response = client.execute(request) + .await + .map_err(Request)?; + + log::trace!("Checking `Content-Type` of the response..."); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .ok_or(ContentTypeMissing)?; + + log::trace!("Extracting MIME type from the `Content-Type` header..."); + let mime_type = crate::utils::extract_mime_from_content_type(content_type) + .ok_or(ContentTypeInvalid)?; + + log::trace!("Ensuring MIME type is acceptable for JRD parsing..."); + if !(mime_type == "application/json" || mime_type == "application/jrd+json") { + log::error!("MIME type `{mime_type}` is not acceptable for JRD parsing."); + return Err(ContentTypeInvalid); + } + + log::trace!("Attempting to parse response as JSON..."); + let data = response.json::() + .await + .map_err(Parse)?; + + Ok(data) + } +} + +impl From for ResourceDescriptorJRD { + fn from(value: ResourceDescriptorXRD) -> Self { + Self { + subject: value.subject, + aliases: value.aliases, + properties: value.properties.into_iter() + .map(From::from) + .collect(), + links: value.links.into_iter() + .map(From::from) + .collect(), + } + } +} + +impl From for ResourceDescriptorLinkJRD { + fn from(value: ResourceDescriptorLinkXRD) -> Self { + Self { + rel: value.rel, + r#type: value.r#type, + href: value.href, + titles: HashMap::from_iter( + value.titles.into_iter() + .map(From::from) + ), + properties: HashMap::from_iter( + value.properties.into_iter() + .map(From::from) + ), + template: value.template, + } + } +} + +impl From for (String, Option) { + fn from(value: ResourceDescriptorPropertyXRD) -> Self { + (value.r#type, value.value) + } +} + +impl From for (String, String) { + fn from(value: ResourceDescriptorTitleXRD) -> Self { + (value.language, value.value) + } +} + + +/// Error occurred during [`ResourceDescriptor::get_jrd`]. +#[derive(Debug, Error)] +pub enum GetJRDError { + /// The HTTP request failed. + #[error("the HTTP request failed")] + Request(reqwest::Error), + + /// The `Content-Type` header of the response is missing. + #[error("the Content-Type header of the response is missing")] + ContentTypeMissing, + + /// The `Content-Type` header of the response is invalid. + #[error("the Content-Type header of the response is invalid")] + ContentTypeInvalid, + + /// The document failed to be parsed as JSON by [`reqwest`]. + #[error("the document failed to be parsed as JSON")] + Parse(reqwest::Error), +} diff --git a/acrate-hostmeta/src/lib.rs b/acrate-hostmeta/src/lib.rs index cf3ef43..dd9bedf 100644 --- a/acrate-hostmeta/src/lib.rs +++ b/acrate-hostmeta/src/lib.rs @@ -1,15 +1,12 @@ -//! Resource descriptior deserializer. +//! Resource descriptior handler. //! //! # Specification //! //! - //! - -mod descriptor; -mod property; -mod link; -mod utils; +pub mod jrd; +pub mod xrd; +pub mod any; -pub use descriptor::*; -pub use property::*; -pub use link::*; +mod utils; diff --git a/acrate-hostmeta/src/link.rs b/acrate-hostmeta/src/link.rs deleted file mode 100644 index 2a4864d..0000000 --- a/acrate-hostmeta/src/link.rs +++ /dev/null @@ -1,64 +0,0 @@ -use serde::Deserialize; -use std::collections::HashMap; -use crate::property::ResourceDescriptorProperty; - -/// A link element, which puts the subject resource in relation with another. -/// -/// # Specification -/// -/// - -/// -#[derive(Debug, Clone, Deserialize)] -pub struct ResourceDescriptorLink { - /// The kind of relation established by the subject with the attached resource. - /// - /// # Specification - /// - /// - - /// - #[serde(alias = "@rel")] - pub rel: String, - - /// The media type of the resource put in relation. - /// - /// # Specification - /// - /// - - /// - #[serde(alias = "@type")] - pub r#type: Option, - - /// URI to the resource put in relation. - /// - /// # Specification - /// - /// - - /// - #[serde(alias = "@href")] - pub href: Option, - - /// Titles of the resource put in relation in various languages. - /// - /// # Specification - /// - /// - - /// - pub titles: Option>>, - - /// Additional information about the resource put in relation. - /// - /// # Specification - /// - /// - - /// - pub properties: Option>, - - /// Template to fill to get the URL to resource-specific information. - /// - /// # Specification - /// - /// - - /// - #[serde(alias = "@template")] - pub template: Option, -} diff --git a/acrate-hostmeta/src/property.rs b/acrate-hostmeta/src/property.rs deleted file mode 100644 index 00e7fe7..0000000 --- a/acrate-hostmeta/src/property.rs +++ /dev/null @@ -1,17 +0,0 @@ -use serde::Deserialize; - -/// A property element, which describes a certain aspect of the subject resource. -/// -/// # Specification -/// -/// - -/// -#[derive(Debug, Clone, Deserialize)] -pub struct ResourceDescriptorProperty { - /// The property identifier, or type. - #[serde(alias = "@type")] - pub r#type: String, - - /// The property value. - pub value: Option, -} diff --git a/acrate-hostmeta/src/xrd.rs b/acrate-hostmeta/src/xrd.rs new file mode 100644 index 0000000..0e89c1d --- /dev/null +++ b/acrate-hostmeta/src/xrd.rs @@ -0,0 +1,292 @@ +use serde::{Serialize, Deserialize}; +use thiserror::Error; +use crate::jrd::{ResourceDescriptorJRD, ResourceDescriptorLinkJRD}; + +/// A resource descriptor object in XRD format. +/// +/// # Specification +/// +/// - +/// - +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceDescriptorXRD { + /// The resource this document refers to. + /// + /// # Specification + /// + /// - + /// + #[serde(rename = "Subject")] + pub subject: Option, + + /// Other names the resource described by this document can be referred to. + /// + /// # Specification + /// + /// - + /// + #[serde(rename = "Alias")] + #[serde(default)] + pub aliases: Vec, + + /// Additional information about the resource described by this document. + /// + /// # Specification + /// + /// - + /// + #[serde(rename = "Property")] + #[serde(default)] + pub properties: Vec, + + /// Links established between the [`Self::subject`] and other resources. + /// + /// # Specification + /// + /// - + /// - + /// + #[serde(rename = "Link")] + #[serde(default)] + pub links: Vec, +} + +/// A link element, which puts the subject resource in relation with another. +/// +/// # Specification +/// +/// - +/// +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceDescriptorLinkXRD { + /// The kind of relation established by the subject with the attached resource. + /// + /// # Specification + /// + /// - + /// + #[serde(rename = "@rel")] + pub rel: String, + + /// The media type of the resource put in relation. + /// + /// # Specification + /// + /// - + /// + #[serde(rename = "@type")] + pub r#type: Option, + + /// URI to the resource put in relation. + /// + /// # Specification + /// + /// - + /// + #[serde(rename = "@href")] + pub href: Option, + + /// Titles of the resource put in relation in various languages. + /// + /// # Specification + /// + /// - + /// + #[serde(default)] + pub titles: Vec, + + /// Additional information about the resource put in relation. + /// + /// # Specification + /// + /// - + /// + #[serde(default)] + pub properties: Vec, + + /// Template to fill to get the URL to resource-specific information. + /// + /// # Specification + /// + /// - + /// + #[serde(rename = "@template")] + pub template: Option, +} + +/// A title of the resource put in relation. +/// +/// # Specification +/// +/// - +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceDescriptorTitleXRD { + /// The language of the title. + #[serde(rename = "@lang")] + pub language: String, + + /// The title itself. + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceDescriptorPropertyXRD { + /// The property identifier, or type. + #[serde(alias = "@type")] + pub r#type: String, + + /// The property value. + pub value: Option, +} + + +impl ResourceDescriptorXRD { + /// Get a [`ResourceDescriptorXRD`] from an URL. + /// + /// # Notes + /// + /// This follows redirects until the redirect chain is 10 hops; see [`reqwest::redirect`] for more info. + /// + /// # Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use acrate_hostmeta::xrd::ResourceDescriptorXRD; + /// + /// let client = reqwest::Client::new(); + /// let url: reqwest::Url = "https://junimo.party/.well-known/host-meta".parse() + /// .expect("URL to be valid"); + /// + /// let rd = ResourceDescriptorXRD::get(&client, url) + /// .await + /// .expect("XRD to be processed correctly"); + /// # }) + /// ``` + /// + pub async fn get(client: &reqwest::Client, url: reqwest::Url) -> Result { + use GetXRDError::*; + + log::debug!("Getting host-meta XRD document at: {url}"); + + log::trace!("Building request..."); + let request = { + log::trace!("Creating new request..."); + let mut request = reqwest::Request::new(reqwest::Method::GET, url); + + log::trace!("Setting request headers..."); + let headers = request.headers_mut(); + + log::trace!("Setting `Accept: application/xrd+xml`..."); + let _ = headers.insert(reqwest::header::ACCEPT, "application/xrd+xml".parse().unwrap()); + + request + }; + + log::trace!("Sending request..."); + let response = client.execute(request) + .await + .map_err(Request)?; + + log::trace!("Checking `Content-Type` of the response..."); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .ok_or(ContentTypeMissing)?; + + log::trace!("Extracting MIME type from the `Content-Type` header..."); + let mime_type = crate::utils::extract_mime_from_content_type(content_type) + .ok_or(ContentTypeInvalid)?; + + log::trace!("Ensuring MIME type is acceptable for XRD parsing..."); + if mime_type != "application/xrd+xml" { + log::error!("MIME type `{mime_type}` is not acceptable for XRD parsing."); + return Err(ContentTypeInvalid) + } + + log::trace!("Attempting to parse response as text..."); + let data = response.text() + .await + .map_err(Decode)?; + + log::trace!("Parsing response as XML..."); + let data = quick_xml::de::from_str::(&data) + .map_err(Parse)?; + + Ok(data) + } +} + +impl From for ResourceDescriptorXRD { + fn from(value: ResourceDescriptorJRD) -> Self { + Self { + subject: value.subject, + aliases: value.aliases, + properties: value.properties.into_iter() + .map(From::from) + .collect(), + links: value.links.into_iter() + .map(From::from) + .collect(), + } + } +} + +impl From<(String, Option)> for ResourceDescriptorPropertyXRD { + fn from(value: (String, Option)) -> Self { + Self { + r#type: value.0, + value: value.1, + } + } +} + +impl From for ResourceDescriptorLinkXRD { + fn from(value: ResourceDescriptorLinkJRD) -> Self { + Self { + rel: value.rel, + r#type: value.r#type, + href: value.href, + titles: value.titles.into_iter() + .map(From::from) + .collect(), + properties: value.properties.into_iter() + .map(From::from) + .collect(), + template: value.template, + } + } +} + +impl From<(String, String)> for ResourceDescriptorTitleXRD { + fn from(value: (String, String)) -> Self { + Self { + language: value.0, + value: value.1, + } + } +} + + +/// Error occurred during [`ResourceDescriptor::get_xrd`]. +#[derive(Debug, Error)] +pub enum GetXRDError { + /// The HTTP request failed. + #[error("the HTTP request failed")] + Request(reqwest::Error), + + /// The `Content-Type` header of the response is missing. + #[error("the Content-Type header of the response is missing")] + ContentTypeMissing, + + /// The `Content-Type` header of the response is invalid. + #[error("the Content-Type header of the response is invalid")] + ContentTypeInvalid, + + /// The document failed to be decoded as text. + #[error("the document failed to be decoded as text")] + Decode(reqwest::Error), + + /// The document failed to be parsed as XML by [`quick_xml`]. + #[error("the document failed to be parsed as XML")] + Parse(quick_xml::DeError), +} diff --git a/acrate-hostmeta/tests/hostmeta_tests.rs b/acrate-hostmeta/tests/hostmeta_tests.rs index 1bdd795..f183688 100644 --- a/acrate-hostmeta/tests/hostmeta_tests.rs +++ b/acrate-hostmeta/tests/hostmeta_tests.rs @@ -23,13 +23,13 @@ fn make_client() -> reqwest::Client { .expect("reqwest client to build") } + macro_rules! test_discover_hostmeta { ($id:ident, $url:literal) => { test_discover_hostmeta!($id, $url,); }; ($id:ident, $url:literal, $($tag:meta),*) => { mod $id { - use acrate_hostmeta::*; use super::*; #[tokio::test] @@ -41,7 +41,7 @@ macro_rules! test_discover_hostmeta { let base: reqwest::Url = $url.parse() .expect("a valid URL"); - let doc = ResourceDescriptor::discover_hostmeta(&client, base) + let doc = acrate_hostmeta::any::ResourceDescriptor::discover_hostmeta(&client, base) .await .expect("host-meta discovery to succeed");