Add LDP container traversal

This commit is contained in:
2026-05-21 13:08:12 -04:00
parent 8a6fe06c27
commit 938b0a17ab
2 changed files with 118 additions and 0 deletions
+1
View File
@@ -5,6 +5,7 @@ pub mod header;
pub mod middleware; pub mod middleware;
mod rdf_source; mod rdf_source;
mod resource; mod resource;
pub mod traverse;
pub mod vocab; pub mod vocab;
#[cfg(feature = "keyed")] #[cfg(feature = "keyed")]
+117
View File
@@ -0,0 +1,117 @@
use crate::{RdfSource, ResourceRequestBuilder, vocab};
use futures::stream::{BoxStream, SelectAll};
use futures::{FutureExt, Stream, StreamExt};
use oxigraph::model::{Dataset, TermRef};
use reqwest_middleware::ClientWithMiddleware;
use reqwest_middleware::reqwest::Url;
use std::collections::HashSet;
use std::pin::Pin;
use std::task::{Context, Poll, ready};
/// Recursively traverse an entire repository.
///
/// Beginning with the root URL, this [`Stream`] will fetch the resource, emit it as a
/// [`RdfSource<Dataset>`], and then fetch every child resource, as determined by the
/// [`ldp:contains`](https://www.w3.org/TR/ldp/#ldpc) predicate.
///
/// All visited URLs are stored internally to prevent loops.
pub struct Traverse<'a> {
http_client: ClientWithMiddleware,
stream: SelectAll<BoxStream<'a, crate::Result<RdfSource<Dataset>>>>,
visited_urls: HashSet<Url>,
pending_urls: HashSet<Url>,
parallelism: Option<usize>,
}
impl<'a> Traverse<'a> {
/// Create a new traversal, starting with the `root`. `parallelism` limits the number of
/// requests which may be in-flight at any given time. A value of `None` indicates infinite
/// parallelism and should be used this with caution, as the reqwest library will open a
/// potentially unbounded number of TCP connections.
pub fn new(http_client: ClientWithMiddleware, root: Url, parallelism: Option<usize>) -> Self {
let mut this = Self {
http_client,
stream: SelectAll::new(),
visited_urls: HashSet::new(),
pending_urls: HashSet::new(),
parallelism,
};
this.visited_urls.insert(root.clone());
this.stream
.push(Self::fetch(this.http_client.clone(), root));
this
}
fn fetch(
http_client: ClientWithMiddleware,
url: Url,
) -> BoxStream<'a, crate::Result<RdfSource<Dataset>>> {
async {
let resource = ResourceRequestBuilder::with_client_and_url(http_client, url)
.build()
.send()
.await?;
let rdf_source = resource.into_rdf_source().await?;
Ok(rdf_source)
}
.into_stream()
.boxed()
}
fn push_pending(&mut self) {
let next_batch;
if let Some(parallelism) = self.parallelism {
if let Some(mut available) = parallelism.checked_sub(self.stream.len()) {
// Plus 1 to account for the document currently being processed.
available += 1;
next_batch = self
.pending_urls
.iter()
.take(available)
.cloned()
.collect::<HashSet<_>>();
} else {
next_batch = HashSet::new();
}
} else {
next_batch = self.pending_urls.iter().cloned().collect::<HashSet<_>>();
}
for url in next_batch {
self.pending_urls.remove(&url);
let client = self.http_client.clone();
self.stream.push(Self::fetch(client, url));
}
}
}
impl<'a> Stream for Traverse<'a> {
type Item = crate::Result<RdfSource<Dataset>>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let result = match ready!(self.stream.poll_next_unpin(cx)) {
Some(Ok(document)) => {
self.visited_urls.insert(document.origin().clone());
if let Some(described_by) = document.described_by() {
self.visited_urls.insert(described_by.clone());
}
let dataset = document.dataset();
for quad in dataset.quads_for_predicate(vocab::ldp::CONTAINS) {
if let TermRef::NamedNode(node) = quad.object
&& let Ok(url) = Url::parse(node.as_str())
&& !self.visited_urls.contains(&url)
{
self.pending_urls.insert(url);
}
}
Poll::Ready(Some(Ok(document)))
}
Some(Err(err)) => Poll::Ready(Some(Err(err))),
None => Poll::Ready(None),
};
self.push_pending();
result
}
}