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.
528pub trait Loader {
529    /// Loader-specific error type.
530    type Error: std::error::Error + Send + Sync + 'static;
531
532    /// Loads the document behind the given IRI, using the given vocabulary.
533    ///
534    /// # Panics
535    ///
536    /// Panics if `url` does not resolve in `vocabulary`, i.e. if it was
537    /// obtained from a different vocabulary.
538    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    /// Loads the document behind the given IRI.
554    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/// Context extraction error.
606#[derive(Debug, thiserror::Error)]
607pub enum ExtractContextError {
608    /// Unexpected JSON value.
609    #[error("unexpected {0}")]
610    Unexpected(jstrict::Kind),
611
612    /// No context definition found.
613    #[error("missing `@context` entry")]
614    NoContext,
615
616    /// Multiple context definitions found.
617    #[error("duplicate `@context` entry")]
618    DuplicateContext,
619
620    /// JSON syntax error.
621    #[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
631/// Documents from which a JSON-LD context can be extracted.
632pub trait ExtractContext {
633    /// Consumes this `ExtractContext`, returning its LD context.
634    ///
635    /// # Errors
636    ///
637    /// Returns an error when the document is not an object, or has no `@context` entry.
638    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        // A single key: `sonic_rs::Object` does not preserve entry order, so a
784        // multi-key document would not compare equal after the round trip.
785        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}