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