diff --git a/acrate-hostmeta/src/lib.rs b/acrate-hostmeta/src/lib.rs index fff705f..81dcb55 100644 --- a/acrate-hostmeta/src/lib.rs +++ b/acrate-hostmeta/src/lib.rs @@ -141,8 +141,8 @@ impl ResourceDescriptor { /// # }) /// ``` /// - pub async fn get_jrd(client: &reqwest::Client, url: reqwest::Url) -> Result { - use HostMetaGetJRDError::*; + pub async fn get_jrd(client: &reqwest::Client, url: reqwest::Url) -> Result { + use GetJRDError::*; log::debug!("Getting JRD document at: {url}"); @@ -210,8 +210,8 @@ impl ResourceDescriptor { /// .expect("XRD to be processed correctly"); /// # }) /// ``` - pub async fn get_xrd(client: &reqwest::Client, url: reqwest::Url) -> Result { - use HostMetaGetXRDError::*; + pub async fn get_xrd(client: &reqwest::Client, url: reqwest::Url) -> Result { + use GetXRDError::*; log::debug!("Getting host-meta XRD document at: {url}"); @@ -457,38 +457,6 @@ impl ResourceDescriptor { Self::discover(client, base) .await } - - /// Well-known path for NodeInfo documents. - /// - /// ## Specification - /// - /// - https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md#discovery - pub const WELLKNOWN_NODEINFO_PATH: &str = "/.well-known/nodeinfo"; - - /// Attempt to discover a NodeInfo 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_nodeinfo(&client, base) - /// .await - /// .expect("NodeInfo to be discovered correctly"); - /// # }) - /// ``` - /// - pub async fn discover_nodeinfo(client: &reqwest::Client, mut base: reqwest::Url) -> Result { - base.set_path(Self::WELLKNOWN_NODEINFO_PATH); - - Self::discover(client, base) - .await - } } /// Error occurred during [`ResourceDescriptor::discover`]. @@ -507,27 +475,27 @@ pub enum ResourceDescriptorDiscoveryError { #[derive(Debug)] pub struct ResourceDescriptorDiscoveryFailures { /// HTTPS XRD retrieval. - pub https_xrd: HostMetaGetXRDError, + pub https_xrd: GetXRDError, /// HTTPS JRD retrieval. - pub https_jrd: HostMetaGetJRDError, + pub https_jrd: GetJRDError, /// HTTPS JRD with .json extension retrieval. - pub https_jrdj: HostMetaGetJRDError, + pub https_jrdj: GetJRDError, /// HTTPS XRD retrieval. - pub http_xrd: HostMetaGetXRDError, + pub http_xrd: GetXRDError, /// HTTP JRD retrieval. - pub http_jrd: HostMetaGetJRDError, + pub http_jrd: GetJRDError, /// HTTP JRD with .json extension retrieval. - pub http_jrdj: HostMetaGetJRDError, + pub http_jrdj: GetJRDError, } /// Error occurred during [`ResourceDescriptor::get_xrd`]. #[derive(Debug)] -pub enum HostMetaGetXRDError { +pub enum GetXRDError { /// The HTTP request failed. Request(reqwest::Error), /// The `Content-Type` header of the response is missing. @@ -542,7 +510,7 @@ pub enum HostMetaGetXRDError { /// Error occurred during [`ResourceDescriptor::get_jrd`]. #[derive(Debug)] -pub enum HostMetaGetJRDError { +pub enum GetJRDError { /// The HTTP request failed. Request(reqwest::Error), /// The `Content-Type` header of the response is missing. diff --git a/acrate-hostmeta/tests/hostmeta_tests.rs b/acrate-hostmeta/tests/hostmeta_tests.rs index 51c4e20..1bdd795 100644 --- a/acrate-hostmeta/tests/hostmeta_tests.rs +++ b/acrate-hostmeta/tests/hostmeta_tests.rs @@ -5,6 +5,7 @@ const CARGO_PKG_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY"); fn init_log() { let mut builder = pretty_env_logger::formatted_builder(); + builder.target(pretty_env_logger::env_logger::Target::Stdout); builder.filter_level(log::LevelFilter::max()); builder.is_test(true); @@ -22,9 +23,9 @@ fn make_client() -> reqwest::Client { .expect("reqwest client to build") } -macro_rules! test { +macro_rules! test_discover_hostmeta { ($id:ident, $url:literal) => { - test!($id, $url,); + test_discover_hostmeta!($id, $url,); }; ($id:ident, $url:literal, $($tag:meta),*) => { mod $id { @@ -33,7 +34,7 @@ macro_rules! test { #[tokio::test] $(#[$tag])* - async fn test_hostmeta() { + async fn test() { init_log(); let client = make_client(); @@ -46,40 +47,16 @@ macro_rules! test { log::info!("Parsed host-meta document: {doc:#?}"); } - - #[tokio::test] - $(#[$tag])* - async fn test_nodeinfo() { - init_log(); - let client = make_client(); - - let base: reqwest::Url = $url.parse() - .expect("a valid URL"); - - let doc = ResourceDescriptor::discover_nodeinfo(&client, base) - .await - .expect("nodeinfo discovery to succeed"); - - log::info!("Parsed nodeinfo document: {doc:#?}"); - } } }; } -test!(akkoma, "https://junimo.party"); - -test!(mastodon, "https://mastodon.social"); - -test!(misskey, "https://misskey.io"); - -test!(iceshrimpnet, "https://ice.frieren.quest"); - -test!(gotosocial, "https://alpha.polymaths.social"); - -test!(bridgyfed, "https://fed.brid.gy"); - -test!(threads, "https://threads.net", ignore = "Not implemented on their end"); - -test!(snac, "https://ngoa.giao.loan", ignore = "Does not support host-meta"); - -test!(hollo, "https://hollo.social", ignore = "Does not support host-meta"); +test_discover_hostmeta!(akkoma, "https://junimo.party"); +test_discover_hostmeta!(mastodon, "https://mastodon.social"); +test_discover_hostmeta!(misskey, "https://misskey.io"); +test_discover_hostmeta!(iceshrimpnet, "https://ice.frieren.quest"); +test_discover_hostmeta!(gotosocial, "https://alpha.polymaths.social"); +test_discover_hostmeta!(bridgyfed, "https://fed.brid.gy"); +test_discover_hostmeta!(threads, "https://threads.net", ignore = "does not support host-meta"); +test_discover_hostmeta!(snac, "https://ngoa.giao.loan", ignore = "does not support host-meta"); +test_discover_hostmeta!(hollo, "https://hollo.social", ignore = "does not support host-meta"); diff --git a/acrate-nodeinfo/Cargo.toml b/acrate-nodeinfo/Cargo.toml index d97b378..d8ca425 100644 --- a/acrate-nodeinfo/Cargo.toml +++ b/acrate-nodeinfo/Cargo.toml @@ -13,6 +13,7 @@ serde_json = "1.0.132" [dev-dependencies] pretty_env_logger = "0.5.0" tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread"] } +tokio-test = "0.4.4" [lints.clippy] tabs-in-doc-comments = "allow" diff --git a/acrate-nodeinfo/src/lib.rs b/acrate-nodeinfo/src/lib.rs index eb34904..0fbf5e4 100644 --- a/acrate-nodeinfo/src/lib.rs +++ b/acrate-nodeinfo/src/lib.rs @@ -1,76 +1,489 @@ -//! Serde-based loose [NodeInfo] fetcher and parser. +//! Serde-based NodeInfo fetcher and loose parser. //! -//! [NodeInfo]: https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md +//! > NodeInfo is an effort to create a standardized way of exposing metadata about a server running one of the distributed social networks. + +use std::collections::HashMap; use serde::Deserialize; +/// A variant of a NodeInfo document. +/// +/// ## Specification +/// +/// - https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md +#[derive(Debug, Clone)] +pub enum NodeInfo { + V1(NodeInfo1), + V2(NodeInfo2), +} + +/// A NodeInfo document at version 1.X. +/// +/// ## Specification +/// +/// - https://github.com/jhass/nodeinfo/blob/main/schemas/1.0/schema.json +/// - https://github.com/jhass/nodeinfo/blob/main/schemas/1.1/schema.json +/// #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeInfo1 { + /// The schema version. pub version: String, + + /// Metadata about server software in use. pub software: NodeInfo1Software, + + /// The protocols supported on this server. pub protocols: NodeInfo1Protocols, + + /// The third party sites this server can connect to via their application API. pub services: NodeInfo1Services, + + /// Whether this server allows open self-registration. pub open_registrations: bool, + + /// Usage statistics for this server. pub usage: NodeInfo1Usage, + + /// Free form key value pairs for software specific values. + /// + /// Clients should not rely on any specific key present. pub metadata: serde_json::Value, } +/// A NodeInfo document at version 2.X. +/// +/// ## Specification +/// +/// - https://github.com/jhass/nodeinfo/blob/main/schemas/2.0/schema.json +/// - https://github.com/jhass/nodeinfo/blob/main/schemas/2.1/schema.json +/// - https://github.com/jhass/nodeinfo/blob/main/schemas/2.2/schema.json +/// #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeInfo2 { + /// The schema version. pub version: String, + + /// Metadata specific to the instance. An instance is a the concrete installation of a software running on a server. pub instance: Option, + + /// Metadata about server software in use. pub software: NodeInfo1Software, - pub protocols: NodeInfo1Protocols, - pub services: NodeInfo1Services, + + /// The protocols supported on this server. + pub protocols: Vec, + + /// The third party sites this server can connect to via their application API. + pub services: Option, + + /// Whether this server allows open self-registration. pub open_registrations: bool, - pub usage: NodeInfo1Usage, + + /// Usage statistics for this server. + pub usage: Option, + + /// Free form key value pairs for software specific values. + /// + /// Clients should not rely on any specific key present. pub metadata: serde_json::Value, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct NodeInfo1Software { +pub struct NodeInfo1Software { + /// The canonical name of this server software. pub name: String, + + /// The version of this server software. pub version: String, + pub repository: Option, + pub homepage: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeInfo1Protocols { + /// The protocols this server can receive traffic for. pub inbound: Vec, + + /// The protocols this server can generate traffic for. pub outbound: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeInfo1Services { + /// The third party sites this server can retrieve messages from for combined display with regular traffic. pub inbound: Vec, + + /// The third party sites this server can publish messages to on the behalf of a user. pub outbound: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeInfo1Usage { - pub users: NodeInfo1UsageUsers, - pub local_posts: i32, - pub local_comments: i32, + /// Statistics about the users of this server. + pub users: Option, + + /// The amount of posts that were made by users that are registered on this server. + pub local_posts: Option, + + /// The amount of comments that were made by users that are registered on this server. + pub local_comments: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeInfo1UsageUsers { - pub total: i32, - pub active_halfyear: i32, - pub active_month: i32, + /// The total amount of on this server registered users. + pub total: Option, + + /// The amount of users that signed in at least once in the last 180 days. + pub active_halfyear: Option, + + /// The amount of users that signed in at least once in the last 30 days. + pub active_month: Option, + + /// The amount of users that signed in at least once in the last 7 days. + pub active_week: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeInfo2Instance { + /// If supported by the software, the administrator-configured name of this instance. pub name: String, + + /// If supported by the software, the administrator-configured long form description of this instance. pub description: String, } + +impl NodeInfo { + /// Discover and get the latest NodeInfo version available given a certain base URL. + /// + /// ## Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use acrate_nodeinfo::NodeInfo; + /// + /// let client = reqwest::Client::new(); + /// let url: reqwest::Url = "https://mastodon.online".parse() + /// .expect("URL to be valid"); + /// + /// let ni = NodeInfo::get_latest_wellknown(&client, url) + /// .await + /// .expect("NodeInfo to be processed correctly"); + /// + /// let version = match ni { + /// NodeInfo::V1(ni) => ni.version, + /// NodeInfo::V2(ni) => ni.version, + /// }; + /// + /// assert_eq!(version, "2.0"); + /// # }) + pub async fn get_latest_wellknown(client: &reqwest::Client, url: reqwest::Url) -> Result { + use NodeInfoGetWellknownError::*; + + log::debug!("Getting well-known NodeInfo document at base: {url}"); + + log::trace!("Discovering NodeInfo document locations..."); + let discovery = acrate_hostmeta::ResourceDescriptor::discover(client, url) + .await + .map_err(Discovery)?; + + log::trace!("Getting a list of NodeInfo document links..."); + let mut links = discovery.links.unwrap_or_default(); + links.sort_unstable_by_key(|o| o.rel.clone()); // TODO: Performance can be improved. + links.reverse(); + + for link in links.into_iter() { + + log::trace!("Checking discovered link href..."); + let url = match link.href { + None => { + log::warn!("Discovered link does not have an href, skipping..."); + continue + }, + Some(href) => { + log::trace!("Discovered link has an href, processing..."); + href + }, + }; + + log::trace!("Parsing discovered link href..."); + let url: reqwest::Url = match url.parse() { + Err(e) => { + log::warn!("Discovered link has an invalid URL as href, skipping: {e:#?}"); + continue + }, + Ok(url) => { + log::trace!("Discovered link has a valid URL, processing..."); + url + }, + }; + + let rel = link.rel; + + let nodeinfo = match rel.as_str() { + "http://nodeinfo.diaspora.software/ns/schema/1.0" => match NodeInfo1::get(client, url).await { + Err(e) => { + log::warn!("Failed to get NodeInfo v1.0 document, skipping: {e:#?}"); + continue; + }, + Ok(nodeinfo) => { + log::trace!("Successfully processed NodeInfo v1.0 document!"); + Self::V1(nodeinfo) + } + } + "http://nodeinfo.diaspora.software/ns/schema/1.1" => match NodeInfo1::get(client, url).await { + Err(e) => { + log::warn!("Failed to get NodeInfo v1.1 document, skipping: {e:#?}"); + continue; + }, + Ok(nodeinfo) => { + log::trace!("Successfully processed NodeInfo v1.1 document!"); + Self::V1(nodeinfo) + } + } + "http://nodeinfo.diaspora.software/ns/schema/2.0" => match NodeInfo2::get(client, url).await { + Err(e) => { + log::warn!("Failed to get NodeInfo v2.0 document, skipping: {e:#?}"); + continue; + }, + Ok(nodeinfo) => { + log::trace!("Successfully processed NodeInfo v2.0 document!"); + Self::V2(nodeinfo) + } + } + "http://nodeinfo.diaspora.software/ns/schema/2.1" => match NodeInfo2::get(client, url).await { + Err(e) => { + log::warn!("Failed to get NodeInfo v2.1 document, skipping: {e:#?}"); + continue; + }, + Ok(nodeinfo) => { + log::trace!("Successfully processed NodeInfo v2.1 document!"); + Self::V2(nodeinfo) + } + } + "http://nodeinfo.diaspora.software/ns/schema/2.2" => match NodeInfo2::get(client, url).await { + Err(e) => { + log::warn!("Failed to get NodeInfo v2.2 document, skipping: {e:#?}"); + continue; + }, + Ok(nodeinfo) => { + log::trace!("Successfully processed NodeInfo v2.2 document!"); + Self::V2(nodeinfo) + } + } + _ => { + log::warn!("Discovered link has unknown rel `{rel}`, skipping."); + continue; + }, + }; + + log::trace!("Successfully retrieved latest NodeInfo: {nodeinfo:#?}") + return Ok(nodeinfo); + } + + log::warn!("Ran out of possible NodeInfo sources, returning an Unsupported error."); + Err(Unsupported) + } + + /// Well-known path for NodeInfo documents. + /// + /// ## Specification + /// + /// - https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md#discovery + pub const WELLKNOWN_NODEINFO_PATH: &str = "/.well-known/nodeinfo"; + + /// Attempt to discover a NodeInfo 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 = NodeInfo::discover(&client, base) + /// .await + /// .expect("NodeInfo to be discovered correctly"); + /// # }) + /// ``` + /// + pub async fn discover(client: &reqwest::Client, mut base: reqwest::Url) -> Result { + base.set_path(Self::WELLKNOWN_NODEINFO_PATH); + + Self::get_latest_wellknown(client, base) + .await + } +} + +/// An error occurred during [`NodeInfo::get_latest_wellknown`]. +#[derive(Debug)] +pub enum NodeInfoGetWellknownError { + /// The discovery of possible locations for NodeInfo documents failed. + Discovery(acrate_hostmeta::ResourceDescriptorDiscoveryError), + /// No compatible NodeInfo documents were detected at the given URL. + Unsupported, +} + +impl NodeInfo1 { + /// Get a NodeInfo v1.X document. + pub async fn get(client: &reqwest::Client, url: reqwest::Url) -> Result { + use NodeInfoGetError::*; + + log::debug!("Getting NodeInfo v1.X 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 = extract_mime_from_content_type(content_type) + .ok_or(ContentTypeInvalid)?; + + log::trace!("Ensuring MIME type is acceptable for NodeInfo documents..."); + if mime_type != "application/json" { + log::error!("MIME type `{mime_type}` is not acceptable for NodeInfo documents."); + return Err(ContentTypeInvalid) + } + + log::trace!("Attempting to parse response as JSON..."); + let data = response.json::() + .await + .map_err(Parse)?; + + log::trace!("Making sure version is compatible with 1.X..."); + if !data.version.starts_with("1.") { + return Err(Version); + } + + Ok(data) + } +} + +impl NodeInfo2 { + /// Get a NodeInfo v2.X document. + /// + /// ## Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use acrate_nodeinfo::NodeInfo2; + /// + /// let client = reqwest::Client::new(); + /// let url: reqwest::Url = "https://junimo.party/nodeinfo/2.1.json".parse() + /// .expect("URL to be valid"); + /// + /// let rd = NodeInfo2::get(&client, url) + /// .await + /// .expect("NodeInfo to be obtained correctly"); + /// # }) + /// ``` + pub async fn get(client: &reqwest::Client, url: reqwest::Url) -> Result { + use NodeInfoGetError::*; + + log::debug!("Getting NodeInfo v2.X 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 = extract_mime_from_content_type(content_type) + .ok_or(ContentTypeInvalid)?; + + log::trace!("Ensuring MIME type is acceptable for NodeInfo documents..."); + if mime_type != "application/json" { + log::error!("MIME type `{mime_type}` is not acceptable for NodeInfo documents."); + return Err(ContentTypeInvalid) + } + + log::trace!("Attempting to parse response as JSON..."); + let data = response.json::() + .await + .map_err(Parse)?; + + log::trace!("Making sure version is compatible with 2.X..."); + if !data.version.starts_with("2.") { + return Err(Version) + } + + Ok(data) + } +} + +/// An error encountered during [`NodeInfo1::get`] or [`NodeInfo2::get`]. +#[derive(Debug)] +pub enum NodeInfoGetError { + /// The HTTP request failed. + Request(reqwest::Error), + /// The `Content-Type` header of the response is missing. + ContentTypeMissing, + /// The `Content-Type` header of the response is invalid. + ContentTypeInvalid, + /// The document failed to be parsed as JSON by [`reqwest`]. + Parse(reqwest::Error), + /// The returned version does not match the version of the created struct. + Version, +} + +/// Extract the MIME type from the value of the `Content-Type` header. +fn extract_mime_from_content_type(value: &reqwest::header::HeaderValue) -> Option { + let value = value.to_str().ok()?; + match value.split_once("; ") { + None => Some(value.to_string()), + Some((mime, _)) => Some(mime.to_string()), + } +} diff --git a/acrate-nodeinfo/tests/nodeinfo_tests.rs b/acrate-nodeinfo/tests/nodeinfo_tests.rs new file mode 100644 index 0000000..e9e2fba --- /dev/null +++ b/acrate-nodeinfo/tests/nodeinfo_tests.rs @@ -0,0 +1,70 @@ +const CARGO_PKG_NAME: &str = env!("CARGO_PKG_NAME"); +const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); +const CARGO_PKG_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY"); + + +fn init_log() { + let mut builder = pretty_env_logger::formatted_builder(); + builder.target(pretty_env_logger::env_logger::Target::Stdout); + builder.filter_level(log::LevelFilter::max()); + builder.is_test(true); + + if builder.try_init().is_ok() { + log::debug!("Initialized logging!"); + } +} + +fn make_client() -> reqwest::Client { + let user_agent = format!("{CARGO_PKG_NAME}/{CARGO_PKG_VERSION} ({CARGO_PKG_REPOSITORY})"); + + reqwest::Client::builder() + .user_agent(user_agent) + .build() + .expect("reqwest client to build") +} + + +macro_rules! test { + ($id:ident, $url:literal, $version:literal) => { + test!($id, $url, $version,); + }; + ($id:ident, $url:literal, $version:literal, $($tag:meta),*) => { + mod $id { + use acrate_nodeinfo::*; + use super::*; + + #[tokio::test] + $(#[$tag])* + async fn test_version() { + init_log(); + let client = make_client(); + + let base: reqwest::Url = $url.parse() + .expect("a valid URL"); + + let doc = NodeInfo::get_latest_wellknown(&client, base) + .await + .expect("NodeInfo discovery to succeed"); + + log::info!("Parsed NodeInfo document: {doc:#?}"); + + let version = match doc { + NodeInfo::V1(d) => d.version, + NodeInfo::V2(d) => d.version, + }; + + assert_eq!(version, $version); + } + } + }; +} + +test!(akkoma, "https://junimo.party", "2.1"); +test!(mastodon, "https://mastodon.social", "2.0"); +test!(misskey, "https://misskey.io", "2.1"); +test!(iceshrimpnet, "https://ice.frieren.quest", "2.1"); +test!(gotosocial, "https://alpha.polymaths.social", "2.0"); +test!(bridgyfed, "https://fed.brid.gy", "2.1"); +test!(threads, "https://threads.net", "", ignore = "does not support NodeInfo"); +test!(snac, "https://ngoa.giao.loan", "2.0"); +test!(hollo, "https://hollo.social", "2.1");