r402_protocol/payment/
resource.rs1use compact_str::CompactString;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase", deny_unknown_fields)]
11#[non_exhaustive]
12pub struct ResourceInfo {
13 pub url: CompactString,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub description: Option<CompactString>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub mime_type: Option<CompactString>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub service_name: Option<CompactString>,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 pub tags: Vec<CompactString>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub icon_url: Option<CompactString>,
36}
37
38impl ResourceInfo {
39 #[must_use]
41 pub fn new(url: impl Into<CompactString>) -> Self {
42 Self {
43 url: url.into(),
44 description: None,
45 mime_type: None,
46 service_name: None,
47 tags: Vec::new(),
48 icon_url: None,
49 }
50 }
51
52 #[must_use]
54 pub fn with_description(mut self, description: impl Into<CompactString>) -> Self {
55 self.description = Some(description.into());
56 self
57 }
58
59 #[must_use]
61 pub fn with_mime_type(mut self, mime_type: impl Into<CompactString>) -> Self {
62 self.mime_type = Some(mime_type.into());
63 self
64 }
65
66 #[must_use]
68 pub fn with_service_name(mut self, service_name: impl Into<CompactString>) -> Self {
69 self.service_name = Some(service_name.into());
70 self
71 }
72
73 #[must_use]
75 pub fn with_tags(mut self, tags: Vec<CompactString>) -> Self {
76 self.tags = tags;
77 self
78 }
79
80 #[must_use]
82 pub fn with_tag(mut self, tag: impl Into<CompactString>) -> Self {
83 self.tags.push(tag.into());
84 self
85 }
86
87 #[must_use]
89 pub fn with_icon_url(mut self, icon_url: impl Into<CompactString>) -> Self {
90 self.icon_url = Some(icon_url.into());
91 self
92 }
93}
94
95#[cfg(test)]
96#[allow(
97 clippy::unwrap_used,
98 clippy::indexing_slicing,
99 reason = "unit tests panic on assertion failure"
100)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn minimal_resource_omits_optional_fields() {
106 let info = ResourceInfo::new("https://example.com/paid");
107 let v = serde_json::to_value(&info).unwrap();
108 assert_eq!(v["url"], "https://example.com/paid");
109 assert!(v.get("description").is_none());
110 assert!(v.get("mimeType").is_none());
111 }
112
113 #[test]
114 fn full_resource_roundtrips() {
115 let info = ResourceInfo::new("https://example.com/r")
116 .with_description("doc")
117 .with_mime_type("application/json")
118 .with_service_name("Example Weather")
119 .with_tag("weather")
120 .with_tag("forecast")
121 .with_icon_url("https://example.com/icon.png");
122 let encoded = serde_json::to_value(&info).unwrap();
123 assert_eq!(encoded["mimeType"], "application/json");
124 assert_eq!(encoded["serviceName"], "Example Weather");
125 assert_eq!(encoded["tags"], serde_json::json!(["weather", "forecast"]));
126 assert_eq!(encoded["iconUrl"], "https://example.com/icon.png");
127 let decoded: ResourceInfo = serde_json::from_value(encoded).unwrap();
128 assert_eq!(decoded, info);
129 }
130
131 #[test]
132 fn discovery_metadata_omitted_by_default() {
133 let info = ResourceInfo::new("https://example.com/r");
134 let v = serde_json::to_value(&info).unwrap();
135 assert!(v.get("serviceName").is_none());
136 assert!(v.get("tags").is_none());
137 assert!(v.get("iconUrl").is_none());
138 }
139
140 #[test]
141 fn deserializes_spec_compliant_optional_fields() {
142 let json = serde_json::json!({ "url": "https://x.test" });
143 let decoded: ResourceInfo = serde_json::from_value(json).unwrap();
144 assert_eq!(decoded.url, "https://x.test");
145 assert!(decoded.description.is_none());
146 assert!(decoded.mime_type.is_none());
147 }
148
149 #[test]
150 fn rejects_unknown_field() {
151 let json = serde_json::json!({ "url": "https://x.test", "unknown": 1 });
152 assert!(serde_json::from_value::<ResourceInfo>(json).is_err());
153 }
154}