Skip to main content

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