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