1use std::collections::{HashMap, HashSet};
2use std::fs::File;
3use std::io::{Read, Write};
4use std::path::Path;
5
6use flate2::write::GzEncoder;
7use flate2::Compression;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use tar::Builder;
11
12use std::collections::BTreeMap;
13
14use crate::spec_parser::{
15 parse_spec_file, ApiSpec, DispatchConfig, Message, MiddlewareConfig, Parameter, RequestBody,
16 ResponseContent, SpecFormat,
17};
18
19use crate::error::{CompileError, CompileWarning};
20use crate::manifest::ProjectManifest;
21
22pub const ARTIFACT_VERSION: u32 = 3;
24
25#[derive(Debug, Clone)]
27pub struct CompileOptions {
28 pub allow_plaintext: bool,
31 pub max_schema_depth: usize,
33 pub max_schema_properties: usize,
35 pub provenance_commit: Option<String>,
37 pub provenance_source: Option<String>,
39 pub no_cache: bool,
41}
42
43impl Default for CompileOptions {
44 fn default() -> Self {
45 Self {
46 allow_plaintext: false,
47 max_schema_depth: 32,
48 max_schema_properties: 256,
49 provenance_commit: None,
50 provenance_source: None,
51 no_cache: false,
52 }
53 }
54}
55
56pub const COMPILER_VERSION: &str = env!("CARGO_PKG_VERSION");
58
59const KNOWN_EXTENSIONS: &[&str] = &[
66 "x-barbacane-dispatch", "x-barbacane-middlewares", "x-barbacane-mcp", ];
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CompileResult {
74 pub manifest: Manifest,
76 pub warnings: Vec<CompileWarning>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Manifest {
83 pub barbacane_artifact_version: u32,
84 pub compiled_at: String,
85 pub compiler_version: String,
86 pub source_specs: Vec<SourceSpec>,
87 pub routes_count: usize,
88 pub checksums: BTreeMap<String, String>,
90 pub plugins: Vec<BundledPlugin>,
92 pub artifact_hash: String,
94 pub provenance: Provenance,
96 #[serde(default)]
98 pub mcp: McpConfig,
99}
100
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct McpConfig {
104 pub enabled: bool,
106 #[serde(default)]
108 pub server_name: Option<String>,
109 #[serde(default)]
111 pub server_version: Option<String>,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct Provenance {
117 pub commit: Option<String>,
119 pub source: Option<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct BundledPlugin {
126 pub name: String,
128 pub version: String,
130 pub plugin_type: String,
132 pub wasm_path: String,
134 pub sha256: String,
136 pub capabilities: PluginCapabilities,
138}
139
140#[derive(Debug, Clone, Default, Serialize, Deserialize)]
142pub struct PluginCapabilities {
143 #[serde(default)]
145 pub body_access: bool,
146}
147
148#[derive(Debug, Clone)]
150pub struct LoadedPlugin {
151 pub version: String,
153 pub wasm_bytes: Vec<u8>,
155 pub body_access: bool,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct SourceSpec {
162 pub file: String,
163 pub sha256: String,
164 #[serde(rename = "type")]
165 pub spec_type: String,
166 pub version: String,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct CompiledRoutes {
172 pub operations: Vec<CompiledOperation>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct CompiledOperation {
178 pub index: usize,
179 pub path: String,
181 pub method: String,
183 pub operation_id: Option<String>,
184 #[serde(default)]
186 pub summary: Option<String>,
187 #[serde(default)]
189 pub description: Option<String>,
190 pub parameters: Vec<Parameter>,
192 pub request_body: Option<RequestBody>,
194 pub dispatch: DispatchConfig,
195 #[serde(default)]
199 pub middlewares: Vec<MiddlewareConfig>,
200 #[serde(default)]
202 pub deprecated: bool,
203 #[serde(default)]
205 pub sunset: Option<String>,
206 #[serde(default, skip_serializing_if = "Vec::is_empty")]
208 pub messages: Vec<Message>,
209 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211 pub bindings: BTreeMap<String, serde_json::Value>,
212 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
214 pub responses: BTreeMap<String, ResponseContent>,
215 #[serde(default)]
217 pub mcp_enabled: Option<bool>,
218 #[serde(default)]
220 pub mcp_description: Option<String>,
221}
222
223pub fn compile(
234 spec_paths: &[&Path],
235 plugins: &[PluginBundle],
236 output: &Path,
237 options: &CompileOptions,
238) -> Result<CompileResult, CompileError> {
239 let specs = parse_specs(spec_paths)?;
240 compile_inner(&specs, plugins, output, options)
241}
242
243pub fn compile_with_manifest(
256 spec_paths: &[&Path],
257 project_manifest: &ProjectManifest,
258 manifest_base_path: &Path,
259 output: &Path,
260 options: &CompileOptions,
261) -> Result<CompileResult, CompileError> {
262 let specs = parse_specs(spec_paths)?;
263
264 let api_specs: Vec<ApiSpec> = specs.iter().map(|(spec, _, _)| spec.clone()).collect();
266
267 project_manifest.validate_specs(&api_specs)?;
269
270 let resolved_plugins =
272 project_manifest.resolve_used_plugins(&api_specs, manifest_base_path, options.no_cache)?;
273
274 let plugin_bundles: Vec<PluginBundle> = resolved_plugins
276 .into_iter()
277 .map(|p| PluginBundle {
278 name: p.name,
279 version: p.version.unwrap_or_else(|| "0.1.0".to_string()),
280 plugin_type: p.plugin_type.unwrap_or_else(|| "plugin".to_string()),
281 wasm_bytes: p.wasm_bytes,
282 body_access: p.body_access,
283 })
284 .collect();
285
286 compile_inner(&specs, &plugin_bundles, output, options)
287}
288
289pub fn load_manifest(artifact_path: &Path) -> Result<Manifest, CompileError> {
291 let file = File::open(artifact_path)?;
292 let decoder = flate2::read::GzDecoder::new(file);
293 let mut archive = tar::Archive::new(decoder);
294
295 for entry in archive.entries()? {
296 let mut entry = entry?;
297 let path = entry.path()?;
298
299 if path.to_str() == Some("manifest.json") {
300 let mut content = String::new();
301 entry.read_to_string(&mut content)?;
302 let manifest: Manifest = serde_json::from_str(&content)?;
303 return Ok(manifest);
304 }
305 }
306
307 Err(CompileError::Io(std::io::Error::new(
308 std::io::ErrorKind::NotFound,
309 "manifest.json not found in artifact",
310 )))
311}
312
313pub fn load_routes(artifact_path: &Path) -> Result<CompiledRoutes, CompileError> {
315 let file = File::open(artifact_path)?;
316 let decoder = flate2::read::GzDecoder::new(file);
317 let mut archive = tar::Archive::new(decoder);
318
319 for entry in archive.entries()? {
320 let mut entry = entry?;
321 let path = entry.path()?;
322
323 if path.to_str() == Some("routes.json") {
324 let mut content = String::new();
325 entry.read_to_string(&mut content)?;
326 let routes: CompiledRoutes = serde_json::from_str(&content)?;
327 return Ok(routes);
328 }
329 }
330
331 Err(CompileError::Io(std::io::Error::new(
332 std::io::ErrorKind::NotFound,
333 "routes.json not found in artifact",
334 )))
335}
336
337pub fn load_specs(artifact_path: &Path) -> Result<HashMap<String, String>, CompileError> {
340 let file = File::open(artifact_path)?;
341 let decoder = flate2::read::GzDecoder::new(file);
342 let mut archive = tar::Archive::new(decoder);
343
344 let mut specs = HashMap::new();
345
346 for entry in archive.entries()? {
347 let mut entry = entry?;
348 let path_str = entry.path()?.to_string_lossy().into_owned();
349
350 if let Some(filename) = path_str.strip_prefix("specs/") {
351 if !filename.is_empty() {
352 let mut content = String::new();
353 entry.read_to_string(&mut content)?;
354 specs.insert(filename.to_string(), content);
355 }
356 }
357 }
358
359 Ok(specs)
360}
361
362pub fn load_plugins(artifact_path: &Path) -> Result<HashMap<String, LoadedPlugin>, CompileError> {
365 let manifest = load_manifest(artifact_path)?;
367
368 let file = File::open(artifact_path)?;
369 let decoder = flate2::read::GzDecoder::new(file);
370 let mut archive = tar::Archive::new(decoder);
371
372 let mut plugins = HashMap::new();
373
374 let plugin_info: HashMap<String, (&BundledPlugin,)> = manifest
376 .plugins
377 .iter()
378 .map(|p| (p.wasm_path.clone(), (p,)))
379 .collect();
380
381 for entry in archive.entries()? {
382 let mut entry = entry?;
383 let path_str = entry.path()?.to_string_lossy().into_owned();
384
385 if let Some((bundled,)) = plugin_info.get(&path_str) {
386 let mut wasm_bytes = Vec::new();
387 entry.read_to_end(&mut wasm_bytes)?;
388 plugins.insert(
389 bundled.name.clone(),
390 LoadedPlugin {
391 version: bundled.version.clone(),
392 wasm_bytes,
393 body_access: bundled.capabilities.body_access,
394 },
395 );
396 }
397 }
398
399 Ok(plugins)
400}
401
402pub struct PluginBundle {
404 pub name: String,
406 pub version: String,
408 pub plugin_type: String,
410 pub wasm_bytes: Vec<u8>,
412 pub body_access: bool,
414}
415
416fn parse_specs(spec_paths: &[&Path]) -> Result<Vec<(ApiSpec, String, String)>, CompileError> {
418 let mut specs = Vec::new();
419 for path in spec_paths {
420 let content = std::fs::read_to_string(path)?;
421 let sha256 = compute_sha256(content.as_bytes());
422 let spec = parse_spec_file(path)?;
423 specs.push((spec, content, sha256));
424 }
425 Ok(specs)
426}
427
428fn resolve_middlewares(
433 global: &[MiddlewareConfig],
434 operation: &Option<Vec<MiddlewareConfig>>,
435) -> Vec<MiddlewareConfig> {
436 match operation {
437 None => global.to_vec(),
438 Some(op_mw) if op_mw.is_empty() => Vec::new(),
439 Some(op_mw) => {
440 let op_names: HashSet<_> = op_mw.iter().map(|m| m.name.as_str()).collect();
441 let mut merged: Vec<_> = global
442 .iter()
443 .filter(|m| !op_names.contains(m.name.as_str()))
444 .cloned()
445 .collect();
446 merged.extend(op_mw.clone());
447 merged
448 }
449 }
450}
451
452fn compile_inner(
454 specs: &[(ApiSpec, String, String)],
455 plugins: &[PluginBundle],
456 output: &Path,
457 options: &CompileOptions,
458) -> Result<CompileResult, CompileError> {
459 let mut warnings: Vec<CompileWarning> = Vec::new();
460 let mut operations: Vec<CompiledOperation> = Vec::new();
461 let mut seen_routes: HashMap<(String, String), String> = HashMap::new();
462 let mut seen_structural: HashMap<(String, String), (String, String)> = HashMap::new();
463 let mut seen_operation_ids: HashMap<String, String> = HashMap::new();
464
465 let root_mcp_config = extract_root_mcp_config(specs);
467
468 for (spec, _, _) in specs {
469 let spec_file = spec.filename.as_deref().unwrap_or("unknown");
470
471 for (idx, mw) in spec.global_middlewares.iter().enumerate() {
473 if mw.name.is_empty() {
474 return Err(CompileError::MissingMiddlewareName(format!(
475 "global middleware #{} in '{}'",
476 idx + 1,
477 spec_file
478 )));
479 }
480 }
481
482 for key in spec.extensions.keys() {
484 if key.starts_with("x-barbacane-") && !KNOWN_EXTENSIONS.contains(&key.as_str()) {
485 warnings.push(CompileWarning {
486 code: "E1015".to_string(),
487 message: format!("unknown extension: {}", key),
488 location: Some(spec_file.to_string()),
489 });
490 }
491 }
492
493 for op in &spec.operations {
494 let location = format!("{} {} in '{}'", op.method, op.path, spec_file);
495
496 for key in op.extensions.keys() {
498 if key.starts_with("x-barbacane-") && !KNOWN_EXTENSIONS.contains(&key.as_str()) {
499 warnings.push(CompileWarning {
500 code: "E1015".to_string(),
501 message: format!("unknown extension: {}", key),
502 location: Some(location.clone()),
503 });
504 }
505 }
506
507 validate_path_template(&op.path, &location)?;
509
510 if let Some(ref op_id) = op.operation_id {
512 if let Some(first_location) = seen_operation_ids.get(op_id) {
513 return Err(CompileError::DuplicateOperationId(
514 op_id.clone(),
515 format!("first at {}, duplicate at {}", first_location, location),
516 ));
517 }
518 seen_operation_ids.insert(op_id.clone(), location.clone());
519 }
520
521 let key = (op.path.clone(), op.method.clone());
523 if let Some(other_spec) = seen_routes.get(&key) {
524 return Err(CompileError::RoutingConflict(format!(
525 "{} {} declared in both '{}' and '{}'",
526 op.method, op.path, other_spec, spec_file
527 )));
528 }
529 seen_routes.insert(key, spec_file.to_string());
530
531 let normalized = normalize_path_template(&op.path);
533 let structural_key = (normalized, op.method.clone());
534 if let Some((other_path, other_spec)) = seen_structural.get(&structural_key) {
535 if other_path != &op.path {
536 return Err(CompileError::AmbiguousRoute(format!(
537 "'{}' and '{}' have same structure but different param names ({} in '{}' vs '{}')",
538 op.path, other_path, op.method, spec_file, other_spec
539 )));
540 }
541 }
542 seen_structural.insert(structural_key, (op.path.clone(), spec_file.to_string()));
543
544 let dispatch = op.dispatch.clone().ok_or_else(|| {
546 CompileError::MissingDispatch(format!(
547 "{} {} in '{}'",
548 op.method, op.path, spec_file
549 ))
550 })?;
551
552 if !options.allow_plaintext {
554 if let Some(url) = extract_upstream_url(&dispatch.config) {
555 if url.starts_with("http://") {
556 return Err(CompileError::PlaintextUpstream(format!(
557 "{} {} in '{}' - upstream URL: {}",
558 op.method, op.path, spec_file, url
559 )));
560 }
561 }
562 }
563
564 let middlewares = resolve_middlewares(&spec.global_middlewares, &op.middlewares);
565
566 for (idx, mw) in middlewares.iter().enumerate() {
568 if mw.name.is_empty() {
569 return Err(CompileError::MissingMiddlewareName(format!(
570 "middleware #{} in {}",
571 idx + 1,
572 location
573 )));
574 }
575 }
576
577 for param in &op.parameters {
580 if let Some(schema) = ¶m.schema {
581 let param_location = format!("{} parameter '{}'", location, param.name);
582 validate_schema_complexity(
583 schema,
584 options.max_schema_depth,
585 options.max_schema_properties,
586 ¶m_location,
587 )?;
588 }
589 }
590
591 if let Some(ref body) = op.request_body {
593 for (content_type, content) in &body.content {
594 if let Some(schema) = &content.schema {
595 let body_location = format!("{} request body ({})", location, content_type);
596 validate_schema_complexity(
597 schema,
598 options.max_schema_depth,
599 options.max_schema_properties,
600 &body_location,
601 )?;
602 }
603 }
604 }
605
606 let (mcp_enabled, mcp_description) =
608 resolve_mcp_config(&root_mcp_config, op.extensions.get("x-barbacane-mcp"));
609
610 if mcp_enabled == Some(true) {
612 if op.operation_id.is_none() {
613 warnings.push(CompileWarning {
614 code: "E1060".to_string(),
615 message: "operation without operationId cannot be exposed as MCP tool"
616 .to_string(),
617 location: Some(location.clone()),
618 });
619 }
620 if op.summary.is_none() && op.description.is_none() {
621 warnings.push(CompileWarning {
622 code: "E1061".to_string(),
623 message:
624 "MCP-enabled operation has no summary or description for tool metadata"
625 .to_string(),
626 location: Some(location.clone()),
627 });
628 }
629 }
630
631 operations.push(CompiledOperation {
632 index: operations.len(),
633 path: op.path.clone(),
634 method: op.method.clone(),
635 operation_id: op.operation_id.clone(),
636 summary: op.summary.clone(),
637 description: op.description.clone(),
638 parameters: op.parameters.clone(),
639 request_body: op.request_body.clone(),
640 dispatch,
641 middlewares,
642 deprecated: op.deprecated,
643 sunset: op.sunset.clone(),
644 messages: op.messages.clone(),
645 bindings: op.bindings.clone(),
646 responses: op.responses.clone(),
647 mcp_enabled,
648 mcp_description,
649 });
650 }
651 }
652
653 operations.sort_by(|a, b| (&a.path, &a.method).cmp(&(&b.path, &b.method)));
655 for (i, op) in operations.iter_mut().enumerate() {
656 op.index = i;
657 }
658
659 let routes = CompiledRoutes { operations };
661 let routes_json = serde_json::to_string_pretty(&routes)?;
662 let routes_sha256 = compute_sha256(routes_json.as_bytes());
663
664 let mut bundled_plugins = Vec::new();
666 let mut checksums = BTreeMap::new();
667 checksums.insert(
668 "routes.json".to_string(),
669 format!("sha256:{}", routes_sha256),
670 );
671
672 for plugin in plugins {
673 let wasm_path = format!("plugins/{}.wasm", plugin.name);
674 let sha256 = compute_sha256(&plugin.wasm_bytes);
675
676 checksums.insert(wasm_path.clone(), format!("sha256:{}", sha256));
677
678 bundled_plugins.push(BundledPlugin {
679 name: plugin.name.clone(),
680 version: plugin.version.clone(),
681 plugin_type: plugin.plugin_type.clone(),
682 wasm_path,
683 sha256,
684 capabilities: PluginCapabilities {
685 body_access: plugin.body_access,
686 },
687 });
688 }
689
690 bundled_plugins.sort_by(|a, b| a.name.cmp(&b.name));
692
693 let mut source_specs: Vec<SourceSpec> = specs
695 .iter()
696 .map(|(spec, _, sha256)| SourceSpec {
697 file: spec
698 .filename
699 .clone()
700 .unwrap_or_else(|| "unknown".to_string()),
701 sha256: sha256.clone(),
702 spec_type: match spec.format {
703 SpecFormat::OpenApi => "openapi".to_string(),
704 SpecFormat::AsyncApi => "asyncapi".to_string(),
705 },
706 version: spec.version.clone(),
707 })
708 .collect();
709
710 source_specs.sort_by(|a, b| a.file.cmp(&b.file));
712
713 let artifact_hash = compute_artifact_hash(&source_specs, &checksums);
714
715 let provenance = Provenance {
716 commit: options.provenance_commit.clone(),
717 source: options.provenance_source.clone(),
718 };
719
720 let mcp = {
722 let mut cfg = root_mcp_config.clone();
723 if cfg.enabled {
724 if cfg.server_name.is_none() {
725 cfg.server_name = specs.first().map(|(s, _, _)| s.title.clone());
726 }
727 if cfg.server_version.is_none() {
728 cfg.server_version = specs.first().map(|(s, _, _)| s.api_version.clone());
729 }
730 }
731 cfg
732 };
733
734 let manifest = Manifest {
735 barbacane_artifact_version: ARTIFACT_VERSION,
736 compiled_at: now_utc_iso8601(),
737 compiler_version: COMPILER_VERSION.to_string(),
738 source_specs,
739 routes_count: routes.operations.len(),
740 checksums,
741 plugins: bundled_plugins,
742 artifact_hash,
743 provenance,
744 mcp,
745 };
746
747 let manifest_json = serde_json::to_string_pretty(&manifest)?;
748
749 let file = File::create(output)?;
751 let encoder = GzEncoder::new(file, Compression::default());
752 let mut archive = Builder::new(encoder);
753
754 add_file_to_tar(&mut archive, "manifest.json", manifest_json.as_bytes())?;
756 add_file_to_tar(&mut archive, "routes.json", routes_json.as_bytes())?;
757
758 for (spec, content, _) in specs {
760 let filename = spec
761 .filename
762 .as_deref()
763 .and_then(|p| Path::new(p).file_name())
764 .and_then(|n| n.to_str())
765 .unwrap_or("spec.yaml");
766 let archive_path = format!("specs/{}", filename);
767 add_file_to_tar(&mut archive, &archive_path, content.as_bytes())?;
768 }
769
770 for plugin in plugins {
772 let wasm_path = format!("plugins/{}.wasm", plugin.name);
773 add_file_to_tar(&mut archive, &wasm_path, &plugin.wasm_bytes)?;
774 }
775
776 let encoder = archive.into_inner()?;
778 encoder.finish()?;
779
780 warnings.sort_by(|a, b| {
782 (&a.location, &a.code, &a.message).cmp(&(&b.location, &b.code, &b.message))
783 });
784
785 Ok(CompileResult { manifest, warnings })
786}
787
788fn compute_sha256(content: &[u8]) -> String {
790 hex::encode(Sha256::new().chain_update(content).finalize())
791}
792
793fn compute_artifact_hash(
799 source_specs: &[SourceSpec],
800 checksums: &BTreeMap<String, String>,
801) -> String {
802 let mut hasher = Sha256::new();
803 for spec in source_specs {
805 hasher.update(format!("source_spec:{}={}\n", spec.file, spec.sha256).as_bytes());
806 }
807 for (key, value) in checksums {
809 hasher.update(format!("{}={}\n", key, value).as_bytes());
810 }
811 format!("sha256:{}", hex::encode(hasher.finalize()))
812}
813
814fn add_file_to_tar<W: Write>(
816 archive: &mut Builder<W>,
817 name: &str,
818 content: &[u8],
819) -> std::io::Result<()> {
820 let mut header = tar::Header::new_gnu();
821 header.set_size(content.len() as u64);
822 header.set_mode(0o644);
823 header.set_mtime(0); header.set_cksum();
825 archive.append_data(&mut header, name, content)
826}
827
828fn now_utc_iso8601() -> String {
830 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
831}
832
833fn extract_upstream_url(config: &serde_json::Value) -> Option<String> {
839 if let Some(url) = config.get("url").and_then(|v| v.as_str()) {
841 return Some(url.to_string());
842 }
843
844 if let Some(upstream) = config.get("upstream").and_then(|v| v.as_str()) {
846 if upstream.starts_with("http://") || upstream.starts_with("https://") {
848 return Some(upstream.to_string());
849 }
850 }
851
852 None
853}
854
855fn validate_path_template(path: &str, location: &str) -> Result<(), CompileError> {
865 let mut seen_params: HashSet<String> = HashSet::new();
866 let mut brace_depth = 0;
867 let mut current_param = String::new();
868 let mut in_param = false;
869 let mut has_wildcard = false;
870
871 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
873 let last_segment = segments.last().copied().unwrap_or("");
874
875 for ch in path.chars() {
876 match ch {
877 '{' => {
878 if in_param {
879 return Err(CompileError::InvalidPathTemplate(format!(
880 "{} - nested braces not allowed",
881 location
882 )));
883 }
884 brace_depth += 1;
885 in_param = true;
886 }
887 '}' => {
888 if !in_param {
889 return Err(CompileError::InvalidPathTemplate(format!(
890 "{} - unmatched closing brace",
891 location
892 )));
893 }
894 brace_depth -= 1;
895 in_param = false;
896
897 let is_wildcard_param = current_param.ends_with('+');
898 let base_name = if is_wildcard_param {
899 ¤t_param[..current_param.len() - 1]
900 } else {
901 ¤t_param
902 };
903
904 if base_name.is_empty() {
905 return Err(CompileError::InvalidPathTemplate(format!(
906 "{} - empty parameter name",
907 location
908 )));
909 }
910
911 if is_wildcard_param {
912 if has_wildcard {
913 return Err(CompileError::InvalidPathTemplate(format!(
914 "{} - at most one wildcard parameter ({{name+}}) allowed per path",
915 location
916 )));
917 }
918 let param_segment = format!("{{{}}}", current_param);
920 if last_segment != param_segment {
921 return Err(CompileError::InvalidPathTemplate(format!(
922 "{} - wildcard parameter '{{{}}}' must be the last path segment",
923 location, current_param
924 )));
925 }
926 has_wildcard = true;
927 }
928
929 if !seen_params.insert(base_name.to_string()) {
930 return Err(CompileError::InvalidPathTemplate(format!(
931 "{} - duplicate parameter '{}'",
932 location, base_name
933 )));
934 }
935 current_param.clear();
936 }
937 _ if in_param => {
938 if ch == '+' {
941 current_param.push(ch);
944 } else if !ch.is_alphanumeric() && ch != '_' {
945 return Err(CompileError::InvalidPathTemplate(format!(
946 "{} - invalid character '{}' in parameter name",
947 location, ch
948 )));
949 } else if current_param.ends_with('+') {
950 return Err(CompileError::InvalidPathTemplate(format!(
952 "{} - '+' is only allowed as the last character of a wildcard parameter name (e.g. {{key+}})",
953 location
954 )));
955 } else {
956 current_param.push(ch);
957 }
958 }
959 _ => {}
960 }
961 }
962
963 if brace_depth != 0 {
964 return Err(CompileError::InvalidPathTemplate(format!(
965 "{} - unclosed brace",
966 location
967 )));
968 }
969
970 Ok(())
971}
972
973fn normalize_path_template(path: &str) -> String {
979 let mut result = String::with_capacity(path.len());
980 let mut in_param = false;
981 let mut is_wildcard = false;
982
983 for ch in path.chars() {
984 match ch {
985 '{' => {
986 result.push('{');
987 result.push('_');
988 in_param = true;
989 is_wildcard = false;
990 }
991 '}' => {
992 if is_wildcard {
993 result.push('+');
994 }
995 result.push('}');
996 in_param = false;
997 is_wildcard = false;
998 }
999 '+' if in_param => {
1000 is_wildcard = true;
1002 }
1003 _ if in_param => {
1004 }
1006 _ => {
1007 result.push(ch);
1008 }
1009 }
1010 }
1011
1012 result
1013}
1014
1015fn validate_schema_complexity(
1017 schema: &serde_json::Value,
1018 max_depth: usize,
1019 max_properties: usize,
1020 location: &str,
1021) -> Result<(), CompileError> {
1022 let (depth, props) = measure_schema_complexity(schema, 0);
1023
1024 if depth > max_depth {
1025 return Err(CompileError::SchemaTooDeep(format!(
1026 "{} - depth {} exceeds limit {}",
1027 location, depth, max_depth
1028 )));
1029 }
1030 if props > max_properties {
1031 return Err(CompileError::SchemaTooComplex(format!(
1032 "{} - {} properties exceed limit {}",
1033 location, props, max_properties
1034 )));
1035 }
1036 Ok(())
1037}
1038
1039fn measure_schema_complexity(value: &serde_json::Value, current_depth: usize) -> (usize, usize) {
1041 match value {
1042 serde_json::Value::Object(obj) => {
1043 let mut max_depth = current_depth;
1044 let mut total_props = 0;
1045
1046 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
1048 total_props += props.len();
1049 for prop_value in props.values() {
1050 let (d, p) = measure_schema_complexity(prop_value, current_depth + 1);
1051 max_depth = max_depth.max(d);
1052 total_props += p;
1053 }
1054 }
1055
1056 if let Some(items) = obj.get("items") {
1058 let (d, p) = measure_schema_complexity(items, current_depth + 1);
1059 max_depth = max_depth.max(d);
1060 total_props += p;
1061 }
1062
1063 for key in ["allOf", "oneOf", "anyOf"] {
1065 if let Some(serde_json::Value::Array(schemas)) = obj.get(key) {
1066 for schema in schemas {
1067 let (d, p) = measure_schema_complexity(schema, current_depth + 1);
1068 max_depth = max_depth.max(d);
1069 total_props += p;
1070 }
1071 }
1072 }
1073
1074 if let Some(additional) = obj.get("additionalProperties") {
1076 if additional.is_object() {
1077 let (d, p) = measure_schema_complexity(additional, current_depth + 1);
1078 max_depth = max_depth.max(d);
1079 total_props += p;
1080 }
1081 }
1082
1083 (max_depth, total_props)
1084 }
1085 serde_json::Value::Array(arr) => {
1086 let mut max_depth = current_depth;
1087 let mut total_props = 0;
1088 for item in arr {
1089 let (d, p) = measure_schema_complexity(item, current_depth + 1);
1090 max_depth = max_depth.max(d);
1091 total_props += p;
1092 }
1093 (max_depth, total_props)
1094 }
1095 _ => (current_depth, 0),
1096 }
1097}
1098
1099mod hex {
1101 const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
1102
1103 pub fn encode(bytes: impl AsRef<[u8]>) -> String {
1104 let bytes = bytes.as_ref();
1105 let mut result = String::with_capacity(bytes.len() * 2);
1106 for &byte in bytes {
1107 result.push(HEX_CHARS[(byte >> 4) as usize] as char);
1108 result.push(HEX_CHARS[(byte & 0x0f) as usize] as char);
1109 }
1110 result
1111 }
1112}
1113
1114fn extract_root_mcp_config(specs: &[(ApiSpec, String, String)]) -> McpConfig {
1116 for (spec, _, _) in specs {
1117 if let Some(mcp_value) = spec.extensions.get("x-barbacane-mcp") {
1118 let enabled = mcp_value
1119 .get("enabled")
1120 .and_then(|v| v.as_bool())
1121 .unwrap_or(false);
1122 let server_name = mcp_value
1123 .get("server_name")
1124 .and_then(|v| v.as_str())
1125 .map(|s| s.to_string());
1126 let server_version = mcp_value
1127 .get("server_version")
1128 .and_then(|v| v.as_str())
1129 .map(|s| s.to_string());
1130 return McpConfig {
1131 enabled,
1132 server_name,
1133 server_version,
1134 };
1135 }
1136 }
1137 McpConfig::default()
1138}
1139
1140fn resolve_mcp_config(
1142 root: &McpConfig,
1143 op_extension: Option<&serde_json::Value>,
1144) -> (Option<bool>, Option<String>) {
1145 if let Some(ext) = op_extension {
1146 let enabled = ext.get("enabled").and_then(|v| v.as_bool());
1147 let description = ext
1148 .get("description")
1149 .and_then(|v| v.as_str())
1150 .map(|s| s.to_string());
1151 let resolved_enabled = enabled.or(if root.enabled { Some(true) } else { None });
1153 (resolved_enabled, description)
1154 } else if root.enabled {
1155 (Some(true), None)
1156 } else {
1157 (None, None)
1158 }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use super::*;
1164 use std::io::Write;
1165 use tempfile::TempDir;
1166
1167 fn create_test_spec(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
1168 let path = dir.join(name);
1169 let mut file = File::create(&path).unwrap();
1170 file.write_all(content.as_bytes()).unwrap();
1171 path
1172 }
1173
1174 #[test]
1175 fn compile_minimal_spec() {
1176 let temp = TempDir::new().unwrap();
1177
1178 let spec_content = r#"
1179openapi: "3.1.0"
1180info:
1181 title: Test API
1182 version: "1.0.0"
1183paths:
1184 /health:
1185 get:
1186 x-barbacane-dispatch:
1187 name: mock
1188 config:
1189 status: 200
1190"#;
1191 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1192 let output_path = temp.path().join("artifact.bca");
1193
1194 let result = compile(
1195 &[spec_path.as_path()],
1196 &[],
1197 &output_path,
1198 &CompileOptions::default(),
1199 )
1200 .unwrap();
1201
1202 assert_eq!(result.manifest.barbacane_artifact_version, ARTIFACT_VERSION);
1203 assert_eq!(result.manifest.routes_count, 1);
1204 assert_eq!(result.manifest.source_specs.len(), 1);
1205 assert_eq!(result.manifest.source_specs[0].spec_type, "openapi");
1206
1207 assert!(output_path.exists());
1209 }
1210
1211 #[test]
1212 fn compile_detects_missing_dispatch() {
1213 let temp = TempDir::new().unwrap();
1214
1215 let spec_content = r#"
1216openapi: "3.1.0"
1217info:
1218 title: Test API
1219 version: "1.0.0"
1220paths:
1221 /health:
1222 get:
1223 operationId: getHealth
1224"#;
1225 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1226 let output_path = temp.path().join("artifact.bca");
1227
1228 let result = compile(
1229 &[spec_path.as_path()],
1230 &[],
1231 &output_path,
1232 &CompileOptions::default(),
1233 );
1234
1235 assert!(matches!(result, Err(CompileError::MissingDispatch(_))));
1236 }
1237
1238 #[test]
1239 fn compile_detects_routing_conflict() {
1240 let temp = TempDir::new().unwrap();
1241
1242 let spec1 = r#"
1243openapi: "3.1.0"
1244info:
1245 title: API 1
1246 version: "1.0.0"
1247paths:
1248 /users:
1249 get:
1250 x-barbacane-dispatch:
1251 name: mock
1252"#;
1253 let spec2 = r#"
1254openapi: "3.1.0"
1255info:
1256 title: API 2
1257 version: "1.0.0"
1258paths:
1259 /users:
1260 get:
1261 x-barbacane-dispatch:
1262 name: mock
1263"#;
1264 let path1 = create_test_spec(temp.path(), "api1.yaml", spec1);
1265 let path2 = create_test_spec(temp.path(), "api2.yaml", spec2);
1266 let output_path = temp.path().join("artifact.bca");
1267
1268 let result = compile(
1269 &[path1.as_path(), path2.as_path()],
1270 &[],
1271 &output_path,
1272 &CompileOptions::default(),
1273 );
1274
1275 assert!(matches!(result, Err(CompileError::RoutingConflict(_))));
1276 }
1277
1278 #[test]
1279 fn load_artifact_manifest() {
1280 let temp = TempDir::new().unwrap();
1281
1282 let spec_content = r#"
1283openapi: "3.1.0"
1284info:
1285 title: Test API
1286 version: "1.0.0"
1287paths:
1288 /health:
1289 get:
1290 x-barbacane-dispatch:
1291 name: mock
1292"#;
1293 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1294 let output_path = temp.path().join("artifact.bca");
1295
1296 compile(
1297 &[spec_path.as_path()],
1298 &[],
1299 &output_path,
1300 &CompileOptions::default(),
1301 )
1302 .unwrap();
1303
1304 let loaded = load_manifest(&output_path).unwrap();
1305 assert_eq!(loaded.barbacane_artifact_version, ARTIFACT_VERSION);
1306 assert_eq!(loaded.routes_count, 1);
1307 }
1308
1309 #[test]
1310 fn load_artifact_routes() {
1311 let temp = TempDir::new().unwrap();
1312
1313 let spec_content = r#"
1314openapi: "3.1.0"
1315info:
1316 title: Test API
1317 version: "1.0.0"
1318paths:
1319 /health:
1320 get:
1321 x-barbacane-dispatch:
1322 name: mock
1323 config:
1324 status: 200
1325 /users/{id}:
1326 get:
1327 x-barbacane-dispatch:
1328 name: mock
1329"#;
1330 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1331 let output_path = temp.path().join("artifact.bca");
1332
1333 compile(
1334 &[spec_path.as_path()],
1335 &[],
1336 &output_path,
1337 &CompileOptions::default(),
1338 )
1339 .unwrap();
1340
1341 let routes = load_routes(&output_path).unwrap();
1342 assert_eq!(routes.operations.len(), 2);
1343 }
1344
1345 #[test]
1346 fn compile_rejects_plaintext_http_url() {
1347 let temp = TempDir::new().unwrap();
1348
1349 let spec_content = r#"
1350openapi: "3.1.0"
1351info:
1352 title: Test API
1353 version: "1.0.0"
1354paths:
1355 /proxy:
1356 get:
1357 x-barbacane-dispatch:
1358 name: http-upstream
1359 config:
1360 url: "http://backend.internal:8080/api"
1361"#;
1362 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1363 let output_path = temp.path().join("artifact.bca");
1364
1365 let result = compile(
1367 &[spec_path.as_path()],
1368 &[],
1369 &output_path,
1370 &CompileOptions::default(),
1371 );
1372 assert!(matches!(result, Err(CompileError::PlaintextUpstream(_))));
1373
1374 let result = compile(
1376 &[spec_path.as_path()],
1377 &[],
1378 &output_path,
1379 &CompileOptions {
1380 allow_plaintext: true,
1381 ..Default::default()
1382 },
1383 );
1384 assert!(result.is_ok());
1385 }
1386
1387 #[test]
1388 fn compile_allows_https_url() {
1389 let temp = TempDir::new().unwrap();
1390
1391 let spec_content = r#"
1392openapi: "3.1.0"
1393info:
1394 title: Test API
1395 version: "1.0.0"
1396paths:
1397 /proxy:
1398 get:
1399 x-barbacane-dispatch:
1400 name: http-upstream
1401 config:
1402 url: "https://backend.internal:8080/api"
1403"#;
1404 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1405 let output_path = temp.path().join("artifact.bca");
1406
1407 let result = compile(
1409 &[spec_path.as_path()],
1410 &[],
1411 &output_path,
1412 &CompileOptions::default(),
1413 );
1414 assert!(result.is_ok());
1415 }
1416
1417 #[test]
1418 fn compile_with_bundled_plugins() {
1419 let temp = TempDir::new().unwrap();
1420
1421 let spec_content = r#"
1422openapi: "3.1.0"
1423info:
1424 title: Test API
1425 version: "1.0.0"
1426paths:
1427 /health:
1428 get:
1429 x-barbacane-dispatch:
1430 name: mock
1431"#;
1432 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1433 let output_path = temp.path().join("artifact.bca");
1434
1435 let fake_wasm = vec![
1437 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, ];
1440
1441 let plugins = vec![PluginBundle {
1442 name: "test-plugin".to_string(),
1443 version: "1.0.0".to_string(),
1444 plugin_type: "middleware".to_string(),
1445 wasm_bytes: fake_wasm.clone(),
1446 body_access: false,
1447 }];
1448
1449 let result = compile(
1450 &[spec_path.as_path()],
1451 &plugins,
1452 &output_path,
1453 &CompileOptions::default(),
1454 )
1455 .unwrap();
1456
1457 assert_eq!(result.manifest.plugins.len(), 1);
1458 assert_eq!(result.manifest.plugins[0].name, "test-plugin");
1459 assert_eq!(result.manifest.plugins[0].version, "1.0.0");
1460 assert_eq!(result.manifest.plugins[0].plugin_type, "middleware");
1461 assert_eq!(
1462 result.manifest.plugins[0].wasm_path,
1463 "plugins/test-plugin.wasm"
1464 );
1465
1466 let loaded = load_plugins(&output_path).unwrap();
1468 assert_eq!(loaded.len(), 1);
1469 let plugin = loaded.get("test-plugin").unwrap();
1470 assert_eq!(plugin.version, "1.0.0");
1471 assert_eq!(plugin.wasm_bytes, fake_wasm);
1472 }
1473
1474 #[test]
1475 fn compile_asyncapi_spec() {
1476 let temp = TempDir::new().unwrap();
1477
1478 let spec_content = r#"
1479asyncapi: "3.0.0"
1480info:
1481 title: User Events API
1482 version: "1.0.0"
1483channels:
1484 userSignedUp:
1485 address: user/signedup
1486 messages:
1487 UserSignedUpMessage:
1488 contentType: application/json
1489 payload:
1490 type: object
1491 properties:
1492 userId:
1493 type: string
1494 bindings:
1495 kafka:
1496 topic: user-events
1497 partitions: 10
1498operations:
1499 processUserSignup:
1500 action: receive
1501 channel:
1502 $ref: '#/channels/userSignedUp'
1503 x-barbacane-dispatch:
1504 name: kafka
1505 config:
1506 topic: user-events
1507 bindings:
1508 kafka:
1509 groupId: user-processor
1510"#;
1511 let spec_path = create_test_spec(temp.path(), "events.yaml", spec_content);
1512 let output_path = temp.path().join("artifact.bca");
1513
1514 let result = compile(
1515 &[spec_path.as_path()],
1516 &[],
1517 &output_path,
1518 &CompileOptions::default(),
1519 )
1520 .unwrap();
1521
1522 assert_eq!(result.manifest.barbacane_artifact_version, ARTIFACT_VERSION);
1523 assert_eq!(result.manifest.routes_count, 1);
1524 assert_eq!(result.manifest.source_specs.len(), 1);
1525 assert_eq!(result.manifest.source_specs[0].spec_type, "asyncapi");
1526
1527 let routes = load_routes(&output_path).unwrap();
1529 assert_eq!(routes.operations.len(), 1);
1530
1531 let op = &routes.operations[0];
1532 assert_eq!(op.path, "user/signedup");
1533 assert_eq!(op.method, "RECEIVE");
1534 assert_eq!(op.operation_id, Some("processUserSignup".to_string()));
1535
1536 assert_eq!(op.messages.len(), 1);
1538 assert_eq!(op.messages[0].name, "UserSignedUpMessage");
1539 assert_eq!(
1540 op.messages[0].content_type,
1541 Some("application/json".to_string())
1542 );
1543
1544 assert!(op.bindings.contains_key("kafka"));
1546 let kafka_binding = op.bindings.get("kafka").unwrap();
1547 assert_eq!(
1548 kafka_binding.get("groupId").and_then(|v| v.as_str()),
1549 Some("user-processor")
1550 );
1551 }
1552
1553 #[test]
1554 fn compile_asyncapi_send_operation() {
1555 let temp = TempDir::new().unwrap();
1556
1557 let spec_content = r#"
1558asyncapi: "3.0.0"
1559info:
1560 title: Notification Service
1561 version: "1.0.0"
1562channels:
1563 notifications:
1564 address: notifications/{userId}
1565 parameters:
1566 userId:
1567 schema:
1568 type: string
1569 messages:
1570 NotificationMessage:
1571 contentType: application/json
1572 payload:
1573 type: object
1574 required:
1575 - title
1576 properties:
1577 title:
1578 type: string
1579operations:
1580 sendNotification:
1581 action: send
1582 channel:
1583 $ref: '#/channels/notifications'
1584 x-barbacane-dispatch:
1585 name: nats
1586 config:
1587 subject: notifications
1588"#;
1589 let spec_path = create_test_spec(temp.path(), "notifications.yaml", spec_content);
1590 let output_path = temp.path().join("artifact.bca");
1591
1592 let result = compile(
1593 &[spec_path.as_path()],
1594 &[],
1595 &output_path,
1596 &CompileOptions::default(),
1597 )
1598 .unwrap();
1599
1600 assert_eq!(result.manifest.routes_count, 1);
1601
1602 let routes = load_routes(&output_path).unwrap();
1603 let op = &routes.operations[0];
1604
1605 assert_eq!(op.path, "notifications/{userId}");
1606 assert_eq!(op.method, "SEND");
1607
1608 assert_eq!(op.parameters.len(), 1);
1610 assert_eq!(op.parameters[0].name, "userId");
1611
1612 assert!(op.request_body.is_some());
1614 }
1615
1616 #[test]
1617 fn compile_detects_invalid_path_template_unclosed_brace() {
1618 let temp = TempDir::new().unwrap();
1619
1620 let spec_content = r#"
1621openapi: "3.1.0"
1622info:
1623 title: Test API
1624 version: "1.0.0"
1625paths:
1626 /users/{id:
1627 get:
1628 x-barbacane-dispatch:
1629 name: mock
1630"#;
1631 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1632 let output_path = temp.path().join("artifact.bca");
1633
1634 let result = compile(
1635 &[spec_path.as_path()],
1636 &[],
1637 &output_path,
1638 &CompileOptions::default(),
1639 );
1640
1641 assert!(matches!(result, Err(CompileError::InvalidPathTemplate(_))));
1642 }
1643
1644 #[test]
1645 fn compile_detects_invalid_path_template_empty_param() {
1646 let temp = TempDir::new().unwrap();
1647
1648 let spec_content = r#"
1649openapi: "3.1.0"
1650info:
1651 title: Test API
1652 version: "1.0.0"
1653paths:
1654 /users/{}:
1655 get:
1656 x-barbacane-dispatch:
1657 name: mock
1658"#;
1659 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1660 let output_path = temp.path().join("artifact.bca");
1661
1662 let result = compile(
1663 &[spec_path.as_path()],
1664 &[],
1665 &output_path,
1666 &CompileOptions::default(),
1667 );
1668
1669 assert!(matches!(result, Err(CompileError::InvalidPathTemplate(_))));
1670 }
1671
1672 #[test]
1673 fn compile_detects_invalid_path_template_duplicate_param() {
1674 let temp = TempDir::new().unwrap();
1675
1676 let spec_content = r#"
1677openapi: "3.1.0"
1678info:
1679 title: Test API
1680 version: "1.0.0"
1681paths:
1682 /users/{id}/posts/{id}:
1683 get:
1684 x-barbacane-dispatch:
1685 name: mock
1686"#;
1687 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1688 let output_path = temp.path().join("artifact.bca");
1689
1690 let result = compile(
1691 &[spec_path.as_path()],
1692 &[],
1693 &output_path,
1694 &CompileOptions::default(),
1695 );
1696
1697 assert!(matches!(result, Err(CompileError::InvalidPathTemplate(_))));
1698 }
1699
1700 #[test]
1701 fn compile_detects_duplicate_operation_ids() {
1702 let temp = TempDir::new().unwrap();
1703
1704 let spec_content = r#"
1705openapi: "3.1.0"
1706info:
1707 title: Test API
1708 version: "1.0.0"
1709paths:
1710 /users:
1711 get:
1712 operationId: getUsers
1713 x-barbacane-dispatch:
1714 name: mock
1715 /customers:
1716 get:
1717 operationId: getUsers
1718 x-barbacane-dispatch:
1719 name: mock
1720"#;
1721 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1722 let output_path = temp.path().join("artifact.bca");
1723
1724 let result = compile(
1725 &[spec_path.as_path()],
1726 &[],
1727 &output_path,
1728 &CompileOptions::default(),
1729 );
1730
1731 assert!(matches!(
1732 result,
1733 Err(CompileError::DuplicateOperationId(_, _))
1734 ));
1735 }
1736
1737 #[test]
1738 fn compile_detects_missing_middleware_name() {
1739 let temp = TempDir::new().unwrap();
1740
1741 let spec_content = r#"
1742openapi: "3.1.0"
1743info:
1744 title: Test API
1745 version: "1.0.0"
1746paths:
1747 /users:
1748 get:
1749 x-barbacane-dispatch:
1750 name: mock
1751 x-barbacane-middlewares:
1752 - name: ""
1753 config: {}
1754"#;
1755 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1756 let output_path = temp.path().join("artifact.bca");
1757
1758 let result = compile(
1759 &[spec_path.as_path()],
1760 &[],
1761 &output_path,
1762 &CompileOptions::default(),
1763 );
1764
1765 assert!(matches!(
1766 result,
1767 Err(CompileError::MissingMiddlewareName(_))
1768 ));
1769 }
1770
1771 #[test]
1772 fn compile_detects_missing_global_middleware_name() {
1773 let temp = TempDir::new().unwrap();
1774
1775 let spec_content = r#"
1776openapi: "3.1.0"
1777info:
1778 title: Test API
1779 version: "1.0.0"
1780x-barbacane-middlewares:
1781 - name: ""
1782 config: {}
1783paths:
1784 /users:
1785 get:
1786 x-barbacane-dispatch:
1787 name: mock
1788"#;
1789 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1790 let output_path = temp.path().join("artifact.bca");
1791
1792 let result = compile(
1793 &[spec_path.as_path()],
1794 &[],
1795 &output_path,
1796 &CompileOptions::default(),
1797 );
1798
1799 assert!(matches!(
1800 result,
1801 Err(CompileError::MissingMiddlewareName(_))
1802 ));
1803 }
1804
1805 #[test]
1806 fn compile_detects_ambiguous_routes() {
1807 let temp = TempDir::new().unwrap();
1808
1809 let spec1 = r#"
1810openapi: "3.1.0"
1811info:
1812 title: API 1
1813 version: "1.0.0"
1814paths:
1815 /users/{id}:
1816 get:
1817 x-barbacane-dispatch:
1818 name: mock
1819"#;
1820 let spec2 = r#"
1821openapi: "3.1.0"
1822info:
1823 title: API 2
1824 version: "1.0.0"
1825paths:
1826 /users/{userId}:
1827 get:
1828 x-barbacane-dispatch:
1829 name: mock
1830"#;
1831 let path1 = create_test_spec(temp.path(), "api1.yaml", spec1);
1832 let path2 = create_test_spec(temp.path(), "api2.yaml", spec2);
1833 let output_path = temp.path().join("artifact.bca");
1834
1835 let result = compile(
1836 &[path1.as_path(), path2.as_path()],
1837 &[],
1838 &output_path,
1839 &CompileOptions::default(),
1840 );
1841
1842 assert!(matches!(result, Err(CompileError::AmbiguousRoute(_))));
1843 }
1844
1845 #[test]
1846 fn compile_allows_same_structure_same_params() {
1847 let temp = TempDir::new().unwrap();
1850
1851 let spec_content = r#"
1852openapi: "3.1.0"
1853info:
1854 title: Test API
1855 version: "1.0.0"
1856paths:
1857 /users/{id}:
1858 get:
1859 x-barbacane-dispatch:
1860 name: mock
1861 /posts/{id}:
1862 get:
1863 x-barbacane-dispatch:
1864 name: mock
1865"#;
1866 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1867 let output_path = temp.path().join("artifact.bca");
1868
1869 let result = compile(
1871 &[spec_path.as_path()],
1872 &[],
1873 &output_path,
1874 &CompileOptions::default(),
1875 );
1876 assert!(result.is_ok());
1877 }
1878
1879 #[test]
1880 fn compile_detects_schema_too_deep() {
1881 let temp = TempDir::new().unwrap();
1882
1883 let mut nested = r#"{"type": "string"}"#.to_string();
1885 for _ in 0..40 {
1886 nested = format!(
1887 r#"{{"type": "object", "properties": {{"nested": {}}}}}"#,
1888 nested
1889 );
1890 }
1891
1892 let spec_content = format!(
1893 r#"
1894openapi: "3.1.0"
1895info:
1896 title: Test API
1897 version: "1.0.0"
1898paths:
1899 /test:
1900 post:
1901 x-barbacane-dispatch:
1902 name: mock
1903 requestBody:
1904 content:
1905 application/json:
1906 schema: {}
1907"#,
1908 nested
1909 );
1910
1911 let spec_path = create_test_spec(temp.path(), "test.yaml", &spec_content);
1912 let output_path = temp.path().join("artifact.bca");
1913
1914 let result = compile(
1915 &[spec_path.as_path()],
1916 &[],
1917 &output_path,
1918 &CompileOptions::default(),
1919 );
1920
1921 assert!(matches!(result, Err(CompileError::SchemaTooDeep(_))));
1922 }
1923
1924 #[test]
1925 fn compile_detects_schema_too_complex() {
1926 let temp = TempDir::new().unwrap();
1927
1928 let mut properties = String::new();
1930 for i in 0..300 {
1931 if i > 0 {
1932 properties.push_str(", ");
1933 }
1934 properties.push_str(&format!(r#""prop{}": {{"type": "string"}}"#, i));
1935 }
1936
1937 let spec_content = format!(
1938 r#"
1939openapi: "3.1.0"
1940info:
1941 title: Test API
1942 version: "1.0.0"
1943paths:
1944 /test:
1945 post:
1946 x-barbacane-dispatch:
1947 name: mock
1948 requestBody:
1949 content:
1950 application/json:
1951 schema:
1952 type: object
1953 properties:
1954 {{{}}}
1955"#,
1956 properties
1957 );
1958
1959 let spec_path = create_test_spec(temp.path(), "test.yaml", &spec_content);
1960 let output_path = temp.path().join("artifact.bca");
1961
1962 let result = compile(
1963 &[spec_path.as_path()],
1964 &[],
1965 &output_path,
1966 &CompileOptions::default(),
1967 );
1968
1969 assert!(matches!(result, Err(CompileError::SchemaTooComplex(_))));
1970 }
1971
1972 #[test]
1973 fn compile_allows_schema_within_limits() {
1974 let temp = TempDir::new().unwrap();
1975
1976 let spec_content = r#"
1977openapi: "3.1.0"
1978info:
1979 title: Test API
1980 version: "1.0.0"
1981paths:
1982 /test:
1983 post:
1984 x-barbacane-dispatch:
1985 name: mock
1986 requestBody:
1987 content:
1988 application/json:
1989 schema:
1990 type: object
1991 properties:
1992 name:
1993 type: string
1994 age:
1995 type: integer
1996 address:
1997 type: object
1998 properties:
1999 street:
2000 type: string
2001 city:
2002 type: string
2003"#;
2004 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2005 let output_path = temp.path().join("artifact.bca");
2006
2007 let result = compile(
2008 &[spec_path.as_path()],
2009 &[],
2010 &output_path,
2011 &CompileOptions::default(),
2012 );
2013 assert!(result.is_ok());
2014 }
2015
2016 #[test]
2017 fn normalize_path_template_works() {
2018 assert_eq!(normalize_path_template("/users/{id}"), "/users/{_}");
2019 assert_eq!(normalize_path_template("/users/{userId}"), "/users/{_}");
2020 assert_eq!(
2021 normalize_path_template("/users/{id}/posts/{postId}"),
2022 "/users/{_}/posts/{_}"
2023 );
2024 assert_eq!(normalize_path_template("/static/path"), "/static/path");
2025 assert_eq!(normalize_path_template("/files/{path+}"), "/files/{_+}");
2027 assert_eq!(
2028 normalize_path_template("/files/{bucket}/{key+}"),
2029 "/files/{_}/{_+}"
2030 );
2031 }
2032
2033 #[test]
2034 fn validate_path_template_valid_cases() {
2035 assert!(validate_path_template("/users", "test").is_ok());
2036 assert!(validate_path_template("/users/{id}", "test").is_ok());
2037 assert!(validate_path_template("/users/{user_id}", "test").is_ok());
2038 assert!(validate_path_template("/users/{id}/posts/{postId}", "test").is_ok());
2039 assert!(validate_path_template("/files/{path+}", "test").is_ok());
2041 assert!(validate_path_template("/files/{bucket}/{key+}", "test").is_ok());
2043 assert!(validate_path_template("/api/{version}/files/{rest+}", "test").is_ok());
2045 }
2046
2047 #[test]
2048 fn validate_path_template_invalid_cases() {
2049 assert!(validate_path_template("/users/{id", "test").is_err());
2051 assert!(validate_path_template("/users/{}", "test").is_err());
2053 assert!(validate_path_template("/users/{id}/posts/{id}", "test").is_err());
2055 assert!(validate_path_template("/users/{{id}}", "test").is_err());
2057 assert!(validate_path_template("/users/{id-name}", "test").is_err());
2059 assert!(validate_path_template("/users/{id+}/orders", "test").is_err());
2061 assert!(validate_path_template("/a/{x+}/{y+}", "test").is_err());
2063 assert!(validate_path_template("/users/{na+me}", "test").is_err());
2065 assert!(validate_path_template("/users/{+}", "test").is_err());
2067 }
2068
2069 #[test]
2070 fn compile_inherits_global_middlewares_when_none() {
2071 let temp = TempDir::new().unwrap();
2072
2073 let spec_content = r#"
2074openapi: "3.1.0"
2075info:
2076 title: Test API
2077 version: "1.0.0"
2078x-barbacane-middlewares:
2079 - name: rate-limit
2080 config:
2081 quota: 60
2082 - name: cors
2083 config:
2084 allow_origin: "*"
2085paths:
2086 /users:
2087 get:
2088 x-barbacane-dispatch:
2089 name: mock
2090"#;
2091 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2092 let output_path = temp.path().join("artifact.bca");
2093
2094 compile(
2095 &[spec_path.as_path()],
2096 &[],
2097 &output_path,
2098 &CompileOptions::default(),
2099 )
2100 .unwrap();
2101
2102 let routes = load_routes(&output_path).unwrap();
2103 assert_eq!(routes.operations.len(), 1);
2104 let op = &routes.operations[0];
2105 assert_eq!(op.middlewares.len(), 2);
2106 assert_eq!(op.middlewares[0].name, "rate-limit");
2107 assert_eq!(op.middlewares[1].name, "cors");
2108 }
2109
2110 #[test]
2111 fn compile_empty_middlewares_opts_out_of_globals() {
2112 let temp = TempDir::new().unwrap();
2113
2114 let spec_content = r#"
2115openapi: "3.1.0"
2116info:
2117 title: Test API
2118 version: "1.0.0"
2119x-barbacane-middlewares:
2120 - name: rate-limit
2121 config:
2122 quota: 60
2123paths:
2124 /users:
2125 get:
2126 x-barbacane-dispatch:
2127 name: mock
2128 x-barbacane-middlewares: []
2129"#;
2130 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2131 let output_path = temp.path().join("artifact.bca");
2132
2133 compile(
2134 &[spec_path.as_path()],
2135 &[],
2136 &output_path,
2137 &CompileOptions::default(),
2138 )
2139 .unwrap();
2140
2141 let routes = load_routes(&output_path).unwrap();
2142 let op = &routes.operations[0];
2143 assert_eq!(op.middlewares.len(), 0);
2144 }
2145
2146 #[test]
2147 fn compile_merges_global_and_operation_middlewares() {
2148 let temp = TempDir::new().unwrap();
2149
2150 let spec_content = r#"
2151openapi: "3.1.0"
2152info:
2153 title: Test API
2154 version: "1.0.0"
2155x-barbacane-middlewares:
2156 - name: rate-limit
2157 config:
2158 quota: 60
2159 - name: cors
2160 config:
2161 allow_origin: "*"
2162paths:
2163 /users:
2164 get:
2165 x-barbacane-dispatch:
2166 name: mock
2167 x-barbacane-middlewares:
2168 - name: auth
2169 config:
2170 type: bearer
2171"#;
2172 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2173 let output_path = temp.path().join("artifact.bca");
2174
2175 compile(
2176 &[spec_path.as_path()],
2177 &[],
2178 &output_path,
2179 &CompileOptions::default(),
2180 )
2181 .unwrap();
2182
2183 let routes = load_routes(&output_path).unwrap();
2184 let op = &routes.operations[0];
2185 assert_eq!(op.middlewares.len(), 3);
2187 assert_eq!(op.middlewares[0].name, "rate-limit");
2188 assert_eq!(op.middlewares[1].name, "cors");
2189 assert_eq!(op.middlewares[2].name, "auth");
2190 }
2191
2192 #[test]
2193 fn compile_operation_middleware_overrides_global_by_name() {
2194 let temp = TempDir::new().unwrap();
2195
2196 let spec_content = r#"
2197openapi: "3.1.0"
2198info:
2199 title: Test API
2200 version: "1.0.0"
2201x-barbacane-middlewares:
2202 - name: rate-limit
2203 config:
2204 quota: 60
2205 - name: cors
2206 config:
2207 allow_origin: "*"
2208paths:
2209 /users:
2210 get:
2211 x-barbacane-dispatch:
2212 name: mock
2213 x-barbacane-middlewares:
2214 - name: rate-limit
2215 config:
2216 quota: 1000
2217"#;
2218 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2219 let output_path = temp.path().join("artifact.bca");
2220
2221 compile(
2222 &[spec_path.as_path()],
2223 &[],
2224 &output_path,
2225 &CompileOptions::default(),
2226 )
2227 .unwrap();
2228
2229 let routes = load_routes(&output_path).unwrap();
2230 let op = &routes.operations[0];
2231 assert_eq!(op.middlewares.len(), 2);
2233 assert_eq!(op.middlewares[0].name, "cors");
2234 assert_eq!(op.middlewares[1].name, "rate-limit");
2235 assert_eq!(op.middlewares[1].config.get("quota").unwrap(), 1000);
2237 }
2238
2239 #[test]
2240 fn compile_inherits_global_middlewares() {
2241 let temp = TempDir::new().unwrap();
2242
2243 let spec_content = r#"
2244openapi: "3.1.0"
2245info:
2246 title: Test API
2247 version: "1.0.0"
2248x-barbacane-middlewares:
2249 - name: rate-limit
2250 config:
2251 quota: 60
2252paths:
2253 /health:
2254 get:
2255 x-barbacane-dispatch:
2256 name: mock
2257"#;
2258 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2259 let output_path = temp.path().join("artifact.bca");
2260
2261 let plugins = vec![];
2262 let result = compile(
2263 &[spec_path.as_path()],
2264 &plugins,
2265 &output_path,
2266 &CompileOptions::default(),
2267 )
2268 .unwrap();
2269
2270 let routes = load_routes(&output_path).unwrap();
2271 let op = &routes.operations[0];
2272 assert_eq!(op.middlewares.len(), 1);
2273 assert_eq!(op.middlewares[0].name, "rate-limit");
2274 assert_eq!(result.manifest.plugins.len(), 0);
2275 }
2276
2277 #[test]
2278 fn artifact_hash_is_deterministic() {
2279 let source_specs = vec![
2280 SourceSpec {
2281 file: "api.yaml".to_string(),
2282 sha256: "aaa".to_string(),
2283 spec_type: "openapi".to_string(),
2284 version: "3.1.0".to_string(),
2285 },
2286 SourceSpec {
2287 file: "events.yaml".to_string(),
2288 sha256: "bbb".to_string(),
2289 spec_type: "asyncapi".to_string(),
2290 version: "3.0.0".to_string(),
2291 },
2292 ];
2293 let mut checksums = BTreeMap::new();
2294 checksums.insert("routes.json".to_string(), "ccc".to_string());
2295 checksums.insert("plugins/mock.wasm".to_string(), "ddd".to_string());
2296
2297 let hash1 = compute_artifact_hash(&source_specs, &checksums);
2298 let hash2 = compute_artifact_hash(&source_specs, &checksums);
2299
2300 assert_eq!(hash1, hash2, "Same inputs must produce same hash");
2301 assert!(
2302 hash1.starts_with("sha256:"),
2303 "Hash must have sha256: prefix"
2304 );
2305 }
2306
2307 #[test]
2308 fn artifact_hash_differs_with_different_specs() {
2309 let specs_a = vec![SourceSpec {
2310 file: "api.yaml".to_string(),
2311 sha256: "aaa".to_string(),
2312 spec_type: "openapi".to_string(),
2313 version: "3.1.0".to_string(),
2314 }];
2315 let specs_b = vec![SourceSpec {
2316 file: "api.yaml".to_string(),
2317 sha256: "bbb".to_string(),
2318 spec_type: "openapi".to_string(),
2319 version: "3.1.0".to_string(),
2320 }];
2321 let checksums = BTreeMap::new();
2322
2323 let hash_a = compute_artifact_hash(&specs_a, &checksums);
2324 let hash_b = compute_artifact_hash(&specs_b, &checksums);
2325
2326 assert_ne!(
2327 hash_a, hash_b,
2328 "Different spec hashes must produce different artifact hashes"
2329 );
2330 }
2331
2332 #[test]
2333 fn artifact_hash_differs_with_different_checksums() {
2334 let specs = vec![SourceSpec {
2335 file: "api.yaml".to_string(),
2336 sha256: "aaa".to_string(),
2337 spec_type: "openapi".to_string(),
2338 version: "3.1.0".to_string(),
2339 }];
2340 let mut checksums_a = BTreeMap::new();
2341 checksums_a.insert("routes.json".to_string(), "v1".to_string());
2342 let mut checksums_b = BTreeMap::new();
2343 checksums_b.insert("routes.json".to_string(), "v2".to_string());
2344
2345 let hash_a = compute_artifact_hash(&specs, &checksums_a);
2346 let hash_b = compute_artifact_hash(&specs, &checksums_b);
2347
2348 assert_ne!(
2349 hash_a, hash_b,
2350 "Different route checksums must produce different artifact hashes"
2351 );
2352 }
2353
2354 #[test]
2355 fn provenance_serialization_round_trip() {
2356 let prov = Provenance {
2357 commit: Some("abc123".to_string()),
2358 source: Some("ci/github-actions".to_string()),
2359 };
2360
2361 let json = serde_json::to_string(&prov).unwrap();
2362 let deserialized: Provenance = serde_json::from_str(&json).unwrap();
2363
2364 assert_eq!(deserialized.commit, Some("abc123".to_string()));
2365 assert_eq!(deserialized.source, Some("ci/github-actions".to_string()));
2366 }
2367
2368 #[test]
2369 fn provenance_defaults_to_none() {
2370 let prov = Provenance::default();
2371
2372 assert!(prov.commit.is_none());
2373 assert!(prov.source.is_none());
2374
2375 let json = serde_json::to_string(&prov).unwrap();
2377 let deserialized: Provenance = serde_json::from_str(&json).unwrap();
2378 assert!(deserialized.commit.is_none());
2379 assert!(deserialized.source.is_none());
2380 }
2381
2382 #[test]
2383 fn compile_produces_artifact_hash_and_provenance() {
2384 let temp = TempDir::new().unwrap();
2385
2386 let spec_content = r#"
2387openapi: "3.1.0"
2388info:
2389 title: Test API
2390 version: "1.0.0"
2391paths:
2392 /health:
2393 get:
2394 x-barbacane-dispatch:
2395 name: mock
2396"#;
2397 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2398 let output_path = temp.path().join("artifact.bca");
2399
2400 let result = compile(
2401 &[spec_path.as_path()],
2402 &[],
2403 &output_path,
2404 &CompileOptions {
2405 provenance_commit: Some("deadbeef".to_string()),
2406 provenance_source: Some("test".to_string()),
2407 ..Default::default()
2408 },
2409 )
2410 .unwrap();
2411
2412 assert!(result.manifest.artifact_hash.starts_with("sha256:"));
2414 assert!(result.manifest.artifact_hash.len() > 10);
2415
2416 assert_eq!(
2418 result.manifest.provenance.commit,
2419 Some("deadbeef".to_string())
2420 );
2421 assert_eq!(result.manifest.provenance.source, Some("test".to_string()));
2422
2423 let loaded = load_manifest(&output_path).unwrap();
2425 assert_eq!(loaded.artifact_hash, result.manifest.artifact_hash);
2426 assert_eq!(loaded.provenance.commit, Some("deadbeef".to_string()));
2427 assert_eq!(loaded.provenance.source, Some("test".to_string()));
2428 }
2429
2430 #[test]
2431 fn compile_without_provenance_has_none_fields() {
2432 let temp = TempDir::new().unwrap();
2433
2434 let spec_content = r#"
2435openapi: "3.1.0"
2436info:
2437 title: Test API
2438 version: "1.0.0"
2439paths:
2440 /health:
2441 get:
2442 x-barbacane-dispatch:
2443 name: mock
2444"#;
2445 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2446 let output_path = temp.path().join("artifact.bca");
2447
2448 let result = compile(
2449 &[spec_path.as_path()],
2450 &[],
2451 &output_path,
2452 &CompileOptions::default(),
2453 )
2454 .unwrap();
2455
2456 assert!(result.manifest.artifact_hash.starts_with("sha256:"));
2458
2459 assert!(result.manifest.provenance.commit.is_none());
2461 assert!(result.manifest.provenance.source.is_none());
2462 }
2463
2464 #[test]
2465 fn same_spec_produces_same_artifact_hash() {
2466 let temp = TempDir::new().unwrap();
2467
2468 let spec_content = r#"
2469openapi: "3.1.0"
2470info:
2471 title: Test API
2472 version: "1.0.0"
2473paths:
2474 /health:
2475 get:
2476 x-barbacane-dispatch:
2477 name: mock
2478 config:
2479 status: 200
2480"#;
2481 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2482
2483 let out1 = temp.path().join("artifact1.bca");
2484 let out2 = temp.path().join("artifact2.bca");
2485
2486 let r1 = compile(
2487 &[spec_path.as_path()],
2488 &[],
2489 &out1,
2490 &CompileOptions::default(),
2491 )
2492 .unwrap();
2493 let r2 = compile(
2494 &[spec_path.as_path()],
2495 &[],
2496 &out2,
2497 &CompileOptions::default(),
2498 )
2499 .unwrap();
2500
2501 assert_eq!(
2502 r1.manifest.artifact_hash, r2.manifest.artifact_hash,
2503 "Compiling the same spec twice must produce the same artifact hash"
2504 );
2505 }
2506
2507 #[test]
2508 fn different_specs_produce_different_artifact_hashes() {
2509 let temp = TempDir::new().unwrap();
2510
2511 let spec_a = r#"
2512openapi: "3.1.0"
2513info:
2514 title: API A
2515 version: "1.0.0"
2516paths:
2517 /health:
2518 get:
2519 x-barbacane-dispatch:
2520 name: mock
2521 config:
2522 status: 200
2523"#;
2524 let spec_b = r#"
2525openapi: "3.1.0"
2526info:
2527 title: API B
2528 version: "1.0.0"
2529paths:
2530 /health:
2531 get:
2532 x-barbacane-dispatch:
2533 name: mock
2534 config:
2535 status: 200
2536 /users:
2537 get:
2538 x-barbacane-dispatch:
2539 name: mock
2540 config:
2541 status: 200
2542"#;
2543 let path_a = create_test_spec(temp.path(), "a.yaml", spec_a);
2544 let path_b = create_test_spec(temp.path(), "b.yaml", spec_b);
2545 let out_a = temp.path().join("a.bca");
2546 let out_b = temp.path().join("b.bca");
2547
2548 let ra = compile(&[path_a.as_path()], &[], &out_a, &CompileOptions::default()).unwrap();
2549 let rb = compile(&[path_b.as_path()], &[], &out_b, &CompileOptions::default()).unwrap();
2550
2551 assert_ne!(
2552 ra.manifest.artifact_hash, rb.manifest.artifact_hash,
2553 "Different specs must produce different artifact hashes"
2554 );
2555 }
2556
2557 #[test]
2558 fn provenance_does_not_affect_artifact_hash() {
2559 let temp = TempDir::new().unwrap();
2560
2561 let spec_content = r#"
2562openapi: "3.1.0"
2563info:
2564 title: Test API
2565 version: "1.0.0"
2566paths:
2567 /health:
2568 get:
2569 x-barbacane-dispatch:
2570 name: mock
2571"#;
2572 let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2573 let out1 = temp.path().join("a.bca");
2574 let out2 = temp.path().join("b.bca");
2575
2576 let r1 = compile(
2577 &[spec_path.as_path()],
2578 &[],
2579 &out1,
2580 &CompileOptions {
2581 provenance_commit: Some("commit-a".to_string()),
2582 provenance_source: Some("source-a".to_string()),
2583 ..Default::default()
2584 },
2585 )
2586 .unwrap();
2587 let r2 = compile(
2588 &[spec_path.as_path()],
2589 &[],
2590 &out2,
2591 &CompileOptions {
2592 provenance_commit: Some("commit-b".to_string()),
2593 provenance_source: Some("source-b".to_string()),
2594 ..Default::default()
2595 },
2596 )
2597 .unwrap();
2598
2599 assert_eq!(
2600 r1.manifest.artifact_hash, r2.manifest.artifact_hash,
2601 "Provenance metadata must not affect artifact hash"
2602 );
2603 }
2604
2605 #[test]
2608 fn extract_root_mcp_config_enabled() {
2609 let spec = ApiSpec {
2610 filename: None,
2611 format: SpecFormat::OpenApi,
2612 version: "3.1.0".to_string(),
2613 title: "My API".to_string(),
2614 api_version: "2.0.0".to_string(),
2615 operations: vec![],
2616 global_middlewares: vec![],
2617 extensions: BTreeMap::from([(
2618 "x-barbacane-mcp".to_string(),
2619 serde_json::json!({
2620 "enabled": true,
2621 "server_name": "Custom Name"
2622 }),
2623 )]),
2624 };
2625 let specs = vec![(spec, String::new(), String::new())];
2626 let cfg = extract_root_mcp_config(&specs);
2627 assert!(cfg.enabled);
2628 assert_eq!(cfg.server_name.as_deref(), Some("Custom Name"));
2629 assert!(cfg.server_version.is_none());
2630 }
2631
2632 #[test]
2633 fn extract_root_mcp_config_disabled_by_default() {
2634 let spec = ApiSpec {
2635 filename: None,
2636 format: SpecFormat::OpenApi,
2637 version: "3.1.0".to_string(),
2638 title: "Test".to_string(),
2639 api_version: "1.0.0".to_string(),
2640 operations: vec![],
2641 global_middlewares: vec![],
2642 extensions: BTreeMap::new(),
2643 };
2644 let specs = vec![(spec, String::new(), String::new())];
2645 let cfg = extract_root_mcp_config(&specs);
2646 assert!(!cfg.enabled);
2647 }
2648
2649 #[test]
2650 fn resolve_mcp_config_inherits_from_root() {
2651 let root = McpConfig {
2652 enabled: true,
2653 server_name: None,
2654 server_version: None,
2655 };
2656 let (enabled, desc) = resolve_mcp_config(&root, None);
2658 assert_eq!(enabled, Some(true));
2659 assert!(desc.is_none());
2660 }
2661
2662 #[test]
2663 fn resolve_mcp_config_operation_overrides_root() {
2664 let root = McpConfig {
2665 enabled: true,
2666 server_name: None,
2667 server_version: None,
2668 };
2669 let ext = serde_json::json!({"enabled": false});
2671 let (enabled, _) = resolve_mcp_config(&root, Some(&ext));
2672 assert_eq!(enabled, Some(false));
2673 }
2674
2675 #[test]
2676 fn resolve_mcp_config_operation_description_override() {
2677 let root = McpConfig {
2678 enabled: true,
2679 server_name: None,
2680 server_version: None,
2681 };
2682 let ext = serde_json::json!({"description": "Custom tool description"});
2683 let (enabled, desc) = resolve_mcp_config(&root, Some(&ext));
2684 assert_eq!(enabled, Some(true));
2686 assert_eq!(desc.as_deref(), Some("Custom tool description"));
2687 }
2688
2689 #[test]
2690 fn resolve_mcp_config_root_disabled_no_inheritance() {
2691 let root = McpConfig {
2692 enabled: false,
2693 server_name: None,
2694 server_version: None,
2695 };
2696 let (enabled, _) = resolve_mcp_config(&root, None);
2697 assert!(enabled.is_none());
2698 }
2699}