Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ members = [
"nats-extra",
"jetstream-extra",
"nats-counters",
"nats-context",
]

20 changes: 20 additions & 0 deletions nats-context/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "nats-context"
version = "0.1.0"
edition = "2024"
authors = ["Marcel Hauf <github@marcelhauf.name>"]
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" }
210 changes: 210 additions & 0 deletions nats-context/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<String>>,
}

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<Self, Error> {
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<ErrorKind>;

#[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<dyn std::error::Error>> {
/// 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<Self, Error> {
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<Self, Error> {
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<async_nats::ConnectOptions> {
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<async_nats::ServerAddr>;

/// Parses the `Context` URLs as `ServerAddrs`.
fn to_server_addrs(&self) -> io::Result<Self::Iter> {
self.settings
.url
.parse::<async_nats::ServerAddr>()
.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<std::path::PathBuf> {
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<std::path::PathBuf> {
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<std::path::PathBuf> {
nats_config_dir()?
.join("context")
.join(format!("{name}.json"))
.into()
}
16 changes: 16 additions & 0 deletions nats-context/tests/context.rs
Original file line number Diff line number Diff line change
@@ -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()
}
);
}
25 changes: 25 additions & 0 deletions nats-context/tests/contexts/demo.json
Original file line number Diff line number Diff line change
@@ -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
}