use std::collections::BTreeMap;
use std::sync::LazyLock;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::value::OneOrMany;
#[allow(
clippy::panic,
reason = "panic on failure is unreachable: every caller passes a compile-time-constant URI that is covered by unit tests"
)]
fn parse_static_uri(label: &'static str, uri: &'static str) -> Url {
Url::parse(uri).unwrap_or_else(|e| panic!("invalid {label} URI constant `{uri}`: {e}"))
}
static AS2_URL: LazyLock<Url> = LazyLock::new(|| parse_static_uri("AS2", Context::AS2));
static SECURITY_V1_URL: LazyLock<Url> =
LazyLock::new(|| parse_static_uri("security/v1", Context::SECURITY_V1));
static DATA_INTEGRITY_V2_URL: LazyLock<Url> =
LazyLock::new(|| parse_static_uri("data-integrity/v2", Context::DATA_INTEGRITY_V2));
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ContextEntry {
Uri(Url),
Object(BTreeMap<String, serde_json::Value>),
}
impl From<Url> for ContextEntry {
fn from(url: Url) -> Self {
Self::Uri(url)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Context(pub OneOrMany<ContextEntry>);
impl Context {
pub const AS2: &'static str = "https://www.w3.org/ns/activitystreams";
pub const CID_V1: &'static str = "https://www.w3.org/ns/cid/v1";
pub const DATA_INTEGRITY_V2: &'static str = "https://w3id.org/security/data-integrity/v2";
pub const SECURITY_V1: &'static str = "https://w3id.org/security/v1";
#[must_use]
pub fn activitystreams() -> Self {
Self(OneOrMany::one(ContextEntry::Uri(AS2_URL.clone())))
}
#[must_use]
pub fn activitystreams_security() -> Self {
Self(OneOrMany::many(vec![
ContextEntry::Uri(AS2_URL.clone()),
ContextEntry::Uri(SECURITY_V1_URL.clone()),
]))
}
#[must_use]
pub fn activitystreams_integrity() -> Self {
Self(OneOrMany::many(vec![
ContextEntry::Uri(AS2_URL.clone()),
ContextEntry::Uri(DATA_INTEGRITY_V2_URL.clone()),
]))
}
#[must_use]
pub fn entries(&self) -> &[ContextEntry] {
self.0.as_slice()
}
pub fn push(&mut self, entry: ContextEntry) {
self.0.push(entry);
}
#[must_use]
pub fn contains(&self, uri: &str) -> bool {
self.0.iter().any(|e| match e {
ContextEntry::Uri(u) => u.as_str() == uri,
ContextEntry::Object(_) => false,
})
}
}
impl Default for Context {
fn default() -> Self {
Self::activitystreams()
}
}
impl From<Url> for Context {
fn from(url: Url) -> Self {
Self(OneOrMany::one(ContextEntry::Uri(url)))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WithContext<T> {
#[serde(rename = "@context")]
pub context: Context,
#[serde(flatten)]
pub inner: T,
}
impl<T> WithContext<T> {
pub fn new(inner: T) -> Self {
Self {
context: Context::default(),
inner,
}
}
pub const fn with_ctx(context: Context, inner: T) -> Self {
Self { context, inner }
}
pub fn into_inner(self) -> T {
self.inner
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[test]
fn default_context_is_as2() {
let ctx = Context::default();
assert_eq!(ctx.entries().len(), 1);
assert!(ctx.contains(Context::AS2));
}
#[test]
fn single_uri_serializes_as_bare_value() {
let ctx = Context::activitystreams();
let v = serde_json::to_value(&ctx).unwrap();
assert_eq!(v, json!("https://www.w3.org/ns/activitystreams"));
}
#[test]
fn multi_uri_serializes_as_array() {
let ctx = Context::activitystreams_security();
let v = serde_json::to_value(&ctx).unwrap();
assert_eq!(
v,
json!([
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
])
);
}
#[test]
fn context_accepts_inline_object() {
let json = json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
{ "toot": "http://joinmastodon.org/ns#" }
]
});
let parsed: serde_json::Value = serde_json::from_value(json).expect("valid json fixture");
let ctx: Context = serde_json::from_value(parsed["@context"].clone()).unwrap();
assert_eq!(ctx.entries().len(), 2);
assert!(matches!(ctx.entries()[1], ContextEntry::Object(_)));
}
}