rovenue/
config.rs

1use crate::error::{RovenueError, RovenueResult};
2use crate::logging::LogLevel;
3
4/// Canonical hosted endpoint used when the caller does not supply a base URL.
5/// MUST stay in sync with the `base_url` default literal in `librovenue.udl`.
6pub const DEFAULT_BASE_URL: &str = "https://api.rovenue.io";
7
8#[derive(Debug, Clone)]
9pub struct Config {
10    pub api_key: String,
11    pub base_url: String,
12    pub log_level: LogLevel,
13    /// Host app's user-facing version string (CFBundleShortVersionString on iOS,
14    /// PackageInfo.versionName on Android). Forwarded into session-event
15    /// telemetry payloads. `None` is serialized as `""` to preserve the
16    /// pre-0.7 wire format.
17    pub app_version: Option<String>,
18    /// Runtime platform the SDK is running on (`ios` / `android` / `web`),
19    /// supplied by the native façade. Sent as the `X-Rovenue-Platform` header
20    /// so the backend can record it as the subscriber's first-install platform
21    /// on create. `None` omits the header entirely.
22    pub platform: Option<String>,
23    /// Remote Config environment (`prod` / `staging` / `development`). Sent as
24    /// the `X-Rovenue-Env` header on the `/v1/config` request so the backend
25    /// serves the matching flag/experiment set. `None` omits the header, which
26    /// the backend treats as `prod`.
27    pub environment: Option<String>,
28}
29
30impl Config {
31    pub fn new(api_key: String, base_url: String) -> RovenueResult<Self> {
32        if api_key.trim().is_empty() {
33            return Err(RovenueError::InvalidApiKey());
34        }
35        Ok(Self {
36            api_key,
37            base_url: resolve_base_url(&base_url)?,
38            log_level: LogLevel::Warn,
39            app_version: None,
40            platform: None,
41            environment: None,
42        })
43    }
44
45    /// Builder-style setter for the host app version.
46    pub fn with_app_version(mut self, app_version: Option<String>) -> Self {
47        self.app_version = app_version;
48        self
49    }
50
51    /// Builder-style setter for the runtime platform.
52    pub fn with_platform(mut self, platform: Option<String>) -> Self {
53        self.platform = platform;
54        self
55    }
56
57    /// Builder-style setter for the Remote Config environment.
58    pub fn with_environment(mut self, environment: Option<String>) -> Self {
59        self.environment = environment;
60        self
61    }
62
63    /// Normalize + validate a `Config` built directly from the UniFFI
64    /// dictionary. The FFI path constructs the struct field-by-field and
65    /// bypasses `new`, so this is where the base-URL rules get enforced over
66    /// the FFI boundary. Empty `base_url` falls back to the hosted default.
67    pub fn normalized(mut self) -> RovenueResult<Self> {
68        self.base_url = resolve_base_url(&self.base_url)?;
69        Ok(self)
70    }
71}
72
73/// The Android emulator's alias for the host machine's loopback. Unroutable
74/// outside an emulator, so allowing plain http to it opens no real target —
75/// and it is the only way an emulator reaches a local dev API.
76const ANDROID_EMULATOR_HOST_LOOPBACK: &str = "10.0.2.2";
77
78/// Hosts a plain `http://` base URL may name (local development only).
79const LOCAL_HTTP_HOSTS: &[&str] = &["localhost", "127.0.0.1", ANDROID_EMULATOR_HOST_LOOPBACK];
80
81/// Resolve a caller-supplied base URL:
82/// - blank (after trim) → [`DEFAULT_BASE_URL`]
83/// - `https://…` accepted
84/// - `http://…` accepted ONLY for localhost / 127.0.0.1 / [::1] and the
85///   Android emulator's host alias 10.0.2.2 (local dev)
86/// - anything else → [`RovenueError::InvalidArgument()`]
87pub fn resolve_base_url(input: &str) -> RovenueResult<String> {
88    let trimmed = input.trim();
89    let url = if trimmed.is_empty() {
90        DEFAULT_BASE_URL
91    } else {
92        trimmed
93    };
94
95    if let Some(rest) = url.strip_prefix("https://") {
96        if rest.is_empty() {
97            return Err(RovenueError::InvalidArgument());
98        }
99        return Ok(url.to_string());
100    }
101
102    if let Some(host) = url.strip_prefix("http://") {
103        let is_local = host.starts_with("[::1]")
104            || LOCAL_HTTP_HOSTS.iter().any(|local| {
105                host == *local
106                    || host.starts_with(&format!("{local}:"))
107                    || host.starts_with(&format!("{local}/"))
108            });
109        if is_local {
110            return Ok(url.to_string());
111        }
112        return Err(RovenueError::InvalidArgument());
113    }
114
115    Err(RovenueError::InvalidArgument())
116}