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}