Skip to main content

camel_component_api/
consumer.rs

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