diff --git a/Cargo.toml b/Cargo.toml index 76eb116..17dff5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,5 +6,6 @@ members = [ "nats-extra", "jetstream-extra", "nats-counters", + "nats-context", ] diff --git a/nats-context/Cargo.toml b/nats-context/Cargo.toml new file mode 100644 index 0000000..b4afa67 --- /dev/null +++ b/nats-context/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "nats-context" +version = "0.1.0" +edition = "2024" +authors = ["Marcel Hauf "] +description = "NATS context API" +license = "Apache-2.0" +documentation = "https://docs.rs/nats-context" +homepage = "https://github.com/synadia-io/orbit.rs" +repository = "https://github.com/synadia-io/orbit.rs" +keywords = ["nats", "extensions", "api", "context"] +categories = ["network-programming", "api-bindings"] + +[dependencies] +async-nats = "0.46" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[dev-dependencies] +nats_server = { git = "https://github.com/nats-io/nats.rs", package = "nats-server" } diff --git a/nats-context/src/lib.rs b/nats-context/src/lib.rs new file mode 100644 index 0000000..0eb6e62 --- /dev/null +++ b/nats-context/src/lib.rs @@ -0,0 +1,210 @@ +use serde::Deserialize; +use std::fmt; +use std::path::PathBuf; +use std::{fs, io, option}; + +#[derive(Debug, Default, Deserialize, PartialEq)] +pub struct Settings { + #[serde(default)] + pub description: String, + #[serde(default)] + pub url: String, + #[serde(default)] + pub socks_proxy: String, + #[serde(default)] + pub token: String, + #[serde(default)] + pub user: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub creds: String, + #[serde(default)] + pub nkey: String, + #[serde(default)] + pub cert: String, + #[serde(default)] + pub key: String, + #[serde(default)] + pub ca: String, + #[serde(default)] + pub nsc: String, + #[serde(default)] + pub jetstream_domain: String, + #[serde(default)] + pub jetstream_api_prefix: String, + #[serde(default)] + pub jetstream_event_prefix: String, + #[serde(default)] + pub inbox_prefix: String, + #[serde(default)] + pub user_jwt: String, + #[serde(default)] + pub color_scheme: String, + #[serde(default)] + pub tls_first: bool, + #[serde(default)] + pub windows_cert_store: String, + #[serde(default)] + pub windows_cert_match_by: String, + #[serde(default)] + pub windows_cert_match: String, + pub windows_ca_certs_match: Option>, +} + +impl Settings { + /// Reads `Settings` from provided `filename`. + /// + /// # Errors + /// + /// Will return `Err` if `filename` does not exist or the could not be read. + pub fn read_from_file(filename: PathBuf) -> Result { + let ctx_file = + fs::File::open(filename).map_err(|e| Error::with_source(ErrorKind::IoError, e))?; + serde_json::from_reader(ctx_file).map_err(|e| Error::with_source(ErrorKind::SerdeError, e)) + } +} + +pub struct Context { + name: String, + settings: Settings, +} + +pub type Error = async_nats::error::Error; + +#[derive(Debug, Clone, PartialEq)] +pub enum ErrorKind { + ContextNameFileNotFound, + ContextFileNotFound, + URLNotFound, + IoError, + SerdeError, +} + +impl fmt::Display for ErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ContextNameFileNotFound => write!(f, "context.txt not found"), + Self::ContextFileNotFound => write!(f, "context file not found"), + Self::URLNotFound => write!(f, "url not found in context settings"), + Self::IoError => write!(f, "io error"), + Self::SerdeError => write!(f, "deserialization error"), + } + } +} + +impl Context { + /// Loads the selected NATS CLI Context. + /// The selected Context name is read from `$HOME/.config/nats/context.txt`. + /// Context Settings is read from `$HOME/.config/nats/context/{name}.json`. + /// + /// # Examples + /// + /// ```no_run + /// # async fn connect() -> Result<(), Box> { + /// let ctx = nats_context::Context::new()?; + /// let settings = ctx.settings(); + /// let nc = ctx.options().await?.connect(&settings.url).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Will return `Err` if context.txt file could not be loaded or the `Settings` for the selected + /// context could not be loaded. + pub fn new() -> Result { + let ctx_name_path = + context_name_path().ok_or(Error::new(ErrorKind::ContextNameFileNotFound))?; + let ctx_name = fs::read_to_string(ctx_name_path) + .map_err(|e| Error::with_source(ErrorKind::IoError, e))?; + Context::new_with_name(ctx_name.trim()) + } + + /// Loads a Context by name. + /// Context Settings is read from `$HOME/.config/nats/context/{name}.json`. + /// + /// # Errors + /// + /// Will return `Err` if context file does not exist or a `Context` could not be parsed. + pub fn new_with_name(name: &str) -> Result { + let ctx_path = context_path(name).ok_or(Error::new(ErrorKind::ContextFileNotFound))?; + let settings = Settings::read_from_file(ctx_path)?; + Ok(Context { + name: name.to_string(), + settings, + }) + } + + /// Constructs a `ConnectOptions` from parsed `Context` `Settings`. + /// + /// # Errors + /// + /// Will return `Err` if `Settings.creds` is provided and the credential file could not be + /// loaded. + pub async fn options(&self) -> io::Result { + let mut options = async_nats::ConnectOptions::new().name(&self.name); + if !self.settings.token.is_empty() { + options = options.token(self.settings.token.clone()); + } + if !self.settings.user.is_empty() && !self.settings.password.is_empty() { + options = options + .user_and_password(self.settings.user.clone(), self.settings.password.clone()); + } + if !self.settings.creds.is_empty() { + options = options.credentials_file(&self.settings.creds).await?; + } + if !self.settings.nkey.is_empty() { + options = options.nkey(self.settings.nkey.clone()); + } + if !self.settings.inbox_prefix.is_empty() { + options = options.custom_inbox_prefix(self.settings.inbox_prefix.clone()); + } + if self.settings.tls_first { + options = options.tls_first(); + } + Ok(options) + } + + /// Returns the parsed `Context` `Settings`. + pub fn settings(&self) -> &Settings { + &self.settings + } +} + +impl async_nats::ToServerAddrs for Context { + type Iter = option::IntoIter; + + /// Parses the `Context` URLs as `ServerAddrs`. + fn to_server_addrs(&self) -> io::Result { + self.settings + .url + .parse::() + .map(|addr| Some(addr).into_iter()) + } +} + +/// Returns the root nats config directory +/// `$HOME/.config/nats` as a path. +/// If $HOME can't be resolved, None is returned. +fn nats_config_dir() -> Option { + let home_dir = std::env::home_dir()?; + Some(home_dir.join(".config").join("nats")) +} + +/// Returns `$HOME/.config/nats/context.txt` as a path. +/// If $HOME can't be resolved, None is returned. +/// `context.txt` contains the selected NATS CLI context name. +fn context_name_path() -> Option { + nats_config_dir()?.join("context.txt").into() +} + +/// Returns `$HOME/.config/nats/context/{name}.json` as a path. +/// If $HOME can't be resolved, None is returned. +/// The JSON document contains NATS CLI context Settings. +fn context_path(name: &str) -> Option { + nats_config_dir()? + .join("context") + .join(format!("{name}.json")) + .into() +} diff --git a/nats-context/tests/context.rs b/nats-context/tests/context.rs new file mode 100644 index 0000000..5ed4ce1 --- /dev/null +++ b/nats-context/tests/context.rs @@ -0,0 +1,16 @@ +use std::path::PathBuf; + +use nats_context::Settings; + +#[test] +fn read_settings_test() { + let demo_path = PathBuf::from("tests/contexts/demo.json"); + let demo_settings = Settings::read_from_file(demo_path).expect("read settings"); + assert_eq!( + demo_settings, + Settings { + url: "nats://demo.nats.io:4222".to_string(), + ..Default::default() + } + ); +} diff --git a/nats-context/tests/contexts/demo.json b/nats-context/tests/contexts/demo.json new file mode 100644 index 0000000..6f8f7c0 --- /dev/null +++ b/nats-context/tests/contexts/demo.json @@ -0,0 +1,25 @@ +{ + "description": "", + "url": "nats://demo.nats.io:4222", + "socks_proxy": "", + "token": "", + "user": "", + "password": "", + "creds": "", + "nkey": "", + "cert": "", + "key": "", + "ca": "", + "nsc": "", + "jetstream_domain": "", + "jetstream_api_prefix": "", + "jetstream_event_prefix": "", + "inbox_prefix": "", + "user_jwt": "", + "color_scheme": "", + "tls_first": false, + "windows_cert_store": "", + "windows_cert_match_by": "", + "windows_cert_match": "", + "windows_ca_certs_match": null +}