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 HttpInput {
70    pub discoverable: bool,
71
72    pub method: Method,
73
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub body_type: Option<InputBodyType>,
76
77    #[serde(skip_serializing_if = "Option::is_none")]
78    #[builder(with = |iter: impl IntoIterator<Item = (&'static str, FieldDefinition)>| {
79        iter.into_iter()
80            .map(|(k, v)| (k.to_string(), v))
81            .collect()
82    })]
83    pub query_params: Option<Record<FieldDefinition>>,
84
85    #[serde(skip_serializing_if = "Option::is_none")]
86    #[builder(with = |iter: impl IntoIterator<Item = (&'static str, FieldDefinition)>| {
87        iter.into_iter()
88            .map(|(k, v)| (k.to_string(), v))
89            .collect()
90    })]
91    pub body_fields: Option<Record<FieldDefinition>>,
92
93    #[serde(skip_serializing_if = "Option::is_none")]
94    #[builder(with = |iter: impl IntoIterator<Item = (&'static str, FieldDefinition)>| {
95        iter.into_iter()
96            .map(|(k, v)| (k.to_string(), v))
97            .collect()
98    })]
99    pub header_fields: Option<Record<FieldDefinition>>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "type")]
104pub enum Input {
105    #[serde(rename = "http")]
106    Http(HttpInput),
107}
108
109impl Input {
110    pub fn as_http(&self) -> Option<&HttpInput> {
111        match self {
112            Input::Http(http_input) => Some(http_input),
113        }
114    }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118pub enum Method {
119    #[serde(rename = "GET")]
120    Get,
121    #[serde(rename = "POST")]
122    Post,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126pub enum InputBodyType {
127    #[serde(rename = "json")]
128    Json,
129    #[serde(rename = "form-data")]
130    FormData,
131    #[serde(rename = "multipart-form-data")]
132    MultipartFormData,
133    #[serde(rename = "text")]
134    Text,
135    #[serde(rename = "binary")]
136    Binary,
137    #[serde(rename = "event-stream")]
138    EventStream,
139}
140
141#[derive(Builder, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct OutputSchema {
144    pub input: Input,
145
146    #[serde(skip_serializing_if = "Option::is_none")]
147    #[builder(with = |iter: impl IntoIterator<Item = (&'static str, FieldDefinition)>| {
148        iter.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
149    })]
150    pub output: Option<Record<FieldDefinition>>,
151}
152
153impl OutputSchema {
154    pub fn http_get_discoverable() -> Self {
155        Self::builder()
156            .input(Input::Http(
157                HttpInput::builder()
158                    .method(Method::Get)
159                    .discoverable(true)
160                    .build(),
161            ))
162            .build()
163    }
164
165    pub fn http_post_discoverable() -> Self {
166        Self::builder()
167            .input(Input::Http(
168                HttpInput::builder()
169                    .method(Method::Post)
170                    .discoverable(true)
171                    .build(),
172            ))
173            .build()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use serde_json::json;
180
181    use super::*;
182
183    fn setup_complex_input() -> Input {
184        Input::Http(
185            HttpInput::builder()
186                .method(Method::Post)
187                .discoverable(true)
188                .body_type(InputBodyType::Json)
189                .header_fields([(
190                    "example_header",
191                    FieldDefinition::builder()
192                        .description("An example header")
193                        .field_type("string")
194                        .required(Required)
195                        .build(),
196                )])
197                .query_params([(
198                    "exmple_query",
199                    FieldDefinition::builder()
200                        .description("An example query parameter")
201                        .field_type("string")
202                        .build(),
203                )])
204                .body_fields([(
205                    "example",
206                    FieldDefinition::builder()
207                        .description("An example field")
208                        .field_type("string")
209                        .required(["nested_field", "nested_field2"])
210                        .properties([
211                            (
212                                "nested_field",
213                                FieldDefinition::builder()
214                                    .field_type("number")
215                                    .description("A nested field")
216                                    .required(Required)
217                                    .build(),
218                            ),
219                            (
220                                "nested_field2",
221                                FieldDefinition::builder()
222                                    .field_type("string")
223                                    .description("Optional nested field")
224                                    .field_enum(["a", "b", "c"])
225                                    .build(),
226                            ),
227                        ])
228                        .build(),
229                )])
230                .build(),
231        )
232    }
233
234    #[test]
235    fn build_input() {
236        let input = setup_complex_input();
237
238        let input_json = json!({
239            "discoverable": true,
240            "type": "http",
241            "method": "post",
242            "bodyType": "json",
243            "headerFields": {
244                "example_header": {
245                    "type": "string",
246                    "required": true,
247                    "description": "An example header"
248                }
249            },
250            "queryParams": {
251                "exmple_query": {
252                    "type": "string",
253                    "description": "An example query parameter"
254                }
255            },
256            "bodyFields": {
257                "example": {
258                    "type": "string",
259                    "required": ["nested_field", "nested_field2"],
260                    "description": "An example field",
261                    "properties": {
262                        "nested_field": {
263                            "type": "number",
264                            "required": true,
265                            "description": "A nested field"
266                        },
267                        "nested_field2": {
268                            "type": "string",
269                            "description": "Optional nested field",
270                            "enum": ["a", "b", "c"]
271                        }
272                    }
273                }
274            }
275        });
276
277        assert_eq!(serde_json::to_value(&input).unwrap(), input_json);
278    }
279
280    #[test]
281    fn build_output_schema() {
282        let input = setup_complex_input();
283
284        let output_schema = OutputSchema::builder()
285            .input(input.clone())
286            .output([(
287                "response_field",
288                FieldDefinition::builder()
289                    .field_type("string")
290                    .description("A response field")
291                    .required(Required)
292                    .build(),
293            )])
294            .build();
295
296        let output_schema_json = json!({
297            "input": {
298                "discoverable": true,
299                "type": "http",
300                "method": "post",
301                "bodyType": "json",
302                "headerFields": {
303                    "example_header": {
304                        "type": "string",
305                        "required": true,
306                        "description": "An example header"
307                    }
308                },
309                "queryParams": {
310                    "exmple_query": {
311                        "type": "string",
312                        "description": "An example query parameter"
313                    }
314                },
315                "bodyFields": {
316                    "example": {
317                        "type": "string",
318                        "required": ["nested_field", "nested_field2"],
319                        "description": "An example field",
320                        "properties": {
321                            "nested_field": {
322                                "type": "number",
323                                "required": true,
324                                "description": "A nested field"
325                            },
326                            "nested_field2": {
327                                "type": "string",
328                                "description": "Optional nested field",
329                                "enum": ["a", "b", "c"]
330                            }
331                        }
332                    }
333                }
334            },
335            "output": {
336                "response_field": {
337                    "type": "string",
338                    "required": true,
339                    "description": "A response field"
340                }
341            }
342        });
343
344        assert_eq!(
345            serde_json::to_value(&output_schema).unwrap(),
346            output_schema_json
347        );
348    }
349
350    #[test]
351    fn discoverable_helpers() {
352        let get_schema = OutputSchema::http_get_discoverable();
353        assert!(get_schema.input.as_http().unwrap().discoverable);
354        assert_eq!(get_schema.input.as_http().unwrap().method, Method::Get);
355
356        let get_schema_json = json!({
357            "input": {
358                "discoverable": true,
359                "type": "http",
360                "method": "get"
361            }
362        });
363
364        assert_eq!(serde_json::to_value(&get_schema).unwrap(), get_schema_json);
365
366        let post_schema = OutputSchema::http_post_discoverable();
367        assert_eq!(post_schema.input.as_http().unwrap().method, Method::Post);
368        assert!(post_schema.input.as_http().unwrap().discoverable);
369
370        let post_schema_json = json!({
371            "input": {
372                "discoverable": true,
373                "type": "http",
374                "method": "post"
375            }
376        });
377
378        assert_eq!(
379            serde_json::to_value(&post_schema).unwrap(),
380            post_schema_json
381        );
382    }
383}