use std::collections::HashMap;
use std::env;
use crate::error::{Error, Result};
pub const DEFAULT_GROUP: &str = "DEFAULT_GROUP";
pub const DEFAULT_WEIGHT: f64 = 1.0;
pub const META_GRPC_PORT: &str = "gRPC_port";
pub mod env_keys {
pub const NACOS_ADDR: &str = "NACOS_ADDR";
pub const NACOS_NAMESPACE: &str = "NACOS_NAMESPACE";
pub const NACOS_USERNAME: &str = "NACOS_USERNAME";
pub const NACOS_PASSWORD: &str = "NACOS_PASSWORD";
pub const SERVICE_ADDR: &str = "SERVICE_ADDR";
pub const SERVICE_NAME: &str = "SERVICE_NAME";
pub const SERVICE_HOST: &str = "SERVICE_HOST";
}
#[derive(Debug, Clone)]
pub struct ServiceConfig {
pub nacos_addr: String,
pub namespace: String,
pub service_name: String,
pub group: String,
pub service_host: String,
pub service_port: u16,
pub weight: f64,
pub ephemeral: bool,
pub auth: Option<(String, String)>,
pub metadata: HashMap<String, String>,
}
impl ServiceConfig {
pub fn builder() -> ServiceConfigBuilder {
ServiceConfigBuilder::default()
}
pub fn from_env() -> Result<Self> {
let nacos_addr = read_env(env_keys::NACOS_ADDR)?;
let namespace = read_env(env_keys::NACOS_NAMESPACE)?;
let service_addr = read_env(env_keys::SERVICE_ADDR)?;
let service_name = read_env(env_keys::SERVICE_NAME)?;
let service_host = env::var(env_keys::SERVICE_HOST).ok();
let username = env::var(env_keys::NACOS_USERNAME).ok();
let password = env::var(env_keys::NACOS_PASSWORD).ok();
let mut builder = Self::builder()
.nacos_addr(nacos_addr)
.namespace(namespace)
.service_name(service_name)
.bind_addr(service_addr)?;
if let Some(host) = service_host {
builder = builder.service_host(host);
}
match (username, password) {
(Some(u), Some(p)) => builder = builder.auth(u, p),
(None, None) => {}
_ => {
return Err(Error::invalid_config(
"NACOS_USERNAME and NACOS_PASSWORD must be provided together",
));
}
}
builder.build()
}
}
#[derive(Debug, Default, Clone)]
pub struct ServiceConfigBuilder {
nacos_addr: Option<String>,
namespace: Option<String>,
service_name: Option<String>,
group: Option<String>,
service_host: Option<String>,
service_port: Option<u16>,
weight: Option<f64>,
ephemeral: Option<bool>,
auth: Option<(String, String)>,
metadata: HashMap<String, String>,
}
impl ServiceConfigBuilder {
pub fn nacos_addr(mut self, addr: impl Into<String>) -> Self {
self.nacos_addr = Some(addr.into());
self
}
pub fn namespace(mut self, ns: impl Into<String>) -> Self {
self.namespace = Some(ns.into());
self
}
pub fn service_name(mut self, name: impl Into<String>) -> Self {
self.service_name = Some(name.into());
self
}
pub fn group(mut self, group: impl Into<String>) -> Self {
self.group = Some(group.into());
self
}
pub fn service_host(mut self, host: impl Into<String>) -> Self {
self.service_host = Some(host.into());
self
}
pub fn service_port(mut self, port: u16) -> Self {
self.service_port = Some(port);
self
}
pub fn bind_addr(mut self, addr: impl AsRef<str>) -> Result<Self> {
let addr = addr.as_ref();
let (host, port) = addr.rsplit_once(':').ok_or_else(|| {
Error::invalid_config(format!("invalid bind address `{addr}` (expect host:port)"))
})?;
if host.is_empty() {
return Err(Error::invalid_config(format!(
"invalid bind address `{addr}`: empty host"
)));
}
let port: u16 = port.parse().map_err(|_| {
Error::invalid_config(format!("invalid bind address `{addr}`: bad port"))
})?;
self.service_port = Some(port);
Ok(self)
}
pub fn weight(mut self, weight: f64) -> Self {
self.weight = Some(weight);
self
}
pub fn ephemeral(mut self, ephemeral: bool) -> Self {
self.ephemeral = Some(ephemeral);
self
}
pub fn auth(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
self.auth = Some((username.into(), password.into()));
self
}
pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn metadata_all<I, K, V>(mut self, entries: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.metadata
.extend(entries.into_iter().map(|(k, v)| (k.into(), v.into())));
self
}
pub fn build(self) -> Result<ServiceConfig> {
let nacos_addr = require(self.nacos_addr, "nacos_addr")?;
validate_host_port(&nacos_addr, "nacos_addr")?;
let namespace = require(self.namespace, "namespace")?;
let service_name = require(self.service_name, "service_name")?;
let service_port = require(self.service_port, "service_port")?;
let service_host = match self.service_host {
Some(h) => h,
None => local_ip_address::local_ip()?.to_string(),
};
let mut metadata = self.metadata;
metadata
.entry(META_GRPC_PORT.to_string())
.or_insert_with(|| service_port.to_string());
Ok(ServiceConfig {
nacos_addr,
namespace,
service_name,
group: self.group.unwrap_or_else(|| DEFAULT_GROUP.to_string()),
service_host,
service_port,
weight: self.weight.unwrap_or(DEFAULT_WEIGHT),
ephemeral: self.ephemeral.unwrap_or(true),
auth: self.auth,
metadata,
})
}
}
fn read_env(name: &str) -> Result<String> {
env::var(name).map_err(|source| Error::Env {
name: name.to_string(),
source,
})
}
fn require<T>(value: Option<T>, field: &str) -> Result<T> {
value.ok_or_else(|| Error::invalid_config(format!("missing required field `{field}`")))
}
fn validate_host_port(addr: &str, field: &str) -> Result<()> {
let (host, port) = addr.rsplit_once(':').ok_or_else(|| {
Error::invalid_config(format!("invalid `{field}` = `{addr}` (expect host:port)"))
})?;
if host.is_empty() {
return Err(Error::invalid_config(format!(
"invalid `{field}` = `{addr}`: empty host"
)));
}
port.parse::<u16>()
.map_err(|_| Error::invalid_config(format!("invalid `{field}` = `{addr}`: bad port")))?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn builder_requires_mandatory_fields() {
let err = ServiceConfig::builder().build().unwrap_err();
assert!(matches!(err, Error::InvalidConfig(_)));
}
#[test]
fn builder_bind_addr_extracts_port() {
let cfg = ServiceConfig::builder()
.nacos_addr("127.0.0.1:8848")
.namespace("public")
.service_name("svc")
.bind_addr("0.0.0.0:9000")
.unwrap()
.service_host("10.0.0.1")
.build()
.unwrap();
assert_eq!(cfg.service_port, 9000);
assert_eq!(cfg.service_host, "10.0.0.1");
assert_eq!(
cfg.metadata.get(META_GRPC_PORT).map(String::as_str),
Some("9000")
);
}
#[test]
fn builder_rejects_bad_bind_addr() {
let err = ServiceConfig::builder()
.bind_addr("no-port-here")
.unwrap_err();
assert!(matches!(err, Error::InvalidConfig(_)));
let err = ServiceConfig::builder().bind_addr(":9000").unwrap_err();
assert!(matches!(err, Error::InvalidConfig(_)));
let err = ServiceConfig::builder()
.bind_addr("host:not-a-port")
.unwrap_err();
assert!(matches!(err, Error::InvalidConfig(_)));
}
#[test]
fn validate_host_port_accepts_hostname() {
assert!(validate_host_port("nacos.internal:8848", "nacos_addr").is_ok());
assert!(validate_host_port("localhost:8848", "nacos_addr").is_ok());
assert!(validate_host_port("127.0.0.1:8848", "nacos_addr").is_ok());
}
#[test]
fn validate_host_port_rejects_empty_host() {
assert!(validate_host_port(":8848", "nacos_addr").is_err());
}
#[test]
fn metadata_user_override_takes_precedence() {
let cfg = ServiceConfig::builder()
.nacos_addr("127.0.0.1:8848")
.namespace("public")
.service_name("svc")
.service_host("1.2.3.4")
.service_port(9000)
.metadata(META_GRPC_PORT, "custom")
.build()
.unwrap();
assert_eq!(
cfg.metadata.get(META_GRPC_PORT).map(String::as_str),
Some("custom")
);
}
}