spiral-sdk 0.1.10

Rust SDK for Spiral resource and table APIs
Documentation
//! Application registrations and organization installations.

use std::ops::Deref;
use std::sync::Arc;

use reqwest::Client as ReqwestClient;

/// Authenticated Application operations bound to an SDK connection.
pub use super::generated::apps::AuthenticatedClient as ApplicationOperations;
pub use crate::api::apps::objects::*;
use crate::auth::TokenSource;
#[cfg(feature = "tables")]
use crate::client::ScandalSelection;
use crate::http::HttpClient;
use crate::{
    Application as ApplicationHandle, ApplicationRegistration, Error,
    Installation as InstallationHandle, InstallationRegistration, List,
    UserInstallation as UserInstallationHandle,
};

pub(crate) struct ApplicationService {
    pub(crate) api_url: String,
    pub(crate) client: ApplicationOperations,
    pub(crate) auth: Option<TokenSource>,
    pub(crate) auth_url: reqwest::Url,
    pub(crate) http: ReqwestClient,
    #[cfg(feature = "tables")]
    execution: ExecutionSettings,
}

#[derive(Clone, Default)]
pub(crate) struct ExecutionSettings {
    #[cfg(feature = "tables")]
    pub(crate) scandal: Option<ScandalSelection>,
}

impl ApplicationService {
    pub(crate) fn connection(&self, auth: Option<TokenSource>) -> Result<crate::Client, Error> {
        let mut builder = crate::Client::builder()
            .api_url(&self.api_url)
            .auth_url(self.auth_url.as_str())
            .http_client(self.http.clone());
        if let Some(auth) = auth {
            builder = builder.token_source(auth);
        }
        #[cfg(feature = "tables")]
        match &self.execution.scandal {
            Some(ScandalSelection::Given(scandal)) => {
                builder = builder.scandal(Arc::clone(scandal))
            }
            Some(ScandalSelection::Remote(endpoint)) => {
                builder = builder.scandal_url(endpoint.as_str());
            }
            #[cfg(feature = "embedded")]
            Some(ScandalSelection::Embedded(scandals)) => {
                builder = builder.embedded_scandals(scandals.clone());
            }
            None => {}
        }
        builder.build()
    }
}

/// Manage application registrations, scopes and organization sign-in policy.
#[derive(Clone)]
pub struct ApplicationsClient {
    pub(crate) service: Arc<ApplicationService>,
}

/// Request, approve, suspend, resume and remove organization installations.
#[derive(Clone)]
pub struct InstallationsClient {
    pub(crate) service: Arc<ApplicationService>,
    pub(crate) connection: crate::Client,
}

pub(crate) fn clients(
    base: &str,
    auth_url: reqwest::Url,
    http: ReqwestClient,
    auth: Option<TokenSource>,
    _execution: ExecutionSettings,
) -> Result<Arc<ApplicationService>, Error> {
    let service = Arc::new(ApplicationService {
        api_url: base.to_owned(),
        client: ApplicationOperations::from_http(HttpClient::new(
            reqwest::Url::parse(base).map_err(Error::invalid)?,
            http.clone(),
            auth.clone(),
        ))?,
        http,
        auth_url,
        auth,
        #[cfg(feature = "tables")]
        execution: _execution,
    });
    Ok(service)
}

impl Deref for ApplicationsClient {
    type Target = ApplicationOperations;

    fn deref(&self) -> &Self::Target {
        &self.service.client
    }
}

impl ApplicationsClient {
    /// Register an application in a Project using this connection's credentials.
    pub async fn create(
        &self,
        project_id: impl AsRef<str>,
        request: ApplicationConfiguration,
    ) -> Result<Application, Error> {
        Ok(self
            .service
            .client
            .create_application(project_id.as_ref().to_owned(), request)
            .await?)
    }

    /// The application authenticated by the current app credential. No request is sent yet.
    pub fn current(&self) -> ApplicationHandle {
        ApplicationHandle::new(self.clone())
    }
    /// Select a registered application by ID without fetching it.
    pub(crate) fn application(&self, id: impl Into<String>) -> ApplicationRegistration {
        ApplicationRegistration::new(self.clone(), id.into())
    }
}
impl InstallationsClient {
    /// List installations in the authenticated Organization.
    pub fn list(&self) -> List<'_, Installation> {
        self.service
            .client
            .list_installations_list(Default::default())
    }

    /// The installation authenticated by the current installation credential.
    pub fn current(&self) -> InstallationHandle {
        InstallationHandle::current(self.clone())
    }
    /// Select an installation in the authenticated organization without fetching it.
    pub(crate) fn installation(&self, id: impl Into<String>) -> InstallationRegistration {
        InstallationRegistration::new(self.clone(), id.into())
    }
    /// Discover the delegated user's app installation in their current organization.
    /// Returns `None` before setup has been requested. Project reads retain this user context.
    pub async fn for_user(&self) -> Result<Option<UserInstallationHandle>, Error> {
        let found = self.service.client.get_user_installation().await?;
        Ok(found
            .installation
            .map(|info| UserInstallationHandle::new(self.clone(), info)))
    }
}

#[cfg(test)]
mod tests {
    #[cfg(feature = "tables")]
    use std::time::Duration;

    #[cfg(feature = "tables")]
    use arrow_array::{Int64Array, RecordBatch, RecordBatchIterator};
    #[cfg(feature = "tables")]
    use arrow_schema::{DataType, Field, Schema};
    #[cfg(feature = "tables")]
    use wiremock::matchers::{header, method};
    #[cfg(feature = "tables")]
    use wiremock::{Mock, MockServer, ResponseTemplate};

    use super::*;
    #[cfg(feature = "tables")]
    use crate::IntoArrowStream;
    #[cfg(feature = "tables")]
    use crate::RemoteScandal;
    #[cfg(feature = "tables")]
    use crate::auth::TokenSource;
    #[cfg(feature = "tables")]
    use crate::remote_scandal::tests::{issue_capability, unsigned_jwt};

    #[cfg(feature = "tables")]
    #[tokio::test]
    async fn installations_call_remote_scandal_with_their_own_capability() {
        let server = MockServer::start().await;
        for caller in ["app", "installation"] {
            let capability = unsigned_jwt(caller, Duration::from_secs(900));
            issue_capability(&server, caller, &capability, 1, 1).await;
        }
        let application = crate::Client::builder()
            .api_url(server.uri())
            .auth_url(server.uri())
            .scandal_url(server.uri())
            .bearer_token("app")
            .build()
            .unwrap();
        let installation = application
            .applications()
            .service
            .connection(Some(TokenSource::fixed("installation".into())))
            .unwrap();
        for client in [&application, &installation] {
            let tables = client.connection.tables().unwrap();
            tables.scandal().list_functions().await.unwrap();
        }
    }

    #[cfg(feature = "tables")]
    #[tokio::test]
    async fn installations_reuse_the_scandal_with_their_own_identity() {
        let server = MockServer::start().await;
        let scandal = RemoteScandal::new("https://scandal.example.test".parse().unwrap()).unwrap();
        let application = crate::Client::builder()
            .api_url(server.uri())
            .bearer_token("app")
            .scandal(Arc::new(scandal))
            .build()
            .unwrap();
        let installation = application
            .applications()
            .service
            .connection(Some(TokenSource::fixed("installation".into())))
            .unwrap();
        for (client, token) in [(&application, "app"), (&installation, "installation")] {
            Mock::given(method("POST"))
                .and(header("authorization", format!("Bearer {token}")))
                .respond_with(ResponseTemplate::new(403))
                .expect(1)
                .mount(&server)
                .await;
            let schema = Arc::new(Schema::new(vec![Field::new(
                "value",
                DataType::Int64,
                false,
            )]));
            let rows =
                RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![1]))])
                    .unwrap();
            let input = Box::new(RecordBatchIterator::new([Ok(rows)], schema));
            assert!(
                client
                    .connection
                    .tables()
                    .unwrap()
                    .create("demo", input.into_write_input().unwrap())
                    .await
                    .is_err()
            );
        }
    }
}