1use serde::{Deserialize, Serialize};
10use url::Url;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[non_exhaustive]
15pub enum Version {
16 #[serde(rename = "2.0")]
18 V2_0,
19
20 #[serde(rename = "2.1")]
22 V2_1,
23}
24
25impl Version {
26 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56#[non_exhaustive]
57pub enum Protocol {
58 ActivityPub,
60 Buddycloud,
62 Dfrn,
64 Diaspora,
66 Libertree,
68 OStatus,
70 PumpIo,
72 Tent,
74 Xmpp,
76 Zot,
78 #[serde(untagged)]
84 Other(String),
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "lowercase")]
95#[non_exhaustive]
96pub enum InboundService {
97 #[serde(rename = "atom1.0")]
99 Atom1_0,
100 GnuSocial,
102 Imap,
104 Pnut,
106 Pop3,
108 PumpIo,
110 #[serde(rename = "rss2.0")]
112 Rss2_0,
113 Twitter,
115 #[serde(untagged)]
117 Other(String),
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128#[non_exhaustive]
129pub enum OutboundService {
130 #[serde(rename = "atom1.0")]
132 Atom1_0,
133 Blogger,
135 Buddycloud,
137 Diaspora,
139 Dreamwidth,
141 Drupal,
143 Facebook,
145 Friendica,
147 GnuSocial,
149 Google,
151 InsaneJournal,
153 Libertree,
155 LinkedIn,
157 LiveJournal,
159 MediaGoblin,
161 MySpace,
163 Pinterest,
165 Pnut,
167 Posterous,
169 PumpIo,
171 RedMatrix,
173 #[serde(rename = "rss2.0")]
175 Rss2_0,
176 Smtp,
178 Tent,
180 Tumblr,
182 Twitter,
184 WordPress,
186 Xmpp,
188 #[serde(untagged)]
190 Other(String),
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
198#[non_exhaustive]
199pub struct Services {
200 #[serde(default)]
202 pub inbound: Vec<InboundService>,
203
204 #[serde(default)]
206 pub outbound: Vec<OutboundService>,
207}
208
209impl Services {
210 #[must_use]
212 pub const fn new(inbound: Vec<InboundService>, outbound: Vec<OutboundService>) -> Self {
213 Self { inbound, outbound }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[non_exhaustive]
220pub struct Software {
221 pub name: String,
227
228 pub version: String,
230
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub repository: Option<Url>,
234
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub homepage: Option<Url>,
238}
239
240impl Software {
241 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 #[must_use]
253 pub fn with_repository(mut self, repository: Url) -> Self {
254 self.repository = Some(repository);
255 self
256 }
257
258 #[must_use]
260 pub fn with_homepage(mut self, homepage: Url) -> Self {
261 self.homepage = Some(homepage);
262 self
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
268#[non_exhaustive]
269pub struct UserCount {
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub total: Option<u64>,
273
274 #[serde(
276 rename = "activeHalfyear",
277 default,
278 skip_serializing_if = "Option::is_none"
279 )]
280 pub active_halfyear: Option<u64>,
281
282 #[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 #[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 #[must_use]
308 pub const fn with_total(mut self, total: u64) -> Self {
309 self.total = Some(total);
310 self
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
319#[non_exhaustive]
320pub struct Usage {
321 #[serde(default)]
324 pub users: UserCount,
325
326 #[serde(
328 rename = "localPosts",
329 default,
330 skip_serializing_if = "Option::is_none"
331 )]
332 pub local_posts: Option<u64>,
333
334 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[non_exhaustive]
381pub struct NodeInfo {
382 pub version: Version,
384
385 pub software: Software,
387
388 #[serde(default)]
390 pub protocols: Vec<Protocol>,
391
392 #[serde(default)]
394 pub services: Services,
395
396 #[serde(rename = "openRegistrations")]
398 pub open_registrations: bool,
399
400 #[serde(default)]
402 pub usage: Usage,
403
404 #[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 #[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#[derive(Debug)]
434pub struct NodeInfoBuilder {
435 inner: NodeInfo,
436}
437
438impl NodeInfoBuilder {
439 #[must_use]
441 pub fn protocol(mut self, p: Protocol) -> Self {
442 self.inner.protocols.push(p);
443 self
444 }
445
446 #[must_use]
448 pub fn protocols(mut self, ps: Vec<Protocol>) -> Self {
449 self.inner.protocols = ps;
450 self
451 }
452
453 #[must_use]
455 pub fn services(mut self, services: Services) -> Self {
456 self.inner.services = services;
457 self
458 }
459
460 #[must_use]
462 pub const fn open_registrations(mut self, open: bool) -> Self {
463 self.inner.open_registrations = open;
464 self
465 }
466
467 #[must_use]
469 pub const fn usage(mut self, usage: Usage) -> Self {
470 self.inner.usage = usage;
471 self
472 }
473
474 #[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 #[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 #[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 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 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 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}