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}