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}