odata_client 0.1.0

Client for accessing OData APIs
Documentation
/* TODO:
- Retrieve multiple
    - Query options
        - filter, orderby, top, skip, count, expand, select, search, lambdas (`any`/`all`)
        - Supporting these would only be for query efficiency; doing most of this manipulation
        client-side is likely preferable. $filter would be the most useful.
    - Service-defined custom query options
    - Improve behaviour/error message when receiving HTTP 204 (no content), e.g. from nonexistent ID
- Create
- Update
- Delete
- Add relationship
- Modify relationship
- Call action
- Call function
- ETag support
*/

mod http_client;
#[cfg(test)]
mod test;

use self::http_client::ODataHttpClient;
use crate::{EntityProperties, ExpandQuery};
use anyhow::Context;
use reqwest::{Method, Request, Url};
use serde::Deserialize;
use std::marker::PhantomData;

/// Represents an Entity Set in an OData service.
pub struct EntitySetEndpoint<P> {
    pub service_url: &'static str,
    pub name: &'static str,
    pub marker: PhantomData<P>,
}

impl<P: EntityProperties> EntitySetEndpoint<P> {
    /// Retrieves entity records in this entity set.
    // TODO: `$filter` support
    pub async fn retrieve<Client: ODataHttpClient>(
        &self,
        client: &Client,
    ) -> Result<Vec<Entity<P>>, anyhow::Error> {
        retrieve_entity_set(client, self.service_url, self.name).await
    }

    /// Retrieves a single entity record from the entity set.
    pub async fn retrieve_entity<Client: ODataHttpClient>(
        &self,
        client: &Client,
        id: &str,
    ) -> Result<Entity<P>, anyhow::Error> {
        retrieve_entity_from_set(client, self.service_url, self.name, id).await
    }
}

/// Represents a singleton in an OData service.
pub struct SingletonEndpoint<P> {
    pub service_url: &'static str,
    pub name: &'static str,
    pub marker: PhantomData<P>,
}

impl<P: EntityProperties> SingletonEndpoint<P> {
    /// Retrieves the entity record which occupies the singleton endpoint.
    pub async fn get<Client: ODataHttpClient>(
        &self,
        client: &Client,
    ) -> Result<Entity<P>, anyhow::Error> {
        retrieve_singleton_entity(client, self.service_url, self.name).await
    }
}

/// Represents a retrieved entity record.
#[derive(Debug, Deserialize)]
pub struct Entity<P> {
    #[serde(rename = "@odata.id")]
    pub id: Option<String>,
    #[serde(rename = "@odata.etag")]
    pub etag: Option<String>,
    #[serde(rename = "@odata.editLink")]
    pub edit_link: Option<String>,
    #[serde(flatten)]
    pub properties: P,
}

/// A link to a fetchable entity record.
#[derive(Debug, Deserialize)]
pub struct EntityLink<P: EntityProperties> {
    #[serde(skip)]
    marker: PhantomData<P>,
    #[serde(rename = "@odata.id")]
    pub id: String,
}

impl<P: EntityProperties> EntityLink<P> {
    /// Fetch this record in full.
    pub async fn get(&self, client: &impl ODataHttpClient) -> Result<Entity<P>, anyhow::Error> {
        let request = Request::new(
            Method::GET,
            with_expand_query::<P>(
                Url::parse(&self.id).context("Entity link ID is not valid URL")?,
            ),
        );

        let response: ServiceResponse<Entity<P>> =
            client.execute_request(request).await?.json().await?;

        Ok(response.payload)
    }
}

/// Entity link for properties whose types are not included in the generated code. `P` is generated
/// as a fieldless stub type.
#[derive(Debug, Deserialize)]
pub struct EntityLinkStub<P> {
    #[serde(skip)]
    marker: PhantomData<P>,
    #[serde(rename = "@odata.id")]
    pub id: String,
}

async fn retrieve_entity_set<P: EntityProperties, Client: ODataHttpClient>(
    client: &Client,
    service_url: &str,
    set_name: &str,
) -> Result<Vec<Entity<P>>, anyhow::Error> {
    let mut entities = Vec::new();
    let mut next_page_url = with_expand_query::<P>(
        Url::parse(&format!("{}/{}", service_url, set_name)).expect("URL parse failed"),
    );

    loop {
        let request = Request::new(Method::GET, next_page_url);

        let mut response: ServiceResponse<EntitySetPayload<P>> =
            client.execute_request(request).await?.json().await?;

        entities.append(&mut response.payload.value);

        if let Some(next_link) = response.payload.next_link {
            next_page_url = Url::parse(&next_link)?;
        } else {
            return Ok(entities);
        }
    }
}

async fn retrieve_entity_from_set<P: EntityProperties, Client: ODataHttpClient>(
    client: &Client,
    service_url: &str,
    set_name: &str,
    id: &str,
) -> Result<Entity<P>, anyhow::Error> {
    let request = Request::new(
        Method::GET,
        with_expand_query::<P>(
            Url::parse(&format!("{}/{}('{}')", service_url, set_name, id))
                .expect("URL parse failed"),
        ),
    );

    let response: ServiceResponse<Entity<P>> =
        client.execute_request(request).await?.json().await?;

    Ok(response.payload)
}

async fn retrieve_singleton_entity<P: EntityProperties, Client: ODataHttpClient>(
    client: &Client,
    service_url: &str,
    singleton_name: &str,
) -> Result<Entity<P>, anyhow::Error> {
    let request = Request::new(
        Method::GET,
        with_expand_query::<P>(
            Url::parse(&format!("{}/{}", service_url, singleton_name)).expect("URL parse failed"),
        ),
    );

    let response: ServiceResponse<Entity<P>> =
        client.execute_request(request).await?.json().await?;

    Ok(response.payload)
}

fn with_expand_query<P: EntityProperties>(mut url: Url) -> Url {
    match P::EXPAND_QUERY {
        ExpandQuery::None => url,
        ExpandQuery::Expand(query) => {
            url.query_pairs_mut().append_pair("$expand", query);
            url
        }
    }
}

#[derive(Debug, Deserialize)]
struct ServiceResponse<Payload> {
    #[serde(rename = "@odata.context")]
    context: String,
    #[serde(flatten)]
    payload: Payload,
}

#[derive(Debug, Deserialize)]
struct EntitySetPayload<P> {
    #[serde(rename = "@odata.nextLink")]
    next_link: Option<String>,
    value: Vec<Entity<P>>,
}