clawspec_core/client/call/
execution.rs

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