Skip to main content

r402_protocol/payment/
resource.rs

1//! Resource metadata on a 402 challenge.
2
3use compact_str::CompactString;
4use serde::{Deserialize, Serialize};
5
6/// Human-readable metadata describing the paid resource.
7///
8/// Spec §5.1.2: only `url` is required.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase", deny_unknown_fields)]
11#[non_exhaustive]
12pub struct ResourceInfo {
13    /// Canonical URL of the resource.
14    pub url: CompactString,
15    /// Optional human-readable description.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub description: Option<CompactString>,
18    /// Optional MIME type.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub mime_type: Option<CompactString>,
21    /// Human-readable name of the service hosting the resource.
22    ///
23    /// Printable ASCII, max 32 characters per spec §5.1.2.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub service_name: Option<CompactString>,
26    /// Topical tags for the service, used for discovery filtering.
27    ///
28    /// Max 5 entries; each printable ASCII, max 32 characters, per spec §5.1.2.
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub tags: Vec<CompactString>,
31    /// Absolute `https`/`http` URL to an icon representing the service.
32    ///
33    /// Max 2048 characters per spec §5.1.2.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub icon_url: Option<CompactString>,
36}
37
38impl ResourceInfo {
39    /// Constructs a [`ResourceInfo`] carrying just a URL.
40    #[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    /// Sets `description`.
53    #[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    /// Sets `mimeType`.
60    #[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    /// Sets `serviceName`.
67    #[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    /// Replaces the `tags` list.
74    #[must_use]
75    pub fn with_tags(mut self, tags: Vec<CompactString>) -> Self {
76        self.tags = tags;
77        self
78    }
79
80    /// Appends a single tag.
81    #[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    /// Sets `iconUrl`.
88    #[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}