Skip to main content

qcs_api_client_common/configuration/
settings.rs

1//! Models and utilities for managing QCS settings.
2use std::collections::{BTreeSet, HashMap};
3use std::path::PathBuf;
4
5use figment::providers::Format;
6use figment::{Figment, providers::Toml};
7use serde::{Deserialize, Serialize};
8
9#[cfg(feature = "stubs")]
10use pyo3_stub_gen::derive::gen_stub_pyclass;
11
12use crate::configuration::error::DiscoveryError;
13use crate::configuration::oidc::{DISCOVERY_REQUIRED_SCOPE, fetch_discovery};
14use crate::configuration::tokens::default_http_client;
15
16use super::{
17    DEFAULT_API_URL, DEFAULT_GRPC_API_URL, DEFAULT_PROFILE_NAME, DEFAULT_QUILC_URL,
18    DEFAULT_QVM_URL, LoadError, env_or_default_quilc_url, env_or_default_qvm_url,
19    expand_path_from_env_or_default,
20};
21
22/// The scopes that we prefer to request during an interactive login.
23///
24/// Using `offline_access` allows automatic token refresh, reducing the need for repeated logins.
25/// Actual scopes used depends on what the [`Discovery`][crate::configuration::oidc::Discovery]
26/// document says the provider supports, see [`resolve_scopes`][`super::login::resolve_scopes`]
27/// for how these preferences are coalesced with other scope configuration sources.
28pub const PREFERRED_LOGIN_SCOPES: [&str; 4] = [
29    DISCOVERY_REQUIRED_SCOPE,
30    "profile",
31    "email",
32    "offline_access",
33];
34
35/// Setting the `QCS_SETTINGS_FILE_PATH` environment variable will change which file is used for loading [`Settings`].
36pub const SETTINGS_PATH_VAR: &str = "QCS_SETTINGS_FILE_PATH";
37/// The default path that [`Settings`] will be loaded from;
38pub const DEFAULT_SETTINGS_PATH: &str = "~/.qcs/settings.toml";
39
40/// The structure of QCS settings, typically serialized as a TOML file at [`DEFAULT_SETTINGS_PATH`].
41#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
42pub struct Settings {
43    /// The default profile to use - this should match a key of [`Settings::profiles`].
44    #[serde(default = "default_profile_name")]
45    pub default_profile_name: String,
46
47    /// All named [`Profile`]s defined in the settings file.
48    #[serde(default = "default_profiles")]
49    pub profiles: HashMap<String, Profile>,
50
51    /// All named [`AuthServer`]s defined in the settings file.
52    #[serde(default = "default_auth_servers")]
53    pub auth_servers: HashMap<String, AuthServer>,
54
55    /// The path to the settings file this [`Settings`] was loaded from,
56    /// if it was loaded from a file. This is not stored in the settings file itself.
57    #[serde(skip)]
58    pub file_path: Option<PathBuf>,
59}
60
61impl Settings {
62    /// Load [`Settings`] from the path specified by the [`SETTINGS_PATH_VAR`] environment variable if set,
63    /// or else the default path at [`DEFAULT_SETTINGS_PATH`].
64    ///
65    /// # Errors
66    ///
67    /// [`LoadError`] if the settings file cannot be loaded.
68    pub fn load() -> Result<Self, LoadError> {
69        let path = expand_path_from_env_or_default(SETTINGS_PATH_VAR, DEFAULT_SETTINGS_PATH)?;
70        #[cfg(feature = "tracing")]
71        tracing::debug!("loading QCS settings from {path:?}");
72        Self::load_from_path(&path)
73    }
74
75    /// Load [`Settings`] from the path specified by `path`.
76    ///
77    /// # Errors
78    ///
79    /// [`LoadError`] if the settings file cannot be loaded.
80    pub fn load_from_path(path: &PathBuf) -> Result<Self, LoadError> {
81        let mut settings: Self = Figment::from(Toml::file(path)).extract()?;
82        settings.file_path = Some(path.into());
83        Ok(settings)
84    }
85}
86
87impl Default for Settings {
88    fn default() -> Self {
89        Self {
90            default_profile_name: default_profile_name(),
91            profiles: default_profiles(),
92            auth_servers: default_auth_servers(),
93            file_path: None,
94        }
95    }
96}
97
98fn default_profile_name() -> String {
99    DEFAULT_PROFILE_NAME.to_string()
100}
101
102fn default_profiles() -> HashMap<String, Profile> {
103    HashMap::from([(DEFAULT_PROFILE_NAME.to_string(), Profile::default())])
104}
105
106fn default_auth_servers() -> HashMap<String, AuthServer> {
107    HashMap::from([(DEFAULT_PROFILE_NAME.to_string(), AuthServer::default())])
108}
109
110/// A particular profile of [`Settings`], which defines all the configurable options
111/// for connecting to a particular QCS instance using a particular set of credentials.
112#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
113pub struct Profile {
114    /// URL of the QCS REST API.
115    #[serde(default = "default_api_url")]
116    pub api_url: String,
117    /// URL of the QCS gRPC API.
118    #[serde(default = "default_grpc_api_url")]
119    pub grpc_api_url: String,
120    /// Name of the [`AuthServer`] to use.
121    #[serde(default = "default_profile_name")]
122    pub auth_server_name: String,
123    /// Name of the [`Credential`][`super::secrets::Credential`] to use from the corresponding [`Secrets`][`super::secrets::Secrets`].
124    #[serde(default = "default_profile_name")]
125    pub credentials_name: String,
126    /// Application specific settings.
127    #[serde(default)]
128    pub applications: Applications,
129}
130
131impl Default for Profile {
132    fn default() -> Self {
133        Self {
134            api_url: DEFAULT_API_URL.to_string(),
135            grpc_api_url: DEFAULT_GRPC_API_URL.to_string(),
136            auth_server_name: DEFAULT_PROFILE_NAME.to_string(),
137            credentials_name: DEFAULT_PROFILE_NAME.to_string(),
138            applications: Applications::default(),
139        }
140    }
141}
142
143fn default_api_url() -> String {
144    DEFAULT_API_URL.to_string()
145}
146
147fn default_grpc_api_url() -> String {
148    DEFAULT_GRPC_API_URL.to_string()
149}
150
151pub(crate) const QCS_DEFAULT_CLIENT_ID_PRODUCTION: &str = "0oa3ykoirzDKpkfzk357";
152pub(crate) const QCS_DEFAULT_AUTH_ISSUER_PRODUCTION: &str =
153    "https://auth.qcs.rigetti.com/oauth2/aus8jcovzG0gW2TUG355";
154
155/// OAuth 2.0 authorization server.
156#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
157#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
158#[cfg_attr(
159    feature = "python",
160    pyo3::pyclass(
161        module = "qcs_api_client_common._qcs_api_client_common.configuration",
162        eq,
163        get_all,
164        set_all,
165        from_py_object
166    )
167)]
168pub struct AuthServer {
169    /// OAuth 2.0 client id.
170    pub client_id: String,
171    /// OAuth 2.0 issuer URL.
172    ///
173    /// This is the base URL of the identity provider.
174    /// For Okta, this usually looks like `https://example.okta.com/oauth2/default`.
175    /// For Cognito, it might look like `https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example`.
176    ///
177    /// Note that this is technically distinct from the `issuer` field in [`OidcDiscovery`],
178    /// which is the canonical URI that the identity provider uses to sign and validate tokens,
179    /// but the OpenID specification requires that they match exactly,
180    /// and that they match the `iss` claim in Tokens issued by this identity provider.
181    pub issuer: String,
182
183    /// OAuth 2.0 scopes to request during authorization requests.
184    /// See [`resolve_scopes`][super::login::resolve_scopes] for more defails.
185    pub scopes: Option<BTreeSet<String>>,
186}
187
188impl Default for AuthServer {
189    fn default() -> Self {
190        Self {
191            client_id: QCS_DEFAULT_CLIENT_ID_PRODUCTION.to_string(),
192            issuer: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
193            scopes: None,
194        }
195    }
196}
197
198impl AuthServer {
199    /// Create a new [`AuthServer`] with a `client_id` and `issuer` and an optional list of scopes.
200    ///
201    /// If `scopes` is [`None`], [`resolve_scopes`][super::login::resolve_scopes] will be used to
202    /// determine the scopes to request during authorization.
203    #[must_use]
204    pub const fn new(client_id: String, issuer: String, scopes: Option<BTreeSet<String>>) -> Self {
205        Self {
206            client_id,
207            issuer,
208            scopes,
209        }
210    }
211
212    /// Create a new [`AuthServer`] with the specified `client_id` and `issuer`,
213    /// populating `scopes` with all `scopes_supported` fetched from the issuer's discovery document.
214    ///
215    /// Note that the advertised set is generally broader than a login requires, and requesting
216    /// all scopes will sometimes cause errors. See [`PREFERRED_LOGIN_SCOPES`].
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the discovery document cannot be fetched or parsed.
221    pub async fn new_with_discovery_supported_scopes(
222        client_id: String,
223        issuer: String,
224    ) -> Result<Self, DiscoveryError> {
225        let client = default_http_client()?;
226        let discovery = fetch_discovery(&client, &issuer).await?;
227        Ok(Self {
228            client_id,
229            issuer,
230            scopes: discovery.scopes_supported,
231        })
232    }
233}
234
235/// Settings for secondary applications used by QCS SDKs.
236#[derive(Deserialize, Clone, Debug, Default, PartialEq, Eq, Serialize)]
237pub struct Applications {
238    /// Settings for use of the pyquil SDK.
239    #[serde(default)]
240    pub pyquil: Pyquil,
241}
242
243/// Settings for secondary applications used by pyquil.
244#[derive(Deserialize, Clone, Debug, PartialEq, Eq, Serialize)]
245pub struct Pyquil {
246    /// URL of the QVM server.
247    #[serde(default = "env_or_default_qvm_url")]
248    pub qvm_url: String,
249
250    /// URL of the Quilc compiler server.
251    #[serde(default = "env_or_default_quilc_url")]
252    pub quilc_url: String,
253}
254
255impl Default for Pyquil {
256    fn default() -> Self {
257        Self {
258            quilc_url: DEFAULT_QUILC_URL.to_string(),
259            qvm_url: DEFAULT_QVM_URL.to_string(),
260        }
261    }
262}
263
264#[cfg(test)]
265mod test {
266    #![allow(clippy::result_large_err, reason = "happens in figment tests")]
267
268    use std::path::PathBuf;
269
270    use super::{SETTINGS_PATH_VAR, Settings};
271
272    #[test]
273    fn returns_err_if_invalid_path_env() {
274        figment::Jail::expect_with(|jail| {
275            jail.set_env(SETTINGS_PATH_VAR, "/blah/doesnt_exist.toml");
276            Settings::load().expect_err("Should return error when a file cannot be found.");
277            Ok(())
278        });
279    }
280
281    #[test]
282    fn test_uses_defaults_incomplete_settings() {
283        figment::Jail::expect_with(|jail| {
284            let _ = jail.create_file("settings.toml", r#"default_profile_name = "TEST""#)?;
285            jail.set_env(SETTINGS_PATH_VAR, "settings.toml");
286            let loaded = Settings::load().expect("should load settings");
287            let expected = Settings {
288                default_profile_name: "TEST".to_string(),
289                file_path: Some(PathBuf::from("settings.toml")),
290                ..Settings::default()
291            };
292
293            assert_eq!(loaded, expected);
294
295            Ok(())
296        });
297    }
298
299    #[test]
300    fn loads_from_env_var_path() {
301        figment::Jail::expect_with(|jail| {
302            let settings = Settings {
303                default_profile_name: "TEST".to_string(),
304                file_path: Some(PathBuf::from("secrets.toml")),
305                ..Settings::default()
306            };
307            let settings_string =
308                toml::to_string(&settings).expect("Should be able to serialize settings");
309
310            _ = jail.create_file("secrets.toml", &settings_string)?;
311            jail.set_env(SETTINGS_PATH_VAR, "secrets.toml");
312
313            assert_eq!(settings, Settings::load().unwrap());
314
315            Ok(())
316        });
317    }
318}