rustapi_core/
response.rs

1//! Response types for RustAPI
2//!
3//! This module provides types for building HTTP responses. The core trait is
4//! [`IntoResponse`], which allows any type to be converted into an HTTP response.
5//!
6//! # Built-in Response Types
7//!
8//! | Type | Status | Content-Type | Description |
9//! |------|--------|--------------|-------------|
10//! | `String` / `&str` | 200 | text/plain | Plain text response |
11//! | `()` | 200 | - | Empty response |
12//! | [`Json<T>`] | 200 | application/json | JSON response |
13//! | [`Created<T>`] | 201 | application/json | Created resource |
14//! | [`NoContent`] | 204 | - | No content response |
15//! | [`Html<T>`] | 200 | text/html | HTML response |
16//! | [`Redirect`] | 3xx | - | HTTP redirect |
17//! | [`WithStatus<T, N>`] | N | varies | Custom status code |
18//! | [`ApiError`] | varies | application/json | Error response |
19//!
20//! # Example
21//!
22//! ```rust,ignore
23//! use rustapi_core::{Json, Created, NoContent, IntoResponse};
24//! use serde::Serialize;
25//!
26//! #[derive(Serialize)]
27//! struct User {
28//!     id: i64,
29//!     name: String,
30//! }
31//!
32//! // Return JSON with 200 OK
33//! async fn get_user() -> Json<User> {
34//!     Json(User { id: 1, name: "Alice".to_string() })
35//! }
36//!
37//! // Return JSON with 201 Created
38//! async fn create_user() -> Created<User> {
39//!     Created(User { id: 2, name: "Bob".to_string() })
40//! }
41//!
42//! // Return 204 No Content
43//! async fn delete_user() -> NoContent {
44//!     NoContent
45//! }
46//!
47//! // Return custom status code
48//! async fn accepted() -> WithStatus<String, 202> {
49//!     WithStatus("Request accepted".to_string())
50//! }
51//! ```
52//!
53//! # Tuple Responses
54//!
55//! You can also return tuples to customize the response:
56//!
57//! ```rust,ignore
58//! use http::StatusCode;
59//!
60//! // (StatusCode, body)
61//! async fn custom_status() -> (StatusCode, String) {
62//!     (StatusCode::ACCEPTED, "Accepted".to_string())
63//! }
64//!
65//! // (StatusCode, headers, body)
66//! async fn with_headers() -> (StatusCode, HeaderMap, String) {
67//!     let mut headers = HeaderMap::new();
68//!     headers.insert("X-Custom", "value".parse().unwrap());
69//!     (StatusCode::OK, headers, "Hello".to_string())
70//! }
71//! ```
72
73use crate::error::{ApiError, ErrorResponse};
74use bytes::Bytes;
75use http::{header, HeaderMap, HeaderValue, StatusCode};
76use http_body_util::Full;
77use rustapi_openapi::{MediaType, Operation, ResponseModifier, ResponseSpec, Schema, SchemaRef};
78use serde::Serialize;
79use std::collections::HashMap;
80
81/// HTTP Response type
82pub type Response = http::Response<Full<Bytes>>;
83
84/// Trait for types that can be converted into an HTTP response
85pub trait IntoResponse {
86    /// Convert self into a Response
87    fn into_response(self) -> Response;
88}
89
90// Implement for Response itself
91impl IntoResponse for Response {
92    fn into_response(self) -> Response {
93        self
94    }
95}
96
97// Implement for () - returns 200 OK with empty body
98impl IntoResponse for () {
99    fn into_response(self) -> Response {
100        http::Response::builder()
101            .status(StatusCode::OK)
102            .body(Full::new(Bytes::new()))
103            .unwrap()
104    }
105}
106
107// Implement for &'static str
108impl IntoResponse for &'static str {
109    fn into_response(self) -> Response {
110        http::Response::builder()
111            .status(StatusCode::OK)
112            .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
113            .body(Full::new(Bytes::from(self)))
114            .unwrap()
115    }
116}
117
118// Implement for String
119impl IntoResponse for String {
120    fn into_response(self) -> Response {
121        http::Response::builder()
122            .status(StatusCode::OK)
123            .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
124            .body(Full::new(Bytes::from(self)))
125            .unwrap()
126    }
127}
128
129// Implement for StatusCode
130impl IntoResponse for StatusCode {
131    fn into_response(self) -> Response {
132        http::Response::builder()
133            .status(self)
134            .body(Full::new(Bytes::new()))
135            .unwrap()
136    }
137}
138
139// Implement for (StatusCode, impl IntoResponse)
140impl<R: IntoResponse> IntoResponse for (StatusCode, R) {
141    fn into_response(self) -> Response {
142        let mut response = self.1.into_response();
143        *response.status_mut() = self.0;
144        response
145    }
146}
147
148// Implement for (StatusCode, HeaderMap, impl IntoResponse)
149impl<R: IntoResponse> IntoResponse for (StatusCode, HeaderMap, R) {
150    fn into_response(self) -> Response {
151        let mut response = self.2.into_response();
152        *response.status_mut() = self.0;
153        response.headers_mut().extend(self.1);
154        response
155    }
156}
157
158// Implement for Result<T, E> where both implement IntoResponse
159impl<T: IntoResponse, E: IntoResponse> IntoResponse for Result<T, E> {
160    fn into_response(self) -> Response {
161        match self {
162            Ok(v) => v.into_response(),
163            Err(e) => e.into_response(),
164        }
165    }
166}
167
168// Implement for ApiError
169// Implement for ApiError with environment-aware error masking
170impl IntoResponse for ApiError {
171    fn into_response(self) -> Response {
172        let status = self.status;
173        // ErrorResponse::from now handles environment-aware masking
174        let error_response = ErrorResponse::from(self);
175        let body = serde_json::to_vec(&error_response).unwrap_or_else(|_| {
176            br#"{"error":{"type":"internal_error","message":"Failed to serialize error"}}"#.to_vec()
177        });
178
179        http::Response::builder()
180            .status(status)
181            .header(header::CONTENT_TYPE, "application/json")
182            .body(Full::new(Bytes::from(body)))
183            .unwrap()
184    }
185}
186
187impl ResponseModifier for ApiError {
188    fn update_response(op: &mut Operation) {
189        // We define common error responses here
190        // 400 Bad Request
191        op.responses.insert(
192            "400".to_string(),
193            ResponseSpec {
194                description: "Bad Request".to_string(),
195                content: {
196                    let mut map = HashMap::new();
197                    map.insert(
198                        "application/json".to_string(),
199                        MediaType {
200                            schema: SchemaRef::Ref {
201                                reference: "#/components/schemas/ErrorSchema".to_string(),
202                            },
203                        },
204                    );
205                    Some(map)
206                },
207            },
208        );
209
210        // 500 Internal Server Error
211        op.responses.insert(
212            "500".to_string(),
213            ResponseSpec {
214                description: "Internal Server Error".to_string(),
215                content: {
216                    let mut map = HashMap::new();
217                    map.insert(
218                        "application/json".to_string(),
219                        MediaType {
220                            schema: SchemaRef::Ref {
221                                reference: "#/components/schemas/ErrorSchema".to_string(),
222                            },
223                        },
224                    );
225                    Some(map)
226                },
227            },
228        );
229    }
230}
231
232/// 201 Created response wrapper
233///
234/// Returns HTTP 201 with JSON body.
235///
236/// # Example
237///
238/// ```rust,ignore
239/// async fn create_user(body: UserIn) -> Result<Created<UserOut>> {
240///     let user = db.create(body).await?;
241///     Ok(Created(user))
242/// }
243/// ```
244#[derive(Debug, Clone)]
245pub struct Created<T>(pub T);
246
247impl<T: Serialize> IntoResponse for Created<T> {
248    fn into_response(self) -> Response {
249        match serde_json::to_vec(&self.0) {
250            Ok(body) => http::Response::builder()
251                .status(StatusCode::CREATED)
252                .header(header::CONTENT_TYPE, "application/json")
253                .body(Full::new(Bytes::from(body)))
254                .unwrap(),
255            Err(err) => {
256                ApiError::internal(format!("Failed to serialize response: {}", err)).into_response()
257            }
258        }
259    }
260}
261
262impl<T: for<'a> Schema<'a>> ResponseModifier for Created<T> {
263    fn update_response(op: &mut Operation) {
264        let (name, _) = T::schema();
265
266        let schema_ref = SchemaRef::Ref {
267            reference: format!("#/components/schemas/{}", name),
268        };
269
270        op.responses.insert(
271            "201".to_string(),
272            ResponseSpec {
273                description: "Created".to_string(),
274                content: {
275                    let mut map = HashMap::new();
276                    map.insert(
277                        "application/json".to_string(),
278                        MediaType { schema: schema_ref },
279                    );
280                    Some(map)
281                },
282            },
283        );
284    }
285}
286
287/// 204 No Content response
288///
289/// Returns HTTP 204 with empty body.
290///
291/// # Example
292///
293/// ```rust,ignore
294/// async fn delete_user(id: i64) -> Result<NoContent> {
295///     db.delete(id).await?;
296///     Ok(NoContent)
297/// }
298/// ```
299#[derive(Debug, Clone, Copy)]
300pub struct NoContent;
301
302impl IntoResponse for NoContent {
303    fn into_response(self) -> Response {
304        http::Response::builder()
305            .status(StatusCode::NO_CONTENT)
306            .body(Full::new(Bytes::new()))
307            .unwrap()
308    }
309}
310
311impl ResponseModifier for NoContent {
312    fn update_response(op: &mut Operation) {
313        op.responses.insert(
314            "204".to_string(),
315            ResponseSpec {
316                description: "No Content".to_string(),
317                content: None,
318            },
319        );
320    }
321}
322
323/// HTML response wrapper
324#[derive(Debug, Clone)]
325pub struct Html<T>(pub T);
326
327impl<T: Into<String>> IntoResponse for Html<T> {
328    fn into_response(self) -> Response {
329        http::Response::builder()
330            .status(StatusCode::OK)
331            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
332            .body(Full::new(Bytes::from(self.0.into())))
333            .unwrap()
334    }
335}
336
337impl<T> ResponseModifier for Html<T> {
338    fn update_response(op: &mut Operation) {
339        op.responses.insert(
340            "200".to_string(),
341            ResponseSpec {
342                description: "HTML Content".to_string(),
343                content: {
344                    let mut map = HashMap::new();
345                    map.insert(
346                        "text/html".to_string(),
347                        MediaType {
348                            schema: SchemaRef::Inline(serde_json::json!({ "type": "string" })),
349                        },
350                    );
351                    Some(map)
352                },
353            },
354        );
355    }
356}
357
358/// Redirect response
359#[derive(Debug, Clone)]
360pub struct Redirect {
361    status: StatusCode,
362    location: HeaderValue,
363}
364
365impl Redirect {
366    /// Create a 302 Found redirect
367    pub fn to(uri: &str) -> Self {
368        Self {
369            status: StatusCode::FOUND,
370            location: HeaderValue::from_str(uri).expect("Invalid redirect URI"),
371        }
372    }
373
374    /// Create a 301 Permanent redirect
375    pub fn permanent(uri: &str) -> Self {
376        Self {
377            status: StatusCode::MOVED_PERMANENTLY,
378            location: HeaderValue::from_str(uri).expect("Invalid redirect URI"),
379        }
380    }
381
382    /// Create a 307 Temporary redirect
383    pub fn temporary(uri: &str) -> Self {
384        Self {
385            status: StatusCode::TEMPORARY_REDIRECT,
386            location: HeaderValue::from_str(uri).expect("Invalid redirect URI"),
387        }
388    }
389}
390
391impl IntoResponse for Redirect {
392    fn into_response(self) -> Response {
393        http::Response::builder()
394            .status(self.status)
395            .header(header::LOCATION, self.location)
396            .body(Full::new(Bytes::new()))
397            .unwrap()
398    }
399}
400
401impl ResponseModifier for Redirect {
402    fn update_response(op: &mut Operation) {
403        // Can be 301, 302, 307. We'll verify what we can generically say.
404        // Or we document "3xx"
405        op.responses.insert(
406            "3xx".to_string(),
407            ResponseSpec {
408                description: "Redirection".to_string(),
409                content: None,
410            },
411        );
412    }
413}
414
415/// Generic wrapper for returning a response with a custom status code.
416///
417/// The status code is specified as a const generic parameter.
418///
419/// # Example
420///
421/// ```rust,ignore
422/// use rustapi_core::response::WithStatus;
423///
424/// async fn accepted_handler() -> WithStatus<String, 202> {
425///     WithStatus("Request accepted for processing".to_string())
426/// }
427///
428/// async fn custom_status() -> WithStatus<&'static str, 418> {
429///     WithStatus("I'm a teapot")
430/// }
431/// ```
432#[derive(Debug, Clone)]
433pub struct WithStatus<T, const CODE: u16>(pub T);
434
435impl<T: IntoResponse, const CODE: u16> IntoResponse for WithStatus<T, CODE> {
436    fn into_response(self) -> Response {
437        let mut response = self.0.into_response();
438        // Convert the const generic to StatusCode
439        if let Ok(status) = StatusCode::from_u16(CODE) {
440            *response.status_mut() = status;
441        }
442        response
443    }
444}
445
446impl<T: for<'a> Schema<'a>, const CODE: u16> ResponseModifier for WithStatus<T, CODE> {
447    fn update_response(op: &mut Operation) {
448        let (name, _) = T::schema();
449
450        let schema_ref = SchemaRef::Ref {
451            reference: format!("#/components/schemas/{}", name),
452        };
453
454        op.responses.insert(
455            CODE.to_string(),
456            ResponseSpec {
457                description: format!("Response with status {}", CODE),
458                content: {
459                    let mut map = HashMap::new();
460                    map.insert(
461                        "application/json".to_string(),
462                        MediaType { schema: schema_ref },
463                    );
464                    Some(map)
465                },
466            },
467        );
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use http_body_util::BodyExt;
475    use proptest::prelude::*;
476
477    // Helper to extract body bytes from a Full<Bytes> body
478    async fn body_to_bytes(body: Full<Bytes>) -> Bytes {
479        body.collect().await.unwrap().to_bytes()
480    }
481
482    // **Feature: phase3-batteries-included, Property 19: WithStatus response correctness**
483    //
484    // For any status code N and body B, `WithStatus<B, N>` SHALL produce a response
485    // with status N and body equal to B serialized.
486    //
487    // **Validates: Requirements 6.2**
488    proptest! {
489        #![proptest_config(ProptestConfig::with_cases(100))]
490
491        #[test]
492        fn prop_with_status_response_correctness(
493            body in "[a-zA-Z0-9 ]{0,100}",
494        ) {
495            let rt = tokio::runtime::Runtime::new().unwrap();
496            rt.block_on(async {
497                // We need to test with specific const generics, so we'll test a few representative cases
498                // and verify the pattern holds. Since const generics must be known at compile time,
499                // we test the behavior by checking that the status code is correctly applied.
500
501                // Test with 200 OK
502                let response_200: Response = WithStatus::<_, 200>(body.clone()).into_response();
503                prop_assert_eq!(response_200.status().as_u16(), 200);
504
505                // Test with 201 Created
506                let response_201: Response = WithStatus::<_, 201>(body.clone()).into_response();
507                prop_assert_eq!(response_201.status().as_u16(), 201);
508
509                // Test with 202 Accepted
510                let response_202: Response = WithStatus::<_, 202>(body.clone()).into_response();
511                prop_assert_eq!(response_202.status().as_u16(), 202);
512
513                // Test with 204 No Content
514                let response_204: Response = WithStatus::<_, 204>(body.clone()).into_response();
515                prop_assert_eq!(response_204.status().as_u16(), 204);
516
517                // Test with 400 Bad Request
518                let response_400: Response = WithStatus::<_, 400>(body.clone()).into_response();
519                prop_assert_eq!(response_400.status().as_u16(), 400);
520
521                // Test with 404 Not Found
522                let response_404: Response = WithStatus::<_, 404>(body.clone()).into_response();
523                prop_assert_eq!(response_404.status().as_u16(), 404);
524
525                // Test with 418 I'm a teapot
526                let response_418: Response = WithStatus::<_, 418>(body.clone()).into_response();
527                prop_assert_eq!(response_418.status().as_u16(), 418);
528
529                // Test with 500 Internal Server Error
530                let response_500: Response = WithStatus::<_, 500>(body.clone()).into_response();
531                prop_assert_eq!(response_500.status().as_u16(), 500);
532
533                // Test with 503 Service Unavailable
534                let response_503: Response = WithStatus::<_, 503>(body.clone()).into_response();
535                prop_assert_eq!(response_503.status().as_u16(), 503);
536
537                // Verify body is preserved (using a fresh 200 response)
538                let response_for_body: Response = WithStatus::<_, 200>(body.clone()).into_response();
539                let body_bytes = body_to_bytes(response_for_body.into_body()).await;
540                let body_str = String::from_utf8_lossy(&body_bytes);
541                prop_assert_eq!(body_str.as_ref(), body.as_str());
542
543                Ok(())
544            })?;
545        }
546    }
547
548    #[tokio::test]
549    async fn test_with_status_preserves_content_type() {
550        // Test that WithStatus preserves the content type from the inner response
551        let response: Response = WithStatus::<_, 202>("hello world").into_response();
552
553        assert_eq!(response.status().as_u16(), 202);
554        assert_eq!(
555            response.headers().get(header::CONTENT_TYPE).unwrap(),
556            "text/plain; charset=utf-8"
557        );
558    }
559
560    #[tokio::test]
561    async fn test_with_status_with_empty_body() {
562        let response: Response = WithStatus::<_, 204>(()).into_response();
563
564        assert_eq!(response.status().as_u16(), 204);
565        // Empty body should have zero size
566        let body_bytes = body_to_bytes(response.into_body()).await;
567        assert!(body_bytes.is_empty());
568    }
569
570    #[test]
571    fn test_with_status_common_codes() {
572        // Test common HTTP status codes
573        assert_eq!(
574            WithStatus::<_, 100>("").into_response().status().as_u16(),
575            100
576        ); // Continue
577        assert_eq!(
578            WithStatus::<_, 200>("").into_response().status().as_u16(),
579            200
580        ); // OK
581        assert_eq!(
582            WithStatus::<_, 201>("").into_response().status().as_u16(),
583            201
584        ); // Created
585        assert_eq!(
586            WithStatus::<_, 202>("").into_response().status().as_u16(),
587            202
588        ); // Accepted
589        assert_eq!(
590            WithStatus::<_, 204>("").into_response().status().as_u16(),
591            204
592        ); // No Content
593        assert_eq!(
594            WithStatus::<_, 301>("").into_response().status().as_u16(),
595            301
596        ); // Moved Permanently
597        assert_eq!(
598            WithStatus::<_, 302>("").into_response().status().as_u16(),
599            302
600        ); // Found
601        assert_eq!(
602            WithStatus::<_, 400>("").into_response().status().as_u16(),
603            400
604        ); // Bad Request
605        assert_eq!(
606            WithStatus::<_, 401>("").into_response().status().as_u16(),
607            401
608        ); // Unauthorized
609        assert_eq!(
610            WithStatus::<_, 403>("").into_response().status().as_u16(),
611            403
612        ); // Forbidden
613        assert_eq!(
614            WithStatus::<_, 404>("").into_response().status().as_u16(),
615            404
616        ); // Not Found
617        assert_eq!(
618            WithStatus::<_, 500>("").into_response().status().as_u16(),
619            500
620        ); // Internal Server Error
621        assert_eq!(
622            WithStatus::<_, 502>("").into_response().status().as_u16(),
623            502
624        ); // Bad Gateway
625        assert_eq!(
626            WithStatus::<_, 503>("").into_response().status().as_u16(),
627            503
628        ); // Service Unavailable
629    }
630}