Skip to main content

nautilus_betfair/common/
credential.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Betfair API credential storage.
17
18use std::fmt::Debug;
19
20use nautilus_core::string::secret::REDACTED;
21use thiserror::Error;
22use zeroize::ZeroizeOnDrop;
23
24/// Environment variable name for the Betfair account username.
25pub const BETFAIR_USERNAME_ENV: &str = "BETFAIR_USERNAME";
26
27/// Environment variable name for the Betfair account password.
28pub const BETFAIR_PASSWORD_ENV: &str = "BETFAIR_PASSWORD";
29
30/// Environment variable name for the Betfair application key.
31pub const BETFAIR_APP_KEY_ENV: &str = "BETFAIR_APP_KEY";
32
33/// Errors that can occur when resolving credentials.
34#[derive(Debug, Error)]
35pub enum CredentialError {
36    /// Username was provided but password is missing.
37    #[error("Username provided but password is missing")]
38    MissingPassword,
39    /// Password was provided but username is missing.
40    #[error("Password provided but username is missing")]
41    MissingUsername,
42    /// App key is missing.
43    #[error("App key is missing")]
44    MissingAppKey,
45}
46
47/// Betfair API credentials for session-token authentication.
48///
49/// Betfair uses username/password login to obtain a session token,
50/// which is then passed as `X-Authentication` on subsequent requests.
51/// The `app_key` identifies the application and is sent as `X-Application`.
52///
53/// Secrets are automatically zeroized on drop for security.
54#[derive(Clone, ZeroizeOnDrop)]
55pub struct BetfairCredential {
56    username: Box<str>,
57    password: Box<str>,
58    app_key: Box<str>,
59}
60
61impl Debug for BetfairCredential {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct(stringify!(BetfairCredential))
64            .field("username", &self.username)
65            .field("password", &REDACTED)
66            .field("app_key", &self.app_key)
67            .finish()
68    }
69}
70
71impl BetfairCredential {
72    /// Creates a new [`BetfairCredential`] instance.
73    #[must_use]
74    pub fn new(username: String, password: String, app_key: String) -> Self {
75        Self {
76            username: username.into_boxed_str(),
77            password: password.into_boxed_str(),
78            app_key: app_key.into_boxed_str(),
79        }
80    }
81
82    /// Load credentials from environment variables.
83    ///
84    /// Reads `BETFAIR_USERNAME`, `BETFAIR_PASSWORD`, and `BETFAIR_APP_KEY`.
85    ///
86    /// Returns `None` if any variable is not set.
87    #[must_use]
88    pub fn from_env() -> Option<Self> {
89        let username = std::env::var(BETFAIR_USERNAME_ENV).ok()?;
90        let password = std::env::var(BETFAIR_PASSWORD_ENV).ok()?;
91        let app_key = std::env::var(BETFAIR_APP_KEY_ENV).ok()?;
92        Some(Self::new(username, password, app_key))
93    }
94
95    /// Resolves credentials from provided values or environment.
96    ///
97    /// If all three values are provided, uses those directly.
98    /// If none are provided, falls back to environment variables.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if credentials are partially provided.
103    pub fn resolve(
104        username: Option<String>,
105        password: Option<String>,
106        app_key: Option<String>,
107    ) -> Result<Option<Self>, CredentialError> {
108        match (username, password, app_key) {
109            (Some(u), Some(p), Some(k)) => Ok(Some(Self::new(u, p, k))),
110            (None, None, None) => Ok(Self::from_env()),
111            (Some(_), None, _) => Err(CredentialError::MissingPassword),
112            (None, Some(_), _) => Err(CredentialError::MissingUsername),
113            (_, _, None) => Err(CredentialError::MissingAppKey),
114            (None, None, Some(_)) => Err(CredentialError::MissingUsername),
115        }
116    }
117
118    /// Returns the account username.
119    #[must_use]
120    pub fn username(&self) -> &str {
121        &self.username
122    }
123
124    /// Returns the account password.
125    #[must_use]
126    pub fn password(&self) -> &str {
127        &self.password
128    }
129
130    /// Returns the application key.
131    #[must_use]
132    pub fn app_key(&self) -> &str {
133        &self.app_key
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use rstest::rstest;
140
141    use super::*;
142
143    #[rstest]
144    fn test_credential_creation() {
145        let cred = BetfairCredential::new(
146            "testuser".to_string(),
147            "testpass".to_string(),
148            "appkey123".to_string(),
149        );
150
151        assert_eq!(cred.username(), "testuser");
152        assert_eq!(cred.password(), "testpass");
153        assert_eq!(cred.app_key(), "appkey123");
154    }
155
156    #[rstest]
157    fn test_debug_redacts_password() {
158        let cred = BetfairCredential::new(
159            "myuser".to_string(),
160            "supersecret".to_string(),
161            "myappkey".to_string(),
162        );
163
164        let debug_output = format!("{cred:?}");
165
166        assert!(debug_output.contains(REDACTED));
167        assert!(!debug_output.contains("supersecret"));
168        assert!(debug_output.contains("myuser"));
169        assert!(debug_output.contains("myappkey"));
170    }
171
172    #[rstest]
173    fn test_resolve_with_all_credentials() {
174        let result = BetfairCredential::resolve(
175            Some("user".to_string()),
176            Some("pass".to_string()),
177            Some("key".to_string()),
178        );
179
180        assert!(result.is_ok());
181        let cred = result.unwrap().unwrap();
182        assert_eq!(cred.username(), "user");
183    }
184
185    #[rstest]
186    fn test_resolve_missing_password() {
187        let result =
188            BetfairCredential::resolve(Some("user".to_string()), None, Some("key".to_string()));
189
190        assert!(result.is_err());
191        assert!(matches!(
192            result.unwrap_err(),
193            CredentialError::MissingPassword
194        ));
195    }
196
197    #[rstest]
198    fn test_resolve_missing_username() {
199        let result =
200            BetfairCredential::resolve(None, Some("pass".to_string()), Some("key".to_string()));
201
202        assert!(result.is_err());
203        assert!(matches!(
204            result.unwrap_err(),
205            CredentialError::MissingUsername
206        ));
207    }
208
209    #[rstest]
210    fn test_resolve_missing_app_key() {
211        let result =
212            BetfairCredential::resolve(Some("user".to_string()), Some("pass".to_string()), None);
213
214        assert!(result.is_err());
215        assert!(matches!(
216            result.unwrap_err(),
217            CredentialError::MissingAppKey
218        ));
219    }
220
221    #[rstest]
222    fn test_resolve_none_falls_back_to_env() {
223        // Without env vars set, should return None
224        let result = BetfairCredential::resolve(None, None, None);
225
226        assert!(result.is_ok());
227        // Will be None unless env vars are set in the test environment
228    }
229}