Skip to main content

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            /// Borrow the identifier as a string slice.
35            pub fn as_str(&self) -> &str {
36                &self.0
37            }
38
39            /// Consume the identifier, returning the owned string.
40            pub fn into_string(self) -> String {
41                self.0
42            }
43        }
44
45        impl FromStr for $name {
46            type Err = A2AError;
47
48            fn from_str(s: &str) -> Result<Self, Self::Err> {
49                if s.trim().is_empty() {
50                    return Err(A2AError::ValidationError {
51                        field: $field.to_string(),
52                        message: concat!($field, " cannot be empty").to_string(),
53                    });
54                }
55                Ok(Self(s.to_owned()))
56            }
57        }
58
59        impl TryFrom<&str> for $name {
60            type Error = A2AError;
61
62            fn try_from(s: &str) -> Result<Self, Self::Error> {
63                s.parse()
64            }
65        }
66
67        impl TryFrom<String> for $name {
68            type Error = A2AError;
69
70            fn try_from(s: String) -> Result<Self, Self::Error> {
71                s.as_str().parse()
72            }
73        }
74
75        impl AsRef<str> for $name {
76            fn as_ref(&self) -> &str {
77                &self.0
78            }
79        }
80
81        impl fmt::Display for $name {
82            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83                f.write_str(&self.0)
84            }
85        }
86    };
87}
88
89define_id!(
90    /// Identifies a task within an agent.
91    TaskId,
92    "task_id"
93);
94
95define_id!(
96    /// Identifies a conversation/session context grouping related tasks.
97    ContextId,
98    "context_id"
99);
100
101define_id!(
102    /// Identifies a single push-notification configuration for a task.
103    PushConfigId,
104    "push_notification_config_id"
105);
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn rejects_empty_and_whitespace() {
113        assert!(TaskId::from_str("").is_err());
114        assert!(TaskId::from_str("   ").is_err());
115        assert!(ContextId::from_str("").is_err());
116    }
117
118    #[test]
119    fn accepts_non_empty() {
120        let id = TaskId::from_str("task-123").unwrap();
121        assert_eq!(id.as_str(), "task-123");
122        assert_eq!(id.to_string(), "task-123");
123    }
124
125    #[test]
126    fn try_from_owned_and_borrowed() {
127        assert!(TaskId::try_from("x").is_ok());
128        assert!(TaskId::try_from("x".to_string()).is_ok());
129        assert!(TaskId::try_from(String::new()).is_err());
130    }
131}