actpub_activitystreams/
context.rs1use std::collections::BTreeMap;
10use std::sync::LazyLock;
11
12use serde::{Deserialize, Serialize};
13use url::Url;
14
15use crate::value::OneOrMany;
16
17#[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
29static AS2_URL: LazyLock<Url> = LazyLock::new(|| parse_static_uri("AS2", Context::AS2));
32
33static SECURITY_V1_URL: LazyLock<Url> =
35 LazyLock::new(|| parse_static_uri("security/v1", Context::SECURITY_V1));
36
37static DATA_INTEGRITY_V2_URL: LazyLock<Url> =
39 LazyLock::new(|| parse_static_uri("data-integrity/v2", Context::DATA_INTEGRITY_V2));
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(untagged)]
48pub enum ContextEntry {
49 Uri(Url),
51 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(transparent)]
70pub struct Context(pub OneOrMany<ContextEntry>);
71
72impl Context {
73 pub const AS2: &'static str = "https://www.w3.org/ns/activitystreams";
75 pub const CID_V1: &'static str = "https://www.w3.org/ns/cid/v1";
77 pub const DATA_INTEGRITY_V2: &'static str = "https://w3id.org/security/data-integrity/v2";
79 pub const SECURITY_V1: &'static str = "https://w3id.org/security/v1";
81
82 #[must_use]
84 pub fn activitystreams() -> Self {
85 Self(OneOrMany::one(ContextEntry::Uri(AS2_URL.clone())))
86 }
87
88 #[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 #[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 #[must_use]
110 pub fn entries(&self) -> &[ContextEntry] {
111 self.0.as_slice()
112 }
113
114 pub fn push(&mut self, entry: ContextEntry) {
116 self.0.push(entry);
117 }
118
119 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct WithContext<T> {
158 #[serde(rename = "@context")]
160 pub context: Context,
161 #[serde(flatten)]
163 pub inner: T,
164}
165
166impl<T> WithContext<T> {
167 pub fn new(inner: T) -> Self {
169 Self {
170 context: Context::default(),
171 inner,
172 }
173 }
174
175 pub const fn with_ctx(context: Context, inner: T) -> Self {
177 Self { context, inner }
178 }
179
180 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}