Skip to content

Commit a588c09

Browse files
authored
Merge pull request #25 from agntcy/fix/grpc-agent-card-url-normalization
fix: normalize gRPC agent card URLs
2 parents f1a5639 + 92fa995 commit a588c09

File tree

4 files changed

+212
-15
lines changed

4 files changed

+212
-15
lines changed

a2a-grpc/src/errors.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,38 @@ mod tests {
116116
assert_eq!(status.code(), tonic::Code::Unknown);
117117
}
118118

119+
#[test]
120+
fn test_additional_a2a_error_to_status_mappings() {
121+
let cases = [
122+
(
123+
error_code::EXTENDED_CARD_NOT_CONFIGURED,
124+
tonic::Code::Unimplemented,
125+
),
126+
(
127+
error_code::EXTENSION_SUPPORT_REQUIRED,
128+
tonic::Code::FailedPrecondition,
129+
),
130+
(
131+
error_code::VERSION_NOT_SUPPORTED,
132+
tonic::Code::FailedPrecondition,
133+
),
134+
];
135+
136+
for (code, expected_grpc) in cases {
137+
let err = A2AError::new(code, "test");
138+
let status = a2a_error_to_status(&err);
139+
assert_eq!(status.code(), expected_grpc);
140+
}
141+
}
142+
143+
#[test]
144+
fn test_unknown_status_maps_to_internal_error() {
145+
let status = tonic::Status::new(tonic::Code::Cancelled, "cancelled");
146+
let err = status_to_a2a_error(&status);
147+
assert_eq!(err.code, error_code::INTERNAL_ERROR);
148+
assert_eq!(err.message, "cancelled");
149+
}
150+
119151
#[test]
120152
fn test_message_preserved() {
121153
let err = A2AError::new(error_code::TASK_NOT_FOUND, "task xyz not found");

a2a-pb/src/pbconv.rs

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -835,20 +835,18 @@ pub fn from_proto_stream_response(r: &proto::StreamResponse) -> Option<StreamRes
835835

836836
pub fn to_proto_agent_interface(i: &AgentInterface) -> proto::AgentInterface {
837837
proto::AgentInterface {
838-
url: i.url.clone(),
838+
url: i.wire_url(),
839839
protocol_binding: i.protocol_binding.clone(),
840840
tenant: i.tenant.clone().unwrap_or_default(),
841841
protocol_version: i.protocol_version.clone(),
842842
}
843843
}
844844

845845
pub fn from_proto_agent_interface(i: &proto::AgentInterface) -> AgentInterface {
846-
AgentInterface {
847-
url: i.url.clone(),
848-
protocol_binding: i.protocol_binding.clone(),
849-
protocol_version: i.protocol_version.clone(),
850-
tenant: empty_to_none(&i.tenant),
851-
}
846+
let mut iface = AgentInterface::new(i.url.clone(), i.protocol_binding.clone());
847+
iface.protocol_version = i.protocol_version.clone();
848+
iface.tenant = empty_to_none(&i.tenant);
849+
iface
852850
}
853851

854852
pub fn to_proto_agent_provider(p: &AgentProvider) -> proto::AgentProvider {
@@ -2216,6 +2214,25 @@ mod tests {
22162214
assert_eq!(iface, back);
22172215
}
22182216

2217+
#[test]
2218+
fn test_agent_interface_roundtrip_normalizes_grpc_http_scheme() {
2219+
let iface = AgentInterface {
2220+
url: "http://localhost:50051".to_string(),
2221+
protocol_binding: TRANSPORT_PROTOCOL_GRPC.to_string(),
2222+
protocol_version: "1.0".to_string(),
2223+
tenant: Some("ten".to_string()),
2224+
};
2225+
2226+
let proto = to_proto_agent_interface(&iface);
2227+
assert_eq!(proto.url, "localhost:50051");
2228+
2229+
let back = from_proto_agent_interface(&proto);
2230+
assert_eq!(back.url, "localhost:50051");
2231+
assert_eq!(back.protocol_binding, TRANSPORT_PROTOCOL_GRPC);
2232+
assert_eq!(back.protocol_version, "1.0");
2233+
assert_eq!(back.tenant.as_deref(), Some("ten"));
2234+
}
2235+
22192236
#[test]
22202237
fn test_agent_card_signature_with_header() {
22212238
let sig = AgentCardSignature {

a2a/src/agent_card.rs

Lines changed: 134 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// Copyright AGNTCY Contributors (https://github.com/agntcy)
22
// SPDX-License-Identifier: Apache-2.0
3-
use serde::{Deserialize, Deserializer, Serialize};
3+
use serde::{Deserialize, Deserializer, Serialize, Serializer};
44
use serde_json::Value;
55
use std::collections::HashMap;
66

7-
use crate::types::{ProtocolVersion, TransportProtocol};
7+
use crate::types::{ProtocolVersion, TRANSPORT_PROTOCOL_GRPC, TransportProtocol};
88

99
// ---------------------------------------------------------------------------
1010
// AgentCard
@@ -56,26 +56,77 @@ where
5656
// ---------------------------------------------------------------------------
5757

5858
/// A URL + protocol binding combination for reaching the agent.
59-
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60-
#[serde(rename_all = "camelCase")]
59+
#[derive(Debug, Clone, PartialEq)]
6160
pub struct AgentInterface {
6261
pub url: String,
6362
pub protocol_binding: TransportProtocol,
6463
pub protocol_version: ProtocolVersion,
65-
66-
#[serde(default, skip_serializing_if = "Option::is_none")]
6764
pub tenant: Option<String>,
6865
}
6966

67+
#[derive(Deserialize)]
68+
#[serde(rename_all = "camelCase")]
69+
struct AgentInterfaceSerde {
70+
url: String,
71+
protocol_binding: TransportProtocol,
72+
protocol_version: ProtocolVersion,
73+
#[serde(default)]
74+
tenant: Option<String>,
75+
}
76+
77+
fn normalize_agent_interface_url(url: String, protocol_binding: &str) -> String {
78+
if protocol_binding.eq_ignore_ascii_case(TRANSPORT_PROTOCOL_GRPC) {
79+
if let Some(stripped) = url.strip_prefix("http://") {
80+
return stripped.to_string();
81+
}
82+
}
83+
84+
url
85+
}
86+
7087
impl AgentInterface {
7188
pub fn new(url: impl Into<String>, protocol_binding: impl Into<String>) -> Self {
89+
let protocol_binding = protocol_binding.into();
7290
AgentInterface {
73-
url: url.into(),
74-
protocol_binding: protocol_binding.into(),
91+
url: normalize_agent_interface_url(url.into(), &protocol_binding),
92+
protocol_binding,
7593
protocol_version: crate::VERSION.to_string(),
7694
tenant: None,
7795
}
7896
}
97+
98+
pub fn wire_url(&self) -> String {
99+
normalize_agent_interface_url(self.url.clone(), &self.protocol_binding)
100+
}
101+
}
102+
103+
impl Serialize for AgentInterface {
104+
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
105+
use serde::ser::SerializeStruct;
106+
107+
let mut state = serializer
108+
.serialize_struct("AgentInterface", if self.tenant.is_some() { 4 } else { 3 })?;
109+
state.serialize_field("url", &self.wire_url())?;
110+
state.serialize_field("protocolBinding", &self.protocol_binding)?;
111+
state.serialize_field("protocolVersion", &self.protocol_version)?;
112+
if let Some(tenant) = &self.tenant {
113+
state.serialize_field("tenant", tenant)?;
114+
}
115+
state.end()
116+
}
117+
}
118+
119+
impl<'de> Deserialize<'de> for AgentInterface {
120+
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
121+
let raw = AgentInterfaceSerde::deserialize(deserializer)?;
122+
123+
Ok(Self {
124+
url: normalize_agent_interface_url(raw.url, &raw.protocol_binding),
125+
protocol_binding: raw.protocol_binding,
126+
protocol_version: raw.protocol_version,
127+
tenant: raw.tenant,
128+
})
129+
}
79130
}
80131

81132
// ---------------------------------------------------------------------------
@@ -445,6 +496,81 @@ mod tests {
445496
assert!(!iface.protocol_version.is_empty());
446497
}
447498

499+
#[test]
500+
fn test_agent_interface_new_normalizes_grpc_http_scheme() {
501+
let iface = AgentInterface::new("http://localhost:50051", TRANSPORT_PROTOCOL_GRPC);
502+
assert_eq!(iface.url, "localhost:50051");
503+
assert_eq!(iface.protocol_binding, TRANSPORT_PROTOCOL_GRPC);
504+
}
505+
506+
#[test]
507+
fn test_agent_interface_new_preserves_grpc_https_scheme() {
508+
let iface = AgentInterface::new("https://localhost:50051", TRANSPORT_PROTOCOL_GRPC);
509+
assert_eq!(iface.url, "https://localhost:50051");
510+
assert_eq!(iface.protocol_binding, TRANSPORT_PROTOCOL_GRPC);
511+
}
512+
513+
#[test]
514+
fn test_agent_interface_serde_normalizes_grpc_http_scheme() {
515+
let iface = AgentInterface {
516+
url: "http://localhost:50051".to_string(),
517+
protocol_binding: TRANSPORT_PROTOCOL_GRPC.to_string(),
518+
protocol_version: crate::VERSION.to_string(),
519+
tenant: Some("tenant-a".to_string()),
520+
};
521+
522+
let json = serde_json::to_string(&iface).unwrap();
523+
assert!(json.contains("\"url\":\"localhost:50051\""));
524+
525+
let back: AgentInterface = serde_json::from_str(&json).unwrap();
526+
assert_eq!(back.url, "localhost:50051");
527+
assert_eq!(back.protocol_binding, TRANSPORT_PROTOCOL_GRPC);
528+
assert_eq!(back.tenant.as_deref(), Some("tenant-a"));
529+
}
530+
531+
#[test]
532+
fn test_agent_card_deserialize_null_skills_as_default() {
533+
let json = serde_json::json!({
534+
"name": "Test Agent",
535+
"description": "A test agent",
536+
"version": "1.0.0",
537+
"supportedInterfaces": [
538+
{
539+
"url": "http://localhost:3000",
540+
"protocolBinding": "JSONRPC",
541+
"protocolVersion": crate::VERSION
542+
}
543+
],
544+
"capabilities": {},
545+
"defaultInputModes": ["text/plain"],
546+
"defaultOutputModes": ["text/plain"],
547+
"skills": null
548+
});
549+
550+
let card: AgentCard = serde_json::from_value(json).unwrap();
551+
assert!(card.skills.is_empty());
552+
}
553+
554+
#[test]
555+
fn test_security_scheme_deserialize_unknown_variant_errors() {
556+
let err = serde_json::from_value::<SecurityScheme>(serde_json::json!({
557+
"unknown": {"value": true}
558+
}))
559+
.unwrap_err();
560+
561+
assert!(err.to_string().contains("unknown security scheme variant"));
562+
}
563+
564+
#[test]
565+
fn test_oauth_flows_deserialize_unknown_variant_errors() {
566+
let err = serde_json::from_value::<OAuthFlows>(serde_json::json!({
567+
"unknown": {"tokenUrl": "https://example.com/token"}
568+
}))
569+
.unwrap_err();
570+
571+
assert!(err.to_string().contains("unknown OAuth flow variant"));
572+
}
573+
448574
#[test]
449575
fn test_security_scheme_apikey_serde() {
450576
let ss = SecurityScheme::ApiKey(ApiKeySecurityScheme {

a2a/src/errors.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,28 @@ mod tests {
209209
assert_eq!(A2AError::new(9999, "unknown").http_status_code(), 500);
210210
}
211211

212+
#[test]
213+
fn test_http_status_codes_for_remaining_a2a_mappings() {
214+
assert_eq!(
215+
A2AError::push_notification_not_supported().http_status_code(),
216+
400
217+
);
218+
assert_eq!(
219+
A2AError::unsupported_operation("nope").http_status_code(),
220+
400
221+
);
222+
assert_eq!(
223+
A2AError::version_not_supported("9.9").http_status_code(),
224+
400
225+
);
226+
assert_eq!(A2AError::parse_error("bad").http_status_code(), 400);
227+
assert_eq!(A2AError::invalid_request("bad").http_status_code(), 400);
228+
assert_eq!(
229+
A2AError::method_not_found("missing").http_status_code(),
230+
404
231+
);
232+
}
233+
212234
#[test]
213235
fn test_to_jsonrpc_error() {
214236
let e = A2AError::task_not_found("t1");

0 commit comments

Comments
 (0)