Skip to main content

r402_core/wire/
extensions.rs

1//! x402 protocol extension envelope.
2//!
3//! Per the x402 v2 spec §Extensions, every wire message carrying extensions
4//! uses a JSON object keyed by the stable extension ID. Each value carries an
5//! extension-specific payload; the canonical shape is `{ info, schema }` but
6//! extensions may choose to supply any JSON payload. This module models both
7//! via [`ExtensionEntry`], which transparently serializes either form.
8
9use std::collections::HashMap;
10
11use compact_str::CompactString;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// Canonical x402 extension envelope.
16///
17/// Maps each extension's stable identifier (e.g. `"bazaar"`,
18/// `"payment-identifier"`) to its [`ExtensionEntry`]. The type is
19/// `#[serde(transparent)]` so on the wire it appears as a plain JSON object.
20///
21/// # Examples
22///
23/// ```
24/// use r402_core::wire::{Extensions, ExtensionEntry};
25/// use serde_json::json;
26///
27/// let mut ext = Extensions::new();
28/// ext.insert("bazaar", ExtensionEntry::info(json!({"registered": true})));
29/// let rendered = serde_json::to_value(&ext).unwrap();
30/// assert_eq!(rendered["bazaar"]["info"]["registered"], true);
31/// ```
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(transparent)]
34pub struct Extensions(HashMap<CompactString, ExtensionEntry>);
35
36impl Extensions {
37    /// Creates an empty extension map.
38    #[must_use]
39    pub fn new() -> Self {
40        Self(HashMap::new())
41    }
42
43    /// Returns `true` when no extensions are present.
44    #[must_use]
45    pub fn is_empty(&self) -> bool {
46        self.0.is_empty()
47    }
48
49    /// Number of registered extensions.
50    #[must_use]
51    pub fn len(&self) -> usize {
52        self.0.len()
53    }
54
55    /// Looks up an extension by stable ID.
56    #[must_use]
57    pub fn get(&self, id: &str) -> Option<&ExtensionEntry> {
58        self.0.get(id)
59    }
60
61    /// Inserts or replaces an extension payload for the given ID.
62    pub fn insert(&mut self, id: impl Into<CompactString>, entry: ExtensionEntry) {
63        let _ = self.0.insert(id.into(), entry);
64    }
65
66    /// Removes an extension by stable ID, returning the previous value if any.
67    #[must_use]
68    pub fn remove(&mut self, id: &str) -> Option<ExtensionEntry> {
69        self.0.remove(id)
70    }
71
72    /// Iterates over `(id, entry)` pairs.
73    pub fn iter(&self) -> impl Iterator<Item = (&CompactString, &ExtensionEntry)> {
74        self.0.iter()
75    }
76}
77
78impl<K, V> FromIterator<(K, V)> for Extensions
79where
80    K: Into<CompactString>,
81    V: Into<ExtensionEntry>,
82{
83    fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
84        Self(
85            iter.into_iter()
86                .map(|(k, v)| (k.into(), v.into()))
87                .collect(),
88        )
89    }
90}
91
92/// Payload attached to a single extension key.
93///
94/// Two shapes are permitted by the spec:
95///
96/// 1. [`ExtensionEntry::Structured`] — the canonical `{info, schema}` envelope
97///    recommended for new extensions. `schema` is optional and, when present,
98///    should describe the expected shape of client-submitted data.
99/// 2. [`ExtensionEntry::Raw`] — an opaque JSON value, forwarded verbatim.
100///    Used when an extension predates the canonical envelope or needs a
101///    non-object top-level value.
102///
103/// Serialization preserves the original form: structured values emit
104/// `{info, schema?}`; raw values emit their stored JSON directly.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(untagged)]
107pub enum ExtensionEntry {
108    /// Canonical `{info, schema?}` envelope.
109    Structured {
110        /// Extension-specific payload.
111        info: Value,
112        /// Optional JSON Schema for client-submitted fields.
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        schema: Option<Value>,
115    },
116    /// Raw JSON payload, forwarded as-is.
117    Raw(Value),
118}
119
120impl ExtensionEntry {
121    /// Constructs a structured entry with only `info`.
122    #[must_use]
123    pub const fn info(info: Value) -> Self {
124        Self::Structured { info, schema: None }
125    }
126
127    /// Constructs a structured entry with `info` and `schema`.
128    #[must_use]
129    pub const fn with_schema(info: Value, schema: Value) -> Self {
130        Self::Structured {
131            info,
132            schema: Some(schema),
133        }
134    }
135
136    /// Constructs a raw entry wrapping the supplied JSON value.
137    #[must_use]
138    pub const fn raw(value: Value) -> Self {
139        Self::Raw(value)
140    }
141
142    /// Returns the `info` payload if this entry is structured.
143    #[must_use]
144    pub const fn as_info(&self) -> Option<&Value> {
145        match self {
146            Self::Structured { info, .. } => Some(info),
147            Self::Raw(_) => None,
148        }
149    }
150
151    /// Returns the schema if present.
152    #[must_use]
153    pub const fn as_schema(&self) -> Option<&Value> {
154        match self {
155            Self::Structured { schema, .. } => schema.as_ref(),
156            Self::Raw(_) => None,
157        }
158    }
159
160    /// Returns the raw JSON value regardless of shape.
161    #[must_use]
162    pub fn to_value(&self) -> Value {
163        match self {
164            Self::Structured { info, schema } => {
165                let mut obj = serde_json::Map::new();
166                let _ = obj.insert("info".to_owned(), info.clone());
167                if let Some(schema) = schema {
168                    let _ = obj.insert("schema".to_owned(), schema.clone());
169                }
170                Value::Object(obj)
171            }
172            Self::Raw(value) => value.clone(),
173        }
174    }
175}
176
177impl From<Value> for ExtensionEntry {
178    fn from(value: Value) -> Self {
179        Self::Raw(value)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use serde_json::json;
186
187    use super::*;
188
189    #[test]
190    fn extensions_empty_by_default() {
191        let ext = Extensions::new();
192        assert!(ext.is_empty());
193        assert_eq!(serde_json::to_value(&ext).unwrap(), json!({}));
194    }
195
196    #[test]
197    fn structured_entry_roundtrip() {
198        let mut ext = Extensions::new();
199        ext.insert(
200            "bazaar",
201            ExtensionEntry::with_schema(json!({"registered": true}), json!({"type": "object"})),
202        );
203        let encoded = serde_json::to_value(&ext).unwrap();
204        assert_eq!(encoded["bazaar"]["info"]["registered"], true);
205        assert_eq!(encoded["bazaar"]["schema"]["type"], "object");
206        let decoded: Extensions = serde_json::from_value(encoded).unwrap();
207        assert_eq!(decoded, ext);
208    }
209
210    #[test]
211    fn raw_entry_roundtrip() {
212        let mut ext = Extensions::new();
213        ext.insert("custom", ExtensionEntry::raw(json!([1, 2, 3])));
214        let encoded = serde_json::to_value(&ext).unwrap();
215        assert_eq!(encoded["custom"], json!([1, 2, 3]));
216        let decoded: Extensions = serde_json::from_value(encoded).unwrap();
217        assert_eq!(decoded, ext);
218    }
219}