use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::rels;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Jrd {
pub subject: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub properties: BTreeMap<String, Option<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<JrdLink>,
}
impl Jrd {
pub fn builder(subject: impl Into<String>) -> JrdBuilder {
JrdBuilder {
inner: Self {
subject: subject.into(),
..Self::default()
},
}
}
#[must_use]
pub fn find_link(&self, rel: &str) -> Option<&JrdLink> {
self.links.iter().find(|l| l.rel == rel)
}
#[must_use]
pub fn activitypub_actor(&self) -> Option<&JrdLink> {
self.links
.iter()
.find(|l| {
l.rel == rels::SELF
&& matches!(
l.media_type.as_deref(),
Some(mt) if mt == rels::MEDIA_TYPE_ACTIVITYPUB
)
})
.or_else(|| {
self.links.iter().find(|l| {
l.rel == rels::SELF
&& matches!(
l.media_type.as_deref(),
Some(mt) if mt.starts_with("application/ld+json")
)
})
})
}
}
#[derive(Debug)]
pub struct JrdBuilder {
inner: Jrd,
}
impl JrdBuilder {
#[must_use]
pub fn alias(mut self, alias: impl Into<String>) -> Self {
self.inner.aliases.push(alias.into());
self
}
#[must_use]
pub fn property(mut self, key: impl Into<String>, value: Option<String>) -> Self {
self.inner.properties.insert(key.into(), value);
self
}
#[must_use]
pub fn link(mut self, link: JrdLink) -> Self {
self.inner.links.push(link);
self
}
#[must_use]
pub fn build(self) -> Jrd {
self.inner
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct JrdLink {
pub rel: String,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub href: Option<Url>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub titles: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub properties: BTreeMap<String, Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
}
impl JrdLink {
pub fn builder(rel: impl Into<String>) -> JrdLinkBuilder {
JrdLinkBuilder {
inner: Self {
rel: rel.into(),
..Self::default()
},
}
}
pub const fn validate(&self) -> Result<(), &'static str> {
if self.href.is_some() && self.template.is_some() {
return Err("JRD link must not have both `href` and `template`");
}
Ok(())
}
}
#[derive(Debug)]
pub struct JrdLinkBuilder {
inner: JrdLink,
}
impl JrdLinkBuilder {
#[must_use]
pub fn media_type(mut self, media_type: impl Into<String>) -> Self {
self.inner.media_type = Some(media_type.into());
self
}
#[must_use]
pub fn href(mut self, href: Url) -> Self {
self.inner.href = Some(href);
self.inner.template = None;
self
}
#[must_use]
pub fn title(mut self, lang: impl Into<String>, title: impl Into<String>) -> Self {
self.inner.titles.insert(lang.into(), title.into());
self
}
#[must_use]
pub fn property(mut self, key: impl Into<String>, value: Option<String>) -> Self {
self.inner.properties.insert(key.into(), value);
self
}
#[must_use]
pub fn template(mut self, template: impl Into<String>) -> Self {
self.inner.template = Some(template.into());
self.inner.href = None;
self
}
#[must_use]
pub fn build(self) -> JrdLink {
self.inner
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[test]
fn jrd_serializes_only_set_fields() {
let jrd = Jrd::builder("acct:alice@example.com").build();
let v = serde_json::to_value(&jrd).unwrap();
assert_eq!(v, json!({ "subject": "acct:alice@example.com" }));
}
#[test]
fn mastodon_style_jrd_roundtrips() {
let raw = json!({
"subject": "acct:Gargron@mastodon.social",
"aliases": [
"https://mastodon.social/@Gargron",
"https://mastodon.social/users/Gargron"
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://mastodon.social/@Gargron"
},
{
"rel": "self",
"type": "application/activity+json",
"href": "https://mastodon.social/users/Gargron"
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "https://mastodon.social/authorize_interaction?uri={uri}"
}
]
});
let jrd: Jrd = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(jrd.subject, "acct:Gargron@mastodon.social");
assert_eq!(jrd.aliases.len(), 2);
assert_eq!(jrd.links.len(), 3);
let actor = jrd.activitypub_actor().expect("has actor link");
assert_eq!(
actor.href.as_ref().map(Url::as_str),
Some("https://mastodon.social/users/Gargron")
);
let subscribe = jrd.find_link(rels::OSTATUS_SUBSCRIBE).unwrap();
assert!(subscribe.template.is_some());
assert!(subscribe.href.is_none());
let back = serde_json::to_value(&jrd).unwrap();
assert_eq!(back, raw);
}
#[test]
fn builder_round_trips_through_serde() {
let jrd = Jrd::builder("acct:alice@example.com")
.alias("https://example.com/@alice")
.link(
JrdLink::builder(rels::ACTIVITYPUB_ACTOR)
.href("https://example.com/users/alice".parse().unwrap())
.media_type("application/activity+json")
.build(),
)
.build();
let actor = jrd.activitypub_actor().unwrap();
assert_eq!(actor.rel, "self");
let json = serde_json::to_value(&jrd).unwrap();
let back: Jrd = serde_json::from_value(json).unwrap();
assert_eq!(back, jrd);
}
#[test]
fn property_with_null_value_roundtrips() {
let raw = json!({
"subject": "acct:alice@example.com",
"properties": { "http://example/schema/foo": null }
});
let jrd: Jrd = serde_json::from_value(raw.clone()).expect("deserialise");
assert_eq!(
jrd.properties.get("http://example/schema/foo"),
Some(&None),
"null property must deserialise to Some(None), not None",
);
let back = serde_json::to_value(&jrd).expect("serialise");
assert_eq!(back, raw);
}
#[test]
fn jrd_link_validate_accepts_href_only() {
let link = JrdLink::builder(rels::ACTIVITYPUB_ACTOR)
.href("https://example.com/a".parse().expect("valid URL"))
.build();
assert!(link.validate().is_ok());
}
#[test]
fn jrd_link_validate_accepts_template_only() {
let link = JrdLink::builder(rels::OSTATUS_SUBSCRIBE)
.template("https://example.com/subscribe?uri={uri}")
.build();
assert!(link.validate().is_ok());
}
#[test]
fn jrd_link_validate_rejects_both_href_and_template() {
let mut link = JrdLink::builder(rels::SELF).build();
link.href = Some("https://example.com/a".parse().expect("valid URL"));
link.template = Some("https://example.com/t?u={u}".to_owned());
assert!(
link.validate().is_err(),
"RFC 7033 §4.4.4 forbids both `href` and `template` on a single link",
);
}
#[test]
fn jrd_link_builder_href_after_template_clears_template() {
let link = JrdLink::builder(rels::SELF)
.template("https://example.com/t?u={u}")
.href("https://example.com/a".parse().expect("valid URL"))
.build();
assert!(link.template.is_none(), "template must be cleared");
assert!(link.href.is_some(), "href must be retained");
assert!(link.validate().is_ok());
}
#[test]
fn activitypub_actor_falls_back_to_ld_json_profile() {
let jrd: Jrd = serde_json::from_value(json!({
"subject": "acct:alice@example.com",
"links": [{
"rel": "self",
"type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
"href": "https://example.com/users/alice"
}]
}))
.expect("JSON-LD profile JRD must parse");
let actor = jrd
.activitypub_actor()
.expect("should fall back to ld+json profile");
assert_eq!(
actor.href.as_ref().map(Url::as_str),
Some("https://example.com/users/alice"),
);
}
#[test]
fn find_link_returns_none_for_missing_rel() {
let jrd = Jrd::builder("acct:alice@example.com").build();
assert!(jrd.find_link("http://example.com/rel/missing").is_none());
}
}