use axum::{
http::{header::LOCATION, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct Created<T: Serialize> {
pub data: T,
location: Option<String>,
}
impl<T: Serialize> Created<T> {
pub fn new(data: T) -> Self {
Self {
data,
location: None,
}
}
pub fn with_location(mut self, location: impl Into<String>) -> Self {
self.location = Some(location.into());
self
}
}
impl<T: Serialize> IntoResponse for Created<T> {
fn into_response(self) -> Response {
let header = self
.location
.as_deref()
.and_then(|l| HeaderValue::from_str(l).ok());
match header {
Some(loc) => (StatusCode::CREATED, [(LOCATION, loc)], Json(self.data)).into_response(),
None => (StatusCode::CREATED, Json(self.data)).into_response(),
}
}
}
#[derive(Debug, Clone)]
pub struct Accepted<T: Serialize> {
pub data: T,
}
impl<T: Serialize> Accepted<T> {
pub fn new(data: T) -> Self {
Self { data }
}
}
impl<T: Serialize> IntoResponse for Accepted<T> {
fn into_response(self) -> Response {
(StatusCode::ACCEPTED, Json(self.data)).into_response()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoContent;
impl NoContent {
pub fn new() -> Self {
Self
}
}
impl IntoResponse for NoContent {
fn into_response(self) -> Response {
StatusCode::NO_CONTENT.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Serialize)]
struct Item {
id: String,
}
async fn body_json(res: Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
}
#[tokio::test]
async fn created_has_201_and_serializes_resource() {
let res = Created::new(Item { id: "1".into() }).into_response();
assert_eq!(res.status(), StatusCode::CREATED);
assert!(res.headers().get(LOCATION).is_none());
let body = body_json(res).await;
assert_eq!(body["id"], "1");
}
#[tokio::test]
async fn created_with_location_sets_header() {
let res = Created::new(Item { id: "1".into() })
.with_location("/items/1")
.into_response();
assert_eq!(res.status(), StatusCode::CREATED);
assert_eq!(res.headers().get(LOCATION).unwrap(), "/items/1");
let body = body_json(res).await;
assert_eq!(body["id"], "1");
}
#[tokio::test]
async fn created_drops_invalid_location() {
let res = Created::new(Item { id: "1".into() })
.with_location("/items/\n1")
.into_response();
assert_eq!(res.status(), StatusCode::CREATED);
assert!(res.headers().get(LOCATION).is_none());
}
#[tokio::test]
async fn accepted_has_202_and_serializes_body() {
let res = Accepted::new(Item { id: "job-1".into() }).into_response();
assert_eq!(res.status(), StatusCode::ACCEPTED);
let body = body_json(res).await;
assert_eq!(body["id"], "job-1");
}
#[tokio::test]
async fn no_content_has_204_and_empty_body() {
let res = NoContent.into_response();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
assert!(bytes.is_empty());
}
#[test]
fn no_content_new_matches_unit() {
let _ = NoContent::new();
let _ = NoContent;
}
}