1use crate::HashSet;
2use iri_rs::{Iri, IriBuf, iri};
3use mediatype::{
4 MediaType,
5 MediaTypeBuf,
6 names::{APPLICATION, JSON, LD},
7};
8
9pub const LD_JSON_MEDIA_TYPE: MediaType<'static> = MediaType::from_parts(APPLICATION, LD, Some(JSON), &[]);
11
12pub const JSON_MEDIA_TYPE: MediaType<'static> = MediaType::new(APPLICATION, JSON);
14use rdfx::vocabulary::{IriVocabulary, IriVocabularyMut};
15use std::{borrow::Cow, hash::Hash};
16
17pub mod chain;
19pub mod dereference;
21pub mod fs;
23pub mod map;
25pub mod none;
27
28pub use chain::ChainLoader;
29pub use dereference::Dereference;
30pub use fs::FsLoader;
31pub use none::NoLoader;
32
33#[cfg(feature = "reqwest")]
34pub mod reqwest;
35
36#[cfg(feature = "reqwest")]
37pub use self::reqwest::ReqwestLoader;
38
39pub type LoadingResult<I, E> = Result<RemoteDocument<I>, LoadError<E>>;
41
42pub type RemoteContextReference<I = IriBuf> = RemoteDocumentReference<I, jsonld_syntax::Context>;
44
45#[derive(Debug, Clone)]
49pub enum RemoteDocumentReference<I = IriBuf, T = jstrict::Value> {
50 Iri(I),
52
53 Loaded(RemoteDocument<I, T>),
55}
56
57impl<I, T> RemoteDocumentReference<I, T> {
58 pub fn iri(iri: I) -> Self {
63 Self::Iri(iri)
64 }
65}
66
67impl<I> RemoteDocumentReference<I> {
68 pub async fn load_with<V, L>(self, vocabulary: &mut V, loader: &L) -> LoadingResult<I, L::Error>
77 where
78 V: IriVocabularyMut<Iri = I>,
79 L: Loader,
80 I: Clone + Eq + Hash,
81 {
82 match self {
83 Self::Iri(r) => Ok(loader.dereference(vocabulary, r).await?.map(Into::into)),
84 Self::Loaded(doc) => Ok(doc),
85 }
86 }
87
88 pub async fn loaded_with<V, L>(&self, vocabulary: &mut V, loader: &L) -> Result<Cow<'_, RemoteDocument<V::Iri>>, LoadError<L::Error>>
99 where
100 V: IriVocabularyMut<Iri = I>,
101 L: Loader,
102 I: Clone + Eq + Hash,
103 {
104 match self {
105 Self::Iri(r) => Ok(Cow::Owned(loader.dereference(vocabulary, r.clone()).await?.map(Into::into))),
106 Self::Loaded(doc) => Ok(Cow::Borrowed(doc)),
107 }
108 }
109}
110
111#[derive(Debug, thiserror::Error)]
112pub enum ContextLoadError<E> {
114 #[error(transparent)]
115 LoadingDocumentFailed(#[from] LoadError<E>),
117
118 #[error("context extraction failed")]
119 ContextExtractionFailed(#[from] ExtractContextError),
121}
122
123impl<I> RemoteContextReference<I> {
124 pub async fn load_context_with<V, L: Loader>(self, vocabulary: &mut V, loader: &L) -> Result<RemoteContext<I>, ContextLoadError<L::Error>>
133 where
134 V: IriVocabularyMut<Iri = I>,
135 I: Clone + Eq + Hash,
136 {
137 match self {
138 Self::Iri(r) => Ok(loader.dereference(vocabulary, r).await?.try_map(ExtractContext::into_ld_context)?),
139 Self::Loaded(doc) => Ok(doc),
140 }
141 }
142
143 pub async fn loaded_context_with<V, L: Loader>(&self, vocabulary: &mut V, loader: &L) -> Result<Cow<'_, RemoteContext<I>>, ContextLoadError<L::Error>>
154 where
155 V: IriVocabularyMut<Iri = I>,
156 I: Clone + Eq + Hash,
157 {
158 match self {
159 Self::Iri(r) => Ok(Cow::Owned(
160 loader.dereference(vocabulary, r.clone()).await?.try_map(ExtractContext::into_ld_context)?,
161 )),
162 Self::Loaded(doc) => Ok(Cow::Borrowed(doc)),
163 }
164 }
165}
166
167#[derive(Debug, Clone)]
171pub struct RemoteDocument<I = IriBuf, T = jstrict::Value> {
172 pub url: Option<I>,
174
175 pub content_type: Option<MediaTypeBuf>,
178
179 pub context_url: Option<I>,
189
190 pub profile: HashSet<Profile<I>>,
192
193 pub document: T,
195}
196
197pub type RemoteContext<I = IriBuf> = RemoteDocument<I, jsonld_syntax::context::Context>;
199
200impl<I, T> RemoteDocument<I, T> {
201 pub fn new(url: Option<I>, content_type: Option<MediaTypeBuf>, document: T) -> Self {
208 Self::new_full(url, content_type, None, HashSet::default(), document)
209 }
210
211 pub fn new_full(url: Option<I>, content_type: Option<MediaTypeBuf>, context_url: Option<I>, profile: HashSet<Profile<I>>, document: T) -> Self {
225 Self {
226 url,
227 content_type,
228 context_url,
229 profile,
230 document,
231 }
232 }
233
234 pub fn map<U>(self, f: impl Fn(T) -> U) -> RemoteDocument<I, U> {
236 RemoteDocument {
237 url: self.url,
238 content_type: self.content_type,
239 context_url: self.context_url,
240 profile: self.profile,
241 document: f(self.document),
242 }
243 }
244
245 pub fn try_map<U, E>(self, f: impl Fn(T) -> Result<U, E>) -> Result<RemoteDocument<I, U>, E> {
251 Ok(RemoteDocument {
252 url: self.url,
253 content_type: self.content_type,
254 context_url: self.context_url,
255 profile: self.profile,
256 document: f(self.document)?,
257 })
258 }
259
260 pub fn map_iris<J>(self, mut f: impl FnMut(I) -> J) -> RemoteDocument<J, T>
262 where
263 J: Eq + Hash,
264 {
265 RemoteDocument {
266 url: self.url.map(&mut f),
267 content_type: self.content_type,
268 context_url: self.context_url.map(&mut f),
269 profile: self.profile.into_iter().map(|p| p.map_iri(&mut f)).collect(),
270 document: self.document,
271 }
272 }
273
274 pub fn url(&self) -> Option<&I> {
276 self.url.as_ref()
277 }
278
279 pub fn content_type(&self) -> Option<&MediaTypeBuf> {
282 self.content_type.as_ref()
283 }
284
285 pub fn context_url(&self) -> Option<&I> {
296 self.context_url.as_ref()
297 }
298
299 pub fn document(&self) -> &T {
301 &self.document
302 }
303
304 pub fn document_mut(&mut self) -> &mut T {
306 &mut self.document
307 }
308
309 pub fn into_document(self) -> T {
311 self.document
312 }
313
314 pub fn into_url(self) -> Option<I> {
316 self.url
317 }
318
319 pub fn set_url(&mut self, url: Option<I>) {
321 self.url = url;
322 }
323}
324
325impl<I> RemoteDocument<I, jstrict::Value> {
326 pub fn from_value(url: Option<I>, content_type: Option<MediaTypeBuf>, document: impl Into<jstrict::Value>) -> Self {
333 Self::new(url, content_type, document.into())
334 }
335}
336
337#[cfg(feature = "serde-json")]
338impl<I> RemoteDocument<I, jstrict::Value> {
339 pub fn from_serde_json(url: Option<I>, content_type: Option<MediaTypeBuf>, document: serde_json::Value) -> Self {
341 Self::new(url, content_type, jstrict::Value::from_serde_json(document))
342 }
343
344 pub fn into_serde_json(self) -> RemoteDocument<I, serde_json::Value> {
346 self.map(jstrict::Value::into_serde_json)
347 }
348}
349
350#[cfg(feature = "sonic-rs")]
351impl<I> RemoteDocument<I, jstrict::Value> {
352 pub fn from_sonic_rs(url: Option<I>, content_type: Option<MediaTypeBuf>, document: &sonic_rs::Value) -> Self {
354 Self::new(url, content_type, jstrict::Value::from_sonic_rs(document))
355 }
356
357 pub fn into_sonic_rs(self) -> RemoteDocument<I, sonic_rs::Value> {
359 self.map(jstrict::Value::into_sonic_rs)
360 }
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
367pub enum StandardProfile {
368 Expanded,
370
371 Compacted,
373
374 Context,
376
377 Flattened,
379
380 Framed,
382}
383
384impl StandardProfile {
385 #[must_use]
387 pub fn from_iri(iri: Iri<&str>) -> Option<Self> {
388 if iri == iri!("http://www.w3.org/ns/json-ld#expanded") {
389 Some(Self::Expanded)
390 } else if iri == iri!("http://www.w3.org/ns/json-ld#compacted") {
391 Some(Self::Compacted)
392 } else if iri == iri!("http://www.w3.org/ns/json-ld#context") {
393 Some(Self::Context)
394 } else if iri == iri!("http://www.w3.org/ns/json-ld#flattened") {
395 Some(Self::Flattened)
396 } else if iri == iri!("http://www.w3.org/ns/json-ld#framed") {
397 Some(Self::Framed)
398 } else {
399 None
400 }
401 }
402
403 #[must_use]
405 pub fn iri(&self) -> Iri<&'static str> {
406 match self {
407 Self::Expanded => iri!("http://www.w3.org/ns/json-ld#expanded"),
408 Self::Compacted => iri!("http://www.w3.org/ns/json-ld#compacted"),
409 Self::Context => iri!("http://www.w3.org/ns/json-ld#context"),
410 Self::Flattened => iri!("http://www.w3.org/ns/json-ld#flattened"),
411 Self::Framed => iri!("http://www.w3.org/ns/json-ld#framed"),
412 }
413 }
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
423pub enum Profile<I = IriBuf> {
424 Standard(StandardProfile),
426 Custom(I),
428}
429
430impl Profile {
431 #[must_use]
434 pub fn new(iri: Iri<&str>) -> Self {
435 match StandardProfile::from_iri(iri) {
436 Some(p) => Self::Standard(p),
437 None => Self::Custom(iri.into()),
438 }
439 }
440
441 #[must_use]
443 pub fn iri(&self) -> Iri<&str> {
444 match self {
445 Self::Standard(s) => s.iri(),
446 Self::Custom(c) => c.as_ref(),
447 }
448 }
449}
450
451impl<I> Profile<I> {
452 pub fn new_with(iri: Iri<&str>, vocabulary: &mut impl IriVocabularyMut<Iri = I>) -> Self {
454 match StandardProfile::from_iri(iri) {
455 Some(p) => Self::Standard(p),
456 None => Self::Custom(vocabulary.insert(iri)),
457 }
458 }
459
460 pub fn iri_with<'a>(&'a self, vocabulary: &'a impl IriVocabulary<Iri = I>) -> Option<Iri<&'a str>> {
463 match self {
464 Self::Standard(s) => Some(s.iri()),
465 Self::Custom(c) => vocabulary.iri(c),
466 }
467 }
468
469 pub fn map_iri<J>(self, f: impl FnOnce(I) -> J) -> Profile<J> {
471 match self {
472 Self::Standard(p) => Profile::Standard(p),
473 Self::Custom(i) => Profile::Custom(f(i)),
474 }
475 }
476}
477
478#[derive(Debug, thiserror::Error)]
481#[error("loading document `{target}` failed: {source}")]
482pub struct LoadError<E> {
484 pub target: IriBuf,
486 #[source]
487 pub source: E,
489}
490
491impl<E> LoadError<E> {
492 pub fn new(target: IriBuf, source: E) -> Self {
494 Self { target, source }
495 }
496
497 pub fn map_source<F, U>(self, f: F) -> LoadError<U>
499 where
500 F: FnOnce(E) -> U,
501 {
502 LoadError {
503 target: self.target,
504 source: f(self.source),
505 }
506 }
507}
508
509pub trait Loader {
529 type Error: std::error::Error + Send + Sync + 'static;
531
532 async fn load_with<V>(&self, vocabulary: &mut V, url: V::Iri) -> LoadingResult<V::Iri, Self::Error>
539 where
540 V: IriVocabularyMut,
541 V::Iri: Clone + Eq + Hash,
542 {
543 #[allow(clippy::expect_used)]
544 let lexical_url = vocabulary.iri(&url).expect("`url` does not resolve in the given vocabulary");
545 #[expect(
546 clippy::disallowed_methods,
547 reason = "the provided `load_with` delegates to the loader's own `load` inside its caller's guarded poll"
548 )]
549 let document = self.load(lexical_url).await?;
550 Ok(document.map_iris(|i| vocabulary.insert_owned(i)))
551 }
552
553 async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>>;
555}
556
557impl<L: Loader> Loader for &L {
558 type Error = L::Error;
559
560 #[expect(
561 clippy::disallowed_methods,
562 reason = "a loader delegating to the loader it wraps already runs inside its caller's guarded poll"
563 )]
564 async fn load_with<V>(&self, vocabulary: &mut V, url: V::Iri) -> LoadingResult<V::Iri, Self::Error>
565 where
566 V: IriVocabularyMut,
567 V::Iri: Clone + Eq + Hash,
568 {
569 L::load_with(self, vocabulary, url).await
570 }
571
572 #[expect(
573 clippy::disallowed_methods,
574 reason = "a loader delegating to the loader it wraps already runs inside its caller's guarded poll"
575 )]
576 async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>> {
577 L::load(self, url).await
578 }
579}
580
581impl<L: Loader> Loader for &mut L {
582 type Error = L::Error;
583
584 #[expect(
585 clippy::disallowed_methods,
586 reason = "a loader delegating to the loader it wraps already runs inside its caller's guarded poll"
587 )]
588 async fn load_with<V>(&self, vocabulary: &mut V, url: V::Iri) -> LoadingResult<V::Iri, Self::Error>
589 where
590 V: IriVocabularyMut,
591 V::Iri: Clone + Eq + Hash,
592 {
593 L::load_with(self, vocabulary, url).await
594 }
595
596 #[expect(
597 clippy::disallowed_methods,
598 reason = "a loader delegating to the loader it wraps already runs inside its caller's guarded poll"
599 )]
600 async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>> {
601 L::load(self, url).await
602 }
603}
604
605#[derive(Debug, thiserror::Error)]
607pub enum ExtractContextError {
608 #[error("unexpected {0}")]
610 Unexpected(jstrict::Kind),
611
612 #[error("missing `@context` entry")]
614 NoContext,
615
616 #[error("duplicate `@context` entry")]
618 DuplicateContext,
619
620 #[error("JSON-LD context syntax error: {0}")]
622 Syntax(jsonld_syntax::context::InvalidContext),
623}
624
625impl ExtractContextError {
626 fn duplicate_context(jstrict::object::Duplicate(_, _): jstrict::object::Duplicate<jstrict::object::Entry>) -> Self {
627 Self::DuplicateContext
628 }
629}
630
631pub trait ExtractContext {
633 fn into_ld_context(self) -> Result<jsonld_syntax::context::Context, ExtractContextError>;
639}
640
641impl ExtractContext for jstrict::Value {
642 fn into_ld_context(self) -> Result<jsonld_syntax::context::Context, ExtractContextError> {
643 match self {
644 Self::Object(mut o) => match o.remove_unique("@context").map_err(ExtractContextError::duplicate_context)? {
645 Some(context) => {
646 use jsonld_syntax::TryFromJson;
647 jsonld_syntax::context::Context::try_from_json(&context.value).map_err(ExtractContextError::Syntax)
648 }
649 None => Err(ExtractContextError::NoContext),
650 },
651 other => Err(ExtractContextError::Unexpected(other.kind())),
652 }
653 }
654}
655
656#[cfg(all(test, feature = "serde-json"))]
657mod serde_json_tests {
658 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
659 use super::*;
660 use iri_rs::iri;
661
662 #[test]
663 fn from_serde_json_preserves_url_and_content_type() {
664 let url = IriBuf::from(iri!("https://example.com/sample.jsonld"));
665 let mime: MediaTypeBuf = LD_JSON_MEDIA_TYPE.into();
666 let value = serde_json::json!({"foo": "bar"});
667
668 let doc = RemoteDocument::from_serde_json(Some(url.clone()), Some(mime.clone()), value);
669
670 assert_eq!(doc.url(), Some(&url));
671 assert_eq!(doc.content_type(), Some(&mime));
672 assert!(doc.context_url().is_none());
673 assert!(doc.profile.is_empty());
674 match doc.document() {
675 jstrict::Value::Object(o) => {
676 assert_eq!(o.get("foo").next().unwrap().as_str(), Some("bar"));
677 }
678 other => panic!("expected object, got {:?}", other.kind()),
679 }
680 }
681
682 #[test]
683 fn from_serde_json_handles_all_value_kinds() {
684 let value = serde_json::json!({
685 "null": null,
686 "bool": true,
687 "num": 1.5,
688 "str": "x",
689 "arr": [1, 2],
690 "obj": {"k": "v"}
691 });
692 let doc = RemoteDocument::<IriBuf, _>::from_serde_json(None, None, value);
693 let jstrict::Value::Object(obj) = doc.document() else {
694 panic!("expected object")
695 };
696 assert!(matches!(obj.get("null").next().unwrap(), jstrict::Value::Null));
697 assert!(matches!(obj.get("bool").next().unwrap(), jstrict::Value::Boolean(true)));
698 assert!(matches!(obj.get("num").next().unwrap(), jstrict::Value::Number(_)));
699 assert!(matches!(obj.get("str").next().unwrap(), jstrict::Value::String(_)));
700 assert!(matches!(obj.get("arr").next().unwrap(), jstrict::Value::Array(_)));
701 assert!(matches!(obj.get("obj").next().unwrap(), jstrict::Value::Object(_)));
702 }
703
704 #[test]
705 fn into_serde_json_round_trip() {
706 use jstrict::Parse;
707 let original = jstrict::Value::parse_str(r#"{"a": [1, 2, 3], "b": "x"}"#).unwrap().0;
708 let doc: RemoteDocument<IriBuf, _> = RemoteDocument::new(None, None, original.clone());
709 let serde_doc = doc.into_serde_json();
710 let round_tripped = jstrict::Value::from_serde_json(serde_doc.document.clone());
711 assert_eq!(round_tripped, original);
712 }
713
714 #[test]
715 fn from_value_accepts_jstrict_value() {
716 use jstrict::Parse;
717 let value = jstrict::Value::parse_str(r#"{"k": 1}"#).unwrap().0;
718 let doc: RemoteDocument<IriBuf, _> = RemoteDocument::from_value(None, None, value);
719 match doc.document() {
720 jstrict::Value::Object(_) => {}
721 _ => panic!("expected object"),
722 }
723 }
724
725 #[test]
726 fn from_value_accepts_serde_json_value() {
727 let v = serde_json::json!({"k": 1});
728 let via_value: RemoteDocument<IriBuf, _> = RemoteDocument::from_value(None, None, v.clone());
729 let via_serde: RemoteDocument<IriBuf, _> = RemoteDocument::from_serde_json(None, None, v);
730 assert!(jsonld_syntax::Compare::compare(via_value.document(), via_serde.document()));
731 }
732}
733
734#[cfg(all(test, feature = "sonic-rs"))]
735mod sonic_rs_tests {
736 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
737 use super::*;
738 use iri_rs::iri;
739
740 #[test]
741 fn from_sonic_rs_preserves_url_and_content_type() {
742 let url = IriBuf::from(iri!("https://example.com/sample.jsonld"));
743 let mime: MediaTypeBuf = LD_JSON_MEDIA_TYPE.into();
744 let value: sonic_rs::Value = sonic_rs::json!({"foo": "bar"});
745
746 let doc = RemoteDocument::from_sonic_rs(Some(url.clone()), Some(mime.clone()), &value);
747
748 assert_eq!(doc.url(), Some(&url));
749 assert_eq!(doc.content_type(), Some(&mime));
750 match doc.document() {
751 jstrict::Value::Object(o) => {
752 assert_eq!(o.get("foo").next().unwrap().as_str(), Some("bar"));
753 }
754 other => panic!("expected object, got {:?}", other.kind()),
755 }
756 }
757
758 #[test]
759 fn from_sonic_rs_handles_all_value_kinds() {
760 let value: sonic_rs::Value = sonic_rs::json!({
761 "null": null,
762 "bool": true,
763 "num": 1.5,
764 "str": "x",
765 "arr": [1, 2],
766 "obj": {"k": "v"}
767 });
768 let doc = RemoteDocument::<IriBuf, _>::from_sonic_rs(None, None, &value);
769 let jstrict::Value::Object(obj) = doc.document() else {
770 panic!("expected object")
771 };
772 assert!(matches!(obj.get("null").next().unwrap(), jstrict::Value::Null));
773 assert!(matches!(obj.get("bool").next().unwrap(), jstrict::Value::Boolean(true)));
774 assert!(matches!(obj.get("num").next().unwrap(), jstrict::Value::Number(_)));
775 assert!(matches!(obj.get("str").next().unwrap(), jstrict::Value::String(_)));
776 assert!(matches!(obj.get("arr").next().unwrap(), jstrict::Value::Array(_)));
777 assert!(matches!(obj.get("obj").next().unwrap(), jstrict::Value::Object(_)));
778 }
779
780 #[test]
781 fn into_sonic_rs_round_trip() {
782 use jstrict::Parse;
783 let original = jstrict::Value::parse_str(r#"{"a": [1, 2, 3]}"#).unwrap().0;
786 let doc: RemoteDocument<IriBuf, _> = RemoteDocument::new(None, None, original.clone());
787 let sonic_doc = doc.into_sonic_rs();
788 let round_tripped = jstrict::Value::from_sonic_rs(&sonic_doc.document);
789 assert_eq!(round_tripped, original);
790 }
791}