use camel_api::CamelError;
use crate::route_ast::{
MarshalStep, RouteDslRest, RouteDslRestOperation, RouteDslRoute, RouteDslStep, SetHeaderData,
SetHeaderStep, ToStep, UnmarshalStep,
};
pub fn lower_all_rest_to_routes(
rest_blocks: &[RouteDslRest],
) -> Result<Vec<RouteDslRoute>, CamelError> {
let mut routes = Vec::new();
let mut registered: Vec<(String, u16, String, Vec<PathSegment>)> = Vec::new();
for rest in rest_blocks {
validate_rest_block(rest)?;
for op in &rest.operations {
let route = lower_operation(rest, &op.method, op)?;
let verb_upper = op.method.to_uppercase();
let verb_lc = op.method.to_lowercase();
let full_path = build_full_path(&rest.path, &op.path);
let segs = parse_path_template(&full_path);
let op_label = op.operation_id.as_deref().unwrap_or(&verb_lc);
if registered.iter().any(|(h, p, m, s)| {
h == &rest.host && *p == rest.port && m == &verb_upper && *s == segs
}) {
return Err(CamelError::RouteError(format!(
"rest operation '{op_label}': duplicate (method, path) tuple \
{verb_upper} {full_path} on listener {}:{} — two operations \
cannot claim the same listener, verb, and full path (spec §6.3)",
rest.host, rest.port
)));
}
for (h, p, m, s) in ®istered {
if h == &rest.host
&& *p == rest.port
&& m == &verb_upper
&& templates_ambiguous(s, &segs)
{
return Err(CamelError::RouteError(format!(
"rest operation '{op_label}': path '{full_path}' is ambiguous \
with an existing template of equal specificity for method \
{verb_upper} on listener {}:{} (spec §7.2) — disambiguate by \
making one path more specific",
rest.host, rest.port
)));
}
}
registered.push((rest.host.clone(), rest.port, verb_upper, segs));
routes.push(route);
}
}
Ok(routes)
}
pub fn expand_rest_into(
routes: &mut Vec<RouteDslRoute>,
rest_blocks: &[RouteDslRest],
) -> Result<(), CamelError> {
if rest_blocks.is_empty() {
return Ok(());
}
let lowered = lower_all_rest_to_routes(rest_blocks)?;
routes.extend(lowered);
Ok(())
}
pub fn check_duplicate_route_ids(routes: &[RouteDslRoute]) -> Result<(), CamelError> {
let mut seen = std::collections::HashSet::with_capacity(routes.len());
for route in routes {
if !seen.insert(&route.id) {
return Err(CamelError::RouteError(format!(
"duplicate route id '{}' after REST expansion",
route.id
)));
}
}
Ok(())
}
fn validate_rest_block(rest: &RouteDslRest) -> Result<(), CamelError> {
if rest.path.trim().is_empty() {
return Err(CamelError::RouteError(format!(
"rest block on listener {}:{}: 'path' is required and must be non-empty \
(spec §6.3) — an empty base path is not allowed",
rest.host, rest.port
)));
}
if rest.operations.is_empty() {
return Err(CamelError::RouteError(format!(
"rest block {}:{}{}: 'operations' must declare at least one verb \
(spec §6.3) — a REST block with no operations produces no routes",
rest.host, rest.port, rest.path
)));
}
Ok(())
}
fn validate_path_template(full_path: &str, op_label: &str) -> Result<(), CamelError> {
let segs = parse_path_template(full_path);
for seg in &segs {
if let PathSegment::Param(name) = seg
&& name.is_empty()
{
return Err(CamelError::RouteError(format!(
"rest operation '{op_label}': path '{full_path}' contains an empty \
parameter '{{}}' — parameter names are required"
)));
}
}
let param_names = extract_param_names(&segs);
let mut seen = std::collections::HashSet::new();
for name in ¶m_names {
if !seen.insert(name.clone()) {
return Err(CamelError::RouteError(format!(
"rest operation '{op_label}': path '{full_path}' declares parameter \
'{name}' more than once — path parameter names must be unique"
)));
}
}
Ok(())
}
fn templates_ambiguous(a: &[PathSegment], b: &[PathSegment]) -> bool {
if a.len() != b.len() {
return false;
}
if template_specificity(a) != template_specificity(b) {
return false;
}
for (sa, sb) in a.iter().zip(b.iter()) {
if let (PathSegment::Literal(x), PathSegment::Literal(y)) = (sa, sb)
&& x != y
{
return false;
}
}
true
}
fn lower_operation(
rest: &RouteDslRest,
verb: &str,
op: &RouteDslRestOperation,
) -> Result<RouteDslRoute, CamelError> {
const KNOWN_VERBS: &[&str] = &["get", "post", "put", "delete", "patch", "head", "options"];
let verb_lc = verb.to_lowercase();
if !KNOWN_VERBS.contains(&verb_lc.as_str()) {
return Err(CamelError::RouteError(format!(
"rest operation '{}': unknown HTTP verb '{}' (expected one of: {})",
op.operation_id.as_deref().unwrap_or(verb),
verb,
KNOWN_VERBS.join(", "),
)));
}
let has_to = op.to.is_some();
let has_steps = !op.steps.is_empty();
if has_to && has_steps {
return Err(CamelError::RouteError(format!(
"rest operation '{}' cannot have both 'to' and 'steps'",
op.operation_id.as_deref().unwrap_or(verb)
)));
}
if !has_to && !has_steps {
return Err(CamelError::RouteError(format!(
"rest operation '{}' must have 'to' or 'steps'",
op.operation_id.as_deref().unwrap_or(verb)
)));
}
if op.consumes != "application/json" {
return Err(CamelError::RouteError(format!(
"rest operation '{}': v1 supports only consumes=application/json (got '{}')",
op.operation_id.as_deref().unwrap_or(verb),
op.consumes
)));
}
if op.produces != "application/json" {
return Err(CamelError::RouteError(format!(
"rest operation '{}': v1 supports only produces=application/json (got '{}')",
op.operation_id.as_deref().unwrap_or(verb),
op.produces
)));
}
let route_id = op.operation_id.clone().unwrap_or_else(|| {
format!("{}_{}{}", verb, rest.path.trim_start_matches('/'), op.path)
.replace('/', "-")
.replace(['{', '}'], "")
});
let full_path = build_full_path(&rest.path, &op.path);
validate_path_template(&full_path, op.operation_id.as_deref().unwrap_or(verb))?;
let from = format!(
"http://{}:{}{}?httpMethod={}",
rest.host,
rest.port,
full_path,
verb.to_uppercase()
);
let default_status = op
.success_status
.unwrap_or_else(|| default_status_for_verb(&verb_lc));
let mut steps: Vec<RouteDslStep> = vec![RouteDslStep::SetHeader(SetHeaderStep {
set_header: SetHeaderData {
key: "CamelHttpResponseCode".to_string(),
value: Some(serde_json::Value::Number(default_status.into())),
language: None,
source: None,
simple: None,
rhai: None,
jsonpath: None,
xpath: None,
},
})];
if verb_has_body(&verb_lc) {
steps.push(RouteDslStep::Unmarshal(UnmarshalStep {
unmarshal: "json".to_string(),
schema: op.request_schema.clone(),
}));
}
if let Some(ref to_uri) = op.to {
steps.push(RouteDslStep::To(ToStep { to: to_uri.clone() }));
} else {
steps.extend(op.steps.iter().cloned());
}
steps.push(RouteDslStep::Marshal(MarshalStep {
marshal: "json".to_string(),
}));
steps.push(RouteDslStep::SetHeader(SetHeaderStep {
set_header: SetHeaderData {
key: "Content-Type".to_string(),
value: Some(serde_json::Value::String("application/json".to_string())),
language: None,
source: None,
simple: None,
rhai: None,
jsonpath: None,
xpath: None,
},
}));
Ok(RouteDslRoute {
id: route_id,
from,
steps,
auto_startup: true,
startup_order: 0,
sequential: false,
concurrent: None,
error_handler: None,
circuit_breaker: None,
security_policy: None,
on_complete: None,
on_failure: None,
})
}
pub fn build_full_path(base: &str, suffix: &str) -> String {
let base = base.trim_end_matches('/');
let suffix = suffix.trim_start_matches('/');
if suffix.is_empty() {
base.to_string()
} else {
format!("{}/{}", base, suffix)
}
}
pub fn default_status_for_verb(verb: &str) -> u16 {
match verb {
"post" => 201,
"delete" => 204,
_ => 200,
}
}
pub fn verb_has_body(verb: &str) -> bool {
matches!(verb, "post" | "put" | "patch")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathSegment {
Literal(String),
Param(String),
}
pub fn parse_path_template(path: &str) -> Vec<PathSegment> {
path.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(|seg| {
if seg.starts_with('{') && seg.ends_with('}') {
PathSegment::Param(seg[1..seg.len() - 1].to_string())
} else {
PathSegment::Literal(seg.to_string())
}
})
.collect()
}
pub fn template_specificity(segments: &[PathSegment]) -> usize {
segments
.iter()
.filter(|s| matches!(s, PathSegment::Literal(_)))
.count()
}
pub fn extract_param_names(segments: &[PathSegment]) -> Vec<String> {
segments
.iter()
.filter_map(|s| match s {
PathSegment::Param(name) => Some(name.clone()),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
fn make_rest(op_id: &str, verb: &str, path: &str, to: &str) -> RouteDslRest {
let operations = vec![RouteDslRestOperation {
method: verb.to_string(),
path: path.to_string(),
operation_id: Some(op_id.to_string()),
to: Some(to.to_string()),
steps: vec![],
consumes: "application/json".to_string(),
produces: "application/json".to_string(),
success_status: None,
request_schema: None,
response: None,
description: None,
parameters: BTreeMap::new(),
}];
RouteDslRest {
host: "0.0.0.0".to_string(),
port: 8080,
path: "/users".to_string(),
operations,
}
}
fn make_op(operation_id: &str) -> RouteDslRestOperation {
RouteDslRestOperation {
method: "GET".to_string(),
path: "/".to_string(),
operation_id: Some(operation_id.to_string()),
to: Some(format!("direct:{operation_id}")),
steps: vec![],
consumes: "application/json".to_string(),
produces: "application/json".to_string(),
success_status: None,
request_schema: None,
response: None,
description: None,
parameters: BTreeMap::new(),
}
}
#[test]
fn lower_single_get_operation() {
let rest = make_rest("getUser", "get", "/{id}", "bean:userService");
let routes = lower_all_rest_to_routes(&[rest]).unwrap();
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].id, "getUser");
assert!(routes[0].from.contains("httpMethod=GET"));
assert!(routes[0].from.contains("/users/{id}"));
assert!(routes[0].from.contains("0.0.0.0:8080"));
}
#[test]
fn lower_post_has_201_default() {
let rest = make_rest("createUser", "post", "/", "bean:createUser");
let routes = lower_all_rest_to_routes(&[rest]).unwrap();
let first = routes[0].steps.first().unwrap();
match first {
RouteDslStep::SetHeader(h) => {
assert_eq!(h.set_header.key, "CamelHttpResponseCode");
assert_eq!(h.set_header.value, Some(serde_json::json!(201)));
}
_ => panic!("expected SetHeader as first step for default status"),
}
}
#[test]
fn lower_get_defaults_to_200() {
let rest = make_rest("getUser", "get", "/{id}", "bean:userService");
let routes = lower_all_rest_to_routes(&[rest]).unwrap();
let first = routes[0].steps.first().unwrap();
match first {
RouteDslStep::SetHeader(h) => {
assert_eq!(h.set_header.value, Some(serde_json::json!(200)));
}
_ => panic!("expected SetHeader first"),
}
}
#[test]
fn lower_delete_defaults_to_204() {
let rest = make_rest("deleteUser", "delete", "/{id}", "bean:deleteUser");
let routes = lower_all_rest_to_routes(&[rest]).unwrap();
let first = routes[0].steps.first().unwrap();
match first {
RouteDslStep::SetHeader(h) => {
assert_eq!(h.set_header.value, Some(serde_json::json!(204)));
}
_ => panic!("expected SetHeader first"),
}
}
#[test]
fn lower_rejects_to_and_steps_both() {
let mut rest = make_rest("op", "get", "/", "bean:svc");
rest.operations[0].steps = vec![RouteDslStep::To(crate::route_ast::ToStep {
to: "bean:other".into(),
})];
let result = lower_all_rest_to_routes(&[rest]);
assert!(result.is_err());
}
#[test]
fn lower_rejects_non_json_consumes() {
let mut rest = make_rest("op", "get", "/", "bean:svc");
rest.operations[0].consumes = "application/xml".to_string();
let result = lower_all_rest_to_routes(&[rest]);
assert!(result.is_err());
}
#[test]
fn parse_path_template_segments() {
let segments = parse_path_template("/users/{id}/posts/{postId}");
assert_eq!(segments.len(), 4);
assert!(matches!(segments[0], PathSegment::Literal(ref s) if s == "users"));
assert!(matches!(segments[1], PathSegment::Param(ref s) if s == "id"));
assert!(matches!(segments[2], PathSegment::Literal(ref s) if s == "posts"));
assert!(matches!(segments[3], PathSegment::Param(ref s) if s == "postId"));
}
#[test]
fn path_template_specificity_count() {
let s1 = parse_path_template("/users/me");
let s2 = parse_path_template("/users/{id}");
assert!(template_specificity(&s1) > template_specificity(&s2));
}
#[test]
fn parse_path_template_empty() {
let segments = parse_path_template("");
assert!(segments.is_empty());
}
#[test]
fn parse_path_template_root() {
let segments = parse_path_template("/");
assert!(segments.is_empty());
}
#[test]
fn extract_param_names_from_template() {
let segments = parse_path_template("/users/{id}/posts/{postId}");
let params = extract_param_names(&segments);
assert_eq!(params, vec!["id", "postId"]);
}
#[test]
fn lower_rejects_duplicate_operation_ids() {
let rest1 = make_rest("getUser", "get", "/{id}", "bean:svc");
let rest2 = make_rest("getUser", "get", "/{id}", "bean:other");
let mut all = lower_all_rest_to_routes(&[rest1]).unwrap();
all.extend(lower_all_rest_to_routes(&[rest2]).unwrap());
assert!(check_duplicate_route_ids(&all).is_err());
}
fn make_rest_with_base(
op_id: &str,
verb: &str,
base: &str,
path: &str,
to: &str,
) -> RouteDslRest {
let operations = vec![RouteDslRestOperation {
method: verb.to_string(),
path: path.to_string(),
operation_id: Some(op_id.to_string()),
to: Some(to.to_string()),
steps: vec![],
consumes: "application/json".to_string(),
produces: "application/json".to_string(),
success_status: None,
request_schema: None,
response: None,
description: None,
parameters: BTreeMap::new(),
}];
RouteDslRest {
host: "0.0.0.0".to_string(),
port: 8080,
path: base.to_string(),
operations,
}
}
#[test]
fn lower_all_rejects_cross_block_ambiguous_templates() {
let a = make_rest_with_base("getUserA", "get", "/users", "/{id}", "bean:a");
let b = make_rest_with_base("getUserB", "get", "/users", "/{name}", "bean:b");
let err = lower_all_rest_to_routes(&[a, b])
.err()
.expect("ambiguous templates across blocks must be rejected");
let msg = err.to_string();
assert!(msg.contains("ambiguous"), "got: {msg}");
}
#[test]
fn lower_all_rejects_duplicate_method_path_across_blocks() {
let a = make_rest_with_base("getUserA", "get", "/users", "/{id}", "bean:a");
let b = make_rest_with_base("getUserB", "get", "/users", "/{id}", "bean:b");
let err = lower_all_rest_to_routes(&[a, b])
.err()
.expect("duplicate (method, path) must be rejected");
let msg = err.to_string();
assert!(msg.contains("duplicate"), "got: {msg}");
}
#[test]
fn lower_all_allows_different_verbs_same_path() {
let get = make_rest_with_base("getUser", "get", "/users", "/{id}", "bean:g");
let delete = make_rest_with_base("deleteUser", "delete", "/users", "/{id}", "bean:d");
let routes = lower_all_rest_to_routes(&[get, delete]).unwrap();
assert_eq!(routes.len(), 2);
}
#[test]
fn lower_all_allows_different_specificity_overlap() {
let me = make_rest_with_base("getMe", "get", "/users", "/me", "bean:me");
let id = make_rest_with_base("getUser", "get", "/users", "/{id}", "bean:id");
let routes = lower_all_rest_to_routes(&[me, id]).unwrap();
assert_eq!(routes.len(), 2);
}
fn make_rest_on_listener(
host: &str,
port: u16,
op_id: &str,
verb: &str,
base: &str,
path: &str,
to: &str,
) -> RouteDslRest {
let mut rest = make_rest_with_base(op_id, verb, base, path, to);
rest.host = host.to_string();
rest.port = port;
rest
}
#[test]
fn lower_all_allows_same_method_path_on_different_port() {
let a = make_rest_on_listener(
"0.0.0.0", 8080, "getUserA", "get", "/users", "/{id}", "bean:a",
);
let b = make_rest_on_listener(
"0.0.0.0", 9090, "getUserB", "get", "/users", "/{id}", "bean:b",
);
let routes = lower_all_rest_to_routes(&[a, b]).unwrap();
assert_eq!(routes.len(), 2);
}
#[test]
fn lower_all_allows_same_method_path_on_different_host() {
let a = make_rest_on_listener(
"0.0.0.0", 8080, "getUserA", "get", "/users", "/{id}", "bean:a",
);
let b = make_rest_on_listener(
"127.0.0.1",
8080,
"getUserB",
"get",
"/users",
"/{id}",
"bean:b",
);
let routes = lower_all_rest_to_routes(&[a, b]).unwrap();
assert_eq!(routes.len(), 2);
}
#[test]
fn lower_rejects_empty_base_path() {
let mut rest = make_rest("getUser", "get", "/{id}", "bean:svc");
rest.path = "".to_string();
let err = lower_all_rest_to_routes(&[rest])
.err()
.expect("empty base path must be rejected");
assert!(err.to_string().contains("path"), "got: {err}");
}
#[test]
fn lower_rejects_whitespace_only_base_path() {
let mut rest = make_rest("getUser", "get", "/{id}", "bean:svc");
rest.path = " ".to_string();
assert!(lower_all_rest_to_routes(&[rest]).is_err());
}
#[test]
fn lower_rejects_empty_operations() {
let rest = RouteDslRest {
host: "0.0.0.0".to_string(),
port: 8080,
path: "/users".to_string(),
operations: vec![],
};
let err = lower_all_rest_to_routes(&[rest])
.err()
.expect("empty operations must be rejected");
assert!(err.to_string().contains("operations"), "got: {err}");
}
#[test]
fn lower_all_rejects_empty_operations_in_second_block() {
let a = make_rest_with_base("getUser", "get", "/users", "/{id}", "bean:a");
let mut b = make_rest_with_base("createUser", "post", "/users", "/", "bean:b");
b.path = "".to_string();
assert!(lower_all_rest_to_routes(&[a, b]).is_err());
}
#[test]
fn lower_injects_application_json_content_type() {
let rest = make_rest("getUser", "get", "/{id}", "bean:svc");
let routes = lower_all_rest_to_routes(&[rest]).unwrap();
let last = routes[0].steps.last().expect("steps must be non-empty");
match last {
RouteDslStep::SetHeader(h) => {
assert_eq!(h.set_header.key, "Content-Type");
assert_eq!(
h.set_header.value,
Some(serde_json::json!("application/json"))
);
}
other => panic!("expected SetHeader(Content-Type) last, got {other:?}"),
}
}
#[test]
fn lower_content_type_is_after_marshal() {
let rest = make_rest("getUser", "get", "/{id}", "bean:svc");
let steps = &lower_all_rest_to_routes(&[rest]).unwrap()[0].steps;
let marshal_idx = steps
.iter()
.position(|s| matches!(s, RouteDslStep::Marshal(_)))
.expect("marshal step present");
let ct_idx = steps
.iter()
.position(
|s| matches!(s, RouteDslStep::SetHeader(h) if h.set_header.key == "Content-Type"),
)
.expect("Content-Type step present");
assert!(ct_idx > marshal_idx, "Content-Type must follow marshal");
}
#[test]
fn lower_rejects_unknown_verb() {
let rest = make_rest("op", "geet", "/", "bean:svc");
let err = lower_all_rest_to_routes(&[rest]).err();
assert!(err.is_some(), "unknown verb must be rejected");
let msg = format!("{}", err.unwrap());
assert!(msg.contains("unknown HTTP verb 'geet'"), "got: {msg}");
}
#[test]
fn lower_accepts_uppercase_verb_key() {
let rest = make_rest("createUser", "POST", "/", "bean:create");
let routes = lower_all_rest_to_routes(&[rest]).unwrap();
assert!(routes[0].from.contains("httpMethod=POST"));
let first = match routes[0].steps.first().unwrap() {
RouteDslStep::SetHeader(h) => h,
_ => panic!("expected SetHeader first"),
};
assert_eq!(first.set_header.value, Some(serde_json::json!(201)));
}
#[test]
fn lower_rejects_empty_path_param() {
let rest = make_rest_with_base("op", "get", "/users", "/{}", "bean:svc");
let err = lower_all_rest_to_routes(&[rest]).err();
assert!(err.is_some(), "empty {{}} param must be rejected");
}
#[test]
fn lower_rejects_duplicate_path_param() {
let rest = make_rest_with_base("op", "get", "/users", "/{id}/{id}", "bean:svc");
let err = lower_all_rest_to_routes(&[rest]).err();
assert!(err.is_some(), "duplicate path param must be rejected");
}
#[test]
fn expand_rest_into_is_noop_for_empty() {
let mut routes = vec![];
expand_rest_into(&mut routes, &[]).unwrap();
assert!(routes.is_empty());
}
#[test]
fn check_duplicate_route_ids_detects_collision() {
let rest = make_rest("getUser", "get", "/{id}", "bean:svc");
let mut routes = lower_all_rest_to_routes(&[rest]).unwrap();
let dup = routes[0].clone();
routes.push(dup);
assert!(check_duplicate_route_ids(&routes).is_err());
}
#[test]
fn rest_lowering_omits_schema_when_absent() {
let rest = make_rest("createUser", "post", "/", "bean:create");
let routes = lower_all_rest_to_routes(&[rest]).unwrap();
let unmarshal = routes[0]
.steps
.iter()
.find_map(|s| match s {
RouteDslStep::Unmarshal(u) => Some(u),
_ => None,
})
.expect("POST must inject unmarshal step");
assert!(unmarshal.schema.is_none());
}
#[test]
fn rest_lowering_includes_schema_when_present() {
let mut rest = make_rest("createUser", "post", "/", "bean:create");
let schema = serde_json::json!({
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
});
rest.operations[0].request_schema = Some(schema.clone());
let routes = lower_all_rest_to_routes(&[rest]).unwrap();
let unmarshal = routes[0]
.steps
.iter()
.find_map(|s| match s {
RouteDslStep::Unmarshal(u) => Some(u),
_ => None,
})
.expect("POST must inject unmarshal step");
assert_eq!(unmarshal.schema, Some(schema));
}
#[test]
fn rest_lowering_skips_schema_for_verb_without_body() {
let mut rest = make_rest("getUser", "get", "/{id}", "bean:svc");
rest.operations[0].request_schema = Some(serde_json::json!({ "type": "object" }));
let routes = lower_all_rest_to_routes(&[rest]).unwrap();
let has_unmarshal = routes[0]
.steps
.iter()
.any(|s| matches!(s, RouteDslStep::Unmarshal(_)));
assert!(!has_unmarshal, "GET must not emit unmarshal");
}
#[test]
fn within_block_identical_verb_path_is_duplicate() {
let rest = RouteDslRest {
host: "0.0.0.0".to_string(),
port: 8080,
path: "/api".to_string(),
operations: vec![
RouteDslRestOperation {
method: "GET".to_string(),
path: "/health".to_string(),
to: Some("direct:a".to_string()),
..make_op("a")
},
RouteDslRestOperation {
method: "GET".to_string(),
path: "/health".to_string(),
to: Some("direct:b".to_string()),
..make_op("b")
},
],
};
let err = lower_all_rest_to_routes(&[rest])
.err()
.expect("expected duplicate error");
assert!(
err.to_string().contains("duplicate"),
"expected duplicate error, got: {err}"
);
}
#[test]
fn within_block_same_shape_distinct_param_is_ambiguous() {
let rest = RouteDslRest {
host: "0.0.0.0".to_string(),
port: 8080,
path: "/api".to_string(),
operations: vec![
RouteDslRestOperation {
method: "GET".to_string(),
path: "/x/{id}".to_string(),
to: Some("direct:a".to_string()),
..make_op("a")
},
RouteDslRestOperation {
method: "GET".to_string(),
path: "/x/{name}".to_string(),
to: Some("direct:b".to_string()),
..make_op("b")
},
],
};
let err = lower_all_rest_to_routes(&[rest])
.err()
.expect("expected ambiguous error");
assert!(
err.to_string().contains("ambiguous"),
"expected ambiguous error, got: {err}"
);
}
#[test]
fn empty_operations_list_is_rejected() {
let rest = RouteDslRest {
host: "0.0.0.0".to_string(),
port: 8080,
path: "/api".to_string(),
operations: vec![],
};
let err = lower_all_rest_to_routes(&[rest])
.err()
.expect("expected error for empty operations");
assert!(err.to_string().contains("operations"), "got: {err}");
}
#[test]
fn multiple_ops_same_verb_preserve_declaration_order() {
let rest = RouteDslRest {
host: "0.0.0.0".to_string(),
port: 8080,
path: "/api".to_string(),
operations: vec![
RouteDslRestOperation {
method: "GET".to_string(),
path: "/conteos".to_string(),
..make_op("getConteos")
},
RouteDslRestOperation {
method: "GET".to_string(),
path: "/health".to_string(),
..make_op("healthCheck")
},
],
};
let routes = lower_all_rest_to_routes(&[rest]).unwrap();
assert_eq!(routes.len(), 2);
assert!(
routes[0].id.contains("getConteos"),
"expected first id to contain 'getConteos', got: {}",
routes[0].id
);
assert!(
routes[1].id.contains("healthCheck"),
"expected second id to contain 'healthCheck', got: {}",
routes[1].id
);
}
}