diff --git a/Cargo.lock b/Cargo.lock index 1250631..9f94d70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3555,10 +3555,23 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3655,6 +3668,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -5013,6 +5037,13 @@ dependencies = [ [[package]] name = "x402-extensions" version = "0.2.0" +dependencies = [ + "bon", + "schemars 1.2.1", + "serde", + "serde_json", + "x402-core", +] [[package]] name = "x402-kit" @@ -5043,6 +5074,7 @@ dependencies = [ "url", "url-macro", "x402-core", + "x402-extensions", "x402-paywall", ] diff --git a/x402-core/src/types/extensions.rs b/x402-core/src/types/extensions.rs index d9f6173..08cb2b4 100644 --- a/x402-core/src/types/extensions.rs +++ b/x402-core/src/types/extensions.rs @@ -1,18 +1,225 @@ //! This module defines types related to X402 protocol extensions. +//! +//! Extensions enable modular optional functionality beyond core payment mechanics. +//! Servers advertise supported extensions in `PaymentRequired`, and clients echo them +//! in `PaymentPayload`. +//! +//! # Extension Type +//! +//! [`Extension`] is a generic type parameterized by the info type `T`. +//! When used in transport types (e.g., `PaymentRequired`, `PaymentPayload`), +//! the default `T = AnyJson` provides type-erased JSON storage. +//! +//! For typed extension construction, use a concrete info type `T` that implements +//! [`ExtensionInfo`]. This enables compile-time schema generation and type-safe +//! extension handling. +//! +//! # Example +//! +//! ``` +//! use x402_core::types::{Extension, AnyJson, Record}; +//! use serde_json::json; +//! +//! // Type-erased extension (transport form) +//! let ext = Extension::new( +//! json!({"key": "value"}), +//! json!({"type": "object"}), +//! ); +//! +//! // With extra fields +//! let ext = Extension::new( +//! json!({"key": "value"}), +//! json!({"type": "object"}), +//! ).with_extra("supportedChains", json!([{"chainId": "eip155:8453"}])); +//! +//! // Serializes to: {"info": {...}, "schema": {...}, "supportedChains": [...]} +//! let json = serde_json::to_value(&ext).unwrap(); +//! assert!(json.get("supportedChains").is_some()); +//! ``` use std::fmt::Display; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, ser::SerializeMap}; -use crate::types::AnyJson; +use crate::types::{AnyJson, Record}; /// Represents an extension in the X402 protocol. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Extension { +/// +/// An extension has: +/// - `info`: Extension-specific data provided by the server +/// - `schema`: JSON Schema defining the expected structure of `info` +/// - `extra`: Additional extension-specific fields (flattened during serialization) +/// +/// The generic parameter `T` defaults to [`AnyJson`] for transport/type-erased use. +/// Use a concrete type implementing [`ExtensionInfo`] for typed extension construction. +#[derive(Debug, Clone)] +pub struct Extension { /// The information about the extension. - pub info: AnyJson, + pub info: T, /// The schema defining the extension's structure. pub schema: AnyJson, + /// Additional extension-specific fields, flattened during serialization. + pub extra: Record, +} + +impl Extension { + /// Create a new type-erased extension. + pub fn new(info: AnyJson, schema: AnyJson) -> Self { + Extension { + info, + schema, + extra: Record::new(), + } + } + + /// Convert a type-erased extension into a typed extension. + /// + /// This deserializes the `info` field from JSON into the concrete type `T`. + pub fn into_typed( + self, + ) -> Result, serde_json::Error> { + let info: T = serde_json::from_value(self.info)?; + Ok(Extension { + info, + schema: self.schema, + extra: self.extra, + }) + } +} + +impl Extension { + /// Add an extra field to the extension. + /// + /// Extra fields are flattened alongside `info` and `schema` during serialization. + pub fn with_extra(mut self, key: impl Into, value: impl Into) -> Self { + self.extra.insert(key.into(), value.into()); + self + } +} + +impl Extension { + /// Create a typed extension with auto-generated schema from `T`'s [`ExtensionInfo`] implementation. + pub fn typed(info: T) -> Self { + Extension { + info, + schema: T::schema(), + extra: Record::new(), + } + } + + /// Convert this typed extension into a key-value pair for insertion into `Record`. + /// + /// The key is `T::ID` and the value is the type-erased [`Extension`]. + pub fn into_pair(self) -> (String, Extension) + where + T: Serialize, + { + ( + T::ID.to_string(), + Extension { + info: serde_json::to_value(&self.info).unwrap_or_else(|e| { + panic!("Failed to serialize extension '{}' info: {e}", T::ID) + }), + schema: self.schema, + extra: self.extra, + }, + ) + } +} + +/// Trait for typed extension info with compile-time schema generation. +/// +/// Implement this trait for your extension's info type to enable: +/// - Type-safe extension construction via [`Extension::typed`] +/// - Automatic schema generation via [`ExtensionInfo::schema`] +/// - Automatic key assignment via [`ExtensionInfo::ID`] +/// +/// # Example +/// +/// ``` +/// use serde::{Serialize, Deserialize}; +/// use x402_core::types::{ExtensionInfo, AnyJson}; +/// use serde_json::json; +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] +/// struct MyInfo { +/// pub value: String, +/// } +/// +/// impl ExtensionInfo for MyInfo { +/// const ID: &'static str = "my-extension"; +/// fn schema() -> AnyJson { +/// json!({ +/// "type": "object", +/// "properties": { +/// "value": { "type": "string" } +/// }, +/// "required": ["value"] +/// }) +/// } +/// } +/// ``` +pub trait ExtensionInfo: Clone + 'static { + /// The extension identifier, used as the key in the `extensions` map. + const ID: &'static str; + + /// Generate a JSON Schema for this extension's info type. + fn schema() -> AnyJson; +} + +/// Convenience trait for inserting typed extensions into a `Record`. +pub trait ExtensionMapInsert { + /// Insert a typed extension, using its [`ExtensionInfo::ID`] as the key. + fn insert_typed(&mut self, ext: Extension); +} + +impl ExtensionMapInsert for Record { + fn insert_typed(&mut self, ext: Extension) { + let (key, value) = ext.into_pair(); + self.insert(key, value); + } +} + +// Custom Serialize: output info, schema, and flatten extra fields +impl Serialize for Extension { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(Some(2 + self.extra.len()))?; + map.serialize_entry("info", &self.info)?; + map.serialize_entry("schema", &self.schema)?; + for (k, v) in &self.extra { + map.serialize_entry(k, v)?; + } + map.end() + } +} + +// Custom Deserialize: extract info and schema, collect remaining into extra +impl<'de, T> Deserialize<'de> for Extension +where + T: serde::de::DeserializeOwned, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut map: Record = Record::deserialize(deserializer)?; + let info_val = map + .remove("info") + .ok_or_else(|| serde::de::Error::missing_field("info"))?; + let schema = map + .remove("schema") + .ok_or_else(|| serde::de::Error::missing_field("schema"))?; + let info: T = serde_json::from_value(info_val).map_err(serde::de::Error::custom)?; + + Ok(Extension { + info, + schema, + extra: map, + }) + } } /// Represents the identifier for an extension in the X402 protocol. @@ -24,3 +231,126 @@ impl Display for ExtensionIdentifier { write!(f, "{}", self.0) } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn serialize_extension_without_extra() { + let ext = Extension::new(json!({"domain": "example.com"}), json!({"type": "object"})); + + let json = serde_json::to_value(&ext).unwrap(); + assert_eq!(json.get("info").unwrap(), &json!({"domain": "example.com"})); + assert_eq!(json.get("schema").unwrap(), &json!({"type": "object"})); + // No extra fields + assert_eq!(json.as_object().unwrap().len(), 2); + } + + #[test] + fn serialize_extension_with_extra() { + let ext = Extension::new(json!({"domain": "example.com"}), json!({"type": "object"})) + .with_extra( + "supportedChains", + json!([{"chainId": "eip155:8453", "type": "eip191"}]), + ); + + let json = serde_json::to_value(&ext).unwrap(); + assert_eq!(json.get("info").unwrap(), &json!({"domain": "example.com"})); + assert_eq!(json.get("schema").unwrap(), &json!({"type": "object"})); + assert!(json.get("supportedChains").is_some()); + assert_eq!(json.as_object().unwrap().len(), 3); + } + + #[test] + fn deserialize_extension_without_extra() { + let json = json!({ + "info": {"domain": "example.com"}, + "schema": {"type": "object"} + }); + + let ext: Extension = serde_json::from_value(json).unwrap(); + assert_eq!(ext.info, json!({"domain": "example.com"})); + assert_eq!(ext.schema, json!({"type": "object"})); + assert!(ext.extra.is_empty()); + } + + #[test] + fn deserialize_extension_with_extra() { + let json = json!({ + "info": {"domain": "example.com"}, + "schema": {"type": "object"}, + "supportedChains": [{"chainId": "eip155:8453"}] + }); + + let ext: Extension = serde_json::from_value(json).unwrap(); + assert_eq!(ext.info, json!({"domain": "example.com"})); + assert_eq!(ext.schema, json!({"type": "object"})); + assert_eq!( + ext.extra.get("supportedChains").unwrap(), + &json!([{"chainId": "eip155:8453"}]) + ); + } + + #[test] + fn roundtrip_extension_with_extra() { + let ext = Extension::new(json!({"domain": "example.com"}), json!({"type": "object"})) + .with_extra("customField", json!("custom_value")); + + let serialized = serde_json::to_value(&ext).unwrap(); + let deserialized: Extension = serde_json::from_value(serialized.clone()).unwrap(); + + let re_serialized = serde_json::to_value(&deserialized).unwrap(); + assert_eq!(serialized, re_serialized); + } + + #[test] + fn typed_extension_into_pair() { + #[derive(Debug, Clone, Serialize)] + struct TestInfo { + pub value: String, + } + + impl ExtensionInfo for TestInfo { + const ID: &'static str = "test-ext"; + fn schema() -> AnyJson { + json!({"type": "object", "properties": {"value": {"type": "string"}}}) + } + } + + let ext = Extension::typed(TestInfo { + value: "hello".to_string(), + }); + + let (key, transport_ext) = ext.into_pair(); + assert_eq!(key, "test-ext"); + assert_eq!(transport_ext.info, json!({"value": "hello"})); + assert_eq!( + transport_ext.schema, + json!({"type": "object", "properties": {"value": {"type": "string"}}}) + ); + } + + #[test] + fn extension_map_insert_typed() { + #[derive(Debug, Clone, Serialize)] + struct TestInfo { + pub data: i32, + } + + impl ExtensionInfo for TestInfo { + const ID: &'static str = "test"; + fn schema() -> AnyJson { + json!({"type": "object"}) + } + } + + let mut extensions: Record = Record::new(); + extensions.insert_typed(Extension::typed(TestInfo { data: 42 })); + + assert!(extensions.contains_key("test")); + assert_eq!(extensions["test"].info, json!({"data": 42})); + } +} diff --git a/x402-extensions/Cargo.toml b/x402-extensions/Cargo.toml index 7416fa0..eedc1f0 100644 --- a/x402-extensions/Cargo.toml +++ b/x402-extensions/Cargo.toml @@ -5,6 +5,11 @@ edition = "2024" repository = "https://github.com/AIMOverse/x402-kit" authors = ["Archer "] license = "MIT" -description = "(V2 Supported) A fully modular SDK for building complex X402 payment integrations." +description = "(V2 Supported) X402 protocol extension types for resource discovery and modular functionality." [dependencies] +x402-core = { version = "2.3.0", path = "../x402-core" } +bon = { version = "3.8" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +schemars = { version = "1.0" } diff --git a/x402-extensions/src/bazaar.rs b/x402-extensions/src/bazaar.rs new file mode 100644 index 0000000..b9b33cb --- /dev/null +++ b/x402-extensions/src/bazaar.rs @@ -0,0 +1,444 @@ +//! The `bazaar` extension for resource discovery and cataloging. +//! +//! The `bazaar` extension enables resource servers to declare their endpoint +//! specifications (HTTP method or MCP tool name, input parameters, and output format) +//! so that facilitators can catalog and index them in a discovery service. +//! +//! # Example: GET Endpoint +//! +//! ``` +//! use x402_extensions::bazaar::*; +//! use x402_core::types::Extension; +//! use serde_json::json; +//! +//! let info = BazaarInfo::builder() +//! .input(BazaarInput::Http(BazaarHttpInput::builder() +//! .method(HttpMethod::GET) +//! .query_params(json!({"city": "San Francisco"})) +//! .build())) +//! .output(BazaarOutput::builder() +//! .output_type("json") +//! .example(json!({"city": "San Francisco", "weather": "foggy"})) +//! .build()) +//! .build(); +//! +//! let ext = Extension::typed(info); +//! let (key, transport) = ext.into_pair(); +//! assert_eq!(key, "bazaar"); +//! ``` +//! +//! # Example: POST Endpoint +//! +//! ``` +//! use x402_extensions::bazaar::*; +//! use x402_core::types::Extension; +//! use serde_json::json; +//! +//! let info = BazaarInfo::builder() +//! .input(BazaarInput::Http(BazaarHttpInput::builder() +//! .method(HttpMethod::POST) +//! .body_type("json") +//! .body(json!({"query": "example"})) +//! .build())) +//! .build(); +//! +//! let ext = Extension::typed(info); +//! let (key, _) = ext.into_pair(); +//! assert_eq!(key, "bazaar"); +//! ``` +//! +//! # Example: MCP Tool +//! +//! ``` +//! use x402_extensions::bazaar::*; +//! use x402_core::types::Extension; +//! use serde_json::json; +//! +//! let info = BazaarInfo::builder() +//! .input(BazaarInput::Mcp(BazaarMcpInput::builder() +//! .tool("financial_analysis") +//! .input_schema(json!({ +//! "type": "object", +//! "properties": { +//! "ticker": { "type": "string" } +//! }, +//! "required": ["ticker"] +//! })) +//! .description("AI-powered financial analysis") +//! .build())) +//! .output(BazaarOutput::builder() +//! .output_type("json") +//! .example(json!({"summary": "Strong fundamentals", "score": 8.5})) +//! .build()) +//! .build(); +//! +//! let ext = Extension::typed(info); +//! let (key, _) = ext.into_pair(); +//! assert_eq!(key, "bazaar"); +//! ``` + +use bon::Builder; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use x402_core::types::{AnyJson, ExtensionInfo}; + +/// Discovery info for the `bazaar` extension. +/// +/// Contains the input specification and optional output description +/// for a resource server endpoint. +#[derive(Builder, Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct BazaarInfo { + /// How to call the endpoint or tool. + pub input: BazaarInput, + + /// Expected response format (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, +} + +impl ExtensionInfo for BazaarInfo { + const ID: &'static str = "bazaar"; + + fn schema() -> AnyJson { + let schema = schemars::schema_for!(BazaarInfo); + serde_json::to_value(&schema).expect("BazaarInfo schema generation should not fail") + } +} + +/// Discriminated union for input types. +/// +/// - `Http`: HTTP endpoints (GET, HEAD, DELETE, POST, PUT, PATCH) +/// - `Mcp`: MCP (Model Context Protocol) tools +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type")] +pub enum BazaarInput { + /// HTTP endpoint input. + #[serde(rename = "http")] + Http(BazaarHttpInput), + + /// MCP tool input. + #[serde(rename = "mcp")] + Mcp(BazaarMcpInput), +} + +/// HTTP endpoint input specification. +/// +/// For query parameter methods (GET, HEAD, DELETE), use `query_params` and `headers`. +/// For body methods (POST, PUT, PATCH), additionally use `body_type` and `body`. +#[derive(Builder, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BazaarHttpInput { + /// HTTP method (GET, HEAD, DELETE, POST, PUT, PATCH). + pub method: HttpMethod, + + /// Query parameter examples. + #[serde(skip_serializing_if = "Option::is_none")] + pub query_params: Option, + + /// Custom header examples. + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + + /// Request body content type. Required for body methods (POST, PUT, PATCH). + /// One of `"json"`, `"form-data"`, `"text"`. + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub body_type: Option, + + /// Request body example. Required for body methods (POST, PUT, PATCH). + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +/// HTTP methods supported by the bazaar extension. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum HttpMethod { + /// HTTP GET method. + GET, + /// HTTP HEAD method. + HEAD, + /// HTTP DELETE method. + DELETE, + /// HTTP POST method. + POST, + /// HTTP PUT method. + PUT, + /// HTTP PATCH method. + PATCH, +} + +/// MCP tool input specification. +/// +/// Describes an MCP tool's name, input schema, and optional metadata. +#[derive(Builder, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BazaarMcpInput { + /// MCP tool name (matches what's passed to `tools/call`). + #[builder(into)] + pub tool: String, + + /// JSON Schema for the tool's `arguments`, following the MCP `Tool.inputSchema` format. + pub input_schema: AnyJson, + + /// Human-readable description of the tool. + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub description: Option, + + /// MCP transport protocol. One of `"streamable-http"` or `"sse"`. + /// Defaults to `"streamable-http"` if omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub transport: Option, + + /// Example `arguments` object. + #[serde(skip_serializing_if = "Option::is_none")] + pub example: Option, +} + +/// MCP transport protocol options. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum McpTransport { + /// Streamable HTTP transport (default). + StreamableHttp, + /// Server-Sent Events transport. + Sse, +} + +/// Output specification for a bazaar discovery entry. +#[derive(Builder, Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct BazaarOutput { + /// Response content type (e.g., `"json"`, `"text"`). + #[serde(rename = "type")] + #[builder(into)] + pub output_type: String, + + /// Additional format information. + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub format: Option, + + /// Example response value. + #[serde(skip_serializing_if = "Option::is_none")] + pub example: Option, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use x402_core::types::{Extension, ExtensionMapInsert, Record}; + + use super::*; + + #[test] + fn bazaar_get_endpoint() { + let info = BazaarInfo::builder() + .input(BazaarInput::Http( + BazaarHttpInput::builder() + .method(HttpMethod::GET) + .query_params(json!({"city": "San Francisco"})) + .build(), + )) + .output( + BazaarOutput::builder() + .output_type("json") + .example(json!({ + "city": "San Francisco", + "weather": "foggy", + "temperature": 60 + })) + .build(), + ) + .build(); + + let ext = Extension::typed(info); + let (key, transport_ext) = ext.into_pair(); + + assert_eq!(key, "bazaar"); + + let info_json = &transport_ext.info; + assert_eq!(info_json["input"]["type"], "http"); + assert_eq!(info_json["input"]["method"], "GET"); + assert_eq!( + info_json["input"]["queryParams"], + json!({"city": "San Francisco"}) + ); + assert_eq!(info_json["output"]["type"], "json"); + } + + #[test] + fn bazaar_post_endpoint() { + let info = BazaarInfo::builder() + .input(BazaarInput::Http( + BazaarHttpInput::builder() + .method(HttpMethod::POST) + .body_type("json") + .body(json!({"query": "example"})) + .build(), + )) + .output( + BazaarOutput::builder() + .output_type("json") + .example(json!({"results": []})) + .build(), + ) + .build(); + + let ext = Extension::typed(info); + let (key, transport_ext) = ext.into_pair(); + + assert_eq!(key, "bazaar"); + + let info_json = &transport_ext.info; + assert_eq!(info_json["input"]["type"], "http"); + assert_eq!(info_json["input"]["method"], "POST"); + assert_eq!(info_json["input"]["bodyType"], "json"); + assert_eq!(info_json["input"]["body"], json!({"query": "example"})); + } + + #[test] + fn bazaar_mcp_tool() { + let info = BazaarInfo::builder() + .input(BazaarInput::Mcp( + BazaarMcpInput::builder() + .tool("financial_analysis") + .input_schema(json!({ + "type": "object", + "properties": { + "ticker": { "type": "string" }, + "analysis_type": { "type": "string", "enum": ["quick", "deep"] } + }, + "required": ["ticker"] + })) + .description("Advanced AI-powered financial analysis") + .example(json!({ + "ticker": "AAPL", + "analysis_type": "deep" + })) + .build(), + )) + .output( + BazaarOutput::builder() + .output_type("json") + .example(json!({ + "summary": "Strong fundamentals...", + "score": 8.5 + })) + .build(), + ) + .build(); + + let ext = Extension::typed(info); + let (key, transport_ext) = ext.into_pair(); + + assert_eq!(key, "bazaar"); + + let info_json = &transport_ext.info; + assert_eq!(info_json["input"]["type"], "mcp"); + assert_eq!(info_json["input"]["tool"], "financial_analysis"); + assert!(info_json["input"]["inputSchema"].is_object()); + } + + #[test] + fn bazaar_mcp_with_transport() { + let info = BazaarInfo::builder() + .input(BazaarInput::Mcp( + BazaarMcpInput::builder() + .tool("my_tool") + .input_schema(json!({"type": "object"})) + .transport(McpTransport::Sse) + .build(), + )) + .build(); + + let (_, ext) = Extension::typed(info).into_pair(); + assert_eq!(ext.info["input"]["transport"], "sse"); + } + + #[test] + fn bazaar_schema_is_generated() { + let schema = ::schema(); + assert!(schema.is_object()); + // Schema should define the structure of BazaarInfo + let schema_obj = schema.as_object().unwrap(); + assert!( + schema_obj.contains_key("properties") || schema_obj.contains_key("$defs"), + "Schema should contain properties or definitions" + ); + } + + #[test] + fn bazaar_insert_into_extension_map() { + let mut extensions: Record = Record::new(); + + extensions.insert_typed(Extension::typed( + BazaarInfo::builder() + .input(BazaarInput::Http( + BazaarHttpInput::builder().method(HttpMethod::GET).build(), + )) + .build(), + )); + + assert!(extensions.contains_key("bazaar")); + assert_eq!(extensions["bazaar"].info["input"]["type"], "http"); + assert_eq!(extensions["bazaar"].info["input"]["method"], "GET"); + } + + #[test] + fn bazaar_roundtrip_serialization() { + let info = BazaarInfo::builder() + .input(BazaarInput::Http( + BazaarHttpInput::builder() + .method(HttpMethod::POST) + .body_type("json") + .body(json!({"key": "value"})) + .headers(json!({"Authorization": "Bearer token"})) + .build(), + )) + .output( + BazaarOutput::builder() + .output_type("json") + .format("utf-8") + .build(), + ) + .build(); + + // Serialize to JSON and back + let json = serde_json::to_value(&info).unwrap(); + let deserialized: BazaarInfo = serde_json::from_value(json.clone()).unwrap(); + let re_serialized = serde_json::to_value(&deserialized).unwrap(); + + assert_eq!(json, re_serialized); + } + + #[test] + fn bazaar_transport_roundtrip() { + let info = BazaarInfo::builder() + .input(BazaarInput::Mcp( + BazaarMcpInput::builder() + .tool("test_tool") + .input_schema(json!({"type": "object"})) + .build(), + )) + .build(); + + let ext = Extension::typed(info); + let (key, transport_ext) = ext.into_pair(); + + // Serialize the transport extension + let json = serde_json::to_value(&transport_ext).unwrap(); + + // Deserialize back + let deserialized: Extension = serde_json::from_value(json).unwrap(); + + assert_eq!( + transport_ext.info, deserialized.info, + "Info should roundtrip" + ); + assert_eq!( + transport_ext.schema, deserialized.schema, + "Schema should roundtrip" + ); + assert_eq!(key, "bazaar"); + } +} diff --git a/x402-extensions/src/lib.rs b/x402-extensions/src/lib.rs index b93cf3f..a224d15 100644 --- a/x402-extensions/src/lib.rs +++ b/x402-extensions/src/lib.rs @@ -1,14 +1,53 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +//! # X402 Extensions +//! +//! This crate provides concrete extension type implementations for the X402 protocol. +//! +//! Extensions enable modular optional functionality beyond core payment mechanics. +//! Servers advertise supported extensions in `PaymentRequired`, and clients echo them +//! in `PaymentPayload`. +//! +//! ## Available Extensions +//! +//! - [`bazaar`]: Resource discovery and cataloging for x402-enabled endpoints and MCP tools +//! - [`sign_in_with_x`]: Authenticated sign-in alongside payment +//! +//! ## Defining Custom Extensions +//! +//! You can define your own extensions by implementing the [`ExtensionInfo`](x402_core::types::ExtensionInfo) +//! trait from `x402-core`: +//! +//! ``` +//! use serde::{Serialize, Deserialize}; +//! use x402_core::types::{Extension, ExtensionInfo, AnyJson}; +//! use serde_json::json; +//! +//! #[derive(Debug, Clone, Serialize, Deserialize)] +//! pub struct MyExtensionInfo { +//! pub custom_field: String, +//! } +//! +//! impl ExtensionInfo for MyExtensionInfo { +//! const ID: &'static str = "my-custom-extension"; +//! fn schema() -> AnyJson { +//! json!({ +//! "type": "object", +//! "properties": { +//! "custom_field": { "type": "string" } +//! }, +//! "required": ["custom_field"] +//! }) +//! } +//! } +//! +//! let ext = Extension::typed(MyExtensionInfo { +//! custom_field: "hello".to_string(), +//! }); +//! let (key, transport) = ext.into_pair(); +//! assert_eq!(key, "my-custom-extension"); +//! ``` -#[cfg(test)] -mod tests { - use super::*; +/// The `bazaar` extension for resource discovery and cataloging. +pub mod bazaar; - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +/// The `sign-in-with-x` extension for authenticated access. +pub mod sign_in_with_x; diff --git a/x402-extensions/src/sign_in_with_x.rs b/x402-extensions/src/sign_in_with_x.rs new file mode 100644 index 0000000..7fa8f96 --- /dev/null +++ b/x402-extensions/src/sign_in_with_x.rs @@ -0,0 +1,295 @@ +//! The `sign-in-with-x` extension for authenticated access. +//! +//! The `sign-in-with-x` extension enables resource servers to require +//! authenticated sign-in alongside payment. The server provides sign-in +//! parameters and supported chains, and the client echoes back the extension +//! with a signature. +//! +//! # Example +//! +//! ``` +//! use x402_extensions::sign_in_with_x::*; +//! use x402_core::types::Extension; +//! use serde_json::json; +//! +//! let info = SignInWithXInfo::builder() +//! .domain("api.example.com") +//! .uri("https://api.example.com/premium-data") +//! .version("1") +//! .nonce("a1b2c3d4e5f67890a1b2c3d4e5f67890") +//! .issued_at("2024-01-15T10:30:00.000Z") +//! .statement("Sign in to access premium data") +//! .build(); +//! +//! let ext = Extension::typed(info) +//! .with_extra("supportedChains", json!([ +//! {"chainId": "eip155:8453", "type": "eip191"} +//! ])); +//! +//! let (key, transport) = ext.into_pair(); +//! assert_eq!(key, "sign-in-with-x"); +//! +//! // Extra fields are flattened in serialization +//! let json = serde_json::to_value(&transport).unwrap(); +//! assert!(json.get("supportedChains").is_some()); +//! ``` + +use bon::Builder; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use x402_core::types::{AnyJson, ExtensionInfo}; + +/// Sign-in info for the `sign-in-with-x` extension. +/// +/// Contains parameters for authenticated sign-in that the server provides. +/// Clients echo this back with additional fields (e.g., `address`, `signature`). +#[derive(Builder, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SignInWithXInfo { + /// The domain requesting the sign-in. + #[builder(into)] + pub domain: String, + + /// The URI of the resource being accessed. + #[builder(into)] + pub uri: String, + + /// The sign-in message version. + #[builder(into)] + pub version: String, + + /// A unique nonce to prevent replay attacks. + #[builder(into)] + pub nonce: String, + + /// The timestamp when this sign-in request was issued (ISO 8601). + #[builder(into)] + pub issued_at: String, + + /// When the sign-in request expires (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub expiration_time: Option, + + /// Human-readable statement for the sign-in. + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub statement: Option, + + /// Resources associated with this sign-in. + #[serde(skip_serializing_if = "Option::is_none")] + pub resources: Option>, +} + +impl ExtensionInfo for SignInWithXInfo { + const ID: &'static str = "sign-in-with-x"; + + fn schema() -> AnyJson { + let schema = schemars::schema_for!(SignInWithXInfo); + serde_json::to_value(&schema).expect("SignInWithXInfo schema generation should not fail") + } +} + +/// A supported chain entry for the `sign-in-with-x` extension. +/// +/// Used in the `supportedChains` extra field. +#[derive(Builder, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SupportedChain { + /// The chain identifier in CAIP-2 format (e.g., `"eip155:8453"`). + #[builder(into)] + pub chain_id: String, + + /// The signature type (e.g., `"eip191"`). + #[serde(rename = "type")] + #[builder(into)] + pub chain_type: String, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use x402_core::types::{Extension, ExtensionMapInsert, Record}; + + use super::*; + + #[test] + fn sign_in_with_x_basic() { + let info = SignInWithXInfo::builder() + .domain("api.example.com") + .uri("https://api.example.com/premium-data") + .version("1") + .nonce("a1b2c3d4e5f67890a1b2c3d4e5f67890") + .issued_at("2024-01-15T10:30:00.000Z") + .build(); + + let ext = Extension::typed(info); + let (key, transport_ext) = ext.into_pair(); + + assert_eq!(key, "sign-in-with-x"); + assert_eq!(transport_ext.info["domain"], "api.example.com"); + assert_eq!(transport_ext.info["version"], "1"); + } + + #[test] + fn sign_in_with_x_with_optional_fields() { + let info = SignInWithXInfo::builder() + .domain("api.example.com") + .uri("https://api.example.com/premium-data") + .version("1") + .nonce("a1b2c3d4e5f67890a1b2c3d4e5f67890") + .issued_at("2024-01-15T10:30:00.000Z") + .expiration_time("2024-01-15T10:35:00.000Z") + .statement("Sign in to access premium data") + .resources(vec!["https://api.example.com/premium-data".to_string()]) + .build(); + + let (_, ext) = Extension::typed(info).into_pair(); + + assert_eq!(ext.info["statement"], "Sign in to access premium data"); + assert_eq!(ext.info["expirationTime"], "2024-01-15T10:35:00.000Z"); + assert!(ext.info["resources"].is_array()); + } + + #[test] + fn sign_in_with_x_with_supported_chains() { + let info = SignInWithXInfo::builder() + .domain("api.example.com") + .uri("https://api.example.com/premium-data") + .version("1") + .nonce("a1b2c3d4e5f67890") + .issued_at("2024-01-15T10:30:00.000Z") + .build(); + + let chains = vec![ + SupportedChain::builder() + .chain_id("eip155:8453") + .chain_type("eip191") + .build(), + ]; + + let ext = Extension::typed(info) + .with_extra("supportedChains", serde_json::to_value(&chains).unwrap()); + + let (key, transport_ext) = ext.into_pair(); + assert_eq!(key, "sign-in-with-x"); + + // Serialize and check extra fields are flattened + let json = serde_json::to_value(&transport_ext).unwrap(); + assert!(json.get("supportedChains").is_some()); + assert_eq!(json["supportedChains"][0]["chainId"], "eip155:8453"); + assert_eq!(json["supportedChains"][0]["type"], "eip191"); + } + + #[test] + fn sign_in_with_x_schema_is_generated() { + let schema = ::schema(); + assert!(schema.is_object()); + } + + #[test] + fn sign_in_with_x_insert_into_map() { + let mut extensions: Record = Record::new(); + + let info = SignInWithXInfo::builder() + .domain("example.com") + .uri("https://example.com") + .version("1") + .nonce("test_nonce") + .issued_at("2024-01-01T00:00:00.000Z") + .build(); + + extensions.insert_typed(Extension::typed(info)); + + assert!(extensions.contains_key("sign-in-with-x")); + assert_eq!(extensions["sign-in-with-x"].info["domain"], "example.com"); + } + + #[test] + fn sign_in_with_x_roundtrip() { + let info = SignInWithXInfo::builder() + .domain("api.example.com") + .uri("https://api.example.com/resource") + .version("1") + .nonce("nonce123") + .issued_at("2024-01-15T10:30:00.000Z") + .expiration_time("2024-01-15T10:35:00.000Z") + .statement("Test statement") + .build(); + + let json = serde_json::to_value(&info).unwrap(); + let deserialized: SignInWithXInfo = serde_json::from_value(json.clone()).unwrap(); + let re_serialized = serde_json::to_value(&deserialized).unwrap(); + + assert_eq!(json, re_serialized); + } + + #[test] + fn sign_in_with_x_transport_roundtrip_with_extra() { + let info = SignInWithXInfo::builder() + .domain("api.example.com") + .uri("https://api.example.com/resource") + .version("1") + .nonce("nonce123") + .issued_at("2024-01-15T10:30:00.000Z") + .build(); + + let ext = Extension::typed(info).with_extra( + "supportedChains", + json!([{"chainId": "eip155:8453", "type": "eip191"}]), + ); + + let (_, transport_ext) = ext.into_pair(); + + // Serialize + let json = serde_json::to_value(&transport_ext).unwrap(); + + // Deserialize back + let deserialized: Extension = serde_json::from_value(json.clone()).unwrap(); + + // Verify roundtrip + assert_eq!(transport_ext.info, deserialized.info); + assert_eq!(transport_ext.schema, deserialized.schema); + assert_eq!( + deserialized.extra.get("supportedChains").unwrap(), + &json!([{"chainId": "eip155:8453", "type": "eip191"}]) + ); + } + + #[test] + fn sign_in_with_x_full_spec_example() { + // Test against the full example from the x402 spec + let json = json!({ + "info": { + "domain": "api.example.com", + "uri": "https://api.example.com/premium-data", + "version": "1", + "nonce": "a1b2c3d4e5f67890a1b2c3d4e5f67890", + "issuedAt": "2024-01-15T10:30:00.000Z", + "expirationTime": "2024-01-15T10:35:00.000Z", + "statement": "Sign in to access premium data", + "resources": ["https://api.example.com/premium-data"] + }, + "supportedChains": [ + { + "chainId": "eip155:8453", + "type": "eip191" + } + ], + "schema": {} + }); + + let ext: Extension = serde_json::from_value(json).unwrap(); + assert_eq!(ext.info["domain"], "api.example.com"); + assert!(ext.extra.contains_key("supportedChains")); + + // Convert to typed + let typed_ext: Extension = ext.into_typed().unwrap(); + assert_eq!(typed_ext.info.domain, "api.example.com"); + assert_eq!(typed_ext.info.version, "1"); + assert_eq!( + typed_ext.info.statement.as_deref(), + Some("Sign in to access premium data") + ); + } +} diff --git a/x402-kit/Cargo.toml b/x402-kit/Cargo.toml index 35438af..2baed48 100644 --- a/x402-kit/Cargo.toml +++ b/x402-kit/Cargo.toml @@ -19,6 +19,7 @@ actix-web = ["paywall", "x402-paywall/actix-web"] [dependencies] # === Core Deps === x402-core = { version = "2.3.0", path = "../x402-core" } +x402-extensions = { version = "0.2.0", path = "../x402-extensions" } hex = { version = "0.4" } alloy-primitives = { version = "1.4" } bon = { version = "3.8" } diff --git a/x402-kit/src/lib.rs b/x402-kit/src/lib.rs index a418e4f..2c91a9f 100644 --- a/x402-kit/src/lib.rs +++ b/x402-kit/src/lib.rs @@ -463,6 +463,11 @@ pub mod paywall { pub use x402_paywall::*; } +/// X402 protocol extension implementations. +pub mod extensions { + pub use x402_extensions::*; +} + /// Facilitator client utilities. #[cfg(feature = "facilitator-client")] pub mod facilitator_client;