Skip to main content

actpub_activitystreams/
context.rs

1//! The JSON-LD `@context` property.
2//!
3//! `ActivityPub` is technically a JSON-LD protocol, but in practice the
4//! Fediverse consumes documents as plain JSON using a small set of well-known
5//! context URIs. This module provides a lightweight, tolerant representation
6//! of `@context` that round-trips all shapes encountered in production
7//! without any full JSON-LD processing.
8
9use std::collections::BTreeMap;
10use std::sync::LazyLock;
11
12use serde::{Deserialize, Serialize};
13use url::Url;
14
15use crate::value::OneOrMany;
16
17/// Parses a compile-time constant URI string. The panic is unreachable at
18/// runtime because every call site passes a static string literal that was
19/// already validated against the url crate's grammar; failure would
20/// indicate a bug in a new crate constant, caught by the unit-test suite.
21#[allow(
22    clippy::panic,
23    reason = "panic on failure is unreachable: every caller passes a compile-time-constant URI that is covered by unit tests"
24)]
25fn parse_static_uri(label: &'static str, uri: &'static str) -> Url {
26    Url::parse(uri).unwrap_or_else(|e| panic!("invalid {label} URI constant `{uri}`: {e}"))
27}
28
29/// Lazily-parsed [`Context::AS2`] URL, shared across all constructors to
30/// avoid per-call allocation.
31static AS2_URL: LazyLock<Url> = LazyLock::new(|| parse_static_uri("AS2", Context::AS2));
32
33/// Lazily-parsed [`Context::SECURITY_V1`] URL.
34static SECURITY_V1_URL: LazyLock<Url> =
35    LazyLock::new(|| parse_static_uri("security/v1", Context::SECURITY_V1));
36
37/// Lazily-parsed [`Context::DATA_INTEGRITY_V2`] URL.
38static DATA_INTEGRITY_V2_URL: LazyLock<Url> =
39    LazyLock::new(|| parse_static_uri("data-integrity/v2", Context::DATA_INTEGRITY_V2));
40
41/// A single entry in the `@context` array.
42///
43/// Most entries are URI references to well-known AS 2.0 / security contexts;
44/// the remainder are inline maps that define additional terms (such as
45/// Mastodon's `toot:` namespace).
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(untagged)]
48pub enum ContextEntry {
49    /// A bare context URI.
50    Uri(Url),
51    /// An inline JSON-LD context object.
52    ///
53    /// Values are preserved verbatim as [`serde_json::Value`] since Rust-side
54    /// processing does not inspect them.
55    Object(BTreeMap<String, serde_json::Value>),
56}
57
58impl From<Url> for ContextEntry {
59    fn from(url: Url) -> Self {
60        Self::Uri(url)
61    }
62}
63
64/// The value of a JSON-LD `@context` property.
65///
66/// May be a single entry (emitted as a bare value) or multiple entries
67/// (emitted as an array). The wire format is driven by [`OneOrMany`].
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(transparent)]
70pub struct Context(pub OneOrMany<ContextEntry>);
71
72impl Context {
73    /// The canonical Activity Streams 2.0 context URI.
74    pub const AS2: &'static str = "https://www.w3.org/ns/activitystreams";
75    /// The Controlled Identifiers v1 context, required by FEP-521a.
76    pub const CID_V1: &'static str = "https://www.w3.org/ns/cid/v1";
77    /// The Data Integrity v2 context, required by FEP-8b32 proofs.
78    pub const DATA_INTEGRITY_V2: &'static str = "https://w3id.org/security/data-integrity/v2";
79    /// The legacy Security v1 context used by older Mastodon actors.
80    pub const SECURITY_V1: &'static str = "https://w3id.org/security/v1";
81
82    /// Creates a [`Context`] containing only the canonical AS 2.0 URI.
83    #[must_use]
84    pub fn activitystreams() -> Self {
85        Self(OneOrMany::one(ContextEntry::Uri(AS2_URL.clone())))
86    }
87
88    /// Creates a [`Context`] containing the AS 2.0 URI plus the Security v1
89    /// URI — the combination emitted by most current Fediverse actors.
90    #[must_use]
91    pub fn activitystreams_security() -> Self {
92        Self(OneOrMany::many(vec![
93            ContextEntry::Uri(AS2_URL.clone()),
94            ContextEntry::Uri(SECURITY_V1_URL.clone()),
95        ]))
96    }
97
98    /// Creates a [`Context`] containing AS 2.0 plus the Data Integrity
99    /// context — the combination required when emitting FEP-8b32 proofs.
100    #[must_use]
101    pub fn activitystreams_integrity() -> Self {
102        Self(OneOrMany::many(vec![
103            ContextEntry::Uri(AS2_URL.clone()),
104            ContextEntry::Uri(DATA_INTEGRITY_V2_URL.clone()),
105        ]))
106    }
107
108    /// Returns the entries of this context.
109    #[must_use]
110    pub fn entries(&self) -> &[ContextEntry] {
111        self.0.as_slice()
112    }
113
114    /// Appends an entry to this context.
115    pub fn push(&mut self, entry: ContextEntry) {
116        self.0.push(entry);
117    }
118
119    /// Returns `true` if the context contains the given URI.
120    #[must_use]
121    pub fn contains(&self, uri: &str) -> bool {
122        self.0.iter().any(|e| match e {
123            ContextEntry::Uri(u) => u.as_str() == uri,
124            ContextEntry::Object(_) => false,
125        })
126    }
127}
128
129impl Default for Context {
130    fn default() -> Self {
131        Self::activitystreams()
132    }
133}
134
135impl From<Url> for Context {
136    fn from(url: Url) -> Self {
137        Self(OneOrMany::one(ContextEntry::Uri(url)))
138    }
139}
140
141/// Wraps any Activity Streams payload with a JSON-LD `@context`.
142///
143/// Use this on outbound values to ensure conformant serialization; inbound
144/// values typically carry their own `@context` and can be deserialized
145/// directly into this type.
146///
147/// # Examples
148///
149/// ```
150/// # use actpub_activitystreams::{Context, WithContext, Object};
151/// let obj = Object::with_kind("Note");
152/// let wrapped = WithContext::new(obj);
153/// let json = serde_json::to_string(&wrapped).unwrap();
154/// assert!(json.contains("@context"));
155/// ```
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct WithContext<T> {
158    /// The JSON-LD context of the payload.
159    #[serde(rename = "@context")]
160    pub context: Context,
161    /// The wrapped payload. Flattened in the wire format.
162    #[serde(flatten)]
163    pub inner: T,
164}
165
166impl<T> WithContext<T> {
167    /// Wraps `inner` with the default AS 2.0 context.
168    pub fn new(inner: T) -> Self {
169        Self {
170            context: Context::default(),
171            inner,
172        }
173    }
174
175    /// Wraps `inner` with an explicit context.
176    pub const fn with_ctx(context: Context, inner: T) -> Self {
177        Self { context, inner }
178    }
179
180    /// Unwraps the inner payload.
181    pub fn into_inner(self) -> T {
182        self.inner
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use pretty_assertions::assert_eq;
189    use serde_json::json;
190
191    use super::*;
192
193    #[test]
194    fn default_context_is_as2() {
195        let ctx = Context::default();
196        assert_eq!(ctx.entries().len(), 1);
197        assert!(ctx.contains(Context::AS2));
198    }
199
200    #[test]
201    fn single_uri_serializes_as_bare_value() {
202        let ctx = Context::activitystreams();
203        let v = serde_json::to_value(&ctx).unwrap();
204        assert_eq!(v, json!("https://www.w3.org/ns/activitystreams"));
205    }
206
207    #[test]
208    fn multi_uri_serializes_as_array() {
209        let ctx = Context::activitystreams_security();
210        let v = serde_json::to_value(&ctx).unwrap();
211        assert_eq!(
212            v,
213            json!([
214                "https://www.w3.org/ns/activitystreams",
215                "https://w3id.org/security/v1"
216            ])
217        );
218    }
219
220    #[test]
221    fn context_accepts_inline_object() {
222        let json = json!({
223            "@context": [
224                "https://www.w3.org/ns/activitystreams",
225                { "toot": "http://joinmastodon.org/ns#" }
226            ]
227        });
228        let parsed: serde_json::Value = serde_json::from_value(json).expect("valid json fixture");
229        let ctx: Context = serde_json::from_value(parsed["@context"].clone()).unwrap();
230        assert_eq!(ctx.entries().len(), 2);
231        assert!(matches!(ctx.entries()[1], ContextEntry::Object(_)));
232    }
233}