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}