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/// Loader reading documents from the file system.
20pub mod fs;
21/// Loader serving documents from an in-memory map.
22pub mod map;
23/// Loader that refuses every request.
24pub 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
36/// Result of loading a remote document.
37pub type LoadingResult<I, E> = Result<RemoteDocument<I>, LoadError<E>>;
38
39/// Reference to a remote context, either inline or by URL.
40pub type RemoteContextReference<I = IriBuf> = RemoteDocumentReference<I, jsonld_syntax::Context>;
41
42/// Remote document, loaded or not.
43///
44/// Either an IRI or the actual document content.
45#[derive(Debug, Clone)]
46pub enum RemoteDocumentReference<I = IriBuf, T = jstrict::Value> {
47    /// IRI to the remote document.
48    Iri(I),
49
50    /// Remote document content.
51    Loaded(RemoteDocument<I, T>),
52}
53
54impl<I, T> RemoteDocumentReference<I, T> {
55    /// Creates an IRI to a `jstrict::Value` JSON document.
56    ///
57    /// This method can replace `RemoteDocumentReference::Iri` to help the type
58    /// inference in the case where `T = jstrict::Value`.
59    pub fn iri(iri: I) -> Self {
60        Self::Iri(iri)
61    }
62}
63
64impl<I> RemoteDocumentReference<I> {
65    /// Loads the remote document with the given `vocabulary` and `loader`.
66    ///
67    /// If the document is already [`Self::Loaded`], simply returns the inner
68    /// [`RemoteDocument`].
69    ///
70    /// # Errors
71    ///
72    /// Returns the loader's error when the document cannot be fetched or parsed.
73    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    /// Loads the remote document with the given `vocabulary` and `loader`.
86    ///
87    /// For [`Self::Iri`] returns an owned [`RemoteDocument`] with
88    /// [`Cow::Owned`].
89    /// For [`Self::Loaded`] returns a reference to the inner [`RemoteDocument`]
90    /// with [`Cow::Borrowed`].
91    ///
92    /// # Errors
93    ///
94    /// Returns the loader's error when the document cannot be fetched or parsed.
95    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)]
109/// Error raised while loading a remote context.
110pub enum ContextLoadError<E> {
111    #[error(transparent)]
112    /// The document carrying the context could not be loaded.
113    LoadingDocumentFailed(#[from] LoadError<E>),
114
115    #[error("context extraction failed")]
116    /// Context extraction failed.
117    ContextExtractionFailed(#[from] ExtractContextError),
118}
119
120impl<I> RemoteContextReference<I> {
121    /// Loads the remote context with the given `vocabulary` and `loader`.
122    ///
123    /// If the context is already [`Self::Loaded`], simply returns the inner
124    /// [`RemoteContext`].
125    ///
126    /// # Errors
127    ///
128    /// Returns an error when the document cannot be fetched, or when it holds no `@context` entry.
129    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    /// Loads the remote context with the given `vocabulary` and `loader`.
141    ///
142    /// For [`Self::Iri`] returns an owned [`RemoteContext`] with
143    /// [`Cow::Owned`].
144    /// For [`Self::Loaded`] returns a reference to the inner [`RemoteContext`]
145    /// with [`Cow::Borrowed`].
146    ///
147    /// # Errors
148    ///
149    /// Returns an error when the document cannot be fetched, or when it holds no `@context` entry.
150    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/// Remote document.
165///
166/// Stores the content of a loaded remote document along with its original URL.
167#[derive(Debug, Clone)]
168pub struct RemoteDocument<I = IriBuf, T = jstrict::Value> {
169    /// The final URL of the loaded document, after eventual redirection.
170    pub url: Option<I>,
171
172    /// The HTTP `Content-Type` header value of the loaded document, exclusive
173    /// of any optional parameters.
174    pub content_type: Option<MediaTypeBuf>,
175
176    /// If available, the value of the HTTP `Link Header` [RFC 8288] using the
177    /// `http://www.w3.org/ns/json-ld#context` link relation in the response.
178    ///
179    /// If the response's `Content-Type` is `application/ld+json`, the HTTP
180    /// `Link Header` is ignored. If multiple HTTP `Link Headers` using the
181    /// `http://www.w3.org/ns/json-ld#context` link relation are found, the
182    /// loader fails with a `multiple context link headers` error.
183    ///
184    /// [RFC 8288]: https://www.rfc-editor.org/rfc/rfc8288
185    pub context_url: Option<I>,
186
187    /// Profiles advertised by the document's media type.
188    pub profile: HashSet<Profile<I>>,
189
190    /// The retrieved document.
191    pub document: T,
192}
193
194/// Remote document holding a context.
195pub type RemoteContext<I = IriBuf> = RemoteDocument<I, jsonld_syntax::context::Context>;
196
197impl<I, T> RemoteDocument<I, T> {
198    /// Creates a new remote document.
199    ///
200    /// `url` is the final URL of the loaded document, after eventual
201    /// redirection.
202    /// `content_type` is the HTTP `Content-Type` header value of the loaded
203    /// document, exclusive of any optional parameters.
204    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    /// Creates a new remote document.
209    ///
210    /// `url` is the final URL of the loaded document, after eventual
211    /// redirection.
212    /// `content_type` is the HTTP `Content-Type` header value of the loaded
213    /// document, exclusive of any optional parameters.
214    /// `context_url` is the value of the HTTP `Link Header` [RFC 8288] using the
215    /// `http://www.w3.org/ns/json-ld#context` link relation in the response,
216    /// if any.
217    /// `profile` is the value of any profile parameter retrieved as part of the
218    /// original contentType.
219    ///
220    /// [RFC 8288]: https://www.rfc-editor.org/rfc/rfc8288
221    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    /// Maps the content of the remote document.
232    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    /// Tries to map the content of the remote document.
243    ///
244    /// # Errors
245    ///
246    /// Returns whatever `f` returns for the contained document.
247    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    /// Maps all the IRIs.
258    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    /// Returns a reference to the final URL of the loaded document, after eventual redirection.
272    pub fn url(&self) -> Option<&I> {
273        self.url.as_ref()
274    }
275
276    /// Returns the HTTP `Content-Type` header value of the loaded document,
277    /// exclusive of any optional parameters.
278    pub fn content_type(&self) -> Option<&MediaTypeBuf> {
279        self.content_type.as_ref()
280    }
281
282    /// Returns the value of the HTTP `Link Header` [RFC 8288] using the
283    /// `http://www.w3.org/ns/json-ld#context` link relation in the response,
284    /// if any.
285    ///
286    /// If the response's `Content-Type` is `application/ld+json`, the HTTP
287    /// `Link Header` is ignored. If multiple HTTP `Link Headers` using the
288    /// `http://www.w3.org/ns/json-ld#context` link relation are found, the
289    /// loader fails with a `multiple context link headers` error.
290    ///
291    /// [RFC 8288]: https://www.rfc-editor.org/rfc/rfc8288
292    pub fn context_url(&self) -> Option<&I> {
293        self.context_url.as_ref()
294    }
295
296    /// Returns a reference to the content of the document.
297    pub fn document(&self) -> &T {
298        &self.document
299    }
300
301    /// Returns a mutable reference to the content of the document.
302    pub fn document_mut(&mut self) -> &mut T {
303        &mut self.document
304    }
305
306    /// Drops the original URL and returns the content of the document.
307    pub fn into_document(self) -> T {
308        self.document
309    }
310
311    /// Drops the content and returns the original URL of the document.
312    pub fn into_url(self) -> Option<I> {
313        self.url
314    }
315
316    /// Sets the URL of the document.
317    pub fn set_url(&mut self, url: Option<I>) {
318        self.url = url;
319    }
320}
321
322impl<I> RemoteDocument<I, jstrict::Value> {
323    /// Creates a remote document from any value convertible into a
324    /// [`jstrict::Value`].
325    ///
326    /// With the `serde_json` feature enabled, this also accepts
327    /// `serde_json::Value` thanks to the `From<serde_json::Value>`
328    /// implementation provided by `jstrict`.
329    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    /// Creates a remote document from a [`serde_json::Value`].
337    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    /// Consumes the document, returning a `RemoteDocument<I, serde_json::Value>`.
342    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    /// Creates a remote document from a [`sonic_rs::Value`].
350    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    /// Consumes the document, returning a `RemoteDocument<I, sonic_rs::Value>`.
355    pub fn into_sonic_rs(self) -> RemoteDocument<I, sonic_rs::Value> {
356        self.map(jstrict::Value::into_sonic_rs)
357    }
358}
359
360/// Standard `profile` parameter values defined for the `application/ld+json`.
361///
362/// See: <https://www.w3.org/TR/json-ld11/#iana-considerations>
363#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
364pub enum StandardProfile {
365    /// To request or specify expanded JSON-LD document form.
366    Expanded,
367
368    /// To request or specify compacted JSON-LD document form.
369    Compacted,
370
371    /// To request or specify a JSON-LD context document.
372    Context,
373
374    /// To request or specify flattened JSON-LD document form.
375    Flattened,
376
377    /// To request or specify a JSON-LD framed document.
378    Framed,
379}
380
381impl StandardProfile {
382    /// Returns the standard profile denoted by `iri`, if any.
383    #[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    /// Returns the IRI that identifies this profile.
401    #[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/// Value of the `profile` parameter of the `application/ld+json` media type.
414///
415/// The values the JSON-LD specification itself registers are enumerated by
416/// [`StandardProfile`]; any other IRI is carried as [`Profile::Custom`].
417///
418/// See: <https://www.w3.org/TR/json-ld11/#iana-considerations>
419#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
420pub enum Profile<I = IriBuf> {
421    /// A profile registered by the JSON-LD specification.
422    Standard(StandardProfile),
423    /// A profile outside the ones the specification registers.
424    Custom(I),
425}
426
427impl Profile {
428    /// Builds a profile from an IRI, recognising the ones the specification
429    /// registers.
430    #[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    /// Returns the IRI that identifies this profile.
439    #[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    /// Builds a profile from an IRI, interning it in the given vocabulary.
450    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    /// Returns the [`Iri`] of this profile, if it can be resolved by the given
458    /// `vocabulary`.
459    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    /// Rewrites the IRI of a custom profile.
467    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/// Loading error: wraps the loader-specific error with the IRI we tried to
476/// load.
477#[derive(Debug, thiserror::Error)]
478#[error("loading document `{target}` failed: {source}")]
479/// Error raised while loading a remote document, with the URL that failed.
480pub struct LoadError<E> {
481    /// URL whose loading failed.
482    pub target: IriBuf,
483    #[source]
484    /// Underlying loader error.
485    pub source: E,
486}
487
488impl<E> LoadError<E> {
489    /// Creates a new `LoadError`.
490    pub fn new(target: IriBuf, source: E) -> Self {
491        Self { target, source }
492    }
493
494    /// Rewrites the underlying error, keeping the URL that failed.
495    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
506/// Document loader.
507///
508/// A document loader is required by most processing functions to fetch remote
509/// documents identified by an IRI. In particular, the loader is in charge of
510/// fetching all the remote contexts imported in a `@context` entry.
511///
512/// This library provides a few default loader implementations:
513///   - [`NoLoader`] dummy loader that always fail. Perfect if you are certain
514///     that the processing will not require any loading.
515///   - Standard [`HashMap`](std::collections::HashMap) and
516///     [`BTreeMap`](std::collections::BTreeMap) mapping IRIs to pre-loaded
517///     documents. This way no network calls are performed and the loaded
518///     content can be trusted.
519///   - [`FsLoader`] that redirecting registered IRI prefixes to a local
520///     directory on the file system. This also avoids network calls. The loaded
521///     content can be trusted as long as the file system is trusted.
522///   - `ReqwestLoader` actually downloading the remote documents using the
523///     [`reqwest`](https://crates.io/crates/reqwest) library.
524///     This requires the `reqwest` feature to be enabled.
525pub trait Loader {
526    /// Loader-specific error type.
527    type Error: std::error::Error + Send + Sync + 'static;
528
529    /// Loads the document behind the given IRI, using the given vocabulary.
530    ///
531    /// # Panics
532    ///
533    /// Panics if `url` does not resolve in `vocabulary`, i.e. if it was
534    /// obtained from a different vocabulary.
535    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    /// Loads the document behind the given IRI.
547    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/// Context extraction error.
583#[derive(Debug, thiserror::Error)]
584pub enum ExtractContextError {
585    /// Unexpected JSON value.
586    #[error("unexpected {0}")]
587    Unexpected(jstrict::Kind),
588
589    /// No context definition found.
590    #[error("missing `@context` entry")]
591    NoContext,
592
593    /// Multiple context definitions found.
594    #[error("duplicate `@context` entry")]
595    DuplicateContext,
596
597    /// JSON syntax error.
598    #[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
608/// Documents from which a JSON-LD context can be extracted.
609pub trait ExtractContext {
610    /// Consumes this `ExtractContext`, returning its LD context.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error when the document is not an object, or has no `@context` entry.
615    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        // A single key: `sonic_rs::Object` does not preserve entry order, so a
761        // multi-key document would not compare equal after the round trip.
762        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);
766        assert_eq!(round_tripped, original);
767    }
768}