Skip to main content

camel_processor/
claim_check.rs

1//! Claim Check EIP processor.
2//!
3//! Stashes the message body into a `ClaimCheckRepository` and replaces it with
4//! a lightweight key reference (text body containing the key). Later steps can
5//! retrieve (Get) or retrieve+remove (GetAndRemove) the body, or use LIFO
6//! stack operations (Push/Pop).
7//!
8//! # Process mode
9//!
10//! This is a **Process-mode** Tower `Service<Exchange>` — transforms the body
11//! in place, no child sub-pipeline. No `StepLifecycle` (holds only
12//! `Arc<dyn ClaimCheckRepository>`, no background work).
13
14use std::future::Future;
15use std::pin::Pin;
16use std::sync::Arc;
17use std::task::{Context, Poll};
18
19use tower::Service;
20
21use camel_api::body::Body;
22use camel_api::{CamelError, ClaimCheckRepository, Exchange, Message};
23
24/// Extracts a claim-check key string from the exchange (e.g. from header/property/body).
25/// Returns an error if the key cannot be resolved (null or empty).
26pub type KeyExpression = Arc<dyn Fn(&Exchange) -> Result<String, CamelError> + Send + Sync>;
27
28/// Claim Check operation variant.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum ClaimCheckOp {
31    /// Stash the body in the repository; replace exchange body with key reference.
32    Set,
33    /// Retrieve the body from the repository by key; replace exchange body.
34    Get,
35    /// Retrieve and remove in one atomic step.
36    GetAndRemove,
37    /// Push current body onto a LIFO stack for this key; replace body with key reference.
38    Push,
39    /// Pop body from a LIFO stack for this key; replace exchange body.
40    Pop,
41}
42
43/// Claim Check EIP processor.
44///
45/// Transforms the exchange body to/from a `ClaimCheckRepository` using the
46/// configured operation and key expression. An optional `filter` controls
47/// which parts of the stashed Message are merged back during checkout
48/// operations (Get/GetAndRemove/Pop).
49#[derive(Clone)]
50pub struct ClaimCheckService {
51    repository: Arc<dyn ClaimCheckRepository>,
52    operation: ClaimCheckOp,
53    key_expression: KeyExpression,
54    filter: Option<ClaimCheckFilter>,
55}
56
57impl std::fmt::Debug for ClaimCheckService {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("ClaimCheckService")
60            .field("repository", &self.repository.name())
61            .field("operation", &self.operation)
62            .field("filter", &self.filter)
63            .finish()
64    }
65}
66
67impl ClaimCheckService {
68    /// Create a new `ClaimCheckService` without a filter.
69    pub fn new(
70        repository: Arc<dyn ClaimCheckRepository>,
71        operation: ClaimCheckOp,
72        key_expression: KeyExpression,
73    ) -> Self {
74        Self {
75            repository,
76            operation,
77            key_expression,
78            filter: None,
79        }
80    }
81
82    /// Attach a filter for selective merge-back during checkout operations.
83    pub fn with_filter(mut self, filter: ClaimCheckFilter) -> Self {
84        self.filter = Some(filter);
85        self
86    }
87}
88
89/// Merge cached headers from a stashed message into the current message
90/// according to filter rules. Called from the async `call` future with
91/// all data cloned in advance.
92fn merge_stashed(current: &mut Message, stashed: &Message, filter: &ClaimCheckFilter) {
93    match filter.body {
94        FilterAction::Include => current.body = stashed.body.clone(),
95        FilterAction::Exclude => {}
96        FilterAction::Remove => current.body = Body::Empty,
97    }
98
99    match &filter.headers_action {
100        HeadersAction::All(action) => match action {
101            FilterAction::Include => {
102                for (k, v) in &stashed.headers {
103                    current.headers.insert(k.clone(), v.clone());
104                }
105            }
106            FilterAction::Exclude => {}
107            FilterAction::Remove => current.headers.clear(),
108        },
109        HeadersAction::ByPattern {
110            include,
111            exclude,
112            remove,
113        } => {
114            if !include.is_empty() {
115                for (k, v) in &stashed.headers {
116                    if include.iter().any(|p| p.matches(k)) {
117                        current.headers.insert(k.clone(), v.clone());
118                    }
119                }
120            }
121            if !exclude.is_empty() {
122                for (k, v) in &stashed.headers {
123                    if !exclude.iter().any(|p| p.matches(k)) {
124                        current.headers.insert(k.clone(), v.clone());
125                    }
126                }
127            }
128            if !remove.is_empty() {
129                current
130                    .headers
131                    .retain(|k, _| !remove.iter().any(|p| p.matches(k)));
132            }
133        }
134    }
135}
136
137impl Service<Exchange> for ClaimCheckService {
138    type Response = Exchange;
139    type Error = CamelError;
140    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
141
142    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
143        Poll::Ready(Ok(()))
144    }
145
146    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
147        let repository = self.repository.clone();
148        let operation = self.operation.clone();
149        let key_result = (self.key_expression)(&exchange);
150        let filter = self.filter.clone();
151
152        Box::pin(async move {
153            let key = key_result?;
154            match operation {
155                ClaimCheckOp::Set => {
156                    let stashed = exchange.input.clone();
157                    repository.set(&key, stashed).await?;
158                    exchange.input.body = Body::Text(key);
159                    Ok(exchange)
160                }
161                ClaimCheckOp::Get => {
162                    let stashed = repository.get(&key).await?;
163                    if let Some(ref f) = filter {
164                        merge_stashed(&mut exchange.input, &stashed, f);
165                    } else {
166                        exchange.input.body = stashed.body;
167                    }
168                    Ok(exchange)
169                }
170                ClaimCheckOp::GetAndRemove => {
171                    let stashed = repository.get_and_remove(&key).await?;
172                    if let Some(ref f) = filter {
173                        merge_stashed(&mut exchange.input, &stashed, f);
174                    } else {
175                        exchange.input.body = stashed.body;
176                    }
177                    Ok(exchange)
178                }
179                ClaimCheckOp::Push => {
180                    let stashed = exchange.input.clone();
181                    repository.push(&key, stashed).await?;
182                    exchange.input.body = Body::Text(key);
183                    Ok(exchange)
184                }
185                ClaimCheckOp::Pop => {
186                    let stashed = repository.pop(&key).await?;
187                    if let Some(ref f) = filter {
188                        merge_stashed(&mut exchange.input, &stashed, f);
189                    } else {
190                        exchange.input.body = stashed.body;
191                    }
192                    Ok(exchange)
193                }
194            }
195        })
196    }
197}
198
199fn has_regex_metachars(s: &str) -> bool {
200    s.contains(['^', '$', '(', ')', '[', ']', '{', '}', '|', '+', '.', '\\'])
201}
202
203#[derive(Debug, Clone)]
204pub enum HeaderPattern {
205    All,
206    Prefix(String),
207    Exact(String),
208    Regex(regex::Regex),
209}
210
211impl PartialEq for HeaderPattern {
212    fn eq(&self, other: &Self) -> bool {
213        match (self, other) {
214            (Self::All, Self::All) => true,
215            (Self::Prefix(a), Self::Prefix(b)) => a == b,
216            (Self::Exact(a), Self::Exact(b)) => a == b,
217            (Self::Regex(a), Self::Regex(b)) => a.as_str() == b.as_str(),
218            _ => false,
219        }
220    }
221}
222
223impl HeaderPattern {
224    fn compile(pattern: &str) -> Result<Self, FilterParseError> {
225        if pattern == "*" {
226            return Ok(Self::All);
227        }
228        if let Some(prefix) = pattern.strip_suffix('*') {
229            return Ok(Self::Prefix(prefix.to_string()));
230        }
231        if has_regex_metachars(pattern) {
232            match regex::Regex::new(pattern) {
233                Ok(re) => Ok(Self::Regex(re)),
234                Err(_) => Err(FilterParseError::InvalidPattern(pattern.to_string())),
235            }
236        } else {
237            Ok(Self::Exact(pattern.to_string()))
238        }
239    }
240
241    fn matches(&self, header_key: &str) -> bool {
242        match self {
243            Self::All => true,
244            Self::Prefix(prefix) => header_key.starts_with(prefix),
245            Self::Exact(exact) => header_key == exact,
246            Self::Regex(re) => re.is_match(header_key),
247        }
248    }
249}
250
251#[derive(Debug, Clone, PartialEq)]
252pub struct ClaimCheckFilter {
253    pub body: FilterAction,
254    pub headers_action: HeadersAction,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum FilterAction {
259    Include,
260    Exclude,
261    Remove,
262}
263
264#[derive(Debug, Clone, PartialEq)]
265pub enum HeadersAction {
266    All(FilterAction),
267    ByPattern {
268        include: Vec<HeaderPattern>,
269        exclude: Vec<HeaderPattern>,
270        remove: Vec<HeaderPattern>,
271    },
272}
273
274#[derive(Debug)]
275pub enum FilterParseError {
276    InvalidToken(String),
277    InvalidPattern(String),
278    MixedIncludeExclude,
279}
280
281impl std::fmt::Display for FilterParseError {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        match self {
284            Self::InvalidToken(tok) => write!(f, "invalid filter segment '{tok}'"),
285            Self::InvalidPattern(pat) => write!(f, "invalid header pattern '{pat}'"),
286            Self::MixedIncludeExclude => {
287                write!(f, "cannot mix include (+) and exclude (-) header patterns")
288            }
289        }
290    }
291}
292
293impl ClaimCheckFilter {
294    /// Parse a Camel 4.x filter string into structured rules.
295    /// `attachments` tokens are accepted as no-op but do not affect body/headers defaults.
296    pub fn parse(input: &str) -> Result<Self, FilterParseError> {
297        let mut body_action: Option<FilterAction> = None;
298        let mut headers_action: Option<HeadersAction> = None;
299        let mut has_positive_include = false;
300
301        for token in input.split(',') {
302            let token = token.trim();
303            if token.is_empty() {
304                continue;
305            }
306
307            let (prefix, rule) = if let Some(rest) = token.strip_prefix("--") {
308                (FilterAction::Remove, rest)
309            } else if let Some(rest) = token.strip_prefix('-') {
310                (FilterAction::Exclude, rest)
311            } else {
312                let rest = token.strip_prefix('+').unwrap_or(token);
313                (FilterAction::Include, rest)
314            };
315
316            match rule {
317                "body" => {
318                    if prefix == FilterAction::Include {
319                        has_positive_include = true;
320                    }
321                    body_action = Some(prefix);
322                }
323                "headers" | "header" => {
324                    if prefix == FilterAction::Include {
325                        has_positive_include = true;
326                    }
327                    headers_action = Some(HeadersAction::All(prefix));
328                }
329                "attachments" | "attachment" => {
330                    // Accepted as no-op; never affects has_positive_include or defaults
331                }
332                r if r.starts_with("header:") || r.starts_with("headers:") => {
333                    let (_, pattern_str) = r
334                        .split_once(':')
335                        .ok_or_else(|| FilterParseError::InvalidToken(r.to_string()))?;
336                    let pattern = HeaderPattern::compile(pattern_str)?;
337
338                    if prefix == FilterAction::Include {
339                        has_positive_include = true;
340                    }
341
342                    let (mut include, mut exclude, mut remove) = match headers_action.take() {
343                        Some(HeadersAction::ByPattern {
344                            include,
345                            exclude,
346                            remove,
347                        }) => (include, exclude, remove),
348                        _ => (vec![], vec![], vec![]),
349                    };
350                    match prefix {
351                        FilterAction::Include => {
352                            if !exclude.is_empty() {
353                                return Err(FilterParseError::MixedIncludeExclude);
354                            }
355                            include.push(pattern);
356                        }
357                        FilterAction::Exclude => {
358                            if !include.is_empty() {
359                                return Err(FilterParseError::MixedIncludeExclude);
360                            }
361                            exclude.push(pattern);
362                        }
363                        FilterAction::Remove => remove.push(pattern),
364                    }
365                    headers_action = Some(HeadersAction::ByPattern {
366                        include,
367                        exclude,
368                        remove,
369                    });
370                }
371                other => return Err(FilterParseError::InvalidToken(other.to_string())),
372            }
373        }
374
375        let default_if_omitted = if has_positive_include {
376            FilterAction::Exclude
377        } else {
378            FilterAction::Include
379        };
380
381        Ok(ClaimCheckFilter {
382            body: body_action.unwrap_or(default_if_omitted),
383            headers_action: headers_action.unwrap_or(HeadersAction::All(default_if_omitted)),
384        })
385    }
386}
387
388#[cfg(test)]
389#[path = "claim_check_tests.rs"]
390mod tests;