x402_kit/types/
schema.rs

1use bon::Builder;
2use serde::{Deserialize, Serialize};
3
4use crate::types::Record;
5
6#[derive(Builder, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub struct FieldDefinition {
9    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
10    #[builder(into)]
11    pub field_type: Option<String>,
12
13    #[serde(skip_serializing_if = "Option::is_none")]
14    #[builder(into)]
15    pub required: Option<FieldRequired>,
16
17    #[serde(skip_serializing_if = "Option::is_none")]
18    #[builder(into)]
19    pub description: Option<String>,
20
21    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
22    #[builder(with = |iter: impl for<'a> IntoIterator<Item = &'static str>| iter.into_iter().map(|s| s.to_string()).collect())]
23    pub field_enum: Option<Vec<String>>,
24
25    #[serde(skip_serializing_if = "Option::is_none")]
26    #[builder(with = |iter: impl IntoIterator<Item = (&'static str, FieldDefinition)>| {
27        iter.into_iter()
28            .map(|(k, v)| (k.to_string(), v))
29            .collect()
30    })]
31    pub properties: Option<Record<FieldDefinition>>,
32}
33
34impl TryFrom<serde_json::Value> for FieldDefinition {
35    type Error = serde_json::Error;
36
37    fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
38        serde_json::from_value(value)
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(untagged)]
44pub enum FieldRequired {
45    Boolean(bool),
46    VecString(Vec<String>),
47}
48
49/// Marker type to indicate that a field is required.
50///
51/// Since implementing `From<bool>` conflics with `From<Iterator>`, this type can be used
52/// to indicate that a field is required.
53pub struct Required;
54
55impl From<Required> for FieldRequired {
56    fn from(_: Required) -> Self {
57        FieldRequired::Boolean(true)
58    }
59}
60
61impl<I: IntoIterator<Item = &'static str>> From<I> for FieldRequired {
62    fn from(value: I) -> Self {
63        FieldRequired::VecString(value.into_iter().map(|s| s.to_string()).collect())
64    }
65}
66
67#[derive(Builder, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct Input {
70    pub discoverable: bool,
71
72    #[serde(rename = "type")]
73    pub input_type: InputType,
74
75    pub method: Method,
76
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub body_type: Option<InputBodyType>,
79
80    #[serde(skip_serializing_if = "Option::is_none")]
81    #[builder(with = |iter: impl IntoIterator<Item = (&'static str, FieldDefinition)>| {
82        iter.into_iter()
83            .map(|(k, v)| (k.to_string(), v))
84            .collect()
85    })]
86    pub query_params: Option<Record<FieldDefinition>>,
87
88    #[serde(skip_serializing_if = "Option::is_none")]
89    #[builder(with = |iter: impl IntoIterator<Item = (&'static str, FieldDefinition)>| {
90        iter.into_iter()
91            .map(|(k, v)| (k.to_string(), v))
92            .collect()
93    })]
94    pub body_fields: Option<Record<FieldDefinition>>,
95
96    #[serde(skip_serializing_if = "Option::is_none")]
97    #[builder(with = |iter: impl IntoIterator<Item = (&'static str, FieldDefinition)>| {
98        iter.into_iter()
99            .map(|(k, v)| (k.to_string(), v))
100            .collect()
101    })]
102    pub header_fields: Option<Record<FieldDefinition>>,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106pub enum InputType {
107    #[serde(rename = "http")]
108    Http,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112pub enum Method {
113    #[serde(rename = "get")]
114    Get,
115    #[serde(rename = "post")]
116    Post,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120pub enum InputBodyType {
121    #[serde(rename = "json")]
122    Json,
123    #[serde(rename = "form-data")]
124    FormData,
125    #[serde(rename = "multipart-form-data")]
126    MultipartFormData,
127    #[serde(rename = "text")]
128    Text,
129    #[serde(rename = "binary")]
130    Binary,
131    #[serde(rename = "event-stream")]
132    EventStream,
133}
134
135#[derive(Builder, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct OutputSchema {
138    pub input: Input,
139
140    #[serde(skip_serializing_if = "Option::is_none")]
141    #[builder(with = |iter: impl IntoIterator<Item = (&'static str, FieldDefinition)>| {
142        iter.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
143    })]
144    pub output: Option<Record<FieldDefinition>>,
145}
146
147impl OutputSchema {
148    pub fn discoverable_http_get() -> Self {
149        Self::builder()
150            .input(
151                Input::builder()
152                    .input_type(InputType::Http)
153                    .method(Method::Get)
154                    .discoverable(true)
155                    .build(),
156            )
157            .build()
158    }
159
160    pub fn discoverable_http_post() -> Self {
161        Self::builder()
162            .input(
163                Input::builder()
164                    .input_type(InputType::Http)
165                    .method(Method::Post)
166                    .discoverable(true)
167                    .build(),
168            )
169            .build()
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use serde_json::json;
176
177    use super::*;
178
179    fn setup_complex_input() -> Input {
180        Input::builder()
181            .input_type(InputType::Http)
182            .method(Method::Post)
183            .discoverable(true)
184            .body_type(InputBodyType::Json)
185            .header_fields([(
186                "example_header",
187                FieldDefinition::builder()
188                    .description("An example header")
189                    .field_type("string")
190                    .required(Required)
191                    .build(),
192            )])
193            .query_params([(
194                "exmple_query",
195                FieldDefinition::builder()
196                    .description("An example query parameter")
197                    .field_type("string")
198                    .build(),
199            )])
200            .body_fields([(
201                "example",
202                FieldDefinition::builder()
203                    .description("An example field")
204                    .field_type("string")
205                    .required(["nested_field", "nested_field2"])
206                    .properties([
207                        (
208                            "nested_field",
209                            FieldDefinition::builder()
210                                .field_type("number")
211                                .description("A nested field")
212                                .required(Required)
213                                .build(),
214                        ),
215                        (
216                            "nested_field2",
217                            FieldDefinition::builder()
218                                .field_type("string")
219                                .description("Optional nested field")
220                                .field_enum(["a", "b", "c"])
221                                .build(),
222                        ),
223                    ])
224                    .build(),
225            )])
226            .build()
227    }
228
229    #[test]
230    fn build_input() {
231        let input = setup_complex_input();
232
233        let input_json = json!({
234            "discoverable": true,
235            "type": "http",
236            "method": "post",
237            "bodyType": "json",
238            "headerFields": {
239                "example_header": {
240                    "type": "string",
241                    "required": true,
242                    "description": "An example header"
243                }
244            },
245            "queryParams": {
246                "exmple_query": {
247                    "type": "string",
248                    "description": "An example query parameter"
249                }
250            },
251            "bodyFields": {
252                "example": {
253                    "type": "string",
254                    "required": ["nested_field", "nested_field2"],
255                    "description": "An example field",
256                    "properties": {
257                        "nested_field": {
258                            "type": "number",
259                            "required": true,
260                            "description": "A nested field"
261                        },
262                        "nested_field2": {
263                            "type": "string",
264                            "description": "Optional nested field",
265                            "enum": ["a", "b", "c"]
266                        }
267                    }
268                }
269            }
270        });
271
272        assert_eq!(serde_json::to_value(&input).unwrap(), input_json);
273    }
274
275    #[test]
276    fn build_output_schema() {
277        let input = setup_complex_input();
278
279        let output_schema = OutputSchema::builder()
280            .input(input.clone())
281            .output([(
282                "response_field",
283                FieldDefinition::builder()
284                    .field_type("string")
285                    .description("A response field")
286                    .required(Required)
287                    .build(),
288            )])
289            .build();
290
291        let output_schema_json = json!({
292            "input": {
293                "discoverable": true,
294                "type": "http",
295                "method": "post",
296                "bodyType": "json",
297                "headerFields": {
298                    "example_header": {
299                        "type": "string",
300                        "required": true,
301                        "description": "An example header"
302                    }
303                },
304                "queryParams": {
305                    "exmple_query": {
306                        "type": "string",
307                        "description": "An example query parameter"
308                    }
309                },
310                "bodyFields": {
311                    "example": {
312                        "type": "string",
313                        "required": ["nested_field", "nested_field2"],
314                        "description": "An example field",
315                        "properties": {
316                            "nested_field": {
317                                "type": "number",
318                                "required": true,
319                                "description": "A nested field"
320                            },
321                            "nested_field2": {
322                                "type": "string",
323                                "description": "Optional nested field",
324                                "enum": ["a", "b", "c"]
325                            }
326                        }
327                    }
328                }
329            },
330            "output": {
331                "response_field": {
332                    "type": "string",
333                    "required": true,
334                    "description": "A response field"
335                }
336            }
337        });
338
339        assert_eq!(
340            serde_json::to_value(&output_schema).unwrap(),
341            output_schema_json
342        );
343    }
344
345    #[test]
346    fn discoverable_helpers() {
347        let get_schema = OutputSchema::discoverable_http_get();
348        assert!(matches!(get_schema.input.input_type, InputType::Http));
349        assert_eq!(get_schema.input.method, Method::Get);
350        assert!(get_schema.input.discoverable);
351
352        let get_schema_json = json!({
353            "input": {
354                "discoverable": true,
355                "type": "http",
356                "method": "get"
357            }
358        });
359
360        assert_eq!(serde_json::to_value(&get_schema).unwrap(), get_schema_json);
361
362        let post_schema = OutputSchema::discoverable_http_post();
363        assert!(matches!(post_schema.input.input_type, InputType::Http));
364        assert_eq!(post_schema.input.method, Method::Post);
365        assert!(post_schema.input.discoverable);
366
367        let post_schema_json = json!({
368            "input": {
369                "discoverable": true,
370                "type": "http",
371                "method": "post"
372            }
373        });
374
375        assert_eq!(
376            serde_json::to_value(&post_schema).unwrap(),
377            post_schema_json
378        );
379    }
380}