1use crate::cache::metadata::CacheMetadataManager;
2use crate::config::models::{ApertureSecret, ApiConfig, GlobalConfig, SecretSource};
3use crate::config::url_resolver::BaseUrlResolver;
4use crate::constants;
5use crate::engine::loader;
6use crate::error::Error;
7use crate::fs::{FileSystem, OsFileSystem};
8use crate::spec::{SpecTransformer, SpecValidator};
9use openapiv3::{OpenAPI, ReferenceOr};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14struct CategorizedWarnings<'a> {
16 content_type: Vec<&'a crate::spec::validator::ValidationWarning>,
17 auth: Vec<&'a crate::spec::validator::ValidationWarning>,
18 mixed_content: Vec<&'a crate::spec::validator::ValidationWarning>,
19}
20
21pub struct ConfigManager<F: FileSystem> {
22 fs: F,
23 config_dir: PathBuf,
24}
25
26impl ConfigManager<OsFileSystem> {
27 pub fn new() -> Result<Self, Error> {
33 let config_dir = get_config_dir()?;
34 Ok(Self {
35 fs: OsFileSystem,
36 config_dir,
37 })
38 }
39}
40
41impl<F: FileSystem> ConfigManager<F> {
42 pub const fn with_fs(fs: F, config_dir: PathBuf) -> Self {
43 Self { fs, config_dir }
44 }
45
46 pub fn config_dir(&self) -> &Path {
48 &self.config_dir
49 }
50
51 #[must_use]
53 pub fn skipped_endpoints_to_warnings(
54 skipped_endpoints: &[crate::cache::models::SkippedEndpoint],
55 ) -> Vec<crate::spec::validator::ValidationWarning> {
56 skipped_endpoints
57 .iter()
58 .map(|endpoint| crate::spec::validator::ValidationWarning {
59 endpoint: crate::spec::validator::UnsupportedEndpoint {
60 path: endpoint.path.clone(),
61 method: endpoint.method.clone(),
62 content_type: endpoint.content_type.clone(),
63 },
64 reason: endpoint.reason.clone(),
65 })
66 .collect()
67 }
68
69 fn save_strict_preference(&self, api_name: &str, strict: bool) -> Result<(), Error> {
71 let mut config = self.load_global_config()?;
72 let api_config = config
73 .api_configs
74 .entry(api_name.to_string())
75 .or_insert_with(|| ApiConfig {
76 base_url_override: None,
77 environment_urls: HashMap::new(),
78 strict_mode: false,
79 secrets: HashMap::new(),
80 });
81 api_config.strict_mode = strict;
82 self.save_global_config(&config)?;
83 Ok(())
84 }
85
86 pub fn get_strict_preference(&self, api_name: &str) -> Result<bool, Error> {
92 let config = self.load_global_config()?;
93 Ok(config
94 .api_configs
95 .get(api_name)
96 .is_some_and(|c| c.strict_mode))
97 }
98
99 fn count_total_operations(spec: &OpenAPI) -> usize {
101 spec.paths
102 .iter()
103 .filter_map(|(_, path_item)| match path_item {
104 ReferenceOr::Item(item) => Some(item),
105 ReferenceOr::Reference { .. } => None,
106 })
107 .map(|item| {
108 let mut count = 0;
109 if item.get.is_some() {
110 count += 1;
111 }
112 if item.post.is_some() {
113 count += 1;
114 }
115 if item.put.is_some() {
116 count += 1;
117 }
118 if item.delete.is_some() {
119 count += 1;
120 }
121 if item.patch.is_some() {
122 count += 1;
123 }
124 if item.head.is_some() {
125 count += 1;
126 }
127 if item.options.is_some() {
128 count += 1;
129 }
130 if item.trace.is_some() {
131 count += 1;
132 }
133 count
134 })
135 .sum()
136 }
137
138 #[must_use]
140 pub fn format_validation_warnings(
141 warnings: &[crate::spec::validator::ValidationWarning],
142 total_operations: Option<usize>,
143 indent: &str,
144 ) -> Vec<String> {
145 let mut lines = Vec::new();
146
147 if !warnings.is_empty() {
148 let categorized_warnings = Self::categorize_warnings(warnings);
149 let total_skipped =
150 categorized_warnings.content_type.len() + categorized_warnings.auth.len();
151
152 Self::format_content_type_warnings(
153 &mut lines,
154 &categorized_warnings.content_type,
155 total_operations,
156 total_skipped,
157 indent,
158 );
159 Self::format_auth_warnings(
160 &mut lines,
161 &categorized_warnings.auth,
162 total_operations,
163 total_skipped,
164 indent,
165 !categorized_warnings.content_type.is_empty(),
166 );
167 Self::format_mixed_content_warnings(
168 &mut lines,
169 &categorized_warnings.mixed_content,
170 indent,
171 !categorized_warnings.content_type.is_empty()
172 || !categorized_warnings.auth.is_empty(),
173 );
174 }
175
176 lines
177 }
178
179 pub fn display_validation_warnings(
181 warnings: &[crate::spec::validator::ValidationWarning],
182 total_operations: Option<usize>,
183 ) {
184 if !warnings.is_empty() {
185 let lines = Self::format_validation_warnings(warnings, total_operations, "");
186 for line in lines {
187 if line.is_empty() {
188 eprintln!();
189 } else if line.starts_with("Skipping") || line.starts_with("Endpoints") {
190 eprintln!("{} {line}", crate::constants::MSG_WARNING_PREFIX);
191 } else {
192 eprintln!("{line}");
193 }
194 }
195 eprintln!("\nUse --strict to reject specs with unsupported features.");
196 }
197 }
198
199 pub fn add_spec(
213 &self,
214 name: &str,
215 file_path: &Path,
216 force: bool,
217 strict: bool,
218 ) -> Result<(), Error> {
219 self.check_spec_exists(name, force)?;
220
221 let content = self.fs.read_to_string(file_path)?;
222 let openapi_spec = crate::spec::parse_openapi(&content)?;
223
224 let validator = SpecValidator::new();
226 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
227
228 if !validation_result.is_valid() {
230 return validation_result.into_result();
231 }
232
233 let total_operations = Self::count_total_operations(&openapi_spec);
235
236 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
238
239 self.add_spec_from_validated_openapi(
240 name,
241 &openapi_spec,
242 &content,
243 &validation_result,
244 strict,
245 )
246 }
247
248 #[allow(clippy::future_not_send)]
264 pub async fn add_spec_from_url(
265 &self,
266 name: &str,
267 url: &str,
268 force: bool,
269 strict: bool,
270 ) -> Result<(), Error> {
271 self.check_spec_exists(name, force)?;
272
273 let content = fetch_spec_from_url(url).await?;
275 let openapi_spec = crate::spec::parse_openapi(&content)?;
276
277 let validator = SpecValidator::new();
279 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
280
281 if !validation_result.is_valid() {
283 return validation_result.into_result();
284 }
285
286 let total_operations = Self::count_total_operations(&openapi_spec);
288
289 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
291
292 self.add_spec_from_validated_openapi(
293 name,
294 &openapi_spec,
295 &content,
296 &validation_result,
297 strict,
298 )
299 }
300
301 #[allow(clippy::future_not_send)]
317 pub async fn add_spec_auto(
318 &self,
319 name: &str,
320 file_or_url: &str,
321 force: bool,
322 strict: bool,
323 ) -> Result<(), Error> {
324 if is_url(file_or_url) {
325 self.add_spec_from_url(name, file_or_url, force, strict)
326 .await
327 } else {
328 let path = std::path::Path::new(file_or_url);
330 self.add_spec(name, path, force, strict)
331 }
332 }
333
334 pub fn list_specs(&self) -> Result<Vec<String>, Error> {
340 let specs_dir = self.config_dir.join(crate::constants::DIR_SPECS);
341 if !self.fs.exists(&specs_dir) {
342 return Ok(Vec::new());
343 }
344
345 let mut specs = Vec::new();
346 for entry in self.fs.read_dir(&specs_dir)? {
347 if self.fs.is_file(&entry) {
348 if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
349 if std::path::Path::new(file_name)
350 .extension()
351 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
352 {
353 specs.push(
354 file_name
355 .trim_end_matches(crate::constants::FILE_EXT_YAML)
356 .to_string(),
357 );
358 }
359 }
360 }
361 }
362 Ok(specs)
363 }
364
365 pub fn remove_spec(&self, name: &str) -> Result<(), Error> {
371 let spec_path = self
372 .config_dir
373 .join(crate::constants::DIR_SPECS)
374 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
375 let cache_path = self
376 .config_dir
377 .join(crate::constants::DIR_CACHE)
378 .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
379
380 if !self.fs.exists(&spec_path) {
381 return Err(Error::spec_not_found(name));
382 }
383
384 self.fs.remove_file(&spec_path)?;
385 if self.fs.exists(&cache_path) {
386 self.fs.remove_file(&cache_path)?;
387 }
388
389 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
391 let metadata_manager = CacheMetadataManager::new(&self.fs);
392 let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
394
395 Ok(())
396 }
397
398 pub fn edit_spec(&self, name: &str) -> Result<(), Error> {
407 let spec_path = self
408 .config_dir
409 .join(crate::constants::DIR_SPECS)
410 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
411
412 if !self.fs.exists(&spec_path) {
413 return Err(Error::spec_not_found(name));
414 }
415
416 let editor = std::env::var("EDITOR").map_err(|_| Error::editor_not_set())?;
417
418 Command::new(editor)
419 .arg(&spec_path)
420 .status()
421 .map_err(|e| Error::io_error(format!("Failed to get editor process status: {e}")))?
422 .success()
423 .then_some(()) .ok_or_else(|| Error::editor_failed(name))
425 }
426
427 pub fn load_global_config(&self) -> Result<GlobalConfig, Error> {
433 let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
434 if self.fs.exists(&config_path) {
435 let content = self.fs.read_to_string(&config_path)?;
436 toml::from_str(&content).map_err(|e| Error::invalid_config(e.to_string()))
437 } else {
438 Ok(GlobalConfig::default())
439 }
440 }
441
442 pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
448 let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
449
450 self.fs.create_dir_all(&self.config_dir)?;
452
453 let content = toml::to_string_pretty(config)
454 .map_err(|e| Error::serialization_error(format!("Failed to serialize config: {e}")))?;
455
456 self.fs.write_all(&config_path, content.as_bytes())?;
457 Ok(())
458 }
459
460 pub fn set_url(
471 &self,
472 api_name: &str,
473 url: &str,
474 environment: Option<&str>,
475 ) -> Result<(), Error> {
476 let spec_path = self
478 .config_dir
479 .join(crate::constants::DIR_SPECS)
480 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
481 if !self.fs.exists(&spec_path) {
482 return Err(Error::spec_not_found(api_name));
483 }
484
485 let mut config = self.load_global_config()?;
487
488 let api_config = config
490 .api_configs
491 .entry(api_name.to_string())
492 .or_insert_with(|| ApiConfig {
493 base_url_override: None,
494 environment_urls: HashMap::new(),
495 strict_mode: false,
496 secrets: HashMap::new(),
497 });
498
499 if let Some(env) = environment {
501 api_config
502 .environment_urls
503 .insert(env.to_string(), url.to_string());
504 } else {
505 api_config.base_url_override = Some(url.to_string());
506 }
507
508 self.save_global_config(&config)?;
510 Ok(())
511 }
512
513 #[allow(clippy::type_complexity)]
525 pub fn get_url(
526 &self,
527 api_name: &str,
528 ) -> Result<(Option<String>, HashMap<String, String>, String), Error> {
529 let spec_path = self
531 .config_dir
532 .join(crate::constants::DIR_SPECS)
533 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
534 if !self.fs.exists(&spec_path) {
535 return Err(Error::spec_not_found(api_name));
536 }
537
538 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
540 let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
541
542 let config = self.load_global_config()?;
544
545 let api_config = config.api_configs.get(api_name);
547
548 let base_url_override = api_config.and_then(|c| c.base_url_override.clone());
549 let environment_urls = api_config
550 .map(|c| c.environment_urls.clone())
551 .unwrap_or_default();
552
553 let resolved_url = cached_spec.map_or_else(
555 || "https://api.example.com".to_string(),
556 |spec| {
557 let resolver = BaseUrlResolver::new(&spec);
558 let resolver = if api_config.is_some() {
559 resolver.with_global_config(&config)
560 } else {
561 resolver
562 };
563 resolver.resolve(None)
564 },
565 );
566
567 Ok((base_url_override, environment_urls, resolved_url))
568 }
569
570 #[allow(clippy::type_complexity)]
579 pub fn list_urls(
580 &self,
581 ) -> Result<HashMap<String, (Option<String>, HashMap<String, String>)>, Error> {
582 let config = self.load_global_config()?;
583
584 let mut result = HashMap::new();
585 for (api_name, api_config) in config.api_configs {
586 result.insert(
587 api_name,
588 (api_config.base_url_override, api_config.environment_urls),
589 );
590 }
591
592 Ok(result)
593 }
594
595 #[doc(hidden)]
597 #[allow(clippy::future_not_send)]
598 pub async fn add_spec_from_url_with_timeout(
599 &self,
600 name: &str,
601 url: &str,
602 force: bool,
603 timeout: std::time::Duration,
604 ) -> Result<(), Error> {
605 self.add_spec_from_url_with_timeout_and_mode(name, url, force, timeout, false)
607 .await
608 }
609
610 #[doc(hidden)]
612 #[allow(clippy::future_not_send)]
613 async fn add_spec_from_url_with_timeout_and_mode(
614 &self,
615 name: &str,
616 url: &str,
617 force: bool,
618 timeout: std::time::Duration,
619 strict: bool,
620 ) -> Result<(), Error> {
621 self.check_spec_exists(name, force)?;
622
623 let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
625 let openapi_spec = crate::spec::parse_openapi(&content)?;
626
627 let validator = SpecValidator::new();
629 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
630
631 if !validation_result.is_valid() {
633 return validation_result.into_result();
634 }
635
636 self.add_spec_from_validated_openapi(
639 name,
640 &openapi_spec,
641 &content,
642 &validation_result,
643 strict,
644 )
645 }
646
647 pub fn set_secret(
658 &self,
659 api_name: &str,
660 scheme_name: &str,
661 env_var_name: &str,
662 ) -> Result<(), Error> {
663 let spec_path = self
665 .config_dir
666 .join(crate::constants::DIR_SPECS)
667 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
668 if !self.fs.exists(&spec_path) {
669 return Err(Error::spec_not_found(api_name));
670 }
671
672 let mut config = self.load_global_config()?;
674
675 let api_config = config
677 .api_configs
678 .entry(api_name.to_string())
679 .or_insert_with(|| ApiConfig {
680 base_url_override: None,
681 environment_urls: HashMap::new(),
682 strict_mode: false,
683 secrets: HashMap::new(),
684 });
685
686 api_config.secrets.insert(
688 scheme_name.to_string(),
689 ApertureSecret {
690 source: SecretSource::Env,
691 name: env_var_name.to_string(),
692 },
693 );
694
695 self.save_global_config(&config)?;
697 Ok(())
698 }
699
700 pub fn list_secrets(&self, api_name: &str) -> Result<HashMap<String, ApertureSecret>, Error> {
712 let spec_path = self
714 .config_dir
715 .join(crate::constants::DIR_SPECS)
716 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
717 if !self.fs.exists(&spec_path) {
718 return Err(Error::spec_not_found(api_name));
719 }
720
721 let config = self.load_global_config()?;
723
724 let secrets = config
726 .api_configs
727 .get(api_name)
728 .map(|c| c.secrets.clone())
729 .unwrap_or_default();
730
731 Ok(secrets)
732 }
733
734 pub fn get_secret(
747 &self,
748 api_name: &str,
749 scheme_name: &str,
750 ) -> Result<Option<ApertureSecret>, Error> {
751 let secrets = self.list_secrets(api_name)?;
752 Ok(secrets.get(scheme_name).cloned())
753 }
754
755 pub fn remove_secret(&self, api_name: &str, scheme_name: &str) -> Result<(), Error> {
764 let spec_path = self
766 .config_dir
767 .join(crate::constants::DIR_SPECS)
768 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
769 if !self.fs.exists(&spec_path) {
770 return Err(Error::spec_not_found(api_name));
771 }
772
773 let mut config = self.load_global_config()?;
775
776 let Some(api_config) = config.api_configs.get_mut(api_name) else {
778 return Err(Error::invalid_config(format!(
779 "No secrets configured for API '{api_name}'"
780 )));
781 };
782
783 if api_config.secrets.is_empty() {
785 return Err(Error::invalid_config(format!(
786 "No secrets configured for API '{api_name}'"
787 )));
788 }
789
790 if !api_config.secrets.contains_key(scheme_name) {
792 return Err(Error::invalid_config(format!(
793 "Secret for scheme '{scheme_name}' is not configured for API '{api_name}'"
794 )));
795 }
796
797 api_config.secrets.remove(scheme_name);
799
800 if api_config.secrets.is_empty() && api_config.base_url_override.is_none() {
802 config.api_configs.remove(api_name);
803 }
804
805 self.save_global_config(&config)?;
807
808 Ok(())
809 }
810
811 pub fn clear_secrets(&self, api_name: &str) -> Result<(), Error> {
819 let spec_path = self
821 .config_dir
822 .join(crate::constants::DIR_SPECS)
823 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
824 if !self.fs.exists(&spec_path) {
825 return Err(Error::spec_not_found(api_name));
826 }
827
828 let mut config = self.load_global_config()?;
830
831 if let Some(api_config) = config.api_configs.get_mut(api_name) {
833 api_config.secrets.clear();
835
836 if api_config.base_url_override.is_none() {
838 config.api_configs.remove(api_name);
839 }
840
841 self.save_global_config(&config)?;
843 }
844 Ok(())
847 }
848
849 pub fn set_secret_interactive(&self, api_name: &str) -> Result<(), Error> {
870 let (cached_spec, current_secrets) = self.load_spec_for_interactive_config(api_name)?;
872
873 if cached_spec.security_schemes.is_empty() {
874 println!("No security schemes found in API '{api_name}'.");
875 return Ok(());
876 }
877
878 Self::display_interactive_header(api_name, &cached_spec);
879
880 let options = Self::build_security_scheme_options(&cached_spec, ¤t_secrets);
882
883 self.run_interactive_configuration_loop(
885 api_name,
886 &cached_spec,
887 ¤t_secrets,
888 &options,
889 )?;
890
891 println!("\nInteractive configuration complete!");
892 Ok(())
893 }
894
895 fn check_spec_exists(&self, name: &str, force: bool) -> Result<(), Error> {
901 let spec_path = self
902 .config_dir
903 .join(crate::constants::DIR_SPECS)
904 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
905
906 if self.fs.exists(&spec_path) && !force {
907 return Err(Error::spec_already_exists(name));
908 }
909
910 Ok(())
911 }
912
913 fn transform_spec_to_cached(
919 name: &str,
920 openapi_spec: &OpenAPI,
921 validation_result: &crate::spec::validator::ValidationResult,
922 ) -> Result<crate::cache::models::CachedSpec, Error> {
923 let transformer = SpecTransformer::new();
924
925 let skip_endpoints: Vec<(String, String)> = validation_result
927 .warnings
928 .iter()
929 .filter_map(super::super::spec::validator::ValidationWarning::to_skip_endpoint)
930 .collect();
931
932 transformer.transform_with_warnings(
933 name,
934 openapi_spec,
935 &skip_endpoints,
936 &validation_result.warnings,
937 )
938 }
939
940 fn create_spec_directories(&self, name: &str) -> Result<(PathBuf, PathBuf), Error> {
946 let spec_path = self
947 .config_dir
948 .join(crate::constants::DIR_SPECS)
949 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
950 let cache_path = self
951 .config_dir
952 .join(crate::constants::DIR_CACHE)
953 .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
954
955 let spec_parent = spec_path.parent().ok_or_else(|| {
956 Error::invalid_path(
957 spec_path.display().to_string(),
958 "Path has no parent directory",
959 )
960 })?;
961 let cache_parent = cache_path.parent().ok_or_else(|| {
962 Error::invalid_path(
963 cache_path.display().to_string(),
964 "Path has no parent directory",
965 )
966 })?;
967
968 self.fs.create_dir_all(spec_parent)?;
969 self.fs.create_dir_all(cache_parent)?;
970
971 Ok((spec_path, cache_path))
972 }
973
974 fn write_spec_files(
980 &self,
981 name: &str,
982 content: &str,
983 cached_spec: &crate::cache::models::CachedSpec,
984 spec_path: &Path,
985 cache_path: &Path,
986 ) -> Result<(), Error> {
987 self.fs.write_all(spec_path, content.as_bytes())?;
989
990 let cached_data = bincode::serialize(cached_spec)
992 .map_err(|e| Error::serialization_error(e.to_string()))?;
993 self.fs.write_all(cache_path, &cached_data)?;
994
995 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
997 let metadata_manager = CacheMetadataManager::new(&self.fs);
998 metadata_manager.update_spec_metadata(&cache_dir, name, cached_data.len() as u64)?;
999
1000 Ok(())
1001 }
1002
1003 fn add_spec_from_validated_openapi(
1009 &self,
1010 name: &str,
1011 openapi_spec: &OpenAPI,
1012 content: &str,
1013 validation_result: &crate::spec::validator::ValidationResult,
1014 strict: bool,
1015 ) -> Result<(), Error> {
1016 let cached_spec = Self::transform_spec_to_cached(name, openapi_spec, validation_result)?;
1018
1019 let (spec_path, cache_path) = self.create_spec_directories(name)?;
1021
1022 self.write_spec_files(name, content, &cached_spec, &spec_path, &cache_path)?;
1024
1025 self.save_strict_preference(name, strict)?;
1027
1028 Ok(())
1029 }
1030
1031 fn load_spec_for_interactive_config(
1037 &self,
1038 api_name: &str,
1039 ) -> Result<
1040 (
1041 crate::cache::models::CachedSpec,
1042 std::collections::HashMap<String, ApertureSecret>,
1043 ),
1044 Error,
1045 > {
1046 let spec_path = self
1048 .config_dir
1049 .join(crate::constants::DIR_SPECS)
1050 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
1051 if !self.fs.exists(&spec_path) {
1052 return Err(Error::spec_not_found(api_name));
1053 }
1054
1055 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
1057 let cached_spec = loader::load_cached_spec(&cache_dir, api_name)?;
1058
1059 let current_secrets = self.list_secrets(api_name)?;
1061
1062 Ok((cached_spec, current_secrets))
1063 }
1064
1065 fn display_interactive_header(api_name: &str, cached_spec: &crate::cache::models::CachedSpec) {
1067 println!("Interactive Secret Configuration for API: {api_name}");
1068 println!(
1069 "Found {} security scheme(s):\n",
1070 cached_spec.security_schemes.len()
1071 );
1072 }
1073
1074 fn build_security_scheme_options(
1076 cached_spec: &crate::cache::models::CachedSpec,
1077 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1078 ) -> Vec<(String, String)> {
1079 cached_spec
1080 .security_schemes
1081 .values()
1082 .map(|scheme| {
1083 let mut description = format!("{} ({})", scheme.scheme_type, scheme.name);
1084
1085 match scheme.scheme_type.as_str() {
1087 constants::AUTH_SCHEME_APIKEY => {
1088 if let (Some(location), Some(param)) =
1089 (&scheme.location, &scheme.parameter_name)
1090 {
1091 description = format!("{description} - {location} parameter: {param}");
1092 }
1093 }
1094 "http" => {
1095 if let Some(http_scheme) = &scheme.scheme {
1096 description = format!("{description} - {http_scheme} authentication");
1097 }
1098 }
1099 _ => {}
1100 }
1101
1102 if current_secrets.contains_key(&scheme.name) {
1104 description = format!("{description} [CONFIGURED]");
1105 } else if scheme.aperture_secret.is_some() {
1106 description = format!("{description} [x-aperture-secret]");
1107 } else {
1108 description = format!("{description} [NOT CONFIGURED]");
1109 }
1110
1111 if let Some(openapi_desc) = &scheme.description {
1113 description = format!("{description} - {openapi_desc}");
1114 }
1115
1116 (scheme.name.clone(), description)
1117 })
1118 .collect()
1119 }
1120
1121 fn run_interactive_configuration_loop(
1127 &self,
1128 api_name: &str,
1129 cached_spec: &crate::cache::models::CachedSpec,
1130 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1131 options: &[(String, String)],
1132 ) -> Result<(), Error> {
1133 use crate::interactive::{confirm, prompt_for_input, select_from_options};
1134
1135 loop {
1136 let selected_scheme =
1137 select_from_options("\nSelect a security scheme to configure:", options)?;
1138
1139 let scheme = cached_spec.security_schemes.get(&selected_scheme).expect(
1140 "Selected scheme should exist in cached spec - menu validation ensures this",
1141 );
1142
1143 Self::display_scheme_configuration_details(&selected_scheme, scheme, current_secrets);
1144
1145 let env_var = prompt_for_input(&format!(
1147 "\nEnter environment variable name for '{selected_scheme}' (or press Enter to skip): "
1148 ))?;
1149
1150 if env_var.is_empty() {
1151 println!("Skipping configuration for '{selected_scheme}'");
1152 } else {
1153 self.handle_secret_configuration(api_name, &selected_scheme, &env_var)?;
1154 }
1155
1156 if !confirm("\nConfigure another security scheme?")? {
1158 break;
1159 }
1160 }
1161
1162 Ok(())
1163 }
1164
1165 fn display_scheme_configuration_details(
1167 selected_scheme: &str,
1168 scheme: &crate::cache::models::CachedSecurityScheme,
1169 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1170 ) {
1171 println!("\nConfiguration for '{selected_scheme}':");
1172 println!(" Type: {}", scheme.scheme_type);
1173 if let Some(desc) = &scheme.description {
1174 println!(" Description: {desc}");
1175 }
1176
1177 if let Some(current_secret) = current_secrets.get(selected_scheme) {
1179 println!(" Current: environment variable '{}'", current_secret.name);
1180 } else if let Some(aperture_secret) = &scheme.aperture_secret {
1181 println!(
1182 " Current: x-aperture-secret -> '{}'",
1183 aperture_secret.name
1184 );
1185 } else {
1186 println!(" Current: not configured");
1187 }
1188 }
1189
1190 fn handle_secret_configuration(
1196 &self,
1197 api_name: &str,
1198 selected_scheme: &str,
1199 env_var: &str,
1200 ) -> Result<(), Error> {
1201 use crate::interactive::confirm;
1202
1203 if let Err(e) = crate::interactive::validate_env_var_name(env_var) {
1205 println!("Invalid environment variable name: {e}");
1206 return Ok(()); }
1208
1209 println!("\nConfiguration Preview:");
1211 println!(" API: {api_name}");
1212 println!(" Scheme: {selected_scheme}");
1213 println!(" Environment Variable: {env_var}");
1214
1215 if confirm("Apply this configuration?")? {
1216 self.set_secret(api_name, selected_scheme, env_var)?;
1217 println!("Configuration saved successfully!");
1218 } else {
1219 println!("Configuration cancelled.");
1220 }
1221
1222 Ok(())
1223 }
1224
1225 fn categorize_warnings(
1227 warnings: &[crate::spec::validator::ValidationWarning],
1228 ) -> CategorizedWarnings<'_> {
1229 let mut categorized = CategorizedWarnings {
1230 content_type: Vec::new(),
1231 auth: Vec::new(),
1232 mixed_content: Vec::new(),
1233 };
1234
1235 for warning in warnings {
1236 if warning.reason.contains("no supported content types") {
1237 categorized.content_type.push(warning);
1238 } else if warning.reason.contains("unsupported authentication") {
1239 categorized.auth.push(warning);
1240 } else if warning
1241 .reason
1242 .contains("unsupported content types alongside JSON")
1243 {
1244 categorized.mixed_content.push(warning);
1245 }
1246 }
1247
1248 categorized
1249 }
1250
1251 fn format_content_type_warnings(
1253 lines: &mut Vec<String>,
1254 content_type_warnings: &[&crate::spec::validator::ValidationWarning],
1255 total_operations: Option<usize>,
1256 total_skipped: usize,
1257 indent: &str,
1258 ) {
1259 if content_type_warnings.is_empty() {
1260 return;
1261 }
1262
1263 let warning_msg = total_operations.map_or_else(
1264 || {
1265 format!(
1266 "{}Skipping {} endpoints with unsupported content types:",
1267 indent,
1268 content_type_warnings.len()
1269 )
1270 },
1271 |total| {
1272 let available = total.saturating_sub(total_skipped);
1273 format!(
1274 "{}Skipping {} endpoints with unsupported content types ({} of {} endpoints will be available):",
1275 indent,
1276 content_type_warnings.len(),
1277 available,
1278 total
1279 )
1280 },
1281 );
1282 lines.push(warning_msg);
1283
1284 for warning in content_type_warnings {
1285 lines.push(format!(
1286 "{} - {} {} ({}) - {}",
1287 indent,
1288 warning.endpoint.method,
1289 warning.endpoint.path,
1290 warning.endpoint.content_type,
1291 warning.reason
1292 ));
1293 }
1294 }
1295
1296 fn format_auth_warnings(
1298 lines: &mut Vec<String>,
1299 auth_warnings: &[&crate::spec::validator::ValidationWarning],
1300 total_operations: Option<usize>,
1301 total_skipped: usize,
1302 indent: &str,
1303 add_blank_line: bool,
1304 ) {
1305 if auth_warnings.is_empty() {
1306 return;
1307 }
1308
1309 if add_blank_line {
1310 lines.push(String::new()); }
1312
1313 let warning_msg = total_operations.map_or_else(
1314 || {
1315 format!(
1316 "{}Skipping {} endpoints with unsupported authentication:",
1317 indent,
1318 auth_warnings.len()
1319 )
1320 },
1321 |total| {
1322 let available = total.saturating_sub(total_skipped);
1323 format!(
1324 "{}Skipping {} endpoints with unsupported authentication ({} of {} endpoints will be available):",
1325 indent,
1326 auth_warnings.len(),
1327 available,
1328 total
1329 )
1330 },
1331 );
1332 lines.push(warning_msg);
1333
1334 for warning in auth_warnings {
1335 lines.push(format!(
1336 "{} - {} {} - {}",
1337 indent, warning.endpoint.method, warning.endpoint.path, warning.reason
1338 ));
1339 }
1340 }
1341
1342 fn format_mixed_content_warnings(
1344 lines: &mut Vec<String>,
1345 mixed_content_warnings: &[&crate::spec::validator::ValidationWarning],
1346 indent: &str,
1347 add_blank_line: bool,
1348 ) {
1349 if mixed_content_warnings.is_empty() {
1350 return;
1351 }
1352
1353 if add_blank_line {
1354 lines.push(String::new()); }
1356
1357 lines.push(format!(
1358 "{indent}Endpoints with partial content type support:"
1359 ));
1360 for warning in mixed_content_warnings {
1361 lines.push(format!(
1362 "{} - {} {} supports JSON but not: {}",
1363 indent,
1364 warning.endpoint.method,
1365 warning.endpoint.path,
1366 warning.endpoint.content_type
1367 ));
1368 }
1369 }
1370}
1371
1372pub fn get_config_dir() -> Result<PathBuf, Error> {
1378 let home_dir = dirs::home_dir().ok_or_else(Error::home_directory_not_found)?;
1379 let config_dir = home_dir.join(".config").join("aperture");
1380 Ok(config_dir)
1381}
1382
1383#[must_use]
1385pub fn is_url(input: &str) -> bool {
1386 input.starts_with("http://") || input.starts_with("https://")
1387}
1388
1389const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; #[allow(clippy::future_not_send)]
1401async fn fetch_spec_from_url(url: &str) -> Result<String, Error> {
1402 fetch_spec_from_url_with_timeout(url, std::time::Duration::from_secs(30)).await
1403}
1404
1405#[allow(clippy::future_not_send)]
1406async fn fetch_spec_from_url_with_timeout(
1407 url: &str,
1408 timeout: std::time::Duration,
1409) -> Result<String, Error> {
1410 let client = reqwest::Client::builder()
1412 .timeout(timeout)
1413 .build()
1414 .map_err(|e| Error::network_request_failed(format!("Failed to create HTTP client: {e}")))?;
1415
1416 let response = client.get(url).send().await.map_err(|e| {
1418 if e.is_timeout() {
1419 Error::network_request_failed(format!(
1420 "Request timed out after {} seconds",
1421 timeout.as_secs()
1422 ))
1423 } else if e.is_connect() {
1424 Error::network_request_failed(format!("Failed to connect to {url}: {e}"))
1425 } else {
1426 Error::network_request_failed(format!("Network error: {e}"))
1427 }
1428 })?;
1429
1430 if !response.status().is_success() {
1432 return Err(Error::request_failed(
1433 response.status(),
1434 format!("HTTP {} from {url}", response.status()),
1435 ));
1436 }
1437
1438 if let Some(content_length) = response.content_length() {
1440 if content_length > MAX_RESPONSE_SIZE {
1441 return Err(Error::network_request_failed(format!(
1442 "Response too large: {content_length} bytes (max {MAX_RESPONSE_SIZE} bytes)"
1443 )));
1444 }
1445 }
1446
1447 let bytes = response
1449 .bytes()
1450 .await
1451 .map_err(|e| Error::network_request_failed(format!("Failed to read response body: {e}")))?;
1452
1453 if bytes.len() > usize::try_from(MAX_RESPONSE_SIZE).unwrap_or(usize::MAX) {
1455 return Err(Error::network_request_failed(format!(
1456 "Response too large: {} bytes (max {MAX_RESPONSE_SIZE} bytes)",
1457 bytes.len()
1458 )));
1459 }
1460
1461 String::from_utf8(bytes.to_vec())
1463 .map_err(|e| Error::network_request_failed(format!("Invalid UTF-8 in response: {e}")))
1464}