Skip to main content

raps_kernel/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Configuration module for APS CLI
5//!
6//! Handles loading and managing APS credentials from environment variables or .env file.
7
8use anyhow::{Context, Result};
9use std::env;
10
11use crate::http::HttpClientConfig;
12use crate::types::{ProfileConfig, ProfilesData};
13
14/// Default callback port for 3-legged OAuth
15pub const DEFAULT_CALLBACK_PORT: u16 = 8080;
16
17/// APS Configuration containing client credentials
18#[derive(Debug, Clone)]
19pub struct Config {
20    /// APS Client ID (from APS Developer Portal)
21    pub client_id: String,
22    /// APS Client Secret (from APS Developer Portal)
23    pub client_secret: String,
24    /// Base URL for APS API (defaults to production)
25    pub base_url: String,
26    /// Callback URL for 3-legged OAuth
27    pub callback_url: String,
28    /// Design Automation nickname (optional)
29    #[allow(dead_code)]
30    pub da_nickname: Option<String>,
31    /// HTTP client configuration
32    pub http_config: HttpClientConfig,
33}
34
35impl Config {
36    /// Load configuration with precedence: flags > env vars > active profile > defaults
37    ///
38    /// Looks for:
39    /// 1. Environment variables (APS_CLIENT_ID, APS_CLIENT_SECRET, etc.)
40    /// 2. Active profile configuration (if set)
41    /// 3. Defaults
42    pub fn from_env() -> Result<Self> {
43        // Try to load .env file if it exists (silently ignore if not found)
44        let _ = dotenvy::dotenv();
45
46        // Load profile data
47        let profile_data = Self::load_profile_data().ok();
48
49        // Determine values with precedence: env vars > profile > defaults
50        let client_id = env::var("APS_CLIENT_ID")
51            .or_else(|_| {
52                profile_data
53                    .as_ref()
54                    .and_then(|(_, profile)| profile.client_id.clone())
55                    .ok_or(env::VarError::NotPresent)
56            })
57            .context(
58                "APS_CLIENT_ID not set. Set it via:\n  - Environment variable: APS_CLIENT_ID\n  - Profile: raps config profile create <name> && raps config set client_id <value>",
59            )?;
60
61        let client_secret = env::var("APS_CLIENT_SECRET")
62            .or_else(|_| {
63                profile_data
64                    .as_ref()
65                    .and_then(|(_, profile)| profile.client_secret.clone())
66                    .ok_or(env::VarError::NotPresent)
67            })
68            .context(
69                "APS_CLIENT_SECRET not set. Set it via:\n  - Environment variable: APS_CLIENT_SECRET\n  - Profile: raps config profile create <name> && raps config set client_secret <value>",
70            )?;
71
72        let base_url = env::var("APS_BASE_URL")
73            .or_else(|_| {
74                profile_data
75                    .as_ref()
76                    .and_then(|(_, profile)| profile.base_url.clone())
77                    .ok_or(env::VarError::NotPresent)
78            })
79            .unwrap_or_else(|_| "https://developer.api.autodesk.com".to_string());
80
81        let callback_url = env::var("APS_CALLBACK_URL")
82            .or_else(|_| {
83                profile_data
84                    .as_ref()
85                    .and_then(|(_, profile)| profile.callback_url.clone())
86                    .ok_or(env::VarError::NotPresent)
87            })
88            .unwrap_or_else(|_| format!("http://localhost:{}/callback", DEFAULT_CALLBACK_PORT));
89
90        let da_nickname = env::var("APS_DA_NICKNAME").ok().or_else(|| {
91            profile_data
92                .as_ref()
93                .and_then(|(_, profile)| profile.da_nickname.clone())
94        });
95
96        Ok(Self {
97            client_id,
98            client_secret,
99            base_url,
100            callback_url,
101            da_nickname,
102            http_config: HttpClientConfig::default(),
103        })
104    }
105
106    /// Load configuration leniently — missing client_id/client_secret default to empty strings.
107    /// Useful for commands that don't need API credentials (e.g., auth logout, auth status).
108    pub fn from_env_lenient() -> Result<Self> {
109        let _ = dotenvy::dotenv();
110
111        let profile_data = Self::load_profile_data().ok();
112
113        let client_id = env::var("APS_CLIENT_ID")
114            .or_else(|_| {
115                profile_data
116                    .as_ref()
117                    .and_then(|(_, profile)| profile.client_id.clone())
118                    .ok_or(env::VarError::NotPresent)
119            })
120            .unwrap_or_default();
121
122        let client_secret = env::var("APS_CLIENT_SECRET")
123            .or_else(|_| {
124                profile_data
125                    .as_ref()
126                    .and_then(|(_, profile)| profile.client_secret.clone())
127                    .ok_or(env::VarError::NotPresent)
128            })
129            .unwrap_or_default();
130
131        let base_url = env::var("APS_BASE_URL")
132            .or_else(|_| {
133                profile_data
134                    .as_ref()
135                    .and_then(|(_, profile)| profile.base_url.clone())
136                    .ok_or(env::VarError::NotPresent)
137            })
138            .unwrap_or_else(|_| "https://developer.api.autodesk.com".to_string());
139
140        let callback_url = env::var("APS_CALLBACK_URL")
141            .or_else(|_| {
142                profile_data
143                    .as_ref()
144                    .and_then(|(_, profile)| profile.callback_url.clone())
145                    .ok_or(env::VarError::NotPresent)
146            })
147            .unwrap_or_else(|_| format!("http://localhost:{}/callback", DEFAULT_CALLBACK_PORT));
148
149        let da_nickname = env::var("APS_DA_NICKNAME").ok().or_else(|| {
150            profile_data
151                .as_ref()
152                .and_then(|(_, profile)| profile.da_nickname.clone())
153        });
154
155        Ok(Self {
156            client_id,
157            client_secret,
158            base_url,
159            callback_url,
160            da_nickname,
161            http_config: HttpClientConfig::default(),
162        })
163    }
164
165    /// Load profile data from disk
166    fn load_profile_data() -> Result<(String, ProfileConfig)> {
167        let data = load_profiles()?;
168        let profile_name = data
169            .active_profile
170            .ok_or_else(|| anyhow::anyhow!("No active profile"))?;
171
172        let profile = data
173            .profiles
174            .get(&profile_name)
175            .ok_or_else(|| anyhow::anyhow!("Active profile '{}' not found", profile_name))?
176            .clone();
177
178        Ok((profile_name, profile))
179    }
180
181    /// Validate that client credentials are configured.
182    ///
183    /// Call this before any operation that requires `client_id` / `client_secret`
184    /// (2-legged auth, 3-legged login, token refresh). Returns a clear error
185    /// telling the user how to set the missing value(s).
186    pub fn require_credentials(&self) -> Result<()> {
187        if self.client_id.is_empty() && self.client_secret.is_empty() {
188            anyhow::bail!(
189                "APS_CLIENT_ID and APS_CLIENT_SECRET are not set.\n\
190                 Set them via environment variables or a profile:\n  \
191                 export APS_CLIENT_ID=<your-client-id>\n  \
192                 export APS_CLIENT_SECRET=<your-client-secret>\n  \
193                 Or: raps config profile create <name> && raps config set client_id <value>"
194            );
195        }
196        if self.client_id.is_empty() {
197            anyhow::bail!(
198                "APS_CLIENT_ID is not set.\n\
199                 Set it via:\n  \
200                 export APS_CLIENT_ID=<your-client-id>\n  \
201                 Or: raps config set client_id <value>"
202            );
203        }
204        if self.client_secret.is_empty() {
205            anyhow::bail!(
206                "APS_CLIENT_SECRET is not set.\n\
207                 Set it via:\n  \
208                 export APS_CLIENT_SECRET=<your-client-secret>\n  \
209                 Or: raps config set client_secret <value>"
210            );
211        }
212        Ok(())
213    }
214
215    /// Get the authentication endpoint URL
216    pub fn auth_url(&self) -> String {
217        format!("{}/authentication/v2/token", self.base_url)
218    }
219
220    /// Get the authorization URL for 3-legged OAuth
221    pub fn authorize_url(&self) -> String {
222        format!("{}/authentication/v2/authorize", self.base_url)
223    }
224
225    /// Get the OSS API base URL
226    pub fn oss_url(&self) -> String {
227        format!("{}/oss/v2", self.base_url)
228    }
229
230    /// Get the Model Derivative API base URL
231    pub fn derivative_url(&self) -> String {
232        format!("{}/modelderivative/v2", self.base_url)
233    }
234
235    /// Get the Data Management API base URL (for hubs/projects)
236    pub fn project_url(&self) -> String {
237        format!("{}/project/v1", self.base_url)
238    }
239
240    /// Get the Data Management API base URL (for folders/items)
241    pub fn data_url(&self) -> String {
242        format!("{}/data/v1", self.base_url)
243    }
244
245    /// Get the Webhooks API base URL
246    pub fn webhooks_url(&self) -> String {
247        format!("{}/webhooks/v1", self.base_url)
248    }
249
250    /// Get the Design Automation API base URL
251    pub fn da_url(&self) -> String {
252        format!("{}/da/us-east/v3", self.base_url)
253    }
254
255    /// Get the ACC Issues API base URL
256    pub fn issues_url(&self) -> String {
257        format!("{}/construction/issues/v1", self.base_url)
258    }
259
260    /// Get the Reality Capture API base URL
261    pub fn reality_capture_url(&self) -> String {
262        format!("{}/photo-to-3d/v1", self.base_url)
263    }
264
265    /// Get the RFI API base URL
266    pub fn rfi_url(&self) -> String {
267        format!("{}/construction/rfis/v2", self.base_url)
268    }
269
270    /// Get the Assets API base URL
271    pub fn assets_url(&self) -> String {
272        format!("{}/construction/assets/v1", self.base_url)
273    }
274
275    /// Get the Submittals API base URL
276    pub fn submittals_url(&self) -> String {
277        format!("{}/construction/submittals/v1", self.base_url)
278    }
279
280    /// Get the Checklists API base URL
281    pub fn checklists_url(&self) -> String {
282        format!("{}/construction/checklists/v1", self.base_url)
283    }
284
285    /// Get the AEC Data Model GraphQL API endpoint
286    pub fn aec_graphql_url(&self) -> String {
287        format!("{}/aec/graphql", self.base_url)
288    }
289}
290
291/// Load profiles from disk
292pub fn load_profiles() -> Result<ProfilesData> {
293    let proj_dirs = directories::ProjectDirs::from("com", "autodesk", "raps")
294        .ok_or_else(|| anyhow::anyhow!("Failed to get project directories"))?;
295
296    let profiles_path = proj_dirs.config_dir().join("profiles.json");
297
298    if !profiles_path.exists() {
299        return Ok(ProfilesData::default());
300    }
301
302    let content =
303        std::fs::read_to_string(&profiles_path).context("Failed to read profiles file")?;
304
305    let data: ProfilesData =
306        serde_json::from_str(&content).context("Failed to parse profiles file")?;
307
308    Ok(data)
309}
310
311/// Resolved context values from profile + environment
312#[derive(Debug, Clone, Default)]
313pub struct ContextConfig {
314    pub hub_id: Option<String>,
315    pub project_id: Option<String>,
316    pub account_id: Option<String>,
317}
318
319impl ContextConfig {
320    /// Load context from env vars > active profile
321    pub fn load() -> Self {
322        let profile_data = Config::load_profile_data().ok();
323
324        let hub_id = std::env::var("APS_HUB_ID").ok().or_else(|| {
325            profile_data
326                .as_ref()
327                .and_then(|(_, p)| p.context_hub_id.clone())
328        });
329
330        let project_id = std::env::var("APS_PROJECT_ID").ok().or_else(|| {
331            profile_data
332                .as_ref()
333                .and_then(|(_, p)| p.context_project_id.clone())
334        });
335
336        let account_id = std::env::var("APS_ACCOUNT_ID").ok().or_else(|| {
337            profile_data
338                .as_ref()
339                .and_then(|(_, p)| p.context_account_id.clone())
340        });
341
342        Self {
343            hub_id,
344            project_id,
345            account_id,
346        }
347    }
348
349    /// Resolve hub ID from explicit flag, context, or None
350    pub fn resolve_hub_id(&self, explicit: Option<String>) -> Option<String> {
351        explicit.or_else(|| self.hub_id.clone())
352    }
353
354    /// Resolve project ID from explicit flag, context, or None
355    pub fn resolve_project_id(&self, explicit: Option<String>) -> Option<String> {
356        explicit.or_else(|| self.project_id.clone())
357    }
358
359    /// Resolve account ID from explicit flag, context, or None
360    pub fn resolve_account_id(&self, explicit: Option<String>) -> Option<String> {
361        explicit.or_else(|| self.account_id.clone())
362    }
363}
364
365/// Save profiles to disk
366pub fn save_profiles(data: &ProfilesData) -> Result<()> {
367    let proj_dirs = directories::ProjectDirs::from("com", "autodesk", "raps")
368        .ok_or_else(|| anyhow::anyhow!("Failed to get project directories"))?;
369
370    let config_dir = proj_dirs.config_dir();
371    crate::security::create_dir_restricted(config_dir)?;
372
373    let profiles_path = config_dir.join("profiles.json");
374    let content = serde_json::to_string_pretty(data)?;
375    std::fs::write(&profiles_path, content)?;
376
377    Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    fn create_test_config() -> Config {
385        Config {
386            client_id: "test_client_id".to_string(),
387            client_secret: "test_secret".to_string(),
388            base_url: "https://developer.api.autodesk.com".to_string(),
389            callback_url: "http://localhost:8080/callback".to_string(),
390            da_nickname: None,
391            http_config: HttpClientConfig::default(),
392        }
393    }
394
395    #[test]
396    fn test_auth_url() {
397        let config = create_test_config();
398        let url = config.auth_url();
399        assert_eq!(
400            url,
401            "https://developer.api.autodesk.com/authentication/v2/token"
402        );
403    }
404
405    #[test]
406    fn test_authorize_url() {
407        let config = create_test_config();
408        let url = config.authorize_url();
409        assert_eq!(
410            url,
411            "https://developer.api.autodesk.com/authentication/v2/authorize"
412        );
413    }
414
415    #[test]
416    fn test_oss_url() {
417        let config = create_test_config();
418        let url = config.oss_url();
419        assert_eq!(url, "https://developer.api.autodesk.com/oss/v2");
420    }
421
422    #[test]
423    fn test_derivative_url() {
424        let config = create_test_config();
425        let url = config.derivative_url();
426        assert_eq!(url, "https://developer.api.autodesk.com/modelderivative/v2");
427    }
428
429    #[test]
430    fn test_project_url() {
431        let config = create_test_config();
432        let url = config.project_url();
433        assert_eq!(url, "https://developer.api.autodesk.com/project/v1");
434    }
435
436    #[test]
437    fn test_data_url() {
438        let config = create_test_config();
439        let url = config.data_url();
440        assert_eq!(url, "https://developer.api.autodesk.com/data/v1");
441    }
442
443    #[test]
444    fn test_webhooks_url() {
445        let config = create_test_config();
446        let url = config.webhooks_url();
447        assert_eq!(url, "https://developer.api.autodesk.com/webhooks/v1");
448    }
449
450    #[test]
451    fn test_da_url() {
452        let config = create_test_config();
453        let url = config.da_url();
454        assert_eq!(url, "https://developer.api.autodesk.com/da/us-east/v3");
455    }
456
457    #[test]
458    fn test_issues_url() {
459        let config = create_test_config();
460        let url = config.issues_url();
461        assert_eq!(
462            url,
463            "https://developer.api.autodesk.com/construction/issues/v1"
464        );
465    }
466
467    #[test]
468    fn test_reality_capture_url() {
469        let config = create_test_config();
470        let url = config.reality_capture_url();
471        assert_eq!(url, "https://developer.api.autodesk.com/photo-to-3d/v1");
472    }
473
474    #[test]
475    fn test_custom_base_url() {
476        let config = Config {
477            client_id: "test".to_string(),
478            client_secret: "secret".to_string(),
479            base_url: "https://custom.api.example.com".to_string(),
480            callback_url: "http://localhost:8080/callback".to_string(),
481            da_nickname: None,
482            http_config: HttpClientConfig::default(),
483        };
484        assert!(
485            config
486                .auth_url()
487                .starts_with("https://custom.api.example.com")
488        );
489        assert!(
490            config
491                .oss_url()
492                .starts_with("https://custom.api.example.com")
493        );
494    }
495
496    #[test]
497    fn test_config_with_da_nickname() {
498        let config = Config {
499            client_id: "test".to_string(),
500            client_secret: "secret".to_string(),
501            base_url: "https://developer.api.autodesk.com".to_string(),
502            callback_url: "http://localhost:8080/callback".to_string(),
503            da_nickname: Some("my-nickname".to_string()),
504            http_config: HttpClientConfig::default(),
505        };
506        assert_eq!(config.da_nickname, Some("my-nickname".to_string()));
507    }
508
509    #[test]
510    fn test_all_urls_contain_base_url() {
511        let config = create_test_config();
512        let base = &config.base_url;
513
514        assert!(config.auth_url().starts_with(base));
515        assert!(config.authorize_url().starts_with(base));
516        assert!(config.oss_url().starts_with(base));
517        assert!(config.derivative_url().starts_with(base));
518        assert!(config.project_url().starts_with(base));
519        assert!(config.data_url().starts_with(base));
520        assert!(config.webhooks_url().starts_with(base));
521        assert!(config.da_url().starts_with(base));
522        assert!(config.issues_url().starts_with(base));
523        assert!(config.reality_capture_url().starts_with(base));
524        assert!(config.rfi_url().starts_with(base));
525        assert!(config.assets_url().starts_with(base));
526        assert!(config.submittals_url().starts_with(base));
527        assert!(config.checklists_url().starts_with(base));
528        assert!(config.aec_graphql_url().starts_with(base));
529    }
530
531    #[test]
532    fn test_rfi_url() {
533        let config = create_test_config();
534        let url = config.rfi_url();
535        assert_eq!(
536            url,
537            "https://developer.api.autodesk.com/construction/rfis/v2"
538        );
539    }
540
541    #[test]
542    fn test_assets_url() {
543        let config = create_test_config();
544        let url = config.assets_url();
545        assert_eq!(
546            url,
547            "https://developer.api.autodesk.com/construction/assets/v1"
548        );
549    }
550
551    #[test]
552    fn test_submittals_url() {
553        let config = create_test_config();
554        let url = config.submittals_url();
555        assert_eq!(
556            url,
557            "https://developer.api.autodesk.com/construction/submittals/v1"
558        );
559    }
560
561    #[test]
562    fn test_checklists_url() {
563        let config = create_test_config();
564        let url = config.checklists_url();
565        assert_eq!(
566            url,
567            "https://developer.api.autodesk.com/construction/checklists/v1"
568        );
569    }
570
571    #[test]
572    fn test_default_callback_port() {
573        assert_eq!(DEFAULT_CALLBACK_PORT, 8080);
574    }
575
576    #[test]
577    fn test_default_callback_url_format() {
578        let config = create_test_config();
579        assert!(config.callback_url.contains("localhost"));
580        assert!(config.callback_url.contains("callback"));
581    }
582
583    // ==================== Credential Validation Tests ====================
584
585    #[test]
586    fn test_require_credentials_both_set() {
587        let config = create_test_config();
588        assert!(config.require_credentials().is_ok());
589    }
590
591    #[test]
592    fn test_require_credentials_both_empty() {
593        let config = Config {
594            client_id: "".to_string(),
595            client_secret: "".to_string(),
596            base_url: "https://developer.api.autodesk.com".to_string(),
597            callback_url: "http://localhost:8080/callback".to_string(),
598            da_nickname: None,
599            http_config: HttpClientConfig::default(),
600        };
601        let err = config.require_credentials().unwrap_err();
602        let msg = err.to_string();
603        assert!(msg.contains("APS_CLIENT_ID"));
604        assert!(msg.contains("APS_CLIENT_SECRET"));
605    }
606
607    #[test]
608    fn test_require_credentials_missing_client_id() {
609        let config = Config {
610            client_id: "".to_string(),
611            client_secret: "test_secret".to_string(),
612            base_url: "https://developer.api.autodesk.com".to_string(),
613            callback_url: "http://localhost:8080/callback".to_string(),
614            da_nickname: None,
615            http_config: HttpClientConfig::default(),
616        };
617        let err = config.require_credentials().unwrap_err();
618        let msg = err.to_string();
619        assert!(msg.contains("APS_CLIENT_ID"));
620        assert!(!msg.contains("APS_CLIENT_SECRET"));
621    }
622
623    #[test]
624    fn test_require_credentials_missing_client_secret() {
625        let config = Config {
626            client_id: "test_client_id".to_string(),
627            client_secret: "".to_string(),
628            base_url: "https://developer.api.autodesk.com".to_string(),
629            callback_url: "http://localhost:8080/callback".to_string(),
630            da_nickname: None,
631            http_config: HttpClientConfig::default(),
632        };
633        let err = config.require_credentials().unwrap_err();
634        let msg = err.to_string();
635        assert!(!msg.contains("APS_CLIENT_ID"));
636        assert!(msg.contains("APS_CLIENT_SECRET"));
637    }
638
639    // ==================== ContextConfig Tests ====================
640
641    #[test]
642    fn test_context_config_default() {
643        let ctx = ContextConfig::default();
644        assert!(ctx.hub_id.is_none());
645        assert!(ctx.project_id.is_none());
646        assert!(ctx.account_id.is_none());
647    }
648
649    #[test]
650    fn test_context_config_resolve_with_explicit() {
651        let ctx = ContextConfig {
652            hub_id: Some("stored-hub".to_string()),
653            project_id: Some("stored-proj".to_string()),
654            account_id: None,
655        };
656        // Explicit value takes priority
657        assert_eq!(
658            ctx.resolve_hub_id(Some("explicit-hub".to_string())),
659            Some("explicit-hub".to_string())
660        );
661        // Falls back to stored
662        assert_eq!(
663            ctx.resolve_project_id(None),
664            Some("stored-proj".to_string())
665        );
666        // None when neither
667        assert_eq!(ctx.resolve_account_id(None), None);
668    }
669}