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 fs;
21pub mod map;
23pub mod none;
25
26pub use chain::ChainLoader;
27pub use fs::FsLoader;
28pub use none::NoLoader;
29
30#[cfg(feature = "reqwest")]
31pub mod reqwest;
32
33#[cfg(feature = "reqwest")]
34pub use self::reqwest::ReqwestLoader;
35
36pub type LoadingResult<I, E> = Result<RemoteDocument<I>, LoadError<E>>;
38
39pub type RemoteContextReference<I = IriBuf> = RemoteDocumentReference<I, jsonld_syntax::Context>;
41
42#[derive(Debug, Clone)]
46pub enum RemoteDocumentReference<I = IriBuf, T = jstrict::Value> {
47 Iri(I),
49
50 Loaded(RemoteDocument<I, T>),
52}
53
54impl<I, T> RemoteDocumentReference<I, T> {
55 pub fn iri(iri: I) -> Self {
60 Self::Iri(iri)
61 }
62}
63
64impl<I> RemoteDocumentReference<I> {
65 pub async fn load_with<V, L>(self, vocabulary: &mut V, loader: &L) -> LoadingResult<I, L::Error>
74 where
75 V: IriVocabularyMut<Iri = I>,
76 L: Loader,
77 I: Clone + Eq + Hash,
78 {
79 match self {
80 Self::Iri(r) => Ok(loader.load_with(vocabulary, r).await?.map(Into::into)),
81 Self::Loaded(doc) => Ok(doc),
82 }
83 }
84
85 pub async fn loaded_with<V, L>(&self, vocabulary: &mut V, loader: &L) -> Result<Cow<'_, RemoteDocument<V::Iri>>, LoadError<L::Error>>
96 where
97 V: IriVocabularyMut<Iri = I>,
98 L: Loader,
99 I: Clone + Eq + Hash,
100 {
101 match self {
102 Self::Iri(r) => Ok(Cow::Owned(loader.load_with(vocabulary, r.clone()).await?.map(Into::into))),
103 Self::Loaded(doc) => Ok(Cow::Borrowed(doc)),
104 }
105 }
106}
107
108#[derive(Debug, thiserror::Error)]
109pub enum ContextLoadError<E> {
111 #[error(transparent)]
112 LoadingDocumentFailed(#[from] LoadError<E>),
114
115 #[error("context extraction failed")]
116 ContextExtractionFailed(#[from] ExtractContextError),
118}
119
120impl<I> RemoteContextReference<I> {
121 pub async fn load_context_with<V, L: Loader>(self, vocabulary: &mut V, loader: &L) -> Result<RemoteContext<I>, ContextLoadError<L::Error>>
130 where
131 V: IriVocabularyMut<Iri = I>,
132 I: Clone + Eq + Hash,
133 {
134 match self {
135 Self::Iri(r) => Ok(loader.load_with(vocabulary, r).await?.try_map(ExtractContext::into_ld_context)?),
136 Self::Loaded(doc) => Ok(doc),
137 }
138 }
139
140 pub async fn loaded_context_with<V, L: Loader>(&self, vocabulary: &mut V, loader: &L) -> Result<Cow<'_, RemoteContext<I>>, ContextLoadError<L::Error>>
151 where
152 V: IriVocabularyMut<Iri = I>,
153 I: Clone + Eq + Hash,
154 {
155 match self {
156 Self::Iri(r) => Ok(Cow::Owned(
157 loader.load_with(vocabulary, r.clone()).await?.try_map(ExtractContext::into_ld_context)?,
158 )),
159 Self::Loaded(doc) => Ok(Cow::Borrowed(doc)),
160 }
161 }
162}
163
164#[derive(Debug, Clone)]
168pub struct RemoteDocument<I = IriBuf, T = jstrict::Value> {
169 pub url: Option<I>,
171
172 pub content_type: Option<MediaTypeBuf>,
175
176 pub context_url: Option<I>,
186
187 pub profile: HashSet<Profile<I>>,
189
190 pub document: T,
192}
193
194pub type RemoteContext<I = IriBuf> = RemoteDocument<I, jsonld_syntax::context::Context>;
196
197impl<I, T> RemoteDocument<I, T> {
198 pub fn new(url: Option<I>, content_type: Option<MediaTypeBuf>, document: T) -> Self {
205 Self::new_full(url, content_type, None, HashSet::default(), document)
206 }
207
208 pub fn new_full(url: Option<I>, content_type: Option<MediaTypeBuf>, context_url: Option<I>, profile: HashSet<Profile<I>>, document: T) -> Self {
222 Self {
223 url,
224 content_type,
225 context_url,
226 profile,
227 document,
228 }
229 }
230
231 pub fn map<U>(self, f: impl Fn(T) -> U) -> RemoteDocument<I, U> {
233 RemoteDocument {
234 url: self.url,
235 content_type: self.content_type,
236 context_url: self.context_url,
237 profile: self.profile,
238 document: f(self.document),
239 }
240 }
241
242 pub fn try_map<U, E>(self, f: impl Fn(T) -> Result<U, E>) -> Result<RemoteDocument<I, U>, E> {
248 Ok(RemoteDocument {
249 url: self.url,
250 content_type: self.content_type,
251 context_url: self.context_url,
252 profile: self.profile,
253 document: f(self.document)?,
254 })
255 }
256
257 pub fn map_iris<J>(self, mut f: impl FnMut(I) -> J) -> RemoteDocument<J, T>
259 where
260 J: Eq + Hash,
261 {
262 RemoteDocument {
263 url: self.url.map(&mut f),
264 content_type: self.content_type,
265 context_url: self.context_url.map(&mut f),
266 profile: self.profile.into_iter().map(|p| p.map_iri(&mut f)).collect(),
267 document: self.document,
268 }
269 }
270
271 pub fn url(&self) -> Option<&I> {
273 self.url.as_ref()
274 }
275
276 pub fn content_type(&self) -> Option<&MediaTypeBuf> {
279 self.content_type.as_ref()
280 }
281
282 pub fn context_url(&self) -> Option<&I> {
293 self.context_url.as_ref()
294 }
295
296 pub fn document(&self) -> &T {
298 &self.document
299 }
300
301 pub fn document_mut(&mut self) -> &mut T {
303 &mut self.document
304 }
305
306 pub fn into_document(self) -> T {
308 self.document
309 }
310
311 pub fn into_url(self) -> Option<I> {
313 self.url
314 }
315
316 pub fn set_url(&mut self, url: Option<I>) {
318 self.url = url;
319 }
320}
321
322impl<I> RemoteDocument<I, jstrict::Value> {
323 pub fn from_value(url: Option<I>, content_type: Option<MediaTypeBuf>, document: impl Into<jstrict::Value>) -> Self {
330 Self::new(url, content_type, document.into())
331 }
332}
333
334#[cfg(feature = "serde-json")]
335impl<I> RemoteDocument<I, jstrict::Value> {
336 pub fn from_serde_json(url: Option<I>, content_type: Option<MediaTypeBuf>, document: serde_json::Value) -> Self {
338 Self::new(url, content_type, jstrict::Value::from_serde_json(document))
339 }
340
341 pub fn into_serde_json(self) -> RemoteDocument<I, serde_json::Value> {
343 self.map(jstrict::Value::into_serde_json)
344 }
345}
346
347#[cfg(feature = "sonic-rs")]
348impl<I> RemoteDocument<I, jstrict::Value> {
349 pub fn from_sonic_rs(url: Option<I>, content_type: Option<MediaTypeBuf>, document: sonic_rs::Value) -> Self {
351 Self::new(url, content_type, jstrict::Value::from_sonic_rs(document))
352 }
353
354 pub fn into_sonic_rs(self) -> RemoteDocument<I, sonic_rs::Value> {
356 self.map(jstrict::Value::into_sonic_rs)
357 }
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
364pub enum StandardProfile {
365 Expanded,
367
368 Compacted,
370
371 Context,
373
374 Flattened,
376
377 Framed,
379}
380
381impl StandardProfile {
382 #[must_use]
384 pub fn from_iri(iri: Iri<&str>) -> Option<Self> {
385 if iri == iri!("http://www.w3.org/ns/json-ld#expanded") {
386 Some(Self::Expanded)
387 } else if iri == iri!("http://www.w3.org/ns/json-ld#compacted") {
388 Some(Self::Compacted)
389 } else if iri == iri!("http://www.w3.org/ns/json-ld#context") {
390 Some(Self::Context)
391 } else if iri == iri!("http://www.w3.org/ns/json-ld#flattened") {
392 Some(Self::Flattened)
393 } else if iri == iri!("http://www.w3.org/ns/json-ld#framed") {
394 Some(Self::Framed)
395 } else {
396 None
397 }
398 }
399
400 #[must_use]
402 pub fn iri(&self) -> Iri<&'static str> {
403 match self {
404 Self::Expanded => iri!("http://www.w3.org/ns/json-ld#expanded"),
405 Self::Compacted => iri!("http://www.w3.org/ns/json-ld#compacted"),
406 Self::Context => iri!("http://www.w3.org/ns/json-ld#context"),
407 Self::Flattened => iri!("http://www.w3.org/ns/json-ld#flattened"),
408 Self::Framed => iri!("http://www.w3.org/ns/json-ld#framed"),
409 }
410 }
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
420pub enum Profile<I = IriBuf> {
421 Standard(StandardProfile),
423 Custom(I),
425}
426
427impl Profile {
428 #[must_use]
431 pub fn new(iri: Iri<&str>) -> Self {
432 match StandardProfile::from_iri(iri) {
433 Some(p) => Self::Standard(p),
434 None => Self::Custom(iri.into()),
435 }
436 }
437
438 #[must_use]
440 pub fn iri(&self) -> Iri<&str> {
441 match self {
442 Self::Standard(s) => s.iri(),
443 Self::Custom(c) => c.as_ref(),
444 }
445 }
446}
447
448impl<I> Profile<I> {
449 pub fn new_with(iri: Iri<&str>, vocabulary: &mut impl IriVocabularyMut<Iri = I>) -> Self {
451 match StandardProfile::from_iri(iri) {
452 Some(p) => Self::Standard(p),
453 None => Self::Custom(vocabulary.insert(iri)),
454 }
455 }
456
457 pub fn iri_with<'a>(&'a self, vocabulary: &'a impl IriVocabulary<Iri = I>) -> Option<Iri<&'a str>> {
460 match self {
461 Self::Standard(s) => Some(s.iri()),
462 Self::Custom(c) => vocabulary.iri(c),
463 }
464 }
465
466 pub fn map_iri<J>(self, f: impl FnOnce(I) -> J) -> Profile<J> {
468 match self {
469 Self::Standard(p) => Profile::Standard(p),
470 Self::Custom(i) => Profile::Custom(f(i)),
471 }
472 }
473}
474
475#[derive(Debug, thiserror::Error)]
478#[error("loading document `{target}` failed: {source}")]
479pub struct LoadError<E> {
481 pub target: IriBuf,
483 #[source]
484 pub source: E,
486}
487
488impl<E> LoadError<E> {
489 pub fn new(target: IriBuf, source: E) -> Self {
491 Self { target, source }
492 }
493
494 pub fn map_source<F, U>(self, f: F) -> LoadError<U>
496 where
497 F: FnOnce(E) -> U,
498 {
499 LoadError {
500 target: self.target,
501 source: f(self.source),
502 }
503 }
504}
505
506pub trait Loader {
526 type Error: std::error::Error + Send + Sync + 'static;
528
529 async fn load_with<V>(&self, vocabulary: &mut V, url: V::Iri) -> LoadingResult<V::Iri, Self::Error>
536 where
537 V: IriVocabularyMut,
538 V::Iri: Clone + Eq + Hash,
539 {
540 #[allow(clippy::expect_used)]
541 let lexical_url = vocabulary.iri(&url).expect("`url` does not resolve in the given vocabulary");
542 let document = self.load(lexical_url).await?;
543 Ok(document.map_iris(|i| vocabulary.insert_owned(i)))
544 }
545
546 async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>>;
548}
549
550impl<L: Loader> Loader for &L {
551 type Error = L::Error;
552
553 async fn load_with<V>(&self, vocabulary: &mut V, url: V::Iri) -> LoadingResult<V::Iri, Self::Error>
554 where
555 V: IriVocabularyMut,
556 V::Iri: Clone + Eq + Hash,
557 {
558 L::load_with(self, vocabulary, url).await
559 }
560
561 async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>> {
562 L::load(self, url).await
563 }
564}
565
566impl<L: Loader> Loader for &mut L {
567 type Error = L::Error;
568
569 async fn load_with<V>(&self, vocabulary: &mut V, url: V::Iri) -> LoadingResult<V::Iri, Self::Error>
570 where
571 V: IriVocabularyMut,
572 V::Iri: Clone + Eq + Hash,
573 {
574 L::load_with(self, vocabulary, url).await
575 }
576
577 async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>> {
578 L::load(self, url).await
579 }
580}
581
582#[derive(Debug, thiserror::Error)]
584pub enum ExtractContextError {
585 #[error("unexpected {0}")]
587 Unexpected(jstrict::Kind),
588
589 #[error("missing `@context` entry")]
591 NoContext,
592
593 #[error("duplicate `@context` entry")]
595 DuplicateContext,
596
597 #[error("JSON-LD context syntax error: {0}")]
599 Syntax(jsonld_syntax::context::InvalidContext),
600}
601
602impl ExtractContextError {
603 fn duplicate_context(jstrict::object::Duplicate(_, _): jstrict::object::Duplicate<jstrict::object::Entry>) -> Self {
604 Self::DuplicateContext
605 }
606}
607
608pub trait ExtractContext {
610 fn into_ld_context(self) -> Result<jsonld_syntax::context::Context, ExtractContextError>;
616}
617
618impl ExtractContext for jstrict::Value {
619 fn into_ld_context(self) -> Result<jsonld_syntax::context::Context, ExtractContextError> {
620 match self {
621 Self::Object(mut o) => match o.remove_unique("@context").map_err(ExtractContextError::duplicate_context)? {
622 Some(context) => {
623 use jsonld_syntax::TryFromJson;
624 jsonld_syntax::context::Context::try_from_json(&context.value).map_err(ExtractContextError::Syntax)
625 }
626 None => Err(ExtractContextError::NoContext),
627 },
628 other => Err(ExtractContextError::Unexpected(other.kind())),
629 }
630 }
631}
632
633#[cfg(all(test, feature = "serde-json"))]
634mod serde_json_tests {
635 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
636 use super::*;
637 use iri_rs::iri;
638
639 #[test]
640 fn from_serde_json_preserves_url_and_content_type() {
641 let url = IriBuf::from(iri!("https://example.com/sample.jsonld"));
642 let mime: MediaTypeBuf = LD_JSON_MEDIA_TYPE.into();
643 let value = serde_json::json!({"foo": "bar"});
644
645 let doc = RemoteDocument::from_serde_json(Some(url.clone()), Some(mime.clone()), value);
646
647 assert_eq!(doc.url(), Some(&url));
648 assert_eq!(doc.content_type(), Some(&mime));
649 assert!(doc.context_url().is_none());
650 assert!(doc.profile.is_empty());
651 match doc.document() {
652 jstrict::Value::Object(o) => {
653 assert_eq!(o.get("foo").next().unwrap().as_str(), Some("bar"));
654 }
655 other => panic!("expected object, got {:?}", other.kind()),
656 }
657 }
658
659 #[test]
660 fn from_serde_json_handles_all_value_kinds() {
661 let value = serde_json::json!({
662 "null": null,
663 "bool": true,
664 "num": 1.5,
665 "str": "x",
666 "arr": [1, 2],
667 "obj": {"k": "v"}
668 });
669 let doc = RemoteDocument::<IriBuf, _>::from_serde_json(None, None, value);
670 let jstrict::Value::Object(obj) = doc.document() else {
671 panic!("expected object")
672 };
673 assert!(matches!(obj.get("null").next().unwrap(), jstrict::Value::Null));
674 assert!(matches!(obj.get("bool").next().unwrap(), jstrict::Value::Boolean(true)));
675 assert!(matches!(obj.get("num").next().unwrap(), jstrict::Value::Number(_)));
676 assert!(matches!(obj.get("str").next().unwrap(), jstrict::Value::String(_)));
677 assert!(matches!(obj.get("arr").next().unwrap(), jstrict::Value::Array(_)));
678 assert!(matches!(obj.get("obj").next().unwrap(), jstrict::Value::Object(_)));
679 }
680
681 #[test]
682 fn into_serde_json_round_trip() {
683 use jstrict::Parse;
684 let original = jstrict::Value::parse_str(r#"{"a": [1, 2, 3], "b": "x"}"#).unwrap().0;
685 let doc: RemoteDocument<IriBuf, _> = RemoteDocument::new(None, None, original.clone());
686 let serde_doc = doc.into_serde_json();
687 let round_tripped = jstrict::Value::from_serde_json(serde_doc.document.clone());
688 assert_eq!(round_tripped, original);
689 }
690
691 #[test]
692 fn from_value_accepts_jstrict_value() {
693 use jstrict::Parse;
694 let value = jstrict::Value::parse_str(r#"{"k": 1}"#).unwrap().0;
695 let doc: RemoteDocument<IriBuf, _> = RemoteDocument::from_value(None, None, value);
696 match doc.document() {
697 jstrict::Value::Object(_) => {}
698 _ => panic!("expected object"),
699 }
700 }
701
702 #[test]
703 fn from_value_accepts_serde_json_value() {
704 let v = serde_json::json!({"k": 1});
705 let via_value: RemoteDocument<IriBuf, _> = RemoteDocument::from_value(None, None, v.clone());
706 let via_serde: RemoteDocument<IriBuf, _> = RemoteDocument::from_serde_json(None, None, v);
707 assert!(jsonld_syntax::Compare::compare(via_value.document(), via_serde.document()));
708 }
709}
710
711#[cfg(all(test, feature = "sonic-rs"))]
712mod sonic_rs_tests {
713 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
714 use super::*;
715 use iri_rs::iri;
716
717 #[test]
718 fn from_sonic_rs_preserves_url_and_content_type() {
719 let url = IriBuf::from(iri!("https://example.com/sample.jsonld"));
720 let mime: MediaTypeBuf = LD_JSON_MEDIA_TYPE.into();
721 let value: sonic_rs::Value = sonic_rs::json!({"foo": "bar"});
722
723 let doc = RemoteDocument::from_sonic_rs(Some(url.clone()), Some(mime.clone()), value);
724
725 assert_eq!(doc.url(), Some(&url));
726 assert_eq!(doc.content_type(), Some(&mime));
727 match doc.document() {
728 jstrict::Value::Object(o) => {
729 assert_eq!(o.get("foo").next().unwrap().as_str(), Some("bar"));
730 }
731 other => panic!("expected object, got {:?}", other.kind()),
732 }
733 }
734
735 #[test]
736 fn from_sonic_rs_handles_all_value_kinds() {
737 let value: sonic_rs::Value = sonic_rs::json!({
738 "null": null,
739 "bool": true,
740 "num": 1.5,
741 "str": "x",
742 "arr": [1, 2],
743 "obj": {"k": "v"}
744 });
745 let doc = RemoteDocument::<IriBuf, _>::from_sonic_rs(None, None, value);
746 let jstrict::Value::Object(obj) = doc.document() else {
747 panic!("expected object")
748 };
749 assert!(matches!(obj.get("null").next().unwrap(), jstrict::Value::Null));
750 assert!(matches!(obj.get("bool").next().unwrap(), jstrict::Value::Boolean(true)));
751 assert!(matches!(obj.get("num").next().unwrap(), jstrict::Value::Number(_)));
752 assert!(matches!(obj.get("str").next().unwrap(), jstrict::Value::String(_)));
753 assert!(matches!(obj.get("arr").next().unwrap(), jstrict::Value::Array(_)));
754 assert!(matches!(obj.get("obj").next().unwrap(), jstrict::Value::Object(_)));
755 }
756
757 #[test]
758 fn into_sonic_rs_round_trip() {
759 use jstrict::Parse;
760 let original = jstrict::Value::parse_str(r#"{"a": [1, 2, 3]}"#).unwrap().0;
763 let doc: RemoteDocument<IriBuf, _> = RemoteDocument::new(None, None, original.clone());
764 let sonic_doc = doc.into_sonic_rs();
765 let round_tripped = jstrict::Value::from_sonic_rs(sonic_doc.document.clone());
766 assert_eq!(round_tripped, original);
767 }
768}