Skip to main content

ez_rust_discovery/
config.rs

1use std::collections::HashMap;
2use std::env;
3
4use crate::error::{Error, Result};
5
6/// Default service group, equivalent to nacos's `DEFAULT_GROUP`.
7pub const DEFAULT_GROUP: &str = "DEFAULT_GROUP";
8
9/// Default instance weight.
10pub const DEFAULT_WEIGHT: f64 = 1.0;
11
12/// Metadata key for the gRPC port (used by clients to distinguish multi-protocol endpoints).
13pub const META_GRPC_PORT: &str = "gRPC_port";
14
15/// Names of the environment variables consumed by [`ServiceConfig::from_env`].
16pub mod env_keys {
17    /// Nacos server address (`host:port`).
18    pub const NACOS_ADDR: &str = "NACOS_ADDR";
19    /// Nacos namespace id.
20    pub const NACOS_NAMESPACE: &str = "NACOS_NAMESPACE";
21    /// Nacos auth username.
22    pub const NACOS_USERNAME: &str = "NACOS_USERNAME";
23    /// Nacos auth password.
24    pub const NACOS_PASSWORD: &str = "NACOS_PASSWORD";
25    /// Local listening address (`host:port`); only the port is used for registration.
26    pub const SERVICE_ADDR: &str = "SERVICE_ADDR";
27    /// Service name.
28    pub const SERVICE_NAME: &str = "SERVICE_NAME";
29    /// Advertised host registered to Nacos (defaults to the local IP).
30    pub const SERVICE_HOST: &str = "SERVICE_HOST";
31}
32
33/// Full configuration required to register a service instance.
34///
35/// Build one via [`ServiceConfig::builder`] or [`ServiceConfig::from_env`].
36#[derive(Debug, Clone)]
37pub struct ServiceConfig {
38    /// Nacos server address, formatted as `host:port`.
39    pub nacos_addr: String,
40    /// Nacos namespace id.
41    pub namespace: String,
42    /// Service name.
43    pub service_name: String,
44    /// Service group, defaults to [`DEFAULT_GROUP`].
45    pub group: String,
46    /// Advertised host (IP or hostname) registered to Nacos.
47    pub service_host: String,
48    /// Advertised port registered to Nacos.
49    pub service_port: u16,
50    /// Instance weight, defaults to [`DEFAULT_WEIGHT`].
51    pub weight: f64,
52    /// Whether the instance is ephemeral, defaults to `true`.
53    pub ephemeral: bool,
54    /// Auth credentials (`username`, `password`); both must be provided or neither.
55    pub auth: Option<(String, String)>,
56    /// Extra metadata. [`META_GRPC_PORT`] is auto-populated with the port unless the user
57    /// supplies their own value.
58    pub metadata: HashMap<String, String>,
59}
60
61impl ServiceConfig {
62    /// Create a fresh, empty builder.
63    pub fn builder() -> ServiceConfigBuilder {
64        ServiceConfigBuilder::default()
65    }
66
67    /// Load configuration from environment variables.
68    ///
69    /// Required: [`NACOS_ADDR`](env_keys::NACOS_ADDR), [`NACOS_NAMESPACE`](env_keys::NACOS_NAMESPACE),
70    /// [`SERVICE_ADDR`](env_keys::SERVICE_ADDR), [`SERVICE_NAME`](env_keys::SERVICE_NAME).
71    ///
72    /// Optional: [`SERVICE_HOST`](env_keys::SERVICE_HOST) (falls back to the local IP),
73    /// [`NACOS_USERNAME`](env_keys::NACOS_USERNAME) + [`NACOS_PASSWORD`](env_keys::NACOS_PASSWORD)
74    /// (both must be present, or both absent).
75    pub fn from_env() -> Result<Self> {
76        let nacos_addr = read_env(env_keys::NACOS_ADDR)?;
77        let namespace = read_env(env_keys::NACOS_NAMESPACE)?;
78        let service_addr = read_env(env_keys::SERVICE_ADDR)?;
79        let service_name = read_env(env_keys::SERVICE_NAME)?;
80        let service_host = env::var(env_keys::SERVICE_HOST).ok();
81        let username = env::var(env_keys::NACOS_USERNAME).ok();
82        let password = env::var(env_keys::NACOS_PASSWORD).ok();
83
84        let mut builder = Self::builder()
85            .nacos_addr(nacos_addr)
86            .namespace(namespace)
87            .service_name(service_name)
88            .bind_addr(service_addr)?;
89        if let Some(host) = service_host {
90            builder = builder.service_host(host);
91        }
92        match (username, password) {
93            (Some(u), Some(p)) => builder = builder.auth(u, p),
94            (None, None) => {}
95            _ => {
96                return Err(Error::invalid_config(
97                    "NACOS_USERNAME and NACOS_PASSWORD must be provided together",
98                ));
99            }
100        }
101        builder.build()
102    }
103}
104
105/// Fluent builder for [`ServiceConfig`].
106#[derive(Debug, Default, Clone)]
107pub struct ServiceConfigBuilder {
108    nacos_addr: Option<String>,
109    namespace: Option<String>,
110    service_name: Option<String>,
111    group: Option<String>,
112    service_host: Option<String>,
113    service_port: Option<u16>,
114    weight: Option<f64>,
115    ephemeral: Option<bool>,
116    auth: Option<(String, String)>,
117    metadata: HashMap<String, String>,
118}
119
120impl ServiceConfigBuilder {
121    /// Set the Nacos server address (`host:port`).
122    pub fn nacos_addr(mut self, addr: impl Into<String>) -> Self {
123        self.nacos_addr = Some(addr.into());
124        self
125    }
126
127    /// Set the namespace id.
128    pub fn namespace(mut self, ns: impl Into<String>) -> Self {
129        self.namespace = Some(ns.into());
130        self
131    }
132
133    /// Set the service name.
134    pub fn service_name(mut self, name: impl Into<String>) -> Self {
135        self.service_name = Some(name.into());
136        self
137    }
138
139    /// Set the service group (defaults to [`DEFAULT_GROUP`]).
140    pub fn group(mut self, group: impl Into<String>) -> Self {
141        self.group = Some(group.into());
142        self
143    }
144
145    /// Set the advertised host registered to Nacos (defaults to the local IP).
146    pub fn service_host(mut self, host: impl Into<String>) -> Self {
147        self.service_host = Some(host.into());
148        self
149    }
150
151    /// Set the advertised port registered to Nacos.
152    pub fn service_port(mut self, port: u16) -> Self {
153        self.service_port = Some(port);
154        self
155    }
156
157    /// Parse the port out of a `host:port` string; the host portion is **not** used (the
158    /// advertised host comes from [`service_host`](Self::service_host) or the local IP).
159    ///
160    /// Provided for compatibility with the `SERVICE_ADDR` environment variable convention.
161    pub fn bind_addr(mut self, addr: impl AsRef<str>) -> Result<Self> {
162        let addr = addr.as_ref();
163        let (host, port) = addr.rsplit_once(':').ok_or_else(|| {
164            Error::invalid_config(format!("invalid bind address `{addr}` (expect host:port)"))
165        })?;
166        if host.is_empty() {
167            return Err(Error::invalid_config(format!(
168                "invalid bind address `{addr}`: empty host"
169            )));
170        }
171        let port: u16 = port.parse().map_err(|_| {
172            Error::invalid_config(format!("invalid bind address `{addr}`: bad port"))
173        })?;
174        self.service_port = Some(port);
175        Ok(self)
176    }
177
178    /// Set the instance weight.
179    pub fn weight(mut self, weight: f64) -> Self {
180        self.weight = Some(weight);
181        self
182    }
183
184    /// Set whether the instance is ephemeral (defaults to `true`).
185    pub fn ephemeral(mut self, ephemeral: bool) -> Self {
186        self.ephemeral = Some(ephemeral);
187        self
188    }
189
190    /// Set the auth credentials.
191    pub fn auth(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
192        self.auth = Some((username.into(), password.into()));
193        self
194    }
195
196    /// Insert a single metadata entry.
197    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
198        self.metadata.insert(key.into(), value.into());
199        self
200    }
201
202    /// Insert multiple metadata entries at once.
203    pub fn metadata_all<I, K, V>(mut self, entries: I) -> Self
204    where
205        I: IntoIterator<Item = (K, V)>,
206        K: Into<String>,
207        V: Into<String>,
208    {
209        self.metadata
210            .extend(entries.into_iter().map(|(k, v)| (k.into(), v.into())));
211        self
212    }
213
214    /// Validate and build the [`ServiceConfig`].
215    pub fn build(self) -> Result<ServiceConfig> {
216        let nacos_addr = require(self.nacos_addr, "nacos_addr")?;
217        validate_host_port(&nacos_addr, "nacos_addr")?;
218        let namespace = require(self.namespace, "namespace")?;
219        let service_name = require(self.service_name, "service_name")?;
220        let service_port = require(self.service_port, "service_port")?;
221        let service_host = match self.service_host {
222            Some(h) => h,
223            None => local_ip_address::local_ip()?.to_string(),
224        };
225        let mut metadata = self.metadata;
226        metadata
227            .entry(META_GRPC_PORT.to_string())
228            .or_insert_with(|| service_port.to_string());
229
230        Ok(ServiceConfig {
231            nacos_addr,
232            namespace,
233            service_name,
234            group: self.group.unwrap_or_else(|| DEFAULT_GROUP.to_string()),
235            service_host,
236            service_port,
237            weight: self.weight.unwrap_or(DEFAULT_WEIGHT),
238            ephemeral: self.ephemeral.unwrap_or(true),
239            auth: self.auth,
240            metadata,
241        })
242    }
243}
244
245/// Read an environment variable, turning a miss into [`Error::Env`].
246fn read_env(name: &str) -> Result<String> {
247    env::var(name).map_err(|source| Error::Env {
248        name: name.to_string(),
249        source,
250    })
251}
252
253/// Ensure a required builder field is present.
254fn require<T>(value: Option<T>, field: &str) -> Result<T> {
255    value.ok_or_else(|| Error::invalid_config(format!("missing required field `{field}`")))
256}
257
258/// Validate that `addr` looks like `host:port`, where `host` may be a hostname/IP and `port`
259/// fits in a `u16`.
260fn validate_host_port(addr: &str, field: &str) -> Result<()> {
261    let (host, port) = addr.rsplit_once(':').ok_or_else(|| {
262        Error::invalid_config(format!("invalid `{field}` = `{addr}` (expect host:port)"))
263    })?;
264    if host.is_empty() {
265        return Err(Error::invalid_config(format!(
266            "invalid `{field}` = `{addr}`: empty host"
267        )));
268    }
269    port.parse::<u16>()
270        .map_err(|_| Error::invalid_config(format!("invalid `{field}` = `{addr}`: bad port")))?;
271    Ok(())
272}
273
274#[cfg(test)]
275#[allow(clippy::unwrap_used, clippy::expect_used)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn builder_requires_mandatory_fields() {
281        let err = ServiceConfig::builder().build().unwrap_err();
282        assert!(matches!(err, Error::InvalidConfig(_)));
283    }
284
285    #[test]
286    fn builder_bind_addr_extracts_port() {
287        let cfg = ServiceConfig::builder()
288            .nacos_addr("127.0.0.1:8848")
289            .namespace("public")
290            .service_name("svc")
291            .bind_addr("0.0.0.0:9000")
292            .unwrap()
293            .service_host("10.0.0.1")
294            .build()
295            .unwrap();
296        assert_eq!(cfg.service_port, 9000);
297        assert_eq!(cfg.service_host, "10.0.0.1");
298        assert_eq!(
299            cfg.metadata.get(META_GRPC_PORT).map(String::as_str),
300            Some("9000")
301        );
302    }
303
304    #[test]
305    fn builder_rejects_bad_bind_addr() {
306        let err = ServiceConfig::builder()
307            .bind_addr("no-port-here")
308            .unwrap_err();
309        assert!(matches!(err, Error::InvalidConfig(_)));
310
311        let err = ServiceConfig::builder().bind_addr(":9000").unwrap_err();
312        assert!(matches!(err, Error::InvalidConfig(_)));
313
314        let err = ServiceConfig::builder()
315            .bind_addr("host:not-a-port")
316            .unwrap_err();
317        assert!(matches!(err, Error::InvalidConfig(_)));
318    }
319
320    #[test]
321    fn validate_host_port_accepts_hostname() {
322        assert!(validate_host_port("nacos.internal:8848", "nacos_addr").is_ok());
323        assert!(validate_host_port("localhost:8848", "nacos_addr").is_ok());
324        assert!(validate_host_port("127.0.0.1:8848", "nacos_addr").is_ok());
325    }
326
327    #[test]
328    fn validate_host_port_rejects_empty_host() {
329        assert!(validate_host_port(":8848", "nacos_addr").is_err());
330    }
331
332    #[test]
333    fn metadata_user_override_takes_precedence() {
334        let cfg = ServiceConfig::builder()
335            .nacos_addr("127.0.0.1:8848")
336            .namespace("public")
337            .service_name("svc")
338            .service_host("1.2.3.4")
339            .service_port(9000)
340            .metadata(META_GRPC_PORT, "custom")
341            .build()
342            .unwrap();
343        assert_eq!(
344            cfg.metadata.get(META_GRPC_PORT).map(String::as_str),
345            Some("custom")
346        );
347    }
348}