use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{Read, Write};
use std::path::Path;
use flate2::write::GzEncoder;
use flate2::Compression;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tar::Builder;
use std::collections::BTreeMap;
use crate::spec_parser::{
parse_spec_file, ApiSpec, DispatchConfig, Message, MiddlewareConfig, Parameter, RequestBody,
ResponseContent, SpecFormat,
};
use crate::error::{CompileError, CompileWarning};
use crate::manifest::ProjectManifest;
pub const ARTIFACT_VERSION: u32 = 3;
#[derive(Debug, Clone)]
pub struct CompileOptions {
pub allow_plaintext: bool,
pub max_schema_depth: usize,
pub max_schema_properties: usize,
pub provenance_commit: Option<String>,
pub provenance_source: Option<String>,
pub no_cache: bool,
}
impl Default for CompileOptions {
fn default() -> Self {
Self {
allow_plaintext: false,
max_schema_depth: 32,
max_schema_properties: 256,
provenance_commit: None,
provenance_source: None,
no_cache: false,
}
}
}
pub const COMPILER_VERSION: &str = env!("CARGO_PKG_VERSION");
const KNOWN_EXTENSIONS: &[&str] = &[
"x-barbacane-dispatch", "x-barbacane-middlewares", "x-barbacane-mcp", ];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompileResult {
pub manifest: Manifest,
pub warnings: Vec<CompileWarning>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub barbacane_artifact_version: u32,
pub compiled_at: String,
pub compiler_version: String,
pub source_specs: Vec<SourceSpec>,
pub routes_count: usize,
pub checksums: BTreeMap<String, String>,
pub plugins: Vec<BundledPlugin>,
pub artifact_hash: String,
pub provenance: Provenance,
#[serde(default)]
pub mcp: McpConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpConfig {
pub enabled: bool,
#[serde(default)]
pub server_name: Option<String>,
#[serde(default)]
pub server_version: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Provenance {
pub commit: Option<String>,
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundledPlugin {
pub name: String,
pub version: String,
pub plugin_type: String,
pub wasm_path: String,
pub sha256: String,
pub capabilities: PluginCapabilities,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginCapabilities {
#[serde(default)]
pub body_access: bool,
}
#[derive(Debug, Clone)]
pub struct LoadedPlugin {
pub version: String,
pub wasm_bytes: Vec<u8>,
pub body_access: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceSpec {
pub file: String,
pub sha256: String,
#[serde(rename = "type")]
pub spec_type: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompiledRoutes {
pub operations: Vec<CompiledOperation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompiledOperation {
pub index: usize,
pub path: String,
pub method: String,
pub operation_id: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
pub parameters: Vec<Parameter>,
pub request_body: Option<RequestBody>,
pub dispatch: DispatchConfig,
#[serde(default)]
pub middlewares: Vec<MiddlewareConfig>,
#[serde(default)]
pub deprecated: bool,
#[serde(default)]
pub sunset: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub messages: Vec<Message>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bindings: BTreeMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub responses: BTreeMap<String, ResponseContent>,
#[serde(default)]
pub mcp_enabled: Option<bool>,
#[serde(default)]
pub mcp_description: Option<String>,
}
pub fn compile(
spec_paths: &[&Path],
plugins: &[PluginBundle],
output: &Path,
options: &CompileOptions,
) -> Result<CompileResult, CompileError> {
let specs = parse_specs(spec_paths)?;
compile_inner(&specs, plugins, output, options)
}
pub fn compile_with_manifest(
spec_paths: &[&Path],
project_manifest: &ProjectManifest,
manifest_base_path: &Path,
output: &Path,
options: &CompileOptions,
) -> Result<CompileResult, CompileError> {
let specs = parse_specs(spec_paths)?;
let api_specs: Vec<ApiSpec> = specs.iter().map(|(spec, _, _)| spec.clone()).collect();
project_manifest.validate_specs(&api_specs)?;
let resolved_plugins =
project_manifest.resolve_used_plugins(&api_specs, manifest_base_path, options.no_cache)?;
let plugin_bundles: Vec<PluginBundle> = resolved_plugins
.into_iter()
.map(|p| PluginBundle {
name: p.name,
version: p.version.unwrap_or_else(|| "0.1.0".to_string()),
plugin_type: p.plugin_type.unwrap_or_else(|| "plugin".to_string()),
wasm_bytes: p.wasm_bytes,
body_access: p.body_access,
})
.collect();
compile_inner(&specs, &plugin_bundles, output, options)
}
pub fn load_manifest(artifact_path: &Path) -> Result<Manifest, CompileError> {
let file = File::open(artifact_path)?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?;
if path.to_str() == Some("manifest.json") {
let mut content = String::new();
entry.read_to_string(&mut content)?;
let manifest: Manifest = serde_json::from_str(&content)?;
return Ok(manifest);
}
}
Err(CompileError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"manifest.json not found in artifact",
)))
}
pub fn load_routes(artifact_path: &Path) -> Result<CompiledRoutes, CompileError> {
let file = File::open(artifact_path)?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?;
if path.to_str() == Some("routes.json") {
let mut content = String::new();
entry.read_to_string(&mut content)?;
let routes: CompiledRoutes = serde_json::from_str(&content)?;
return Ok(routes);
}
}
Err(CompileError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"routes.json not found in artifact",
)))
}
pub fn load_specs(artifact_path: &Path) -> Result<HashMap<String, String>, CompileError> {
let file = File::open(artifact_path)?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
let mut specs = HashMap::new();
for entry in archive.entries()? {
let mut entry = entry?;
let path_str = entry.path()?.to_string_lossy().into_owned();
if let Some(filename) = path_str.strip_prefix("specs/") {
if !filename.is_empty() {
let mut content = String::new();
entry.read_to_string(&mut content)?;
specs.insert(filename.to_string(), content);
}
}
}
Ok(specs)
}
pub fn load_plugins(artifact_path: &Path) -> Result<HashMap<String, LoadedPlugin>, CompileError> {
let manifest = load_manifest(artifact_path)?;
let file = File::open(artifact_path)?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
let mut plugins = HashMap::new();
let plugin_info: HashMap<String, (&BundledPlugin,)> = manifest
.plugins
.iter()
.map(|p| (p.wasm_path.clone(), (p,)))
.collect();
for entry in archive.entries()? {
let mut entry = entry?;
let path_str = entry.path()?.to_string_lossy().into_owned();
if let Some((bundled,)) = plugin_info.get(&path_str) {
let mut wasm_bytes = Vec::new();
entry.read_to_end(&mut wasm_bytes)?;
plugins.insert(
bundled.name.clone(),
LoadedPlugin {
version: bundled.version.clone(),
wasm_bytes,
body_access: bundled.capabilities.body_access,
},
);
}
}
Ok(plugins)
}
pub struct PluginBundle {
pub name: String,
pub version: String,
pub plugin_type: String,
pub wasm_bytes: Vec<u8>,
pub body_access: bool,
}
fn parse_specs(spec_paths: &[&Path]) -> Result<Vec<(ApiSpec, String, String)>, CompileError> {
let mut specs = Vec::new();
for path in spec_paths {
let content = std::fs::read_to_string(path)?;
let sha256 = compute_sha256(content.as_bytes());
let spec = parse_spec_file(path)?;
specs.push((spec, content, sha256));
}
Ok(specs)
}
fn resolve_middlewares(
global: &[MiddlewareConfig],
operation: &Option<Vec<MiddlewareConfig>>,
) -> Vec<MiddlewareConfig> {
match operation {
None => global.to_vec(),
Some(op_mw) if op_mw.is_empty() => Vec::new(),
Some(op_mw) => {
let op_names: HashSet<_> = op_mw.iter().map(|m| m.name.as_str()).collect();
let mut merged: Vec<_> = global
.iter()
.filter(|m| !op_names.contains(m.name.as_str()))
.cloned()
.collect();
merged.extend(op_mw.clone());
merged
}
}
}
fn compile_inner(
specs: &[(ApiSpec, String, String)],
plugins: &[PluginBundle],
output: &Path,
options: &CompileOptions,
) -> Result<CompileResult, CompileError> {
let mut warnings: Vec<CompileWarning> = Vec::new();
let mut operations: Vec<CompiledOperation> = Vec::new();
let mut seen_routes: HashMap<(String, String), String> = HashMap::new();
let mut seen_structural: HashMap<(String, String), (String, String)> = HashMap::new();
let mut seen_operation_ids: HashMap<String, String> = HashMap::new();
let root_mcp_config = extract_root_mcp_config(specs);
for (spec, _, _) in specs {
let spec_file = spec.filename.as_deref().unwrap_or("unknown");
for (idx, mw) in spec.global_middlewares.iter().enumerate() {
if mw.name.is_empty() {
return Err(CompileError::MissingMiddlewareName(format!(
"global middleware #{} in '{}'",
idx + 1,
spec_file
)));
}
}
for key in spec.extensions.keys() {
if key.starts_with("x-barbacane-") && !KNOWN_EXTENSIONS.contains(&key.as_str()) {
warnings.push(CompileWarning {
code: "E1015".to_string(),
message: format!("unknown extension: {}", key),
location: Some(spec_file.to_string()),
});
}
}
for op in &spec.operations {
let location = format!("{} {} in '{}'", op.method, op.path, spec_file);
for key in op.extensions.keys() {
if key.starts_with("x-barbacane-") && !KNOWN_EXTENSIONS.contains(&key.as_str()) {
warnings.push(CompileWarning {
code: "E1015".to_string(),
message: format!("unknown extension: {}", key),
location: Some(location.clone()),
});
}
}
validate_path_template(&op.path, &location)?;
if let Some(ref op_id) = op.operation_id {
if let Some(first_location) = seen_operation_ids.get(op_id) {
return Err(CompileError::DuplicateOperationId(
op_id.clone(),
format!("first at {}, duplicate at {}", first_location, location),
));
}
seen_operation_ids.insert(op_id.clone(), location.clone());
}
let key = (op.path.clone(), op.method.clone());
if let Some(other_spec) = seen_routes.get(&key) {
return Err(CompileError::RoutingConflict(format!(
"{} {} declared in both '{}' and '{}'",
op.method, op.path, other_spec, spec_file
)));
}
seen_routes.insert(key, spec_file.to_string());
let normalized = normalize_path_template(&op.path);
let structural_key = (normalized, op.method.clone());
if let Some((other_path, other_spec)) = seen_structural.get(&structural_key) {
if other_path != &op.path {
return Err(CompileError::AmbiguousRoute(format!(
"'{}' and '{}' have same structure but different param names ({} in '{}' vs '{}')",
op.path, other_path, op.method, spec_file, other_spec
)));
}
}
seen_structural.insert(structural_key, (op.path.clone(), spec_file.to_string()));
let dispatch = op.dispatch.clone().ok_or_else(|| {
CompileError::MissingDispatch(format!(
"{} {} in '{}'",
op.method, op.path, spec_file
))
})?;
if !options.allow_plaintext {
if let Some(url) = extract_upstream_url(&dispatch.config) {
if url.starts_with("http://") {
return Err(CompileError::PlaintextUpstream(format!(
"{} {} in '{}' - upstream URL: {}",
op.method, op.path, spec_file, url
)));
}
}
}
let middlewares = resolve_middlewares(&spec.global_middlewares, &op.middlewares);
for (idx, mw) in middlewares.iter().enumerate() {
if mw.name.is_empty() {
return Err(CompileError::MissingMiddlewareName(format!(
"middleware #{} in {}",
idx + 1,
location
)));
}
}
for param in &op.parameters {
if let Some(schema) = ¶m.schema {
let param_location = format!("{} parameter '{}'", location, param.name);
validate_schema_complexity(
schema,
options.max_schema_depth,
options.max_schema_properties,
¶m_location,
)?;
}
}
if let Some(ref body) = op.request_body {
for (content_type, content) in &body.content {
if let Some(schema) = &content.schema {
let body_location = format!("{} request body ({})", location, content_type);
validate_schema_complexity(
schema,
options.max_schema_depth,
options.max_schema_properties,
&body_location,
)?;
}
}
}
let (mcp_enabled, mcp_description) =
resolve_mcp_config(&root_mcp_config, op.extensions.get("x-barbacane-mcp"));
if mcp_enabled == Some(true) {
if op.operation_id.is_none() {
warnings.push(CompileWarning {
code: "E1060".to_string(),
message: "operation without operationId cannot be exposed as MCP tool"
.to_string(),
location: Some(location.clone()),
});
}
if op.summary.is_none() && op.description.is_none() {
warnings.push(CompileWarning {
code: "E1061".to_string(),
message:
"MCP-enabled operation has no summary or description for tool metadata"
.to_string(),
location: Some(location.clone()),
});
}
}
operations.push(CompiledOperation {
index: operations.len(),
path: op.path.clone(),
method: op.method.clone(),
operation_id: op.operation_id.clone(),
summary: op.summary.clone(),
description: op.description.clone(),
parameters: op.parameters.clone(),
request_body: op.request_body.clone(),
dispatch,
middlewares,
deprecated: op.deprecated,
sunset: op.sunset.clone(),
messages: op.messages.clone(),
bindings: op.bindings.clone(),
responses: op.responses.clone(),
mcp_enabled,
mcp_description,
});
}
}
operations.sort_by(|a, b| (&a.path, &a.method).cmp(&(&b.path, &b.method)));
for (i, op) in operations.iter_mut().enumerate() {
op.index = i;
}
let routes = CompiledRoutes { operations };
let routes_json = serde_json::to_string_pretty(&routes)?;
let routes_sha256 = compute_sha256(routes_json.as_bytes());
let mut bundled_plugins = Vec::new();
let mut checksums = BTreeMap::new();
checksums.insert(
"routes.json".to_string(),
format!("sha256:{}", routes_sha256),
);
for plugin in plugins {
let wasm_path = format!("plugins/{}.wasm", plugin.name);
let sha256 = compute_sha256(&plugin.wasm_bytes);
checksums.insert(wasm_path.clone(), format!("sha256:{}", sha256));
bundled_plugins.push(BundledPlugin {
name: plugin.name.clone(),
version: plugin.version.clone(),
plugin_type: plugin.plugin_type.clone(),
wasm_path,
sha256,
capabilities: PluginCapabilities {
body_access: plugin.body_access,
},
});
}
bundled_plugins.sort_by(|a, b| a.name.cmp(&b.name));
let mut source_specs: Vec<SourceSpec> = specs
.iter()
.map(|(spec, _, sha256)| SourceSpec {
file: spec
.filename
.clone()
.unwrap_or_else(|| "unknown".to_string()),
sha256: sha256.clone(),
spec_type: match spec.format {
SpecFormat::OpenApi => "openapi".to_string(),
SpecFormat::AsyncApi => "asyncapi".to_string(),
},
version: spec.version.clone(),
})
.collect();
source_specs.sort_by(|a, b| a.file.cmp(&b.file));
let artifact_hash = compute_artifact_hash(&source_specs, &checksums);
let provenance = Provenance {
commit: options.provenance_commit.clone(),
source: options.provenance_source.clone(),
};
let mcp = {
let mut cfg = root_mcp_config.clone();
if cfg.enabled {
if cfg.server_name.is_none() {
cfg.server_name = specs.first().map(|(s, _, _)| s.title.clone());
}
if cfg.server_version.is_none() {
cfg.server_version = specs.first().map(|(s, _, _)| s.api_version.clone());
}
}
cfg
};
let manifest = Manifest {
barbacane_artifact_version: ARTIFACT_VERSION,
compiled_at: now_utc_iso8601(),
compiler_version: COMPILER_VERSION.to_string(),
source_specs,
routes_count: routes.operations.len(),
checksums,
plugins: bundled_plugins,
artifact_hash,
provenance,
mcp,
};
let manifest_json = serde_json::to_string_pretty(&manifest)?;
let file = File::create(output)?;
let encoder = GzEncoder::new(file, Compression::default());
let mut archive = Builder::new(encoder);
add_file_to_tar(&mut archive, "manifest.json", manifest_json.as_bytes())?;
add_file_to_tar(&mut archive, "routes.json", routes_json.as_bytes())?;
for (spec, content, _) in specs {
let filename = spec
.filename
.as_deref()
.and_then(|p| Path::new(p).file_name())
.and_then(|n| n.to_str())
.unwrap_or("spec.yaml");
let archive_path = format!("specs/{}", filename);
add_file_to_tar(&mut archive, &archive_path, content.as_bytes())?;
}
for plugin in plugins {
let wasm_path = format!("plugins/{}.wasm", plugin.name);
add_file_to_tar(&mut archive, &wasm_path, &plugin.wasm_bytes)?;
}
let encoder = archive.into_inner()?;
encoder.finish()?;
warnings.sort_by(|a, b| {
(&a.location, &a.code, &a.message).cmp(&(&b.location, &b.code, &b.message))
});
Ok(CompileResult { manifest, warnings })
}
fn compute_sha256(content: &[u8]) -> String {
hex::encode(Sha256::new().chain_update(content).finalize())
}
fn compute_artifact_hash(
source_specs: &[SourceSpec],
checksums: &BTreeMap<String, String>,
) -> String {
let mut hasher = Sha256::new();
for spec in source_specs {
hasher.update(format!("source_spec:{}={}\n", spec.file, spec.sha256).as_bytes());
}
for (key, value) in checksums {
hasher.update(format!("{}={}\n", key, value).as_bytes());
}
format!("sha256:{}", hex::encode(hasher.finalize()))
}
fn add_file_to_tar<W: Write>(
archive: &mut Builder<W>,
name: &str,
content: &[u8],
) -> std::io::Result<()> {
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_mtime(0); header.set_cksum();
archive.append_data(&mut header, name, content)
}
fn now_utc_iso8601() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
fn extract_upstream_url(config: &serde_json::Value) -> Option<String> {
if let Some(url) = config.get("url").and_then(|v| v.as_str()) {
return Some(url.to_string());
}
if let Some(upstream) = config.get("upstream").and_then(|v| v.as_str()) {
if upstream.starts_with("http://") || upstream.starts_with("https://") {
return Some(upstream.to_string());
}
}
None
}
fn validate_path_template(path: &str, location: &str) -> Result<(), CompileError> {
let mut seen_params: HashSet<String> = HashSet::new();
let mut brace_depth = 0;
let mut current_param = String::new();
let mut in_param = false;
let mut has_wildcard = false;
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let last_segment = segments.last().copied().unwrap_or("");
for ch in path.chars() {
match ch {
'{' => {
if in_param {
return Err(CompileError::InvalidPathTemplate(format!(
"{} - nested braces not allowed",
location
)));
}
brace_depth += 1;
in_param = true;
}
'}' => {
if !in_param {
return Err(CompileError::InvalidPathTemplate(format!(
"{} - unmatched closing brace",
location
)));
}
brace_depth -= 1;
in_param = false;
let is_wildcard_param = current_param.ends_with('+');
let base_name = if is_wildcard_param {
¤t_param[..current_param.len() - 1]
} else {
¤t_param
};
if base_name.is_empty() {
return Err(CompileError::InvalidPathTemplate(format!(
"{} - empty parameter name",
location
)));
}
if is_wildcard_param {
if has_wildcard {
return Err(CompileError::InvalidPathTemplate(format!(
"{} - at most one wildcard parameter ({{name+}}) allowed per path",
location
)));
}
let param_segment = format!("{{{}}}", current_param);
if last_segment != param_segment {
return Err(CompileError::InvalidPathTemplate(format!(
"{} - wildcard parameter '{{{}}}' must be the last path segment",
location, current_param
)));
}
has_wildcard = true;
}
if !seen_params.insert(base_name.to_string()) {
return Err(CompileError::InvalidPathTemplate(format!(
"{} - duplicate parameter '{}'",
location, base_name
)));
}
current_param.clear();
}
_ if in_param => {
if ch == '+' {
current_param.push(ch);
} else if !ch.is_alphanumeric() && ch != '_' {
return Err(CompileError::InvalidPathTemplate(format!(
"{} - invalid character '{}' in parameter name",
location, ch
)));
} else if current_param.ends_with('+') {
return Err(CompileError::InvalidPathTemplate(format!(
"{} - '+' is only allowed as the last character of a wildcard parameter name (e.g. {{key+}})",
location
)));
} else {
current_param.push(ch);
}
}
_ => {}
}
}
if brace_depth != 0 {
return Err(CompileError::InvalidPathTemplate(format!(
"{} - unclosed brace",
location
)));
}
Ok(())
}
fn normalize_path_template(path: &str) -> String {
let mut result = String::with_capacity(path.len());
let mut in_param = false;
let mut is_wildcard = false;
for ch in path.chars() {
match ch {
'{' => {
result.push('{');
result.push('_');
in_param = true;
is_wildcard = false;
}
'}' => {
if is_wildcard {
result.push('+');
}
result.push('}');
in_param = false;
is_wildcard = false;
}
'+' if in_param => {
is_wildcard = true;
}
_ if in_param => {
}
_ => {
result.push(ch);
}
}
}
result
}
fn validate_schema_complexity(
schema: &serde_json::Value,
max_depth: usize,
max_properties: usize,
location: &str,
) -> Result<(), CompileError> {
let (depth, props) = measure_schema_complexity(schema, 0);
if depth > max_depth {
return Err(CompileError::SchemaTooDeep(format!(
"{} - depth {} exceeds limit {}",
location, depth, max_depth
)));
}
if props > max_properties {
return Err(CompileError::SchemaTooComplex(format!(
"{} - {} properties exceed limit {}",
location, props, max_properties
)));
}
Ok(())
}
fn measure_schema_complexity(value: &serde_json::Value, current_depth: usize) -> (usize, usize) {
match value {
serde_json::Value::Object(obj) => {
let mut max_depth = current_depth;
let mut total_props = 0;
if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
total_props += props.len();
for prop_value in props.values() {
let (d, p) = measure_schema_complexity(prop_value, current_depth + 1);
max_depth = max_depth.max(d);
total_props += p;
}
}
if let Some(items) = obj.get("items") {
let (d, p) = measure_schema_complexity(items, current_depth + 1);
max_depth = max_depth.max(d);
total_props += p;
}
for key in ["allOf", "oneOf", "anyOf"] {
if let Some(serde_json::Value::Array(schemas)) = obj.get(key) {
for schema in schemas {
let (d, p) = measure_schema_complexity(schema, current_depth + 1);
max_depth = max_depth.max(d);
total_props += p;
}
}
}
if let Some(additional) = obj.get("additionalProperties") {
if additional.is_object() {
let (d, p) = measure_schema_complexity(additional, current_depth + 1);
max_depth = max_depth.max(d);
total_props += p;
}
}
(max_depth, total_props)
}
serde_json::Value::Array(arr) => {
let mut max_depth = current_depth;
let mut total_props = 0;
for item in arr {
let (d, p) = measure_schema_complexity(item, current_depth + 1);
max_depth = max_depth.max(d);
total_props += p;
}
(max_depth, total_props)
}
_ => (current_depth, 0),
}
}
mod hex {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
let bytes = bytes.as_ref();
let mut result = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
result.push(HEX_CHARS[(byte >> 4) as usize] as char);
result.push(HEX_CHARS[(byte & 0x0f) as usize] as char);
}
result
}
}
fn extract_root_mcp_config(specs: &[(ApiSpec, String, String)]) -> McpConfig {
for (spec, _, _) in specs {
if let Some(mcp_value) = spec.extensions.get("x-barbacane-mcp") {
let enabled = mcp_value
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let server_name = mcp_value
.get("server_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let server_version = mcp_value
.get("server_version")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
return McpConfig {
enabled,
server_name,
server_version,
};
}
}
McpConfig::default()
}
fn resolve_mcp_config(
root: &McpConfig,
op_extension: Option<&serde_json::Value>,
) -> (Option<bool>, Option<String>) {
if let Some(ext) = op_extension {
let enabled = ext.get("enabled").and_then(|v| v.as_bool());
let description = ext
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let resolved_enabled = enabled.or(if root.enabled { Some(true) } else { None });
(resolved_enabled, description)
} else if root.enabled {
(Some(true), None)
} else {
(None, None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn create_test_spec(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
let path = dir.join(name);
let mut file = File::create(&path).unwrap();
file.write_all(content.as_bytes()).unwrap();
path
}
#[test]
fn compile_minimal_spec() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
config:
status: 200
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
)
.unwrap();
assert_eq!(result.manifest.barbacane_artifact_version, ARTIFACT_VERSION);
assert_eq!(result.manifest.routes_count, 1);
assert_eq!(result.manifest.source_specs.len(), 1);
assert_eq!(result.manifest.source_specs[0].spec_type, "openapi");
assert!(output_path.exists());
}
#[test]
fn compile_detects_missing_dispatch() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/health:
get:
operationId: getHealth
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(result, Err(CompileError::MissingDispatch(_))));
}
#[test]
fn compile_detects_routing_conflict() {
let temp = TempDir::new().unwrap();
let spec1 = r#"
openapi: "3.1.0"
info:
title: API 1
version: "1.0.0"
paths:
/users:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec2 = r#"
openapi: "3.1.0"
info:
title: API 2
version: "1.0.0"
paths:
/users:
get:
x-barbacane-dispatch:
name: mock
"#;
let path1 = create_test_spec(temp.path(), "api1.yaml", spec1);
let path2 = create_test_spec(temp.path(), "api2.yaml", spec2);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[path1.as_path(), path2.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(result, Err(CompileError::RoutingConflict(_))));
}
#[test]
fn load_artifact_manifest() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
)
.unwrap();
let loaded = load_manifest(&output_path).unwrap();
assert_eq!(loaded.barbacane_artifact_version, ARTIFACT_VERSION);
assert_eq!(loaded.routes_count, 1);
}
#[test]
fn load_artifact_routes() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
config:
status: 200
/users/{id}:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
)
.unwrap();
let routes = load_routes(&output_path).unwrap();
assert_eq!(routes.operations.len(), 2);
}
#[test]
fn compile_rejects_plaintext_http_url() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/proxy:
get:
x-barbacane-dispatch:
name: http-upstream
config:
url: "http://backend.internal:8080/api"
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(result, Err(CompileError::PlaintextUpstream(_))));
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions {
allow_plaintext: true,
..Default::default()
},
);
assert!(result.is_ok());
}
#[test]
fn compile_allows_https_url() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/proxy:
get:
x-barbacane-dispatch:
name: http-upstream
config:
url: "https://backend.internal:8080/api"
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(result.is_ok());
}
#[test]
fn compile_with_bundled_plugins() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let fake_wasm = vec![
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, ];
let plugins = vec![PluginBundle {
name: "test-plugin".to_string(),
version: "1.0.0".to_string(),
plugin_type: "middleware".to_string(),
wasm_bytes: fake_wasm.clone(),
body_access: false,
}];
let result = compile(
&[spec_path.as_path()],
&plugins,
&output_path,
&CompileOptions::default(),
)
.unwrap();
assert_eq!(result.manifest.plugins.len(), 1);
assert_eq!(result.manifest.plugins[0].name, "test-plugin");
assert_eq!(result.manifest.plugins[0].version, "1.0.0");
assert_eq!(result.manifest.plugins[0].plugin_type, "middleware");
assert_eq!(
result.manifest.plugins[0].wasm_path,
"plugins/test-plugin.wasm"
);
let loaded = load_plugins(&output_path).unwrap();
assert_eq!(loaded.len(), 1);
let plugin = loaded.get("test-plugin").unwrap();
assert_eq!(plugin.version, "1.0.0");
assert_eq!(plugin.wasm_bytes, fake_wasm);
}
#[test]
fn compile_asyncapi_spec() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
asyncapi: "3.0.0"
info:
title: User Events API
version: "1.0.0"
channels:
userSignedUp:
address: user/signedup
messages:
UserSignedUpMessage:
contentType: application/json
payload:
type: object
properties:
userId:
type: string
bindings:
kafka:
topic: user-events
partitions: 10
operations:
processUserSignup:
action: receive
channel:
$ref: '#/channels/userSignedUp'
x-barbacane-dispatch:
name: kafka
config:
topic: user-events
bindings:
kafka:
groupId: user-processor
"#;
let spec_path = create_test_spec(temp.path(), "events.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
)
.unwrap();
assert_eq!(result.manifest.barbacane_artifact_version, ARTIFACT_VERSION);
assert_eq!(result.manifest.routes_count, 1);
assert_eq!(result.manifest.source_specs.len(), 1);
assert_eq!(result.manifest.source_specs[0].spec_type, "asyncapi");
let routes = load_routes(&output_path).unwrap();
assert_eq!(routes.operations.len(), 1);
let op = &routes.operations[0];
assert_eq!(op.path, "user/signedup");
assert_eq!(op.method, "RECEIVE");
assert_eq!(op.operation_id, Some("processUserSignup".to_string()));
assert_eq!(op.messages.len(), 1);
assert_eq!(op.messages[0].name, "UserSignedUpMessage");
assert_eq!(
op.messages[0].content_type,
Some("application/json".to_string())
);
assert!(op.bindings.contains_key("kafka"));
let kafka_binding = op.bindings.get("kafka").unwrap();
assert_eq!(
kafka_binding.get("groupId").and_then(|v| v.as_str()),
Some("user-processor")
);
}
#[test]
fn compile_asyncapi_send_operation() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
asyncapi: "3.0.0"
info:
title: Notification Service
version: "1.0.0"
channels:
notifications:
address: notifications/{userId}
parameters:
userId:
schema:
type: string
messages:
NotificationMessage:
contentType: application/json
payload:
type: object
required:
- title
properties:
title:
type: string
operations:
sendNotification:
action: send
channel:
$ref: '#/channels/notifications'
x-barbacane-dispatch:
name: nats
config:
subject: notifications
"#;
let spec_path = create_test_spec(temp.path(), "notifications.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
)
.unwrap();
assert_eq!(result.manifest.routes_count, 1);
let routes = load_routes(&output_path).unwrap();
let op = &routes.operations[0];
assert_eq!(op.path, "notifications/{userId}");
assert_eq!(op.method, "SEND");
assert_eq!(op.parameters.len(), 1);
assert_eq!(op.parameters[0].name, "userId");
assert!(op.request_body.is_some());
}
#[test]
fn compile_detects_invalid_path_template_unclosed_brace() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/users/{id:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(result, Err(CompileError::InvalidPathTemplate(_))));
}
#[test]
fn compile_detects_invalid_path_template_empty_param() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/users/{}:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(result, Err(CompileError::InvalidPathTemplate(_))));
}
#[test]
fn compile_detects_invalid_path_template_duplicate_param() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/users/{id}/posts/{id}:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(result, Err(CompileError::InvalidPathTemplate(_))));
}
#[test]
fn compile_detects_duplicate_operation_ids() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/users:
get:
operationId: getUsers
x-barbacane-dispatch:
name: mock
/customers:
get:
operationId: getUsers
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(
result,
Err(CompileError::DuplicateOperationId(_, _))
));
}
#[test]
fn compile_detects_missing_middleware_name() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/users:
get:
x-barbacane-dispatch:
name: mock
x-barbacane-middlewares:
- name: ""
config: {}
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(
result,
Err(CompileError::MissingMiddlewareName(_))
));
}
#[test]
fn compile_detects_missing_global_middleware_name() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
x-barbacane-middlewares:
- name: ""
config: {}
paths:
/users:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(
result,
Err(CompileError::MissingMiddlewareName(_))
));
}
#[test]
fn compile_detects_ambiguous_routes() {
let temp = TempDir::new().unwrap();
let spec1 = r#"
openapi: "3.1.0"
info:
title: API 1
version: "1.0.0"
paths:
/users/{id}:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec2 = r#"
openapi: "3.1.0"
info:
title: API 2
version: "1.0.0"
paths:
/users/{userId}:
get:
x-barbacane-dispatch:
name: mock
"#;
let path1 = create_test_spec(temp.path(), "api1.yaml", spec1);
let path2 = create_test_spec(temp.path(), "api2.yaml", spec2);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[path1.as_path(), path2.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(result, Err(CompileError::AmbiguousRoute(_))));
}
#[test]
fn compile_allows_same_structure_same_params() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/users/{id}:
get:
x-barbacane-dispatch:
name: mock
/posts/{id}:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(result.is_ok());
}
#[test]
fn compile_detects_schema_too_deep() {
let temp = TempDir::new().unwrap();
let mut nested = r#"{"type": "string"}"#.to_string();
for _ in 0..40 {
nested = format!(
r#"{{"type": "object", "properties": {{"nested": {}}}}}"#,
nested
);
}
let spec_content = format!(
r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/test:
post:
x-barbacane-dispatch:
name: mock
requestBody:
content:
application/json:
schema: {}
"#,
nested
);
let spec_path = create_test_spec(temp.path(), "test.yaml", &spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(result, Err(CompileError::SchemaTooDeep(_))));
}
#[test]
fn compile_detects_schema_too_complex() {
let temp = TempDir::new().unwrap();
let mut properties = String::new();
for i in 0..300 {
if i > 0 {
properties.push_str(", ");
}
properties.push_str(&format!(r#""prop{}": {{"type": "string"}}"#, i));
}
let spec_content = format!(
r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/test:
post:
x-barbacane-dispatch:
name: mock
requestBody:
content:
application/json:
schema:
type: object
properties:
{{{}}}
"#,
properties
);
let spec_path = create_test_spec(temp.path(), "test.yaml", &spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(matches!(result, Err(CompileError::SchemaTooComplex(_))));
}
#[test]
fn compile_allows_schema_within_limits() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/test:
post:
x-barbacane-dispatch:
name: mock
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string
age:
type: integer
address:
type: object
properties:
street:
type: string
city:
type: string
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
);
assert!(result.is_ok());
}
#[test]
fn normalize_path_template_works() {
assert_eq!(normalize_path_template("/users/{id}"), "/users/{_}");
assert_eq!(normalize_path_template("/users/{userId}"), "/users/{_}");
assert_eq!(
normalize_path_template("/users/{id}/posts/{postId}"),
"/users/{_}/posts/{_}"
);
assert_eq!(normalize_path_template("/static/path"), "/static/path");
assert_eq!(normalize_path_template("/files/{path+}"), "/files/{_+}");
assert_eq!(
normalize_path_template("/files/{bucket}/{key+}"),
"/files/{_}/{_+}"
);
}
#[test]
fn validate_path_template_valid_cases() {
assert!(validate_path_template("/users", "test").is_ok());
assert!(validate_path_template("/users/{id}", "test").is_ok());
assert!(validate_path_template("/users/{user_id}", "test").is_ok());
assert!(validate_path_template("/users/{id}/posts/{postId}", "test").is_ok());
assert!(validate_path_template("/files/{path+}", "test").is_ok());
assert!(validate_path_template("/files/{bucket}/{key+}", "test").is_ok());
assert!(validate_path_template("/api/{version}/files/{rest+}", "test").is_ok());
}
#[test]
fn validate_path_template_invalid_cases() {
assert!(validate_path_template("/users/{id", "test").is_err());
assert!(validate_path_template("/users/{}", "test").is_err());
assert!(validate_path_template("/users/{id}/posts/{id}", "test").is_err());
assert!(validate_path_template("/users/{{id}}", "test").is_err());
assert!(validate_path_template("/users/{id-name}", "test").is_err());
assert!(validate_path_template("/users/{id+}/orders", "test").is_err());
assert!(validate_path_template("/a/{x+}/{y+}", "test").is_err());
assert!(validate_path_template("/users/{na+me}", "test").is_err());
assert!(validate_path_template("/users/{+}", "test").is_err());
}
#[test]
fn compile_inherits_global_middlewares_when_none() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
x-barbacane-middlewares:
- name: rate-limit
config:
quota: 60
- name: cors
config:
allow_origin: "*"
paths:
/users:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
)
.unwrap();
let routes = load_routes(&output_path).unwrap();
assert_eq!(routes.operations.len(), 1);
let op = &routes.operations[0];
assert_eq!(op.middlewares.len(), 2);
assert_eq!(op.middlewares[0].name, "rate-limit");
assert_eq!(op.middlewares[1].name, "cors");
}
#[test]
fn compile_empty_middlewares_opts_out_of_globals() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
x-barbacane-middlewares:
- name: rate-limit
config:
quota: 60
paths:
/users:
get:
x-barbacane-dispatch:
name: mock
x-barbacane-middlewares: []
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
)
.unwrap();
let routes = load_routes(&output_path).unwrap();
let op = &routes.operations[0];
assert_eq!(op.middlewares.len(), 0);
}
#[test]
fn compile_merges_global_and_operation_middlewares() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
x-barbacane-middlewares:
- name: rate-limit
config:
quota: 60
- name: cors
config:
allow_origin: "*"
paths:
/users:
get:
x-barbacane-dispatch:
name: mock
x-barbacane-middlewares:
- name: auth
config:
type: bearer
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
)
.unwrap();
let routes = load_routes(&output_path).unwrap();
let op = &routes.operations[0];
assert_eq!(op.middlewares.len(), 3);
assert_eq!(op.middlewares[0].name, "rate-limit");
assert_eq!(op.middlewares[1].name, "cors");
assert_eq!(op.middlewares[2].name, "auth");
}
#[test]
fn compile_operation_middleware_overrides_global_by_name() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
x-barbacane-middlewares:
- name: rate-limit
config:
quota: 60
- name: cors
config:
allow_origin: "*"
paths:
/users:
get:
x-barbacane-dispatch:
name: mock
x-barbacane-middlewares:
- name: rate-limit
config:
quota: 1000
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
)
.unwrap();
let routes = load_routes(&output_path).unwrap();
let op = &routes.operations[0];
assert_eq!(op.middlewares.len(), 2);
assert_eq!(op.middlewares[0].name, "cors");
assert_eq!(op.middlewares[1].name, "rate-limit");
assert_eq!(op.middlewares[1].config.get("quota").unwrap(), 1000);
}
#[test]
fn compile_inherits_global_middlewares() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
x-barbacane-middlewares:
- name: rate-limit
config:
quota: 60
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let plugins = vec![];
let result = compile(
&[spec_path.as_path()],
&plugins,
&output_path,
&CompileOptions::default(),
)
.unwrap();
let routes = load_routes(&output_path).unwrap();
let op = &routes.operations[0];
assert_eq!(op.middlewares.len(), 1);
assert_eq!(op.middlewares[0].name, "rate-limit");
assert_eq!(result.manifest.plugins.len(), 0);
}
#[test]
fn artifact_hash_is_deterministic() {
let source_specs = vec![
SourceSpec {
file: "api.yaml".to_string(),
sha256: "aaa".to_string(),
spec_type: "openapi".to_string(),
version: "3.1.0".to_string(),
},
SourceSpec {
file: "events.yaml".to_string(),
sha256: "bbb".to_string(),
spec_type: "asyncapi".to_string(),
version: "3.0.0".to_string(),
},
];
let mut checksums = BTreeMap::new();
checksums.insert("routes.json".to_string(), "ccc".to_string());
checksums.insert("plugins/mock.wasm".to_string(), "ddd".to_string());
let hash1 = compute_artifact_hash(&source_specs, &checksums);
let hash2 = compute_artifact_hash(&source_specs, &checksums);
assert_eq!(hash1, hash2, "Same inputs must produce same hash");
assert!(
hash1.starts_with("sha256:"),
"Hash must have sha256: prefix"
);
}
#[test]
fn artifact_hash_differs_with_different_specs() {
let specs_a = vec![SourceSpec {
file: "api.yaml".to_string(),
sha256: "aaa".to_string(),
spec_type: "openapi".to_string(),
version: "3.1.0".to_string(),
}];
let specs_b = vec![SourceSpec {
file: "api.yaml".to_string(),
sha256: "bbb".to_string(),
spec_type: "openapi".to_string(),
version: "3.1.0".to_string(),
}];
let checksums = BTreeMap::new();
let hash_a = compute_artifact_hash(&specs_a, &checksums);
let hash_b = compute_artifact_hash(&specs_b, &checksums);
assert_ne!(
hash_a, hash_b,
"Different spec hashes must produce different artifact hashes"
);
}
#[test]
fn artifact_hash_differs_with_different_checksums() {
let specs = vec![SourceSpec {
file: "api.yaml".to_string(),
sha256: "aaa".to_string(),
spec_type: "openapi".to_string(),
version: "3.1.0".to_string(),
}];
let mut checksums_a = BTreeMap::new();
checksums_a.insert("routes.json".to_string(), "v1".to_string());
let mut checksums_b = BTreeMap::new();
checksums_b.insert("routes.json".to_string(), "v2".to_string());
let hash_a = compute_artifact_hash(&specs, &checksums_a);
let hash_b = compute_artifact_hash(&specs, &checksums_b);
assert_ne!(
hash_a, hash_b,
"Different route checksums must produce different artifact hashes"
);
}
#[test]
fn provenance_serialization_round_trip() {
let prov = Provenance {
commit: Some("abc123".to_string()),
source: Some("ci/github-actions".to_string()),
};
let json = serde_json::to_string(&prov).unwrap();
let deserialized: Provenance = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.commit, Some("abc123".to_string()));
assert_eq!(deserialized.source, Some("ci/github-actions".to_string()));
}
#[test]
fn provenance_defaults_to_none() {
let prov = Provenance::default();
assert!(prov.commit.is_none());
assert!(prov.source.is_none());
let json = serde_json::to_string(&prov).unwrap();
let deserialized: Provenance = serde_json::from_str(&json).unwrap();
assert!(deserialized.commit.is_none());
assert!(deserialized.source.is_none());
}
#[test]
fn compile_produces_artifact_hash_and_provenance() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions {
provenance_commit: Some("deadbeef".to_string()),
provenance_source: Some("test".to_string()),
..Default::default()
},
)
.unwrap();
assert!(result.manifest.artifact_hash.starts_with("sha256:"));
assert!(result.manifest.artifact_hash.len() > 10);
assert_eq!(
result.manifest.provenance.commit,
Some("deadbeef".to_string())
);
assert_eq!(result.manifest.provenance.source, Some("test".to_string()));
let loaded = load_manifest(&output_path).unwrap();
assert_eq!(loaded.artifact_hash, result.manifest.artifact_hash);
assert_eq!(loaded.provenance.commit, Some("deadbeef".to_string()));
assert_eq!(loaded.provenance.source, Some("test".to_string()));
}
#[test]
fn compile_without_provenance_has_none_fields() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let output_path = temp.path().join("artifact.bca");
let result = compile(
&[spec_path.as_path()],
&[],
&output_path,
&CompileOptions::default(),
)
.unwrap();
assert!(result.manifest.artifact_hash.starts_with("sha256:"));
assert!(result.manifest.provenance.commit.is_none());
assert!(result.manifest.provenance.source.is_none());
}
#[test]
fn same_spec_produces_same_artifact_hash() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
config:
status: 200
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let out1 = temp.path().join("artifact1.bca");
let out2 = temp.path().join("artifact2.bca");
let r1 = compile(
&[spec_path.as_path()],
&[],
&out1,
&CompileOptions::default(),
)
.unwrap();
let r2 = compile(
&[spec_path.as_path()],
&[],
&out2,
&CompileOptions::default(),
)
.unwrap();
assert_eq!(
r1.manifest.artifact_hash, r2.manifest.artifact_hash,
"Compiling the same spec twice must produce the same artifact hash"
);
}
#[test]
fn different_specs_produce_different_artifact_hashes() {
let temp = TempDir::new().unwrap();
let spec_a = r#"
openapi: "3.1.0"
info:
title: API A
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
config:
status: 200
"#;
let spec_b = r#"
openapi: "3.1.0"
info:
title: API B
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
config:
status: 200
/users:
get:
x-barbacane-dispatch:
name: mock
config:
status: 200
"#;
let path_a = create_test_spec(temp.path(), "a.yaml", spec_a);
let path_b = create_test_spec(temp.path(), "b.yaml", spec_b);
let out_a = temp.path().join("a.bca");
let out_b = temp.path().join("b.bca");
let ra = compile(&[path_a.as_path()], &[], &out_a, &CompileOptions::default()).unwrap();
let rb = compile(&[path_b.as_path()], &[], &out_b, &CompileOptions::default()).unwrap();
assert_ne!(
ra.manifest.artifact_hash, rb.manifest.artifact_hash,
"Different specs must produce different artifact hashes"
);
}
#[test]
fn provenance_does_not_affect_artifact_hash() {
let temp = TempDir::new().unwrap();
let spec_content = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
let out1 = temp.path().join("a.bca");
let out2 = temp.path().join("b.bca");
let r1 = compile(
&[spec_path.as_path()],
&[],
&out1,
&CompileOptions {
provenance_commit: Some("commit-a".to_string()),
provenance_source: Some("source-a".to_string()),
..Default::default()
},
)
.unwrap();
let r2 = compile(
&[spec_path.as_path()],
&[],
&out2,
&CompileOptions {
provenance_commit: Some("commit-b".to_string()),
provenance_source: Some("source-b".to_string()),
..Default::default()
},
)
.unwrap();
assert_eq!(
r1.manifest.artifact_hash, r2.manifest.artifact_hash,
"Provenance metadata must not affect artifact hash"
);
}
#[test]
fn extract_root_mcp_config_enabled() {
let spec = ApiSpec {
filename: None,
format: SpecFormat::OpenApi,
version: "3.1.0".to_string(),
title: "My API".to_string(),
api_version: "2.0.0".to_string(),
operations: vec![],
global_middlewares: vec![],
extensions: BTreeMap::from([(
"x-barbacane-mcp".to_string(),
serde_json::json!({
"enabled": true,
"server_name": "Custom Name"
}),
)]),
};
let specs = vec![(spec, String::new(), String::new())];
let cfg = extract_root_mcp_config(&specs);
assert!(cfg.enabled);
assert_eq!(cfg.server_name.as_deref(), Some("Custom Name"));
assert!(cfg.server_version.is_none());
}
#[test]
fn extract_root_mcp_config_disabled_by_default() {
let spec = ApiSpec {
filename: None,
format: SpecFormat::OpenApi,
version: "3.1.0".to_string(),
title: "Test".to_string(),
api_version: "1.0.0".to_string(),
operations: vec![],
global_middlewares: vec![],
extensions: BTreeMap::new(),
};
let specs = vec![(spec, String::new(), String::new())];
let cfg = extract_root_mcp_config(&specs);
assert!(!cfg.enabled);
}
#[test]
fn resolve_mcp_config_inherits_from_root() {
let root = McpConfig {
enabled: true,
server_name: None,
server_version: None,
};
let (enabled, desc) = resolve_mcp_config(&root, None);
assert_eq!(enabled, Some(true));
assert!(desc.is_none());
}
#[test]
fn resolve_mcp_config_operation_overrides_root() {
let root = McpConfig {
enabled: true,
server_name: None,
server_version: None,
};
let ext = serde_json::json!({"enabled": false});
let (enabled, _) = resolve_mcp_config(&root, Some(&ext));
assert_eq!(enabled, Some(false));
}
#[test]
fn resolve_mcp_config_operation_description_override() {
let root = McpConfig {
enabled: true,
server_name: None,
server_version: None,
};
let ext = serde_json::json!({"description": "Custom tool description"});
let (enabled, desc) = resolve_mcp_config(&root, Some(&ext));
assert_eq!(enabled, Some(true));
assert_eq!(desc.as_deref(), Some("Custom tool description"));
}
#[test]
fn resolve_mcp_config_root_disabled_no_inheritance() {
let root = McpConfig {
enabled: false,
server_name: None,
server_version: None,
};
let (enabled, _) = resolve_mcp_config(&root, None);
assert!(enabled.is_none());
}
}