Skip to main content

r402_core/wire/
resource_info.rs

1//! Resource metadata attached to `PaymentRequired` / `PaymentPayload`.
2
3use compact_str::CompactString;
4use serde::{Deserialize, Serialize};
5
6/// Human-readable metadata describing the paid resource.
7///
8/// Per the x402 v2 spec §5.1.2, only `url` is required. `description` and
9/// `mimeType` are optional because many resources (e.g. raw API endpoints)
10/// have no meaningful MIME type or prose description. `serviceName`,
11/// `tags`, and `iconUrl` are optional discovery metadata consumed by
12/// marketplace/bazaar-style aggregators.
13///
14/// The field names use `camelCase` to align with the wire format.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase", deny_unknown_fields)]
17#[non_exhaustive]
18pub struct ResourceInfo {
19    /// Canonical URL of the resource.
20    pub url: CompactString,
21    /// Optional human-readable description.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub description: Option<CompactString>,
24    /// Optional MIME type.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub mime_type: Option<CompactString>,
27    /// Human-readable name of the service hosting the resource.
28    ///
29    /// Printable ASCII, max 32 characters per spec §5.1.2.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub service_name: Option<CompactString>,
32    /// Topical tags for the service, used for discovery filtering.
33    ///
34    /// Max 5 entries; each printable ASCII, max 32 characters, per spec §5.1.2.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub tags: Vec<CompactString>,
37    /// Absolute `https`/`http` URL to an icon representing the service.
38    ///
39    /// Max 2048 characters per spec §5.1.2.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub icon_url: Option<CompactString>,
42}
43
44impl ResourceInfo {
45    /// Constructs a [`ResourceInfo`] carrying just a URL.
46    #[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    /// Builder: sets `description`.
59    #[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    /// Builder: sets `mimeType`.
66    #[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    /// Builder: sets `serviceName`.
73    #[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    /// Builder: replaces the `tags` list.
80    #[must_use]
81    pub fn with_tags(mut self, tags: Vec<CompactString>) -> Self {
82        self.tags = tags;
83        self
84    }
85
86    /// Builder: appends a single tag.
87    #[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    /// Builder: sets `iconUrl`.
94    #[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    /// Spec §5.1.2 discovery metadata is optional; omitting it keeps the
133    /// wire payload minimal for resources that don't opt into cataloging.
134    #[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    /// F-001 regression: unknown top-level field is rejected.
153    #[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}