clawspec_core/client/openapi/
result.rs

1use std::any::{TypeId, type_name};
2
3use headers::{ContentType, Header};
4use http::StatusCode;
5use http::header::CONTENT_TYPE;
6use reqwest::Response;
7use serde::de::DeserializeOwned;
8use utoipa::ToSchema;
9use utoipa::openapi::{RefOr, Schema};
10
11use super::channel::{CollectorMessage, CollectorSender};
12use super::schema::{SchemaEntry, compute_schema_ref};
13use crate::client::ApiClientError;
14use crate::client::response::output::Output;
15
16/// Represents the result of an API call with response processing capabilities.
17///
18/// This struct contains the response from an HTTP request along with methods to
19/// process the response in various formats (JSON, text, bytes, etc.) while
20/// automatically collecting OpenAPI schema information.
21///
22/// # ⚠️ Important: Response Consumption Required
23///
24/// **You must consume this `CallResult` by calling one of the response processing methods**
25/// to ensure proper OpenAPI documentation generation. Simply calling `exchange()` and not
26/// processing the result will result in incomplete OpenAPI specifications.
27///
28/// ## Required Response Processing
29///
30/// Choose the appropriate method based on your expected response:
31///
32/// - **Empty responses** (204 No Content, etc.): [`as_empty()`](Self::as_empty)
33/// - **JSON responses**: [`as_json::<T>()`](Self::as_json)
34/// - **Optional JSON responses** (204/404 → None): [`as_optional_json::<T>()`](Self::as_optional_json)
35/// - **Type-safe error handling**: [`as_result_json::<T, E>()`](Self::as_result_json) (2xx → Ok(T), 4xx/5xx → Err(E))
36/// - **Optional with errors**: [`as_result_option_json::<T, E>()`](Self::as_result_option_json) (combines optional and error handling)
37/// - **Text responses**: [`as_text()`](Self::as_text)
38/// - **Binary responses**: [`as_bytes()`](Self::as_bytes)
39/// - **Raw response access**: [`as_raw()`](Self::as_raw) (includes status code, content-type, and body)
40///
41/// ## Example: Correct Usage
42///
43/// ```rust
44/// use clawspec_core::ApiClient;
45/// # use serde::Deserialize;
46/// # use utoipa::ToSchema;
47/// # #[derive(Deserialize, ToSchema)]
48/// # struct User { id: u32, name: String }
49///
50/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
51/// let mut client = ApiClient::builder().build()?;
52///
53/// // ✅ CORRECT: Always consume the CallResult
54/// let user: User = client
55///     .get("/users/123")?
56///
57///     .await?
58///     .as_json()  // ← This is required!
59///     .await?;
60///
61/// // ✅ CORRECT: For empty responses (like DELETE)
62/// client
63///     .delete("/users/123")?
64///
65///     .await?
66///     .as_empty()  // ← This is required!
67///     .await?;
68///
69/// // ❌ INCORRECT: This will not generate proper OpenAPI documentation
70/// // let _result = client.get("/users/123")?.await?;
71/// // // Missing .as_json() or other consumption method! This will not generate proper OpenAPI documentation
72/// # Ok(())
73/// # }
74/// ```
75///
76/// ## Why This Matters
77///
78/// The OpenAPI schema generation relies on observing how responses are processed.
79/// Without calling a consumption method:
80/// - Response schemas won't be captured
81/// - Content-Type information may be incomplete
82/// - Operation examples won't be generated
83/// - The resulting OpenAPI spec will be missing crucial response documentation
84#[derive(Debug, Clone)]
85pub struct CallResult {
86    operation_id: String,
87    status: StatusCode,
88    content_type: Option<ContentType>,
89    output: Output,
90    pub(in crate::client) collector_sender: CollectorSender,
91}
92
93/// Represents the raw response data from an HTTP request.
94///
95/// This struct provides complete access to the HTTP response including status code,
96/// content type, and body data. It supports both text and binary response bodies.
97///
98/// # Example
99///
100/// ```rust
101/// use clawspec_core::{ApiClient, RawBody};
102/// use http::StatusCode;
103///
104/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
105/// let mut client = ApiClient::builder().build()?;
106/// let raw_result = client
107///     .get("/api/data")?
108///
109///     .await?
110///     .as_raw()
111///     .await?;
112///
113/// println!("Status: {}", raw_result.status_code());
114/// if let Some(content_type) = raw_result.content_type() {
115///     println!("Content-Type: {}", content_type);
116/// }
117/// match raw_result.body() {
118///     RawBody::Text(text) => println!("Text body: {}", text),
119///     RawBody::Binary(bytes) => println!("Binary body: {} bytes", bytes.len()),
120///     RawBody::Empty => println!("Empty body"),
121/// }
122/// # Ok(())
123/// # }
124/// ```
125#[derive(Debug, Clone)]
126pub struct RawResult {
127    status: StatusCode,
128    content_type: Option<String>,
129    body: RawBody,
130}
131
132/// Represents the body content of a raw HTTP response.
133///
134/// This enum handles different types of response bodies:
135/// - Text content (including JSON, HTML, XML, etc.)
136/// - Binary content (images, files, etc.)
137/// - Empty responses
138#[derive(Debug, Clone)]
139pub enum RawBody {
140    /// Text-based content (UTF-8 encoded)
141    Text(String),
142    /// Binary content
143    Binary(Vec<u8>),
144    /// Empty response body
145    Empty,
146}
147
148impl RawResult {
149    /// Returns the HTTP status code of the response.
150    pub fn status_code(&self) -> StatusCode {
151        self.status
152    }
153
154    /// Returns the content type of the response as a string, if present.
155    ///
156    /// # Returns
157    /// - `Some(&str)` with the MIME type (e.g., "application/json")
158    /// - `None` if no Content-Type header was present
159    pub fn content_type(&self) -> Option<&str> {
160        self.content_type.as_deref()
161    }
162
163    /// Returns the response body.
164    pub fn body(&self) -> &RawBody {
165        &self.body
166    }
167
168    /// Returns the response body as text if it's text content.
169    ///
170    /// # Returns
171    /// - `Some(&str)` if the body contains text
172    /// - `None` if the body is binary or empty
173    pub fn text(&self) -> Option<&str> {
174        match &self.body {
175            RawBody::Text(text) => Some(text),
176            _ => None,
177        }
178    }
179
180    /// Returns the response body as binary data if it's binary content.
181    ///
182    /// # Returns
183    /// - `Some(&[u8])` if the body contains binary data
184    /// - `None` if the body is text or empty
185    pub fn bytes(&self) -> Option<&[u8]> {
186        match &self.body {
187            RawBody::Binary(bytes) => Some(bytes),
188            _ => None,
189        }
190    }
191
192    /// Returns true if the response body is empty.
193    pub fn is_empty(&self) -> bool {
194        matches!(self.body, RawBody::Empty)
195    }
196}
197
198impl CallResult {
199    /// Returns the HTTP status code of the response.
200    ///
201    /// Used by the redaction feature to register response examples.
202    #[cfg(feature = "redaction")]
203    pub(in crate::client) fn status(&self) -> StatusCode {
204        self.status
205    }
206
207    /// Returns the content type of the response, if present.
208    ///
209    /// Used by the redaction feature to register response examples.
210    #[cfg(feature = "redaction")]
211    pub(in crate::client) fn content_type(&self) -> Option<&ContentType> {
212        self.content_type.as_ref()
213    }
214
215    /// Returns the operation ID for this result.
216    ///
217    /// Used by the redaction feature to register response examples.
218    #[cfg(feature = "redaction")]
219    pub(in crate::client) fn operation_id(&self) -> &str {
220        &self.operation_id
221    }
222
223    /// Returns a reference to the output.
224    ///
225    /// Used by the redaction feature to access the JSON output for redaction.
226    #[cfg(feature = "redaction")]
227    pub(in crate::client) fn output(&self) -> &Output {
228        &self.output
229    }
230
231    /// Extracts and parses the Content-Type header from the HTTP response.
232    fn extract_content_type(response: &Response) -> Result<Option<ContentType>, ApiClientError> {
233        let content_type = response
234            .headers()
235            .get_all(CONTENT_TYPE)
236            .iter()
237            .collect::<Vec<_>>();
238
239        if content_type.is_empty() {
240            Ok(None)
241        } else {
242            let ct = ContentType::decode(&mut content_type.into_iter())?;
243            Ok(Some(ct))
244        }
245    }
246
247    /// Processes the response body based on content type and status code.
248    async fn process_response_body(
249        response: Response,
250        content_type: &Option<ContentType>,
251        status: StatusCode,
252    ) -> Result<Output, ApiClientError> {
253        if let Some(content_type) = content_type
254            && status != StatusCode::NO_CONTENT
255        {
256            if *content_type == ContentType::json() {
257                let json = response.text().await?;
258                Ok(Output::Json(json))
259            } else if *content_type == ContentType::octet_stream() {
260                let bytes = response.bytes().await?;
261                Ok(Output::Bytes(bytes.to_vec()))
262            } else if content_type.to_string().starts_with("text/") {
263                let text = response.text().await?;
264                Ok(Output::Text(text))
265            } else {
266                let body = response.text().await?;
267                Ok(Output::Other { body })
268            }
269        } else {
270            Ok(Output::Empty)
271        }
272    }
273
274    pub(in crate::client) async fn new(
275        operation_id: String,
276        collector_sender: CollectorSender,
277        response: Response,
278    ) -> Result<Self, ApiClientError> {
279        let status = response.status();
280        let content_type = Self::extract_content_type(&response)?;
281        let output = Self::process_response_body(response, &content_type, status).await?;
282
283        Ok(Self {
284            operation_id,
285            status,
286            content_type,
287            output,
288            collector_sender,
289        })
290    }
291
292    pub(in crate::client) async fn new_without_collection(
293        response: Response,
294    ) -> Result<Self, ApiClientError> {
295        let status = response.status();
296        let content_type = Self::extract_content_type(&response)?;
297        let output = Self::process_response_body(response, &content_type, status).await?;
298
299        Ok(Self {
300            operation_id: String::new(), // Empty operation_id since it won't be used
301            status,
302            content_type,
303            output,
304            collector_sender: CollectorSender::dummy(),
305        })
306    }
307
308    pub(in crate::client) async fn get_output(
309        &self,
310        schema: Option<RefOr<Schema>>,
311    ) -> Result<&Output, ApiClientError> {
312        // Skip if operation_id is empty (skip_collection case)
313        if self.operation_id.is_empty() {
314            return Ok(&self.output);
315        }
316
317        // Send message to register the response
318        let status_code = self.status.as_u16();
319        let description = format!("Status code {status_code}");
320
321        self.collector_sender
322            .send(CollectorMessage::RegisterResponse {
323                operation_id: self.operation_id.clone(),
324                status: self.status,
325                content_type: self.content_type.clone(),
326                schema,
327                description,
328            })
329            .await;
330
331        Ok(&self.output)
332    }
333
334    /// Processes the response as JSON and deserializes it to the specified type.
335    ///
336    /// This method automatically records the response schema in the OpenAPI specification
337    /// and processes the response body as JSON. The type parameter must implement
338    /// `DeserializeOwned` and `ToSchema` for proper JSON parsing and schema generation.
339    ///
340    /// # Type Parameters
341    ///
342    /// - `T`: The target type for deserialization, must implement `DeserializeOwned`, `ToSchema`, and `'static`
343    ///
344    /// # Returns
345    ///
346    /// - `Ok(T)`: The deserialized response object
347    /// - `Err(ApiClientError)`: If the response is not JSON or deserialization fails
348    ///
349    /// # Example
350    ///
351    /// ```rust
352    /// # use clawspec_core::ApiClient;
353    /// # use serde::{Deserialize, Serialize};
354    /// # use utoipa::ToSchema;
355    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
356    /// #[derive(Deserialize, ToSchema)]
357    /// struct User {
358    ///     id: u32,
359    ///     name: String,
360    /// }
361    ///
362    /// let mut client = ApiClient::builder().build()?;
363    /// let user: User = client
364    ///     .get("/users/123")?
365    ///
366    ///     .await?
367    ///     .as_json()
368    ///     .await?;
369    /// # Ok(())
370    /// # }
371    /// ```
372    pub async fn as_json<T>(&mut self) -> Result<T, ApiClientError>
373    where
374        T: DeserializeOwned + ToSchema + 'static,
375    {
376        let schema = self.register_schema::<T>().await;
377        let output = self.get_output(Some(schema)).await?;
378
379        let Output::Json(json) = output else {
380            return Err(ApiClientError::UnsupportedJsonOutput {
381                output: output.clone(),
382                name: type_name::<T>(),
383            });
384        };
385
386        self.deserialize_and_record::<T>(json).await
387    }
388
389    /// Processes the response as optional JSON, treating 204 and 404 status codes as `None`.
390    ///
391    /// This method provides ergonomic handling of optional REST API responses by automatically
392    /// treating 204 (No Content) and 404 (Not Found) status codes as `None`, while deserializing
393    /// other successful responses as `Some(T)`. This is particularly useful for APIs that use
394    /// HTTP status codes to indicate the absence of data rather than errors.
395    ///
396    /// The method automatically records the response schema in the OpenAPI specification,
397    /// maintaining proper documentation generation.
398    ///
399    /// # Type Parameters
400    ///
401    /// - `T`: The target type for deserialization, must implement `DeserializeOwned`, `ToSchema`, and `'static`
402    ///
403    /// # Returns
404    ///
405    /// - `Ok(None)`: If the status code is 204 or 404
406    /// - `Ok(Some(T))`: The deserialized response object for other successful responses
407    /// - `Err(ApiClientError)`: If the response is not JSON or deserialization fails
408    ///
409    /// # Example
410    ///
411    /// ```rust
412    /// # use clawspec_core::ApiClient;
413    /// # use serde::{Deserialize, Serialize};
414    /// # use utoipa::ToSchema;
415    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
416    /// #[derive(Deserialize, ToSchema)]
417    /// struct User {
418    ///     id: u32,
419    ///     name: String,
420    /// }
421    ///
422    /// let mut client = ApiClient::builder().build()?;
423    ///
424    /// // Returns None for 404
425    /// let user: Option<User> = client
426    ///     .get("/users/nonexistent")?
427    ///
428    ///     .await?
429    ///     .as_optional_json()
430    ///     .await?;
431    /// assert!(user.is_none());
432    ///
433    /// // Returns Some(User) for successful response
434    /// let user: Option<User> = client
435    ///     .get("/users/123")?
436    ///
437    ///     .await?
438    ///     .as_optional_json()
439    ///     .await?;
440    /// assert!(user.is_some());
441    /// # Ok(())
442    /// # }
443    /// ```
444    pub async fn as_optional_json<T>(&mut self) -> Result<Option<T>, ApiClientError>
445    where
446        T: DeserializeOwned + ToSchema + 'static,
447    {
448        // Check if status code indicates absence of data
449        if self.status == StatusCode::NO_CONTENT || self.status == StatusCode::NOT_FOUND {
450            // Record the response without a schema
451            self.get_output(None).await?;
452            return Ok(None);
453        }
454
455        let schema = self.register_schema::<T>().await;
456        let output = self.get_output(Some(schema)).await?;
457
458        let Output::Json(json) = output else {
459            return Err(ApiClientError::UnsupportedJsonOutput {
460                output: output.clone(),
461                name: type_name::<T>(),
462            });
463        };
464
465        let result = self.deserialize_and_record::<T>(json).await?;
466        Ok(Some(result))
467    }
468
469    /// Processes the response as a `Result<T, E>` based on HTTP status code.
470    ///
471    /// This method provides type-safe error handling for REST APIs that return structured
472    /// error responses. It automatically deserializes the response body to either the
473    /// success type `T` (for 2xx status codes) or the error type `E` (for 4xx/5xx status codes).
474    ///
475    /// Both success and error schemas are automatically recorded in the OpenAPI specification,
476    /// providing complete documentation of your API's response patterns.
477    ///
478    /// # Type Parameters
479    ///
480    /// - `T`: The success response type, must implement `DeserializeOwned`, `ToSchema`, and `'static`
481    /// - `E`: The error response type, must implement `DeserializeOwned`, `ToSchema`, and `'static`
482    ///
483    /// # Returns
484    ///
485    /// - `Ok(T)`: The deserialized success response for 2xx status codes
486    /// - `Err(E)`: The deserialized error response for 4xx/5xx status codes
487    ///
488    /// # Errors
489    ///
490    /// Returns `ApiClientError` if:
491    /// - The response is not JSON
492    /// - JSON deserialization fails for either type
493    /// - The response body is empty when content is expected
494    ///
495    /// # Example
496    ///
497    /// ```rust
498    /// # use clawspec_core::ApiClient;
499    /// # use serde::{Deserialize, Serialize};
500    /// # use utoipa::ToSchema;
501    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
502    /// #[derive(Deserialize, ToSchema)]
503    /// struct User {
504    ///     id: u32,
505    ///     name: String,
506    /// }
507    ///
508    /// #[derive(Deserialize, ToSchema)]
509    /// struct ApiError {
510    ///     code: String,
511    ///     message: String,
512    /// }
513    ///
514    /// let mut client = ApiClient::builder().build()?;
515    ///
516    /// // Returns Ok(User) for 2xx responses
517    /// let result: Result<User, ApiError> = client
518    ///     .get("/users/123")?
519    ///
520    ///     .await?
521    ///     .as_result_json()
522    ///     .await?;
523    ///
524    /// match result {
525    ///     Ok(user) => println!("User: {}", user.name),
526    ///     Err(err) => println!("Error: {} - {}", err.code, err.message),
527    /// }
528    /// # Ok(())
529    /// # }
530    /// ```
531    pub async fn as_result_json<T, E>(&mut self) -> Result<Result<T, E>, ApiClientError>
532    where
533        T: DeserializeOwned + ToSchema + 'static,
534        E: DeserializeOwned + ToSchema + 'static,
535    {
536        Ok(self
537            .process_result_json_internal::<T, E>(false)
538            .await?
539            .map(|opt| opt.expect("BUG: 404 handling disabled but got None")))
540    }
541
542    /// Processes the response as a `Result<Option<T>, E>` based on HTTP status code.
543    ///
544    /// This method combines optional response handling with type-safe error handling,
545    /// providing comprehensive support for REST APIs that:
546    /// - Return structured error responses for failures (4xx/5xx)
547    /// - Use 204 (No Content) or 404 (Not Found) to indicate absence of data
548    /// - Return data for other successful responses (2xx)
549    ///
550    /// Both success and error schemas are automatically recorded in the OpenAPI specification.
551    ///
552    /// # Type Parameters
553    ///
554    /// - `T`: The success response type, must implement `DeserializeOwned`, `ToSchema`, and `'static`
555    /// - `E`: The error response type, must implement `DeserializeOwned`, `ToSchema`, and `'static`
556    ///
557    /// # Returns
558    ///
559    /// - `Ok(None)`: For 204 (No Content) or 404 (Not Found) status codes
560    /// - `Ok(Some(T))`: The deserialized success response for other 2xx status codes
561    /// - `Err(E)`: The deserialized error response for 4xx/5xx status codes
562    ///
563    /// # Errors
564    ///
565    /// Returns `ApiClientError` if:
566    /// - The response is not JSON (when content is expected)
567    /// - JSON deserialization fails for either type
568    ///
569    /// # Example
570    ///
571    /// ```rust
572    /// # use clawspec_core::ApiClient;
573    /// # use serde::{Deserialize, Serialize};
574    /// # use utoipa::ToSchema;
575    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
576    /// #[derive(Deserialize, ToSchema)]
577    /// struct User {
578    ///     id: u32,
579    ///     name: String,
580    /// }
581    ///
582    /// #[derive(Deserialize, ToSchema)]
583    /// struct ApiError {
584    ///     code: String,
585    ///     message: String,
586    /// }
587    ///
588    /// let mut client = ApiClient::builder().build()?;
589    ///
590    /// // Returns Ok(None) for 404
591    /// let result: Result<Option<User>, ApiError> = client
592    ///     .get("/users/nonexistent")?
593    ///
594    ///     .await?
595    ///     .as_result_option_json()
596    ///     .await?;
597    ///
598    /// match result {
599    ///     Ok(Some(user)) => println!("User: {}", user.name),
600    ///     Ok(None) => println!("User not found"),
601    ///     Err(err) => println!("Error: {} - {}", err.code, err.message),
602    /// }
603    /// # Ok(())
604    /// # }
605    /// ```
606    pub async fn as_result_option_json<T, E>(
607        &mut self,
608    ) -> Result<Result<Option<T>, E>, ApiClientError>
609    where
610        T: DeserializeOwned + ToSchema + 'static,
611        E: DeserializeOwned + ToSchema + 'static,
612    {
613        self.process_result_json_internal::<T, E>(true).await
614    }
615
616    /// Internal helper for processing Result<Option<T>, E> responses.
617    ///
618    /// Handles the common logic for both `as_result_json` and `as_result_option_json`.
619    async fn process_result_json_internal<T, E>(
620        &mut self,
621        treat_404_as_none: bool,
622    ) -> Result<Result<Option<T>, E>, ApiClientError>
623    where
624        T: DeserializeOwned + ToSchema + 'static,
625        E: DeserializeOwned + ToSchema + 'static,
626    {
627        // Register both schemas upfront (they're part of the API contract)
628        let success_schema = self.register_schema::<T>().await;
629        let error_schema = self.register_schema::<E>().await;
630
631        // Check for 204/404 which indicate absence of data (when enabled)
632        if treat_404_as_none
633            && (self.status == StatusCode::NO_CONTENT || self.status == StatusCode::NOT_FOUND)
634        {
635            self.get_output(None).await?;
636            return Ok(Ok(None));
637        }
638
639        let is_success = self.status.is_success();
640        let schema = if is_success {
641            success_schema
642        } else {
643            error_schema
644        };
645
646        let output = self.get_output(Some(schema)).await?;
647
648        let Output::Json(json) = output else {
649            return Err(ApiClientError::UnsupportedJsonOutput {
650                output: output.clone(),
651                name: if is_success {
652                    type_name::<T>()
653                } else {
654                    type_name::<E>()
655                },
656            });
657        };
658
659        if is_success {
660            let value = self.deserialize_and_record::<T>(json).await?;
661            Ok(Ok(Some(value)))
662        } else {
663            let error = self.deserialize_and_record::<E>(json).await?;
664            Ok(Err(error))
665        }
666    }
667
668    /// Registers a schema type and returns its reference.
669    ///
670    /// This helper reduces duplication across `as_json`, `as_optional_json`,
671    /// and `process_result_json_internal` methods.
672    async fn register_schema<T: ToSchema + 'static>(&self) -> RefOr<Schema> {
673        let schema = compute_schema_ref::<T>();
674        self.collector_sender
675            .send(CollectorMessage::AddSchemaEntry(SchemaEntry::of::<T>()))
676            .await;
677        schema
678    }
679
680    /// Helper to deserialize JSON and record examples.
681    async fn deserialize_and_record<T>(&self, json: &str) -> Result<T, ApiClientError>
682    where
683        T: DeserializeOwned + ToSchema + 'static,
684    {
685        let deserializer = &mut serde_json::Deserializer::from_str(json);
686        let result: T = serde_path_to_error::deserialize(deserializer).map_err(|err| {
687            ApiClientError::JsonError {
688                path: err.path().to_string(),
689                error: err.into_inner(),
690                body: json.to_string(),
691            }
692        })?;
693
694        if let Ok(example) = serde_json::to_value(json) {
695            self.collector_sender
696                .send(CollectorMessage::AddExample {
697                    type_id: TypeId::of::<T>(),
698                    type_name: type_name::<T>(),
699                    example,
700                })
701                .await;
702        }
703
704        Ok(result)
705    }
706
707    /// Processes the response as plain text.
708    ///
709    /// This method records the response in the OpenAPI specification and returns
710    /// the response body as a string slice. The response must have a text content type.
711    ///
712    /// # Returns
713    ///
714    /// - `Ok(&str)`: The response body as a string slice
715    /// - `Err(ApiClientError)`: If the response is not text
716    ///
717    /// # Example
718    ///
719    /// ```rust
720    /// # use clawspec_core::ApiClient;
721    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
722    /// let mut client = ApiClient::builder().build()?;
723    /// let text = client
724    ///     .get("/api/status")?
725    ///
726    ///     .await?
727    ///     .as_text()
728    ///     .await?;
729    /// # Ok(())
730    /// # }
731    /// ```
732    pub async fn as_text(&mut self) -> Result<&str, ApiClientError> {
733        let output = self.get_output(None).await?;
734
735        let Output::Text(text) = &output else {
736            return Err(ApiClientError::UnsupportedTextOutput {
737                output: output.clone(),
738            });
739        };
740
741        Ok(text)
742    }
743
744    /// Processes the response as binary data.
745    ///
746    /// This method records the response in the OpenAPI specification and returns
747    /// the response body as a byte slice. The response must have a binary content type.
748    ///
749    /// # Returns
750    ///
751    /// - `Ok(&[u8])`: The response body as a byte slice
752    /// - `Err(ApiClientError)`: If the response is not binary
753    ///
754    /// # Example
755    ///
756    /// ```rust
757    /// # use clawspec_core::ApiClient;
758    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
759    /// let mut client = ApiClient::builder().build()?;
760    /// let bytes = client
761    ///     .get("/api/download")?
762    ///
763    ///     .await?
764    ///     .as_bytes()
765    ///     .await?;
766    /// # Ok(())
767    /// # }
768    /// ```
769    pub async fn as_bytes(&mut self) -> Result<&[u8], ApiClientError> {
770        let output = self.get_output(None).await?;
771
772        let Output::Bytes(bytes) = &output else {
773            return Err(ApiClientError::UnsupportedBytesOutput {
774                output: output.clone(),
775            });
776        };
777
778        Ok(bytes.as_slice())
779    }
780
781    /// Processes the response as raw content with complete HTTP response information.
782    ///
783    /// This method records the response in the OpenAPI specification and returns
784    /// a [`RawResult`] containing the HTTP status code, content type, and response body.
785    /// This method supports both text and binary response content.
786    ///
787    /// # Returns
788    ///
789    /// - `Ok(RawResult)`: Complete raw response data including status, content type, and body
790    /// - `Err(ApiClientError)`: If processing fails
791    ///
792    /// # Example
793    ///
794    /// ```rust
795    /// use clawspec_core::{ApiClient, RawBody};
796    /// use http::StatusCode;
797    ///
798    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
799    /// let mut client = ApiClient::builder().build()?;
800    /// let raw_result = client
801    ///     .get("/api/data")?
802    ///
803    ///     .await?
804    ///     .as_raw()
805    ///     .await?;
806    ///
807    /// println!("Status: {}", raw_result.status_code());
808    /// if let Some(content_type) = raw_result.content_type() {
809    ///     println!("Content-Type: {}", content_type);
810    /// }
811    ///
812    /// match raw_result.body() {
813    ///     RawBody::Text(text) => println!("Text body: {}", text),
814    ///     RawBody::Binary(bytes) => println!("Binary body: {} bytes", bytes.len()),
815    ///     RawBody::Empty => println!("Empty body"),
816    /// }
817    /// # Ok(())
818    /// # }
819    /// ```
820    pub async fn as_raw(&mut self) -> Result<RawResult, ApiClientError> {
821        let output = self.get_output(None).await?;
822
823        let body = match output {
824            Output::Empty => RawBody::Empty,
825            Output::Json(body) | Output::Text(body) | Output::Other { body, .. } => {
826                RawBody::Text(body.clone())
827            }
828            Output::Bytes(bytes) => RawBody::Binary(bytes.clone()),
829        };
830
831        Ok(RawResult {
832            status: self.status,
833            content_type: self.content_type.as_ref().map(|ct| ct.to_string()),
834            body,
835        })
836    }
837
838    /// Records this response as an empty response in the OpenAPI specification.
839    ///
840    /// This method should be used for endpoints that return no content (e.g., DELETE operations,
841    /// PUT operations that don't return a response body).
842    ///
843    /// # Example
844    ///
845    /// ```rust
846    /// # use clawspec_core::ApiClient;
847    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
848    /// let mut client = ApiClient::builder().build()?;
849    ///
850    /// client
851    ///     .delete("/items/123")?
852    ///
853    ///     .await?
854    ///     .as_empty()
855    ///     .await?;
856    /// # Ok(())
857    /// # }
858    /// ```
859    pub async fn as_empty(&mut self) -> Result<(), ApiClientError> {
860        self.get_output(None).await?;
861        Ok(())
862    }
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    #[test]
870    fn test_raw_body_text_variant() {
871        let body = RawBody::Text("Hello, World!".to_string());
872
873        match body {
874            RawBody::Text(text) => assert_eq!(text, "Hello, World!"),
875            _ => panic!("Expected Text variant"),
876        }
877    }
878
879    #[test]
880    fn test_raw_body_binary_variant() {
881        let data = vec![0x01, 0x02, 0x03, 0x04];
882        let body = RawBody::Binary(data.clone());
883
884        match body {
885            RawBody::Binary(bytes) => assert_eq!(bytes, data),
886            _ => panic!("Expected Binary variant"),
887        }
888    }
889
890    #[test]
891    fn test_raw_body_empty_variant() {
892        let body = RawBody::Empty;
893
894        assert!(matches!(body, RawBody::Empty));
895    }
896
897    #[test]
898    fn test_raw_result_status_code() {
899        let result = RawResult {
900            status: StatusCode::OK,
901            content_type: Some("application/json".to_string()),
902            body: RawBody::Text("{}".to_string()),
903        };
904
905        assert_eq!(result.status_code(), StatusCode::OK);
906    }
907
908    #[test]
909    fn test_raw_result_content_type_some() {
910        let result = RawResult {
911            status: StatusCode::OK,
912            content_type: Some("text/plain".to_string()),
913            body: RawBody::Text("Hello".to_string()),
914        };
915
916        assert_eq!(result.content_type(), Some("text/plain"));
917    }
918
919    #[test]
920    fn test_raw_result_content_type_none() {
921        let result = RawResult {
922            status: StatusCode::NO_CONTENT,
923            content_type: None,
924            body: RawBody::Empty,
925        };
926
927        assert_eq!(result.content_type(), None);
928    }
929
930    #[test]
931    fn test_raw_result_body() {
932        let result = RawResult {
933            status: StatusCode::OK,
934            content_type: Some("application/json".to_string()),
935            body: RawBody::Text("{\"key\": \"value\"}".to_string()),
936        };
937
938        assert!(matches!(result.body(), RawBody::Text(_)));
939    }
940
941    #[test]
942    fn test_raw_result_text_with_text_body() {
943        let result = RawResult {
944            status: StatusCode::OK,
945            content_type: Some("text/plain".to_string()),
946            body: RawBody::Text("Hello, World!".to_string()),
947        };
948
949        assert_eq!(result.text(), Some("Hello, World!"));
950    }
951
952    #[test]
953    fn test_raw_result_text_with_binary_body() {
954        let result = RawResult {
955            status: StatusCode::OK,
956            content_type: Some("application/octet-stream".to_string()),
957            body: RawBody::Binary(vec![0x00, 0x01, 0x02]),
958        };
959
960        assert_eq!(result.text(), None);
961    }
962
963    #[test]
964    fn test_raw_result_text_with_empty_body() {
965        let result = RawResult {
966            status: StatusCode::NO_CONTENT,
967            content_type: None,
968            body: RawBody::Empty,
969        };
970
971        assert_eq!(result.text(), None);
972    }
973
974    #[test]
975    fn test_raw_result_bytes_with_binary_body() {
976        let data = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f]; // "Hello" in ASCII
977        let result = RawResult {
978            status: StatusCode::OK,
979            content_type: Some("application/octet-stream".to_string()),
980            body: RawBody::Binary(data.clone()),
981        };
982
983        assert_eq!(result.bytes(), Some(data.as_slice()));
984    }
985
986    #[test]
987    fn test_raw_result_bytes_with_text_body() {
988        let result = RawResult {
989            status: StatusCode::OK,
990            content_type: Some("text/plain".to_string()),
991            body: RawBody::Text("Hello".to_string()),
992        };
993
994        assert_eq!(result.bytes(), None);
995    }
996
997    #[test]
998    fn test_raw_result_bytes_with_empty_body() {
999        let result = RawResult {
1000            status: StatusCode::NO_CONTENT,
1001            content_type: None,
1002            body: RawBody::Empty,
1003        };
1004
1005        assert_eq!(result.bytes(), None);
1006    }
1007
1008    #[test]
1009    fn test_raw_result_is_empty_true() {
1010        let result = RawResult {
1011            status: StatusCode::NO_CONTENT,
1012            content_type: None,
1013            body: RawBody::Empty,
1014        };
1015
1016        assert!(result.is_empty());
1017    }
1018
1019    #[test]
1020    fn test_raw_result_is_empty_false_text() {
1021        let result = RawResult {
1022            status: StatusCode::OK,
1023            content_type: Some("text/plain".to_string()),
1024            body: RawBody::Text("content".to_string()),
1025        };
1026
1027        assert!(!result.is_empty());
1028    }
1029
1030    #[test]
1031    fn test_raw_result_is_empty_false_binary() {
1032        let result = RawResult {
1033            status: StatusCode::OK,
1034            content_type: Some("application/octet-stream".to_string()),
1035            body: RawBody::Binary(vec![1, 2, 3]),
1036        };
1037
1038        assert!(!result.is_empty());
1039    }
1040
1041    #[test]
1042    fn test_raw_body_debug_impl() {
1043        let text_body = RawBody::Text("Hello".to_string());
1044        let debug_str = format!("{text_body:?}");
1045        assert!(debug_str.contains("Text"));
1046        assert!(debug_str.contains("Hello"));
1047
1048        let binary_body = RawBody::Binary(vec![1, 2, 3]);
1049        let debug_str = format!("{binary_body:?}");
1050        assert!(debug_str.contains("Binary"));
1051
1052        let empty_body = RawBody::Empty;
1053        let debug_str = format!("{empty_body:?}");
1054        assert!(debug_str.contains("Empty"));
1055    }
1056
1057    #[test]
1058    fn test_raw_body_clone() {
1059        let original = RawBody::Text("test".to_string());
1060        let cloned = original.clone();
1061
1062        match (original, cloned) {
1063            (RawBody::Text(a), RawBody::Text(b)) => assert_eq!(a, b),
1064            _ => panic!("Clone should preserve variant"),
1065        }
1066    }
1067
1068    #[test]
1069    fn test_raw_result_debug_impl() {
1070        let result = RawResult {
1071            status: StatusCode::OK,
1072            content_type: Some("application/json".to_string()),
1073            body: RawBody::Text("{}".to_string()),
1074        };
1075
1076        let debug_str = format!("{result:?}");
1077        assert!(debug_str.contains("RawResult"));
1078        assert!(debug_str.contains("200"));
1079    }
1080
1081    #[test]
1082    fn test_raw_result_clone() {
1083        let original = RawResult {
1084            status: StatusCode::CREATED,
1085            content_type: Some("text/plain".to_string()),
1086            body: RawBody::Text("Created".to_string()),
1087        };
1088
1089        let cloned = original.clone();
1090
1091        assert_eq!(cloned.status_code(), StatusCode::CREATED);
1092        assert_eq!(cloned.content_type(), Some("text/plain"));
1093        assert_eq!(cloned.text(), Some("Created"));
1094    }
1095}