a2a_rs/port/request_context.rs
1//! What a transport knows about an inbound call, beyond its payload.
2
3use crate::port::authenticator::AuthPrincipal;
4
5/// Who is calling, and which conversation the call belongs to.
6///
7/// Built by the transport adapter that accepted the request and passed down
8/// through `TaskService` to
9/// [`AsyncMessageHandler`](crate::port::AsyncMessageHandler). One value rather
10/// than a widening parameter list: the session id, the principal and (later) the
11/// tenant are all facts about *this* request, and a handler that wants one
12/// usually wants the others.
13///
14/// Not to be confused with [`CallContext`](crate::port::CallContext), which is
15/// interceptor metadata about the call being dispatched (method name, side of
16/// the wire) and says nothing about who made it.
17///
18/// # The principal
19///
20/// [`principal`](Self::principal) is `None` when the agent serves anonymous
21/// callers — no authenticator is configured, so there is nobody to name. It is
22/// what [`AsyncConversationStore`](crate::port::AsyncConversationStore) compares
23/// against a context's recorded owner, which is why the transport has to carry
24/// it rather than the handler guessing: a handler that cannot tell two callers
25/// apart hands the second one the first one's conversation.
26#[derive(Debug, Clone, Default)]
27pub struct RequestContext {
28 /// The context id the caller supplied, if any. Empty on a first turn that
29 /// lets the agent pick one.
30 session_id: Option<String>,
31 /// The authenticated principal, or `None` on an agent that does not
32 /// authenticate.
33 principal: Option<AuthPrincipal>,
34}
35
36impl RequestContext {
37 /// A call from nobody in particular: no session, no principal.
38 ///
39 /// The right context for an internal caller that is not serving a request —
40 /// a bridge invoking a handler directly, or a test.
41 pub fn anonymous() -> Self {
42 Self::default()
43 }
44
45 /// Name the conversation this call belongs to.
46 ///
47 /// An empty string is the same as no session at all, which is what a wire
48 /// message with an unset `context_id` decodes to.
49 #[must_use]
50 pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
51 let session_id = session_id.into();
52 self.session_id = (!session_id.is_empty()).then_some(session_id);
53 self
54 }
55
56 /// Attach the principal the transport authenticated.
57 #[must_use]
58 pub fn with_principal(mut self, principal: impl Into<Option<AuthPrincipal>>) -> Self {
59 self.principal = principal.into();
60 self
61 }
62
63 /// The context id the caller supplied.
64 pub fn session_id(&self) -> Option<&str> {
65 self.session_id.as_deref()
66 }
67
68 /// The authenticated principal, with whatever claims the authenticator
69 /// attached.
70 pub fn principal(&self) -> Option<&AuthPrincipal> {
71 self.principal.as_ref()
72 }
73
74 /// The authenticated principal's id — the identity a conversation is owned
75 /// by.
76 pub fn caller(&self) -> Option<&str> {
77 self.principal.as_ref().map(|p| p.id.as_str())
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn an_anonymous_call_names_nobody() {
87 let ctx = RequestContext::anonymous();
88 assert_eq!(ctx.session_id(), None);
89 assert_eq!(ctx.caller(), None);
90 }
91
92 #[test]
93 fn an_empty_session_id_is_no_session() {
94 // What an unset wire `context_id` decodes to, so the transports do not
95 // each have to remember to filter it.
96 let ctx = RequestContext::anonymous().with_session("");
97 assert_eq!(ctx.session_id(), None);
98 }
99
100 #[test]
101 fn the_caller_is_the_principals_id() {
102 let ctx = RequestContext::anonymous()
103 .with_session("ctx-1")
104 .with_principal(AuthPrincipal::new("alice".to_string(), "jwt".to_string()));
105
106 assert_eq!(ctx.session_id(), Some("ctx-1"));
107 assert_eq!(ctx.caller(), Some("alice"));
108 assert_eq!(ctx.principal().map(|p| p.scheme.as_str()), Some("jwt"));
109 }
110}