Skip to main content

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 futures_util::StreamExt;
76use http::{header, HeaderMap, HeaderValue, StatusCode};
77use http_body_util::Full;
78use rustapi_openapi::{MediaType, Operation, ResponseModifier, ResponseSpec, Schema, SchemaRef};
79use serde::Serialize;
80use std::collections::HashMap;
81use std::pin::Pin;
82use std::task::{Context, Poll};
83
84/// Unified response body type
85pub enum Body {
86    /// Fully buffered body (default)
87    Full(Full<Bytes>),
88    /// Streaming body
89    Streaming(Pin<Box<dyn http_body::Body<Data = Bytes, Error = ApiError> + Send + 'static>>),
90}
91
92impl Body {
93    /// Create a new full body from bytes
94    pub fn new(bytes: Bytes) -> Self {
95        Self::Full(Full::new(bytes))
96    }
97
98    /// Create an empty body
99    pub fn empty() -> Self {
100        Self::Full(Full::new(Bytes::new()))
101    }
102
103    /// Create a streaming body
104    pub fn from_stream<S, E>(stream: S) -> Self
105    where
106        S: futures_util::Stream<Item = Result<Bytes, E>> + Send + 'static,
107        E: Into<ApiError> + 'static,
108    {
109        let body = http_body_util::StreamBody::new(
110            stream.map(|res| res.map_err(|e| e.into()).map(http_body::Frame::data)),
111        );
112        Self::Streaming(Box::pin(body))
113    }
114}
115
116impl Default for Body {
117    fn default() -> Self {
118        Self::empty()
119    }
120}
121
122impl http_body::Body for Body {
123    type Data = Bytes;
124    type Error = ApiError;
125
126    fn poll_frame(
127        self: Pin<&mut Self>,
128        cx: &mut Context<'_>,
129    ) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
130        match self.get_mut() {
131            Body::Full(b) => Pin::new(b)
132                .poll_frame(cx)
133                .map_err(|_| ApiError::internal("Infallible error")),
134            Body::Streaming(b) => b.as_mut().poll_frame(cx),
135        }
136    }
137
138    fn is_end_stream(&self) -> bool {
139        match self {
140            Body::Full(b) => b.is_end_stream(),
141            Body::Streaming(b) => b.is_end_stream(),
142        }
143    }
144
145    fn size_hint(&self) -> http_body::SizeHint {
146        match self {
147            Body::Full(b) => b.size_hint(),
148            Body::Streaming(b) => b.size_hint(),
149        }
150    }
151}
152
153impl From<Bytes> for Body {
154    fn from(bytes: Bytes) -> Self {
155        Self::new(bytes)
156    }
157}
158
159impl From<String> for Body {
160    fn from(s: String) -> Self {
161        Self::new(Bytes::from(s))
162    }
163}
164
165impl From<&'static str> for Body {
166    fn from(s: &'static str) -> Self {
167        Self::new(Bytes::from(s))
168    }
169}
170
171impl From<Vec<u8>> for Body {
172    fn from(v: Vec<u8>) -> Self {
173        Self::new(Bytes::from(v))
174    }
175}
176
177/// HTTP Response type
178pub type Response = http::Response<Body>;
179
180/// Trait for types that can be converted into an HTTP response
181pub trait IntoResponse {
182    /// Convert self into a Response
183    fn into_response(self) -> Response;
184}
185
186// Implement for Response itself
187impl IntoResponse for Response {
188    fn into_response(self) -> Response {
189        self
190    }
191}
192
193// Implement for () - returns 200 OK with empty body
194impl IntoResponse for () {
195    fn into_response(self) -> Response {
196        http::Response::builder()
197            .status(StatusCode::OK)
198            .body(Body::empty())
199            .unwrap()
200    }
201}
202
203// Implement for &'static str
204impl IntoResponse for &'static str {
205    fn into_response(self) -> Response {
206        http::Response::builder()
207            .status(StatusCode::OK)
208            .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
209            .body(Body::from(self))
210            .unwrap()
211    }
212}
213
214// Implement for String
215impl IntoResponse for String {
216    fn into_response(self) -> Response {
217        http::Response::builder()
218            .status(StatusCode::OK)
219            .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
220            .body(Body::from(self))
221            .unwrap()
222    }
223}
224
225// Implement for StatusCode
226impl IntoResponse for StatusCode {
227    fn into_response(self) -> Response {
228        http::Response::builder()
229            .status(self)
230            .body(Body::empty())
231            .unwrap()
232    }
233}
234
235// Implement for (StatusCode, impl IntoResponse)
236impl<R: IntoResponse> IntoResponse for (StatusCode, R) {
237    fn into_response(self) -> Response {
238        let mut response = self.1.into_response();
239        *response.status_mut() = self.0;
240        response
241    }
242}
243
244// Implement for (StatusCode, HeaderMap, impl IntoResponse)
245impl<R: IntoResponse> IntoResponse for (StatusCode, HeaderMap, R) {
246    fn into_response(self) -> Response {
247        let mut response = self.2.into_response();
248        *response.status_mut() = self.0;
249        response.headers_mut().extend(self.1);
250        response
251    }
252}
253
254// Implement for Result<T, E> where both implement IntoResponse
255impl<T: IntoResponse, E: IntoResponse> IntoResponse for Result<T, E> {
256    fn into_response(self) -> Response {
257        match self {
258            Ok(v) => v.into_response(),
259            Err(e) => e.into_response(),
260        }
261    }
262}
263
264// Implement for ApiError
265// Implement for ApiError with environment-aware error masking
266impl IntoResponse for ApiError {
267    fn into_response(self) -> Response {
268        let status = self.status;
269        // ErrorResponse::from now handles environment-aware masking
270        let error_response = ErrorResponse::from(self);
271        let body = serde_json::to_vec(&error_response).unwrap_or_else(|_| {
272            br#"{"error":{"type":"internal_error","message":"Failed to serialize error"}}"#.to_vec()
273        });
274
275        http::Response::builder()
276            .status(status)
277            .header(header::CONTENT_TYPE, "application/json")
278            .body(Body::from(body))
279            .unwrap()
280    }
281}
282
283impl ResponseModifier for ApiError {
284    fn update_response(op: &mut Operation) {
285        // We define common error responses here
286        // 400 Bad Request
287        op.responses.insert(
288            "400".to_string(),
289            ResponseSpec {
290                description: "Bad Request".to_string(),
291                content: {
292                    let mut map = HashMap::new();
293                    map.insert(
294                        "application/json".to_string(),
295                        MediaType {
296                            schema: SchemaRef::Ref {
297                                reference: "#/components/schemas/ErrorSchema".to_string(),
298                            },
299                        },
300                    );
301                    Some(map)
302                },
303            },
304        );
305
306        // 500 Internal Server Error
307        op.responses.insert(
308            "500".to_string(),
309            ResponseSpec {
310                description: "Internal Server Error".to_string(),
311                content: {
312                    let mut map = HashMap::new();
313                    map.insert(
314                        "application/json".to_string(),
315                        MediaType {
316                            schema: SchemaRef::Ref {
317                                reference: "#/components/schemas/ErrorSchema".to_string(),
318                            },
319                        },
320                    );
321                    Some(map)
322                },
323            },
324        );
325    }
326}
327
328/// 201 Created response wrapper
329///
330/// Returns HTTP 201 with JSON body.
331///
332/// # Example
333///
334/// ```rust,ignore
335/// async fn create_user(body: UserIn) -> Result<Created<UserOut>> {
336///     let user = db.create(body).await?;
337///     Ok(Created(user))
338/// }
339/// ```
340#[derive(Debug, Clone)]
341pub struct Created<T>(pub T);
342
343impl<T: Serialize> IntoResponse for Created<T> {
344    fn into_response(self) -> Response {
345        match serde_json::to_vec(&self.0) {
346            Ok(body) => http::Response::builder()
347                .status(StatusCode::CREATED)
348                .header(header::CONTENT_TYPE, "application/json")
349                .body(Body::from(body))
350                .unwrap(),
351            Err(err) => {
352                ApiError::internal(format!("Failed to serialize response: {}", err)).into_response()
353            }
354        }
355    }
356}
357
358impl<T: for<'a> Schema<'a>> ResponseModifier for Created<T> {
359    fn update_response(op: &mut Operation) {
360        let (name, _) = T::schema();
361
362        let schema_ref = SchemaRef::Ref {
363            reference: format!("#/components/schemas/{}", name),
364        };
365
366        op.responses.insert(
367            "201".to_string(),
368            ResponseSpec {
369                description: "Created".to_string(),
370                content: {
371                    let mut map = HashMap::new();
372                    map.insert(
373                        "application/json".to_string(),
374                        MediaType { schema: schema_ref },
375                    );
376                    Some(map)
377                },
378            },
379        );
380    }
381}
382
383/// 204 No Content response
384///
385/// Returns HTTP 204 with empty body.
386///
387/// # Example
388///
389/// ```rust,ignore
390/// async fn delete_user(id: i64) -> Result<NoContent> {
391///     db.delete(id).await?;
392///     Ok(NoContent)
393/// }
394/// ```
395#[derive(Debug, Clone, Copy)]
396pub struct NoContent;
397
398impl IntoResponse for NoContent {
399    fn into_response(self) -> Response {
400        http::Response::builder()
401            .status(StatusCode::NO_CONTENT)
402            .body(Body::empty())
403            .unwrap()
404    }
405}
406
407impl ResponseModifier for NoContent {
408    fn update_response(op: &mut Operation) {
409        op.responses.insert(
410            "204".to_string(),
411            ResponseSpec {
412                description: "No Content".to_string(),
413                content: None,
414            },
415        );
416    }
417}
418
419/// HTML response wrapper
420#[derive(Debug, Clone)]
421pub struct Html<T>(pub T);
422
423impl<T: Into<String>> IntoResponse for Html<T> {
424    fn into_response(self) -> Response {
425        http::Response::builder()
426            .status(StatusCode::OK)
427            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
428            .body(Body::from(self.0.into()))
429            .unwrap()
430    }
431}
432
433impl<T> ResponseModifier for Html<T> {
434    fn update_response(op: &mut Operation) {
435        op.responses.insert(
436            "200".to_string(),
437            ResponseSpec {
438                description: "HTML Content".to_string(),
439                content: {
440                    let mut map = HashMap::new();
441                    map.insert(
442                        "text/html".to_string(),
443                        MediaType {
444                            schema: SchemaRef::Inline(serde_json::json!({ "type": "string" })),
445                        },
446                    );
447                    Some(map)
448                },
449            },
450        );
451    }
452}
453
454/// Redirect response
455#[derive(Debug, Clone)]
456pub struct Redirect {
457    status: StatusCode,
458    location: HeaderValue,
459}
460
461impl Redirect {
462    /// Create a 302 Found redirect
463    pub fn to(uri: &str) -> Self {
464        Self {
465            status: StatusCode::FOUND,
466            location: HeaderValue::from_str(uri).expect("Invalid redirect URI"),
467        }
468    }
469
470    /// Create a 301 Permanent redirect
471    pub fn permanent(uri: &str) -> Self {
472        Self {
473            status: StatusCode::MOVED_PERMANENTLY,
474            location: HeaderValue::from_str(uri).expect("Invalid redirect URI"),
475        }
476    }
477
478    /// Create a 307 Temporary redirect
479    pub fn temporary(uri: &str) -> Self {
480        Self {
481            status: StatusCode::TEMPORARY_REDIRECT,
482            location: HeaderValue::from_str(uri).expect("Invalid redirect URI"),
483        }
484    }
485}
486
487impl IntoResponse for Redirect {
488    fn into_response(self) -> Response {
489        http::Response::builder()
490            .status(self.status)
491            .header(header::LOCATION, self.location)
492            .body(Body::empty())
493            .unwrap()
494    }
495}
496
497impl ResponseModifier for Redirect {
498    fn update_response(op: &mut Operation) {
499        // Can be 301, 302, 307. We'll verify what we can generically say.
500        // Or we document "3xx"
501        op.responses.insert(
502            "3xx".to_string(),
503            ResponseSpec {
504                description: "Redirection".to_string(),
505                content: None,
506            },
507        );
508    }
509}
510
511/// Generic wrapper for returning a response with a custom status code.
512///
513/// The status code is specified as a const generic parameter.
514///
515/// # Example
516///
517/// ```rust,ignore
518/// use rustapi_core::response::WithStatus;
519///
520/// async fn accepted_handler() -> WithStatus<String, 202> {
521///     WithStatus("Request accepted for processing".to_string())
522/// }
523///
524/// async fn custom_status() -> WithStatus<&'static str, 418> {
525///     WithStatus("I'm a teapot")
526/// }
527/// ```
528#[derive(Debug, Clone)]
529pub struct WithStatus<T, const CODE: u16>(pub T);
530
531impl<T: IntoResponse, const CODE: u16> IntoResponse for WithStatus<T, CODE> {
532    fn into_response(self) -> Response {
533        let mut response = self.0.into_response();
534        // Convert the const generic to StatusCode
535        if let Ok(status) = StatusCode::from_u16(CODE) {
536            *response.status_mut() = status;
537        }
538        response
539    }
540}
541
542impl<T: for<'a> Schema<'a>, const CODE: u16> ResponseModifier for WithStatus<T, CODE> {
543    fn update_response(op: &mut Operation) {
544        let (name, _) = T::schema();
545
546        let schema_ref = SchemaRef::Ref {
547            reference: format!("#/components/schemas/{}", name),
548        };
549
550        op.responses.insert(
551            CODE.to_string(),
552            ResponseSpec {
553                description: format!("Response with status {}", CODE),
554                content: {
555                    let mut map = HashMap::new();
556                    map.insert(
557                        "application/json".to_string(),
558                        MediaType { schema: schema_ref },
559                    );
560                    Some(map)
561                },
562            },
563        );
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use proptest::prelude::*;
571
572    // Helper to extract body bytes from a Full<Bytes> body
573    async fn body_to_bytes(body: Body) -> Bytes {
574        use http_body_util::BodyExt;
575        body.collect().await.unwrap().to_bytes()
576    }
577
578    // **Feature: phase3-batteries-included, Property 19: WithStatus response correctness**
579    //
580    // For any status code N and body B, `WithStatus<B, N>` SHALL produce a response
581    // with status N and body equal to B serialized.
582    //
583    // **Validates: Requirements 6.2**
584    proptest! {
585        #![proptest_config(ProptestConfig::with_cases(100))]
586
587        #[test]
588        fn prop_with_status_response_correctness(
589            body in "[a-zA-Z0-9 ]{0,100}",
590        ) {
591            let rt = tokio::runtime::Runtime::new().unwrap();
592            rt.block_on(async {
593                // We need to test with specific const generics, so we'll test a few representative cases
594                // and verify the pattern holds. Since const generics must be known at compile time,
595                // we test the behavior by checking that the status code is correctly applied.
596
597                // Test with 200 OK
598                let response_200: Response = WithStatus::<_, 200>(body.clone()).into_response();
599                prop_assert_eq!(response_200.status().as_u16(), 200);
600
601                // Test with 201 Created
602                let response_201: Response = WithStatus::<_, 201>(body.clone()).into_response();
603                prop_assert_eq!(response_201.status().as_u16(), 201);
604
605                // Test with 202 Accepted
606                let response_202: Response = WithStatus::<_, 202>(body.clone()).into_response();
607                prop_assert_eq!(response_202.status().as_u16(), 202);
608
609                // Test with 204 No Content
610                let response_204: Response = WithStatus::<_, 204>(body.clone()).into_response();
611                prop_assert_eq!(response_204.status().as_u16(), 204);
612
613                // Test with 400 Bad Request
614                let response_400: Response = WithStatus::<_, 400>(body.clone()).into_response();
615                prop_assert_eq!(response_400.status().as_u16(), 400);
616
617                // Test with 404 Not Found
618                let response_404: Response = WithStatus::<_, 404>(body.clone()).into_response();
619                prop_assert_eq!(response_404.status().as_u16(), 404);
620
621                // Test with 418 I'm a teapot
622                let response_418: Response = WithStatus::<_, 418>(body.clone()).into_response();
623                prop_assert_eq!(response_418.status().as_u16(), 418);
624
625                // Test with 500 Internal Server Error
626                let response_500: Response = WithStatus::<_, 500>(body.clone()).into_response();
627                prop_assert_eq!(response_500.status().as_u16(), 500);
628
629                // Test with 503 Service Unavailable
630                let response_503: Response = WithStatus::<_, 503>(body.clone()).into_response();
631                prop_assert_eq!(response_503.status().as_u16(), 503);
632
633                // Verify body is preserved (using a fresh 200 response)
634                let response_for_body: Response = WithStatus::<_, 200>(body.clone()).into_response();
635                let body_bytes = body_to_bytes(response_for_body.into_body()).await;
636                let body_str = String::from_utf8_lossy(&body_bytes);
637                prop_assert_eq!(body_str.as_ref(), body.as_str());
638
639                Ok(())
640            })?;
641        }
642    }
643
644    #[tokio::test]
645    async fn test_with_status_preserves_content_type() {
646        // Test that WithStatus preserves the content type from the inner response
647        let response: Response = WithStatus::<_, 202>("hello world").into_response();
648
649        assert_eq!(response.status().as_u16(), 202);
650        assert_eq!(
651            response.headers().get(header::CONTENT_TYPE).unwrap(),
652            "text/plain; charset=utf-8"
653        );
654    }
655
656    #[tokio::test]
657    async fn test_with_status_with_empty_body() {
658        let response: Response = WithStatus::<_, 204>(()).into_response();
659
660        assert_eq!(response.status().as_u16(), 204);
661        // Empty body should have zero size
662        let body_bytes = body_to_bytes(response.into_body()).await;
663        assert!(body_bytes.is_empty());
664    }
665
666    #[test]
667    fn test_with_status_common_codes() {
668        // Test common HTTP status codes
669        assert_eq!(
670            WithStatus::<_, 100>("").into_response().status().as_u16(),
671            100
672        ); // Continue
673        assert_eq!(
674            WithStatus::<_, 200>("").into_response().status().as_u16(),
675            200
676        ); // OK
677        assert_eq!(
678            WithStatus::<_, 201>("").into_response().status().as_u16(),
679            201
680        ); // Created
681        assert_eq!(
682            WithStatus::<_, 202>("").into_response().status().as_u16(),
683            202
684        ); // Accepted
685        assert_eq!(
686            WithStatus::<_, 204>("").into_response().status().as_u16(),
687            204
688        ); // No Content
689        assert_eq!(
690            WithStatus::<_, 301>("").into_response().status().as_u16(),
691            301
692        ); // Moved Permanently
693        assert_eq!(
694            WithStatus::<_, 302>("").into_response().status().as_u16(),
695            302
696        ); // Found
697        assert_eq!(
698            WithStatus::<_, 400>("").into_response().status().as_u16(),
699            400
700        ); // Bad Request
701        assert_eq!(
702            WithStatus::<_, 401>("").into_response().status().as_u16(),
703            401
704        ); // Unauthorized
705        assert_eq!(
706            WithStatus::<_, 403>("").into_response().status().as_u16(),
707            403
708        ); // Forbidden
709        assert_eq!(
710            WithStatus::<_, 404>("").into_response().status().as_u16(),
711            404
712        ); // Not Found
713        assert_eq!(
714            WithStatus::<_, 500>("").into_response().status().as_u16(),
715            500
716        ); // Internal Server Error
717        assert_eq!(
718            WithStatus::<_, 502>("").into_response().status().as_u16(),
719            502
720        ); // Bad Gateway
721        assert_eq!(
722            WithStatus::<_, 503>("").into_response().status().as_u16(),
723            503
724        ); // Service Unavailable
725    }
726}