Skip to main content

fmp_rust_sdk/
config.rs

1//! Explicit configuration from the process environment or a selected dotenv file.
2
3use std::{env, path::Path};
4
5use crate::{Client, Error, Plan, Result, Subscription, WebSocketEntitlements};
6
7struct ConfigValues {
8    api_key: Option<String>,
9    plan: Option<String>,
10}
11
12impl Client {
13    /// Creates a client from `FMP_API_KEY` and optional `FMP_PLAN` in the process environment.
14    ///
15    /// The API key is required. A missing or blank plan uses the free plan
16    /// (`Plan::Basic`). WebSocket cluster grants are supplied explicitly.
17    pub fn from_env(websocket_entitlements: WebSocketEntitlements) -> Result<Self> {
18        let values = ConfigValues {
19            api_key: environment_value("FMP_API_KEY")?,
20            plan: environment_value("FMP_PLAN")?,
21        };
22        configured_client(values, "process environment", websocket_entitlements)
23    }
24
25    /// Creates a client from the selected dotenv file without populating the process environment.
26    ///
27    /// The file must contain `FMP_API_KEY`. Missing or blank `FMP_PLAN` uses the
28    /// free plan (`Plan::Basic`). Unrelated entries are ignored, and duplicate FMP
29    /// settings return an error. Quoting and variable expansion follow `dotenvy` syntax.
30    ///
31    /// ```no_run
32    /// use std::path::Path;
33    /// use fmp_rust_sdk::{Client, WebSocketEntitlements};
34    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
35    /// let client = Client::from_dotenv(Path::new(".env"), WebSocketEntitlements::none())?;
36    /// # Ok(())
37    /// # }
38    /// ```
39    pub fn from_dotenv(path: &Path, websocket_entitlements: WebSocketEntitlements) -> Result<Self> {
40        let mut entries =
41            dotenvy::from_path_iter(path).map_err(|error| dotenv_error(path, error))?;
42        let values = entries.try_fold(
43            ConfigValues {
44                api_key: None,
45                plan: None,
46            },
47            |values, entry| {
48                let (name, value) = entry.map_err(|error| dotenv_error(path, error))?;
49                match name.as_str() {
50                    "FMP_API_KEY" => {
51                        if values.api_key.is_some() {
52                            return Err(duplicate_setting(path, "FMP_API_KEY"));
53                        }
54                        Ok(ConfigValues {
55                            api_key: Some(value),
56                            ..values
57                        })
58                    }
59                    "FMP_PLAN" => {
60                        if values.plan.is_some() {
61                            return Err(duplicate_setting(path, "FMP_PLAN"));
62                        }
63                        Ok(ConfigValues {
64                            plan: Some(value),
65                            ..values
66                        })
67                    }
68                    _ => Ok(values),
69                }
70            },
71        )?;
72        configured_client(
73            values,
74            &format!("dotenv file '{}'", path.display()),
75            websocket_entitlements,
76        )
77    }
78}
79
80fn environment_value(name: &str) -> Result<Option<String>> {
81    match env::var(name) {
82        Ok(value) => Ok(Some(value)),
83        Err(env::VarError::NotPresent) => Ok(None),
84        Err(env::VarError::NotUnicode(_)) => Err(Error::Configuration {
85            reason: format!("{name} in the process environment must contain valid Unicode"),
86        }),
87    }
88}
89
90fn configured_client(
91    values: ConfigValues,
92    source: &str,
93    websocket_entitlements: WebSocketEntitlements,
94) -> Result<Client> {
95    let api_key = values.api_key.ok_or_else(|| Error::Configuration {
96        reason: format!(
97            "FMP_API_KEY is missing from {source}; add the key from your FMP dashboard"
98        ),
99    })?;
100    let plan = values
101        .plan
102        .filter(|value| !value.trim().is_empty())
103        .map(|value| {
104            value.parse::<Plan>().map_err(|_| Error::Configuration {
105                reason: format!(
106                    "FMP_PLAN in {source} is unrecognized; use basic (or free), starter, premium, ultimate, build, enterprise, or leave it blank for the free plan"
107                ),
108            })
109        })
110        .transpose()?
111        .unwrap_or(Plan::Basic);
112    Client::new(
113        &api_key,
114        Subscription::new(Some(plan), websocket_entitlements),
115    )
116    .map_err(|error| match error {
117        Error::Configuration { reason } => Error::Configuration {
118            reason: format!("cannot configure FMP_API_KEY from {source}: {reason}"),
119        },
120        error => error,
121    })
122}
123
124fn duplicate_setting(path: &Path, name: &str) -> Error {
125    Error::Configuration {
126        reason: format!(
127            "dotenv file '{}' contains duplicate {name} assignments; keep exactly one",
128            path.display()
129        ),
130    }
131}
132
133/// The parser's Display and Debug include raw lines, which can contain credentials.
134fn dotenv_error(path: &Path, error: dotenvy::Error) -> Error {
135    let reason = match error {
136        dotenvy::Error::LineParse(_, index) => format!(
137            "invalid dotenv syntax at byte index {index} within an assignment; check KEY=VALUE syntax and matching quotes"
138        ),
139        dotenvy::Error::Io(error) => format!("file could not be read: {error}"),
140        dotenvy::Error::EnvVar(env::VarError::NotPresent) => {
141            "a referenced environment variable is missing".to_owned()
142        }
143        dotenvy::Error::EnvVar(env::VarError::NotUnicode(_)) => {
144            "a referenced environment variable is not valid Unicode".to_owned()
145        }
146        _ => "the dotenv parser returned an unsupported error variant".to_owned(),
147    };
148    Error::Configuration {
149        reason: format!("cannot read dotenv file '{}': {reason}", path.display()),
150    }
151}