Skip to main content

ra2a/client/
interceptor.rs

1//! Call interceptors for cross-cutting concerns (auth, logging, tracing).
2//!
3//! Aligned with Go's `CallInterceptor` in `a2aclient/middleware.go`.
4//! Interceptors can inspect and modify both request/response payloads
5//! and metadata (HTTP headers). `CallMeta` is propagated to the transport
6//! layer via [`CALL_META`] task-local, mirroring Go's `context.Value`.
7
8use std::any::Any;
9use std::collections::HashMap;
10use std::future::Future;
11use std::pin::Pin;
12
13use crate::error::{A2AError, Result};
14
15tokio::task_local! {
16    /// Per-request metadata propagated from interceptors to the transport layer.
17    ///
18    /// Mirrors Go's `CallMetaFrom(ctx)` pattern. Transport implementations
19    /// use [`call_meta`] to read interceptor-set headers (e.g. auth tokens).
20    pub static CALL_META: CallMeta;
21}
22
23/// Returns a clone of the current request's [`CallMeta`], if set.
24///
25/// Transport implementations call this to retrieve headers set by interceptors.
26pub fn call_meta() -> Option<CallMeta> {
27    CALL_META.try_with(|m| m.clone()).ok()
28}
29
30/// Case-insensitive metadata map carried through interceptor chains.
31///
32/// Mirrors Go's `CallMeta` (backed by `http.Header`). Keys are lowercased
33/// on insertion; values are multi-valued like HTTP headers.
34#[derive(Debug, Clone, Default)]
35pub struct CallMeta {
36    inner: HashMap<String, Vec<String>>,
37}
38
39impl CallMeta {
40    /// Appends a value, lowercasing the key. Duplicates are not added.
41    pub fn append(&mut self, key: impl Into<String>, value: impl Into<String>) {
42        let vals = self
43            .inner
44            .entry(key.into().to_ascii_lowercase())
45            .or_default();
46        let v = value.into();
47        if !vals.contains(&v) {
48            vals.push(v);
49        }
50    }
51
52    /// Returns the first value for the given key (case-insensitive).
53    pub fn get(&self, key: &str) -> Option<&str> {
54        self.inner
55            .get(&key.to_ascii_lowercase())
56            .and_then(|v| v.first().map(String::as_str))
57    }
58
59    /// Returns all values for the given key (case-insensitive).
60    pub fn get_all(&self, key: &str) -> &[String] {
61        self.inner
62            .get(&key.to_ascii_lowercase())
63            .map_or(&[], Vec::as_slice)
64    }
65
66    /// Returns an iterator over all key-value pairs.
67    pub fn iter(&self) -> impl Iterator<Item = (&str, &[String])> {
68        self.inner.iter().map(|(k, v)| (k.as_str(), v.as_slice()))
69    }
70
71    /// Returns true if the map contains no entries.
72    pub fn is_empty(&self) -> bool {
73        self.inner.is_empty()
74    }
75}
76
77/// Transport-agnostic outgoing request that interceptors can observe and modify.
78///
79/// Aligned with Go's `a2aclient.Request`. Carries the method name, metadata
80/// (HTTP headers), the agent card, and the actual request payload.
81pub struct Request {
82    /// The method being called (e.g. `"SendMessage"`).
83    pub method: String,
84    /// The base URL of the agent interface.
85    pub base_url: String,
86    /// Metadata to attach as HTTP headers on the outgoing request.
87    pub meta: CallMeta,
88    /// The agent card, if already resolved.
89    pub card: Option<crate::types::AgentCard>,
90    /// The request payload. One of the `a2a` parameter types, boxed.
91    pub payload: Box<dyn Any + Send>,
92}
93
94/// Transport-agnostic response that interceptors can observe and modify.
95///
96/// Aligned with Go's `a2aclient.Response`. Carries the method name, metadata,
97/// the agent card, and the actual response payload or error.
98pub struct Response {
99    /// The method that was called.
100    pub method: String,
101    /// The base URL of the agent interface.
102    pub base_url: String,
103    /// Metadata from response headers.
104    pub meta: CallMeta,
105    /// The agent card, if resolved.
106    pub card: Option<crate::types::AgentCard>,
107    /// The response payload, if successful.
108    pub payload: Option<Box<dyn Any + Send>>,
109    /// The error, if the call failed.
110    pub err: Option<A2AError>,
111}
112
113/// Middleware for intercepting client calls.
114///
115/// Aligned with Go's `CallInterceptor` interface. Both `before` and `after`
116/// are invoked in the order interceptors were attached.
117pub trait CallInterceptor: Send + Sync {
118    /// Called before the transport call. May modify outgoing metadata and payload.
119    fn before<'a>(
120        &'a self,
121        req: &'a mut Request,
122    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
123        let _ = req;
124        Box::pin(async { Ok(()) })
125    }
126
127    /// Called after the transport call. May inspect/modify response or error.
128    fn after<'a>(
129        &'a self,
130        resp: &'a mut Response,
131    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
132        let _ = resp;
133        Box::pin(async { Ok(()) })
134    }
135}
136
137/// No-op interceptor for embedding in custom implementations.
138pub struct PassthroughInterceptor;
139
140impl CallInterceptor for PassthroughInterceptor {}
141
142/// A [`CallInterceptor`] that attaches static metadata to all outgoing requests.
143///
144/// Aligned with Go's `NewStaticCallMetaInjector`. Useful for injecting
145/// fixed headers (e.g. API keys, tracing IDs) into every request.
146///
147/// # Example
148///
149/// ```
150/// use ra2a::client::{CallMeta, StaticCallMetaInjector, Client};
151///
152/// let mut meta = CallMeta::default();
153/// meta.append("x-api-key", "my-secret");
154/// // client.with_interceptor(StaticCallMetaInjector::new(meta));
155/// ```
156pub struct StaticCallMetaInjector {
157    inject: CallMeta,
158}
159
160impl StaticCallMetaInjector {
161    /// Creates a new injector that appends the given metadata to every request.
162    pub fn new(meta: CallMeta) -> Self {
163        Self { inject: meta }
164    }
165}
166
167impl CallInterceptor for StaticCallMetaInjector {
168    fn before<'a>(
169        &'a self,
170        req: &'a mut Request,
171    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
172        Box::pin(async move {
173            for (key, values) in self.inject.iter() {
174                for value in values {
175                    req.meta.append(key, value);
176                }
177            }
178            Ok(())
179        })
180    }
181}