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        default_security: Option<Vec<crate::client::security::SecurityRequirement>>,
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            collector_sender,
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                #[cfg(feature = "redaction")]
48                response_description: None,
49            },
50            #[cfg(feature = "redaction")]
51            response_description: None,
52            skip_collection: false,
53            security: default_security,
54        };
55        Ok(result)
56    }
57}
58
59impl ApiCall {
60    /// Executes the HTTP request and returns a result that must be consumed for OpenAPI generation.
61    ///
62    /// This method sends the configured HTTP request to the server and returns a [`CallResult`]
63    /// that contains the response. **You must call one of the response processing methods**
64    /// on the returned `CallResult` to ensure proper OpenAPI documentation generation.
65    ///
66    /// # ⚠️ Important: Response Consumption Required
67    ///
68    /// Simply calling `exchange()` is not sufficient! You must consume the [`CallResult`] by
69    /// calling one of these methods:
70    ///
71    /// - [`CallResult::as_empty()`] - For empty responses (204 No Content, DELETE operations, etc.)
72    /// - [`CallResult::as_json::<T>()`] - For JSON responses that should be deserialized
73    /// - [`CallResult::as_text()`] - For plain text responses
74    /// - [`CallResult::as_bytes()`] - For binary responses
75    /// - [`CallResult::as_raw()`] - For complete raw response access (status, content-type, body)
76    ///
77    /// # Example
78    ///
79    /// ```rust
80    /// use clawspec_core::ApiClient;
81    /// # use serde::Deserialize;
82    /// # use utoipa::ToSchema;
83    /// # #[derive(Deserialize, ToSchema)]
84    /// # struct User { id: u32, name: String }
85    ///
86    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
87    /// let mut client = ApiClient::builder().build()?;
88    ///
89    /// // ✅ CORRECT: Always consume the result
90    /// let user: User = client
91    ///     .get("/users/123")?
92    ///     .await?
93    ///     .as_json()  // ← Required for OpenAPI generation!
94    ///     .await?;
95    ///
96    /// // ✅ CORRECT: For operations returning empty responses
97    /// client
98    ///     .delete("/users/123")?
99    ///     .await?
100    ///     .as_empty()  // ← Required for OpenAPI generation!
101    ///     .await?;
102    /// # Ok(())
103    /// # }
104    /// ```
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if:
109    /// - The HTTP request fails (network issues, timeouts, etc.)
110    /// - The response status code is not in the expected range
111    /// - Request building fails (invalid URLs, malformed headers, etc.)
112    ///
113    /// # OpenAPI Documentation
114    ///
115    /// This method automatically collects operation metadata for OpenAPI generation,
116    /// but the response schema and examples are only captured when the [`CallResult`]
117    /// is properly consumed with one of the `as_*` methods.
118    // TODO: Abstract client implementation to support multiple clients - https://github.com/ilaborie/clawspec/issues/78
119    async fn exchange(self) -> Result<CallResult, ApiClientError> {
120        let Self {
121            client,
122            base_uri,
123            collector_sender,
124            method,
125            path,
126            query,
127            headers,
128            body,
129            authentication,
130            cookies,
131            expected_status_codes,
132            metadata,
133            #[cfg(feature = "redaction")]
134            response_description,
135            skip_collection,
136            security,
137        } = self;
138
139        // Resolve OAuth2 authentication to Bearer if needed
140        let resolved_auth = Self::resolve_authentication(authentication).await?;
141
142        // Build URL and request
143        let url = Self::build_url(&base_uri, &path, &query)?;
144        let parameters = CallParameters::with_all(query.clone(), headers.clone(), cookies.clone());
145        let request = Self::build_request(method.clone(), url, &parameters, &body, &resolved_auth)?;
146
147        // Create operation for OpenAPI documentation
148        let operation_id = metadata.operation_id.clone();
149        #[cfg(feature = "redaction")]
150        let mut operation = Self::build_operation(
151            metadata,
152            &method,
153            &path,
154            parameters.clone(),
155            &body,
156            response_description,
157            security,
158        );
159        #[cfg(not(feature = "redaction"))]
160        let mut operation = Self::build_operation(
161            metadata,
162            &method,
163            &path,
164            parameters.clone(),
165            &body,
166            security,
167        );
168
169        // Execute HTTP request
170        debug!(?request, "sending...");
171        let response = client.execute(request).await?;
172        debug!(?response, "...receiving");
173
174        // Validate status code
175        let status_code = response.status().as_u16();
176        if !expected_status_codes.contains(status_code) {
177            // Get the body only if status code is unexpected
178            let body = response
179                .text()
180                .await
181                .map(|text| {
182                    if text.len() > BODY_MAX_LENGTH {
183                        format!("{}... (truncated)", &text[..1024])
184                    } else {
185                        text
186                    }
187                })
188                .unwrap_or_else(|e| format!("<unable to read response body: {e}>"));
189            return Err(ApiClientError::UnexpectedStatusCode { status_code, body });
190        }
191
192        // Process response and collect schemas (only if collection is enabled)
193        let call_result = if skip_collection {
194            CallResult::new_without_collection(response).await?
195        } else {
196            let call_result =
197                CallResult::new(operation_id, collector_sender.clone(), response).await?;
198            operation.add_response(call_result.clone());
199            Self::collect_schemas_and_operation(
200                &collector_sender,
201                &path,
202                &parameters,
203                &body,
204                operation,
205            )
206            .await;
207            call_result
208        };
209
210        Ok(call_result)
211    }
212
213    pub(super) fn build_url(
214        base_uri: &Uri,
215        path: &CallPath,
216        query: &CallQuery,
217    ) -> Result<Url, ApiClientError> {
218        let path_resolved = PathResolved::try_from(path.clone())?;
219        let base_uri = base_uri.to_string();
220        let url = format!(
221            "{}/{}",
222            base_uri.trim_end_matches('/'),
223            path_resolved.path.trim_start_matches('/')
224        );
225        let mut url = url.parse::<Url>()?;
226
227        if !query.is_empty() {
228            let query_string = query.to_query_string()?;
229            url.set_query(Some(&query_string));
230        }
231
232        Ok(url)
233    }
234
235    pub(super) fn build_request(
236        method: Method,
237        url: Url,
238        parameters: &CallParameters,
239        body: &Option<CallBody>,
240        authentication: &Option<crate::client::Authentication>,
241    ) -> Result<Request, ApiClientError> {
242        let mut request = Request::new(method, url);
243        let req_headers = request.headers_mut();
244
245        // Add authentication header if present
246        if let Some(auth) = authentication {
247            let (header_name, header_value) = auth.to_header()?;
248            req_headers.insert(header_name, header_value);
249        }
250
251        // Add custom headers
252        for (name, value) in parameters.to_http_headers()? {
253            req_headers.insert(
254                HeaderName::from_bytes(name.as_bytes())?,
255                HeaderValue::from_str(&value)?,
256            );
257        }
258
259        // Add cookies as Cookie header
260        let cookie_header = parameters.to_cookie_header()?;
261        if !cookie_header.is_empty() {
262            req_headers.insert(
263                HeaderName::from_static("cookie"),
264                HeaderValue::from_str(&cookie_header)?,
265            );
266        }
267
268        // Set body
269        if let Some(body) = body {
270            req_headers.typed_insert(body.content_type.clone());
271            let req_body = request.body_mut();
272            *req_body = Some(Body::from(body.data.clone()));
273        }
274
275        Ok(request)
276    }
277
278    #[cfg(feature = "redaction")]
279    fn build_operation(
280        metadata: OperationMetadata,
281        method: &Method,
282        path: &CallPath,
283        parameters: CallParameters,
284        body: &Option<CallBody>,
285        response_description: Option<String>,
286        security: Option<Vec<crate::client::security::SecurityRequirement>>,
287    ) -> CalledOperation {
288        let OperationMetadata {
289            operation_id,
290            tags,
291            description,
292            response_description: _,
293        } = metadata;
294
295        CalledOperation::build(
296            method.clone(),
297            &path.path,
298            path,
299            parameters,
300            body.as_ref(),
301            OperationMetadata {
302                operation_id: operation_id.to_string(),
303                tags,
304                description,
305                response_description,
306            },
307            security,
308        )
309    }
310
311    #[cfg(not(feature = "redaction"))]
312    fn build_operation(
313        metadata: OperationMetadata,
314        method: &Method,
315        path: &CallPath,
316        parameters: CallParameters,
317        body: &Option<CallBody>,
318        security: Option<Vec<crate::client::security::SecurityRequirement>>,
319    ) -> CalledOperation {
320        CalledOperation::build(
321            method.clone(),
322            &path.path,
323            path,
324            parameters,
325            body.as_ref(),
326            metadata,
327            security,
328        )
329    }
330
331    async fn collect_schemas_and_operation(
332        sender: &CollectorSender,
333        path: &CallPath,
334        parameters: &CallParameters,
335        body: &Option<CallBody>,
336        operation: CalledOperation,
337    ) {
338        // Send path schemas
339        sender
340            .send(CollectorMessage::AddSchemas(path.schemas().clone()))
341            .await;
342
343        // Send parameter schemas
344        sender
345            .send(CollectorMessage::AddSchemas(parameters.collect_schemas()))
346            .await;
347
348        // Send body schema entry if present
349        if let Some(body) = body {
350            sender
351                .send(CollectorMessage::AddSchemaEntry(body.entry.clone()))
352                .await;
353        }
354
355        // Register the operation
356        sender
357            .send(CollectorMessage::RegisterOperation(operation))
358            .await;
359    }
360
361    /// Resolves authentication, acquiring OAuth2 tokens if needed.
362    ///
363    /// For OAuth2 authentication, this method acquires a valid token and converts
364    /// the authentication to Bearer token. For other authentication types, the
365    /// original authentication is returned unchanged.
366    async fn resolve_authentication(
367        authentication: Option<crate::client::Authentication>,
368    ) -> Result<Option<crate::client::Authentication>, ApiClientError> {
369        #[cfg(feature = "oauth2")]
370        {
371            use crate::client::Authentication;
372
373            match authentication {
374                Some(Authentication::OAuth2(ref config)) => {
375                    // Acquire a valid token
376                    let token = config
377                        .0
378                        .get_valid_token()
379                        .await
380                        .map_err(ApiClientError::oauth2_error)?;
381
382                    // Convert to Bearer authentication
383                    Ok(Some(Authentication::Bearer(
384                        token.access_token().to_string().into(),
385                    )))
386                }
387                other => Ok(other),
388            }
389        }
390
391        #[cfg(not(feature = "oauth2"))]
392        {
393            Ok(authentication)
394        }
395    }
396}
397
398/// Implement IntoFuture for ApiCall to enable direct .await syntax
399///
400/// This provides a more ergonomic API by allowing direct `.await` on ApiCall:
401/// ```rust,no_run
402/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
403/// # let mut client = clawspec_core::ApiClient::builder().build()?;
404/// let response = client.get("/users")?.await?;
405/// # Ok(())
406/// # }
407/// ```
408impl IntoFuture for ApiCall {
409    type Output = Result<CallResult, ApiClientError>;
410    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
411
412    fn into_future(self) -> Self::IntoFuture {
413        Box::pin(self.exchange())
414    }
415}