terdoc-client 0.1.1

Client library crate for the terdoc service
Documentation
// SPDX-FileCopyrightText: OpenTalk GmbH <mail@opentalk.eu>
// SPDX-License-Identifier: MIT OR Apache-2.0

use bytes::Bytes;
use futures::TryStreamExt;
use reqwest::{header::USER_AGENT, RequestBuilder, Response, StatusCode};
use snafu::{ensure, ResultExt, Snafu};
use url::Url;

const PATH: &str = "/render";

const VERSION: &str = env!("CARGO_PKG_VERSION");
const NAME: &str = env!("CARGO_PKG_NAME");

pub use terdoc_types::{
    InputFormat, OutputFormat, PdfEngine, TaskData, Template, TerdocClient as TerdocClientTrait,
    TerdocStreamingClient as TerdocStreamingClientTrait,
};

fn default_user_agent() -> String {
    format!("{NAME}/{VERSION}")
}

#[derive(Debug, Snafu)]
pub enum Error {
    #[snafu(display("Network error: {message}"))]
    Network {
        message: String,
        source: reqwest::Error,
    },

    #[snafu(display("Invalid render task"))]
    InvalidRequest { message: String },

    #[snafu(display("Server error"))]
    Server,

    #[snafu(display("Unable to process response: {status_code}"))]
    InvalidResponse { status_code: StatusCode },
}

#[derive(Debug, Snafu)]
pub enum UrlError {
    /// The provided string is not a valid URL
    Malformed { source: url::ParseError },

    /// The provided URL is invalid
    Invalid { message: String, url: String },
}

#[derive(Debug, Clone)]
pub struct TerdocClient {
    client: reqwest::Client,
    server_url: Url,
    user_agent: String,
    middleware: Option<fn(RequestBuilder) -> RequestBuilder>,
}

impl TerdocClient {
    /// Initialize the terdoc client.
    pub fn new(addr: &str) -> Result<Self, UrlError> {
        let client = reqwest::Client::new();
        Self::with_reqwest_client(addr, client)
    }

    fn with_reqwest_client(addr: &str, client: reqwest::Client) -> Result<Self, UrlError> {
        let server_url = Url::parse(addr).context(MalformedSnafu)?;

        ensure!(
            server_url.fragment().is_none(),
            InvalidSnafu {
                message: "The URL must not have a fragment",
                url: addr
            }
        );

        ensure!(
            server_url.query().is_none(),
            InvalidSnafu {
                message: "The URL must not have query parameter",
                url: addr
            }
        );

        ensure!(
            server_url.path() == "/",
            InvalidSnafu {
                message: "The path of the URL must be empty",
                url: addr
            }
        );

        Ok(Self {
            server_url,
            client,
            user_agent: default_user_agent(),
            middleware: None,
        })
    }

    fn render_request_url(&self) -> Url {
        let mut server_url = self.server_url.clone();

        server_url.set_path(PATH);
        server_url
    }

    async fn checked_render_request(&self, task: &TaskData) -> Result<Response, Error> {
        let server_url = self.render_request_url();

        let mut req_builder = self
            .client
            .post(server_url)
            .header(USER_AGENT, &self.user_agent)
            .json(&task);
        if let Some(middleware) = &self.middleware {
            req_builder = middleware(req_builder);
        }
        let res = req_builder.send().await.context(NetworkSnafu {
            message: "Failed to connect",
        })?;
        let status = res.status();
        if status.is_success() {
            Ok(res)
        } else {
            let error_response = res
                .text()
                .await
                .unwrap_or_else(|_| "<unparsable response body>".to_string());
            log::debug!("render request failed: {} - {}", status, error_response);

            if status.is_client_error() {
                InvalidRequestSnafu {
                    message: error_response,
                }
                .fail()
            } else if status.is_server_error() {
                ServerSnafu.fail()
            } else {
                InvalidResponseSnafu {
                    status_code: status,
                }
                .fail()
            }
        }
    }

    pub fn set_user_agent(&mut self, user_agent: String) {
        self.user_agent = user_agent;
    }

    pub fn user_agent(&self) -> &str {
        &self.user_agent
    }

    pub fn set_middleware(&mut self, middleware: fn(RequestBuilder) -> RequestBuilder) {
        self.middleware = Some(middleware);
    }

    pub fn remove_middleware(&mut self) {
        self.middleware = None;
    }
}

impl terdoc_types::TerdocClient for TerdocClient {
    type Error = Error;

    async fn request_render(&mut self, task: &TaskData) -> Result<Vec<u8>, Self::Error> {
        let res = self.checked_render_request(task).await?;

        let bytes = res.bytes().await.context(NetworkSnafu {
            message: "Failed to receive response body",
        })?;
        Ok(bytes.to_vec())
    }
}

impl terdoc_types::TerdocStreamingClient for TerdocClient {
    type Error = Error;

    async fn request_render(
        &mut self,
        task: TaskData,
    ) -> Result<impl futures::Stream<Item = Result<Bytes, Self::Error>>, Self::Error> {
        let res = self.checked_render_request(&task).await?;

        let stream = res.bytes_stream().map_err(|e| Error::Network {
            message: "Failed to receive response body".to_string(),
            source: e,
        });
        Ok(stream)
    }
}