clawspec_core/client/
body.rs

1use headers::ContentType;
2use serde::Serialize;
3use utoipa::ToSchema;
4
5use super::ApiClientError;
6use super::schema::SchemaEntry;
7
8/// Represents the body of an HTTP request with its content type and schema information.
9///
10/// `CallBody` encapsulates the raw body data, content type, and schema entry information
11/// needed for API requests. It supports various content types including JSON, form-encoded,
12/// and raw binary data.
13#[derive(Clone, derive_more::Debug)]
14pub struct CallBody {
15    pub(super) content_type: ContentType,
16    pub(super) entry: SchemaEntry,
17    #[debug(ignore)]
18    pub(super) data: Vec<u8>,
19}
20
21impl CallBody {
22    /// Creates a JSON body from a serializable type.
23    ///
24    /// This method serializes the data as `application/json` using the `serde_json` crate.
25    /// The data must implement `Serialize` and `ToSchema` for proper JSON serialization
26    /// and OpenAPI schema generation.
27    ///
28    /// # Examples
29    ///
30    /// ```rust
31    /// # use clawspec_core::CallBody;
32    /// # use serde::Serialize;
33    /// # use utoipa::ToSchema;
34    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
35    /// #[derive(Serialize, ToSchema)]
36    /// struct User {
37    ///     name: String,
38    ///     age: u32,
39    /// }
40    ///
41    /// let user = User {
42    ///     name: "Alice".to_string(),
43    ///     age: 30,
44    /// };
45    ///
46    /// let body = CallBody::json(&user)?;
47    /// # Ok(())
48    /// # }
49    /// ```
50    pub fn json<T>(t: &T) -> Result<Self, ApiClientError>
51    where
52        T: Serialize + ToSchema + 'static,
53    {
54        let content_type = ContentType::json();
55
56        let mut entry = SchemaEntry::of::<T>();
57        let example = serde_json::to_value(t)?;
58        entry.add_example(example);
59
60        let data = serde_json::to_vec(t)?;
61
62        let result = Self {
63            content_type,
64            entry,
65            data,
66        };
67        Ok(result)
68    }
69
70    /// Creates a form-encoded body from a serializable type.
71    ///
72    /// This method serializes the data as `application/x-www-form-urlencoded`
73    /// using the `serde_urlencoded` crate. The data must implement `Serialize`
74    /// and `ToSchema` for proper form encoding and OpenAPI schema generation.
75    ///
76    /// # Examples
77    ///
78    /// ```rust
79    /// # use clawspec_core::CallBody;
80    /// # use serde::Serialize;
81    /// # use utoipa::ToSchema;
82    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
83    /// #[derive(Serialize, ToSchema)]
84    /// struct LoginForm {
85    ///     username: String,
86    ///     password: String,
87    /// }
88    ///
89    /// let form = LoginForm {
90    ///     username: "user@example.com".to_string(),
91    ///     password: "secret".to_string(),
92    /// };
93    ///
94    /// let body = CallBody::form(&form)?;
95    /// # Ok(())
96    /// # }
97    /// ```
98    pub fn form<T>(t: &T) -> Result<Self, ApiClientError>
99    where
100        T: Serialize + ToSchema + 'static,
101    {
102        let content_type = ContentType::form_url_encoded();
103
104        let mut entry = SchemaEntry::of::<T>();
105        let example = serde_json::to_value(t)?;
106        entry.add_example(example);
107
108        let data = serde_urlencoded::to_string(t)
109            .map_err(|e| ApiClientError::SerializationError {
110                message: format!("Failed to serialize form data: {e}"),
111            })?
112            .into_bytes();
113
114        let result = Self {
115            content_type,
116            entry,
117            data,
118        };
119        Ok(result)
120    }
121
122    /// Creates a raw body with custom content type.
123    ///
124    /// This method allows you to send arbitrary binary data with a specified
125    /// content type. This is useful for sending data that doesn't fit into
126    /// the standard JSON or form categories.
127    ///
128    /// # Examples
129    ///
130    /// ```rust
131    /// use clawspec_core::CallBody;
132    /// use headers::ContentType;
133    ///
134    /// // Send XML data
135    /// let xml_data = r#"<?xml version="1.0"?><user><name>John</name></user>"#;
136    /// let body = CallBody::raw(
137    ///     xml_data.as_bytes().to_vec(),
138    ///     ContentType::xml()
139    /// );
140    ///
141    /// // Send binary data
142    /// let binary_data = vec![0xFF, 0xFE, 0xFD];
143    /// let body = CallBody::raw(
144    ///     binary_data,
145    ///     ContentType::octet_stream()
146    /// );
147    /// ```
148    pub fn raw(data: Vec<u8>, content_type: ContentType) -> Self {
149        // For raw bodies, we don't have a specific type to generate schema from,
150        // so we create a generic binary schema entry
151        let entry = SchemaEntry::raw_binary();
152
153        Self {
154            content_type,
155            entry,
156            data,
157        }
158    }
159
160    /// Creates a text body with text/plain content type.
161    ///
162    /// This is a convenience method for sending plain text data.
163    ///
164    /// # Examples
165    ///
166    /// ```rust
167    /// use clawspec_core::CallBody;
168    ///
169    /// let body = CallBody::text("Hello, World!");
170    /// ```
171    pub fn text(text: &str) -> Self {
172        Self::raw(text.as_bytes().to_vec(), ContentType::text())
173    }
174
175    /// Creates a multipart/form-data body for file uploads and form data.
176    ///
177    /// This method creates a multipart body with a generated boundary and supports
178    /// both text fields and file uploads. The boundary is automatically generated
179    /// and included in the content type.
180    ///
181    /// # Examples
182    ///
183    /// ```rust
184    /// use clawspec_core::CallBody;
185    ///
186    /// let mut parts = Vec::new();
187    /// parts.push(("field1", "value1"));
188    /// parts.push(("file", "file content"));
189    ///
190    /// let body = CallBody::multipart(parts);
191    /// ```
192    pub fn multipart(parts: Vec<(&str, &str)>) -> Self {
193        let boundary = format!("----formdata-clawspec-{}", uuid::Uuid::new_v4());
194        let content_type = format!("multipart/form-data; boundary={boundary}");
195
196        let mut body_data = Vec::new();
197
198        for (name, value) in parts {
199            body_data.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
200            body_data.extend_from_slice(
201                format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
202            );
203            body_data.extend_from_slice(value.as_bytes());
204            body_data.extend_from_slice(b"\r\n");
205        }
206
207        body_data.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
208
209        let content_type = ContentType::from(content_type.parse::<mime::Mime>().unwrap());
210        let entry = SchemaEntry::raw_binary();
211
212        Self {
213            content_type,
214            entry,
215            data: body_data,
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use serde::{Deserialize, Serialize};
224    use utoipa::ToSchema;
225
226    #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
227    struct TestData {
228        name: String,
229        value: i32,
230    }
231
232    #[test]
233    fn test_call_body_json_creates_valid_body() {
234        let test_data = TestData {
235            name: "test".to_string(),
236            value: 42,
237        };
238
239        let body = CallBody::json(&test_data).expect("should create body");
240
241        insta::assert_debug_snapshot!(body, @r#"
242        CallBody {
243            content_type: ContentType(
244                "application/json",
245            ),
246            entry: SchemaEntry {
247                type_name: "clawspec_core::client::body::tests::TestData",
248                name: "TestData",
249                examples: {
250                    Object {
251                        "name": String("test"),
252                        "value": Number(42),
253                    },
254                },
255                ..
256            },
257            ..
258        }
259        "#);
260        let parsed = serde_json::from_slice::<TestData>(&body.data).expect("should parse JSON");
261        assert_eq!(parsed, test_data);
262    }
263
264    #[test]
265    fn test_call_body_form_creates_valid_body() {
266        let test_data = TestData {
267            name: "test user".to_string(),
268            value: 42,
269        };
270
271        let body = CallBody::form(&test_data).expect("should create form body");
272
273        assert_eq!(body.content_type, headers::ContentType::form_url_encoded());
274        assert_eq!(body.entry.name, "TestData");
275
276        // Verify the form encoding
277        let form_data = String::from_utf8(body.data).expect("should be valid UTF-8");
278        insta::assert_snapshot!(form_data, @"name=test+user&value=42");
279    }
280
281    #[test]
282    fn test_call_body_raw_creates_valid_body() {
283        let binary_data = vec![0xFF, 0xFE, 0xFD, 0xFC];
284        let content_type = headers::ContentType::octet_stream();
285
286        let body = CallBody::raw(binary_data.clone(), content_type.clone());
287
288        assert_eq!(body.content_type, content_type);
289        assert_eq!(body.entry.name, "binary");
290        assert_eq!(body.data, binary_data);
291    }
292
293    #[test]
294    fn test_call_body_text_creates_valid_body() {
295        let text_content = "Hello, World!";
296
297        let body = CallBody::text(text_content);
298
299        assert_eq!(body.content_type, headers::ContentType::text());
300        assert_eq!(body.entry.name, "binary");
301        assert_eq!(body.data, text_content.as_bytes());
302    }
303}