Skip to main content

actpub_nodeinfo/
schema.rs

1//! `NodeInfo` schema types (2.0 / 2.1).
2//!
3//! See [http://nodeinfo.diaspora.software/](http://nodeinfo.diaspora.software/)
4//! for the canonical JSON Schemas. All enum values are drawn directly from
5//! the published schemas; unknown values round-trip losslessly through the
6//! `Other(String)` variants so the crate can interoperate with forward-
7//! compatible servers and third-party extensions.
8
9use serde::{Deserialize, Serialize};
10use url::Url;
11
12/// The `NodeInfo` schema version this document conforms to.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[non_exhaustive]
15pub enum Version {
16    /// `NodeInfo` 2.0 — [schema](http://nodeinfo.diaspora.software/ns/schema/2.0).
17    #[serde(rename = "2.0")]
18    V2_0,
19
20    /// `NodeInfo` 2.1 — [schema](http://nodeinfo.diaspora.software/ns/schema/2.1).
21    #[serde(rename = "2.1")]
22    V2_1,
23}
24
25impl Version {
26    /// Returns the version as the lexical string used on the wire.
27    #[must_use]
28    pub const fn as_str(self) -> &'static str {
29        match self {
30            Self::V2_0 => "2.0",
31            Self::V2_1 => "2.1",
32        }
33    }
34
35    /// Returns the full schema URI this version corresponds to, as emitted
36    /// in the `rel` field of the `/.well-known/nodeinfo` discovery document.
37    #[must_use]
38    pub const fn schema_uri(self) -> &'static str {
39        match self {
40            Self::V2_0 => "http://nodeinfo.diaspora.software/ns/schema/2.0",
41            Self::V2_1 => "http://nodeinfo.diaspora.software/ns/schema/2.1",
42        }
43    }
44}
45
46/// A federation protocol supported by a NodeInfo-described server.
47///
48/// The ten named variants are the exact `protocols` enumeration of the
49/// [NodeInfo 2.1 schema][schema]; unknown values (including community
50/// extensions such as `matrix` or `bluesky` used by bridges) round-trip
51/// losslessly through [`Self::Other`].
52///
53/// [schema]: http://nodeinfo.diaspora.software/ns/schema/2.1
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56#[non_exhaustive]
57pub enum Protocol {
58    /// The W3C `ActivityPub` protocol.
59    ActivityPub,
60    /// Buddycloud federation.
61    Buddycloud,
62    /// Distributed Friendika Network.
63    Dfrn,
64    /// Diaspora.
65    Diaspora,
66    /// Libertree.
67    Libertree,
68    /// `OStatus` (legacy).
69    OStatus,
70    /// Pump.io.
71    PumpIo,
72    /// Tent.
73    Tent,
74    /// XMPP with pub-sub.
75    Xmpp,
76    /// Zot.
77    Zot,
78    /// An unrecognised protocol identifier, preserved verbatim.
79    ///
80    /// Common non-schema values seen in the wild include `matrix`,
81    /// `bluesky`, `gnusocial`, and `nostr`. All are preserved through this
82    /// fallback variant.
83    #[serde(untagged)]
84    Other(String),
85}
86
87/// An inbound bridge service defined in `NodeInfo`.
88///
89/// Enumeration matches the `inbound` values from the `services` definition
90/// in the [NodeInfo 2.1 schema][schema].
91///
92/// [schema]: http://nodeinfo.diaspora.software/ns/schema/2.1
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "lowercase")]
95#[non_exhaustive]
96pub enum InboundService {
97    /// Atom 1.0 feed.
98    #[serde(rename = "atom1.0")]
99    Atom1_0,
100    /// GNU Social.
101    GnuSocial,
102    /// IMAP.
103    Imap,
104    /// Pnut.
105    Pnut,
106    /// POP3.
107    Pop3,
108    /// Pump.io.
109    PumpIo,
110    /// RSS 2.0 feed.
111    #[serde(rename = "rss2.0")]
112    Rss2_0,
113    /// Twitter.
114    Twitter,
115    /// An unrecognised service identifier, preserved verbatim.
116    #[serde(untagged)]
117    Other(String),
118}
119
120/// An outbound bridge service defined in `NodeInfo`.
121///
122/// Enumeration matches the `outbound` values from the `services` definition
123/// in the [NodeInfo 2.1 schema][schema].
124///
125/// [schema]: http://nodeinfo.diaspora.software/ns/schema/2.1
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128#[non_exhaustive]
129pub enum OutboundService {
130    /// Atom 1.0 feed.
131    #[serde(rename = "atom1.0")]
132    Atom1_0,
133    /// Blogger.
134    Blogger,
135    /// `BuddyCloud`.
136    Buddycloud,
137    /// Diaspora.
138    Diaspora,
139    /// Dreamwidth.
140    Dreamwidth,
141    /// Drupal.
142    Drupal,
143    /// Facebook.
144    Facebook,
145    /// Friendica.
146    Friendica,
147    /// GNU Social.
148    GnuSocial,
149    /// Google.
150    Google,
151    /// `InsaneJournal`.
152    InsaneJournal,
153    /// Libertree.
154    Libertree,
155    /// `LinkedIn`.
156    LinkedIn,
157    /// `LiveJournal`.
158    LiveJournal,
159    /// `MediaGoblin`.
160    MediaGoblin,
161    /// `MySpace`.
162    MySpace,
163    /// Pinterest.
164    Pinterest,
165    /// Pnut.
166    Pnut,
167    /// Posterous.
168    Posterous,
169    /// Pump.io.
170    PumpIo,
171    /// Redmatrix (legacy).
172    RedMatrix,
173    /// RSS 2.0 feed.
174    #[serde(rename = "rss2.0")]
175    Rss2_0,
176    /// SMTP.
177    Smtp,
178    /// Tent.
179    Tent,
180    /// Tumblr.
181    Tumblr,
182    /// Twitter.
183    Twitter,
184    /// `WordPress`.
185    WordPress,
186    /// Xmpp.
187    Xmpp,
188    /// An unrecognised service identifier, preserved verbatim.
189    #[serde(untagged)]
190    Other(String),
191}
192
193/// Set of inbound/outbound bridge services.
194///
195/// Per the `NodeInfo` schema, both `inbound` and `outbound` are required
196/// arrays (they may be empty).
197#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
198#[non_exhaustive]
199pub struct Services {
200    /// Inbound services.
201    #[serde(default)]
202    pub inbound: Vec<InboundService>,
203
204    /// Outbound services.
205    #[serde(default)]
206    pub outbound: Vec<OutboundService>,
207}
208
209impl Services {
210    /// Constructs a [`Services`] block.
211    #[must_use]
212    pub const fn new(inbound: Vec<InboundService>, outbound: Vec<OutboundService>) -> Self {
213        Self { inbound, outbound }
214    }
215}
216
217/// Metadata about the software powering a server.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[non_exhaustive]
220pub struct Software {
221    /// Canonical software name (e.g. `mastodon`, `lemmy`, `mitra`).
222    ///
223    /// Per `NodeInfo` 2.0/2.1 schemas the name must match a strict regex
224    /// (`^[a-z0-9-]+$`), but this crate preserves the raw string to
225    /// interoperate with the relaxed FEP-0151 profile.
226    pub name: String,
227
228    /// Version string.
229    pub version: String,
230
231    /// Repository URL (`NodeInfo` 2.1 only).
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub repository: Option<Url>,
234
235    /// Homepage URL (`NodeInfo` 2.1 only).
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub homepage: Option<Url>,
238}
239
240impl Software {
241    /// Constructs a minimal [`Software`] description.
242    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
243        Self {
244            name: name.into(),
245            version: version.into(),
246            repository: None,
247            homepage: None,
248        }
249    }
250
251    /// Sets the repository URL and returns `self` for chaining.
252    #[must_use]
253    pub fn with_repository(mut self, repository: Url) -> Self {
254        self.repository = Some(repository);
255        self
256    }
257
258    /// Sets the homepage URL and returns `self` for chaining.
259    #[must_use]
260    pub fn with_homepage(mut self, homepage: Url) -> Self {
261        self.homepage = Some(homepage);
262        self
263    }
264}
265
266/// Per-user activity counts.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
268#[non_exhaustive]
269pub struct UserCount {
270    /// Total registered users.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub total: Option<u64>,
273
274    /// Users active in the last 180 days.
275    #[serde(
276        rename = "activeHalfyear",
277        default,
278        skip_serializing_if = "Option::is_none"
279    )]
280    pub active_halfyear: Option<u64>,
281
282    /// Users active in the last 30 days.
283    #[serde(
284        rename = "activeMonth",
285        default,
286        skip_serializing_if = "Option::is_none"
287    )]
288    pub active_month: Option<u64>,
289}
290
291impl UserCount {
292    /// Constructs a [`UserCount`] from its components.
293    #[must_use]
294    pub const fn new(
295        total: Option<u64>,
296        active_halfyear: Option<u64>,
297        active_month: Option<u64>,
298    ) -> Self {
299        Self {
300            total,
301            active_halfyear,
302            active_month,
303        }
304    }
305
306    /// Sets the total registered-user count and returns `self`.
307    #[must_use]
308    pub const fn with_total(mut self, total: u64) -> Self {
309        self.total = Some(total);
310        self
311    }
312}
313
314/// Aggregate server usage statistics.
315///
316/// Per the `NodeInfo` schema the `users` field is required; the other fields
317/// are optional but conventionally reported by large instances.
318#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
319#[non_exhaustive]
320pub struct Usage {
321    /// Per-user counts (required field; may contain all `None` values if
322    /// the server chooses not to disclose them).
323    #[serde(default)]
324    pub users: UserCount,
325
326    /// Total number of posts authored by local users.
327    #[serde(
328        rename = "localPosts",
329        default,
330        skip_serializing_if = "Option::is_none"
331    )]
332    pub local_posts: Option<u64>,
333
334    /// Total number of comments authored by local users.
335    #[serde(
336        rename = "localComments",
337        default,
338        skip_serializing_if = "Option::is_none"
339    )]
340    pub local_comments: Option<u64>,
341}
342
343impl Usage {
344    /// Constructs a [`Usage`] with only the required `users` field set.
345    #[must_use]
346    pub const fn new(users: UserCount) -> Self {
347        Self {
348            users,
349            local_posts: None,
350            local_comments: None,
351        }
352    }
353
354    /// Sets the total number of posts authored by local users.
355    #[must_use]
356    pub const fn with_local_posts(mut self, posts: u64) -> Self {
357        self.local_posts = Some(posts);
358        self
359    }
360
361    /// Sets the total number of comments authored by local users.
362    #[must_use]
363    pub const fn with_local_comments(mut self, comments: u64) -> Self {
364        self.local_comments = Some(comments);
365        self
366    }
367}
368
369/// A `NodeInfo` 2.0 / 2.1 document.
370///
371/// All seven top-level fields required by the `NodeInfo` 2.1 schema are
372/// always present on the wire — empty arrays and empty `metadata` objects
373/// are emitted rather than omitted, to keep the document strictly
374/// conformant under both 2.0 and 2.1.
375///
376/// Fields that are specific to 2.1 (e.g. [`Software::repository`]) remain
377/// `Option`-typed and are omitted when unset, so a document built with
378/// [`Version::V2_0`] still validates under the 2.0 schema.
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[non_exhaustive]
381pub struct NodeInfo {
382    /// Schema version this document conforms to.
383    pub version: Version,
384
385    /// Software metadata.
386    pub software: Software,
387
388    /// Federation protocols implemented by this server.
389    #[serde(default)]
390    pub protocols: Vec<Protocol>,
391
392    /// Bridge services (always emitted, even when both arrays are empty).
393    #[serde(default)]
394    pub services: Services,
395
396    /// Whether the server accepts new registrations.
397    #[serde(rename = "openRegistrations")]
398    pub open_registrations: bool,
399
400    /// Aggregate usage statistics.
401    #[serde(default)]
402    pub usage: Usage,
403
404    /// Arbitrary software-specific metadata (always emitted, defaulting to
405    /// an empty object per the schema).
406    #[serde(default = "default_metadata")]
407    pub metadata: serde_json::Value,
408}
409
410fn default_metadata() -> serde_json::Value {
411    serde_json::Value::Object(serde_json::Map::new())
412}
413
414impl NodeInfo {
415    /// Returns a new builder at the given schema version.
416    #[must_use]
417    pub fn builder(version: Version, software: Software) -> NodeInfoBuilder {
418        NodeInfoBuilder {
419            inner: Self {
420                version,
421                software,
422                protocols: Vec::new(),
423                services: Services::default(),
424                open_registrations: false,
425                usage: Usage::default(),
426                metadata: default_metadata(),
427            },
428        }
429    }
430}
431
432/// Builder for [`NodeInfo`] produced by [`NodeInfo::builder`].
433#[derive(Debug)]
434pub struct NodeInfoBuilder {
435    inner: NodeInfo,
436}
437
438impl NodeInfoBuilder {
439    /// Appends a supported protocol.
440    #[must_use]
441    pub fn protocol(mut self, p: Protocol) -> Self {
442        self.inner.protocols.push(p);
443        self
444    }
445
446    /// Replaces all protocols.
447    #[must_use]
448    pub fn protocols(mut self, ps: Vec<Protocol>) -> Self {
449        self.inner.protocols = ps;
450        self
451    }
452
453    /// Replaces the services block.
454    #[must_use]
455    pub fn services(mut self, services: Services) -> Self {
456        self.inner.services = services;
457        self
458    }
459
460    /// Sets the `openRegistrations` flag.
461    #[must_use]
462    pub const fn open_registrations(mut self, open: bool) -> Self {
463        self.inner.open_registrations = open;
464        self
465    }
466
467    /// Sets the usage block.
468    #[must_use]
469    pub const fn usage(mut self, usage: Usage) -> Self {
470        self.inner.usage = usage;
471        self
472    }
473
474    /// Attaches arbitrary metadata.
475    #[must_use]
476    pub fn metadata<V: Into<serde_json::Value>>(mut self, v: V) -> Self {
477        self.inner.metadata = v.into();
478        self
479    }
480
481    /// Attaches a single typed metadata entry, producing an object on the
482    /// wire.
483    #[must_use]
484    pub fn metadata_entry(
485        mut self,
486        key: impl Into<String>,
487        value: impl Into<serde_json::Value>,
488    ) -> Self {
489        let mut map = match self.inner.metadata {
490            serde_json::Value::Object(m) => m,
491            _ => serde_json::Map::new(),
492        };
493        map.insert(key.into(), value.into());
494        self.inner.metadata = serde_json::Value::Object(map);
495        self
496    }
497
498    /// Finalises the [`NodeInfo`].
499    #[must_use]
500    pub fn build(self) -> NodeInfo {
501        self.inner
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use pretty_assertions::assert_eq;
508    use serde_json::json;
509
510    use super::*;
511
512    #[test]
513    fn version_roundtrips() {
514        let v = Version::V2_1;
515        assert_eq!(v.as_str(), "2.1");
516        assert_eq!(
517            v.schema_uri(),
518            "http://nodeinfo.diaspora.software/ns/schema/2.1"
519        );
520        let j = serde_json::to_value(v).unwrap();
521        assert_eq!(j, json!("2.1"));
522    }
523
524    #[test]
525    fn every_schema_protocol_roundtrips() {
526        // Covers the exact 10-value `protocols` enumeration of the
527        // NodeInfo 2.1 schema. A regression here means either a schema
528        // drift (an enum variant was renamed/removed) or a serde rename
529        // typo on our side.
530        for (canonical, expected) in [
531            ("activitypub", Protocol::ActivityPub),
532            ("buddycloud", Protocol::Buddycloud),
533            ("dfrn", Protocol::Dfrn),
534            ("diaspora", Protocol::Diaspora),
535            ("libertree", Protocol::Libertree),
536            ("ostatus", Protocol::OStatus),
537            ("pumpio", Protocol::PumpIo),
538            ("tent", Protocol::Tent),
539            ("xmpp", Protocol::Xmpp),
540            ("zot", Protocol::Zot),
541        ] {
542            let p: Protocol =
543                serde_json::from_value(json!(canonical)).expect("known value must deserialise");
544            assert_eq!(
545                p, expected,
546                "{canonical} should deserialise to {expected:?}"
547            );
548
549            let back = serde_json::to_value(&p).expect("known value must serialise");
550            assert_eq!(
551                back,
552                json!(canonical),
553                "{expected:?} should serialise back to {canonical}",
554            );
555        }
556    }
557
558    #[test]
559    fn protocol_preserves_unknown_variant() {
560        // Matrix, Bluesky, Nostr and similar community extensions are not
561        // part of the NodeInfo schema but appear in production documents;
562        // they must be preserved verbatim through `Other(String)`.
563        let p: Protocol =
564            serde_json::from_value(json!("bluesky")).expect("unknown value must deserialise");
565        assert_eq!(p, Protocol::Other("bluesky".to_owned()));
566
567        let back = serde_json::to_value(&p).expect("Other variant must serialise");
568        assert_eq!(back, json!("bluesky"));
569    }
570
571    #[test]
572    fn outbound_service_mediagoblin_roundtrips() {
573        let s: OutboundService = serde_json::from_value(json!("mediagoblin")).unwrap();
574        assert_eq!(s, OutboundService::MediaGoblin);
575        let back = serde_json::to_value(&s).unwrap();
576        assert_eq!(back, json!("mediagoblin"));
577    }
578
579    #[test]
580    fn mastodon_style_nodeinfo_roundtrips_verbatim() {
581        let raw = json!({
582            "version": "2.1",
583            "software": {
584                "name": "mastodon",
585                "version": "4.5.0",
586                "repository": "https://github.com/mastodon/mastodon",
587                "homepage": "https://joinmastodon.org/"
588            },
589            "protocols": ["activitypub"],
590            "services": {
591                "inbound": [],
592                "outbound": []
593            },
594            "openRegistrations": true,
595            "usage": {
596                "users": {
597                    "total": 1234,
598                    "activeHalfyear": 400,
599                    "activeMonth": 50
600                },
601                "localPosts": 9999,
602                "localComments": 8888
603            },
604            "metadata": {}
605        });
606
607        let info: NodeInfo = serde_json::from_value(raw.clone()).unwrap();
608        assert_eq!(info.version, Version::V2_1);
609        assert_eq!(info.software.name, "mastodon");
610        assert_eq!(info.protocols, vec![Protocol::ActivityPub]);
611        assert_eq!(info.usage.users.total, Some(1234));
612        assert!(info.open_registrations);
613
614        let back = serde_json::to_value(&info).unwrap();
615        assert_eq!(back, raw, "roundtrip must preserve verbatim JSON");
616    }
617
618    #[test]
619    fn builder_always_emits_required_fields() {
620        let info = NodeInfo::builder(Version::V2_0, Software::new("test-server", "0.1.0"))
621            .protocol(Protocol::ActivityPub)
622            .open_registrations(false)
623            .build();
624
625        let v = serde_json::to_value(&info).unwrap();
626        assert_eq!(v["version"], json!("2.0"));
627        assert_eq!(v["protocols"], json!(["activitypub"]));
628        assert_eq!(v["services"], json!({"inbound": [], "outbound": []}));
629        assert_eq!(v["openRegistrations"], json!(false));
630        assert_eq!(v["metadata"], json!({}));
631        // `usage.users` must be present even when empty
632        assert!(v["usage"].get("users").is_some());
633    }
634
635    #[test]
636    fn metadata_entry_builds_object() {
637        let info = NodeInfo::builder(Version::V2_1, Software::new("my-server", "1.0.0"))
638            .metadata_entry("supports_feps", json!(["521a", "8b32"]))
639            .build();
640
641        assert_eq!(info.metadata["supports_feps"], json!(["521a", "8b32"]));
642    }
643
644    #[test]
645    fn software_builder_sets_optional_fields() {
646        let sw = Software::new("mastodon", "4.5.0")
647            .with_repository("https://github.com/mastodon/mastodon".parse().unwrap())
648            .with_homepage("https://joinmastodon.org/".parse().unwrap());
649
650        let v = serde_json::to_value(&sw).unwrap();
651        assert_eq!(
652            v["repository"],
653            json!("https://github.com/mastodon/mastodon")
654        );
655        assert_eq!(v["homepage"], json!("https://joinmastodon.org/"));
656    }
657}