Skip to main content

camel_component_api/
consumer.rs

1use std::sync::atomic::AtomicU64;
2use std::sync::{Arc, OnceLock};
3
4use async_trait::async_trait;
5use tokio::sync::{mpsc, oneshot, watch};
6use tokio::task::JoinHandle;
7use tokio_util::sync::CancellationToken;
8
9use camel_api::security_policy::SecurityPolicy;
10use camel_api::{CamelError, Exchange};
11use camel_auth::CredentialSource;
12
13use crate::dispatch::InlineRouteDispatcher;
14
15/// Re-export of [`camel_api::InFlightClaim`] — the context-global
16/// accepted-not-completed RAII claim (drainclaim). The canonical
17/// definition and contract live in `camel_api::in_flight`; this alias
18/// keeps the historical `camel_component_api::consumer::InFlightClaim`
19/// path stable. claimfamily: the type moved to camel-api so
20/// [`camel_api::Exchange`] can carry it for stash-site residency.
21pub use camel_api::InFlightClaim;
22
23/// A message sent from a consumer to the route pipeline.
24///
25/// Fire-and-forget exchanges use `reply_tx = None`.
26/// Request-reply exchanges (e.g. `direct:`) provide a `reply_tx` so the
27/// pipeline result can be sent back to the consumer.
28pub struct ExchangeEnvelope {
29    pub exchange: Exchange,
30    pub reply_tx: Option<oneshot::Sender<Result<Exchange, CamelError>>>,
31    /// RAII claim on the context-global accepted-not-completed counter,
32    /// attached at acceptance boundaries (`ConsumerContext::send` /
33    /// `send_and_wait`, seda enqueue, inline dispatch). `None` on the raw
34    /// `sender()` fast path — a documented uncounted exception (drainclaim).
35    pub in_flight_claim: Option<InFlightClaim>,
36}
37
38/// Declares when the runtime may consider a Consumer "started".
39///
40/// `Immediate` (default) preserves the classic fire-and-forget semantics:
41/// `spawn_consumer_task` returns as soon as the consumer task is spawned,
42/// matching the behaviour of timer, file, direct and similar polling
43/// consumers whose `start()` IS the lifetime loop.
44///
45/// `Explicit` is for resource-binding consumers (HTTP, WebSocket, …) whose
46/// `start()` returns control only after the resource (e.g. `TcpListener`)
47/// is bound and ready. The consumer MUST call `ConsumerContext::mark_ready`
48/// after a successful bind so the runtime can await readiness and propagate
49/// pre-ready `start()` errors as proper startup failures.
50///
51/// Adding this as a default-returning trait method keeps every existing
52/// `Consumer` impl backward compatible.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54#[non_exhaustive]
55pub enum ConsumerStartupMode {
56    /// Consumer's `start()` IS the lifetime loop. The runtime treats the
57    /// consumer as ready the moment `start()` is invoked. (Default)
58    #[default]
59    Immediate,
60    /// Consumer binds/registers a resource inside `start()` and signals
61    /// readiness explicitly via `ConsumerContext::mark_ready()`.
62    Explicit,
63}
64
65/// Internal state of a [`StartupSignal`].
66#[derive(Clone, Debug)]
67enum StartupState {
68    /// Consumer has not yet signalled readiness or failure.
69    Pending,
70    /// Consumer signalled readiness via `mark_ready()`.
71    Ready,
72    /// Consumer's `start()` returned an `Err` before signalling readiness.
73    Failed(String),
74}
75
76/// Shared handle used by a Consumer to signal readiness (or failure) to the
77/// runtime's [`StartupReceiver`].
78///
79/// Constructed in a pair via [`StartupSignal::pair`]. The signal is held by
80/// the consumer side (via [`ConsumerContext`]); the receiver is returned to
81/// the route controller.
82#[derive(Clone)]
83pub struct StartupSignal {
84    tx: watch::Sender<StartupState>,
85}
86
87impl StartupSignal {
88    /// Create a `(signal, receiver)` pair seeded in the `Pending` state.
89    pub fn pair() -> (Self, StartupReceiver) {
90        let (tx, rx) = watch::channel(StartupState::Pending);
91        (Self { tx }, StartupReceiver { rx })
92    }
93
94    /// Mark the consumer as ready. Idempotent — subsequent calls are no-ops
95    /// once the state has transitioned out of `Pending`.
96    ///
97    /// Returns `true` if this call transitioned `Pending → Ready`, `false`
98    /// if the state was already `Ready` or `Failed`. The runtime uses the
99    /// return value to detect Explicit consumers that returned `Ok` from
100    /// `start()` without calling `mark_ready` (a contract violation that
101    /// would hang the controller without the defensive fallback in
102    /// `spawn_consumer_task`).
103    pub fn mark_ready(&self) -> bool {
104        self.tx.send_if_modified(|s| {
105            if matches!(*s, StartupState::Pending) {
106                *s = StartupState::Ready;
107                true
108            } else {
109                false
110            }
111        })
112    }
113
114    /// Mark the consumer's startup as failed with `err`. Idempotent.
115    pub fn mark_failed(&self, err: String) {
116        self.tx.send_if_modified(|s| {
117            if matches!(*s, StartupState::Pending) {
118                *s = StartupState::Failed(err);
119                true
120            } else {
121                false
122            }
123        });
124    }
125}
126
127impl std::fmt::Debug for StartupSignal {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("StartupSignal")
130            .field("state", &self.tx.borrow())
131            .finish()
132    }
133}
134
135/// Receiver half of the consumer startup handshake. Resolves once the
136/// consumer calls [`ConsumerContext::mark_ready`] (Ok) or its `start()`
137/// returns an `Err` first (Err).
138///
139/// For [`ConsumerStartupMode::Immediate`] consumers the receiver is
140/// pre-resolved at construction time (see [`StartupReceiver::immediate`]).
141pub struct StartupReceiver {
142    rx: watch::Receiver<StartupState>,
143}
144
145impl StartupReceiver {
146    /// Construct a receiver that is already resolved as `Ok`. Used for
147    /// [`ConsumerStartupMode::Immediate`] consumers so the controller can
148    /// uniformly `await` every receiver without changing behaviour.
149    pub fn immediate() -> Self {
150        let (tx, rx) = watch::channel(StartupState::Ready);
151        // Drop the sender — state is fixed at Ready. Receiver will never
152        // observe a closure error since it already holds Ready.
153        let _ = tx;
154        Self { rx }
155    }
156
157    /// Wait for the consumer to become ready or fail. Resolves:
158    /// - `Ok(())` if the consumer signalled readiness.
159    /// - `Err(CamelError::RouteError(_))` if the consumer's `start()`
160    ///   returned an error before signalling readiness.
161    /// - `Err(CamelError::RouteError(_))` if the signal sender was dropped
162    ///   without either transition (programming-contract violation).
163    pub async fn await_ready(mut self) -> Result<(), CamelError> {
164        loop {
165            match &*self.rx.borrow() {
166                StartupState::Pending => {}
167                StartupState::Ready => return Ok(()),
168                StartupState::Failed(msg) => {
169                    return Err(CamelError::RouteError(msg.clone()));
170                }
171            }
172            if self.rx.changed().await.is_err() {
173                return Err(CamelError::RouteError(
174                    "consumer startup signal dropped without resolving".to_string(),
175                ));
176            }
177        }
178    }
179}
180
181impl std::fmt::Debug for StartupReceiver {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.debug_struct("StartupReceiver")
184            .field("state", &self.rx.borrow())
185            .finish()
186    }
187}
188
189/// Context provided to a Consumer, allowing it to send exchanges into the route.
190#[derive(Clone)]
191pub struct ConsumerContext {
192    sender: mpsc::Sender<ExchangeEnvelope>,
193    cancel_token: CancellationToken,
194    route_id: String,
195    startup: StartupSignal,
196    /// Inline dispatch capability slot, shared by all clones of this
197    /// context (`Arc<OnceLock<_>>`). Set once by the camel-core runtime
198    /// before the consumer starts; every clone observes the same value.
199    inline_dispatcher: Arc<OnceLock<Arc<dyn InlineRouteDispatcher>>>,
200    /// Context-global accepted-not-completed counter, installed by the
201    /// camel-core runtime at consumer start (drainclaim). `None` keeps
202    /// the context's sends uncounted (tests, raw fast paths).
203    in_flight: Option<Arc<AtomicU64>>,
204}
205
206impl ConsumerContext {
207    /// Create a new consumer context wrapping the given channel sender.
208    ///
209    /// The `route_id` identifies the route this consumer is bound to, enabling
210    /// ADR-0012 per-route metrics and health observations.
211    ///
212    /// The startup signal defaults to a fresh `Pending` pair; the consumer
213    /// can call [`Self::mark_ready`] once it has bound its resource. For
214    /// [`ConsumerStartupMode::Immediate`] consumers the runtime ignores
215    /// the signal (it constructs an already-resolved receiver instead).
216    pub fn new(
217        sender: mpsc::Sender<ExchangeEnvelope>,
218        cancel_token: CancellationToken,
219        route_id: String,
220    ) -> Self {
221        let (startup, _unused_receiver) = StartupSignal::pair();
222        // The receiver is dropped here: `spawn_consumer_task` constructs its
223        // own `(signal, receiver)` pair and replaces this one via
224        // `with_startup` so the controller holds the matching receiver.
225        let _ = _unused_receiver;
226        Self {
227            sender,
228            cancel_token,
229            route_id,
230            startup,
231            inline_dispatcher: Arc::new(OnceLock::new()),
232            in_flight: None,
233        }
234    }
235
236    /// Replace the startup signal carried by this context. Used by
237    /// `spawn_consumer_task` to install the signal whose matching receiver
238    /// is returned to the route controller.
239    pub fn with_startup(mut self, startup: StartupSignal) -> Self {
240        self.startup = startup;
241        self
242    }
243
244    /// Install the context-global accepted-not-completed counter on this
245    /// context (drainclaim). The camel-core runtime calls this at consumer
246    /// start; afterwards every envelope sent via [`Self::send`] or
247    /// [`Self::send_and_wait`] carries an [`InFlightClaim`]. Contexts
248    /// without a counter (tests, raw fast paths) send uncounted envelopes.
249    pub fn with_in_flight_counter(mut self, counter: Arc<AtomicU64>) -> Self {
250        self.in_flight = Some(counter);
251        self
252    }
253
254    /// Returns the installed accepted-not-completed counter, if any
255    /// (drainclaim). Components whose consumers dispatch through the raw
256    /// [`Self::sender`] fast path capture this once at consumer start and
257    /// mint an [`InFlightClaim`] at their acceptance-dequeue points, so raw
258    /// dispatches count exactly like [`Self::send`] (rc-nftni). Returns
259    /// `None` for contexts without a counter (tests) — callers mint none.
260    pub fn in_flight_counter(&self) -> Option<Arc<AtomicU64>> {
261        self.in_flight.clone()
262    }
263
264    /// Returns a clone of the internal [`StartupSignal`] so callers (e.g.
265    /// `spawn_consumer_task`) can drive failure propagation independently of
266    /// the consumer's own `mark_ready()` call.
267    pub fn startup_signal(&self) -> StartupSignal {
268        self.startup.clone()
269    }
270
271    /// Mark this consumer's startup as complete. Only meaningful for
272    /// [`ConsumerStartupMode::Explicit`] consumers — `Immediate` consumers
273    /// never need to call this because the runtime resolves their startup
274    /// receiver at construction time.
275    ///
276    /// Idempotent.
277    pub fn mark_ready(&self) {
278        let _ = self.startup.mark_ready();
279    }
280
281    /// Mark this consumer's startup as FAILED with `err`. Only meaningful for
282    /// [`ConsumerStartupMode::Explicit`] consumers whose readiness is gated on
283    /// an asynchronous event that may never arrive (e.g. Kafka partition
284    /// assignment). Calling this resolves the runtime's startup await with a
285    /// startup error instead of hanging.
286    ///
287    /// Idempotent — the first transition out of `Pending` wins, so a later
288    /// `mark_ready()` cannot override an earlier `mark_failed()` and vice
289    /// versa.
290    pub fn mark_failed(&self, err: String) {
291        self.startup.mark_failed(err);
292    }
293
294    /// Returns a future that resolves when shutdown is requested.
295    /// Use in `tokio::select!` inside consumer loops.
296    pub async fn cancelled(&self) {
297        self.cancel_token.cancelled().await
298    }
299
300    /// Returns true if shutdown has been requested.
301    pub fn is_cancelled(&self) -> bool {
302        self.cancel_token.is_cancelled()
303    }
304
305    /// Returns the route_id this consumer is bound to.
306    ///
307    /// Available for ADR-0012 metrics/health calls that require a route_id
308    /// (categories (b′), (e), (g)). Set at construction time by the route
309    /// controller when spawning the consumer task.
310    pub fn route_id(&self) -> &str {
311        &self.route_id
312    }
313
314    /// Returns a clone of the `CancellationToken`.
315    ///
316    /// Useful for consumers that spawn per-request tasks and need to propagate
317    /// shutdown to each task. See `HttpConsumer` for an example.
318    pub fn cancel_token(&self) -> CancellationToken {
319        self.cancel_token.clone()
320    }
321
322    /// Returns a clone of the channel sender for manual exchange submission.
323    ///
324    /// Useful for consumers that spawn per-request tasks (e.g., `HttpConsumer`)
325    /// where each task independently sends exchanges into the pipeline.
326    /// For simple consumers, prefer `send()` or `send_and_wait()` instead.
327    ///
328    /// Raw pushes through this sender bypass the claim minting of
329    /// [`Self::send`]; components using it MUST capture
330    /// [`Self::in_flight_counter`] once at start and attach a claim to every
331    /// envelope at their acceptance-dequeue point (rc-nftni), or their
332    /// in-flight work stays invisible to `total_in_flight()`.
333    pub fn sender(&self) -> mpsc::Sender<ExchangeEnvelope> {
334        self.sender.clone()
335    }
336
337    /// Publish the inline dispatch capability on this context.
338    ///
339    /// Set once by the camel-core runtime before the consumer starts, when
340    /// the route's effective concurrency model permits the inline fast path.
341    ///
342    /// Set-once, KEEP-FIRST contract: if a dispatcher was already published,
343    /// the first one stays in place, the second call is ignored with a
344    /// warning (it is not an error). All clones of this context share the
345    /// same slot, so a dispatcher set through any clone is visible through
346    /// all of them.
347    pub fn set_inline_dispatcher(&self, dispatcher: Arc<dyn InlineRouteDispatcher>) {
348        if self.inline_dispatcher.set(dispatcher).is_err() {
349            tracing::warn!("inline dispatcher already set; ignoring second set");
350        }
351    }
352
353    /// Returns the inline dispatch capability published by the camel-core
354    /// runtime, if any. See [`Self::set_inline_dispatcher`] for the
355    /// set-once-keep-first contract.
356    pub fn inline_dispatcher(&self) -> Option<Arc<dyn InlineRouteDispatcher>> {
357        self.inline_dispatcher.get().cloned()
358    }
359
360    /// Send an exchange into the route pipeline (fire-and-forget).
361    ///
362    /// Attaches an [`InFlightClaim`] when a counter is installed
363    /// ([`Self::with_in_flight_counter`]); a failed push drops the
364    /// envelope, which drops the claim and rolls the count back.
365    pub async fn send(&self, exchange: Exchange) -> Result<(), CamelError> {
366        self.sender
367            .send(ExchangeEnvelope {
368                exchange,
369                reply_tx: None,
370                in_flight_claim: self.in_flight.as_ref().map(InFlightClaim::attach),
371            })
372            .await
373            .map_err(|_| CamelError::ChannelClosed)
374    }
375
376    /// Send an exchange and wait for the pipeline result (request-reply).
377    ///
378    /// Returns `Ok(exchange)` on success or `Err(e)` if the pipeline failed
379    /// without an error handler absorbing the error.
380    pub async fn send_and_wait(&self, exchange: Exchange) -> Result<Exchange, CamelError> {
381        let (reply_tx, reply_rx) = oneshot::channel();
382        self.sender
383            .send(ExchangeEnvelope {
384                exchange,
385                reply_tx: Some(reply_tx),
386                in_flight_claim: self.in_flight.as_ref().map(InFlightClaim::attach),
387            })
388            .await
389            .map_err(|_| CamelError::ChannelClosed)?;
390        reply_rx.await.map_err(|_| CamelError::ChannelClosed)?
391    }
392}
393
394/// Security context passed to a consumer before `start()`.
395///
396/// Carries the route's security classification so consumers can register
397/// auth state before accepting connections. Declared routes carry the
398/// `SecurityPolicy` from the route controller; plan-only contexts (routes
399/// without a policy declaration) carry just the compiled
400/// [`RouteSecurityPlan`](camel_api::security_policy::RouteSecurityPlan) (`policy = None`). Authentication itself runs
401/// through the kernel fields (`plan` + `providers`).
402pub struct SecurityContext {
403    /// Route policy, when the route declared one. `None` for plan-only
404    /// contexts built via [`SecurityContext::from_plan`].
405    pub policy: Option<Arc<dyn SecurityPolicy>>,
406    pub credential_sources: Vec<CredentialSource>,
407    /// Compiled `RouteSecurityPlan` for the route, when one has been compiled.
408    ///
409    /// Phase-2 transports read the plan from here to drive per-route dispatch
410    /// enforcement. `None` until Task 1.8 compiles plans into the context.
411    pub plan: Option<camel_api::security_policy::RouteSecurityPlan>,
412    /// Provider registry carrying the route's named authenticators.
413    ///
414    /// Phase-2 transports read providers from here (grpc 2.1, mcp 2.6, ws 2.8,
415    /// http 2.9) instead of holding their own authenticator. `None` for routes
416    /// built before the registry injection path lands.
417    pub providers: Option<std::sync::Arc<camel_auth::ProviderRegistry>>,
418}
419
420impl SecurityContext {
421    pub fn new(policy: impl SecurityPolicy + 'static) -> Self {
422        Self {
423            policy: Some(Arc::new(policy)),
424            credential_sources: vec![CredentialSource::AuthorizationHeader],
425            plan: None,
426            providers: None,
427        }
428    }
429
430    pub fn from_arc(policy: Arc<dyn SecurityPolicy>) -> Self {
431        Self {
432            policy: Some(policy),
433            credential_sources: vec![CredentialSource::AuthorizationHeader],
434            plan: None,
435            providers: None,
436        }
437    }
438
439    /// Build a plan-only context: no policy, no providers, empty credential
440    /// sources. The controller delivers a route's compiled classification
441    /// (e.g. the `Public` default for an undeclared server route) through
442    /// this form.
443    pub fn from_plan(plan: camel_api::security_policy::RouteSecurityPlan) -> Self {
444        Self {
445            policy: None,
446            credential_sources: Vec::new(),
447            plan: Some(plan),
448            providers: None,
449        }
450    }
451
452    pub fn with_credential_sources(mut self, sources: Vec<CredentialSource>) -> Self {
453        self.credential_sources = sources;
454        self
455    }
456
457    /// Attach the compiled `RouteSecurityPlan` for this route.
458    pub fn with_plan(mut self, plan: camel_api::security_policy::RouteSecurityPlan) -> Self {
459        self.plan = Some(plan);
460        self
461    }
462
463    /// Attach the provider registry carrying this route's named authenticators.
464    pub fn with_providers(
465        mut self,
466        providers: std::sync::Arc<camel_auth::ProviderRegistry>,
467    ) -> Self {
468        self.providers = Some(providers);
469        self
470    }
471}
472
473impl Clone for SecurityContext {
474    fn clone(&self) -> Self {
475        Self {
476            policy: self.policy.clone(),
477            credential_sources: self.credential_sources.clone(),
478            plan: self.plan.clone(),
479            providers: self.providers.as_ref().map(Arc::clone),
480        }
481    }
482}
483
484impl std::fmt::Debug for SecurityContext {
485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        f.debug_struct("SecurityContext")
487            .field("policy", &self.policy.as_ref().map(|_| "<SecurityPolicy>"))
488            .field("credential_sources", &self.credential_sources)
489            .field("plan", &self.plan)
490            .field(
491                "providers",
492                &self.providers.as_ref().map(|_| "<ProviderRegistry>"),
493            )
494            .finish()
495    }
496}
497
498/// How a consumer's exchanges should be processed by the pipeline.
499#[derive(Debug, Clone, PartialEq, Eq)]
500#[non_exhaustive]
501pub enum ConcurrencyModel {
502    /// Exchanges are processed one at a time, in order. Default for polling
503    /// consumers (timer, file) and synchronous consumers (direct).
504    Sequential,
505    /// Exchanges are processed concurrently via `tokio::spawn`. Optional
506    /// semaphore limit (`max`). `None` means unbounded (channel buffer is
507    /// the only backpressure).
508    Concurrent { max: Option<usize> },
509}
510
511/// A Consumer receives data from an external system and submits Exchanges
512/// to the Route's Pipeline via the [`ConsumerContext`].
513///
514/// # Shutdown Contract
515///
516/// The Runtime guarantees the following lifecycle:
517///
518/// 1. `start()` is called once. The Runtime spawns a task that owns the Consumer.
519/// 2. On route stop, the Runtime cancels the [`ConsumerContext`] cancel token.
520/// 3. The spawned task calls `stop()` on ALL exit paths after `start()` succeeds
521///    (clean exit, crash, cancellation, natural completion).
522/// 4. `background_task_handle()` is a supervision hook for crash propagation (ADR-0007),
523///    NOT the shutdown API.
524///
525/// Component authors MUST ensure:
526///
527/// - `stop()` cancels all component-owned inner tasks and cleans up registrations/resources.
528/// - If inner tasks use a private `CancellationToken`, `stop()` MUST cancel it.
529/// - Best practice: inner tasks should use the [`ConsumerContext`] cancel token (or a child)
530///   so they respond to runtime shutdown without waiting for `stop()`.
531/// - If using a private token, `stop()` must cancel it to ensure prompt cleanup.
532/// - `background_task_handle()` returns the `JoinHandle` of the primary background task,
533///   if any. The Runtime monitors this handle for unexpected exits (crash propagation).
534#[async_trait]
535pub trait Consumer: Send + Sync {
536    /// Start consuming messages, sending them through the provided context.
537    async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError>;
538
539    /// Stop consuming messages and clean up all resources.
540    ///
541    /// Called by the Runtime on every exit path after `start()` succeeds.
542    /// See the [Shutdown Contract](#shutdown-contract) above.
543    async fn stop(&mut self) -> Result<(), CamelError>;
544
545    /// Temporarily suspend consuming messages without fully stopping.
546    ///
547    /// Default: no-op, returns `Ok(())`.
548    async fn suspend(&self) -> Result<(), CamelError> {
549        Ok(())
550    }
551
552    /// Resume consuming after a previous suspension.
553    ///
554    /// Default: no-op, returns `Ok(())`.
555    async fn resume(&self) -> Result<(), CamelError> {
556        Ok(())
557    }
558
559    /// Declares this consumer's natural concurrency model.
560    ///
561    /// The runtime uses this to decide whether to process exchanges
562    /// sequentially or spawn per-exchange. Consumers that accept inbound
563    /// connections (HTTP, WebSocket, Kafka) should override this to return
564    /// `ConcurrencyModel::Concurrent`.
565    ///
566    /// Default: `Sequential`.
567    fn concurrency_model(&self) -> ConcurrencyModel {
568        ConcurrencyModel::Sequential
569    }
570
571    /// Declares how the runtime should wait for this consumer's startup.
572    ///
573    /// - [`ConsumerStartupMode::Immediate`] (default): the consumer's
574    ///   `start()` IS the lifetime loop. The runtime treats the route as
575    ///   started as soon as `start()` is invoked, preserving the existing
576    ///   fire-and-forget semantics for timer/file/direct/… consumers.
577    /// - [`ConsumerStartupMode::Explicit`]: the consumer binds/registers a
578    ///   resource inside `start()` and MUST call
579    ///   [`ConsumerContext::mark_ready`] after a successful bind. The
580    ///   runtime awaits this signal (or an early `start()` error) before
581    ///   treating the route as started, so HTTP/WebSocket listeners fail
582    ///   fast when the bind fails instead of crashing the background task.
583    ///
584    /// Default: `Immediate`.
585    fn startup_mode(&self) -> ConsumerStartupMode {
586        ConsumerStartupMode::Immediate
587    }
588
589    /// Return a handle to the consumer's long-running background task so the
590    /// runtime can monitor it for unexpected exits after `start()` returns `Ok`.
591    ///
592    /// Default: `None` — consumer's work completes entirely within `start()`.
593    /// Override: return `Some(handle)` if `start()` spawns a detached task.
594    ///
595    /// **Contract:** the task must observe `ConsumerContext::cancelled()` so
596    /// runtime shutdown is distinguishable from crash exits.
597    ///
598    /// This method is called at most once; implementations should use `.take()`
599    /// to transfer ownership of the handle.
600    fn background_task_handle(&mut self) -> Option<JoinHandle<Result<(), CamelError>>> {
601        None
602    }
603
604    /// Set the security context for this consumer.
605    ///
606    /// Called by the route controller before `start()` so the consumer
607    /// can register auth state (e.g. WebSocket auth in `WsAppState`).
608    ///
609    /// Default: no-op, returns `Ok(())`.
610    fn set_security_context(&mut self, _ctx: SecurityContext) {}
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn consumer_context_exposes_route_id() {
619        let (tx, _rx) = mpsc::channel(1);
620        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "test-route".to_string());
621        assert_eq!(ctx.route_id(), "test-route");
622    }
623
624    #[tokio::test]
625    async fn test_consumer_context_cancelled() {
626        let (tx, _rx) = mpsc::channel(16);
627        let token = CancellationToken::new();
628        let ctx = ConsumerContext::new(tx, token.clone(), "test-route".to_string());
629
630        assert!(!ctx.is_cancelled());
631        token.cancel();
632        ctx.cancelled().await;
633        assert!(ctx.is_cancelled());
634    }
635
636    #[test]
637    fn test_concurrency_model_default_is_sequential() {
638        use super::ConcurrencyModel;
639
640        struct DummyConsumer;
641
642        #[async_trait::async_trait]
643        impl super::Consumer for DummyConsumer {
644            async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
645                Ok(())
646            }
647            async fn stop(&mut self) -> Result<(), CamelError> {
648                Ok(())
649            }
650        }
651
652        let consumer = DummyConsumer;
653        assert_eq!(consumer.concurrency_model(), ConcurrencyModel::Sequential);
654    }
655
656    #[test]
657    fn test_concurrency_model_concurrent_override() {
658        use super::ConcurrencyModel;
659
660        struct ConcurrentConsumer;
661
662        #[async_trait::async_trait]
663        impl super::Consumer for ConcurrentConsumer {
664            async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
665                Ok(())
666            }
667            async fn stop(&mut self) -> Result<(), CamelError> {
668                Ok(())
669            }
670            fn concurrency_model(&self) -> ConcurrencyModel {
671                ConcurrencyModel::Concurrent { max: Some(16) }
672            }
673        }
674
675        let consumer = ConcurrentConsumer;
676        assert_eq!(
677            consumer.concurrency_model(),
678            ConcurrencyModel::Concurrent { max: Some(16) }
679        );
680    }
681
682    // --- ConsumerStartupMode tests ---
683
684    #[test]
685    fn test_default_startup_mode_is_immediate() {
686        struct DummyConsumer;
687
688        #[async_trait::async_trait]
689        impl super::Consumer for DummyConsumer {
690            async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
691                Ok(())
692            }
693            async fn stop(&mut self) -> Result<(), CamelError> {
694                Ok(())
695            }
696        }
697
698        let consumer = DummyConsumer;
699        assert_eq!(
700            consumer.startup_mode(),
701            super::ConsumerStartupMode::Immediate
702        );
703    }
704
705    #[test]
706    fn test_startup_mode_explicit_override() {
707        struct ExplicitConsumer;
708
709        #[async_trait::async_trait]
710        impl super::Consumer for ExplicitConsumer {
711            async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
712                Ok(())
713            }
714            async fn stop(&mut self) -> Result<(), CamelError> {
715                Ok(())
716            }
717            fn startup_mode(&self) -> super::ConsumerStartupMode {
718                super::ConsumerStartupMode::Explicit
719            }
720        }
721
722        let consumer = ExplicitConsumer;
723        assert_eq!(
724            consumer.startup_mode(),
725            super::ConsumerStartupMode::Explicit
726        );
727    }
728
729    #[tokio::test]
730    async fn test_startup_signal_mark_ready_resolves_receiver_ok() {
731        let (signal, receiver) = StartupSignal::pair();
732        // Not yet signalled — receiver should still be pending.
733        assert!(matches!(*receiver.rx.borrow(), StartupState::Pending));
734
735        // Mark ready — receiver must observe Ok.
736        signal.mark_ready();
737        let result = receiver.await_ready().await;
738        assert!(result.is_ok(), "expected Ok after mark_ready");
739    }
740
741    #[tokio::test]
742    async fn test_startup_signal_mark_failed_propagates_error() {
743        let (signal, receiver) = StartupSignal::pair();
744        signal.mark_failed("bind failed".to_string());
745        let err = receiver
746            .await_ready()
747            .await
748            .expect_err("expected Err after mark_failed");
749        match err {
750            CamelError::RouteError(msg) => assert!(msg.contains("bind failed")),
751            other => panic!("expected RouteError, got {other:?}"),
752        }
753    }
754
755    #[tokio::test]
756    async fn test_startup_signal_idempotent_first_wins() {
757        let (signal, receiver) = StartupSignal::pair();
758        signal.mark_ready();
759        // mark_failed after mark_ready must NOT override.
760        signal.mark_failed("late failure".to_string());
761        let result = receiver.await_ready().await;
762        assert!(result.is_ok(), "first transition (Ready) wins");
763    }
764
765    #[tokio::test]
766    async fn test_startup_receiver_immediate_is_pre_resolved_ok() {
767        let receiver = StartupReceiver::immediate();
768        let result = receiver.await_ready().await;
769        assert!(result.is_ok(), "immediate receiver must resolve Ok");
770    }
771
772    #[tokio::test]
773    async fn test_consumer_context_mark_ready_drives_signal() {
774        let (tx, _rx) = mpsc::channel(1);
775        let ctx = ConsumerContext::new(
776            tx,
777            CancellationToken::new(),
778            "startup-test-route".to_string(),
779        );
780        let (signal, receiver) = StartupSignal::pair();
781        let ctx = ctx.with_startup(signal);
782        ctx.mark_ready();
783        let result = receiver.await_ready().await;
784        assert!(result.is_ok(), "ctx.mark_ready must resolve the receiver");
785    }
786
787    #[tokio::test]
788    async fn test_consumer_context_mark_failed_drives_signal() {
789        let (tx, _rx) = mpsc::channel(1);
790        let ctx = ConsumerContext::new(
791            tx,
792            CancellationToken::new(),
793            "startup-fail-route".to_string(),
794        );
795        let (signal, receiver) = StartupSignal::pair();
796        let ctx = ctx.with_startup(signal);
797        ctx.mark_failed("assignment window elapsed".to_string());
798        let err = receiver
799            .await_ready()
800            .await
801            .expect_err("ctx.mark_failed must resolve the receiver as Err");
802        match err {
803            CamelError::RouteError(msg) => assert!(msg.contains("assignment window elapsed")),
804            other => panic!("expected RouteError, got {other:?}"),
805        }
806    }
807
808    #[tokio::test]
809    async fn test_startup_receiver_dropped_sender_returns_err() {
810        // Build a signal/receiver pair and drop the signal without ever
811        // transitioning — receiver must surface a contract-violation error.
812        let (_signal, receiver) = StartupSignal::pair();
813        drop(_signal);
814        let err = receiver
815            .await_ready()
816            .await
817            .expect_err("dropped signal must surface as Err");
818        match err {
819            CamelError::RouteError(msg) => assert!(msg.contains("dropped")),
820            other => panic!("expected RouteError, got {other:?}"),
821        }
822    }
823
824    #[tokio::test]
825    async fn test_consumer_default_suspend_resume() {
826        struct DummyConsumer;
827
828        #[async_trait::async_trait]
829        impl super::Consumer for DummyConsumer {
830            async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
831                Ok(())
832            }
833            async fn stop(&mut self) -> Result<(), CamelError> {
834                Ok(())
835            }
836        }
837
838        let consumer = DummyConsumer;
839        assert!(consumer.suspend().await.is_ok());
840        assert!(consumer.resume().await.is_ok());
841    }
842
843    // --- SecurityContext tests ---
844
845    struct StubPolicy;
846
847    #[async_trait::async_trait]
848    impl SecurityPolicy for StubPolicy {
849        async fn evaluate(
850            &self,
851            _exchange: &mut Exchange,
852            _auth: &camel_api::security_policy::AuthContext<'_>,
853        ) -> Result<camel_api::security_policy::AuthorizationDecision, CamelError> {
854            Ok(camel_api::security_policy::AuthorizationDecision::Granted {
855                principal: camel_api::security_policy::Principal {
856                    subject: "stub".into(),
857                    issuer: "stub".into(),
858                    audience: vec![],
859                    scopes: vec![],
860                    roles: vec![],
861                    claims: serde_json::json!({}),
862                },
863            })
864        }
865    }
866
867    struct StubAuthenticator;
868
869    #[async_trait::async_trait]
870    impl camel_auth::TokenAuthenticator for StubAuthenticator {
871        async fn authenticate_bearer(
872            &self,
873            _token: &str,
874        ) -> Result<camel_api::security_policy::Principal, CamelError> {
875            Ok(camel_api::security_policy::Principal {
876                subject: "stub".into(),
877                issuer: "stub".into(),
878                audience: vec![],
879                scopes: vec![],
880                roles: vec![],
881                claims: serde_json::json!({}),
882            })
883        }
884    }
885
886    #[test]
887    fn test_security_context_new() {
888        let ctx = SecurityContext::new(StubPolicy);
889        assert!(Arc::strong_count(ctx.policy.as_ref().expect("new sets policy")) == 1);
890        assert_eq!(
891            ctx.credential_sources,
892            vec![camel_auth::CredentialSource::AuthorizationHeader]
893        );
894    }
895
896    #[test]
897    fn test_security_context_from_arc() {
898        let policy: Arc<dyn SecurityPolicy> = Arc::new(StubPolicy);
899        let ctx = SecurityContext::from_arc(Arc::clone(&policy));
900        assert!(Arc::ptr_eq(
901            ctx.policy.as_ref().expect("from_arc sets policy"),
902            &policy
903        ));
904        assert_eq!(
905            ctx.credential_sources,
906            vec![camel_auth::CredentialSource::AuthorizationHeader]
907        );
908    }
909
910    #[test]
911    fn test_security_context_clone_independent() {
912        let ctx = SecurityContext::new(StubPolicy);
913        let cloned = ctx.clone();
914        assert!(Arc::ptr_eq(
915            ctx.policy.as_ref().expect("source policy"),
916            cloned.policy.as_ref().expect("cloned policy"),
917        ));
918        assert_eq!(ctx.credential_sources, cloned.credential_sources);
919    }
920
921    #[test]
922    fn test_security_context_debug_redacts_traits() {
923        let ctx = SecurityContext::new(StubPolicy);
924        let debug_str = format!("{ctx:?}");
925        assert!(debug_str.contains("<SecurityPolicy>"));
926        assert!(debug_str.contains("credential_sources"));
927    }
928
929    #[test]
930    fn test_security_context_with_credential_sources() {
931        let ctx = SecurityContext::new(StubPolicy).with_credential_sources(vec![
932            camel_auth::CredentialSource::Cookie {
933                name: "session".into(),
934            },
935            camel_auth::CredentialSource::AuthorizationHeader,
936        ]);
937        assert_eq!(ctx.credential_sources.len(), 2);
938        assert!(matches!(
939            &ctx.credential_sources[0],
940            camel_auth::CredentialSource::Cookie { .. }
941        ));
942    }
943
944    #[test]
945    fn security_context_holds_plan_and_providers() {
946        use camel_api::security_policy::{AccessMode, RouteSecurityPlan, TransportId};
947
948        let plan = RouteSecurityPlan {
949            access_mode: AccessMode::Public,
950            provider_ref: None,
951            transport: TransportId::Http,
952            credential_sources: vec![],
953            audience_binding: None,
954        };
955
956        let registry = camel_auth::ProviderRegistry::new();
957        registry.register(
958            "default",
959            camel_auth::ProviderEntry {
960                authenticator: Arc::new(StubAuthenticator),
961                audience_binding: None,
962            },
963        );
964
965        let ctx = SecurityContext::new(StubPolicy)
966            .with_plan(plan)
967            .with_providers(Arc::new(registry));
968
969        let ctx_plan = ctx.plan.as_ref().expect("plan must be attached");
970        assert!(matches!(ctx_plan.access_mode, AccessMode::Public));
971        assert_eq!(ctx_plan.transport, TransportId::Http);
972
973        let providers = ctx.providers.as_ref().expect("providers must be attached");
974        let resolved = providers.resolve("default").expect("provider must resolve");
975        assert!(resolved.audience_binding.is_none());
976    }
977
978    /// `finish-auth-flip` Task 1.3: the `SecurityContext.authenticator`
979    /// hand-off is deleted — transports authenticate through the kernel
980    /// fields (`plan` + `providers`). Registry/doc mentions of the word
981    /// `authenticator` (e.g. `ProviderEntry`) are allowed; the struct
982    /// field is not.
983    #[test]
984    fn security_context_authenticator_deleted_source_scan() {
985        let src = include_str!("consumer.rs");
986        // Assembled at runtime so this scan's own text cannot match.
987        let needle = format!("pub {}authenticator", "");
988        assert!(
989            !src.contains(&needle),
990            "SecurityContext.authenticator must stay deleted: field found in consumer.rs"
991        );
992    }
993}
994
995#[cfg(test)]
996#[path = "consumer_claim_tests.rs"]
997mod consumer_claim_tests;