Skip to main content

jsonld_core/loader/
mod.rs

1use crate::HashSet;
2use iri_rs::{Iri, IriBuf, iri};
3use mediatype::{
4    MediaType,
5    MediaTypeBuf,
6    names::{APPLICATION, JSON, LD},
7};
8
9/// `application/ld+json` media type.
10pub const LD_JSON_MEDIA_TYPE: MediaType<'static> = MediaType::from_parts(APPLICATION, LD, Some(JSON), &[]);
11
12/// `application/json` media type.
13pub const JSON_MEDIA_TYPE: MediaType<'static> = MediaType::new(APPLICATION, JSON);
14use rdfx::vocabulary::{IriVocabulary, IriVocabularyMut};
15use std::{borrow::Cow, hash::Hash};
16
17/// Loader combining two loaders, trying each in turn.
18pub mod chain;
19/// Invoking a loader from inside an algorithm, on sufficient native stack.
20pub mod dereference;
21/// Loader reading documents from the file system.
22pub mod fs;
23/// Loader serving documents from an in-memory map.
24pub mod map;
25/// Loader that refuses every request.
26pub 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
39/// Result of loading a remote document.
40pub type LoadingResult<I, E> = Result<RemoteDocument<I>, LoadError<E>>;
41
42/// Reference to a remote context, either inline or by URL.
43pub type RemoteContextReference<I = IriBuf> = RemoteDocumentReference<I, jsonld_syntax::Context>;
44
45/// Remote document, loaded or not.
46///
47/// Either an IRI or the actual document content.
48#[derive(Debug, Clone)]
49pub enum RemoteDocumentReference<I = IriBuf, T = jstrict::Value> {
50    /// IRI to the remote document.
51    Iri(I),
52
53    /// Remote document content.
54    Loaded(RemoteDocument<I, T>),
55}
56
57impl<I, T> RemoteDocumentReference<I, T> {
58    /// Creates an IRI to a `jstrict::Value` JSON document.
59    ///
60    /// This method can replace `RemoteDocumentReference::Iri` to help the type
61    /// inference in the case where `T = jstrict::Value`.
62    pub fn iri(iri: I) -> Self {
63        Self::Iri(iri)
64    }
65}
66
67impl<I> RemoteDocumentReference<I> {
68    /// Loads the remote document with the given `vocabulary` and `loader`.
69    ///
70    /// If the document is already [`Self::Loaded`], simply returns the inner
71    /// [`RemoteDocument`].
72    ///
73    /// # Errors
74    ///
75    /// Returns the loader's error when the document cannot be fetched or parsed.
76    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    /// Loads the remote document with the given `vocabulary` and `loader`.
89    ///
90    /// For [`Self::Iri`] returns an owned [`RemoteDocument`] with
91    /// [`Cow::Owned`].
92    /// For [`Self::Loaded`] returns a reference to the inner [`RemoteDocument`]
93    /// with [`Cow::Borrowed`].
94    ///
95    /// # Errors
96    ///
97    /// Returns the loader's error when the document cannot be fetched or parsed.
98    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)]
112/// Error raised while loading a remote context.
113pub enum ContextLoadError<E> {
114    #[error(transparent)]
115    /// The document carrying the context could not be loaded.
116    LoadingDocumentFailed(#[from] LoadError<E>),
117
118    #[error("context extraction failed")]
119    /// Context extraction failed.
120    ContextExtractionFailed(#[from] ExtractContextError),
121}
122
123impl<I> RemoteContextReference<I> {
124    /// Loads the remote context with the given `vocabulary` and `loader`.
125    ///
126    /// If the context is already [`Self::Loaded`], simply returns the inner
127    /// [`RemoteContext`].
128    ///
129    /// # Errors
130    ///
131    /// Returns an error when the document cannot be fetched, or when it holds no `@context` entry.
132    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    /// Loads the remote context with the given `vocabulary` and `loader`.
144    ///
145    /// For [`Self::Iri`] returns an owned [`RemoteContext`] with
146    /// [`Cow::Owned`].
147    /// For [`Self::Loaded`] returns a reference to the inner [`RemoteContext`]
148    /// with [`Cow::Borrowed`].
149    ///
150    /// # Errors
151    ///
152    /// Returns an error when the document cannot be fetched, or when it holds no `@context` entry.
153    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/// Remote document.
168///
169/// Stores the content of a loaded remote document along with its original URL.
170#[derive(Debug, Clone)]
171pub struct RemoteDocument<I = IriBuf, T = jstrict::Value> {
172    /// The final URL of the loaded document, after eventual redirection.
173    pub url: Option<I>,
174
175    /// The HTTP `Content-Type` header value of the loaded document, exclusive
176    /// of any optional parameters.
177    pub content_type: Option<MediaTypeBuf>,
178
179    /// If available, the value of the HTTP `Link Header` [RFC 8288] using the
180    /// `http://www.w3.org/ns/json-ld#context` link relation in the response.
181    ///
182    /// If the response's `Content-Type` is `application/ld+json`, the HTTP
183    /// `Link Header` is ignored. If multiple HTTP `Link Headers` using the
184    /// `http://www.w3.org/ns/json-ld#context` link relation are found, the
185    /// loader fails with a `multiple context link headers` error.
186    ///
187    /// [RFC 8288]: https://www.rfc-editor.org/rfc/rfc8288
188    pub context_url: Option<I>,
189
190    /// Profiles advertised by the document's media type.
191    pub profile: HashSet<Profile<I>>,
192
193    /// The retrieved document.
194    pub document: T,
195}
196
197/// Remote document holding a context.
198pub type RemoteContext<I = IriBuf> = RemoteDocument<I, jsonld_syntax::context::Context>;
199
200impl<I, T> RemoteDocument<I, T> {
201    /// Creates a new remote document.
202    ///
203    /// `url` is the final URL of the loaded document, after eventual
204    /// redirection.
205    /// `content_type` is the HTTP `Content-Type` header value of the loaded
206    /// document, exclusive of any optional parameters.
207    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    /// Creates a new remote document.
212    ///
213    /// `url` is the final URL of the loaded document, after eventual
214    /// redirection.
215    /// `content_type` is the HTTP `Content-Type` header value of the loaded
216    /// document, exclusive of any optional parameters.
217    /// `context_url` is the value of the HTTP `Link Header` [RFC 8288] using the
218    /// `http://www.w3.org/ns/json-ld#context` link relation in the response,
219    /// if any.
220    /// `profile` is the value of any profile parameter retrieved as part of the
221    /// original contentType.
222    ///
223    /// [RFC 8288]: https://www.rfc-editor.org/rfc/rfc8288
224    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    /// Maps the content of the remote document.
235    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    /// Tries to map the content of the remote document.
246    ///
247    /// # Errors
248    ///
249    /// Returns whatever `f` returns for the contained document.
250    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    /// Maps all the IRIs.
261    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    /// Returns a reference to the final URL of the loaded document, after eventual redirection.
275    pub fn url(&self) -> Option<&I> {
276        self.url.as_ref()
277    }
278
279    /// Returns the HTTP `Content-Type` header value of the loaded document,
280    /// exclusive of any optional parameters.
281    pub fn content_type(&self) -> Option<&MediaTypeBuf> {
282        self.content_type.as_ref()
283    }
284
285    /// Returns the value of the HTTP `Link Header` [RFC 8288] using the
286    /// `http://www.w3.org/ns/json-ld#context` link relation in the response,
287    /// if any.
288    ///
289    /// If the response's `Content-Type` is `application/ld+json`, the HTTP
290    /// `Link Header` is ignored. If multiple HTTP `Link Headers` using the
291    /// `http://www.w3.org/ns/json-ld#context` link relation are found, the
292    /// loader fails with a `multiple context link headers` error.
293    ///
294    /// [RFC 8288]: https://www.rfc-editor.org/rfc/rfc8288
295    pub fn context_url(&self) -> Option<&I> {
296        self.context_url.as_ref()
297    }
298
299    /// Returns a reference to the content of the document.
300    pub fn document(&self) -> &T {
301        &self.document
302    }
303
304    /// Returns a mutable reference to the content of the document.
305    pub fn document_mut(&mut self) -> &mut T {
306        &mut self.document
307    }
308
309    /// Drops the original URL and returns the content of the document.
310    pub fn into_document(self) -> T {
311        self.document
312    }
313
314    /// Drops the content and returns the original URL of the document.
315    pub fn into_url(self) -> Option<I> {
316        self.url
317    }
318
319    /// Sets the URL of the document.
320    pub fn set_url(&mut self, url: Option<I>) {
321        self.url = url;
322    }
323}
324
325impl<I> RemoteDocument<I, jstrict::Value> {
326    /// Creates a remote document from any value convertible into a
327    /// [`jstrict::Value`].
328    ///
329    /// With the `serde_json` feature enabled, this also accepts
330    /// `serde_json::Value` thanks to the `From<serde_json::Value>`
331    /// implementation provided by `jstrict`.
332    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    /// Creates a remote document from a [`serde_json::Value`].
340    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    /// Consumes the document, returning a `RemoteDocument<I, serde_json::Value>`.
345    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    /// Creates a remote document from a [`sonic_rs::Value`].
353    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    /// Consumes the document, returning a `RemoteDocument<I, sonic_rs::Value>`.
358    pub fn into_sonic_rs(self) -> RemoteDocument<I, sonic_rs::Value> {
359        self.map(jstrict::Value::into_sonic_rs)
360    }
361}
362
363/// Standard `profile` parameter values defined for the `application/ld+json`.
364///
365/// See: <https://www.w3.org/TR/json-ld11/#iana-considerations>
366#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
367pub enum StandardProfile {
368    /// To request or specify expanded JSON-LD document form.
369    Expanded,
370
371    /// To request or specify compacted JSON-LD document form.
372    Compacted,
373
374    /// To request or specify a JSON-LD context document.
375    Context,
376
377    /// To request or specify flattened JSON-LD document form.
378    Flattened,
379
380    /// To request or specify a JSON-LD framed document.
381    Framed,
382}
383
384impl StandardProfile {
385    /// Returns the standard profile denoted by `iri`, if any.
386    #[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    /// Returns the IRI that identifies this profile.
404    #[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/// Value of the `profile` parameter of the `application/ld+json` media type.
417///
418/// The values the JSON-LD specification itself registers are enumerated by
419/// [`StandardProfile`]; any other IRI is carried as [`Profile::Custom`].
420///
421/// See: <https://www.w3.org/TR/json-ld11/#iana-considerations>
422#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
423pub enum Profile<I = IriBuf> {
424    /// A profile registered by the JSON-LD specification.
425    Standard(StandardProfile),
426    /// A profile outside the ones the specification registers.
427    Custom(I),
428}
429
430impl Profile {
431    /// Builds a profile from an IRI, recognising the ones the specification
432    /// registers.
433    #[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    /// Returns the IRI that identifies this profile.
442    #[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    /// Builds a profile from an IRI, interning it in the given vocabulary.
453    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    /// Returns the [`Iri`] of this profile, if it can be resolved by the given
461    /// `vocabulary`.
462    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    /// Rewrites the IRI of a custom profile.
470    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/// Loading error: wraps the loader-specific error with the IRI we tried to
479/// load.
480#[derive(Debug, thiserror::Error)]
481#[error("loading document `{target}` failed: {source}")]
482/// Error raised while loading a remote document, with the URL that failed.
483pub struct LoadError<E> {
484    /// URL whose loading failed.
485    pub target: IriBuf,
486    #[source]
487    /// Underlying loader error.
488    pub source: E,
489}
490
491impl<E> LoadError<E> {
492    /// Creates a new `LoadError`.
493    pub fn new(target: IriBuf, source: E) -> Self {
494        Self { target, source }
495    }
496
497    /// Rewrites the underlying error, keeping the URL that failed.
498    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
509/// Document loader.
510///
511/// A document loader is required by most processing functions to fetch remote
512/// documents identified by an IRI. In particular, the loader is in charge of
513/// fetching all the remote contexts imported in a `@context` entry.
514///
515/// This library provides a few default loader implementations:
516///   - [`NoLoader`] dummy loader that always fail. Perfect if you are certain
517///     that the processing will not require any loading.
518///   - Standard [`HashMap`](std::collections::HashMap) and
519///     [`BTreeMap`](std::collections::BTreeMap) mapping IRIs to pre-loaded
520///     documents. This way no network calls are performed and the loaded
521///     content can be trusted.
522///   - [`FsLoader`] that redirecting registered IRI prefixes to a local
523///     directory on the file system. This also avoids network calls. The loaded
524///     content can be trusted as long as the file system is trusted.
525///   - `ReqwestLoader` actually downloading the remote documents using the
526///     [`reqwest`](https://crates.io/crates/reqwest) library.
527///     This requires the `reqwest` feature to be enabled.
528///
529/// A loader is `Sync` and its [`Loader::load`] future is `Send`, so that the
530/// algorithms, which hold the loader by reference across `.await`s, can run
531/// on a multi-threaded executor.
532pub trait Loader: Sync {
533    /// Loader-specific error type.
534    type Error: std::error::Error + Send + Sync + 'static;
535
536    /// Loads the document behind the given IRI.
537    ///
538    /// The future is `Send`, so that an `async fn` generic over its loader,
539    /// and the algorithms that invoke it, are `Send` whenever their other
540    /// inputs are. An implementation written as an `async fn` meets this when
541    /// what it holds across an `.await` is `Send`.
542    fn load(&self, url: Iri<&str>) -> impl Future<Output = Result<RemoteDocument<IriBuf>, LoadError<Self::Error>>> + Send;
543}
544
545impl<L: Loader> Loader for &L {
546    type Error = L::Error;
547
548    #[expect(
549        clippy::disallowed_methods,
550        reason = "a loader delegating to the loader it wraps already runs inside its caller's guarded poll"
551    )]
552    async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>> {
553        L::load(self, url).await
554    }
555}
556
557impl<L: Loader> Loader for &mut 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(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>> {
565        L::load(self, url).await
566    }
567}
568
569/// Context extraction error.
570#[derive(Debug, thiserror::Error)]
571pub enum ExtractContextError {
572    /// Unexpected JSON value.
573    #[error("unexpected {0}")]
574    Unexpected(jstrict::Kind),
575
576    /// No context definition found.
577    #[error("missing `@context` entry")]
578    NoContext,
579
580    /// Multiple context definitions found.
581    #[error("duplicate `@context` entry")]
582    DuplicateContext,
583
584    /// JSON syntax error.
585    #[error("JSON-LD context syntax error: {0}")]
586    Syntax(jsonld_syntax::context::InvalidContext),
587}
588
589impl ExtractContextError {
590    fn duplicate_context(jstrict::object::Duplicate(_, _): jstrict::object::Duplicate<jstrict::object::Entry>) -> Self {
591        Self::DuplicateContext
592    }
593}
594
595/// Documents from which a JSON-LD context can be extracted.
596pub trait ExtractContext {
597    /// Consumes this `ExtractContext`, returning its LD context.
598    ///
599    /// # Errors
600    ///
601    /// Returns an error when the document is not an object, or has no `@context` entry.
602    fn into_ld_context(self) -> Result<jsonld_syntax::context::Context, ExtractContextError>;
603}
604
605impl ExtractContext for jstrict::Value {
606    fn into_ld_context(self) -> Result<jsonld_syntax::context::Context, ExtractContextError> {
607        match self {
608            Self::Object(mut o) => match o.remove_unique("@context").map_err(ExtractContextError::duplicate_context)? {
609                Some(context) => {
610                    use jsonld_syntax::TryFromJson;
611                    jsonld_syntax::context::Context::try_from_json(&context.value).map_err(ExtractContextError::Syntax)
612                }
613                None => Err(ExtractContextError::NoContext),
614            },
615            other => Err(ExtractContextError::Unexpected(other.kind())),
616        }
617    }
618}
619
620#[cfg(all(test, feature = "serde-json"))]
621mod serde_json_tests {
622    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
623    use super::*;
624    use iri_rs::iri;
625
626    #[test]
627    fn from_serde_json_preserves_url_and_content_type() {
628        let url = IriBuf::from(iri!("https://example.com/sample.jsonld"));
629        let mime: MediaTypeBuf = LD_JSON_MEDIA_TYPE.into();
630        let value = serde_json::json!({"foo": "bar"});
631
632        let doc = RemoteDocument::from_serde_json(Some(url.clone()), Some(mime.clone()), value);
633
634        assert_eq!(doc.url(), Some(&url));
635        assert_eq!(doc.content_type(), Some(&mime));
636        assert!(doc.context_url().is_none());
637        assert!(doc.profile.is_empty());
638        match doc.document() {
639            jstrict::Value::Object(o) => {
640                assert_eq!(o.get("foo").next().unwrap().as_str(), Some("bar"));
641            }
642            other => panic!("expected object, got {:?}", other.kind()),
643        }
644    }
645
646    #[test]
647    fn from_serde_json_handles_all_value_kinds() {
648        let value = serde_json::json!({
649            "null": null,
650            "bool": true,
651            "num": 1.5,
652            "str": "x",
653            "arr": [1, 2],
654            "obj": {"k": "v"}
655        });
656        let doc = RemoteDocument::<IriBuf, _>::from_serde_json(None, None, value);
657        let jstrict::Value::Object(obj) = doc.document() else {
658            panic!("expected object")
659        };
660        assert!(matches!(obj.get("null").next().unwrap(), jstrict::Value::Null));
661        assert!(matches!(obj.get("bool").next().unwrap(), jstrict::Value::Boolean(true)));
662        assert!(matches!(obj.get("num").next().unwrap(), jstrict::Value::Number(_)));
663        assert!(matches!(obj.get("str").next().unwrap(), jstrict::Value::String(_)));
664        assert!(matches!(obj.get("arr").next().unwrap(), jstrict::Value::Array(_)));
665        assert!(matches!(obj.get("obj").next().unwrap(), jstrict::Value::Object(_)));
666    }
667
668    #[test]
669    fn into_serde_json_round_trip() {
670        use jstrict::Parse;
671        let original = jstrict::Value::parse_str(r#"{"a": [1, 2, 3], "b": "x"}"#).unwrap().0;
672        let doc: RemoteDocument<IriBuf, _> = RemoteDocument::new(None, None, original.clone());
673        let serde_doc = doc.into_serde_json();
674        let round_tripped = jstrict::Value::from_serde_json(serde_doc.document.clone());
675        assert_eq!(round_tripped, original);
676    }
677
678    #[test]
679    fn from_value_accepts_jstrict_value() {
680        use jstrict::Parse;
681        let value = jstrict::Value::parse_str(r#"{"k": 1}"#).unwrap().0;
682        let doc: RemoteDocument<IriBuf, _> = RemoteDocument::from_value(None, None, value);
683        match doc.document() {
684            jstrict::Value::Object(_) => {}
685            _ => panic!("expected object"),
686        }
687    }
688
689    #[test]
690    fn from_value_accepts_serde_json_value() {
691        let v = serde_json::json!({"k": 1});
692        let via_value: RemoteDocument<IriBuf, _> = RemoteDocument::from_value(None, None, v.clone());
693        let via_serde: RemoteDocument<IriBuf, _> = RemoteDocument::from_serde_json(None, None, v);
694        assert!(jsonld_syntax::Compare::compare(via_value.document(), via_serde.document()));
695    }
696}
697
698#[cfg(all(test, feature = "sonic-rs"))]
699mod sonic_rs_tests {
700    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
701    use super::*;
702    use iri_rs::iri;
703
704    #[test]
705    fn from_sonic_rs_preserves_url_and_content_type() {
706        let url = IriBuf::from(iri!("https://example.com/sample.jsonld"));
707        let mime: MediaTypeBuf = LD_JSON_MEDIA_TYPE.into();
708        let value: sonic_rs::Value = sonic_rs::json!({"foo": "bar"});
709
710        let doc = RemoteDocument::from_sonic_rs(Some(url.clone()), Some(mime.clone()), &value);
711
712        assert_eq!(doc.url(), Some(&url));
713        assert_eq!(doc.content_type(), Some(&mime));
714        match doc.document() {
715            jstrict::Value::Object(o) => {
716                assert_eq!(o.get("foo").next().unwrap().as_str(), Some("bar"));
717            }
718            other => panic!("expected object, got {:?}", other.kind()),
719        }
720    }
721
722    #[test]
723    fn from_sonic_rs_handles_all_value_kinds() {
724        let value: sonic_rs::Value = sonic_rs::json!({
725            "null": null,
726            "bool": true,
727            "num": 1.5,
728            "str": "x",
729            "arr": [1, 2],
730            "obj": {"k": "v"}
731        });
732        let doc = RemoteDocument::<IriBuf, _>::from_sonic_rs(None, None, &value);
733        let jstrict::Value::Object(obj) = doc.document() else {
734            panic!("expected object")
735        };
736        assert!(matches!(obj.get("null").next().unwrap(), jstrict::Value::Null));
737        assert!(matches!(obj.get("bool").next().unwrap(), jstrict::Value::Boolean(true)));
738        assert!(matches!(obj.get("num").next().unwrap(), jstrict::Value::Number(_)));
739        assert!(matches!(obj.get("str").next().unwrap(), jstrict::Value::String(_)));
740        assert!(matches!(obj.get("arr").next().unwrap(), jstrict::Value::Array(_)));
741        assert!(matches!(obj.get("obj").next().unwrap(), jstrict::Value::Object(_)));
742    }
743
744    #[test]
745    fn into_sonic_rs_round_trip() {
746        use jstrict::Parse;
747        // A single key: `sonic_rs::Object` does not preserve entry order, so a
748        // multi-key document would not compare equal after the round trip.
749        let original = jstrict::Value::parse_str(r#"{"a": [1, 2, 3]}"#).unwrap().0;
750        let doc: RemoteDocument<IriBuf, _> = RemoteDocument::new(None, None, original.clone());
751        let sonic_doc = doc.into_sonic_rs();
752        let round_tripped = jstrict::Value::from_sonic_rs(&sonic_doc.document);
753        assert_eq!(round_tripped, original);
754    }
755}