aperture_cli/config/
server_variable_resolver.rs

1use crate::cache::models::{CachedSpec, ServerVariable};
2use crate::error::Error;
3use std::collections::HashMap;
4
5/// Resolves server template variables by parsing CLI arguments and applying validation
6pub struct ServerVariableResolver<'a> {
7    spec: &'a CachedSpec,
8}
9
10impl<'a> ServerVariableResolver<'a> {
11    /// Creates a new resolver for the given spec
12    #[must_use]
13    pub const fn new(spec: &'a CachedSpec) -> Self {
14        Self { spec }
15    }
16
17    /// Parses and validates server variables from CLI arguments
18    ///
19    /// # Arguments
20    /// * `server_var_args` - Command line arguments in format "key=value"
21    ///
22    /// # Returns
23    /// * `Ok(HashMap<String, String>)` - Resolved server variables ready for URL substitution
24    /// * `Err(Error)` - Validation errors or parsing failures
25    ///
26    /// # Errors
27    /// Returns errors for:
28    /// - Invalid key=value format
29    /// - Unknown server variables not defined in `OpenAPI` spec
30    /// - Enum constraint violations
31    /// - Missing required variables (when defaults are not available)
32    pub fn resolve_variables(
33        &self,
34        server_var_args: &[String],
35    ) -> Result<HashMap<String, String>, Error> {
36        let mut resolved_vars = HashMap::new();
37
38        // Parse CLI arguments
39        for arg in server_var_args {
40            let (key, value) = Self::parse_key_value(arg)?;
41            resolved_vars.insert(key, value);
42        }
43
44        // Validate and apply defaults
45        let mut final_vars = HashMap::new();
46
47        for (var_name, var_def) in &self.spec.server_variables {
48            if let Some(provided_value) = resolved_vars.get(var_name) {
49                // Validate provided value against enum constraints
50                Self::validate_enum_constraint(var_name, provided_value, var_def)?;
51                final_vars.insert(var_name.clone(), provided_value.clone());
52            } else if let Some(default_value) = &var_def.default {
53                // Validate default value against enum constraints
54                Self::validate_enum_constraint(var_name, default_value, var_def)?;
55                // Use default value
56                final_vars.insert(var_name.clone(), default_value.clone());
57            } else {
58                // Required variable with no default - this is an error
59                return Err(Error::MissingServerVariable {
60                    name: var_name.clone(),
61                });
62            }
63        }
64
65        // Check for unknown variables provided by user
66        for provided_var in resolved_vars.keys() {
67            if !self.spec.server_variables.contains_key(provided_var) {
68                return Err(Error::UnknownServerVariable {
69                    name: provided_var.clone(),
70                    available: self.spec.server_variables.keys().cloned().collect(),
71                });
72            }
73        }
74
75        Ok(final_vars)
76    }
77
78    /// Substitutes server variables in a URL template
79    ///
80    /// # Arguments
81    /// * `url_template` - URL with template variables like `<https://{region}.api.com>`
82    /// * `variables` - Resolved variable values from `resolve_variables`
83    ///
84    /// # Returns
85    /// * `Ok(String)` - URL with all variables substituted
86    /// * `Err(Error)` - If template contains variables not in the provided map
87    ///
88    /// # Errors
89    /// Returns errors for:
90    /// - Unresolved template variables not found in the provided variables map
91    /// - Invalid template variable names (malformed or too long)
92    pub fn substitute_url(
93        &self,
94        url_template: &str,
95        variables: &HashMap<String, String>,
96    ) -> Result<String, Error> {
97        let mut result = url_template.to_string();
98        let mut start = 0;
99
100        while let Some((open_pos, close_pos)) = find_next_template(&result, start) {
101            let var_name = &result[open_pos + 1..close_pos];
102            Self::validate_template_variable_name(var_name)?;
103
104            let value = Self::get_variable_value(var_name, variables, url_template)?;
105
106            // Perform minimal URL encoding to preserve URL structure while escaping dangerous characters
107            // We don't encode forward slashes as server variables often contain path segments
108            let encoded_value = Self::encode_server_variable(value);
109
110            result.replace_range(open_pos..=close_pos, &encoded_value);
111            start = open_pos + encoded_value.len();
112        }
113
114        Ok(result)
115    }
116
117    /// Gets the value for a template variable, returning an error if not found
118    fn get_variable_value<'b>(
119        var_name: &str,
120        variables: &'b HashMap<String, String>,
121        url_template: &str,
122    ) -> Result<&'b String, Error> {
123        variables
124            .get(var_name)
125            .ok_or_else(|| Error::UnresolvedTemplateVariable {
126                name: var_name.to_string(),
127                url: url_template.to_string(),
128            })
129    }
130
131    /// Parses a key=value string from CLI arguments
132    fn parse_key_value(arg: &str) -> Result<(String, String), Error> {
133        let Some(eq_pos) = arg.find('=') else {
134            return Err(Error::InvalidServerVarFormat {
135                arg: arg.to_string(),
136                reason: "Expected format: key=value".to_string(),
137            });
138        };
139
140        let key = arg[..eq_pos].trim();
141        let value = arg[eq_pos + 1..].trim();
142
143        if key.is_empty() {
144            return Err(Error::InvalidServerVarFormat {
145                arg: arg.to_string(),
146                reason: "Empty variable name".to_string(),
147            });
148        }
149
150        if value.is_empty() {
151            return Err(Error::InvalidServerVarFormat {
152                arg: arg.to_string(),
153                reason: "Empty variable value".to_string(),
154            });
155        }
156
157        Ok((key.to_string(), value.to_string()))
158    }
159
160    /// Validates a value against enum constraints if defined
161    fn validate_enum_constraint(
162        var_name: &str,
163        value: &str,
164        var_def: &ServerVariable,
165    ) -> Result<(), Error> {
166        if !var_def.enum_values.is_empty() && !var_def.enum_values.contains(&value.to_string()) {
167            return Err(Error::InvalidServerVarValue {
168                name: var_name.to_string(),
169                value: value.to_string(),
170                allowed_values: var_def.enum_values.clone(),
171            });
172        }
173        Ok(())
174    }
175
176    /// Validates a template variable name according to `OpenAPI` identifier rules
177    fn validate_template_variable_name(name: &str) -> Result<(), Error> {
178        if name.is_empty() {
179            return Err(Error::InvalidServerVarFormat {
180                arg: "{}".to_string(),
181                reason: "Empty template variable name".to_string(),
182            });
183        }
184
185        if name.len() > 64 {
186            return Err(Error::InvalidServerVarFormat {
187                arg: format!("{{{name}}}"),
188                reason: "Template variable name too long (max 64 chars)".to_string(),
189            });
190        }
191
192        // OpenAPI identifier rules: must start with letter or underscore,
193        // followed by letters, digits, or underscores
194        let mut chars = name.chars();
195        let Some(first_char) = chars.next() else {
196            return Ok(()); // Already checked for empty above
197        };
198
199        if !first_char.is_ascii_alphabetic() && first_char != '_' {
200            return Err(Error::InvalidServerVarFormat {
201                arg: format!("{{{name}}}"),
202                reason: "Template variable names must start with a letter or underscore"
203                    .to_string(),
204            });
205        }
206
207        for char in chars {
208            if !char.is_ascii_alphanumeric() && char != '_' {
209                return Err(Error::InvalidServerVarFormat {
210                    arg: format!("{{{name}}}"),
211                    reason:
212                        "Template variable names must contain only letters, digits, or underscores"
213                            .to_string(),
214                });
215            }
216        }
217
218        Ok(())
219    }
220
221    /// Encodes a server variable value for safe inclusion in URLs
222    /// This performs selective encoding to preserve URL structure while escaping problematic characters
223    fn encode_server_variable(value: &str) -> String {
224        // Characters that should be encoded in server variable values
225        // We preserve forward slashes as they're often used in path segments
226        // but encode other special characters that could break URL parsing
227        value
228            .chars()
229            .map(|c| match c {
230                // Preserve forward slashes and common URL-safe characters
231                '/' | '-' | '_' | '.' | '~' => c.to_string(),
232                // Encode spaces and other special characters
233                ' ' => "%20".to_string(),
234                '?' => "%3F".to_string(),
235                '#' => "%23".to_string(),
236                '[' => "%5B".to_string(),
237                ']' => "%5D".to_string(),
238                '@' => "%40".to_string(),
239                '!' => "%21".to_string(),
240                '$' => "%24".to_string(),
241                '&' => "%26".to_string(),
242                '\'' => "%27".to_string(),
243                '(' => "%28".to_string(),
244                ')' => "%29".to_string(),
245                '*' => "%2A".to_string(),
246                '+' => "%2B".to_string(),
247                ',' => "%2C".to_string(),
248                ';' => "%3B".to_string(),
249                '=' => "%3D".to_string(),
250                '{' => "%7B".to_string(),
251                '}' => "%7D".to_string(),
252                // Keep alphanumeric and other unreserved characters as-is
253                c if c.is_ascii_alphanumeric() => c.to_string(),
254                // Encode any other characters
255                c => urlencoding::encode(&c.to_string()).to_string(),
256            })
257            .collect()
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::cache::models::{CachedSpec, ServerVariable};
265    use std::collections::HashMap;
266
267    fn create_test_spec_with_variables() -> CachedSpec {
268        let mut server_variables = HashMap::new();
269
270        // Required variable with enum constraint
271        server_variables.insert(
272            "region".to_string(),
273            ServerVariable {
274                default: Some("us".to_string()),
275                enum_values: vec!["us".to_string(), "eu".to_string(), "ap".to_string()],
276                description: Some("API region".to_string()),
277            },
278        );
279
280        // Required variable without default
281        server_variables.insert(
282            "env".to_string(),
283            ServerVariable {
284                default: None,
285                enum_values: vec!["dev".to_string(), "staging".to_string(), "prod".to_string()],
286                description: Some("Environment".to_string()),
287            },
288        );
289
290        CachedSpec {
291            cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
292            name: "test-api".to_string(),
293            version: "1.0.0".to_string(),
294            commands: vec![],
295            base_url: Some("https://{region}-{env}.api.example.com".to_string()),
296            servers: vec!["https://{region}-{env}.api.example.com".to_string()],
297            security_schemes: HashMap::new(),
298            skipped_endpoints: vec![],
299            server_variables,
300        }
301    }
302
303    #[test]
304    fn test_resolve_variables_with_all_provided() {
305        let spec = create_test_spec_with_variables();
306        let resolver = ServerVariableResolver::new(&spec);
307
308        let args = vec!["region=eu".to_string(), "env=staging".to_string()];
309        let result = resolver.resolve_variables(&args).unwrap();
310
311        assert_eq!(result.get("region"), Some(&"eu".to_string()));
312        assert_eq!(result.get("env"), Some(&"staging".to_string()));
313    }
314
315    #[test]
316    fn test_resolve_variables_with_defaults() {
317        let spec = create_test_spec_with_variables();
318        let resolver = ServerVariableResolver::new(&spec);
319
320        let args = vec!["env=prod".to_string()]; // Only provide required var, let region use default
321        let result = resolver.resolve_variables(&args).unwrap();
322
323        assert_eq!(result.get("region"), Some(&"us".to_string())); // Default value
324        assert_eq!(result.get("env"), Some(&"prod".to_string()));
325    }
326
327    #[test]
328    fn test_invalid_enum_value() {
329        let spec = create_test_spec_with_variables();
330        let resolver = ServerVariableResolver::new(&spec);
331
332        let args = vec!["region=invalid".to_string(), "env=prod".to_string()];
333        let result = resolver.resolve_variables(&args);
334
335        assert!(result.is_err());
336        match result.unwrap_err() {
337            Error::InvalidServerVarValue {
338                name,
339                value,
340                allowed_values,
341            } => {
342                assert_eq!(name, "region");
343                assert_eq!(value, "invalid");
344                assert!(allowed_values.contains(&"us".to_string()));
345            }
346            _ => panic!("Expected InvalidServerVarValue error"),
347        }
348    }
349
350    #[test]
351    fn test_missing_required_variable() {
352        let spec = create_test_spec_with_variables();
353        let resolver = ServerVariableResolver::new(&spec);
354
355        let args = vec!["region=us".to_string()]; // Missing required 'env'
356        let result = resolver.resolve_variables(&args);
357
358        assert!(result.is_err());
359        match result.unwrap_err() {
360            Error::MissingServerVariable { name } => {
361                assert_eq!(name, "env");
362            }
363            _ => panic!("Expected MissingServerVariable error"),
364        }
365    }
366
367    #[test]
368    fn test_unknown_variable() {
369        let spec = create_test_spec_with_variables();
370        let resolver = ServerVariableResolver::new(&spec);
371
372        let args = vec![
373            "region=us".to_string(),
374            "env=prod".to_string(),
375            "unknown=value".to_string(),
376        ];
377        let result = resolver.resolve_variables(&args);
378
379        assert!(result.is_err());
380        match result.unwrap_err() {
381            Error::UnknownServerVariable { name, .. } => {
382                assert_eq!(name, "unknown");
383            }
384            _ => panic!("Expected UnknownServerVariable error"),
385        }
386    }
387
388    #[test]
389    fn test_invalid_format() {
390        let spec = create_test_spec_with_variables();
391        let resolver = ServerVariableResolver::new(&spec);
392
393        let args = vec!["invalid-format".to_string()];
394        let result = resolver.resolve_variables(&args);
395
396        assert!(result.is_err());
397        match result.unwrap_err() {
398            Error::InvalidServerVarFormat { .. } => {
399                // Expected
400            }
401            _ => panic!("Expected InvalidServerVarFormat error"),
402        }
403    }
404
405    #[test]
406    fn test_substitute_url() {
407        let spec = create_test_spec_with_variables();
408        let resolver = ServerVariableResolver::new(&spec);
409
410        let mut variables = HashMap::new();
411        variables.insert("region".to_string(), "eu".to_string());
412        variables.insert("env".to_string(), "staging".to_string());
413
414        let result = resolver
415            .substitute_url("https://{region}-{env}.api.example.com", &variables)
416            .unwrap();
417        assert_eq!(result, "https://eu-staging.api.example.com");
418    }
419
420    #[test]
421    fn test_substitute_url_missing_variable() {
422        let spec = create_test_spec_with_variables();
423        let resolver = ServerVariableResolver::new(&spec);
424
425        let mut variables = HashMap::new();
426        variables.insert("region".to_string(), "eu".to_string());
427        // Missing 'env' variable
428
429        let result = resolver.substitute_url("https://{region}-{env}.api.example.com", &variables);
430
431        assert!(result.is_err());
432        match result.unwrap_err() {
433            Error::UnresolvedTemplateVariable { name, .. } => {
434                assert_eq!(name, "env");
435            }
436            _ => panic!("Expected UnresolvedTemplateVariable error"),
437        }
438    }
439
440    #[test]
441    fn test_template_variable_name_validation_empty() {
442        let spec = create_test_spec_with_variables();
443        let resolver = ServerVariableResolver::new(&spec);
444
445        let variables = HashMap::new();
446        let result = resolver.substitute_url("https://{}.api.example.com", &variables);
447
448        assert!(result.is_err());
449        match result.unwrap_err() {
450            Error::InvalidServerVarFormat { arg, reason } => {
451                assert_eq!(arg, "{}");
452                assert!(reason.contains("Empty template variable name"));
453            }
454            _ => panic!("Expected InvalidServerVarFormat error"),
455        }
456    }
457
458    #[test]
459    fn test_template_variable_name_validation_invalid_chars() {
460        let spec = create_test_spec_with_variables();
461        let resolver = ServerVariableResolver::new(&spec);
462
463        let variables = HashMap::new();
464        let result = resolver.substitute_url("https://{invalid-name}.api.example.com", &variables);
465
466        assert!(result.is_err());
467        match result.unwrap_err() {
468            Error::InvalidServerVarFormat { arg, reason } => {
469                assert_eq!(arg, "{invalid-name}");
470                assert!(reason.contains("letters, digits, or underscores"));
471            }
472            _ => panic!("Expected InvalidServerVarFormat error"),
473        }
474    }
475
476    #[test]
477    fn test_template_variable_name_validation_too_long() {
478        let spec = create_test_spec_with_variables();
479        let resolver = ServerVariableResolver::new(&spec);
480
481        let long_name = "a".repeat(65); // Longer than 64 chars
482        let variables = HashMap::new();
483        let result = resolver.substitute_url(
484            &format!("https://{{{long_name}}}.api.example.com"),
485            &variables,
486        );
487
488        assert!(result.is_err());
489        match result.unwrap_err() {
490            Error::InvalidServerVarFormat { reason, .. } => {
491                assert!(reason.contains("too long"));
492            }
493            _ => panic!("Expected InvalidServerVarFormat error"),
494        }
495    }
496
497    #[test]
498    fn test_template_variable_name_validation_valid_names() {
499        let spec = create_test_spec_with_variables();
500        let resolver = ServerVariableResolver::new(&spec);
501
502        let mut variables = HashMap::new();
503        variables.insert("valid_name".to_string(), "test".to_string());
504        variables.insert("_underscore".to_string(), "test".to_string());
505        variables.insert("name123".to_string(), "test".to_string());
506
507        // These should all pass validation (though they'll fail with UnresolvedTemplateVariable)
508        let test_cases = vec![
509            "https://{valid_name}.api.com",
510            "https://{_underscore}.api.com",
511            "https://{name123}.api.com",
512        ];
513
514        for test_case in test_cases {
515            let result = resolver.substitute_url(test_case, &variables);
516            // Should not fail with InvalidServerVarFormat
517            if let Err(Error::InvalidServerVarFormat { .. }) = result {
518                panic!("Template variable name validation failed for: {test_case}");
519            }
520        }
521    }
522
523    #[test]
524    fn test_empty_default_value() {
525        let mut server_variables = HashMap::new();
526
527        // Variable with empty string default
528        server_variables.insert(
529            "prefix".to_string(),
530            ServerVariable {
531                default: Some("".to_string()),
532                enum_values: vec![],
533                description: Some("Optional prefix".to_string()),
534            },
535        );
536
537        let spec = CachedSpec {
538            cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
539            name: "test-api".to_string(),
540            version: "1.0.0".to_string(),
541            commands: vec![],
542            base_url: Some("https://{prefix}api.example.com".to_string()),
543            servers: vec!["https://{prefix}api.example.com".to_string()],
544            security_schemes: HashMap::new(),
545            skipped_endpoints: vec![],
546            server_variables,
547        };
548
549        let resolver = ServerVariableResolver::new(&spec);
550
551        // Test with no args - should use empty string default
552        let result = resolver.resolve_variables(&[]).unwrap();
553        assert_eq!(result.get("prefix"), Some(&"".to_string()));
554
555        // Test substitution with empty string default
556        let url = resolver
557            .substitute_url("https://{prefix}api.example.com", &result)
558            .unwrap();
559        assert_eq!(url, "https://api.example.com");
560
561        // Test with explicit override
562        let args = vec!["prefix=staging-".to_string()];
563        let result = resolver.resolve_variables(&args).unwrap();
564        assert_eq!(result.get("prefix"), Some(&"staging-".to_string()));
565
566        let url = resolver
567            .substitute_url("https://{prefix}api.example.com", &result)
568            .unwrap();
569        assert_eq!(url, "https://staging-api.example.com");
570    }
571
572    #[test]
573    fn test_url_encoding_in_substitution() {
574        let mut server_variables = HashMap::new();
575        server_variables.insert(
576            "path".to_string(),
577            ServerVariable {
578                default: Some("api/v1".to_string()),
579                enum_values: vec![],
580                description: Some("API path".to_string()),
581            },
582        );
583
584        let spec = CachedSpec {
585            cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
586            name: "test-api".to_string(),
587            version: "1.0.0".to_string(),
588            commands: vec![],
589            base_url: Some("https://example.com/{path}".to_string()),
590            servers: vec!["https://example.com/{path}".to_string()],
591            security_schemes: HashMap::new(),
592            skipped_endpoints: vec![],
593            server_variables,
594        };
595
596        let resolver = ServerVariableResolver::new(&spec);
597
598        // Test with value containing special characters
599        let args = vec!["path=api/v2/test&debug=true".to_string()];
600        let result = resolver.resolve_variables(&args).unwrap();
601
602        let url = resolver
603            .substitute_url("https://example.com/{path}", &result)
604            .unwrap();
605
606        // The ampersand and equals sign should be URL-encoded, but forward slashes preserved
607        assert_eq!(url, "https://example.com/api/v2/test%26debug%3Dtrue");
608
609        // Test with spaces
610        let args = vec!["path=api/test endpoint".to_string()];
611        let result = resolver.resolve_variables(&args).unwrap();
612
613        let url = resolver
614            .substitute_url("https://example.com/{path}", &result)
615            .unwrap();
616
617        // Spaces should be encoded as %20, but forward slashes preserved
618        assert_eq!(url, "https://example.com/api/test%20endpoint");
619
620        // Test with various special characters
621        let args = vec!["path=test?query=1#anchor".to_string()];
622        let result = resolver.resolve_variables(&args).unwrap();
623
624        let url = resolver
625            .substitute_url("https://example.com/{path}", &result)
626            .unwrap();
627
628        // Query and anchor characters should be encoded
629        assert_eq!(url, "https://example.com/test%3Fquery%3D1%23anchor");
630    }
631}
632
633/// Finds the next template variable boundaries (opening and closing braces)
634fn find_next_template(s: &str, start: usize) -> Option<(usize, usize)> {
635    let open_pos = s[start..].find('{').map(|pos| start + pos)?;
636    let close_pos = s[open_pos..].find('}').map(|pos| open_pos + pos)?;
637    Some((open_pos, close_pos))
638}