integresql 0.1.1

Rust client for the IntegreSQL Postgres testing tool
Documentation
use http::response::Response;
use http::uri::Uri;
use http::{Request, StatusCode};
use log::{debug, error, info};
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use thiserror::Error;
use ureq::middleware::MiddlewareNext;
use ureq::{Agent, Body, ResponseExt, SendBody};

use crate::server_models::*;

const CONNECT_TIMEOUT: Duration = Duration::from_millis(800);

// Some IntegreSQL operations, like getting a test DB, can block for 5 seconds
// when pool maintenance occurs, so the default timeout is set to 6 seconds to
// tolerate that
pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(6);

const EMPTY_STRING: &str = "";

const API_VERSION: &str = "v1";

const JSON_CONTENT_TYPE: &str = "application/json";

/// The various error types that can be returned by an IntegreSQL request.
#[derive(Debug, Error)]
pub enum IntegresqlError {
    /// Indicates that the IntegreSQL server cannot connect to Postgres.
    #[error("Postgres DB not ready")]
    ManagerNotReady,

    /// Indicates that the requested template or test database was not found.
    #[error("template or test database not found")]
    NotFound,

    /// Indicates that this client could not connect to the IntegreSQL API
    /// server.
    #[error("error connecting to IntegreSQL server: {0}")]
    NetworkError(String),

    /// Indicates that an error occurred while executing the setup function
    /// for a template database.
    #[error("error while setting up template DB: {0}")]
    SetupError(String),

    /// Indicates that some other error occurred while processing the request.
    #[error("{0}")]
    OtherError(String),
}

fn log_request(
    req: Request<SendBody>,
    next: MiddlewareNext,
) -> Result<Response<Body>, ureq::Error> {
    let start = Instant::now();
    info!(
        "Sending request to IntegreSQL method={} uri={}",
        req.method(),
        req.uri()
    );
    let result = next.handle(req);
    let duration = start.elapsed();
    if duration > Duration::from_secs(1) {
        log::warn!(
            "Slow response from IntegreSQL response_status={} uri={} duration_seconds={:.2?}",
            result.as_ref().map(|r| r.status().as_u16()).unwrap_or(0),
            result.as_ref().map(|r| r.get_uri().path()).unwrap_or(""),
            duration
        );
    }
    result
}

pub(crate) enum InitializeTemplateResult {
    Success(Database),
    TemplateAlreadyInitialized,
    Err(IntegresqlError),
}

/// Low-level client for interacting with an IntegreSQL server.
///
/// This client allows users to manage both Postgres template databases and test
/// databases based on those templates.
/// 
/// Client is cheap to clone- no heap allocations needed. The underlying ureq::Agent
/// will reuse its connection pool for all clones.
#[derive(Clone)]
pub(crate) struct Client {
    base_url: Arc<str>,
    http_agent: Agent,
}

impl std::fmt::Debug for Client {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Client")
            .field("base_url", &self.base_url)
            .finish()
    }
}

impl Client {
    pub fn new(base_url: Uri, timeout: Duration) -> Self {
        let http_agent = Agent::config_builder()
            .timeout_connect(Some(CONNECT_TIMEOUT))
            .timeout_global(Some(timeout))
            //.timeout_recv_response(Some(timeout))
            .http_status_as_error(false)
            .middleware(log_request)
            .build()
            .new_agent();
        Self {
            base_url: Arc::from(base_url.to_string()),
            http_agent,
        }
    }

    /// Drops all test databases.
    ///
    /// This method drops all existing test databases from Postgres and deregisters all
    /// template databases from the IntegreSQL server. It does not *drop* the template
    /// databases themselves, though. As such, it will disallow creation of new test
    /// databases from any existing templates, but will when intitializing new templates,
    /// will reuse an existing template database if one already exists with the same
    /// template hash.
    /// 
    /// To fully drop and deregister a template database, use `discard_template`.
    pub fn clear_db_tracking(&self) -> Result<(), IntegresqlError> {
        let resp = self.do_delete("admin/templates")?;
        match resp.status {
            StatusCode::NO_CONTENT => Ok(()),
            _ => {
                error!("Failed to clear template databases: {}", resp.body);
                Err(IntegresqlError::OtherError(resp.body))
            }
        }
    }

    /// Tells IntegreSQL to create a blank new template database with the given hash, and returns
    /// credentials to connect to it.
    ///
    /// Callers should use the credentials to initialize the template database,
    /// i.e. create any tables, functions, data, etc that the template should
    /// contain. Once setup is complete, callers should call `finalize_template`
    /// to mark the template as ready for use (or `discard_template` to discard
    /// it if setup failed).
    pub fn initialize_template(&self, template_hash: TemplateHash) -> InitializeTemplateResult {
        let payload = TemplateRequest {
            hash: template_hash,
        };
        let resp = match self.do_post("templates", &payload) {
            Ok(resp) => resp,
            Err(e) => {
                error!("Failed to initialize template database: {}", e);
                return InitializeTemplateResult::Err(e);
            }
        };
        match resp.status {
            StatusCode::OK => {
                let result: Result<InitializeTemplateResponse, IntegresqlError> =
                    resp.parse_result();
                match result {
                    Ok(result) => InitializeTemplateResult::Success(result.database),
                    Err(e) => InitializeTemplateResult::Err(e),
                }
            }
            StatusCode::LOCKED => {
                debug!(
                    "Skipping template setup, template {} is already initialized",
                    template_hash
                );
                InitializeTemplateResult::TemplateAlreadyInitialized
            }
            _ => InitializeTemplateResult::Err(resp.into_error()),
        }
    }

    /// Tells IntegreSQL that this template database has been initialized and is ready
    /// to be cloned into test databases.
    pub fn finalize_template(&self, template_hash: TemplateHash) -> Result<(), IntegresqlError> {
        let endpoint = format!("templates/{}", template_hash);
        self.do_put(&endpoint, &Value::Null)?.into_empty_result()
    }

    /// Deletes the template database with the given hash.
    ///
    /// This prevents the creation of new test databases from this template, but
    /// does not delete any existing test databases that have already been
    /// created from this template.
    pub fn discard_template(&self, template_hash: TemplateHash) -> Result<(), IntegresqlError> {
        let endpoint = format!("templates/{}", template_hash);
        let result = self.do_delete(&endpoint)?;
        match result.status {
            StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => Ok(()),
            _ => Err(result.into_error()),
        }
    }

    /// Returns credentials to connect to a test database created from the
    /// indicated template database.
    pub fn get_test_db(
        &self,
        template_hash: TemplateHash,
    ) -> Result<GetTestDbResponse, IntegresqlError> {
        let endpoint = format!("templates/{}/tests", template_hash);
        self.do_get(&endpoint)?.parse_result()
    }

    /// Causes the indicated test database to be dropped and recreated
    ///
    /// This method should be called by callers that have modified the test database
    /// once they are done using it.
    pub fn recreate_test_db(
        &self,
        template_hash: TemplateHash,
        id: i32,
    ) -> Result<(), IntegresqlError> {
        let endpoint = format!("templates/{}/tests/{}/recreate", template_hash, id);
        self.do_post(&endpoint, &EMPTY_STRING)?.into_empty_result()
    }

    /// Unlocks the indicated test database, allowing it to be used by other clients.
    ///
    /// This is faster than recreating the test database, but should only be
    /// done if the caller has not modified it.
    pub fn unlock_test_db(
        &self,
        template_hash: TemplateHash,
        id: i32,
    ) -> Result<(), IntegresqlError> {
        let endpoint = format!("templates/{}/tests/{}/unlock", template_hash, id);
        self.do_post(&endpoint, &EMPTY_STRING)?.into_empty_result()
    }

    fn do_get(&self, endpoint: &str) -> Result<ServerResponse, IntegresqlError> {
        self.http_agent
            .get(self.get_full_endpoint(endpoint))
            .header("Accept", JSON_CONTENT_TYPE)
            .call()
            .map_err(|e| IntegresqlError::NetworkError(e.to_string()))
            .map(|r| r.into())
    }

    fn do_post<T>(&self, endpoint: &str, body: &T) -> Result<ServerResponse, IntegresqlError>
    where
        T: serde::Serialize,
    {
        self.http_agent
            .post(self.get_full_endpoint(endpoint))
            .header("Content-Type", JSON_CONTENT_TYPE)
            .header("Accept", JSON_CONTENT_TYPE)
            .send_json(body)
            .map_err(|e| IntegresqlError::NetworkError(e.to_string()))
            .map(|r| r.into())
    }

    fn do_put<T>(&self, endpoint: &str, body: &T) -> Result<ServerResponse, IntegresqlError>
    where
        T: serde::Serialize,
    {
        self.http_agent
            .put(self.get_full_endpoint(endpoint))
            .header("Content-Type", JSON_CONTENT_TYPE)
            .header("Accept", JSON_CONTENT_TYPE)
            .send_json(body)
            .map_err(|e| IntegresqlError::NetworkError(e.to_string()))
            .map(|r| r.into())
    }

    fn do_delete(&self, endpoint: &str) -> Result<ServerResponse, IntegresqlError> {
        self.http_agent
            .delete(self.get_full_endpoint(endpoint))
            .header("Accept", JSON_CONTENT_TYPE)
            .call()
            .map_err(|e| IntegresqlError::NetworkError(e.to_string()))
            .map(|r| r.into())
    }

    fn get_full_endpoint(&self, endpoint: &str) -> String {
        let full_url = format!("{}/{}/{}", self.base_url, API_VERSION, endpoint);
        full_url
    }
}

struct ServerResponse {
    status: StatusCode,
    body: String,
}

impl From<Response<Body>> for ServerResponse {
    fn from(val: Response<Body>) -> Self {
        debug!("Received response from IntegreSQL: {}", val.status());
        ServerResponse {
            status: val.status(),
            body: val.into_body().read_to_string().unwrap_or_default(),
        }
    }
}

impl ServerResponse {
    fn into_error(self) -> IntegresqlError {
        match self.status {
            StatusCode::NOT_FOUND => {
                error!("Got 404 response from IntegreSQL: {}", self.body);
                IntegresqlError::NotFound
            }
            StatusCode::SERVICE_UNAVAILABLE => {
                error!("Got 503 response from IntegreSQL: {}", self.body);
                IntegresqlError::ManagerNotReady
            }
            _ => {
                error!(
                    "Got unexpected HTTP response from IntegreSQL: {} - {}",
                    self.status, self.body
                );
                IntegresqlError::OtherError(self.body)
            }
        }
    }

    fn into_empty_result(self) -> Result<(), IntegresqlError> {
        match self.status {
            StatusCode::NO_CONTENT => Ok(()),
            _ => Err(self.into_error()),
        }
    }

    fn parse_result<T>(self) -> Result<T, IntegresqlError>
    where
        T: serde::de::DeserializeOwned,
    {
        match self.status {
            StatusCode::OK => {
                let parsed: T = serde_json::from_str(&self.body).map_err(|e| {
                    IntegresqlError::NetworkError(format!("failed to parse response JSON: {}", e))
                })?;
                Ok(parsed)
            }
            _ => Err(self.into_error()),
        }
    }
}