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