1use crate::client::{Client, RequestError};
26use crate::kv::{KvError, KvStore};
27use http::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue};
28use ojak_jsonld::Registry;
29use serde_json::Value;
30use std::fmt;
31use std::future::Future;
32use std::time::Duration;
33use url::Url;
34
35pub const MEDIA_TYPE: &str = "application/ld+json";
37
38pub const CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
40
41pub const MAX_CONTEXT_BYTES: usize = 1024 * 1024;
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct Limits {
47 pub max_remote: usize,
52 pub timeout: Duration,
55 pub json_ld: ojak_jsonld::Limits,
57}
58
59impl Default for Limits {
60 fn default() -> Self {
61 Self {
62 max_remote: 8,
63 timeout: Duration::from_secs(30),
65 json_ld: ojak_jsonld::Limits::default(),
66 }
67 }
68}
69
70#[derive(Debug)]
72pub enum Error {
73 InvalidIri(String),
75 Request(String, RequestError),
78 Status(String, u16),
80 MediaType(String, Option<String>),
82 NotAContext(String),
84 Load(String, String),
86 Kv(KvError),
89 TooMany(usize),
91 TimedOut,
93 JsonLd(ojak_jsonld::Error),
95}
96
97impl fmt::Display for Error {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 match self {
100 Self::InvalidIri(iri) => write!(f, "not a context IRI ojak will fetch: {iri}"),
101 Self::Request(iri, error) => write!(f, "context {iri}: {error}"),
102 Self::Status(iri, status) => write!(f, "context {iri}: answered {status}"),
103 Self::MediaType(iri, Some(kind)) => {
104 write!(f, "context {iri}: served as {kind}, not {MEDIA_TYPE}")
105 }
106 Self::MediaType(iri, None) => write!(f, "context {iri}: served with no type"),
107 Self::NotAContext(iri) => write!(f, "context {iri}: not a JSON-LD context"),
108 Self::Load(iri, why) => write!(f, "context {iri}: {why}"),
109 Self::Kv(error) => write!(f, "context cache: {error}"),
110 Self::TooMany(limit) => {
111 write!(
112 f,
113 "names more than {limit} contexts ojak would have to fetch"
114 )
115 }
116 Self::TimedOut => f.write_str("fetching its contexts took too long"),
117 Self::JsonLd(error) => write!(f, "JSON-LD: {error}"),
118 }
119 }
120}
121
122impl std::error::Error for Error {}
123
124pub async fn fetch(client: &Client, iri: &str) -> Result<String, Error> {
135 let url = Url::parse(iri).map_err(|_| Error::InvalidIri(iri.to_owned()))?;
136 if !matches!(url.scheme(), "http" | "https") {
137 return Err(Error::InvalidIri(iri.to_owned()));
138 }
139 let mut headers = HeaderMap::new();
140 headers.insert(ACCEPT, HeaderValue::from_static(MEDIA_TYPE));
141 let response = client
142 .get(&url, headers)
143 .await
144 .map_err(|error| Error::Request(iri.to_owned(), error))?;
145 if response.status != 200 {
146 return Err(Error::Status(iri.to_owned(), response.status));
147 }
148 let kind = response
150 .headers
151 .get(CONTENT_TYPE)
152 .and_then(|value| value.to_str().ok())
153 .map(|value| {
154 value
155 .split(';')
156 .next()
157 .unwrap_or_default()
158 .trim()
159 .to_ascii_lowercase()
160 });
161 if kind.as_deref() != Some(MEDIA_TYPE) {
162 return Err(Error::MediaType(iri.to_owned(), kind));
163 }
164 if response.body.len() > MAX_CONTEXT_BYTES {
165 return Err(Error::Request(iri.to_owned(), RequestError::TooLarge));
166 }
167 String::from_utf8(response.body).map_err(|_| Error::NotAContext(iri.to_owned()))
168}
169
170pub async fn fetch_cached<K: KvStore>(
183 kv: &K,
184 client: &Client,
185 iri: &str,
186 ttl: Duration,
187 mut on_store_error: impl FnMut(KvError),
188) -> Result<String, Error> {
189 let key = ["jsonld", "context", iri];
190 match kv.get(&key).await {
191 Ok(Some(Value::String(body))) => return Ok(body),
192 Ok(_) => {}
193 Err(error) => on_store_error(error),
194 }
195 let body = fetch(client, iri).await?;
196 if let Err(error) = kv.set(&key, Value::String(body.clone()), Some(ttl)).await {
197 on_store_error(error);
198 }
199 Ok(body)
200}
201
202pub async fn resolve<F, Fut, E>(
217 registry: &Registry,
218 document: &Value,
219 limits: Limits,
220 load: F,
221) -> Result<Option<Registry>, Error>
222where
223 F: FnMut(String) -> Fut,
224 Fut: Future<Output = Result<String, E>>,
225 E: fmt::Display,
226{
227 tokio::time::timeout(
228 limits.timeout,
229 resolve_within(registry, document, limits, load),
230 )
231 .await
232 .unwrap_or(Err(Error::TimedOut))
233}
234
235async fn resolve_within<F, Fut, E>(
236 registry: &Registry,
237 document: &Value,
238 limits: Limits,
239 mut load: F,
240) -> Result<Option<Registry>, Error>
241where
242 F: FnMut(String) -> Fut,
243 Fut: Future<Output = Result<String, E>>,
244 E: fmt::Display,
245{
246 let mut extended: Option<Registry> = None;
247 let mut loaded = 0;
248 loop {
249 let current = extended.as_ref().unwrap_or(registry);
250 let missing = ojak_jsonld::rdf::unresolved_contexts(current, document, limits.json_ld)
251 .map_err(Error::JsonLd)?;
252 if missing.is_empty() {
253 return Ok(extended);
254 }
255 let mut next = current.clone();
256 for iri in missing {
257 if next.knows(&iri) {
258 continue;
259 }
260 loaded += 1;
261 if loaded > limits.max_remote {
262 return Err(Error::TooMany(limits.max_remote));
263 }
264 let body = load(iri.clone())
265 .await
266 .map_err(|error| Error::Load(iri.clone(), error.to_string()))?;
267 let parsed: Value =
268 serde_json::from_str(&body).map_err(|_| Error::NotAContext(iri.clone()))?;
269 if !parsed
270 .as_object()
271 .is_some_and(|members| members.contains_key("@context"))
272 {
273 return Err(Error::NotAContext(iri));
274 }
275 next = next.with(iri, parsed);
276 }
277 extended = Some(next);
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use serde_json::json;
285 use std::cell::RefCell;
286
287 fn signed_with(context: Value) -> Value {
288 json!({
289 "@context": context,
290 "id": "https://a.example/notes/1",
291 "type": "Note",
292 "content": "hello",
293 })
294 }
295
296 #[tokio::test]
297 async fn nothing_is_loaded_for_a_document_over_bundled_contexts() {
298 let registry = Registry::bundled();
299 let document = signed_with(json!("https://www.w3.org/ns/activitystreams"));
300 let resolved = resolve(®istry, &document, Limits::default(), |iri| async move {
301 Err::<String, _>(format!("asked for {iri}"))
302 })
303 .await
304 .unwrap();
305 assert!(resolved.is_none());
306 }
307
308 #[tokio::test]
309 async fn a_context_and_the_one_it_names_are_loaded() {
310 let registry = Registry::bundled();
311 let document = signed_with(json!([
312 "https://www.w3.org/ns/activitystreams",
313 "https://a.example/ns"
314 ]));
315 let asked = RefCell::new(Vec::new());
316 let resolved = resolve(®istry, &document, Limits::default(), |iri| {
317 asked.borrow_mut().push(iri.clone());
318 async move {
319 Ok::<_, String>(match iri.as_str() {
320 "https://a.example/ns" => json!({"@context": [
321 "https://a.example/more",
322 {"mood": "https://a.example/ns#mood"}
323 ]})
324 .to_string(),
325 _ => {
326 json!({"@context": {"weather": "https://a.example/ns#weather"}}).to_string()
327 }
328 })
329 }
330 })
331 .await
332 .unwrap()
333 .expect("contexts were added");
334 assert_eq!(
335 *asked.borrow(),
336 ["https://a.example/ns", "https://a.example/more"]
337 );
338 let canonical = ojak_jsonld::rdf::canonize(
339 &resolved,
340 &json!({
341 "@context": ["https://www.w3.org/ns/activitystreams", "https://a.example/ns"],
342 "id": "https://a.example/notes/1",
343 "mood": "sunny",
344 "weather": "fair",
345 }),
346 )
347 .unwrap();
348 assert!(canonical.contains("<https://a.example/ns#mood> \"sunny\""));
349 assert!(canonical.contains("<https://a.example/ns#weather> \"fair\""));
350 }
351
352 #[tokio::test]
353 async fn too_many_contexts_are_refused() {
354 let registry = Registry::bundled();
355 let names: Vec<Value> = (0..3)
356 .map(|n| Value::from(format!("https://a.example/ns/{n}")))
357 .collect();
358 let document = signed_with(Value::Array(names));
359 let limits = Limits {
360 max_remote: 2,
361 ..Limits::default()
362 };
363 let result = resolve(®istry, &document, limits, |_| async {
364 Ok::<_, String>(json!({"@context": {}}).to_string())
365 })
366 .await;
367 assert!(matches!(result, Err(Error::TooMany(2))), "{result:?}");
368 }
369
370 #[tokio::test]
371 async fn a_context_that_names_itself_in_a_ring_is_bounded() {
372 let registry = Registry::bundled();
373 let document = signed_with(json!("https://a.example/0"));
374 let result = resolve(®istry, &document, Limits::default(), |iri| async move {
376 let n: u32 = iri.rsplit('/').next().unwrap().parse().unwrap();
377 Ok::<_, String>(json!({"@context": format!("https://a.example/{}", n + 1)}).to_string())
378 })
379 .await;
380 assert!(matches!(result, Err(Error::TooMany(8))), "{result:?}");
381 }
382
383 #[tokio::test]
384 async fn what_is_not_a_context_is_refused() {
385 let registry = Registry::bundled();
386 let document = signed_with(json!("https://a.example/ns"));
387 for body in ["not json", "[1, 2]", "{\"terms\": {}}"] {
388 let result = resolve(®istry, &document, Limits::default(), |_| async move {
389 Ok::<_, String>(body.to_owned())
390 })
391 .await;
392 assert!(
393 matches!(result, Err(Error::NotAContext(_))),
394 "{body}: {result:?}"
395 );
396 }
397 let result = resolve(®istry, &document, Limits::default(), |_| async {
398 Err::<String, _>("gone")
399 })
400 .await;
401 assert!(matches!(result, Err(Error::Load(_, _))), "{result:?}");
402 }
403
404 #[tokio::test]
405 async fn loading_is_held_to_a_deadline() {
406 let registry = Registry::bundled();
407 let document = signed_with(json!("https://a.example/ns"));
408 let limits = Limits {
409 timeout: Duration::from_millis(20),
410 ..Limits::default()
411 };
412 let result = resolve(®istry, &document, limits, |_| async {
413 tokio::time::sleep(Duration::from_secs(5)).await;
414 Ok::<_, String>(json!({"@context": {}}).to_string())
415 })
416 .await;
417 assert!(matches!(result, Err(Error::TimedOut)), "{result:?}");
418 }
419}