use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Version {
#[serde(rename = "2.0")]
V2_0,
#[serde(rename = "2.1")]
V2_1,
}
impl Version {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::V2_0 => "2.0",
Self::V2_1 => "2.1",
}
}
#[must_use]
pub const fn schema_uri(self) -> &'static str {
match self {
Self::V2_0 => "http://nodeinfo.diaspora.software/ns/schema/2.0",
Self::V2_1 => "http://nodeinfo.diaspora.software/ns/schema/2.1",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Protocol {
ActivityPub,
Buddycloud,
Dfrn,
Diaspora,
Libertree,
OStatus,
PumpIo,
Tent,
Xmpp,
Zot,
#[serde(untagged)]
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum InboundService {
#[serde(rename = "atom1.0")]
Atom1_0,
GnuSocial,
Imap,
Pnut,
Pop3,
PumpIo,
#[serde(rename = "rss2.0")]
Rss2_0,
Twitter,
#[serde(untagged)]
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum OutboundService {
#[serde(rename = "atom1.0")]
Atom1_0,
Blogger,
Buddycloud,
Diaspora,
Dreamwidth,
Drupal,
Facebook,
Friendica,
GnuSocial,
Google,
InsaneJournal,
Libertree,
LinkedIn,
LiveJournal,
MediaGoblin,
MySpace,
Pinterest,
Pnut,
Posterous,
PumpIo,
RedMatrix,
#[serde(rename = "rss2.0")]
Rss2_0,
Smtp,
Tent,
Tumblr,
Twitter,
WordPress,
Xmpp,
#[serde(untagged)]
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Services {
#[serde(default)]
pub inbound: Vec<InboundService>,
#[serde(default)]
pub outbound: Vec<OutboundService>,
}
impl Services {
#[must_use]
pub const fn new(inbound: Vec<InboundService>, outbound: Vec<OutboundService>) -> Self {
Self { inbound, outbound }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Software {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<Url>,
}
impl Software {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
version: version.into(),
repository: None,
homepage: None,
}
}
#[must_use]
pub fn with_repository(mut self, repository: Url) -> Self {
self.repository = Some(repository);
self
}
#[must_use]
pub fn with_homepage(mut self, homepage: Url) -> Self {
self.homepage = Some(homepage);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UserCount {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total: Option<u64>,
#[serde(
rename = "activeHalfyear",
default,
skip_serializing_if = "Option::is_none"
)]
pub active_halfyear: Option<u64>,
#[serde(
rename = "activeMonth",
default,
skip_serializing_if = "Option::is_none"
)]
pub active_month: Option<u64>,
}
impl UserCount {
#[must_use]
pub const fn new(
total: Option<u64>,
active_halfyear: Option<u64>,
active_month: Option<u64>,
) -> Self {
Self {
total,
active_halfyear,
active_month,
}
}
#[must_use]
pub const fn with_total(mut self, total: u64) -> Self {
self.total = Some(total);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Usage {
#[serde(default)]
pub users: UserCount,
#[serde(
rename = "localPosts",
default,
skip_serializing_if = "Option::is_none"
)]
pub local_posts: Option<u64>,
#[serde(
rename = "localComments",
default,
skip_serializing_if = "Option::is_none"
)]
pub local_comments: Option<u64>,
}
impl Usage {
#[must_use]
pub const fn new(users: UserCount) -> Self {
Self {
users,
local_posts: None,
local_comments: None,
}
}
#[must_use]
pub const fn with_local_posts(mut self, posts: u64) -> Self {
self.local_posts = Some(posts);
self
}
#[must_use]
pub const fn with_local_comments(mut self, comments: u64) -> Self {
self.local_comments = Some(comments);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NodeInfo {
pub version: Version,
pub software: Software,
#[serde(default)]
pub protocols: Vec<Protocol>,
#[serde(default)]
pub services: Services,
#[serde(rename = "openRegistrations")]
pub open_registrations: bool,
#[serde(default)]
pub usage: Usage,
#[serde(default = "default_metadata")]
pub metadata: serde_json::Value,
}
fn default_metadata() -> serde_json::Value {
serde_json::Value::Object(serde_json::Map::new())
}
impl NodeInfo {
#[must_use]
pub fn builder(version: Version, software: Software) -> NodeInfoBuilder {
NodeInfoBuilder {
inner: Self {
version,
software,
protocols: Vec::new(),
services: Services::default(),
open_registrations: false,
usage: Usage::default(),
metadata: default_metadata(),
},
}
}
}
#[derive(Debug)]
pub struct NodeInfoBuilder {
inner: NodeInfo,
}
impl NodeInfoBuilder {
#[must_use]
pub fn protocol(mut self, p: Protocol) -> Self {
self.inner.protocols.push(p);
self
}
#[must_use]
pub fn protocols(mut self, ps: Vec<Protocol>) -> Self {
self.inner.protocols = ps;
self
}
#[must_use]
pub fn services(mut self, services: Services) -> Self {
self.inner.services = services;
self
}
#[must_use]
pub const fn open_registrations(mut self, open: bool) -> Self {
self.inner.open_registrations = open;
self
}
#[must_use]
pub const fn usage(mut self, usage: Usage) -> Self {
self.inner.usage = usage;
self
}
#[must_use]
pub fn metadata<V: Into<serde_json::Value>>(mut self, v: V) -> Self {
self.inner.metadata = v.into();
self
}
#[must_use]
pub fn metadata_entry(
mut self,
key: impl Into<String>,
value: impl Into<serde_json::Value>,
) -> Self {
let mut map = match self.inner.metadata {
serde_json::Value::Object(m) => m,
_ => serde_json::Map::new(),
};
map.insert(key.into(), value.into());
self.inner.metadata = serde_json::Value::Object(map);
self
}
#[must_use]
pub fn build(self) -> NodeInfo {
self.inner
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[test]
fn version_roundtrips() {
let v = Version::V2_1;
assert_eq!(v.as_str(), "2.1");
assert_eq!(
v.schema_uri(),
"http://nodeinfo.diaspora.software/ns/schema/2.1"
);
let j = serde_json::to_value(v).unwrap();
assert_eq!(j, json!("2.1"));
}
#[test]
fn every_schema_protocol_roundtrips() {
for (canonical, expected) in [
("activitypub", Protocol::ActivityPub),
("buddycloud", Protocol::Buddycloud),
("dfrn", Protocol::Dfrn),
("diaspora", Protocol::Diaspora),
("libertree", Protocol::Libertree),
("ostatus", Protocol::OStatus),
("pumpio", Protocol::PumpIo),
("tent", Protocol::Tent),
("xmpp", Protocol::Xmpp),
("zot", Protocol::Zot),
] {
let p: Protocol =
serde_json::from_value(json!(canonical)).expect("known value must deserialise");
assert_eq!(
p, expected,
"{canonical} should deserialise to {expected:?}"
);
let back = serde_json::to_value(&p).expect("known value must serialise");
assert_eq!(
back,
json!(canonical),
"{expected:?} should serialise back to {canonical}",
);
}
}
#[test]
fn protocol_preserves_unknown_variant() {
let p: Protocol =
serde_json::from_value(json!("bluesky")).expect("unknown value must deserialise");
assert_eq!(p, Protocol::Other("bluesky".to_owned()));
let back = serde_json::to_value(&p).expect("Other variant must serialise");
assert_eq!(back, json!("bluesky"));
}
#[test]
fn outbound_service_mediagoblin_roundtrips() {
let s: OutboundService = serde_json::from_value(json!("mediagoblin")).unwrap();
assert_eq!(s, OutboundService::MediaGoblin);
let back = serde_json::to_value(&s).unwrap();
assert_eq!(back, json!("mediagoblin"));
}
#[test]
fn mastodon_style_nodeinfo_roundtrips_verbatim() {
let raw = json!({
"version": "2.1",
"software": {
"name": "mastodon",
"version": "4.5.0",
"repository": "https://github.com/mastodon/mastodon",
"homepage": "https://joinmastodon.org/"
},
"protocols": ["activitypub"],
"services": {
"inbound": [],
"outbound": []
},
"openRegistrations": true,
"usage": {
"users": {
"total": 1234,
"activeHalfyear": 400,
"activeMonth": 50
},
"localPosts": 9999,
"localComments": 8888
},
"metadata": {}
});
let info: NodeInfo = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(info.version, Version::V2_1);
assert_eq!(info.software.name, "mastodon");
assert_eq!(info.protocols, vec![Protocol::ActivityPub]);
assert_eq!(info.usage.users.total, Some(1234));
assert!(info.open_registrations);
let back = serde_json::to_value(&info).unwrap();
assert_eq!(back, raw, "roundtrip must preserve verbatim JSON");
}
#[test]
fn builder_always_emits_required_fields() {
let info = NodeInfo::builder(Version::V2_0, Software::new("test-server", "0.1.0"))
.protocol(Protocol::ActivityPub)
.open_registrations(false)
.build();
let v = serde_json::to_value(&info).unwrap();
assert_eq!(v["version"], json!("2.0"));
assert_eq!(v["protocols"], json!(["activitypub"]));
assert_eq!(v["services"], json!({"inbound": [], "outbound": []}));
assert_eq!(v["openRegistrations"], json!(false));
assert_eq!(v["metadata"], json!({}));
assert!(v["usage"].get("users").is_some());
}
#[test]
fn metadata_entry_builds_object() {
let info = NodeInfo::builder(Version::V2_1, Software::new("my-server", "1.0.0"))
.metadata_entry("supports_feps", json!(["521a", "8b32"]))
.build();
assert_eq!(info.metadata["supports_feps"], json!(["521a", "8b32"]));
}
#[test]
fn software_builder_sets_optional_fields() {
let sw = Software::new("mastodon", "4.5.0")
.with_repository("https://github.com/mastodon/mastodon".parse().unwrap())
.with_homepage("https://joinmastodon.org/".parse().unwrap());
let v = serde_json::to_value(&sw).unwrap();
assert_eq!(
v["repository"],
json!("https://github.com/mastodon/mastodon")
);
assert_eq!(v["homepage"], json!("https://joinmastodon.org/"));
}
}