use serde::Deserialize;
use super::filters::FilterEntry;
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
#[serde(try_from = "ChainRefRaw", untagged)]
pub enum ChainRef {
Inline {
name: String,
filters: Vec<FilterEntry>,
},
Named(String),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ChainRefRaw {
Inline(InlineChainRaw),
Named(String),
}
#[derive(Deserialize)]
struct InlineChainRaw {
name: String,
filters: Vec<FilterEntry>,
#[serde(flatten)]
unknown: std::collections::HashMap<String, serde_yaml::Value>,
}
impl TryFrom<ChainRefRaw> for ChainRef {
type Error = String;
fn try_from(raw: ChainRefRaw) -> Result<Self, Self::Error> {
match raw {
ChainRefRaw::Named(name) => Ok(Self::Named(name)),
ChainRefRaw::Inline(inline) => {
if !inline.unknown.is_empty() {
let mut keys: Vec<&str> = inline.unknown.keys().map(String::as_str).collect();
keys.sort_unstable();
return Err(format!(
"inline chain '{}': unknown field(s): {}; expected only 'name' and \
'filters' (branch-level fields like 'rejoin' belong on the branch, \
not the chain)",
inline.name,
keys.join(", ")
));
}
Ok(Self::Inline {
name: inline.name,
filters: inline.filters,
})
},
}
}
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::needless_raw_strings,
clippy::needless_raw_string_hashes,
clippy::panic,
clippy::min_ident_chars,
reason = "tests use unwrap/expect/indexing/raw strings for brevity"
)]
mod tests {
use super::*;
#[test]
fn parse_named_ref() {
let chain_ref: ChainRef = serde_yaml::from_str(r#""my_chain""#).unwrap();
assert!(
matches!(chain_ref, ChainRef::Named(s) if s == "my_chain"),
"should parse as Named variant"
);
}
#[test]
fn parse_inline_ref() {
let yaml = r#"
name: inline_chain
filters:
- filter: headers
"#;
let chain_ref: ChainRef = serde_yaml::from_str(yaml).unwrap();
match chain_ref {
ChainRef::Inline { name, filters } => {
assert_eq!(name, "inline_chain", "inline chain name mismatch");
assert_eq!(filters.len(), 1, "inline chain should have 1 filter");
},
ChainRef::Named(_) => panic!("should parse as Inline variant"),
}
}
#[test]
fn parse_inline_with_multiple_filters() {
let yaml = r#"
name: multi
filters:
- filter: headers
- filter: cors
"#;
let chain_ref: ChainRef = serde_yaml::from_str(yaml).unwrap();
match chain_ref {
ChainRef::Inline { filters, .. } => {
assert_eq!(filters.len(), 2, "should have 2 filters");
},
ChainRef::Named(_) => panic!("should parse as Inline variant"),
}
}
#[test]
fn parse_named_in_sequence() {
let yaml = r#"
- chain_a
- chain_b
"#;
let refs: Vec<ChainRef> = serde_yaml::from_str(yaml).unwrap();
assert_eq!(refs.len(), 2, "should have 2 chain refs");
assert!(
matches!(&refs[0], ChainRef::Named(s) if s == "chain_a"),
"first ref should be Named 'chain_a'"
);
assert!(
matches!(&refs[1], ChainRef::Named(s) if s == "chain_b"),
"second ref should be Named 'chain_b'"
);
}
#[test]
fn parse_mixed_sequence() {
let yaml = r#"
- chain_a
- name: inline
filters:
- filter: router
"#;
let refs: Vec<ChainRef> = serde_yaml::from_str(yaml).unwrap();
assert_eq!(refs.len(), 2, "should have 2 chain refs");
assert!(
matches!(&refs[0], ChainRef::Named(s) if s == "chain_a"),
"first should be Named"
);
assert!(matches!(&refs[1], ChainRef::Inline { .. }), "second should be Inline");
}
#[test]
fn inline_chain_with_misplaced_branch_field_rejected() {
let yaml = "name: inline\nfilters:\n - filter: router\nrejoin: next\n";
let err = serde_yaml::from_str::<ChainRef>(yaml).unwrap_err();
assert!(
err.to_string().contains("rejoin"),
"a branch-level key on an inline chain must be rejected by name, got: {err}"
);
}
#[test]
fn inline_chain_with_unknown_key_rejected() {
let yaml = "name: inline\nfilters:\n - filter: router\nfitlers: []\n";
let err = serde_yaml::from_str::<ChainRef>(yaml).unwrap_err();
assert!(
err.to_string().contains("fitlers"),
"typoed keys on an inline chain must be rejected by name, got: {err}"
);
}
}