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) {
49 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 Self::validate_enum_constraint(var_name, default_value, var_def)?;
55 final_vars.insert(var_name.clone(), default_value.clone());
57 } else {
58 return Err(Error::missing_server_variable(var_name));
60 }
61 }
62
63 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 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 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 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 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 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 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 let mut chars = name.chars();
192 let Some(first_char) = chars.next() else {
193 return Ok(()); };
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 fn encode_server_variable(value: &str) -> String {
218 value
222 .chars()
223 .map(|c| match c {
224 '/' | '-' | '_' | '.' | '~' => c.to_string(),
226 ' ' => "%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 c if c.is_ascii_alphanumeric() => c.to_string(),
248 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 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 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()]; let result = resolver.resolve_variables(&args).unwrap();
317
318 assert_eq!(result.get("region"), Some(&"us".to_string())); 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()]; 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 }
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 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); 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 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 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 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 let result = resolver.resolve_variables(&[]).unwrap();
578 assert_eq!(result.get("prefix"), Some(&"".to_string()));
579
580 let url = resolver
582 .substitute_url("https://{prefix}api.example.com", &result)
583 .unwrap();
584 assert_eq!(url, "https://api.example.com");
585
586 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 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 assert_eq!(url, "https://example.com/api/v2/test%26debug%3Dtrue");
633
634 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 assert_eq!(url, "https://example.com/api/test%20endpoint");
644
645 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 assert_eq!(url, "https://example.com/test%3Fquery%3D1%23anchor");
655 }
656}
657
658fn 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}