a2a_rs/port/interceptor.rs
1//! The `CallInterceptor` port: before/after middleware around A2A calls.
2//!
3//! An interceptor is a cross-cutting hook that runs *around* every A2A call —
4//! the chain-of-responsibility analogue of the official SDK's `CallInterceptor`.
5//! It is a **port** (a capability the application needs from the edge), so the
6//! trait lives here and concrete interceptors (logging, metrics, auth-token
7//! injection) are adapters. The same trait is wired into both the client
8//! transport ([`JsonRpcClient`](crate::adapter::JsonRpcClient)) and the server
9//! transport ([`JsonRpcAdapter`](crate::adapter::JsonRpcAdapter)); the
10//! [`CallContext::side`] tells an interceptor which direction it is observing.
11//!
12//! Chains run `before` hooks in registration order, dispatch the call, then run
13//! `after` hooks in reverse order — the conventional onion ordering, so an
14//! interceptor's `after` wraps everything its `before` set up. A `before` that
15//! returns `Err` short-circuits the call (the dispatch never happens) but its
16//! `after` still runs, observing the error.
17//!
18//! The hooks see call *metadata* (method name, side), not the typed
19//! request/response — those differ per method and would force the trait generic.
20//! Metadata is enough for the canonical uses (logging, metrics, tracing spans,
21//! header/auth propagation handled by the adapter around the chain).
22
23use std::sync::Arc;
24
25use async_trait::async_trait;
26
27use crate::domain::A2AError;
28
29/// Which side of the wire an interceptor chain is running on.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum CallSide {
32 /// The outbound client transport is making the call.
33 Client,
34 /// The inbound server transport is handling the call.
35 Server,
36}
37
38/// Metadata about an in-flight A2A call, passed to each interceptor hook.
39#[derive(Debug, Clone)]
40pub struct CallContext {
41 /// The A2A method name (PascalCase wire name, e.g. `"SendMessage"`).
42 pub method: String,
43 /// Whether this chain runs on the client or server side.
44 pub side: CallSide,
45}
46
47impl CallContext {
48 /// Construct a context for `method` on the given `side`.
49 pub fn new(method: impl Into<String>, side: CallSide) -> Self {
50 Self {
51 method: method.into(),
52 side,
53 }
54 }
55}
56
57/// A before/after hook around an A2A call (auth, logging, metrics, tracing).
58///
59/// Both hooks have default no-op bodies, so an interceptor overrides only the
60/// side it cares about.
61#[async_trait]
62pub trait CallInterceptor: Send + Sync {
63 /// Run before the call is dispatched. Returning `Err` short-circuits the
64 /// call: the dispatch is skipped and the error is returned to the caller
65 /// (after `after` hooks still run, observing it).
66 async fn before(&self, _ctx: &CallContext) -> Result<(), A2AError> {
67 Ok(())
68 }
69
70 /// Run after the call completes, observing its outcome (`Ok` on success,
71 /// `Err` with a borrow of the error otherwise).
72 async fn after(&self, _ctx: &CallContext, _outcome: Result<(), &A2AError>) {}
73}
74
75/// Run a chain's `before` hooks in registration order; the first `Err`
76/// short-circuits and is returned without invoking the remaining hooks.
77pub async fn run_before(
78 interceptors: &[Arc<dyn CallInterceptor>],
79 ctx: &CallContext,
80) -> Result<(), A2AError> {
81 for interceptor in interceptors {
82 interceptor.before(ctx).await?;
83 }
84 Ok(())
85}
86
87/// Run a chain's `after` hooks in reverse registration order (onion unwinding).
88pub async fn run_after(
89 interceptors: &[Arc<dyn CallInterceptor>],
90 ctx: &CallContext,
91 outcome: Result<(), &A2AError>,
92) {
93 for interceptor in interceptors.iter().rev() {
94 interceptor.after(ctx, outcome).await;
95 }
96}