Skip to main content

qcs_api_client_common/configuration/
settings.rs

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