use std::collections::{BTreeMap, HashSet};
use serde_json::Value;
use super::error::ParseError;
use super::model::{
ApiSpec, ContentSchema, DispatchConfig, Message, MiddlewareConfig, Operation, Parameter,
RequestBody, ResponseContent, SpecFormat,
};
fn resolve_ref<'a>(root: &'a Value, ref_path: &str) -> Option<&'a Value> {
if !ref_path.starts_with("#/") {
return None;
}
let mut current = root;
for segment in ref_path[2..].split('/') {
let unescaped = segment.replace("~1", "/").replace("~0", "~");
current = current.get(&unescaped)?;
}
Some(current)
}
fn resolve_schema_refs(
value: &Value,
root: &Value,
visited: &mut HashSet<String>,
) -> Result<Value, ParseError> {
match value {
Value::Object(obj) => {
if let Some(ref_str) = obj.get("$ref").and_then(|v| v.as_str()) {
if !visited.insert(ref_str.to_string()) {
return Err(ParseError::SchemaError(format!(
"circular $ref detected: {}",
ref_str
)));
}
let target = resolve_ref(root, ref_str)
.ok_or_else(|| ParseError::UnresolvedRef(ref_str.to_string()))?;
let resolved = resolve_schema_refs(target, root, visited)?;
visited.remove(ref_str);
Ok(resolved)
} else {
let mut new_obj = serde_json::Map::with_capacity(obj.len());
for (key, val) in obj {
new_obj.insert(key.clone(), resolve_schema_refs(val, root, visited)?);
}
Ok(Value::Object(new_obj))
}
}
Value::Array(arr) => {
let items: Result<Vec<_>, _> = arr
.iter()
.map(|v| resolve_schema_refs(v, root, visited))
.collect();
Ok(Value::Array(items?))
}
other => Ok(other.clone()),
}
}
const HTTP_METHODS: &[&str] = &[
"get", "post", "put", "delete", "patch", "head", "options", "trace", "query",
];
pub fn parse_spec(input: &str) -> Result<ApiSpec, ParseError> {
let root: Value =
serde_yaml::from_str(input).map_err(|e| ParseError::ParseError(e.to_string()))?;
let root_obj = root
.as_object()
.ok_or_else(|| ParseError::ParseError("spec root must be an object".into()))?;
let (format, version) = detect_format(root_obj)?;
let info = root_obj
.get("info")
.and_then(|v| v.as_object())
.ok_or_else(|| ParseError::SchemaError("missing 'info' object".into()))?;
let title = info
.get("title")
.and_then(|v| v.as_str())
.ok_or_else(|| ParseError::SchemaError("missing 'info.title'".into()))?
.to_string();
let api_version = info
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("0.0.0")
.to_string();
let extensions = extract_extensions(root_obj);
let global_middlewares = extract_middlewares(root_obj);
let operations = match format {
SpecFormat::OpenApi => parse_openapi_paths(root_obj, &root)?,
SpecFormat::AsyncApi => parse_asyncapi_channels(root_obj, &root)?,
};
Ok(ApiSpec {
filename: None,
format,
version,
title,
api_version,
operations,
global_middlewares,
extensions,
})
}
pub fn parse_spec_file(path: &std::path::Path) -> Result<ApiSpec, ParseError> {
let content = std::fs::read_to_string(path)?;
let mut spec = parse_spec(&content)?;
spec.filename = path
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string());
Ok(spec)
}
fn detect_format(
root: &serde_json::Map<String, Value>,
) -> Result<(SpecFormat, String), ParseError> {
if let Some(version) = root.get("openapi").and_then(|v| v.as_str()) {
if !version.starts_with("3.") {
return Err(ParseError::SchemaError(format!(
"unsupported OpenAPI version: {} (only 3.x supported)",
version
)));
}
Ok((SpecFormat::OpenApi, version.to_string()))
} else if let Some(version) = root.get("asyncapi").and_then(|v| v.as_str()) {
if !version.starts_with("3.") {
return Err(ParseError::SchemaError(format!(
"unsupported AsyncAPI version: {} (only 3.x supported)",
version
)));
}
Ok((SpecFormat::AsyncApi, version.to_string()))
} else {
Err(ParseError::UnknownFormat)
}
}
fn extract_extensions(obj: &serde_json::Map<String, Value>) -> BTreeMap<String, Value> {
obj.iter()
.filter(|(k, _)| k.starts_with("x-barbacane-"))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
fn extract_middlewares(obj: &serde_json::Map<String, Value>) -> Vec<MiddlewareConfig> {
obj.get("x-barbacane-middlewares")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| serde_json::from_value(item.clone()).ok())
.collect()
})
.unwrap_or_default()
}
fn extract_dispatch(obj: &serde_json::Map<String, Value>) -> Option<DispatchConfig> {
obj.get("x-barbacane-dispatch")
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
fn parse_openapi_paths(
root: &serde_json::Map<String, Value>,
spec_root: &Value,
) -> Result<Vec<Operation>, ParseError> {
let mut operations = Vec::new();
let paths = match root.get("paths").and_then(|v| v.as_object()) {
Some(p) => p,
None => return Ok(operations), };
for (path, path_item) in paths {
let path_obj = path_item.as_object().ok_or_else(|| {
ParseError::SchemaError(format!("path item for '{}' must be an object", path))
})?;
let path_params = parse_parameters(path_obj, spec_root)?;
for method in HTTP_METHODS {
if let Some(op_value) = path_obj.get(*method) {
let op_obj = op_value.as_object().ok_or_else(|| {
ParseError::SchemaError(format!(
"operation {} {} must be an object",
method.to_uppercase(),
path
))
})?;
let mut params = path_params.clone();
params.extend(parse_parameters(op_obj, spec_root)?);
let operation_id = op_obj
.get("operationId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let summary = op_obj
.get("summary")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = op_obj
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let request_body = parse_request_body(op_obj, spec_root)?;
let responses = parse_responses(op_obj, spec_root)?;
let dispatch = extract_dispatch(op_obj);
let middlewares = if op_obj.contains_key("x-barbacane-middlewares") {
Some(extract_middlewares(op_obj))
} else {
None
};
let deprecated = op_obj
.get("deprecated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let sunset = op_obj
.get("x-sunset")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let extensions = extract_extensions(op_obj);
operations.push(Operation {
path: path.clone(),
method: method.to_uppercase(),
operation_id,
summary,
description,
parameters: params,
request_body,
dispatch,
middlewares,
deprecated,
sunset,
extensions,
messages: Vec::new(), bindings: BTreeMap::new(), responses,
});
}
}
if let Some(additional) = path_obj
.get("additionalOperations")
.and_then(|v| v.as_object())
{
for (method_name, op_value) in additional {
let op_obj = op_value.as_object().ok_or_else(|| {
ParseError::SchemaError(format!(
"additionalOperations.{} on {} must be an object",
method_name, path
))
})?;
let mut params = path_params.clone();
params.extend(parse_parameters(op_obj, spec_root)?);
let operation_id = op_obj
.get("operationId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let summary = op_obj
.get("summary")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = op_obj
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let request_body = parse_request_body(op_obj, spec_root)?;
let responses = parse_responses(op_obj, spec_root)?;
let dispatch = extract_dispatch(op_obj);
let middlewares = if op_obj.contains_key("x-barbacane-middlewares") {
Some(extract_middlewares(op_obj))
} else {
None
};
let deprecated = op_obj
.get("deprecated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let sunset = op_obj
.get("x-sunset")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let extensions = extract_extensions(op_obj);
operations.push(Operation {
path: path.clone(),
method: method_name.to_uppercase(),
operation_id,
summary,
description,
parameters: params,
request_body,
dispatch,
middlewares,
deprecated,
sunset,
extensions,
messages: Vec::new(),
bindings: BTreeMap::new(),
responses,
});
}
}
}
Ok(operations)
}
fn parse_parameters(
obj: &serde_json::Map<String, Value>,
spec_root: &Value,
) -> Result<Vec<Parameter>, ParseError> {
let Some(arr) = obj.get("parameters").and_then(|v| v.as_array()) else {
return Ok(Vec::new());
};
let mut params = Vec::with_capacity(arr.len());
for item in arr {
let Some(param_obj) = item.as_object() else {
continue;
};
let Some(location) = param_obj.get("in").and_then(|v| v.as_str()) else {
continue;
};
let location = location.to_string();
let raw_schema = if location == "querystring" {
extract_content_schema(param_obj)
} else {
param_obj.get("schema").cloned()
};
let schema = raw_schema
.map(|s| resolve_schema_refs(&s, spec_root, &mut HashSet::new()))
.transpose()?;
let Some(name) = param_obj.get("name").and_then(|v| v.as_str()) else {
continue;
};
params.push(Parameter {
name: name.to_string(),
location,
required: param_obj
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(false),
schema,
});
}
Ok(params)
}
fn extract_content_schema(param_obj: &serde_json::Map<String, Value>) -> Option<Value> {
let content = param_obj.get("content")?.as_object()?;
let (_media_type, media_obj) = content.iter().next()?;
media_obj.as_object()?.get("schema").cloned()
}
fn parse_request_body(
obj: &serde_json::Map<String, Value>,
spec_root: &Value,
) -> Result<Option<RequestBody>, ParseError> {
let Some(body) = obj.get("requestBody").and_then(|v| v.as_object()) else {
return Ok(None);
};
let required = body
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let Some(content_obj) = body.get("content").and_then(|v| v.as_object()) else {
return Ok(None);
};
let mut content = BTreeMap::new();
for (media_type, media_obj) in content_obj {
let raw_schema = media_obj.as_object().and_then(|o| o.get("schema").cloned());
let schema = raw_schema
.map(|s| resolve_schema_refs(&s, spec_root, &mut HashSet::new()))
.transpose()?;
content.insert(media_type.clone(), ContentSchema { schema });
}
Ok(Some(RequestBody { required, content }))
}
fn parse_responses(
obj: &serde_json::Map<String, Value>,
spec_root: &Value,
) -> Result<BTreeMap<String, ResponseContent>, ParseError> {
let Some(responses) = obj.get("responses").and_then(|v| v.as_object()) else {
return Ok(BTreeMap::new());
};
let mut result = BTreeMap::new();
for (status_code, resp_value) in responses {
let resolved = resolve_schema_refs(resp_value, spec_root, &mut HashSet::new())?;
let Some(resp_obj) = resolved.as_object() else {
continue;
};
let Some(content_obj) = resp_obj.get("content").and_then(|v| v.as_object()) else {
continue;
};
let mut content = BTreeMap::new();
for (media_type, media_obj) in content_obj {
let raw_schema = media_obj.as_object().and_then(|o| o.get("schema").cloned());
let schema = raw_schema
.map(|s| resolve_schema_refs(&s, spec_root, &mut HashSet::new()))
.transpose()?;
content.insert(media_type.clone(), ContentSchema { schema });
}
if !content.is_empty() {
result.insert(status_code.clone(), ResponseContent { content });
}
}
Ok(result)
}
fn parse_asyncapi_channels(
root: &serde_json::Map<String, Value>,
spec_root: &Value,
) -> Result<Vec<Operation>, ParseError> {
let mut operations = Vec::new();
let channels = root.get("channels").and_then(|v| v.as_object());
let ops = root.get("operations").and_then(|v| v.as_object());
let ops = match ops {
Some(o) => o,
None => return Ok(operations),
};
let channel_lookup = build_channel_lookup(channels, spec_root)?;
for (op_id, op_value) in ops {
let op_obj = op_value.as_object().ok_or_else(|| {
ParseError::SchemaError(format!("operation '{}' must be an object", op_id))
})?;
let action = op_obj
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ParseError::SchemaError(format!("operation '{}' missing 'action' field", op_id))
})?;
let method = match action {
"send" => "SEND",
"receive" => "RECEIVE",
other => {
return Err(ParseError::SchemaError(format!(
"operation '{}' has invalid action '{}' (must be 'send' or 'receive')",
op_id, other
)))
}
}
.to_string();
let (address, channel_messages, channel_params, channel_bindings) =
resolve_channel_ref(op_obj, &channel_lookup, spec_root)?;
let messages = parse_operation_messages(op_obj, &channel_messages, spec_root)?;
let request_body = if method == "SEND" && !messages.is_empty() {
messages.first().and_then(|msg| {
msg.payload.as_ref().map(|schema| {
let content_type = msg
.content_type
.clone()
.unwrap_or_else(|| "application/json".to_string());
let mut content = BTreeMap::new();
content.insert(
content_type,
ContentSchema {
schema: Some(schema.clone()),
},
);
RequestBody {
required: true,
content,
}
})
})
} else {
None
};
let mut bindings = channel_bindings;
if let Some(op_bindings) = op_obj.get("bindings").and_then(|v| v.as_object()) {
for (protocol, config) in op_bindings {
bindings.insert(protocol.clone(), config.clone());
}
}
let dispatch = extract_dispatch(op_obj);
let middlewares = if op_obj.contains_key("x-barbacane-middlewares") {
Some(extract_middlewares(op_obj))
} else {
None
};
let deprecated = op_obj
.get("deprecated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let sunset = op_obj
.get("x-sunset")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let extensions = extract_extensions(op_obj);
operations.push(Operation {
path: address,
method,
operation_id: Some(op_id.clone()),
summary: None,
description: None,
parameters: channel_params,
request_body,
dispatch,
middlewares,
deprecated,
sunset,
extensions,
messages,
bindings,
responses: BTreeMap::new(),
});
}
Ok(operations)
}
type ChannelInfo = (
String,
Vec<Message>,
Vec<Parameter>,
BTreeMap<String, Value>,
);
fn build_channel_lookup(
channels: Option<&serde_json::Map<String, Value>>,
spec_root: &Value,
) -> Result<BTreeMap<String, ChannelInfo>, ParseError> {
let mut lookup = BTreeMap::new();
let channels = match channels {
Some(c) => c,
None => return Ok(lookup),
};
for (name, channel_value) in channels {
let channel_obj = match channel_value.as_object() {
Some(o) => o,
None => continue,
};
let address = channel_obj
.get("address")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| name.clone());
let messages = parse_channel_messages(channel_obj, spec_root)?;
let parameters = parse_channel_parameters(channel_obj, spec_root)?;
let bindings = channel_obj
.get("bindings")
.and_then(|v| v.as_object())
.map(|b| {
b.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
lookup.insert(name.clone(), (address, messages, parameters, bindings));
}
Ok(lookup)
}
fn parse_channel_messages(
channel: &serde_json::Map<String, Value>,
spec_root: &Value,
) -> Result<Vec<Message>, ParseError> {
let messages_obj = match channel.get("messages").and_then(|v| v.as_object()) {
Some(m) => m,
None => return Ok(Vec::new()),
};
let mut messages = Vec::with_capacity(messages_obj.len());
for (name, msg_value) in messages_obj {
let Some(msg_obj) = msg_value.as_object() else {
continue;
};
let payload = msg_obj
.get("payload")
.map(|p| resolve_schema_refs(p, spec_root, &mut HashSet::new()))
.transpose()?;
let content_type = msg_obj
.get("contentType")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let bindings = msg_obj
.get("bindings")
.and_then(|v| v.as_object())
.map(|b| {
b.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
messages.push(Message {
name: name.clone(),
payload,
content_type,
bindings,
});
}
Ok(messages)
}
fn parse_channel_parameters(
channel: &serde_json::Map<String, Value>,
spec_root: &Value,
) -> Result<Vec<Parameter>, ParseError> {
let params = match channel.get("parameters").and_then(|v| v.as_object()) {
Some(p) => p,
None => return Ok(Vec::new()),
};
let mut result = Vec::with_capacity(params.len());
for (name, param_value) in params {
let raw_schema = param_value
.as_object()
.and_then(|o| o.get("schema").cloned());
let schema = raw_schema
.map(|s| resolve_schema_refs(&s, spec_root, &mut HashSet::new()))
.transpose()?;
result.push(Parameter {
name: name.clone(),
location: "path".to_string(),
required: true,
schema,
});
}
Ok(result)
}
fn resolve_channel_ref(
op: &serde_json::Map<String, Value>,
lookup: &BTreeMap<String, ChannelInfo>,
spec_root: &Value,
) -> Result<ChannelInfo, ParseError> {
let channel = op
.get("channel")
.ok_or_else(|| ParseError::SchemaError("operation missing 'channel' field".into()))?;
if let Some(channel_obj) = channel.as_object() {
if let Some(ref_str) = channel_obj.get("$ref").and_then(|v| v.as_str()) {
let channel_name = ref_str.strip_prefix("#/channels/").ok_or_else(|| {
ParseError::SchemaError(format!(
"invalid channel $ref '{}' (expected #/channels/...)",
ref_str
))
})?;
lookup.get(channel_name).cloned().ok_or_else(|| {
ParseError::SchemaError(format!(
"channel '{}' referenced but not defined",
channel_name
))
})
} else {
let address = channel_obj
.get("address")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let messages = parse_channel_messages(channel_obj, spec_root)?;
let parameters = parse_channel_parameters(channel_obj, spec_root)?;
let bindings = channel_obj
.get("bindings")
.and_then(|v| v.as_object())
.map(|b| {
b.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
Ok((address, messages, parameters, bindings))
}
} else {
Err(ParseError::SchemaError(
"operation 'channel' must be an object (either $ref or inline)".into(),
))
}
}
fn parse_operation_messages(
op: &serde_json::Map<String, Value>,
channel_messages: &[Message],
spec_root: &Value,
) -> Result<Vec<Message>, ParseError> {
let Some(msgs) = op.get("messages").and_then(|v| v.as_array()) else {
return Ok(channel_messages.to_vec());
};
let mut result = Vec::with_capacity(msgs.len());
for msg in msgs {
let Some(obj) = msg.as_object() else {
continue;
};
if let Some(ref_str) = obj.get("$ref").and_then(|v| v.as_str()) {
let parts: Vec<&str> = ref_str.split('/').collect();
if parts.len() >= 5 && parts[3] == "messages" {
let msg_name = parts[4];
if let Some(m) = channel_messages.iter().find(|m| m.name == msg_name) {
result.push(m.clone());
}
}
continue;
}
let name = obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string();
let payload = obj
.get("payload")
.map(|p| resolve_schema_refs(p, spec_root, &mut HashSet::new()))
.transpose()?;
let content_type = obj
.get("contentType")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let bindings = obj
.get("bindings")
.and_then(|v| v.as_object())
.map(|b| {
b.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
result.push(Message {
name,
payload,
content_type,
bindings,
});
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_openapi() {
let yaml = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/health:
get:
operationId: getHealth
x-barbacane-dispatch:
name: mock
config:
status: 200
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.format, SpecFormat::OpenApi);
assert_eq!(spec.version, "3.1.0");
assert_eq!(spec.title, "Test API");
assert_eq!(spec.operations.len(), 1);
let op = &spec.operations[0];
assert_eq!(op.path, "/health");
assert_eq!(op.method, "GET");
assert_eq!(op.operation_id, Some("getHealth".to_string()));
let dispatch = op.dispatch.as_ref().unwrap();
assert_eq!(dispatch.name, "mock");
}
#[test]
fn parse_path_with_parameters() {
let yaml = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema:
type: integer
x-barbacane-dispatch:
name: mock
config:
status: 200
"#;
let spec = parse_spec(yaml).unwrap();
let op = &spec.operations[0];
assert_eq!(op.parameters.len(), 1);
let param = &op.parameters[0];
assert_eq!(param.name, "id");
assert_eq!(param.location, "path");
assert!(param.required);
}
#[test]
fn parse_global_middlewares() {
let yaml = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
x-barbacane-middlewares:
- name: rate-limit
config:
quota: 100
window: 60
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.global_middlewares.len(), 1);
assert_eq!(spec.global_middlewares[0].name, "rate-limit");
}
#[test]
fn parse_operation_middlewares_override() {
let yaml = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
x-barbacane-middlewares:
- name: global-auth
paths:
/public:
get:
x-barbacane-middlewares: []
x-barbacane-dispatch:
name: mock
"#;
let spec = parse_spec(yaml).unwrap();
let op = &spec.operations[0];
assert!(op.middlewares.is_some());
assert_eq!(op.middlewares.as_ref().unwrap().len(), 0);
}
#[test]
fn reject_openapi_2() {
let yaml = r#"
swagger: "2.0"
info:
title: Old API
version: "1.0.0"
paths: {}
"#;
let result = parse_spec(yaml);
assert!(matches!(result, Err(ParseError::UnknownFormat)));
}
#[test]
fn parse_multiple_methods() {
let yaml = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/users:
get:
x-barbacane-dispatch:
name: mock
post:
x-barbacane-dispatch:
name: mock
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.operations.len(), 2);
let methods: Vec<&str> = spec
.operations
.iter()
.map(|op| op.method.as_str())
.collect();
assert!(methods.contains(&"GET"));
assert!(methods.contains(&"POST"));
}
#[test]
fn extract_barbacane_extensions() {
let yaml = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
x-barbacane-middlewares:
- name: rate-limit
config:
requests_per_second: 100
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
x-barbacane-middlewares:
- name: cache
config:
ttl: 60
"#;
let spec = parse_spec(yaml).unwrap();
assert!(spec.extensions.contains_key("x-barbacane-middlewares"));
let op = &spec.operations[0];
assert!(op.extensions.contains_key("x-barbacane-middlewares"));
}
#[test]
fn parse_request_body() {
let yaml = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/users:
post:
operationId: createUser
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
properties:
name:
type: string
email:
type: string
format: email
x-barbacane-dispatch:
name: mock
"#;
let spec = parse_spec(yaml).unwrap();
let op = &spec.operations[0];
let body = op.request_body.as_ref().expect("should have request body");
assert!(body.required);
assert!(body.content.contains_key("application/json"));
let json_content = &body.content["application/json"];
let schema = json_content.schema.as_ref().expect("should have schema");
assert_eq!(schema.get("type").and_then(|v| v.as_str()), Some("object"));
}
#[test]
fn parse_deprecated_operation() {
let yaml = r#"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/old-endpoint:
get:
deprecated: true
x-sunset: "Sat, 31 Dec 2025 23:59:59 GMT"
x-barbacane-dispatch:
name: mock
/new-endpoint:
get:
x-barbacane-dispatch:
name: mock
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.operations.len(), 2);
let old_op = spec
.operations
.iter()
.find(|op| op.path == "/old-endpoint")
.unwrap();
assert!(old_op.deprecated);
assert_eq!(
old_op.sunset,
Some("Sat, 31 Dec 2025 23:59:59 GMT".to_string())
);
let new_op = spec
.operations
.iter()
.find(|op| op.path == "/new-endpoint")
.unwrap();
assert!(!new_op.deprecated);
assert!(new_op.sunset.is_none());
}
#[test]
fn parse_minimal_asyncapi() {
let yaml = r#"
asyncapi: "3.0.0"
info:
title: User Events API
version: "1.0.0"
channels:
userSignedUp:
address: user/signedup
messages:
UserSignedUpMessage:
payload:
type: object
properties:
userId:
type: string
operations:
processUserSignup:
action: receive
channel:
$ref: '#/channels/userSignedUp'
x-barbacane-dispatch:
name: kafka
config:
topic: user-events
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.format, SpecFormat::AsyncApi);
assert_eq!(spec.version, "3.0.0");
assert_eq!(spec.title, "User Events API");
assert_eq!(spec.operations.len(), 1);
let op = &spec.operations[0];
assert_eq!(op.path, "user/signedup");
assert_eq!(op.method, "RECEIVE");
assert_eq!(op.operation_id, Some("processUserSignup".to_string()));
let dispatch = op.dispatch.as_ref().unwrap();
assert_eq!(dispatch.name, "kafka");
assert_eq!(op.messages.len(), 1);
assert_eq!(op.messages[0].name, "UserSignedUpMessage");
assert!(op.messages[0].payload.is_some());
}
#[test]
fn parse_asyncapi_send_operation() {
let yaml = 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
- body
properties:
title:
type: string
body:
type: string
operations:
sendNotification:
action: send
channel:
$ref: '#/channels/notifications'
x-barbacane-dispatch:
name: nats
config:
subject: notifications
"#;
let spec = parse_spec(yaml).unwrap();
let op = &spec.operations[0];
assert_eq!(op.method, "SEND");
assert_eq!(op.path, "notifications/{userId}");
assert_eq!(op.operation_id, Some("sendNotification".to_string()));
assert_eq!(op.parameters.len(), 1);
assert_eq!(op.parameters[0].name, "userId");
assert_eq!(op.parameters[0].location, "path");
assert!(op.parameters[0].required);
assert!(op.request_body.is_some());
let body = op.request_body.as_ref().unwrap();
assert!(body.required);
assert!(body.content.contains_key("application/json"));
assert_eq!(op.messages.len(), 1);
assert_eq!(
op.messages[0].content_type,
Some("application/json".to_string())
);
}
#[test]
fn parse_asyncapi_with_bindings() {
let yaml = r#"
asyncapi: "3.0.0"
info:
title: Order Events
version: "1.0.0"
channels:
orderCreated:
address: orders.created
bindings:
kafka:
topic: order-events
partitions: 10
replicas: 3
messages:
OrderCreatedMessage:
bindings:
kafka:
key:
type: string
payload:
type: object
operations:
handleOrderCreated:
action: receive
channel:
$ref: '#/channels/orderCreated'
bindings:
kafka:
groupId: order-processor
x-barbacane-dispatch:
name: kafka
"#;
let spec = parse_spec(yaml).unwrap();
let op = &spec.operations[0];
assert!(op.bindings.contains_key("kafka"));
let kafka_binding = op.bindings.get("kafka").unwrap();
assert!(kafka_binding.get("groupId").is_some());
assert!(op.messages[0].bindings.contains_key("kafka"));
}
#[test]
fn parse_asyncapi_inline_channel() {
let yaml = r#"
asyncapi: "3.0.0"
info:
title: Inline Channel Test
version: "1.0.0"
operations:
inlineOp:
action: receive
channel:
address: inline/topic
messages:
InlineMessage:
payload:
type: string
x-barbacane-dispatch:
name: mock
"#;
let spec = parse_spec(yaml).unwrap();
let op = &spec.operations[0];
assert_eq!(op.path, "inline/topic");
assert_eq!(op.messages.len(), 1);
assert_eq!(op.messages[0].name, "InlineMessage");
}
#[test]
fn parse_asyncapi_multiple_operations() {
let yaml = r#"
asyncapi: "3.0.0"
info:
title: Multi-Op API
version: "1.0.0"
channels:
events:
address: events
messages:
Event:
payload:
type: object
operations:
publishEvent:
action: send
channel:
$ref: '#/channels/events'
x-barbacane-dispatch:
name: kafka
consumeEvent:
action: receive
channel:
$ref: '#/channels/events'
x-barbacane-dispatch:
name: kafka
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.operations.len(), 2);
let send_op = spec
.operations
.iter()
.find(|op| op.method == "SEND")
.unwrap();
let recv_op = spec
.operations
.iter()
.find(|op| op.method == "RECEIVE")
.unwrap();
assert_eq!(send_op.operation_id, Some("publishEvent".to_string()));
assert_eq!(recv_op.operation_id, Some("consumeEvent".to_string()));
}
#[test]
fn parse_asyncapi_global_middlewares() {
let yaml = r#"
asyncapi: "3.0.0"
info:
title: Middleware Test
version: "1.0.0"
x-barbacane-middlewares:
- name: auth
config:
type: jwt
channels:
events:
address: events
messages:
Event:
payload:
type: object
operations:
handleEvent:
action: receive
channel:
$ref: '#/channels/events'
x-barbacane-dispatch:
name: kafka
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.global_middlewares.len(), 1);
assert_eq!(spec.global_middlewares[0].name, "auth");
}
#[test]
fn parse_asyncapi_3_1() {
let yaml = r#"
asyncapi: "3.1.0"
info:
title: User Events API
version: "1.0.0"
channels:
userSignedUp:
address: user/signedup
messages:
UserSignedUpMessage:
payload:
type: object
properties:
userId:
type: string
operations:
processUserSignup:
action: send
channel:
$ref: '#/channels/userSignedUp'
x-barbacane-dispatch:
name: kafka
config:
topic: user-events
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.format, SpecFormat::AsyncApi);
assert_eq!(spec.version, "3.1.0");
assert_eq!(spec.operations.len(), 1);
}
#[test]
fn reject_asyncapi_2() {
let yaml = r#"
asyncapi: "2.6.0"
info:
title: Old AsyncAPI
version: "1.0.0"
channels: {}
"#;
let result = parse_spec(yaml);
assert!(matches!(result, Err(ParseError::SchemaError(_))));
}
#[test]
fn parse_query_method() {
let yaml = r#"
openapi: "3.2.0"
info:
title: Query Method API
version: "1.0.0"
paths:
/search:
query:
operationId: searchItems
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
filter:
type: string
x-barbacane-dispatch:
name: mock
config:
status: 200
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.version, "3.2.0");
assert_eq!(spec.operations.len(), 1);
let op = &spec.operations[0];
assert_eq!(op.path, "/search");
assert_eq!(op.method, "QUERY");
assert_eq!(op.operation_id, Some("searchItems".to_string()));
assert!(op.request_body.is_some());
}
#[test]
fn parse_additional_operations() {
let yaml = r#"
openapi: "3.2.0"
info:
title: Custom Methods API
version: "1.0.0"
paths:
/cache/{key}:
get:
operationId: getCache
x-barbacane-dispatch:
name: mock
additionalOperations:
purge:
operationId: purgeCache
parameters:
- name: key
in: path
required: true
schema:
type: string
x-barbacane-dispatch:
name: mock
config:
status: 204
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.operations.len(), 2);
let get_op = spec
.operations
.iter()
.find(|op| op.method == "GET")
.unwrap();
assert_eq!(get_op.operation_id, Some("getCache".to_string()));
let purge_op = spec
.operations
.iter()
.find(|op| op.method == "PURGE")
.unwrap();
assert_eq!(purge_op.operation_id, Some("purgeCache".to_string()));
assert_eq!(purge_op.parameters.len(), 1);
assert_eq!(purge_op.parameters[0].name, "key");
}
#[test]
fn parse_additional_operations_inherits_path_params() {
let yaml = r#"
openapi: "3.2.0"
info:
title: Path Params Inheritance
version: "1.0.0"
paths:
/items/{id}:
parameters:
- name: id
in: path
required: true
schema:
type: string
additionalOperations:
link:
operationId: linkItem
x-barbacane-dispatch:
name: mock
"#;
let spec = parse_spec(yaml).unwrap();
assert_eq!(spec.operations.len(), 1);
let op = &spec.operations[0];
assert_eq!(op.method, "LINK");
assert_eq!(op.parameters.len(), 1);
assert_eq!(op.parameters[0].name, "id");
}
#[test]
fn parse_querystring_parameter() {
let yaml = r#"
openapi: "3.2.0"
info:
title: Querystring API
version: "1.0.0"
paths:
/search:
get:
operationId: search
parameters:
- name: q
in: querystring
required: true
content:
application/x-www-form-urlencoded:
schema:
type: string
minLength: 1
x-barbacane-dispatch:
name: mock
"#;
let spec = parse_spec(yaml).unwrap();
let op = &spec.operations[0];
assert_eq!(op.parameters.len(), 1);
let param = &op.parameters[0];
assert_eq!(param.name, "q");
assert_eq!(param.location, "querystring");
assert!(param.required);
assert!(param.schema.is_some());
assert_eq!(
param.schema.as_ref().unwrap().get("type").unwrap(),
"string"
);
}
#[test]
fn resolve_ref_in_parameter_schema() {
let yaml = r##"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
components:
schemas:
UserId:
type: integer
format: int64
paths:
/users/{id}:
get:
parameters:
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/UserId"
x-barbacane-dispatch:
name: mock
"##;
let spec = parse_spec(yaml).unwrap();
let param = &spec.operations[0].parameters[0];
let schema = param.schema.as_ref().unwrap();
assert!(schema.get("$ref").is_none());
assert_eq!(schema.get("type").unwrap(), "integer");
assert_eq!(schema.get("format").unwrap(), "int64");
}
#[test]
fn resolve_ref_in_request_body() {
let yaml = r##"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
components:
schemas:
CreateUser:
type: object
required: [name]
properties:
name:
type: string
paths:
/users:
post:
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUser"
x-barbacane-dispatch:
name: mock
"##;
let spec = parse_spec(yaml).unwrap();
let body = spec.operations[0].request_body.as_ref().unwrap();
let schema = body.content["application/json"].schema.as_ref().unwrap();
assert!(schema.get("$ref").is_none());
assert_eq!(schema.get("type").unwrap(), "object");
assert!(schema.get("properties").is_some());
}
#[test]
fn resolve_nested_ref() {
let yaml = r##"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
components:
schemas:
Address:
type: object
properties:
street:
type: string
User:
type: object
properties:
address:
$ref: "#/components/schemas/Address"
paths:
/users:
post:
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/User"
x-barbacane-dispatch:
name: mock
"##;
let spec = parse_spec(yaml).unwrap();
let body = spec.operations[0].request_body.as_ref().unwrap();
let schema = body.content["application/json"].schema.as_ref().unwrap();
assert!(schema.get("$ref").is_none());
let address_schema = schema.get("properties").unwrap().get("address").unwrap();
assert!(address_schema.get("$ref").is_none());
assert_eq!(address_schema.get("type").unwrap(), "object");
}
#[test]
fn unresolved_ref_returns_error() {
let yaml = r##"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
paths:
/users:
get:
parameters:
- name: id
in: query
schema:
$ref: "#/components/schemas/DoesNotExist"
x-barbacane-dispatch:
name: mock
"##;
let err = parse_spec(yaml).unwrap_err();
assert!(
matches!(err, ParseError::UnresolvedRef(ref s) if s.contains("DoesNotExist")),
"expected UnresolvedRef, got: {:?}",
err
);
}
#[test]
fn circular_ref_returns_error() {
let yaml = r##"
openapi: "3.1.0"
info:
title: Test API
version: "1.0.0"
components:
schemas:
Node:
type: object
properties:
child:
$ref: "#/components/schemas/Node"
paths:
/nodes:
get:
parameters:
- name: root
in: query
schema:
$ref: "#/components/schemas/Node"
x-barbacane-dispatch:
name: mock
"##;
let err = parse_spec(yaml).unwrap_err();
assert!(
matches!(err, ParseError::SchemaError(ref s) if s.contains("circular")),
"expected SchemaError with 'circular', got: {:?}",
err
);
}
#[test]
fn asyncapi_message_payload_ref() {
let yaml = r##"
asyncapi: "3.0.0"
info:
title: Test API
version: "1.0.0"
components:
schemas:
UserEvent:
type: object
properties:
userId:
type: string
channels:
userSignedUp:
address: user/signedup
messages:
userSignedUp:
payload:
$ref: "#/components/schemas/UserEvent"
operations:
onUserSignedUp:
action: receive
channel:
$ref: "#/channels/userSignedUp"
"##;
let spec = parse_spec(yaml).unwrap();
let op = &spec.operations[0];
let msg = &op.messages[0];
let payload = msg.payload.as_ref().unwrap();
assert!(payload.get("$ref").is_none());
assert_eq!(payload.get("type").unwrap(), "object");
}
#[test]
fn parse_summary_and_description() {
let yaml = r##"
openapi: "3.1.0"
info:
title: Test
version: "1.0.0"
paths:
/orders:
post:
operationId: createOrder
summary: Create a new order
description: Creates an order with items and shipping address
x-barbacane-dispatch:
name: mock
config:
status: 200
"##;
let spec = parse_spec(yaml).expect("should parse");
let op = &spec.operations[0];
assert_eq!(op.summary.as_deref(), Some("Create a new order"));
assert_eq!(
op.description.as_deref(),
Some("Creates an order with items and shipping address")
);
}
#[test]
fn parse_summary_and_description_absent() {
let yaml = r##"
openapi: "3.1.0"
info:
title: Test
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
config:
status: 200
"##;
let spec = parse_spec(yaml).expect("should parse");
let op = &spec.operations[0];
assert!(op.summary.is_none());
assert!(op.description.is_none());
}
#[test]
fn parse_responses_with_schema() {
let yaml = r##"
openapi: "3.1.0"
info:
title: Test
version: "1.0.0"
paths:
/orders:
post:
operationId: createOrder
x-barbacane-dispatch:
name: mock
config:
status: 200
responses:
"200":
content:
application/json:
schema:
type: object
properties:
order_id:
type: string
"404":
content:
application/json:
schema:
type: object
properties:
error:
type: string
"##;
let spec = parse_spec(yaml).expect("should parse");
let op = &spec.operations[0];
assert_eq!(op.responses.len(), 2);
assert!(op.responses.contains_key("200"));
assert!(op.responses.contains_key("404"));
let resp_200 = &op.responses["200"];
let schema = resp_200.content["application/json"]
.schema
.as_ref()
.expect("schema");
assert!(schema["properties"]["order_id"].is_object());
}
#[test]
fn parse_responses_with_ref() {
let yaml = r##"
openapi: "3.1.0"
info:
title: Test
version: "1.0.0"
components:
schemas:
Order:
type: object
properties:
id:
type: string
paths:
/orders:
post:
operationId: createOrder
x-barbacane-dispatch:
name: mock
config:
status: 200
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
"##;
let spec = parse_spec(yaml).expect("should parse");
let op = &spec.operations[0];
let schema = op.responses["200"].content["application/json"]
.schema
.as_ref()
.expect("schema");
assert!(schema.get("$ref").is_none());
assert!(schema["properties"]["id"].is_object());
}
#[test]
fn parse_responses_empty_when_no_content() {
let yaml = r##"
openapi: "3.1.0"
info:
title: Test
version: "1.0.0"
paths:
/health:
get:
x-barbacane-dispatch:
name: mock
config:
status: 204
responses:
"204":
description: No content
"##;
let spec = parse_spec(yaml).expect("should parse");
let op = &spec.operations[0];
assert!(op.responses.is_empty());
}
}