Skip to main content

axum_api_kit/
success.rs

1//! Success-side response helpers for the parts of the HTTP lifecycle that
2//! [`ListResponse`](crate::ListResponse) and [`ApiError`](crate::ApiError) don't
3//! cover: resource creation, asynchronous acceptance, and empty responses.
4//!
5//! - [`Created<T>`] - `201 Created` carrying the new resource as a JSON body,
6//!   with an optional `Location` header.
7//! - [`Accepted<T>`] - `202 Accepted` carrying a JSON body (e.g. a job handle)
8//!   for work that will complete asynchronously.
9//! - [`NoContent`] - `204 No Content` with an empty body, for deletes and
10//!   updates that return nothing.
11
12use axum::{
13    http::{header::LOCATION, HeaderValue, StatusCode},
14    response::{IntoResponse, Response},
15    Json,
16};
17use serde::Serialize;
18
19/// A `201 Created` response carrying the newly created resource as a JSON body.
20///
21/// The body is the resource itself (serialized directly, not wrapped), matching
22/// the common REST convention for `POST` create endpoints. Optionally sets a
23/// `Location` header pointing at the new resource via [`Created::with_location`].
24///
25/// # Example
26///
27/// ```rust
28/// use axum::response::IntoResponse;
29/// use axum_api_kit::Created;
30/// use serde::Serialize;
31///
32/// #[derive(Serialize)]
33/// struct User { id: String, name: String }
34///
35/// async fn create_user() -> impl IntoResponse {
36///     let user = User { id: "42".into(), name: "Ada".into() };
37///     Created::new(user).with_location("/users/42")
38/// }
39/// ```
40#[derive(Debug, Clone)]
41pub struct Created<T: Serialize> {
42    /// The created resource, serialized as the JSON response body.
43    pub data: T,
44    location: Option<String>,
45}
46
47impl<T: Serialize> Created<T> {
48    /// Builds a `201 Created` response for `data` with no `Location` header.
49    pub fn new(data: T) -> Self {
50        Self {
51            data,
52            location: None,
53        }
54    }
55
56    /// Sets the `Location` header to point at the new resource.
57    ///
58    /// If the value cannot be represented as a valid header (e.g. it contains
59    /// control characters), the header is omitted rather than panicking.
60    pub fn with_location(mut self, location: impl Into<String>) -> Self {
61        self.location = Some(location.into());
62        self
63    }
64}
65
66impl<T: Serialize> IntoResponse for Created<T> {
67    fn into_response(self) -> Response {
68        let header = self
69            .location
70            .as_deref()
71            .and_then(|l| HeaderValue::from_str(l).ok());
72        match header {
73            Some(loc) => (StatusCode::CREATED, [(LOCATION, loc)], Json(self.data)).into_response(),
74            None => (StatusCode::CREATED, Json(self.data)).into_response(),
75        }
76    }
77}
78
79/// A `202 Accepted` response carrying a JSON body.
80///
81/// Use for requests that have been accepted for processing but will complete
82/// asynchronously - the body typically describes how to track the work (e.g. a
83/// job id or a status URL).
84///
85/// # Example
86///
87/// ```rust
88/// use axum::response::IntoResponse;
89/// use axum_api_kit::Accepted;
90/// use serde::Serialize;
91///
92/// #[derive(Serialize)]
93/// struct Job { id: String }
94///
95/// async fn enqueue() -> impl IntoResponse {
96///     Accepted::new(Job { id: "job-1".into() })
97/// }
98/// ```
99#[derive(Debug, Clone)]
100pub struct Accepted<T: Serialize> {
101    /// The body describing the accepted work, serialized as JSON.
102    pub data: T,
103}
104
105impl<T: Serialize> Accepted<T> {
106    /// Builds a `202 Accepted` response for `data`.
107    pub fn new(data: T) -> Self {
108        Self { data }
109    }
110}
111
112impl<T: Serialize> IntoResponse for Accepted<T> {
113    fn into_response(self) -> Response {
114        (StatusCode::ACCEPTED, Json(self.data)).into_response()
115    }
116}
117
118/// A `204 No Content` response with an empty body.
119///
120/// Use for `DELETE` and for `PUT`/`PATCH` handlers that return nothing.
121///
122/// # Example
123///
124/// ```rust
125/// use axum::response::IntoResponse;
126/// use axum_api_kit::NoContent;
127///
128/// async fn delete_user() -> impl IntoResponse {
129///     NoContent
130/// }
131/// ```
132#[derive(Debug, Clone, Copy, Default)]
133pub struct NoContent;
134
135impl NoContent {
136    /// Builds a `204 No Content` response.
137    pub fn new() -> Self {
138        Self
139    }
140}
141
142impl IntoResponse for NoContent {
143    fn into_response(self) -> Response {
144        StatusCode::NO_CONTENT.into_response()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[derive(Serialize)]
153    struct Item {
154        id: String,
155    }
156
157    async fn body_json(res: Response) -> serde_json::Value {
158        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
159            .await
160            .unwrap();
161        serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
162    }
163
164    #[tokio::test]
165    async fn created_has_201_and_serializes_resource() {
166        let res = Created::new(Item { id: "1".into() }).into_response();
167        assert_eq!(res.status(), StatusCode::CREATED);
168        assert!(res.headers().get(LOCATION).is_none());
169        let body = body_json(res).await;
170        assert_eq!(body["id"], "1");
171    }
172
173    #[tokio::test]
174    async fn created_with_location_sets_header() {
175        let res = Created::new(Item { id: "1".into() })
176            .with_location("/items/1")
177            .into_response();
178        assert_eq!(res.status(), StatusCode::CREATED);
179        assert_eq!(res.headers().get(LOCATION).unwrap(), "/items/1");
180        let body = body_json(res).await;
181        assert_eq!(body["id"], "1");
182    }
183
184    #[tokio::test]
185    async fn created_drops_invalid_location() {
186        // A newline cannot be a header value; the header should be omitted.
187        let res = Created::new(Item { id: "1".into() })
188            .with_location("/items/\n1")
189            .into_response();
190        assert_eq!(res.status(), StatusCode::CREATED);
191        assert!(res.headers().get(LOCATION).is_none());
192    }
193
194    #[tokio::test]
195    async fn accepted_has_202_and_serializes_body() {
196        let res = Accepted::new(Item { id: "job-1".into() }).into_response();
197        assert_eq!(res.status(), StatusCode::ACCEPTED);
198        let body = body_json(res).await;
199        assert_eq!(body["id"], "job-1");
200    }
201
202    #[tokio::test]
203    async fn no_content_has_204_and_empty_body() {
204        let res = NoContent.into_response();
205        assert_eq!(res.status(), StatusCode::NO_CONTENT);
206        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
207            .await
208            .unwrap();
209        assert!(bytes.is_empty());
210    }
211
212    #[test]
213    fn no_content_new_matches_unit() {
214        // Both construction styles compile and are equivalent.
215        let _ = NoContent::new();
216        let _ = NoContent;
217    }
218}