use std::collections::BTreeMap;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use serde::{Deserialize, Serialize};
pub const MEDIA_TYPE: &str = "application/jrd+json";
pub const WELL_KNOWN_PATH: &str = "/.well-known/webfinger";
const RESOURCE: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');
pub fn acct(user: &str, host: &str) -> String {
format!("acct:{user}@{host}")
}
pub fn request_url(host: &str, resource: &str, rels: &[&str]) -> String {
let mut url = format!(
"https://{host}{WELL_KNOWN_PATH}?resource={}",
utf8_percent_encode(resource, RESOURCE)
);
for rel in rels {
url.push_str("&rel=");
url.push_str(&utf8_percent_encode(rel, RESOURCE).to_string());
}
url
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Jrd {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<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<Link>,
}
impl Jrd {
pub fn link(&self, rel: &str) -> Option<&Link> {
self.links.iter().find(|link| link.rel == rel)
}
pub fn links_with(&self, rel: &str) -> impl Iterator<Item = &Link> {
self.links.iter().filter(move |link| link.rel == rel)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Link {
pub rel: String,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub href: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
#[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>>,
}
pub fn parse(json: &str) -> Result<Jrd, serde_json::Error> {
serde_json::from_str(json)
}
#[cfg(test)]
mod tests {
use super::*;
const CAROL: &str = r#"{
"subject" : "acct:carol@example.com",
"aliases" : ["https://example.com/~carol/"],
"properties" : { "http://example.com/ns/role" : "employee", "http://x/nil" : null },
"links" : [
{ "rel" : "http://webfinger.example/rel/profile-page",
"href" : "https://www.example.com/~carol/" },
{ "rel" : "self", "type" : "application/activity+json",
"href" : "https://example.com/users/carol",
"titles" : { "en-us" : "Carol" } }
]
}"#;
#[test]
fn an_account_uri_is_built_per_rfc_7565() {
assert_eq!(acct("bob", "example.com"), "acct:bob@example.com");
}
#[test]
fn the_resource_is_encoded_the_way_the_rfc_shows() {
let url = request_url("example.com", &acct("carol", "example.com"), &[]);
assert_eq!(
url,
"https://example.com/.well-known/webfinger?resource=acct%3Acarol%40example.com"
);
}
#[test]
fn rel_filters_append_in_order() {
let url = request_url("example.com", "acct:a@b", &["self", "http://x/y"]);
assert!(url.ends_with("&rel=self&rel=http%3A%2F%2Fx%2Fy"), "got {url}");
}
#[test]
fn a_jrd_round_trips_its_subject_aliases_and_links() {
let jrd = parse(CAROL).unwrap();
assert_eq!(jrd.subject.as_deref(), Some("acct:carol@example.com"));
assert_eq!(jrd.aliases, vec!["https://example.com/~carol/"]);
assert_eq!(jrd.links.len(), 2);
}
#[test]
fn a_link_is_found_by_relation_with_its_media_type() {
let jrd = parse(CAROL).unwrap();
let this = jrd.link("self").unwrap();
assert_eq!(this.media_type.as_deref(), Some("application/activity+json"));
assert_eq!(this.href.as_deref(), Some("https://example.com/users/carol"));
assert_eq!(this.titles.get("en-us").map(String::as_str), Some("Carol"));
}
#[test]
fn a_null_property_survives_as_a_present_key() {
let jrd = parse(CAROL).unwrap();
assert_eq!(jrd.properties.get("http://x/nil"), Some(&None));
assert_eq!(
jrd.properties.get("http://example.com/ns/role"),
Some(&Some("employee".to_string()))
);
}
#[test]
fn a_minimal_jrd_needs_only_links() {
let jrd = parse(r#"{"links":[{"rel":"self"}]}"#).unwrap();
assert_eq!(jrd.subject, None);
assert!(jrd.aliases.is_empty());
assert_eq!(jrd.links[0].rel, "self");
}
#[test]
fn a_link_without_a_rel_is_rejected() {
assert!(parse(r#"{"links":[{"href":"https://x/"}]}"#).is_err());
}
}