Skip to main content

clawspec_core/client/parameters/
body.rs

1use headers::ContentType;
2use serde::Serialize;
3use utoipa::ToSchema;
4
5use crate::client::error::ApiClientError;
6use crate::client::openapi::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(in crate::client) content_type: ContentType,
16    pub(in crate::client) entry: SchemaEntry,
17    #[debug(ignore)]
18    pub(in crate::client) 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    /// Creates a JSON body without setting an example.
220    ///
221    /// This method is used internally by the redaction feature to create a body
222    /// where the example will be set later after redaction is applied.
223    ///
224    /// The original value is serialized for the HTTP request, but no example
225    /// is added to the schema entry. The example will be set separately with
226    /// the redacted value for OpenAPI documentation.
227    #[cfg(feature = "redaction")]
228    pub(crate) fn json_without_example<T>(t: &T) -> Result<Self, ApiClientError>
229    where
230        T: Serialize + ToSchema + 'static,
231    {
232        let content_type = ContentType::json();
233        let entry = SchemaEntry::of::<T>(); // No example yet
234        let data = serde_json::to_vec(t)?;
235
236        Ok(Self {
237            content_type,
238            entry,
239            data,
240        })
241    }
242
243    /// Sets the example on the schema entry.
244    ///
245    /// This method is used internally by the redaction feature to set the
246    /// redacted example value for OpenAPI documentation.
247    #[cfg(feature = "redaction")]
248    pub(crate) fn set_example(&mut self, example: serde_json::Value) {
249        self.entry.add_example(example);
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use serde::{Deserialize, Serialize};
257    use utoipa::ToSchema;
258
259    #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
260    struct TestData {
261        name: String,
262        value: i32,
263    }
264
265    #[test]
266    fn test_call_body_json_creates_valid_body() {
267        let test_data = TestData {
268            name: "test".to_string(),
269            value: 42,
270        };
271
272        let body = CallBody::json(&test_data).expect("should create body");
273
274        insta::assert_debug_snapshot!(body, @r#"
275        CallBody {
276            content_type: ContentType(
277                "application/json",
278            ),
279            entry: SchemaEntry {
280                type_name: "clawspec_core::client::parameters::body::tests::TestData",
281                name: "TestData",
282                examples: {
283                    Object {
284                        "name": String("test"),
285                        "value": Number(42),
286                    },
287                },
288                ..
289            },
290            ..
291        }
292        "#);
293        let parsed = serde_json::from_slice::<TestData>(&body.data).expect("should parse JSON");
294        assert_eq!(parsed, test_data);
295    }
296
297    #[test]
298    fn test_call_body_form_creates_valid_body() {
299        let test_data = TestData {
300            name: "test user".to_string(),
301            value: 42,
302        };
303
304        let body = CallBody::form(&test_data).expect("should create form body");
305
306        assert_eq!(body.content_type, headers::ContentType::form_url_encoded());
307        assert_eq!(body.entry.name, "TestData");
308
309        // Verify the form encoding
310        let form_data = String::from_utf8(body.data).expect("should be valid UTF-8");
311        insta::assert_snapshot!(form_data, @"name=test+user&value=42");
312    }
313
314    #[test]
315    fn test_call_body_raw_creates_valid_body() {
316        let binary_data = vec![0xFF, 0xFE, 0xFD, 0xFC];
317        let content_type = headers::ContentType::octet_stream();
318
319        let body = CallBody::raw(binary_data.clone(), content_type.clone());
320
321        assert_eq!(body.content_type, content_type);
322        assert_eq!(body.entry.name, "binary");
323        assert_eq!(body.data, binary_data);
324    }
325
326    #[test]
327    fn test_call_body_text_creates_valid_body() {
328        let text_content = "Hello, World!";
329
330        let body = CallBody::text(text_content);
331
332        assert_eq!(body.content_type, headers::ContentType::text());
333        assert_eq!(body.entry.name, "binary");
334        assert_eq!(body.data, text_content.as_bytes());
335    }
336}