Skip to main content

obz_core/
provider.rs

1//! Provider interface definitions.
2//!
3//! This module defines the contracts between the CLI and backend providers:
4//!
5//! - [`traits`] — `MetricProvider`, `LogProvider`, `TraceProvider` trait definitions
6//! - [`params`] — Query parameter types passed to provider methods
7//! - [`results`] — Result types returned by provider methods
8//! - [`ProviderConfig`] — Dynamic key-value configuration passed to provider factories
9//!
10//! Concrete provider implementations live in the `obz-providers` crate.
11
12pub mod params;
13pub mod results;
14pub mod traits;
15
16use std::collections::BTreeMap;
17use std::time::Duration;
18
19use crate::model::error::{ErrorCode, ObzError};
20
21// Re-export commonly used items at the module level.
22pub use params::{
23    ExtensionParams, LabelValuesParams, LogSearchParams, MetricInfoParams, MetricMetadataParams,
24    MetricQueryParams, TraceGetParams, TraceSearchParams,
25};
26pub use results::{
27    ExtensionResult, LogSearchResult, MetricQueryResult, MetricResultType, ProviderResult,
28    TraceSearchResult,
29};
30pub use traits::{ExtensionProvider, LogProvider, MetricProvider, TraceProvider};
31
32/// Dynamic configuration values resolved from config file profiles and
33/// CLI flags, passed to provider factory functions.
34///
35/// Configuration is organized into four namespaces:
36///
37/// - **values** — Connection parameters and provider-specific settings
38///   (endpoint, project, logstore, index, …).
39/// - **auth** — Authentication credentials (token, username, password,
40///   access-key-id, api-key, …). Empty-string values are treated as
41///   absent.
42/// - **headers** — Custom HTTP headers injected into every request.
43///   Keys are stored in lowercase.
44/// - **verbose** / **timeout** — Promoted from string-key conventions
45///   to typed fields.
46///
47/// # Security
48///
49/// [`Debug`] is manually implemented:
50/// - The entire `auth` map is printed as `[REDACTED]`.
51/// - Header values containing sensitive substrings (`token`, `secret`,
52///   `key`, `auth`) are individually redacted.
53/// - `values` uses [`is_sensitive_key`] for per-key redaction.
54///
55/// # Example
56///
57/// ```
58/// use obz_core::ProviderConfig;
59///
60/// let mut config = ProviderConfig::new();
61/// config.set("endpoint", "http://localhost:8428");
62/// config.set_auth("token", "my-secret");
63///
64/// assert_eq!(config.get("endpoint"), Some("http://localhost:8428"));
65/// assert_eq!(config.bearer_token(), Some("my-secret".to_string()));
66/// assert!(config.require("endpoint").is_ok());
67/// assert!(config.require("missing").is_err());
68///
69/// // Debug output redacts auth entirely
70/// let debug = format!("{:?}", config);
71/// assert!(debug.contains("[REDACTED]"));
72/// assert!(!debug.contains("my-secret"));
73/// ```
74#[derive(Clone)]
75pub struct ProviderConfig {
76    /// Connection parameters and provider-specific settings.
77    values: BTreeMap<String, String>,
78    /// Authentication credentials. Empty-string values are treated as absent.
79    auth: BTreeMap<String, String>,
80    /// Custom HTTP headers (keys stored in lowercase).
81    headers: BTreeMap<String, String>,
82    /// Whether verbose/debug output is enabled.
83    verbose: bool,
84    /// HTTP client timeout.
85    timeout: Option<Duration>,
86}
87
88impl ProviderConfig {
89    /// Create an empty configuration.
90    pub fn new() -> Self {
91        Self {
92            values: BTreeMap::new(),
93            auth: BTreeMap::new(),
94            headers: BTreeMap::new(),
95            verbose: false,
96            timeout: None,
97        }
98    }
99
100    // ── Values (connection parameters) ──────────────────────
101
102    /// Set a configuration value, returning `&mut Self` for chaining.
103    ///
104    /// If the key already exists, its value is overwritten.
105    pub fn set(&mut self, key: &str, value: impl Into<String>) -> &mut Self {
106        self.values.insert(key.to_string(), value.into());
107        self
108    }
109
110    /// Get a configuration value by key.
111    ///
112    /// Returns `None` if the key is not present.
113    pub fn get(&self, key: &str) -> Option<&str> {
114        self.values.get(key).map(String::as_str)
115    }
116
117    /// Get a cloned configuration value by key.
118    ///
119    /// Convenience method for cases where an owned `String` is needed
120    /// (e.g., passing to a constructor that takes `Option<String>`).
121    pub fn get_owned(&self, key: &str) -> Option<String> {
122        self.values.get(key).cloned()
123    }
124
125    /// Get a required configuration value by key.
126    ///
127    /// # Errors
128    ///
129    /// Returns [`ObzError::InvalidArgument`] with [`ErrorCode::MissingRequired`]
130    /// if the key is not present. The error message suggests setting the value
131    /// in `config.yaml`.
132    pub fn require(&self, key: &str) -> Result<&str, ObzError> {
133        self.get(key).ok_or_else(|| ObzError::InvalidArgument {
134            code: ErrorCode::MissingRequired,
135            message: format!("--{key} is required"),
136            suggestion: None,
137        })
138    }
139
140    /// Get a required provider configuration value by key.
141    ///
142    /// Like [`require`](Self::require), but the error message includes a
143    /// hint pointing the user to `config.yaml`. Use this for provider
144    /// config fields (e.g. `endpoint`) that are typically set in the
145    /// config file rather than on the command line.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`ObzError::InvalidArgument`] with [`ErrorCode::MissingRequired`]
150    /// if the key is not present.
151    pub fn require_config(&self, key: &str) -> Result<&str, ObzError> {
152        self.get(key).ok_or_else(|| ObzError::InvalidArgument {
153            code: ErrorCode::MissingRequired,
154            message: format!(
155                "--{key} is required. Set it in config.yaml under your provider's config block"
156            ),
157            suggestion: None,
158        })
159    }
160
161    // ── Auth (credentials) ──────────────────────────────────
162
163    /// Set an auth credential value.
164    ///
165    /// Empty strings are stored but [`auth_get`](Self::auth_get) treats
166    /// them as absent.
167    pub fn set_auth(&mut self, key: &str, value: impl Into<String>) -> &mut Self {
168        self.auth.insert(key.to_string(), value.into());
169        self
170    }
171
172    /// Get an auth credential value by key.
173    ///
174    /// Returns `None` if the key is missing **or** its value is an empty
175    /// string (empty auth values are treated as absent to prevent
176    /// accidental empty-credential requests).
177    pub fn auth_get(&self, key: &str) -> Option<&str> {
178        self.auth
179            .get(key)
180            .map(String::as_str)
181            .filter(|v| !v.is_empty())
182    }
183
184    /// Get a cloned auth credential value by key.
185    ///
186    /// Returns `None` if the key is missing or empty.
187    pub fn auth_get_owned(&self, key: &str) -> Option<String> {
188        self.auth_get(key).map(str::to_string)
189    }
190
191    /// Get a required auth credential value by key.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`ObzError::InvalidArgument`] with [`ErrorCode::MissingRequired`]
196    /// if the key is missing or empty. The error message directs the user to
197    /// set the value in `config.yaml` under `providers.<name>.auth.<key>`.
198    pub fn auth_require(&self, key: &str) -> Result<&str, ObzError> {
199        self.auth_get(key)
200            .ok_or_else(|| auth_missing_error(key, ""))
201    }
202
203    /// Get the bearer token from auth credentials.
204    ///
205    /// Shorthand for `self.auth_get_owned("token")`.
206    pub fn bearer_token(&self) -> Option<String> {
207        self.auth_get_owned("token")
208    }
209
210    /// Extract HTTP Basic Auth credentials (`username`, `password`) from
211    /// the auth map.
212    ///
213    /// Returns `None` when either key is missing or empty, which means
214    /// the provider should skip basic-auth and fall through to other auth
215    /// methods (e.g., bearer token).
216    pub fn basic_auth(&self) -> Option<(String, String)> {
217        match (self.auth_get("username"), self.auth_get("password")) {
218            (Some(u), Some(p)) => Some((u.to_string(), p.to_string())),
219            _ => None,
220        }
221    }
222
223    // ── Headers ─────────────────────────────────────────────
224
225    /// Set a custom HTTP header. The key is automatically lowercased.
226    pub fn set_header(&mut self, key: &str, value: impl Into<String>) -> &mut Self {
227        self.headers.insert(key.to_ascii_lowercase(), value.into());
228        self
229    }
230
231    /// Return the custom headers map.
232    pub fn custom_headers(&self) -> &BTreeMap<String, String> {
233        &self.headers
234    }
235
236    // ── Verbose / Timeout ───────────────────────────────────
237
238    /// Set the verbose flag.
239    pub fn set_verbose(&mut self, verbose: bool) {
240        self.verbose = verbose;
241    }
242
243    /// Check whether verbose/debug output is enabled.
244    pub fn verbose(&self) -> bool {
245        self.verbose
246    }
247
248    /// Set the HTTP client timeout.
249    pub fn set_timeout(&mut self, timeout: Duration) {
250        self.timeout = Some(timeout);
251    }
252
253    /// Get the HTTP client timeout.
254    pub fn timeout(&self) -> Option<Duration> {
255        self.timeout
256    }
257}
258
259impl Default for ProviderConfig {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265/// Returns `true` if a key name likely holds a sensitive value.
266///
267/// Matches keys containing `token`, `password`, `secret` as substrings,
268/// and keys ending with `-key` or equal to `key` (to avoid false positives
269/// on keys like `"monkey"` or `"hotkey"`).
270pub fn is_sensitive_key(key: &str) -> bool {
271    let k = key.to_ascii_lowercase();
272    k.contains("token")
273        || k.contains("password")
274        || k.contains("secret")
275        || k == "key"
276        || k.ends_with("-key")
277}
278
279/// Create a standardized error for missing auth credentials.
280///
281/// Produces a consistent error message directing the user to set the
282/// credential in `config.yaml` under the provider's `auth` section.
283pub fn auth_missing_error(key: &str, provider_type: &str) -> ObzError {
284    let hint = if provider_type.is_empty() {
285        format!("Set it in config.yaml: providers.<name>.auth.{key}")
286    } else {
287        format!(
288            "Set it in config.yaml: providers.<name>.auth.{key} (provider type: {provider_type})"
289        )
290    };
291    ObzError::InvalidArgument {
292        code: ErrorCode::MissingRequired,
293        message: format!("{key} is required. {hint}"),
294        suggestion: None,
295    }
296}
297
298impl std::fmt::Debug for ProviderConfig {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        let mut s = f.debug_struct("ProviderConfig");
301
302        // Values: per-key redaction using is_sensitive_key.
303        let redacted_values: BTreeMap<&str, &str> = self
304            .values
305            .iter()
306            .map(|(k, v)| {
307                if is_sensitive_key(k) {
308                    (k.as_str(), "[REDACTED]")
309                } else {
310                    (k.as_str(), v.as_str())
311                }
312            })
313            .collect();
314        s.field("values", &redacted_values);
315
316        // Auth: entire map is redacted.
317        if self.auth.is_empty() {
318            s.field("auth", &"{}");
319        } else {
320            s.field("auth", &"[REDACTED]");
321        }
322
323        // Headers: selective redaction.
324        let redacted_headers: BTreeMap<&str, &str> = self
325            .headers
326            .iter()
327            .map(|(k, v)| {
328                if is_sensitive_key(k) {
329                    (k.as_str(), "[REDACTED]")
330                } else {
331                    (k.as_str(), v.as_str())
332                }
333            })
334            .collect();
335        s.field("headers", &redacted_headers);
336
337        s.field("verbose", &self.verbose);
338        s.field("timeout", &self.timeout);
339        s.finish()
340    }
341}
342
343/// Observability signal type.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum Signal {
346    /// Metrics signal.
347    Metric,
348    /// Logs signal.
349    Log,
350    /// Traces signal.
351    Trace,
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    // ── Values tests ───────────────────────────────────────
359
360    #[test]
361    fn test_get_returns_value_when_key_exists() {
362        let mut config = ProviderConfig::new();
363        config.set("endpoint", "http://localhost:8428");
364        assert_eq!(config.get("endpoint"), Some("http://localhost:8428"));
365    }
366
367    #[test]
368    fn test_get_returns_none_when_key_missing() {
369        let config = ProviderConfig::new();
370        assert_eq!(config.get("endpoint"), None);
371    }
372
373    #[test]
374    fn test_get_owned_returns_cloned_value() {
375        let mut config = ProviderConfig::new();
376        config.set("project", "abc");
377        assert_eq!(config.get_owned("project"), Some("abc".to_string()));
378        assert_eq!(config.get_owned("missing"), None);
379    }
380
381    #[test]
382    fn test_require_returns_value_when_key_exists() {
383        let mut config = ProviderConfig::new();
384        config.set("endpoint", "http://localhost:8428");
385        assert_eq!(config.require("endpoint").unwrap(), "http://localhost:8428");
386    }
387
388    #[test]
389    fn test_require_returns_error_when_key_missing() {
390        let config = ProviderConfig::new();
391        let err = config.require("endpoint").unwrap_err();
392        match err {
393            ObzError::InvalidArgument { code, message, .. } => {
394                assert_eq!(code, ErrorCode::MissingRequired);
395                assert!(message.contains("--endpoint"));
396            }
397            _ => panic!("expected InvalidArgument, got {err:?}"),
398        }
399    }
400
401    #[test]
402    fn test_require_config_returns_value_when_key_exists() {
403        let mut config = ProviderConfig::new();
404        config.set("endpoint", "http://localhost:8428");
405        assert_eq!(
406            config.require_config("endpoint").unwrap(),
407            "http://localhost:8428"
408        );
409    }
410
411    #[test]
412    fn test_require_config_error_includes_config_hint() {
413        let config = ProviderConfig::new();
414        let err = config.require_config("endpoint").unwrap_err();
415        match err {
416            ObzError::InvalidArgument { code, message, .. } => {
417                assert_eq!(code, ErrorCode::MissingRequired);
418                assert!(message.contains("--endpoint"));
419                assert!(
420                    message.contains("config.yaml"),
421                    "require_config error should mention config.yaml, got: {message}"
422                );
423            }
424            _ => panic!("expected InvalidArgument, got {err:?}"),
425        }
426    }
427
428    #[test]
429    fn test_require_error_does_not_mention_config() {
430        let config = ProviderConfig::new();
431        let err = config.require("query").unwrap_err();
432        match err {
433            ObzError::InvalidArgument { message, .. } => {
434                assert!(
435                    !message.contains("config.yaml"),
436                    "require() should not mention config.yaml, got: {message}"
437                );
438            }
439            _ => panic!("expected InvalidArgument, got {err:?}"),
440        }
441    }
442
443    #[test]
444    fn test_set_overwrites_existing_value() {
445        let mut config = ProviderConfig::new();
446        config.set("endpoint", "http://old");
447        config.set("endpoint", "http://new");
448        assert_eq!(config.get("endpoint"), Some("http://new"));
449    }
450
451    #[test]
452    fn test_set_supports_chaining() {
453        let mut config = ProviderConfig::new();
454        config
455            .set("endpoint", "http://localhost")
456            .set("project", "abc");
457        assert_eq!(config.get("endpoint"), Some("http://localhost"));
458        assert_eq!(config.get("project"), Some("abc"));
459    }
460
461    // ── Auth tests ─────────────────────────────────────────
462
463    #[test]
464    fn test_auth_get_returns_value() {
465        let mut config = ProviderConfig::new();
466        config.set_auth("token", "my-token");
467        assert_eq!(config.auth_get("token"), Some("my-token"));
468    }
469
470    #[test]
471    fn test_auth_get_returns_none_when_missing() {
472        let config = ProviderConfig::new();
473        assert_eq!(config.auth_get("token"), None);
474    }
475
476    #[test]
477    fn test_auth_get_returns_none_for_empty_string() {
478        let mut config = ProviderConfig::new();
479        config.set_auth("token", "");
480        assert_eq!(config.auth_get("token"), None);
481    }
482
483    #[test]
484    fn test_auth_get_owned_returns_cloned_value() {
485        let mut config = ProviderConfig::new();
486        config.set_auth("api-key", "abc123");
487        assert_eq!(config.auth_get_owned("api-key"), Some("abc123".to_string()));
488        assert_eq!(config.auth_get_owned("missing"), None);
489    }
490
491    #[test]
492    fn test_auth_require_returns_value() {
493        let mut config = ProviderConfig::new();
494        config.set_auth("token", "secret");
495        assert_eq!(config.auth_require("token").unwrap(), "secret");
496    }
497
498    #[test]
499    fn test_auth_require_returns_error_when_missing() {
500        let config = ProviderConfig::new();
501        let err = config.auth_require("token").unwrap_err();
502        match err {
503            ObzError::InvalidArgument { code, message, .. } => {
504                assert_eq!(code, ErrorCode::MissingRequired);
505                assert!(message.contains("token"));
506                assert!(message.contains("config.yaml"));
507            }
508            _ => panic!("expected InvalidArgument, got {err:?}"),
509        }
510    }
511
512    #[test]
513    fn test_auth_require_returns_error_for_empty_string() {
514        let mut config = ProviderConfig::new();
515        config.set_auth("token", "");
516        assert!(config.auth_require("token").is_err());
517    }
518
519    #[test]
520    fn test_bearer_token_returns_token_from_auth() {
521        let mut config = ProviderConfig::new();
522        config.set_auth("token", "bearer-secret");
523        assert_eq!(config.bearer_token(), Some("bearer-secret".to_string()));
524    }
525
526    #[test]
527    fn test_bearer_token_returns_none_when_missing() {
528        let config = ProviderConfig::new();
529        assert_eq!(config.bearer_token(), None);
530    }
531
532    #[test]
533    fn test_basic_auth_returns_credentials_when_both_present() {
534        let mut config = ProviderConfig::new();
535        config.set_auth("username", "admin");
536        config.set_auth("password", "secret");
537        assert_eq!(
538            config.basic_auth(),
539            Some(("admin".to_string(), "secret".to_string()))
540        );
541    }
542
543    #[test]
544    fn test_basic_auth_returns_none_when_username_missing() {
545        let mut config = ProviderConfig::new();
546        config.set_auth("password", "secret");
547        assert_eq!(config.basic_auth(), None);
548    }
549
550    #[test]
551    fn test_basic_auth_returns_none_when_password_missing() {
552        let mut config = ProviderConfig::new();
553        config.set_auth("username", "admin");
554        assert_eq!(config.basic_auth(), None);
555    }
556
557    #[test]
558    fn test_basic_auth_returns_none_when_both_missing() {
559        let config = ProviderConfig::new();
560        assert_eq!(config.basic_auth(), None);
561    }
562
563    #[test]
564    fn test_basic_auth_returns_none_when_empty_strings() {
565        let mut config = ProviderConfig::new();
566        config.set_auth("username", "");
567        config.set_auth("password", "secret");
568        assert_eq!(config.basic_auth(), None);
569    }
570
571    // ── Headers tests ──────────────────────────────────────
572
573    #[test]
574    fn test_set_header_lowercases_key() {
575        let mut config = ProviderConfig::new();
576        config.set_header("X-Scope-OrgID", "my-tenant");
577        assert_eq!(
578            config.custom_headers().get("x-scope-orgid"),
579            Some(&"my-tenant".to_string())
580        );
581    }
582
583    #[test]
584    fn test_custom_headers_returns_all_headers() {
585        let mut config = ProviderConfig::new();
586        config.set_header("x-custom", "value1");
587        config.set_header("x-other", "value2");
588        assert_eq!(config.custom_headers().len(), 2);
589    }
590
591    // ── Verbose / Timeout tests ────────────────────────────
592
593    #[test]
594    fn test_verbose_returns_true_when_set() {
595        let mut config = ProviderConfig::new();
596        config.set_verbose(true);
597        assert!(config.verbose());
598    }
599
600    #[test]
601    fn test_verbose_returns_false_by_default() {
602        let config = ProviderConfig::new();
603        assert!(!config.verbose());
604    }
605
606    #[test]
607    fn test_timeout_returns_none_by_default() {
608        let config = ProviderConfig::new();
609        assert_eq!(config.timeout(), None);
610    }
611
612    #[test]
613    fn test_timeout_returns_value_when_set() {
614        let mut config = ProviderConfig::new();
615        config.set_timeout(Duration::from_secs(30));
616        assert_eq!(config.timeout(), Some(Duration::from_secs(30)));
617    }
618
619    // ── Debug / redaction tests ────────────────────────────
620
621    #[test]
622    fn test_debug_redacts_auth_entirely() {
623        let mut config = ProviderConfig::new();
624        config.set("endpoint", "http://localhost");
625        config.set_auth("token", "super-secret-token");
626        config.set_auth("password", "hunter2");
627
628        let debug = format!("{config:?}");
629
630        assert!(!debug.contains("super-secret-token"));
631        assert!(!debug.contains("hunter2"));
632        assert!(debug.contains("[REDACTED]"));
633        assert!(debug.contains("http://localhost"));
634    }
635
636    #[test]
637    fn test_debug_redacts_sensitive_value_keys() {
638        let mut config = ProviderConfig::new();
639        config.set("endpoint", "http://localhost");
640        config.set("access-key-secret", "should-be-redacted");
641
642        let debug = format!("{config:?}");
643        assert!(!debug.contains("should-be-redacted"));
644        assert!(debug.contains("[REDACTED]"));
645    }
646
647    #[test]
648    fn test_debug_preserves_non_sensitive_values() {
649        let mut config = ProviderConfig::new();
650        config.set("endpoint", "http://localhost");
651        config.set("project", "my-project");
652        config.set("region", "cn-hangzhou");
653
654        let debug = format!("{config:?}");
655        assert!(debug.contains("http://localhost"));
656        assert!(debug.contains("my-project"));
657        assert!(debug.contains("cn-hangzhou"));
658    }
659
660    #[test]
661    fn test_debug_redacts_sensitive_header_values() {
662        let mut config = ProviderConfig::new();
663        config.set_header("x-auth-token", "secret-header-val");
664        config.set_header("x-custom", "visible-val");
665
666        let debug = format!("{config:?}");
667        assert!(!debug.contains("secret-header-val"));
668        assert!(debug.contains("visible-val"));
669    }
670
671    #[test]
672    fn test_sensitive_key_detection_avoids_false_positives() {
673        let mut config = ProviderConfig::new();
674        config.set("monkey", "banana");
675        config.set("hotkey-binding", "ctrl+c");
676        config.set("keyboard", "us-layout");
677
678        let debug = format!("{config:?}");
679        assert!(debug.contains("banana"));
680        assert!(debug.contains("ctrl+c"));
681        assert!(debug.contains("us-layout"));
682    }
683
684    #[test]
685    fn test_default() {
686        let config = ProviderConfig::default();
687        assert_eq!(config.get("anything"), None);
688        assert!(!config.verbose());
689        assert_eq!(config.timeout(), None);
690    }
691
692    // ── auth_missing_error tests ───────────────────────────
693
694    #[test]
695    fn test_auth_missing_error_without_provider_type() {
696        let err = auth_missing_error("token", "");
697        match err {
698            ObzError::InvalidArgument { code, message, .. } => {
699                assert_eq!(code, ErrorCode::MissingRequired);
700                assert!(message.contains("token"));
701                assert!(message.contains("config.yaml"));
702            }
703            _ => panic!("expected InvalidArgument"),
704        }
705    }
706
707    #[test]
708    fn test_auth_missing_error_with_provider_type() {
709        let err = auth_missing_error("api-key", "dd");
710        match err {
711            ObzError::InvalidArgument { message, .. } => {
712                assert!(message.contains("api-key"));
713                assert!(message.contains("dd"));
714            }
715            _ => panic!("expected InvalidArgument"),
716        }
717    }
718}