Add documentation, refactor public-facing API

This commit is contained in:
2026-05-19 23:08:17 -04:00
parent f29b5af96d
commit be2fd42c5c
11 changed files with 228 additions and 300 deletions
+6 -1
View File
@@ -1,7 +1,12 @@
[package]
name = "ldp"
version = "0.1.0"
version = "0.0.0"
edition = "2024"
description = "A library to assist with the creation and maintenance of remote RDF data via LDP"
readme = "README.md"
license = "GPL-3.0-only"
keywords = ["ldp", "rdf", "sparql"]
categories = ["database", "web-programming::http-client"]
[dependencies]
async-trait.workspace = true
+4
View File
@@ -0,0 +1,4 @@
# LDP
This library is intended to implement the [Linked Data Platform](https://www.w3.org/TR/ldp/) standard, to facilitate the
creation and maintenance of RDF data on remote servers.
+4 -6
View File
@@ -1,7 +1,9 @@
use thiserror::Error;
/// The result type for the library.
pub type Result<T> = std::result::Result<T, Error>;
/// The error type for the library.
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
@@ -13,15 +15,11 @@ pub enum Error {
#[error(transparent)]
ReqwestMiddleware(#[from] reqwest_middleware::Error),
#[error(transparent)]
InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
/// The server did not advertise LDP support.
#[error("Server did not advertise LDP support")]
LDPUnsupported,
/// The response from the server was not in a format we understand.
#[error("Response was not in a supported RDF format")]
UnsupportedFormat,
#[error("Document has been modified since last fetch, and overwrite was not enabled")]
DocumentModified,
}
+8 -7
View File
@@ -1,3 +1,5 @@
#![cfg_attr(doc, doc = include_str!("../README.md"))]
mod error;
pub mod header;
pub mod middleware;
@@ -5,12 +7,11 @@ mod rdf_source;
mod resource;
pub mod vocab;
pub use http::{HeaderName, HeaderValue};
pub use http;
pub use oxigraph;
pub use reqwest_middleware::ClientBuilder;
pub use reqwest_middleware::reqwest::Client;
pub use reqwest_middleware::reqwest::Url;
pub use reqwest_middleware;
pub use reqwest_middleware::reqwest;
pub use error::Result;
pub use rdf_source::RdfSource;
pub use resource::{Resource, ResourceRequestBuilder};
pub use error::{Error, Result};
pub use rdf_source::{RdfSource, RdfSourceUpdateRequest, RdfSourceUpdateResponse};
pub use resource::{Resource, ResourceRequest, ResourceRequestBuilder, ResponseFormat};
+1
View File
@@ -4,6 +4,7 @@ use reqwest_middleware::reqwest::header::HeaderValue;
use reqwest_middleware::reqwest::{Request, Response, header};
use reqwest_middleware::{Middleware, Next};
/// HTTP Basic Authentication
pub struct BasicAuthMiddleware {
username: String,
password: Option<String>,
+83 -28
View File
@@ -1,11 +1,12 @@
use crate::{Url, error};
use bytes::BufMut;
use http::{HeaderValue, StatusCode, header};
use http::{StatusCode, header};
use oxigraph::io::{RdfFormat, RdfSerializer};
use oxigraph::model::Dataset;
use reqwest_middleware::ClientWithMiddleware;
use reqwest_middleware::reqwest::Request;
use reqwest_middleware::reqwest::Url;
/// A LDP [RDF Source](https://www.w3.org/TR/ldp/#ldprs).
#[derive(Clone, Debug)]
pub struct RdfSource {
pub(crate) origin: Url,
pub(crate) described_by: Option<Url>,
@@ -14,22 +15,31 @@ pub struct RdfSource {
}
impl RdfSource {
/// The original URL used to procure this RDF Source.
pub fn origin(&self) -> &Url {
&self.origin
}
/// The URL at which the RDF description of the resource is located.
///
/// This is the URL at which updates are submitted.
pub fn described_by(&self) -> Option<&Url> {
self.described_by.as_ref()
}
/// The state token of the resource.
///
/// This is used for optimistic locking.
pub fn state_token(&self) -> Option<&str> {
self.state_token.as_deref()
}
/// The underlying Dataset.
pub fn dataset(&self) -> &Dataset {
&self.dataset
}
/// Serializes the Dataset in to the provided format.
pub fn serialize(&self, format: RdfFormat) -> crate::Result<bytes::Bytes> {
let writer = bytes::BytesMut::new().writer();
let mut serializer = RdfSerializer::from_format(format).for_writer(writer);
@@ -48,40 +58,85 @@ impl RdfSource {
Ok(finished_writer.into_inner().freeze())
}
pub fn to_request(
&self,
client: ClientWithMiddleware,
format: RdfFormat,
) -> crate::Result<Request> {
/// Prepare an update request.
pub fn to_update(&self, format: RdfFormat) -> crate::Result<RdfSourceUpdateRequest> {
let url = self.described_by.clone().unwrap_or(self.origin.clone());
let media_type = format.media_type().to_string();
let body = self.serialize(format)?;
Ok(client
.put(url)
.header(header::CONTENT_TYPE, format.media_type())
.body(body)
.build()?)
Ok(RdfSourceUpdateRequest {
url,
state_token: self.state_token.clone(),
media_type,
body,
})
}
}
/// An update request.
pub struct RdfSourceUpdateRequest {
url: Url,
state_token: Option<String>,
media_type: String,
body: bytes::Bytes,
}
/// An update response.
///
/// If the document was modified since it was last fetched, and if the user set `overwrite` to
/// `false`, then the request will be returned back to the user so it can be re-submitted.
pub enum RdfSourceUpdateResponse {
/// The update succeeded.
Success,
/// The update failed specifically because of optimistic locking, and `overwrite` was disabled.
/// The original request is preserved here to allow the user to cheaply re-submit the request
/// with `overwrite` set to `true`.
DocumentModified(RdfSourceUpdateRequest),
}
impl RdfSourceUpdateRequest {
/// Send the update request.
///
/// If `overwrite` is `true`, then optimistic locking is disabled. In the event of a failed
/// update, the original request is preserved to permit the user to cheaply re-submit it. This
/// avoids unnecessary cloning/serialization.
///
/// Optimistic locking is implemented via the `X-State-Token` and `X-If-State-Token` HTTP
/// headers. The [412 Precondition Failed](https://http.dev/412) status code is used to
/// determine whether the update failed specifically because of optimistic locking.
pub async fn send(
&self,
self,
client: ClientWithMiddleware,
mut request: Request,
overwrite: bool,
) -> crate::Result<()> {
if !overwrite && let Some(state_token) = &self.state_token {
let value = HeaderValue::from_str(state_token.as_str())?;
request
.headers_mut()
.insert(crate::header::X_IF_STATE_TOKEN, value);
}
) -> crate::Result<RdfSourceUpdateResponse> {
if overwrite {
client
.put(self.url)
.header(header::CONTENT_TYPE, self.media_type)
.body(self.body)
.send()
.await?
.error_for_status()?;
Ok(RdfSourceUpdateResponse::Success)
} else {
let mut builder = client
.put(self.url.clone())
.header(header::CONTENT_TYPE, self.media_type.clone());
let response = client.execute(request).await?;
if let Some(state_token) = &self.state_token {
builder = builder.header(crate::header::X_IF_STATE_TOKEN, state_token.as_str());
}
match response.status() {
StatusCode::PRECONDITION_FAILED => Err(error::Error::DocumentModified),
_ => {
response.error_for_status()?;
Ok(())
let response = builder.body(self.body.clone()).send().await?;
match response.status() {
StatusCode::PRECONDITION_FAILED => {
Ok(RdfSourceUpdateResponse::DocumentModified(self))
}
_ => {
response.error_for_status()?;
Ok(RdfSourceUpdateResponse::Success)
}
}
}
}
+121 -36
View File
@@ -1,5 +1,5 @@
use crate::rdf_source::RdfSource;
use crate::{error, vocab};
use crate::vocab;
use bytes::Bytes;
use futures::Stream;
use oxigraph::io::{RdfFormat, RdfParser};
@@ -8,6 +8,23 @@ use reqwest_middleware::reqwest::{Client, Response, StatusCode, Url, header};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, RequestBuilder};
use tracing::error;
/// Builds an HTTP request for a [Resource](https://www.w3.org/TR/ldp/#ldpr).
///
/// # Example
/// ```rust
/// use ldp::reqwest::{Client, Url};
/// use ldp::reqwest_middleware::ClientBuilder;
/// use ldp::ResourceRequestBuilder;
///
/// let client = ClientBuilder::new(Client::new()).build();
/// let url = Url::parse("http://server/resource")?;
/// let resource = ResourceRequestBuilder::with_client_and_url(client.clone(), url)
/// .follow_described_by(true)
/// .accept_all_rdf_formats()
/// .send();
/// .await?;
/// ```
#[derive(Clone, Debug)]
pub struct ResourceRequestBuilder {
client: ClientWithMiddleware,
url: Url,
@@ -16,10 +33,14 @@ pub struct ResourceRequestBuilder {
}
impl ResourceRequestBuilder {
/// Creates a new request.
///
/// A reqwest client will be created and managed by this library.
pub fn new(url: Url) -> Self {
Self::with_client_and_url(ClientBuilder::new(Client::new()).build(), url)
}
/// Creates a new request with the given HTTP client.
pub fn with_client_and_url(client: ClientWithMiddleware, url: Url) -> Self {
Self {
client,
@@ -29,17 +50,23 @@ impl ResourceRequestBuilder {
}
}
/// If the HTTP response includes a [describedby](https://www.w3.org/TR/ldp/#link-relation-describedby) link rel, then it will be followed.
pub fn follow_described_by(mut self, value: bool) -> Self {
self.follow_described_by = value;
self
}
pub fn allow_format(mut self, format: RdfFormat) -> Self {
/// Restrict the request to the given RDF format.
///
/// Repeated calls are additive. By default, all formats — even non-RDF formats — are accepted.
/// If your intention is to process non-RDF data, then this should not be called.
pub fn accept_rdf_format(mut self, format: RdfFormat) -> Self {
self.formats.push(format);
self
}
pub fn allow_only_supported_formats(mut self) -> Self {
/// Restrict the request to all the formats supported by the underlying parser.
pub fn accept_all_rdf_formats(mut self) -> Self {
self.formats = vec![
RdfFormat::N3,
RdfFormat::NQuads,
@@ -51,20 +78,19 @@ impl ResourceRequestBuilder {
self
}
pub async fn send(self) -> crate::Result<Resource> {
Resource::from_builder(self).await
/// Build the request.
pub fn build(self) -> ResourceRequest {
ResourceRequest { builder: self }
}
}
pub struct Resource {
origin: Url,
described_by: Option<Url>,
state_token: Option<String>,
format: Option<RdfFormat>,
response: Response,
/// A request for a LDP [Resource](https://www.w3.org/TR/ldp/#ldpr).
#[derive(Clone, Debug)]
pub struct ResourceRequest {
builder: ResourceRequestBuilder,
}
impl Resource {
impl ResourceRequest {
/// Described by example:
/// Link: <http://fedora.quill.lan/rest/E2/fcr:metadata>; rel="describedby"
fn extract_described_by(response: &Response) -> Option<Url> {
@@ -104,41 +130,46 @@ impl Resource {
}
}
}
Err(error::Error::LDPUnsupported)
Err(crate::Error::LDPUnsupported)
}
fn add_media_types(
formats: Vec<RdfFormat>,
mut request_builder: RequestBuilder,
) -> RequestBuilder {
let media_types = formats.iter().map(|f| f.media_type());
fn add_media_types(&self, mut request_builder: RequestBuilder) -> RequestBuilder {
let media_types = self.builder.formats.iter().map(|f| f.media_type());
for media_type in media_types {
request_builder = request_builder.header(header::ACCEPT, media_type);
}
request_builder
}
pub async fn from_builder(builder: ResourceRequestBuilder) -> crate::Result<Self> {
let request_builder = builder.client.head(builder.url.clone());
/// Send the request.
///
/// There are two stages to this process. First, a HEAD request is made to determine whether
/// the requested resource is described by an RDF graph at another location. If it is, and if
/// the user permits it, then the URL in the
/// [describedby](https://www.w3.org/TR/ldp/#link-relation-describedby) header is used.
/// Otherwise, the original URL is used.
///
/// During the second stage, a GET request is made. No parsing occurs at this time.
pub async fn send(&self) -> crate::Result<Resource> {
let request_builder = self.builder.client.head(self.builder.url.clone());
let mut response = request_builder.send().await?.error_for_status()?;
Resource::ensure_ldp_support(&response)?;
Self::ensure_ldp_support(&response)?;
let url_to_get;
let described_by = Self::extract_described_by(&response);
if let Some(new_url) = &described_by
&& builder.follow_described_by
&& self.builder.follow_described_by
{
url_to_get = new_url.clone();
} else {
url_to_get = builder.url.clone();
url_to_get = self.builder.url.clone();
}
let mut request_builder = builder.client.get(url_to_get);
if !builder.formats.is_empty() {
request_builder = Self::add_media_types(builder.formats, request_builder);
}
let mut request_builder = self.builder.client.get(url_to_get);
request_builder = self.add_media_types(request_builder);
response = request_builder.send().await?.error_for_status()?;
Resource::ensure_ldp_support(&response)?;
Self::ensure_ldp_support(&response)?;
let state_token = response
.headers()
@@ -148,28 +179,82 @@ impl Resource {
let format = response
.headers()
.get(header::CONTENT_TYPE)
.map(|hv| hv.to_str().unwrap_or_default())
.and_then(RdfFormat::from_media_type);
.and_then(|hv| hv.to_str().ok())
.map(|value| {
if let Some(format) = RdfFormat::from_media_type(value) {
ResponseFormat::RdfFormat(format)
} else {
ResponseFormat::Other(value.to_string())
}
})
.unwrap_or(ResponseFormat::Unspecified);
Ok(Self {
origin: builder.url,
Ok(Resource {
origin: self.builder.url.clone(),
described_by,
state_token,
format,
response,
})
}
}
pub fn format(&self) -> Option<RdfFormat> {
self.format
/// The format of the response, as determined by the `Content-Type` HTTP header.
pub enum ResponseFormat {
RdfFormat(RdfFormat),
Other(String),
Unspecified,
}
/// A LDP [Resource](https://www.w3.org/TR/ldp/#ldpr).
///
/// A Resource on its own is not very useful, as it is a very abstract concept. If the intention is
/// to treat the response as opaque data (such as a media file), call [`Resource::into_stream`]. In
/// the spec, this is equivalent to a [NonRDFSource](https://www.w3.org/TR/ldp/#ldpnr).
///
/// If the intention is to treat the response as RDF data to be parsed, call
/// [`Resource::into_rdf_source`].
pub struct Resource {
origin: Url,
described_by: Option<Url>,
state_token: Option<String>,
format: ResponseFormat,
response: Response,
}
impl Resource {
/// The original URL used to make the request.
pub fn origin(&self) -> &Url {
&self.origin
}
/// The URL used to describe the Resource, which may be different from the Resource itself.
///
/// This is useful because it allows RDF data to be attached to non-RDF data, such as a video.
pub fn described_by(&self) -> Option<&Url> {
self.described_by.as_ref()
}
/// The format of the response.
pub fn format(&self) -> &ResponseFormat {
&self.format
}
/// The state token, as extracted from the `X-State-Token` header.
///
/// Note that this is a feature [specific to Fedora](https://fedora.info/2021/05/01/spec/#state-tokens).
pub fn state_token(&self) -> Option<&String> {
self.state_token.as_ref()
}
/// Extract the response as a stream of unparsed bytes.
pub fn into_stream(self) -> impl Stream<Item = reqwest_middleware::reqwest::Result<Bytes>> {
self.response.bytes_stream()
}
/// Parse the response.
pub async fn into_rdf_source(self) -> crate::Result<RdfSource> {
if let Some(format) = self.format {
if let ResponseFormat::RdfFormat(format) = self.format {
let graph_url = self.described_by.as_ref().unwrap_or(&self.origin);
let graph = GraphNameRef::NamedNode(NamedNodeRef::new_unchecked(graph_url.as_str()));
let parser = RdfParser::from_format(format).with_default_graph(graph);
@@ -183,7 +268,7 @@ impl Resource {
dataset,
})
} else {
Err(error::Error::UnsupportedFormat)
Err(crate::Error::UnsupportedFormat)
}
}
}