ldp 0.0.0

A library to assist with the creation and maintenance of remote RDF data via LDP
Documentation
use crate::rdf_source::RdfSource;
use crate::vocab;
use bytes::Bytes;
use futures::Stream;
use oxigraph::io::{RdfFormat, RdfParser};
use oxigraph::model::{Dataset, GraphNameRef, NamedNodeRef};
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,
    follow_described_by: bool,
    formats: Vec<RdfFormat>,
}

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,
            url,
            follow_described_by: true,
            formats: Vec::new(),
        }
    }

    /// 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
    }

    /// 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
    }

    /// 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,
            RdfFormat::NTriples,
            RdfFormat::RdfXml,
            RdfFormat::TriG,
            RdfFormat::Turtle,
        ];
        self
    }

    /// Build the request.
    pub fn build(self) -> ResourceRequest {
        ResourceRequest { builder: self }
    }
}

/// A request for a LDP [Resource](https://www.w3.org/TR/ldp/#ldpr).
#[derive(Clone, Debug)]
pub struct ResourceRequest {
    builder: ResourceRequestBuilder,
}

impl ResourceRequest {
    /// Described by example:
    /// Link: <http://fedora.quill.lan/rest/E2/fcr:metadata>; rel="describedby"
    fn extract_described_by(response: &Response) -> Option<Url> {
        if response.status() == StatusCode::OK {
            let headers = response.headers();
            for link in headers.get_all(header::LINK) {
                let link = link.to_str().unwrap_or("");
                match parse_link_header::parse(link) {
                    Ok(link_map) => {
                        if let Some(metadata_url) = link_map.get(&Some("describedby".to_string())) {
                            let raw_url = metadata_url.raw_uri.as_str();
                            return Url::parse(raw_url).ok();
                        }
                    }
                    Err(err) => error!(err = ?err, "Failed to parse Link header"),
                }
            }
        }
        None
    }

    fn ensure_ldp_support(response: &Response) -> crate::Result<()> {
        if response.status() == StatusCode::OK {
            let headers = response.headers();
            for link in headers.get_all(header::LINK) {
                let link = link.to_str().unwrap_or("");
                match parse_link_header::parse(link) {
                    Ok(link_map) => {
                        if let Some(metadata_url) = link_map.get(&Some("type".to_string())) {
                            let raw_url = metadata_url.raw_uri.as_str();
                            if raw_url == vocab::ldp::RESOURCE {
                                return Ok(());
                            }
                        }
                    }
                    Err(err) => error!(err = ?err, "Failed to parse Link header"),
                }
            }
        }
        Err(crate::Error::LDPUnsupported)
    }

    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
    }

    /// 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()?;
        Self::ensure_ldp_support(&response)?;

        let url_to_get;
        let described_by = Self::extract_described_by(&response);
        if let Some(new_url) = &described_by
            && self.builder.follow_described_by
        {
            url_to_get = new_url.clone();
        } else {
            url_to_get = self.builder.url.clone();
        }

        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()?;
        Self::ensure_ldp_support(&response)?;

        let state_token = response
            .headers()
            .get(crate::header::X_STATE_TOKEN)
            .and_then(|hv| hv.to_str().ok().map(|et| et.to_string()));

        let format = response
            .headers()
            .get(header::CONTENT_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(Resource {
            origin: self.builder.url.clone(),
            described_by,
            state_token,
            format,
            response,
        })
    }
}

/// 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 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);
            let body = self.response.bytes().await?;
            let quads = parser.for_slice(&body);
            let dataset = quads.filter_map(Result::ok).collect::<Dataset>();
            Ok(RdfSource {
                origin: self.origin,
                described_by: self.described_by,
                state_token: self.state_token,
                dataset,
            })
        } else {
            Err(crate::Error::UnsupportedFormat)
        }
    }
}