clawspec_core/client/call/
execution.rs

1use std::future::{Future, IntoFuture};
2use std::pin::Pin;
3
4use headers::HeaderMapExt;
5use http::header::{HeaderName, HeaderValue};
6use http::{Method, Uri};
7use reqwest::{Body, Request};
8use tracing::debug;
9use url::Url;
10
11use super::{ApiCall, BODY_MAX_LENGTH, CollectorSender};
12use crate::client::call_parameters::{CallParameters, OperationMetadata};
13use crate::client::openapi::CalledOperation;
14use crate::client::openapi::channel::CollectorMessage;
15use crate::client::parameters::PathResolved;
16use crate::client::response::ExpectedStatusCodes;
17use crate::client::{ApiClientError, CallBody, CallPath, CallQuery, CallResult};
18
19impl ApiCall {
20    pub(in crate::client) fn build(
21        client: reqwest::Client,
22        base_uri: Uri,
23        collector_sender: CollectorSender,
24        method: Method,
25        path: CallPath,
26        authentication: Option<crate::client::Authentication>,
27    ) -> Result<Self, ApiClientError> {
28        let operation_id = slug::slugify(format!("{method} {}", path.path));
29
30        let result = Self {
31            client,
32            base_uri,
33            collector_sender,
34            method,
35            path,
36            query: CallQuery::default(),
37            headers: None,
38            body: None,
39            authentication,
40            cookies: None,
41            expected_status_codes: ExpectedStatusCodes::default(),
42            metadata: OperationMetadata {
43                operation_id,
44                tags: None,
45                description: None,
46                response_description: None,
47            },
48            response_description: None,
49            skip_collection: false,
50        };
51        Ok(result)
52    }
53}
54
55impl ApiCall {
56    /// Executes the HTTP request and returns a result that must be consumed for OpenAPI generation.
57    ///
58    /// This method sends the configured HTTP request to the server and returns a [`CallResult`]
59    /// that contains the response. **You must call one of the response processing methods**
60    /// on the returned `CallResult` to ensure proper OpenAPI documentation generation.
61    ///
62    /// # ⚠️ Important: Response Consumption Required
63    ///
64    /// Simply calling `exchange()` is not sufficient! You must consume the [`CallResult`] by
65    /// calling one of these methods:
66    ///
67    /// - [`CallResult::as_empty()`] - For empty responses (204 No Content, DELETE operations, etc.)
68    /// - [`CallResult::as_json::<T>()`] - For JSON responses that should be deserialized
69    /// - [`CallResult::as_text()`] - For plain text responses
70    /// - [`CallResult::as_bytes()`] - For binary responses
71    /// - [`CallResult::as_raw()`] - For complete raw response access (status, content-type, body)
72    ///
73    /// # Example
74    ///
75    /// ```rust
76    /// use clawspec_core::ApiClient;
77    /// # use serde::Deserialize;
78    /// # use utoipa::ToSchema;
79    /// # #[derive(Deserialize, ToSchema)]
80    /// # struct User { id: u32, name: String }
81    ///
82    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
83    /// let mut client = ApiClient::builder().build()?;
84    ///
85    /// // ✅ CORRECT: Always consume the result
86    /// let user: User = client
87    ///     .get("/users/123")?
88    ///     .await?
89    ///     .as_json()  // ← Required for OpenAPI generation!
90    ///     .await?;
91    ///
92    /// // ✅ CORRECT: For operations returning empty responses
93    /// client
94    ///     .delete("/users/123")?
95    ///     .await?
96    ///     .as_empty()  // ← Required for OpenAPI generation!
97    ///     .await?;
98    /// # Ok(())
99    /// # }
100    /// ```
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if:
105    /// - The HTTP request fails (network issues, timeouts, etc.)
106    /// - The response status code is not in the expected range
107    /// - Request building fails (invalid URLs, malformed headers, etc.)
108    ///
109    /// # OpenAPI Documentation
110    ///
111    /// This method automatically collects operation metadata for OpenAPI generation,
112    /// but the response schema and examples are only captured when the [`CallResult`]
113    /// is properly consumed with one of the `as_*` methods.
114    // TODO: Abstract client implementation to support multiple clients - https://github.com/ilaborie/clawspec/issues/78
115    async fn exchange(self) -> Result<CallResult, ApiClientError> {
116        let Self {
117            client,
118            base_uri,
119            collector_sender,
120            method,
121            path,
122            query,
123            headers,
124            body,
125            authentication,
126            cookies,
127            expected_status_codes,
128            metadata,
129            response_description,
130            skip_collection,
131        } = self;
132
133        // Build URL and request
134        let url = Self::build_url(&base_uri, &path, &query)?;
135        let parameters = CallParameters::with_all(query.clone(), headers.clone(), cookies.clone());
136        let request =
137            Self::build_request(method.clone(), url, &parameters, &body, &authentication)?;
138
139        // Create operation for OpenAPI documentation
140        let operation_id = metadata.operation_id.clone();
141        let mut operation = Self::build_operation(
142            metadata,
143            &method,
144            &path,
145            parameters.clone(),
146            &body,
147            response_description,
148        );
149
150        // Execute HTTP request
151        debug!(?request, "sending...");
152        let response = client.execute(request).await?;
153        debug!(?response, "...receiving");
154
155        // Validate status code
156        let status_code = response.status().as_u16();
157        if !expected_status_codes.contains(status_code) {
158            // Get the body only if status code is unexpected
159            let body = response
160                .text()
161                .await
162                .map(|text| {
163                    if text.len() > BODY_MAX_LENGTH {
164                        format!("{}... (truncated)", &text[..1024])
165                    } else {
166                        text
167                    }
168                })
169                .unwrap_or_else(|e| format!("<unable to read response body: {e}>"));
170            return Err(ApiClientError::UnexpectedStatusCode { status_code, body });
171        }
172
173        // Process response and collect schemas (only if collection is enabled)
174        let call_result = if skip_collection {
175            CallResult::new_without_collection(response).await?
176        } else {
177            let call_result =
178                CallResult::new(operation_id, collector_sender.clone(), response).await?;
179            operation.add_response(call_result.clone());
180            Self::collect_schemas_and_operation(
181                &collector_sender,
182                &path,
183                &parameters,
184                &body,
185                operation,
186            )
187            .await;
188            call_result
189        };
190
191        Ok(call_result)
192    }
193
194    pub(super) fn build_url(
195        base_uri: &Uri,
196        path: &CallPath,
197        query: &CallQuery,
198    ) -> Result<Url, ApiClientError> {
199        let path_resolved = PathResolved::try_from(path.clone())?;
200        let base_uri = base_uri.to_string();
201        let url = format!(
202            "{}/{}",
203            base_uri.trim_end_matches('/'),
204            path_resolved.path.trim_start_matches('/')
205        );
206        let mut url = url.parse::<Url>()?;
207
208        if !query.is_empty() {
209            let query_string = query.to_query_string()?;
210            url.set_query(Some(&query_string));
211        }
212
213        Ok(url)
214    }
215
216    pub(super) fn build_request(
217        method: Method,
218        url: Url,
219        parameters: &CallParameters,
220        body: &Option<CallBody>,
221        authentication: &Option<crate::client::Authentication>,
222    ) -> Result<Request, ApiClientError> {
223        let mut request = Request::new(method, url);
224        let req_headers = request.headers_mut();
225
226        // Add authentication header if present
227        if let Some(auth) = authentication {
228            let (header_name, header_value) = auth.to_header()?;
229            req_headers.insert(header_name, header_value);
230        }
231
232        // Add custom headers
233        for (name, value) in parameters.to_http_headers()? {
234            req_headers.insert(
235                HeaderName::from_bytes(name.as_bytes())?,
236                HeaderValue::from_str(&value)?,
237            );
238        }
239
240        // Add cookies as Cookie header
241        let cookie_header = parameters.to_cookie_header()?;
242        if !cookie_header.is_empty() {
243            req_headers.insert(
244                HeaderName::from_static("cookie"),
245                HeaderValue::from_str(&cookie_header)?,
246            );
247        }
248
249        // Set body
250        if let Some(body) = body {
251            req_headers.typed_insert(body.content_type.clone());
252            let req_body = request.body_mut();
253            *req_body = Some(Body::from(body.data.clone()));
254        }
255
256        Ok(request)
257    }
258
259    fn build_operation(
260        metadata: OperationMetadata,
261        method: &Method,
262        path: &CallPath,
263        parameters: CallParameters,
264        body: &Option<CallBody>,
265        response_description: Option<String>,
266    ) -> CalledOperation {
267        let OperationMetadata {
268            operation_id,
269            tags,
270            description,
271            response_description: _,
272        } = metadata;
273
274        CalledOperation::build(
275            method.clone(),
276            &path.path,
277            path,
278            parameters,
279            body.as_ref(),
280            OperationMetadata {
281                operation_id: operation_id.to_string(),
282                tags,
283                description,
284                response_description,
285            },
286        )
287    }
288
289    async fn collect_schemas_and_operation(
290        sender: &CollectorSender,
291        path: &CallPath,
292        parameters: &CallParameters,
293        body: &Option<CallBody>,
294        operation: CalledOperation,
295    ) {
296        // Send path schemas
297        sender
298            .send(CollectorMessage::AddSchemas(path.schemas().clone()))
299            .await;
300
301        // Send parameter schemas
302        sender
303            .send(CollectorMessage::AddSchemas(parameters.collect_schemas()))
304            .await;
305
306        // Send body schema entry if present
307        if let Some(body) = body {
308            sender
309                .send(CollectorMessage::AddSchemaEntry(body.entry.clone()))
310                .await;
311        }
312
313        // Register the operation
314        sender
315            .send(CollectorMessage::RegisterOperation(operation))
316            .await;
317    }
318}
319
320/// Implement IntoFuture for ApiCall to enable direct .await syntax
321///
322/// This provides a more ergonomic API by allowing direct `.await` on ApiCall:
323/// ```rust,no_run
324/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
325/// # let mut client = clawspec_core::ApiClient::builder().build()?;
326/// let response = client.get("/users")?.await?;
327/// # Ok(())
328/// # }
329/// ```
330impl IntoFuture for ApiCall {
331    type Output = Result<CallResult, ApiClientError>;
332    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
333
334    fn into_future(self) -> Self::IntoFuture {
335        Box::pin(self.exchange())
336    }
337}