diff --git a/Cargo.lock b/Cargo.lock index 4a1da33..801c95a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -49,21 +34,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - [[package]] name = "base64" version = "0.22.1" @@ -155,33 +125,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "color-eyre" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" -dependencies = [ - "backtrace", - "color-spantrace", - "eyre", - "indenter", - "once_cell", - "owo-colors", - "tracing-error", -] - -[[package]] -name = "color-spantrace" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" -dependencies = [ - "once_cell", - "owo-colors", - "tracing-core", - "tracing-error", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -248,26 +191,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "eyre" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" -dependencies = [ - "indenter", - "once_cell", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -393,12 +316,6 @@ dependencies = [ "wasip2", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "glob" version = "0.3.3" @@ -602,12 +519,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indenter" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" - [[package]] name = "ipnet" version = "2.12.0" @@ -663,20 +574,9 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "ldctl" -version = "0.1.0" -dependencies = [ - "color-eyre", - "futures", - "ldp", - "tokio", - "tracing", -] - [[package]] name = "ldp" -version = "0.1.0" +version = "0.0.0" dependencies = [ "async-trait", "base64", @@ -749,15 +649,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - [[package]] name = "mio" version = "1.2.0" @@ -779,27 +670,12 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - [[package]] name = "oxigraph" version = "0.5.8" @@ -924,16 +800,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - [[package]] name = "parking_lot_core" version = "0.9.12" @@ -1171,12 +1037,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - [[package]] name = "rustc-hash" version = "2.1.2" @@ -1252,31 +1112,12 @@ dependencies = [ "digest", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "siphasher" version = "1.0.3" @@ -1423,15 +1264,6 @@ dependencies = [ "syn", ] -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -1448,28 +1280,13 @@ version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ - "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", - "tokio-macros", "windows-sys", ] -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -1557,28 +1374,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" -dependencies = [ - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "sharded-slab", - "thread_local", - "tracing-core", ] [[package]] @@ -1617,12 +1412,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index a74c4d1..7ebdf48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ resolver = "2" members = [ "ldp", - "ldctl", ] [workspace.dependencies] diff --git a/ldctl/Cargo.toml b/ldctl/Cargo.toml deleted file mode 100644 index 9840c46..0000000 --- a/ldctl/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "ldctl" -version = "0.1.0" -edition = "2024" - -[dependencies] diff --git a/ldctl/src/main.rs b/ldctl/src/main.rs deleted file mode 100644 index cb512be..0000000 --- a/ldctl/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -} diff --git a/ldp/Cargo.toml b/ldp/Cargo.toml index fec5960..9f4e4b1 100644 --- a/ldp/Cargo.toml +++ b/ldp/Cargo.toml @@ -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 diff --git a/ldp/README.md b/ldp/README.md new file mode 100644 index 0000000..ac5fc20 --- /dev/null +++ b/ldp/README.md @@ -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. \ No newline at end of file diff --git a/ldp/src/error.rs b/ldp/src/error.rs index ebf4811..5017215 100644 --- a/ldp/src/error.rs +++ b/ldp/src/error.rs @@ -1,7 +1,9 @@ use thiserror::Error; +/// The result type for the library. pub type Result = std::result::Result; +/// 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, } diff --git a/ldp/src/lib.rs b/ldp/src/lib.rs index e8d3919..f811e43 100644 --- a/ldp/src/lib.rs +++ b/ldp/src/lib.rs @@ -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}; diff --git a/ldp/src/middleware.rs b/ldp/src/middleware.rs index 69c3757..f05c0b9 100644 --- a/ldp/src/middleware.rs +++ b/ldp/src/middleware.rs @@ -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, diff --git a/ldp/src/rdf_source.rs b/ldp/src/rdf_source.rs index ea14e83..80316cd 100644 --- a/ldp/src/rdf_source.rs +++ b/ldp/src/rdf_source.rs @@ -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, @@ -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 { 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 { + /// Prepare an update request. + pub fn to_update(&self, format: RdfFormat) -> crate::Result { 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, + 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 { + 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) + } } } } diff --git a/ldp/src/resource.rs b/ldp/src/resource.rs index 6dee376..a3b1b58 100644 --- a/ldp/src/resource.rs +++ b/ldp/src/resource.rs @@ -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::from_builder(self).await + /// Build the request. + pub fn build(self) -> ResourceRequest { + ResourceRequest { builder: self } } } -pub struct Resource { - origin: Url, - described_by: Option, - state_token: Option, - format: Option, - 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: ; rel="describedby" fn extract_described_by(response: &Response) -> Option { @@ -104,41 +130,46 @@ impl Resource { } } } - Err(error::Error::LDPUnsupported) + Err(crate::Error::LDPUnsupported) } - fn add_media_types( - formats: Vec, - 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 { - 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 { + 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 { - 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, + state_token: Option, + 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> { self.response.bytes_stream() } + /// Parse the response. pub async fn into_rdf_source(self) -> crate::Result { - 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) } } }