gw_bin/triggers/
http.rs

1use super::{Trigger, TriggerError};
2use crate::context::Context;
3use log::{debug, info};
4use std::{collections::HashMap, sync::mpsc::Sender};
5use thiserror::Error;
6use tiny_http::{Response, Server};
7
8const TRIGGER_NAME: &str = "HTTP";
9
10/// A trigger that runs on an HTTP request.
11///
12/// This could be used to trigger checks from git remotes (e.g. GitHub, GitLab) with webhooks.
13/// Given that your server can be reached from the outside, you can pass your server's hostname
14/// or IP address and have actions running on git changes immediately.
15pub struct HttpTrigger {
16    http: String,
17}
18
19/// Custom error describing the error cases for the HttpTrigger.
20#[derive(Debug, Error)]
21pub enum HttpError {
22    /// Initializing the HTTP server failed. It usually means the configuration was incorrect
23    /// or the port was already allocated.
24    #[error("cannot start server on {0}")]
25    CantStartServer(String),
26    /// Cannot send trigger with Sender. This usually because the receiver is dropped.
27    #[error("cannot trigger changes, receiver hang up")]
28    ReceiverHangup(#[from] std::sync::mpsc::SendError<Option<Context>>),
29    /// Failed to send response.
30    #[error("failed to send response")]
31    FailedResponse(#[from] std::io::Error),
32}
33
34impl From<HttpError> for TriggerError {
35    fn from(val: HttpError) -> Self {
36        match val {
37            HttpError::CantStartServer(s) => TriggerError::Misconfigured(s),
38            HttpError::ReceiverHangup(s) => TriggerError::ReceiverHangup(s),
39            HttpError::FailedResponse(s) => TriggerError::FailedTrigger(s.to_string()),
40        }
41    }
42}
43
44impl HttpTrigger {
45    /// Create an new HTTP trigger with a HTTP url. It accepts an address as a string,
46    /// for example "0.0.0.0:1234".
47    pub fn new(http: String) -> Self {
48        Self { http }
49    }
50
51    fn listen_inner(&self, tx: Sender<Option<Context>>) -> Result<(), HttpError> {
52        let listener =
53            Server::http(&self.http).map_err(|_| HttpError::CantStartServer(self.http.clone()))?;
54        info!("Listening on {}...", self.http);
55        for request in listener.incoming_requests() {
56            debug!("Received request on {} {}", request.method(), request.url());
57
58            let context: Context = HashMap::from([
59                ("TRIGGER_NAME", TRIGGER_NAME.to_string()),
60                ("HTTP_METHOD", request.method().to_string()),
61                ("HTTP_URL", request.url().to_string()),
62            ]);
63            tx.send(Some(context)).map_err(HttpError::from)?;
64
65            request.respond(Response::from_string("OK"))?;
66        }
67        Ok(())
68    }
69}
70
71impl Trigger for HttpTrigger {
72    /// Starts a minimal HTTP 1.1 server, that triggers on every request.
73    ///
74    /// Every method and every URL triggers and returns 200 status code with plaintext "OK".
75    fn listen(&self, tx: Sender<Option<Context>>) -> Result<(), TriggerError> {
76        self.listen_inner(tx)?;
77
78        Ok(())
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use std::{
86        error::Error,
87        sync::mpsc,
88        thread::{self, sleep},
89        time::Duration,
90    };
91
92    #[test]
93    fn it_should_be_created_from_http_url() {
94        let trigger = HttpTrigger::new(String::from("0.0.0.0:1234"));
95        assert_eq!("0.0.0.0:1234", &trigger.http);
96    }
97
98    #[test]
99    fn it_should_return_ok_on_every_request() -> Result<(), Box<dyn Error>> {
100        let trigger = HttpTrigger::new(String::from("0.0.0.0:10101"));
101        let (tx, rx) = mpsc::channel::<Option<Context>>();
102
103        thread::spawn(move || {
104            let _ = trigger.listen_inner(tx);
105        });
106
107        // Sleep for the HTTP server to start up.
108        sleep(Duration::from_millis(100));
109
110        let result = ureq::get("http://localhost:10101").call()?;
111        assert_eq!(200, result.status());
112        assert_eq!("OK", result.into_body().read_to_string()?);
113
114        let result = ureq::post("http://localhost:10101/trigger").send_empty()?;
115        assert_eq!(200, result.status());
116        assert_eq!("OK", result.into_body().read_to_string()?);
117
118        let msg = rx.recv()?;
119        let context = msg.unwrap();
120        assert_eq!(TRIGGER_NAME, context.get("TRIGGER_NAME").unwrap());
121        assert_eq!("GET", context.get("HTTP_METHOD").unwrap());
122        assert_eq!("/", context.get("HTTP_URL").unwrap());
123
124        let msg = rx.recv()?;
125        let context = msg.unwrap();
126        assert_eq!(TRIGGER_NAME, context.get("TRIGGER_NAME").unwrap());
127        assert_eq!("POST", context.get("HTTP_METHOD").unwrap());
128        assert_eq!("/trigger", context.get("HTTP_URL").unwrap());
129
130        Ok(())
131    }
132
133    #[test]
134    fn it_should_fail_if_http_url_invalid() {
135        let trigger = HttpTrigger::new(String::from("aaaaa"));
136
137        let (tx, _rx) = mpsc::channel::<Option<Context>>();
138
139        let result = trigger.listen_inner(tx);
140        assert!(
141            matches!(result, Err(HttpError::CantStartServer(_))),
142            "{result:?} should be CantStartServer"
143        )
144    }
145
146    #[test]
147    fn it_should_fail_if_sending_fails() -> Result<(), Box<dyn Error>> {
148        let trigger = HttpTrigger::new(String::from("0.0.0.0:10102"));
149        let (tx, rx) = mpsc::channel::<Option<Context>>();
150
151        thread::spawn(move || {
152            // Sleep for the HTTP server to start up.
153            sleep(Duration::from_millis(200));
154
155            let _ = ureq::get("http://localhost:10102").call();
156        });
157
158        // Drop receiver to create a hangup error
159        drop(rx);
160
161        let result = trigger.listen_inner(tx);
162        assert!(
163            matches!(result, Err(HttpError::ReceiverHangup(_))),
164            "{result:?} should be ReceiverHangup"
165        );
166
167        Ok(())
168    }
169}