use axum::body::Body;
use axum::response::{IntoResponse, Response};
use http::{HeaderValue, StatusCode, header};
use serde::Serialize;
use jsonapi_core::{
Document, DocumentBuilder, FieldsetConfig, JsonApiMediaType, Resource, ResourceObject,
};
use jsonapi_http::{content_type_value, try_json_api_response, try_json_api_response_filtered};
use crate::error::JsonApiError;
#[derive(Debug, Clone)]
pub struct JsonApiResponse<P, I = Resource> {
document: Document<P, I>,
status: StatusCode,
media_type: JsonApiMediaType,
fields: Option<FieldsetConfig>,
location: Option<String>,
}
impl<P, I> JsonApiResponse<P, I> {
#[must_use]
pub fn new(document: Document<P, I>) -> Self {
Self {
document,
status: StatusCode::OK,
media_type: JsonApiMediaType::plain(),
fields: None,
location: None,
}
}
#[must_use]
pub fn status(mut self, status: StatusCode) -> Self {
self.status = status;
self
}
#[must_use]
pub fn media_type(mut self, media_type: JsonApiMediaType) -> Self {
self.media_type = media_type;
self
}
#[must_use]
pub fn fields(mut self, fields: FieldsetConfig) -> Self {
self.fields = Some(fields);
self
}
#[must_use]
pub fn location(mut self, location: impl Into<String>) -> Self {
self.location = Some(location.into());
self
}
#[must_use]
pub fn created(self, self_link: impl Into<String>) -> Self {
self.status(StatusCode::CREATED).location(self_link)
}
}
impl<P: ResourceObject> JsonApiResponse<P, Resource> {
#[must_use]
pub fn single(primary: P) -> Self {
Self::new(DocumentBuilder::single(primary).build())
}
#[must_use]
pub fn collection(primary: Vec<P>) -> Self {
Self::new(DocumentBuilder::collection(primary).build())
}
}
impl<P, I> IntoResponse for JsonApiResponse<P, I>
where
P: ResourceObject,
I: Serialize,
{
fn into_response(self) -> Response {
let content_type = content_type_value(&self.media_type);
let result = match &self.fields {
Some(fields) if !fields.is_empty() => {
try_json_api_response_filtered(self.status, content_type, &self.document, fields)
}
_ => try_json_api_response(self.status, content_type, &self.document),
};
let response = match result {
Ok(response) => response,
Err(err) => return JsonApiError::internal(err.to_string()).into_response(),
};
let mut response = response.map(Body::from);
if let Some(location) = self.location
&& let Ok(value) = HeaderValue::from_str(&location)
{
response.headers_mut().insert(header::LOCATION, value);
}
response
}
}
impl<P, I> From<Document<P, I>> for JsonApiResponse<P, I> {
fn from(document: Document<P, I>) -> Self {
Self::new(document)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn sample_document() -> Document<Resource> {
serde_json::from_str(r#"{"data":{"type":"articles","id":"1"}}"#).unwrap()
}
fn read(response: Response) -> (StatusCode, Value) {
let status = response.status();
let bytes =
pollster::block_on(axum::body::to_bytes(response.into_body(), usize::MAX)).unwrap();
(status, serde_json::from_slice(&bytes).unwrap())
}
#[test]
fn created_sets_201_and_location_header_with_body_intact() {
let response = JsonApiResponse::new(sample_document())
.created("https://api.test/articles/1")
.into_response();
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("https://api.test/articles/1")
);
let (status, json) = read(response);
assert_eq!(status, StatusCode::CREATED);
assert_eq!(json["data"]["id"], "1");
}
#[test]
fn location_without_created_sets_header_but_keeps_status() {
let response = JsonApiResponse::new(sample_document())
.location("https://api.test/articles/1")
.into_response();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("https://api.test/articles/1")
);
}
#[test]
fn no_location_header_by_default() {
let response = JsonApiResponse::new(sample_document()).into_response();
assert!(response.headers().get(header::LOCATION).is_none());
}
fn sample_resource(id: &str) -> Resource {
serde_json::from_str(&format!(r#"{{"type":"articles","id":"{id}"}}"#)).unwrap()
}
#[test]
fn single_wraps_one_resource_as_ok_data_document() {
let response = JsonApiResponse::single(sample_resource("1")).into_response();
let (status, json) = read(response);
assert_eq!(status, StatusCode::OK);
assert_eq!(json["data"]["type"], "articles");
assert_eq!(json["data"]["id"], "1");
}
#[test]
fn collection_wraps_many_resources_as_ok_data_document() {
let response =
JsonApiResponse::collection(vec![sample_resource("1"), sample_resource("2")])
.into_response();
let (status, json) = read(response);
assert_eq!(status, StatusCode::OK);
assert_eq!(json["data"].as_array().unwrap().len(), 2);
assert_eq!(json["data"][1]["id"], "2");
}
#[test]
fn into_response_defaults_to_ok_json_api_with_body() {
let response = JsonApiResponse::new(sample_document()).into_response();
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/vnd.api+json")
);
let (status, json) = read(response);
assert_eq!(status, StatusCode::OK);
assert_eq!(json["data"]["type"], "articles");
assert_eq!(json["data"]["id"], "1");
}
#[test]
fn status_override_is_applied_with_intact_body() {
let response = JsonApiResponse::new(sample_document())
.status(StatusCode::CREATED)
.into_response();
let (status, json) = read(response);
assert_eq!(status, StatusCode::CREATED);
assert_eq!(json["data"]["id"], "1");
}
#[test]
fn from_document_defaults_to_ok_response() {
let response: JsonApiResponse<Resource> = sample_document().into();
let (status, json) = read(response.into_response());
assert_eq!(status, StatusCode::OK);
assert_eq!(json["data"]["id"], "1");
}
#[test]
fn collection_document_serializes_as_array() {
let document: Document<Resource> = serde_json::from_str(
r#"{"data":[{"type":"articles","id":"1"},{"type":"articles","id":"2"}]}"#,
)
.unwrap();
let (status, json) = read(JsonApiResponse::new(document).into_response());
assert_eq!(status, StatusCode::OK);
let data = json["data"].as_array().expect("collection is an array");
assert_eq!(data.len(), 2);
assert_eq!(data[1]["id"], "2");
}
fn compound_document() -> Document<Resource> {
serde_json::from_str(
r#"{"data":{"type":"articles","id":"1",
"attributes":{"title":"Hi","body":"World"},
"relationships":{"author":{"data":{"type":"people","id":"9"}}}},
"included":[{"type":"people","id":"9",
"attributes":{"name":"Dan","email":"dan@example.com"}}]}"#,
)
.unwrap()
}
#[test]
fn fields_trims_primary_attributes_keeping_type_and_id() {
let config = FieldsetConfig::new().fields("articles", &["title"]);
let (status, json) = read(
JsonApiResponse::new(compound_document())
.fields(config)
.into_response(),
);
assert_eq!(status, StatusCode::OK);
assert_eq!(json["data"]["type"], "articles");
assert_eq!(json["data"]["id"], "1");
assert_eq!(json["data"]["attributes"]["title"], "Hi");
assert!(json["data"]["attributes"].get("body").is_none());
assert!(json["data"].get("relationships").is_none());
}
#[test]
fn fields_trims_included_resource_by_its_own_type_entry() {
let config = FieldsetConfig::new()
.fields("articles", &["title"])
.fields("people", &["name"]);
let (_status, json) = read(
JsonApiResponse::new(compound_document())
.fields(config)
.into_response(),
);
assert_eq!(json["data"]["attributes"]["title"], "Hi");
assert!(json["data"]["attributes"].get("body").is_none());
assert_eq!(json["included"][0]["type"], "people");
assert_eq!(json["included"][0]["id"], "9");
assert_eq!(json["included"][0]["attributes"]["name"], "Dan");
assert!(json["included"][0]["attributes"].get("email").is_none());
}
#[test]
fn empty_and_absent_fields_produce_the_same_output_as_no_filter() {
let (_s, baseline) = read(JsonApiResponse::new(compound_document()).into_response());
let (_s, empty) = read(
JsonApiResponse::new(compound_document())
.fields(FieldsetConfig::new())
.into_response(),
);
assert_eq!(baseline, empty);
assert_eq!(empty["data"]["attributes"]["body"], "World");
assert_eq!(
empty["included"][0]["attributes"]["email"],
"dan@example.com"
);
}
#[test]
fn media_type_adds_profile_parameter() {
let media =
JsonApiMediaType::parse("application/vnd.api+json; profile=\"https://example.com/p\"")
.unwrap();
let response = JsonApiResponse::new(sample_document())
.media_type(media)
.into_response();
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/vnd.api+json; profile=\"https://example.com/p\"")
);
}
}