Add documentation, refactor public-facing API
This commit is contained in:
+6
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user