1use crate::cache::models::{CachedSpec, ServerVariable};
2use crate::error::Error;
3use std::collections::HashMap;
4
5pub struct ServerVariableResolver<'a> {
7 spec: &'a CachedSpec,
8}
9
10impl<'a> ServerVariableResolver<'a> {
11 #[must_use]
13 pub const fn new(spec: &'a CachedSpec) -> Self {
14 Self { spec }
15 }
16
17 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 for arg in server_var_args {
40 let (key, value) = Self::parse_key_value(arg)?;
41 resolved_vars.insert(key, value);
42 }
43
44 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) {
50 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 if let Some(default_value) = &var_def.default {
58 Self::validate_enum_constraint(var_name, default_value, var_def)?;
60 final_vars.insert(var_name.clone(), default_value.clone());
62 continue;
63 }
64
65 return Err(Error::missing_server_variable(var_name));
67 }
68
69 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 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 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 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 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 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 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 let mut chars = name.chars();
198 let Some(first_char) = chars.next() else {
199 return Ok(()); };
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 fn encode_server_variable(value: &str) -> String {
224 value
228 .chars()
229 .map(|c| match c {
230 '/' | '-' | '_' | '.' | '~' => c.to_string(),
232 ' ' => "%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 c if c.is_ascii_alphanumeric() => c.to_string(),
254 c => urlencoding::encode(&c.to_string()).to_string(),
256 })
257 .collect()
258 }
259}
260
261fn 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 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 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()]; let result = resolver.resolve_variables(&args).unwrap();
330
331 assert_eq!(result.get("region"), Some(&"us".to_string())); 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()]; 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 }
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 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); 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 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 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 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 let result = resolver.resolve_variables(&[]).unwrap();
591 assert_eq!(result.get("prefix"), Some(&String::new()));
592
593 let url = resolver
595 .substitute_url("https://{prefix}api.example.com", &result)
596 .unwrap();
597 assert_eq!(url, "https://api.example.com");
598
599 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 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 assert_eq!(url, "https://example.com/api/v2/test%26debug%3Dtrue");
646
647 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 assert_eq!(url, "https://example.com/api/test%20endpoint");
657
658 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 assert_eq!(url, "https://example.com/test%3Fquery%3D1%23anchor");
668 }
669}