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::MissingServerVariable {
60 name: var_name.clone(),
61 });
62 }
63 }
64
65 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 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 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 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 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 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 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 let mut chars = name.chars();
195 let Some(first_char) = chars.next() else {
196 return Ok(()); };
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 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
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 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 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()]; let result = resolver.resolve_variables(&args).unwrap();
322
323 assert_eq!(result.get("region"), Some(&"us".to_string())); 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()]; 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 }
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 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); 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 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 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 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 let result = resolver.resolve_variables(&[]).unwrap();
553 assert_eq!(result.get("prefix"), Some(&"".to_string()));
554
555 let url = resolver
557 .substitute_url("https://{prefix}api.example.com", &result)
558 .unwrap();
559 assert_eq!(url, "https://api.example.com");
560
561 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 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 assert_eq!(url, "https://example.com/api/v2/test%26debug%3Dtrue");
608
609 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 assert_eq!(url, "https://example.com/api/test%20endpoint");
619
620 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 assert_eq!(url, "https://example.com/test%3Fquery%3D1%23anchor");
630 }
631}
632
633fn 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}