diff options
Diffstat (limited to 'src/config.rs')
-rw-r--r-- | src/config.rs | 251 |
1 files changed, 251 insertions, 0 deletions
diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e70043e --- /dev/null +++ b/src/config.rs @@ -0,0 +1,251 @@ +use std::path::{Path, PathBuf}; +use std::fs::{self, File}; +use std::io; +use std::fmt::{self, Display}; +use serde::Deserialize; + +/// All the application configuration is stored in this structure. +#[derive(PartialEq, Clone, Debug)] +pub struct Config { + /// The secret string shared with GitHub that is used to verify signed requests. + pub secret: String, + + /// Event-command pairs. Each element of this array should be matched (and optionally executed) + /// against the commands in gaide. + pub commands: Vec<Command>, +} + +impl Config { + pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> { + let raw_config = RawConfig::from_path(path)?; + let secret = fs::read_to_string(raw_config.secret_file)?; + Ok(Config { + secret, + commands: raw_config.commands, + }) + } +} + +/// This struct reflects the actual JSON on disk. It is further processed before being returned to +/// the rest of the application. +#[derive(Deserialize, Clone, Debug, PartialEq)] +struct RawConfig { + /// Path to file containing the secret that was shared with GitHub. + secret_file: PathBuf, + + /// Event-command pairs. + commands: Vec<Command>, +} + +/// Represents an event-command pair. The command is run whenever the given event is received from +/// GitHub's API. +#[derive(Deserialize, Clone, Debug, PartialEq)] +pub struct Command { + /// The name of an event from the GitHub API. A full list of events can be found in [GitHub's + /// documenation][gh-events]. + /// + /// [gh-events]: https://docs.github.com/en/webhooks/webhook-events-and-payloads + pub event: String, + + /// Path to the program to be executed when [`event`](event) occurs. + pub command: String, + + /// Additional arguments to bass to [`command`](command). + #[serde(default)] + pub args: Vec<String>, +} + +/* +/// Serde helper which disallows empty strings for [`PathBuf`s](std::path::PathBuf). Based on [this +/// StackOverflow post][so]. +/// +/// [so]: https://stackoverflow.com/a/46755370 +fn string_as_nonempty_pathbuf<'de, D>(deserializer: D) -> Result<PathBuf, D::Error> +where + D: Deserializer<'de> +{ + let raw: &str = Deserialize::deserialize(deserializer)?; + if raw.is_empty() { + Err(de::Error::custom("path cannot be empty")) + } else { + Ok(PathBuf::from(raw)) + } +} +*/ + +/// Errors that can occur when reading configuration. +#[derive(Debug)] +pub enum ConfigError { + /// An IO error occured, such as failing to read the file. + Io(io::Error), + /// Decoding the file failed, e.g. if JSON is missing comma. + SerdeError(serde_json::Error), +} + +impl From<io::Error> for ConfigError { + fn from(e: io::Error) -> ConfigError { + ConfigError::Io(e) + } +} + +impl From<serde_json::Error> for ConfigError { + fn from(e: serde_json::Error) -> ConfigError { + use serde_json::error::Category; + match e.classify() { + Category::Io => ConfigError::Io(e.into()), + _ => ConfigError::SerdeError(e), + } + } +} + +impl Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + match self { + ConfigError::Io(e) => write!(f, "io error: {}", e), + ConfigError::SerdeError(e) => write!(f, "decoding error: {}", e), + } + } +} + +impl RawConfig { + pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> { + let file = File::open(path.as_ref())?; + let config: Self = serde_json::from_reader(file)?; + config.validate()?; + Ok(config) + } + + #[allow(dead_code)] // Useful for tests. + pub(self) fn from_str(s: &str) -> Result<Self, ConfigError> { + let config: Self = serde_json::from_str(s)?; + config.validate()?; + Ok(config) + } + + fn validate(&self) -> Result<(), ConfigError> { + if self.secret_file.is_relative() { + eprintln!("warning: configuration key `.secret_file` is relative path. This will be resolved relative to server's CWD at runtime which is most likely not what you want."); + // " <- Fix shitty Vim syntax highlighting + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{Config, Command, RawConfig, ConfigError}; + use std::path::Path; + + macro_rules! assert_matches { + ( $e:expr , $pat:pat ) => { + assert_matches!($e, $pat => ()) + }; + ( $e:expr , $pat:pat => $c:expr ) => { + match $e { + $pat => $c, + ref e => panic!("assertion failed: `{:?}` does not match `{}`", + e, stringify!($pat)) + } + }; + } + + macro_rules! assert_contains { + ( $a:expr , $b:expr ) => { + let a_string: String = $a.to_string(); + let b_string: String = $b.to_string(); + + if !a_string.contains(&b_string) { + panic!("assertion failed: expected {:?} to contain {:?}", a_string, b_string) + } + }; + } + + #[test] + fn load_valid_raw_config() { + let config_json = r#" + { + "secret_file": "/path/to/secret.txt", + + "commands": [ + { + "event": "ping", + "command": "/usr/bin/handle-ping", + "args": [] + } + ] + } + "#; + let parsed_config = RawConfig::from_str(config_json).expect("valid config"); + let expected_config = RawConfig { + secret_file: Path::new("/path/to/secret.txt").to_path_buf(), + commands: vec![ + Command { + event: "ping".to_string(), + command: "/usr/bin/handle-ping".to_string(), + args: vec![], + }, + ], + }; + assert_eq!(parsed_config, expected_config); + } + + #[test] + fn args_are_optional() { + let command_json = r#" + { + "event": "ping", + "command": "/usr/bin/handle-ping" + } + "#; + let parsed_command: Command = serde_json::from_str(command_json) + .expect("valid configuration"); + let expected_command = Command { + event: "ping".to_string(), + command: "/usr/bin/handle-ping".to_string(), + args: vec![], + }; + assert_eq!(expected_command, parsed_command); + } + + #[test] + fn invalid_json_gives_error() { + // This JSON has a trailing comma, which isn't allowed. + let config_json = r#" + { + "secret_file": "blah", + "commands": [], + } + "#; + let result = RawConfig::from_str(config_json); + let err = assert_matches!(result, Err(ConfigError::SerdeError(e)) => e); + assert_eq!(err.line(), 5); + assert_eq!(err.column(), 13); + assert!(err.is_syntax()); + } + + #[test] + fn read_valid_config() { + let parse_result = Config::from_path("examples/config.json"); + let parsed_config = assert_matches!(parse_result, Ok(c @ Config { .. }) => c); + let expected_config = Config { + secret: "mysecret".to_string(), + commands: vec![ + Command { + event: "ping".to_string(), + command: "/bin/echo".to_string(), + args: vec![ + "Got ping event!!".to_string() + ], + }, + Command { + event: "push".to_string(), + command: "/bin/echo".to_string(), + args: vec![ + "Got push event!!".to_string() + ], + }, + ], + }; + assert_eq!(parsed_config, expected_config); + } +} |