octocrate 0.1.2

A library of github API
Documentation
use crate::{GithubAPI, GithubApp, GithubWebhookEvent};
use actix_web::{post, web, App, HttpRequest, HttpResponse, HttpServer, Responder};
use octocrate_infra::{GithubError, GithubResult};
use tokio::sync::{mpsc, oneshot};

pub type GithubWebhookEventListener = Box<
    dyn Fn(GithubWebhookEvent, GithubAPI) -> Result<(), Box<dyn std::error::Error>> + Send + Sync,
>;

#[derive(Debug)]
pub enum Message {
    WebhookEvent(GithubWebhookEvent),
    ServerHandle(actix_web::dev::ServerHandle),
    Stop(oneshot::Sender<()>),
}

#[derive(Clone, Debug)]
pub struct ServerHandle {
    msg_tx: mpsc::Sender<Message>,
}

pub struct WebhookServer {
    github_app: GithubApp,
    server_handle: ServerHandle,
    msg_rx: mpsc::Receiver<Message>,
    listeners: Vec<GithubWebhookEventListener>,
}

pub struct WebhookServerBuilder {
    app_id: Option<String>,
    private_key: Option<String>,
    base_url: Option<String>,
}

impl ServerHandle {
    pub fn new(msg_tx: mpsc::Sender<Message>) -> Self {
        Self { msg_tx }
    }

    pub async fn trigger_webhook_event(&self, event: GithubWebhookEvent) -> GithubResult<()> {
        self.msg_tx.send(Message::WebhookEvent(event)).await?;

        Ok(())
    }

    pub async fn stop(&self) -> GithubResult<()> {
        let (tx, rx) = oneshot::channel::<()>();

        self.msg_tx.send(Message::Stop(tx)).await?;

        rx.await?;

        Ok(())
    }
}

impl WebhookServer {
    pub fn builder() -> WebhookServerBuilder {
        WebhookServerBuilder::new()
    }

    pub fn new(
        app_id: impl Into<String>,
        private_key: impl Into<String>,
        base_url: impl Into<String>,
    ) -> Self {
        let github_app = GithubApp::new(app_id, private_key, base_url);

        let (tx, rx) = mpsc::channel::<Message>(20);
        Self {
            github_app,
            listeners: Vec::new(),
            server_handle: ServerHandle::new(tx),
            msg_rx: rx,
        }
    }

    pub fn server_handle(&self) -> ServerHandle {
        self.server_handle.clone()
    }

    pub async fn start(&mut self) -> GithubResult<()> {
        let msg_tx = self.server_handle.msg_tx.clone();

        tokio::spawn(async move {
            #[post("/github/webhook")]
            async fn webhook(
                payload: web::Bytes,
                req: HttpRequest,
                sender: web::Data<mpsc::UnboundedSender<Message>>,
            ) -> impl Responder {
                let event_name = req.headers().get("X-GitHub-Event").unwrap();
                let payload = String::from_utf8(payload.to_vec()).unwrap();

                let event_name = event_name.to_str().unwrap();
                let event = GithubWebhookEvent::try_parse(event_name.to_string(), payload).unwrap();

                sender.send(Message::WebhookEvent(event)).unwrap();

                HttpResponse::Ok()
            }

            let port = std::env::var("PORT").unwrap_or(String::from("3000"));
            let port = port.parse::<u16>().unwrap();

            let tx = msg_tx.clone();

            let srv = HttpServer::new(move || {
                return App::new()
                    .service(webhook)
                    .app_data(web::Data::new(msg_tx.clone()));
            })
            .bind(("127.0.0.1", port))
            .unwrap()
            .run();

            let handle = srv.handle();

            tx.send(Message::ServerHandle(handle.clone()))
                .await
                .unwrap();

            println!("Server started on http://localhost:{}", port);

            srv.await.unwrap();
        });

        let mut server_handle: Option<actix_web::dev::ServerHandle> = None;

        loop {
            tokio::select! {
              msg = self.msg_rx.recv() => {
                  if let Some(msg) = msg {
                    match msg {
                      Message::WebhookEvent(event) => {
                          let installation = event.installation();
                          match self.github_app.get_api(installation.id).await {
                            Ok(api) => {
                              for listener in &self.listeners {
                                if let Err(error) = listener(event.clone(), api.clone()) {
                                    eprintln!("Error: {}", error);
                                }
                              }
                            }
                            Err(error) => {
                              eprintln!("Error on WebhookServer.start(): {}", error);
                            }
                          };
                      }
                      #[allow(unused_assignments)]
                      Message::ServerHandle(handle) => {
                          server_handle = Some(handle);
                      }
                      Message::Stop(tx) => {
                        if let Some(handle) = server_handle.clone() {
                          handle.stop(true).await;
                        }
                        tx.send(()).unwrap();

                        break;
                      }
                  }
                }
              }

              v = tokio::signal::ctrl_c() => {
                if let Ok(_) = v {
                  if let Some(handle) = server_handle.clone() {
                    handle.stop(true).await;
                  }
                  break;
                }
              }
            };
        }

        Ok(())
    }

    pub fn on_webhook_event<F>(&mut self, listener: F) -> &mut Self
    where
        F: Fn(GithubWebhookEvent, GithubAPI) -> Result<(), Box<dyn std::error::Error>>,
        F: Send + Sync + 'static,
    {
        self.listeners.push(Box::new(listener));

        self
    }
}

impl WebhookServerBuilder {
    pub fn new() -> Self {
        Self {
            app_id: None,
            private_key: None,
            base_url: None,
        }
    }

    pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
        self.app_id = Some(app_id.into());
        self
    }

    pub fn private_key(mut self, private_key: impl Into<String>) -> Self {
        self.private_key = Some(private_key.into());
        self
    }

    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = Some(base_url.into());
        self
    }

    pub fn build(self) -> GithubResult<WebhookServer> {
        let app_id = self.app_id.ok_or_else(|| {
            GithubError::new("app_id is required to create a WebhookServer".to_string())
        })?;

        let private_key = self.private_key.ok_or_else(|| {
            GithubError::new("private_key is required to create a WebhookServer".to_string())
        })?;

        let base_url = self
            .base_url
            .unwrap_or_else(|| "https://api.github.com".to_string());

        let app = WebhookServer::new(app_id, private_key, base_url);

        Ok(app)
    }
}

#[cfg(test)]
mod tests {
    use crate::test_utils;
    use octocrate_infra::GithubResult;

    #[tokio::test]
    #[ignore]
    async fn start() -> GithubResult<()> {
        let mut app = test_utils::create_github_app()?;

        app.start().await?;

        Ok(())
    }

    #[tokio::test]
    async fn get_installations() -> GithubResult<()> {
        let app = test_utils::create_github_app()?;

        let _installations = app.get_installations().await?;

        Ok(())
    }

    #[tokio::test]
    async fn get_installation() -> GithubResult<()> {
        let app = test_utils::create_github_app()?;
        let envs = test_utils::load_test_envs()?;

        let installation = app.get_installation(envs.installation_id).await?;

        assert_eq!(installation.id, envs.installation_id);
        Ok(())
    }

    #[tokio::test]
    async fn get_installation_access_token() -> GithubResult<()> {
        let mut app = test_utils::create_github_app()?;
        let envs = test_utils::load_test_envs()?;
        let installation_id = envs.installation_id;

        let access_token = app
            .get_installation_access_token(installation_id.clone())
            .await?;

        let access_token1 = app.get_installation_access_token(installation_id).await?;

        assert_eq!(access_token.expires_at, access_token1.expires_at);

        Ok(())
    }

    #[tokio::test]
    async fn get_repository_installation() -> GithubResult<()> {
        let app = test_utils::create_github_app()?;
        let envs = test_utils::load_test_envs()?;
        let github_app_id = envs.github_app_id;

        let installation = app
            .get_repository_installation(envs.repo_owner.clone(), envs.repo_name.clone())
            .await?;

        assert_eq!(installation.app_id.to_string(), github_app_id);

        Ok(())
    }
}