Skip to main content

r402_core/extensions/
bazaar.rs

1//! `bazaar` extension — optional resource discovery metadata.
2//!
3//! Sellers that opt into the bazaar extension advertise their paid resources
4//! via the `PaymentRequired.extensions["bazaar"]` block. This gives
5//! discovery services (Coinbase Bazaar, custom aggregators) a machine
6//! readable description without requiring a separate HTTP endpoint.
7//!
8//! # Wire format
9//!
10//! ```json
11//! {
12//!   "extensions": {
13//!     "bazaar": {
14//!       "info": {
15//!         "catalog": "https://example.com/.well-known/x402-catalog",
16//!         "categories": ["data", "api"],
17//!         "registered": true
18//!       }
19//!     }
20//!   }
21//! }
22//! ```
23
24#![cfg(feature = "ext-bazaar")]
25#![cfg_attr(docsrs, doc(cfg(feature = "ext-bazaar")))]
26
27use compact_str::CompactString;
28use serde_json::Value;
29
30use super::{AdvertiseContext, Extension};
31use crate::wire::ExtensionEntry;
32
33/// Server-side implementation of the `bazaar` discovery extension.
34#[derive(Debug, Clone, Default)]
35pub struct BazaarExtension {
36    /// Optional catalog URL pointing at a machine-readable resource index.
37    pub catalog: Option<CompactString>,
38    /// Optional list of free-form category labels.
39    pub categories: Vec<CompactString>,
40    /// Whether this resource has been registered with the upstream discovery
41    /// service.
42    pub registered: bool,
43}
44
45impl BazaarExtension {
46    /// Constructs an empty bazaar extension.
47    #[must_use]
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    /// Builder: attaches a catalog URL.
53    #[must_use]
54    pub fn with_catalog(mut self, catalog: impl Into<CompactString>) -> Self {
55        self.catalog = Some(catalog.into());
56        self
57    }
58
59    /// Builder: adds a single category label.
60    #[must_use]
61    pub fn with_category(mut self, category: impl Into<CompactString>) -> Self {
62        self.categories.push(category.into());
63        self
64    }
65
66    /// Builder: marks the resource as registered.
67    #[must_use]
68    pub const fn registered(mut self) -> Self {
69        self.registered = true;
70        self
71    }
72}
73
74impl Extension for BazaarExtension {
75    fn id(&self) -> &'static str {
76        "bazaar"
77    }
78
79    fn advertise(&self, _ctx: &AdvertiseContext<'_>) -> Option<ExtensionEntry> {
80        let mut info = serde_json::Map::new();
81        if let Some(catalog) = &self.catalog {
82            let _ = info.insert("catalog".to_owned(), Value::String(catalog.to_string()));
83        }
84        if !self.categories.is_empty() {
85            let cats: Vec<Value> = self
86                .categories
87                .iter()
88                .map(|c| Value::String(c.to_string()))
89                .collect();
90            let _ = info.insert("categories".to_owned(), Value::Array(cats));
91        }
92        let _ = info.insert("registered".to_owned(), Value::Bool(self.registered));
93        Some(ExtensionEntry::info(Value::Object(info)))
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use serde_json::json;
100
101    use super::*;
102
103    #[test]
104    fn advertise_includes_catalog_and_categories() {
105        let ext = BazaarExtension::new()
106            .with_catalog("https://example.com/catalog")
107            .with_category("data")
108            .with_category("api")
109            .registered();
110        let ctx = AdvertiseContext { requirement: None };
111        let entry = ext.advertise(&ctx).unwrap();
112        let info = entry.as_info().unwrap();
113        assert_eq!(info["catalog"], "https://example.com/catalog");
114        assert_eq!(info["registered"], true);
115        assert_eq!(info["categories"], json!(["data", "api"]));
116    }
117
118    #[test]
119    fn advertise_minimal_emits_registered_flag() {
120        let ext = BazaarExtension::new();
121        let ctx = AdvertiseContext { requirement: None };
122        let entry = ext.advertise(&ctx).unwrap();
123        let info = entry.as_info().unwrap();
124        assert_eq!(info["registered"], false);
125        assert!(info.get("catalog").is_none());
126    }
127}