a2a_rs/domain/ids.rs
1//! Strongly-typed identifiers for the A2A protocol.
2//!
3//! Applies "parse, don't validate" to the codebase's own identifiers: a
4//! [`TaskId`], [`ContextId`], or [`PushConfigId`] can only be constructed from a
5//! non-empty string via [`FromStr`]/[`TryFrom`], so port methods that accept one
6//! never have to re-check emptiness, and argument-order mix-ups
7//! (`cancel(context_id, task_id)`) become compile errors.
8//!
9//! ## Deserialization caveat
10//!
11//! These newtypes derive `Deserialize` with `#[serde(transparent)]`, which means
12//! a value reconstructed from the wire does **not** pass through the validating
13//! [`FromStr`] path. That is intentional: deserialized identifiers are validated
14//! once at the RPC boundary (the request processor converts wire strings through
15//! [`FromStr`] before they reach a port). Treat [`FromStr`]/[`TryFrom`] as the
16//! only validating constructors; `Deserialize` is a transport convenience.
17
18use std::fmt;
19use std::str::FromStr;
20
21use serde::{Deserialize, Serialize};
22
23use crate::domain::error::A2AError;
24
25/// Generates a validating string newtype identifier.
26macro_rules! define_id {
27 ($(#[$meta:meta])* $name:ident, $field:literal) => {
28 $(#[$meta])*
29 #[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
30 #[serde(transparent)]
31 pub struct $name(String);
32
33 impl $name {
34 /// A fresh server-assigned identifier (UUID v4).
35 ///
36 /// The A2A wire makes a client's task and context ids optional; when
37 /// one is absent the server picks it.
38 pub fn generate() -> Self {
39 Self(uuid::Uuid::new_v4().to_string())
40 }
41
42 /// Borrow the identifier as a string slice.
43 pub fn as_str(&self) -> &str {
44 &self.0
45 }
46
47 /// Consume the identifier, returning the owned string.
48 pub fn into_string(self) -> String {
49 self.0
50 }
51 }
52
53 impl FromStr for $name {
54 type Err = A2AError;
55
56 fn from_str(s: &str) -> Result<Self, Self::Err> {
57 if s.trim().is_empty() {
58 return Err(A2AError::ValidationError {
59 field: $field.to_string(),
60 message: concat!($field, " cannot be empty").to_string(),
61 });
62 }
63 Ok(Self(s.to_owned()))
64 }
65 }
66
67 impl TryFrom<&str> for $name {
68 type Error = A2AError;
69
70 fn try_from(s: &str) -> Result<Self, Self::Error> {
71 s.parse()
72 }
73 }
74
75 impl TryFrom<String> for $name {
76 type Error = A2AError;
77
78 fn try_from(s: String) -> Result<Self, Self::Error> {
79 s.as_str().parse()
80 }
81 }
82
83 impl AsRef<str> for $name {
84 fn as_ref(&self) -> &str {
85 &self.0
86 }
87 }
88
89 impl fmt::Display for $name {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str(&self.0)
92 }
93 }
94 };
95}
96
97define_id!(
98 /// Identifies a task within an agent.
99 TaskId,
100 "task_id"
101);
102
103define_id!(
104 /// Identifies a conversation/session context grouping related tasks.
105 ContextId,
106 "context_id"
107);
108
109define_id!(
110 /// Identifies a single push-notification configuration for a task.
111 PushConfigId,
112 "push_notification_config_id"
113);
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn rejects_empty_and_whitespace() {
121 assert!(TaskId::from_str("").is_err());
122 assert!(TaskId::from_str(" ").is_err());
123 assert!(ContextId::from_str("").is_err());
124 }
125
126 #[test]
127 fn accepts_non_empty() {
128 let id = TaskId::from_str("task-123").unwrap();
129 assert_eq!(id.as_str(), "task-123");
130 assert_eq!(id.to_string(), "task-123");
131 }
132
133 #[test]
134 fn try_from_owned_and_borrowed() {
135 assert!(TaskId::try_from("x").is_ok());
136 assert!(TaskId::try_from("x".to_string()).is_ok());
137 assert!(TaskId::try_from(String::new()).is_err());
138 }
139}