use crate::HashSet;
use iri_rs::{Iri, IriBuf, iri};
use mediatype::{
MediaType,
MediaTypeBuf,
names::{APPLICATION, JSON, LD},
};
pub const LD_JSON_MEDIA_TYPE: MediaType<'static> = MediaType::from_parts(APPLICATION, LD, Some(JSON), &[]);
pub const JSON_MEDIA_TYPE: MediaType<'static> = MediaType::new(APPLICATION, JSON);
use rdfx::vocabulary::{IriVocabulary, IriVocabularyMut};
use std::{borrow::Cow, hash::Hash};
pub mod chain;
pub mod dereference;
pub mod fs;
pub mod map;
pub mod none;
pub use chain::ChainLoader;
pub use dereference::Dereference;
pub use fs::FsLoader;
pub use none::NoLoader;
#[cfg(feature = "reqwest")]
pub mod reqwest;
#[cfg(feature = "reqwest")]
pub use self::reqwest::ReqwestLoader;
pub type LoadingResult<I, E> = Result<RemoteDocument<I>, LoadError<E>>;
pub type RemoteContextReference<I = IriBuf> = RemoteDocumentReference<I, jsonld_syntax::Context>;
#[derive(Debug, Clone)]
pub enum RemoteDocumentReference<I = IriBuf, T = jstrict::Value> {
Iri(I),
Loaded(RemoteDocument<I, T>),
}
impl<I, T> RemoteDocumentReference<I, T> {
pub fn iri(iri: I) -> Self {
Self::Iri(iri)
}
}
impl<I> RemoteDocumentReference<I> {
pub async fn load_with<V, L>(self, vocabulary: &mut V, loader: &L) -> LoadingResult<I, L::Error>
where
V: IriVocabularyMut<Iri = I>,
L: Loader,
I: Clone + Eq + Hash,
{
match self {
Self::Iri(r) => Ok(loader.dereference(vocabulary, r).await?.map(Into::into)),
Self::Loaded(doc) => Ok(doc),
}
}
pub async fn loaded_with<V, L>(&self, vocabulary: &mut V, loader: &L) -> Result<Cow<'_, RemoteDocument<V::Iri>>, LoadError<L::Error>>
where
V: IriVocabularyMut<Iri = I>,
L: Loader,
I: Clone + Eq + Hash,
{
match self {
Self::Iri(r) => Ok(Cow::Owned(loader.dereference(vocabulary, r.clone()).await?.map(Into::into))),
Self::Loaded(doc) => Ok(Cow::Borrowed(doc)),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ContextLoadError<E> {
#[error(transparent)]
LoadingDocumentFailed(#[from] LoadError<E>),
#[error("context extraction failed")]
ContextExtractionFailed(#[from] ExtractContextError),
}
impl<I> RemoteContextReference<I> {
pub async fn load_context_with<V, L: Loader>(self, vocabulary: &mut V, loader: &L) -> Result<RemoteContext<I>, ContextLoadError<L::Error>>
where
V: IriVocabularyMut<Iri = I>,
I: Clone + Eq + Hash,
{
match self {
Self::Iri(r) => Ok(loader.dereference(vocabulary, r).await?.try_map(ExtractContext::into_ld_context)?),
Self::Loaded(doc) => Ok(doc),
}
}
pub async fn loaded_context_with<V, L: Loader>(&self, vocabulary: &mut V, loader: &L) -> Result<Cow<'_, RemoteContext<I>>, ContextLoadError<L::Error>>
where
V: IriVocabularyMut<Iri = I>,
I: Clone + Eq + Hash,
{
match self {
Self::Iri(r) => Ok(Cow::Owned(
loader.dereference(vocabulary, r.clone()).await?.try_map(ExtractContext::into_ld_context)?,
)),
Self::Loaded(doc) => Ok(Cow::Borrowed(doc)),
}
}
}
#[derive(Debug, Clone)]
pub struct RemoteDocument<I = IriBuf, T = jstrict::Value> {
pub url: Option<I>,
pub content_type: Option<MediaTypeBuf>,
pub context_url: Option<I>,
pub profile: HashSet<Profile<I>>,
pub document: T,
}
pub type RemoteContext<I = IriBuf> = RemoteDocument<I, jsonld_syntax::context::Context>;
impl<I, T> RemoteDocument<I, T> {
pub fn new(url: Option<I>, content_type: Option<MediaTypeBuf>, document: T) -> Self {
Self::new_full(url, content_type, None, HashSet::default(), document)
}
pub fn new_full(url: Option<I>, content_type: Option<MediaTypeBuf>, context_url: Option<I>, profile: HashSet<Profile<I>>, document: T) -> Self {
Self {
url,
content_type,
context_url,
profile,
document,
}
}
pub fn map<U>(self, f: impl Fn(T) -> U) -> RemoteDocument<I, U> {
RemoteDocument {
url: self.url,
content_type: self.content_type,
context_url: self.context_url,
profile: self.profile,
document: f(self.document),
}
}
pub fn try_map<U, E>(self, f: impl Fn(T) -> Result<U, E>) -> Result<RemoteDocument<I, U>, E> {
Ok(RemoteDocument {
url: self.url,
content_type: self.content_type,
context_url: self.context_url,
profile: self.profile,
document: f(self.document)?,
})
}
pub fn map_iris<J>(self, mut f: impl FnMut(I) -> J) -> RemoteDocument<J, T>
where
J: Eq + Hash,
{
RemoteDocument {
url: self.url.map(&mut f),
content_type: self.content_type,
context_url: self.context_url.map(&mut f),
profile: self.profile.into_iter().map(|p| p.map_iri(&mut f)).collect(),
document: self.document,
}
}
pub fn url(&self) -> Option<&I> {
self.url.as_ref()
}
pub fn content_type(&self) -> Option<&MediaTypeBuf> {
self.content_type.as_ref()
}
pub fn context_url(&self) -> Option<&I> {
self.context_url.as_ref()
}
pub fn document(&self) -> &T {
&self.document
}
pub fn document_mut(&mut self) -> &mut T {
&mut self.document
}
pub fn into_document(self) -> T {
self.document
}
pub fn into_url(self) -> Option<I> {
self.url
}
pub fn set_url(&mut self, url: Option<I>) {
self.url = url;
}
}
impl<I> RemoteDocument<I, jstrict::Value> {
pub fn from_value(url: Option<I>, content_type: Option<MediaTypeBuf>, document: impl Into<jstrict::Value>) -> Self {
Self::new(url, content_type, document.into())
}
}
#[cfg(feature = "serde-json")]
impl<I> RemoteDocument<I, jstrict::Value> {
pub fn from_serde_json(url: Option<I>, content_type: Option<MediaTypeBuf>, document: serde_json::Value) -> Self {
Self::new(url, content_type, jstrict::Value::from_serde_json(document))
}
pub fn into_serde_json(self) -> RemoteDocument<I, serde_json::Value> {
self.map(jstrict::Value::into_serde_json)
}
}
#[cfg(feature = "sonic-rs")]
impl<I> RemoteDocument<I, jstrict::Value> {
pub fn from_sonic_rs(url: Option<I>, content_type: Option<MediaTypeBuf>, document: &sonic_rs::Value) -> Self {
Self::new(url, content_type, jstrict::Value::from_sonic_rs(document))
}
pub fn into_sonic_rs(self) -> RemoteDocument<I, sonic_rs::Value> {
self.map(jstrict::Value::into_sonic_rs)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum StandardProfile {
Expanded,
Compacted,
Context,
Flattened,
Framed,
}
impl StandardProfile {
#[must_use]
pub fn from_iri(iri: Iri<&str>) -> Option<Self> {
if iri == iri!("http://www.w3.org/ns/json-ld#expanded") {
Some(Self::Expanded)
} else if iri == iri!("http://www.w3.org/ns/json-ld#compacted") {
Some(Self::Compacted)
} else if iri == iri!("http://www.w3.org/ns/json-ld#context") {
Some(Self::Context)
} else if iri == iri!("http://www.w3.org/ns/json-ld#flattened") {
Some(Self::Flattened)
} else if iri == iri!("http://www.w3.org/ns/json-ld#framed") {
Some(Self::Framed)
} else {
None
}
}
#[must_use]
pub fn iri(&self) -> Iri<&'static str> {
match self {
Self::Expanded => iri!("http://www.w3.org/ns/json-ld#expanded"),
Self::Compacted => iri!("http://www.w3.org/ns/json-ld#compacted"),
Self::Context => iri!("http://www.w3.org/ns/json-ld#context"),
Self::Flattened => iri!("http://www.w3.org/ns/json-ld#flattened"),
Self::Framed => iri!("http://www.w3.org/ns/json-ld#framed"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Profile<I = IriBuf> {
Standard(StandardProfile),
Custom(I),
}
impl Profile {
#[must_use]
pub fn new(iri: Iri<&str>) -> Self {
match StandardProfile::from_iri(iri) {
Some(p) => Self::Standard(p),
None => Self::Custom(iri.into()),
}
}
#[must_use]
pub fn iri(&self) -> Iri<&str> {
match self {
Self::Standard(s) => s.iri(),
Self::Custom(c) => c.as_ref(),
}
}
}
impl<I> Profile<I> {
pub fn new_with(iri: Iri<&str>, vocabulary: &mut impl IriVocabularyMut<Iri = I>) -> Self {
match StandardProfile::from_iri(iri) {
Some(p) => Self::Standard(p),
None => Self::Custom(vocabulary.insert(iri)),
}
}
pub fn iri_with<'a>(&'a self, vocabulary: &'a impl IriVocabulary<Iri = I>) -> Option<Iri<&'a str>> {
match self {
Self::Standard(s) => Some(s.iri()),
Self::Custom(c) => vocabulary.iri(c),
}
}
pub fn map_iri<J>(self, f: impl FnOnce(I) -> J) -> Profile<J> {
match self {
Self::Standard(p) => Profile::Standard(p),
Self::Custom(i) => Profile::Custom(f(i)),
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("loading document `{target}` failed: {source}")]
pub struct LoadError<E> {
pub target: IriBuf,
#[source]
pub source: E,
}
impl<E> LoadError<E> {
pub fn new(target: IriBuf, source: E) -> Self {
Self { target, source }
}
pub fn map_source<F, U>(self, f: F) -> LoadError<U>
where
F: FnOnce(E) -> U,
{
LoadError {
target: self.target,
source: f(self.source),
}
}
}
pub trait Loader {
type Error: std::error::Error + Send + Sync + 'static;
async fn load_with<V>(&self, vocabulary: &mut V, url: V::Iri) -> LoadingResult<V::Iri, Self::Error>
where
V: IriVocabularyMut,
V::Iri: Clone + Eq + Hash,
{
#[allow(clippy::expect_used)]
let lexical_url = vocabulary.iri(&url).expect("`url` does not resolve in the given vocabulary");
#[expect(
clippy::disallowed_methods,
reason = "the provided `load_with` delegates to the loader's own `load` inside its caller's guarded poll"
)]
let document = self.load(lexical_url).await?;
Ok(document.map_iris(|i| vocabulary.insert_owned(i)))
}
async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>>;
}
impl<L: Loader> Loader for &L {
type Error = L::Error;
#[expect(
clippy::disallowed_methods,
reason = "a loader delegating to the loader it wraps already runs inside its caller's guarded poll"
)]
async fn load_with<V>(&self, vocabulary: &mut V, url: V::Iri) -> LoadingResult<V::Iri, Self::Error>
where
V: IriVocabularyMut,
V::Iri: Clone + Eq + Hash,
{
L::load_with(self, vocabulary, url).await
}
#[expect(
clippy::disallowed_methods,
reason = "a loader delegating to the loader it wraps already runs inside its caller's guarded poll"
)]
async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>> {
L::load(self, url).await
}
}
impl<L: Loader> Loader for &mut L {
type Error = L::Error;
#[expect(
clippy::disallowed_methods,
reason = "a loader delegating to the loader it wraps already runs inside its caller's guarded poll"
)]
async fn load_with<V>(&self, vocabulary: &mut V, url: V::Iri) -> LoadingResult<V::Iri, Self::Error>
where
V: IriVocabularyMut,
V::Iri: Clone + Eq + Hash,
{
L::load_with(self, vocabulary, url).await
}
#[expect(
clippy::disallowed_methods,
reason = "a loader delegating to the loader it wraps already runs inside its caller's guarded poll"
)]
async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>> {
L::load(self, url).await
}
}
#[derive(Debug, thiserror::Error)]
pub enum ExtractContextError {
#[error("unexpected {0}")]
Unexpected(jstrict::Kind),
#[error("missing `@context` entry")]
NoContext,
#[error("duplicate `@context` entry")]
DuplicateContext,
#[error("JSON-LD context syntax error: {0}")]
Syntax(jsonld_syntax::context::InvalidContext),
}
impl ExtractContextError {
fn duplicate_context(jstrict::object::Duplicate(_, _): jstrict::object::Duplicate<jstrict::object::Entry>) -> Self {
Self::DuplicateContext
}
}
pub trait ExtractContext {
fn into_ld_context(self) -> Result<jsonld_syntax::context::Context, ExtractContextError>;
}
impl ExtractContext for jstrict::Value {
fn into_ld_context(self) -> Result<jsonld_syntax::context::Context, ExtractContextError> {
match self {
Self::Object(mut o) => match o.remove_unique("@context").map_err(ExtractContextError::duplicate_context)? {
Some(context) => {
use jsonld_syntax::TryFromJson;
jsonld_syntax::context::Context::try_from_json(&context.value).map_err(ExtractContextError::Syntax)
}
None => Err(ExtractContextError::NoContext),
},
other => Err(ExtractContextError::Unexpected(other.kind())),
}
}
}
#[cfg(all(test, feature = "serde-json"))]
mod serde_json_tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use iri_rs::iri;
#[test]
fn from_serde_json_preserves_url_and_content_type() {
let url = IriBuf::from(iri!("https://example.com/sample.jsonld"));
let mime: MediaTypeBuf = LD_JSON_MEDIA_TYPE.into();
let value = serde_json::json!({"foo": "bar"});
let doc = RemoteDocument::from_serde_json(Some(url.clone()), Some(mime.clone()), value);
assert_eq!(doc.url(), Some(&url));
assert_eq!(doc.content_type(), Some(&mime));
assert!(doc.context_url().is_none());
assert!(doc.profile.is_empty());
match doc.document() {
jstrict::Value::Object(o) => {
assert_eq!(o.get("foo").next().unwrap().as_str(), Some("bar"));
}
other => panic!("expected object, got {:?}", other.kind()),
}
}
#[test]
fn from_serde_json_handles_all_value_kinds() {
let value = serde_json::json!({
"null": null,
"bool": true,
"num": 1.5,
"str": "x",
"arr": [1, 2],
"obj": {"k": "v"}
});
let doc = RemoteDocument::<IriBuf, _>::from_serde_json(None, None, value);
let jstrict::Value::Object(obj) = doc.document() else {
panic!("expected object")
};
assert!(matches!(obj.get("null").next().unwrap(), jstrict::Value::Null));
assert!(matches!(obj.get("bool").next().unwrap(), jstrict::Value::Boolean(true)));
assert!(matches!(obj.get("num").next().unwrap(), jstrict::Value::Number(_)));
assert!(matches!(obj.get("str").next().unwrap(), jstrict::Value::String(_)));
assert!(matches!(obj.get("arr").next().unwrap(), jstrict::Value::Array(_)));
assert!(matches!(obj.get("obj").next().unwrap(), jstrict::Value::Object(_)));
}
#[test]
fn into_serde_json_round_trip() {
use jstrict::Parse;
let original = jstrict::Value::parse_str(r#"{"a": [1, 2, 3], "b": "x"}"#).unwrap().0;
let doc: RemoteDocument<IriBuf, _> = RemoteDocument::new(None, None, original.clone());
let serde_doc = doc.into_serde_json();
let round_tripped = jstrict::Value::from_serde_json(serde_doc.document.clone());
assert_eq!(round_tripped, original);
}
#[test]
fn from_value_accepts_jstrict_value() {
use jstrict::Parse;
let value = jstrict::Value::parse_str(r#"{"k": 1}"#).unwrap().0;
let doc: RemoteDocument<IriBuf, _> = RemoteDocument::from_value(None, None, value);
match doc.document() {
jstrict::Value::Object(_) => {}
_ => panic!("expected object"),
}
}
#[test]
fn from_value_accepts_serde_json_value() {
let v = serde_json::json!({"k": 1});
let via_value: RemoteDocument<IriBuf, _> = RemoteDocument::from_value(None, None, v.clone());
let via_serde: RemoteDocument<IriBuf, _> = RemoteDocument::from_serde_json(None, None, v);
assert!(jsonld_syntax::Compare::compare(via_value.document(), via_serde.document()));
}
}
#[cfg(all(test, feature = "sonic-rs"))]
mod sonic_rs_tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use iri_rs::iri;
#[test]
fn from_sonic_rs_preserves_url_and_content_type() {
let url = IriBuf::from(iri!("https://example.com/sample.jsonld"));
let mime: MediaTypeBuf = LD_JSON_MEDIA_TYPE.into();
let value: sonic_rs::Value = sonic_rs::json!({"foo": "bar"});
let doc = RemoteDocument::from_sonic_rs(Some(url.clone()), Some(mime.clone()), &value);
assert_eq!(doc.url(), Some(&url));
assert_eq!(doc.content_type(), Some(&mime));
match doc.document() {
jstrict::Value::Object(o) => {
assert_eq!(o.get("foo").next().unwrap().as_str(), Some("bar"));
}
other => panic!("expected object, got {:?}", other.kind()),
}
}
#[test]
fn from_sonic_rs_handles_all_value_kinds() {
let value: sonic_rs::Value = sonic_rs::json!({
"null": null,
"bool": true,
"num": 1.5,
"str": "x",
"arr": [1, 2],
"obj": {"k": "v"}
});
let doc = RemoteDocument::<IriBuf, _>::from_sonic_rs(None, None, &value);
let jstrict::Value::Object(obj) = doc.document() else {
panic!("expected object")
};
assert!(matches!(obj.get("null").next().unwrap(), jstrict::Value::Null));
assert!(matches!(obj.get("bool").next().unwrap(), jstrict::Value::Boolean(true)));
assert!(matches!(obj.get("num").next().unwrap(), jstrict::Value::Number(_)));
assert!(matches!(obj.get("str").next().unwrap(), jstrict::Value::String(_)));
assert!(matches!(obj.get("arr").next().unwrap(), jstrict::Value::Array(_)));
assert!(matches!(obj.get("obj").next().unwrap(), jstrict::Value::Object(_)));
}
#[test]
fn into_sonic_rs_round_trip() {
use jstrict::Parse;
let original = jstrict::Value::parse_str(r#"{"a": [1, 2, 3]}"#).unwrap().0;
let doc: RemoteDocument<IriBuf, _> = RemoteDocument::new(None, None, original.clone());
let sonic_doc = doc.into_sonic_rs();
let round_tripped = jstrict::Value::from_sonic_rs(&sonic_doc.document);
assert_eq!(round_tripped, original);
}
}