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