use axum::http::{HeaderMap, HeaderValue, StatusCode};
use fraiseql_core::schema::{DeleteResponse, RestConfig};
use serde_json::json;
use xxhash_rust::xxh3::xxh3_64;
use super::{
handler::{PreferHeader, RestError, RestResponse},
params::PaginationParams,
resource::HttpMethod,
};
pub struct RestResponseFormatter<'a> {
config: &'a RestConfig,
base_path: &'a str,
}
impl<'a> RestResponseFormatter<'a> {
#[must_use]
pub const fn new(config: &'a RestConfig, base_path: &'a str) -> Self {
Self { config, base_path }
}
pub fn format_single(
&self,
result: &serde_json::Value,
request_headers: &HeaderMap,
) -> Result<RestResponse, RestError> {
let data = extract_single_data(result)?;
let body = json!({ "data": data });
let body_bytes = serde_json::to_vec(&body)
.map_err(|e| RestError::internal(format!("Failed to serialize response: {e}")))?;
let mut headers = HeaderMap::new();
set_request_id(request_headers, &mut headers);
if self.config.etag {
let etag = compute_etag(&body_bytes);
if check_if_none_match(request_headers, &etag) == Some(true) {
headers.insert("etag", header_value(&etag));
return Ok(RestResponse {
status: StatusCode::NOT_MODIFIED,
headers,
body: None,
});
}
headers.insert("etag", header_value(&etag));
}
Ok(RestResponse {
status: StatusCode::OK,
headers,
body: Some(body),
})
}
pub fn format_collection(
&self,
result: &serde_json::Value,
total: Option<u64>,
pagination: &PaginationParams,
resource_path: &str,
request_headers: &HeaderMap,
prefer: &PreferHeader,
) -> Result<RestResponse, RestError> {
let data = extract_collection_data(result)?;
let mut response = json!({ "data": data });
match pagination {
PaginationParams::Offset { limit, offset } => {
let mut meta = json!({
"limit": limit,
"offset": offset,
});
if let Some(total) = total {
meta["total"] = json!(total);
}
response["meta"] = meta;
let base = format!("{}{}", self.base_path, resource_path);
let links = build_offset_links(&base, *limit, *offset, total);
response["links"] = links;
},
PaginationParams::Cursor {
first,
after,
last,
before,
} => {
let mut meta = serde_json::Map::new();
if let Some(page_info) = extract_relay_page_info(&data) {
if let Some(has_next) = page_info.get("hasNextPage") {
meta.insert("hasNextPage".to_string(), has_next.clone());
}
if let Some(has_prev) = page_info.get("hasPreviousPage") {
meta.insert("hasPreviousPage".to_string(), has_prev.clone());
}
}
if let Some(f) = first {
meta.insert("first".to_string(), json!(f));
}
if let Some(ref a) = after {
meta.insert("after".to_string(), json!(a));
}
if let Some(l) = last {
meta.insert("last".to_string(), json!(l));
}
if let Some(ref b) = before {
meta.insert("before".to_string(), json!(b));
}
if let Some(total) = total {
meta.insert("total".to_string(), json!(total));
}
response["meta"] = serde_json::Value::Object(meta);
let base = format!("{}{}", self.base_path, resource_path);
let links = build_cursor_links(&base, *first, after.as_deref(), &data);
response["links"] = links;
},
PaginationParams::None => {
},
}
let body_bytes = serde_json::to_vec(&response)
.map_err(|e| RestError::internal(format!("Failed to serialize response: {e}")))?;
let mut headers = HeaderMap::new();
set_request_id(request_headers, &mut headers);
if prefer.count_exact && total.is_some() {
headers.insert("preference-applied", HeaderValue::from_static("count=exact"));
}
if self.config.etag {
let etag = compute_etag(&body_bytes);
if check_if_none_match(request_headers, &etag) == Some(true) {
headers.insert("etag", header_value(&etag));
return Ok(RestResponse {
status: StatusCode::NOT_MODIFIED,
headers,
body: None,
});
}
headers.insert("etag", header_value(&etag));
}
Ok(RestResponse {
status: StatusCode::OK,
headers,
body: Some(response),
})
}
pub fn format_created(
&self,
result: &serde_json::Value,
resource_path: &str,
id: Option<&serde_json::Value>,
request_headers: &HeaderMap,
) -> Result<RestResponse, RestError> {
let data = extract_mutation_data(result)?;
let body = json!({ "data": data });
let mut headers = HeaderMap::new();
set_request_id(request_headers, &mut headers);
if let Some(id_val) = id.or_else(|| extract_id_from_data(&data)) {
let id_str = format_id_for_url(id_val);
let location = format!("{}{}/{}", self.base_path, resource_path, id_str);
if let Ok(val) = HeaderValue::from_str(&location) {
headers.insert("location", val);
}
}
Ok(RestResponse {
status: StatusCode::CREATED,
headers,
body: Some(body),
})
}
pub fn format_mutation(
&self,
result: &serde_json::Value,
request_headers: &HeaderMap,
) -> Result<RestResponse, RestError> {
let data = extract_mutation_data(result)?;
let body = json!({ "data": data });
let mut headers = HeaderMap::new();
set_request_id(request_headers, &mut headers);
Ok(RestResponse {
status: StatusCode::OK,
headers,
body: Some(body),
})
}
pub fn format_deleted(
&self,
result: &serde_json::Value,
mutation_name: &str,
prefer: &PreferHeader,
request_headers: &HeaderMap,
) -> RestResponse {
let mut headers = HeaderMap::new();
set_request_id(request_headers, &mut headers);
let want_entity = if prefer.return_representation {
true
} else if prefer.return_minimal {
false
} else {
matches!(self.config.delete_response, DeleteResponse::Entity)
};
if want_entity {
let entity = extract_delete_entity(result, mutation_name);
if let Some(entity_value) = entity {
if prefer.return_representation {
headers.insert(
"preference-applied",
HeaderValue::from_static("return=representation"),
);
}
RestResponse {
status: StatusCode::OK,
headers,
body: Some(json!({ "data": entity_value })),
}
} else {
if prefer.return_representation {
headers
.insert("preference-applied", HeaderValue::from_static("return=minimal"));
headers.insert(
"x-preference-fallback",
HeaderValue::from_static("entity-unavailable"),
);
}
RestResponse {
status: StatusCode::NO_CONTENT,
headers,
body: None,
}
}
} else {
if prefer.return_minimal {
headers.insert("preference-applied", HeaderValue::from_static("return=minimal"));
}
RestResponse {
status: StatusCode::NO_CONTENT,
headers,
body: None,
}
}
}
#[must_use]
pub fn format_error(
error: &RestError,
request_headers: &HeaderMap,
allowed_methods: Option<&[HttpMethod]>,
) -> RestResponse {
let mut headers = HeaderMap::new();
set_request_id(request_headers, &mut headers);
if error.status == StatusCode::METHOD_NOT_ALLOWED {
if let Some(methods) = allowed_methods {
let allow_value: String =
methods.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
if let Ok(val) = HeaderValue::from_str(&allow_value) {
headers.insert("allow", val);
}
}
}
RestResponse {
status: error.status,
headers,
body: Some(error.to_json()),
}
}
}
fn compute_etag(body: &[u8]) -> String {
let hash = xxh3_64(body);
format!("W/\"{hash:016x}\"")
}
fn check_if_none_match(headers: &HeaderMap, etag: &str) -> Option<bool> {
let inm = headers.get("if-none-match")?.to_str().ok()?;
if inm.trim() == "*" {
return Some(true);
}
Some(inm.split(',').any(|tag| tag.trim() == etag))
}
fn extract_single_data(result: &serde_json::Value) -> Result<serde_json::Value, RestError> {
if let Some(data_obj) = result.get("data") {
if let serde_json::Value::Object(map) = data_obj {
Ok(map.values().next().cloned().unwrap_or(serde_json::Value::Null))
} else {
Ok(data_obj.clone())
}
} else {
Ok(result.clone())
}
}
fn extract_collection_data(result: &serde_json::Value) -> Result<serde_json::Value, RestError> {
extract_single_data(result)
}
fn extract_mutation_data(result: &serde_json::Value) -> Result<serde_json::Value, RestError> {
if let Some(data_obj) = result.get("data") {
if let serde_json::Value::Object(map) = data_obj {
if let Some(mutation_result) = map.values().next() {
if let Some(entity) = mutation_result.get("entity") {
if !entity.is_null() {
return Ok(entity.clone());
}
}
return Ok(mutation_result.clone());
}
}
Ok(data_obj.clone())
} else {
Ok(result.clone())
}
}
fn extract_delete_entity(
result: &serde_json::Value,
mutation_name: &str,
) -> Option<serde_json::Value> {
let entity = result.get("data")?.get(mutation_name)?.get("entity")?;
if entity.is_null() {
None
} else {
Some(entity.clone())
}
}
fn extract_relay_page_info(data: &serde_json::Value) -> Option<&serde_json::Value> {
data.get("pageInfo")
}
fn extract_id_from_data(data: &serde_json::Value) -> Option<&serde_json::Value> {
data.get("id")
}
fn format_id_for_url(id: &serde_json::Value) -> String {
match id {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}
fn build_offset_links(
base: &str,
limit: u64,
offset: u64,
total: Option<u64>,
) -> serde_json::Value {
let mut links = serde_json::Map::new();
links.insert("self".to_string(), json!(format!("{base}?limit={limit}&offset={offset}")));
links.insert("first".to_string(), json!(format!("{base}?limit={limit}&offset=0")));
let next_offset = offset + limit;
let has_next = total.is_none_or(|t| next_offset < t);
if has_next {
links.insert(
"next".to_string(),
json!(format!("{base}?limit={limit}&offset={next_offset}")),
);
} else {
links.insert("next".to_string(), serde_json::Value::Null);
}
if offset > 0 {
let prev_offset = offset.saturating_sub(limit);
links.insert(
"prev".to_string(),
json!(format!("{base}?limit={limit}&offset={prev_offset}")),
);
} else {
links.insert("prev".to_string(), serde_json::Value::Null);
}
if let Some(total) = total {
if total > 0 {
let last_offset = ((total - 1) / limit) * limit;
links.insert(
"last".to_string(),
json!(format!("{base}?limit={limit}&offset={last_offset}")),
);
} else {
links.insert("last".to_string(), json!(format!("{base}?limit={limit}&offset=0")));
}
}
serde_json::Value::Object(links)
}
fn build_cursor_links(
base: &str,
first: Option<u64>,
after: Option<&str>,
data: &serde_json::Value,
) -> serde_json::Value {
let mut links = serde_json::Map::new();
let mut self_url = base.to_string();
if let Some(f) = first {
self_url = format!("{self_url}?first={f}");
if let Some(a) = after {
self_url = format!("{self_url}&after={a}");
}
}
links.insert("self".to_string(), json!(self_url));
let has_next = data
.get("pageInfo")
.and_then(|pi| pi.get("hasNextPage"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if has_next {
if let Some(end_cursor) = extract_end_cursor(data) {
let mut next_url = base.to_string();
if let Some(f) = first {
next_url = format!("{next_url}?first={f}&after={end_cursor}");
} else {
next_url = format!("{next_url}?after={end_cursor}");
}
links.insert("next".to_string(), json!(next_url));
}
}
serde_json::Value::Object(links)
}
fn extract_end_cursor(data: &serde_json::Value) -> Option<&str> {
data.get("pageInfo")?.get("endCursor")?.as_str()
}
pub(crate) fn set_request_id(request_headers: &HeaderMap, response_headers: &mut HeaderMap) {
let request_id = request_headers
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map_or_else(|| uuid::Uuid::new_v4().to_string(), |s| s.to_string());
if let Ok(val) = HeaderValue::from_str(&request_id) {
response_headers.insert("x-request-id", val);
}
}
fn header_value(s: &str) -> HeaderValue {
HeaderValue::from_str(s).expect("ETag string must be valid ASCII")
}
impl RestError {
#[must_use]
pub fn method_not_allowed() -> Self {
Self {
status: StatusCode::METHOD_NOT_ALLOWED,
code: "METHOD_NOT_ALLOWED",
message: "Method not allowed".to_string(),
details: None,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] #[allow(clippy::missing_panics_doc)] mod tests {
use super::*;
fn v(s: &str) -> serde_json::Value {
serde_json::from_str(s).unwrap()
}
fn default_config() -> RestConfig {
RestConfig::default()
}
fn no_etag_config() -> RestConfig {
RestConfig {
etag: false,
..RestConfig::default()
}
}
fn entity_delete_config() -> RestConfig {
RestConfig {
delete_response: DeleteResponse::Entity,
..RestConfig::default()
}
}
fn empty_headers() -> HeaderMap {
HeaderMap::new()
}
fn headers_with_request_id(id: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("x-request-id", HeaderValue::from_str(id).unwrap());
h
}
fn headers_with_if_none_match(etag: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("if-none-match", HeaderValue::from_str(etag).unwrap());
h
}
#[test]
fn etag_format_is_weak_validator_hex() {
let etag = compute_etag(b"hello world");
assert!(etag.starts_with("W/\""));
assert!(etag.ends_with('"'));
let inner = &etag[3..etag.len() - 1];
assert_eq!(inner.len(), 16);
assert!(inner.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn etag_deterministic() {
let a = compute_etag(b"same content");
let b = compute_etag(b"same content");
assert_eq!(a, b);
}
#[test]
fn etag_changes_with_content() {
let a = compute_etag(b"content A");
let b = compute_etag(b"content B");
assert_ne!(a, b);
}
#[test]
fn if_none_match_absent() {
assert!(check_if_none_match(&empty_headers(), "W/\"abc\"").is_none());
}
#[test]
fn if_none_match_matches() {
let etag = "W/\"abc123\"";
let headers = headers_with_if_none_match(etag);
assert_eq!(check_if_none_match(&headers, etag), Some(true));
}
#[test]
fn if_none_match_stale() {
let headers = headers_with_if_none_match("W/\"old\"");
assert_eq!(check_if_none_match(&headers, "W/\"new\""), Some(false));
}
#[test]
fn if_none_match_wildcard() {
let headers = headers_with_if_none_match("*");
assert_eq!(check_if_none_match(&headers, "W/\"any\""), Some(true));
}
#[test]
fn if_none_match_comma_separated() {
let headers = headers_with_if_none_match("W/\"a\", W/\"b\", W/\"c\"");
assert_eq!(check_if_none_match(&headers, "W/\"b\""), Some(true));
assert_eq!(check_if_none_match(&headers, "W/\"d\""), Some(false));
}
#[test]
fn single_resource_200_with_data() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"user":{"id":1,"name":"Alice"}}}"#);
let resp = formatter.format_single(&result, &empty_headers()).unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body = resp.body.unwrap();
assert_eq!(body["data"]["id"], 1);
assert_eq!(body["data"]["name"], "Alice");
assert!(body.get("meta").is_none());
}
#[test]
fn single_resource_has_etag() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"user":{"id":1}}}"#);
let resp = formatter.format_single(&result, &empty_headers()).unwrap();
assert!(resp.headers.get("etag").is_some());
let etag = resp.headers.get("etag").unwrap().to_str().unwrap();
assert!(etag.starts_with("W/\""));
}
#[test]
fn single_resource_no_etag_when_disabled() {
let config = no_etag_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"user":{"id":1}}}"#);
let resp = formatter.format_single(&result, &empty_headers()).unwrap();
assert!(resp.headers.get("etag").is_none());
}
#[test]
fn single_resource_304_on_matching_etag() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"user":{"id":1}}}"#);
let resp1 = formatter.format_single(&result, &empty_headers()).unwrap();
let etag = resp1.headers.get("etag").unwrap().to_str().unwrap().to_string();
let headers = headers_with_if_none_match(&etag);
let resp2 = formatter.format_single(&result, &headers).unwrap();
assert_eq!(resp2.status, StatusCode::NOT_MODIFIED);
assert!(resp2.body.is_none());
assert!(resp2.headers.get("etag").is_some());
}
#[test]
fn single_resource_200_on_stale_etag() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"user":{"id":1}}}"#);
let headers = headers_with_if_none_match("W/\"stale\"");
let resp = formatter.format_single(&result, &headers).unwrap();
assert_eq!(resp.status, StatusCode::OK);
assert!(resp.body.is_some());
}
#[test]
fn single_resource_has_request_id() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"user":{"id":1}}}"#);
let headers = headers_with_request_id("abc-123");
let resp = formatter.format_single(&result, &headers).unwrap();
assert_eq!(resp.headers.get("x-request-id").unwrap().to_str().unwrap(), "abc-123");
}
#[test]
fn single_resource_generates_request_id_when_missing() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"user":{"id":1}}}"#);
let resp = formatter.format_single(&result, &empty_headers()).unwrap();
let id = resp.headers.get("x-request-id").unwrap().to_str().unwrap();
assert_eq!(id.len(), 36); }
#[test]
fn collection_offset_with_total() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"users":[{"id":1},{"id":2}]}}"#);
let pagination = PaginationParams::Offset {
limit: 10,
offset: 0,
};
let prefer = PreferHeader {
count_exact: true,
..Default::default()
};
let resp = formatter
.format_collection(&result, Some(42), &pagination, "/users", &empty_headers(), &prefer)
.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body = resp.body.unwrap();
assert!(body["data"].is_array());
assert_eq!(body["meta"]["total"], 42);
assert_eq!(body["meta"]["limit"], 10);
assert_eq!(body["meta"]["offset"], 0);
assert_eq!(
resp.headers.get("preference-applied").unwrap().to_str().unwrap(),
"count=exact"
);
}
#[test]
fn collection_offset_without_total_omits_meta_total() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"users":[{"id":1}]}}"#);
let pagination = PaginationParams::Offset {
limit: 10,
offset: 0,
};
let prefer = PreferHeader::default();
let resp = formatter
.format_collection(&result, None, &pagination, "/users", &empty_headers(), &prefer)
.unwrap();
let body = resp.body.unwrap();
assert!(body["meta"].get("total").is_none());
assert!(resp.headers.get("preference-applied").is_none());
}
#[test]
fn collection_offset_links_with_total() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"users":[{"id":1}]}}"#);
let pagination = PaginationParams::Offset {
limit: 10,
offset: 10,
};
let prefer = PreferHeader::default();
let resp = formatter
.format_collection(&result, Some(42), &pagination, "/users", &empty_headers(), &prefer)
.unwrap();
let body = resp.body.unwrap();
let links = &body["links"];
assert_eq!(links["self"], "/rest/v1/users?limit=10&offset=10");
assert_eq!(links["first"], "/rest/v1/users?limit=10&offset=0");
assert_eq!(links["next"], "/rest/v1/users?limit=10&offset=20");
assert_eq!(links["prev"], "/rest/v1/users?limit=10&offset=0");
assert_eq!(links["last"], "/rest/v1/users?limit=10&offset=40");
}
#[test]
fn collection_offset_links_first_page() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"users":[]}}"#);
let pagination = PaginationParams::Offset {
limit: 10,
offset: 0,
};
let prefer = PreferHeader::default();
let resp = formatter
.format_collection(&result, Some(42), &pagination, "/users", &empty_headers(), &prefer)
.unwrap();
let body = resp.body.unwrap();
let links = &body["links"];
assert!(links["prev"].is_null());
assert_eq!(links["next"], "/rest/v1/users?limit=10&offset=10");
}
#[test]
fn collection_offset_links_last_page() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"users":[]}}"#);
let pagination = PaginationParams::Offset {
limit: 10,
offset: 40,
};
let prefer = PreferHeader::default();
let resp = formatter
.format_collection(&result, Some(42), &pagination, "/users", &empty_headers(), &prefer)
.unwrap();
let body = resp.body.unwrap();
let links = &body["links"];
assert!(links["next"].is_null()); }
#[test]
fn collection_offset_links_last_omitted_without_total() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"users":[]}}"#);
let pagination = PaginationParams::Offset {
limit: 10,
offset: 0,
};
let prefer = PreferHeader::default();
let resp = formatter
.format_collection(&result, None, &pagination, "/users", &empty_headers(), &prefer)
.unwrap();
let body = resp.body.unwrap();
let links = &body["links"];
assert!(links.get("last").is_none());
}
#[test]
fn collection_cursor_has_next_page_meta() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(
r#"{"data":{"posts":{"edges":[{"cursor":"c1","node":{"id":1}}],"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"endCursor":"c1"}}}}"#,
);
let pagination = PaginationParams::Cursor {
first: Some(5),
after: None,
last: None,
before: None,
};
let prefer = PreferHeader::default();
let resp = formatter
.format_collection(&result, None, &pagination, "/posts", &empty_headers(), &prefer)
.unwrap();
let body = resp.body.unwrap();
assert_eq!(body["meta"]["hasNextPage"], true);
assert_eq!(body["meta"]["hasPreviousPage"], false);
assert_eq!(body["meta"]["first"], 5);
}
#[test]
fn collection_cursor_links_with_next() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(
r#"{"data":{"posts":{"edges":[{"cursor":"c1","node":{"id":1}}],"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"endCursor":"c1"}}}}"#,
);
let pagination = PaginationParams::Cursor {
first: Some(10),
after: None,
last: None,
before: None,
};
let prefer = PreferHeader::default();
let resp = formatter
.format_collection(&result, None, &pagination, "/posts", &empty_headers(), &prefer)
.unwrap();
let body = resp.body.unwrap();
let links = &body["links"];
assert_eq!(links["self"], "/rest/v1/posts?first=10");
assert_eq!(links["next"], "/rest/v1/posts?first=10&after=c1");
}
#[test]
fn collection_cursor_no_next_link_when_no_next_page() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(
r#"{"data":{"posts":{"edges":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}}}"#,
);
let pagination = PaginationParams::Cursor {
first: Some(10),
after: None,
last: None,
before: None,
};
let prefer = PreferHeader::default();
let resp = formatter
.format_collection(&result, None, &pagination, "/posts", &empty_headers(), &prefer)
.unwrap();
let body = resp.body.unwrap();
assert!(body["links"].get("next").is_none());
}
#[test]
fn collection_has_etag() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"users":[{"id":1}]}}"#);
let pagination = PaginationParams::Offset {
limit: 10,
offset: 0,
};
let prefer = PreferHeader::default();
let resp = formatter
.format_collection(&result, None, &pagination, "/users", &empty_headers(), &prefer)
.unwrap();
assert!(resp.headers.get("etag").is_some());
}
#[test]
fn collection_304_on_matching_etag() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"users":[{"id":1}]}}"#);
let pagination = PaginationParams::Offset {
limit: 10,
offset: 0,
};
let prefer = PreferHeader::default();
let resp1 = formatter
.format_collection(&result, None, &pagination, "/users", &empty_headers(), &prefer)
.unwrap();
let etag = resp1.headers.get("etag").unwrap().to_str().unwrap().to_string();
let headers = headers_with_if_none_match(&etag);
let resp2 = formatter
.format_collection(&result, None, &pagination, "/users", &headers, &prefer)
.unwrap();
assert_eq!(resp2.status, StatusCode::NOT_MODIFIED);
assert!(resp2.body.is_none());
}
#[test]
fn created_201_with_location() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"createUser":{"entity":{"id":3,"name":"Charlie"}}}}"#);
let resp = formatter.format_created(&result, "/users", None, &empty_headers()).unwrap();
assert_eq!(resp.status, StatusCode::CREATED);
let body = resp.body.unwrap();
assert_eq!(body["data"]["id"], 3);
assert_eq!(resp.headers.get("location").unwrap().to_str().unwrap(), "/rest/v1/users/3");
}
#[test]
fn created_201_with_explicit_id() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"createUser":{"entity":{"id":5}}}}"#);
let id = json!(5);
let resp = formatter
.format_created(&result, "/users", Some(&id), &empty_headers())
.unwrap();
assert_eq!(resp.headers.get("location").unwrap().to_str().unwrap(), "/rest/v1/users/5");
}
#[test]
fn created_201_with_uuid_id() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let uuid = "550e8400-e29b-41d4-a716-446655440000";
let result = v(&format!(
r#"{{"data":{{"createUser":{{"entity":{{"id":"{uuid}","name":"Alice"}}}}}}}}"#
));
let resp = formatter.format_created(&result, "/users", None, &empty_headers()).unwrap();
let location = resp.headers.get("location").unwrap().to_str().unwrap();
assert_eq!(location, format!("/rest/v1/users/{uuid}"));
}
#[test]
fn created_201_has_request_id() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"createUser":{"entity":{"id":1}}}}"#);
let headers = headers_with_request_id("req-42");
let resp = formatter.format_created(&result, "/users", None, &headers).unwrap();
assert_eq!(resp.headers.get("x-request-id").unwrap().to_str().unwrap(), "req-42");
}
#[test]
fn mutation_200_with_data() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"updateUser":{"entity":{"id":1,"name":"Updated"}}}}"#);
let resp = formatter.format_mutation(&result, &empty_headers()).unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body = resp.body.unwrap();
assert_eq!(body["data"]["id"], 1);
}
#[test]
fn deleted_204_no_content_default() {
let config = default_config(); let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"deleteUser":{"success":true,"entity":null}}}"#);
let prefer = PreferHeader::default();
let resp = formatter.format_deleted(&result, "deleteUser", &prefer, &empty_headers());
assert_eq!(resp.status, StatusCode::NO_CONTENT);
assert!(resp.body.is_none());
}
#[test]
fn deleted_200_entity_config() {
let config = entity_delete_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result =
v(r#"{"data":{"deleteUser":{"success":true,"entity":{"id":1,"name":"Alice"}}}}"#);
let prefer = PreferHeader::default();
let resp = formatter.format_deleted(&result, "deleteUser", &prefer, &empty_headers());
assert_eq!(resp.status, StatusCode::OK);
let body = resp.body.unwrap();
assert_eq!(body["data"]["id"], 1);
}
#[test]
fn deleted_prefer_return_representation_with_entity() {
let config = default_config(); let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result =
v(r#"{"data":{"deleteUser":{"success":true,"entity":{"id":1,"name":"Alice"}}}}"#);
let prefer = PreferHeader {
return_representation: true,
..Default::default()
};
let resp = formatter.format_deleted(&result, "deleteUser", &prefer, &empty_headers());
assert_eq!(resp.status, StatusCode::OK);
let body = resp.body.unwrap();
assert_eq!(body["data"]["id"], 1);
assert_eq!(
resp.headers.get("preference-applied").unwrap().to_str().unwrap(),
"return=representation"
);
}
#[test]
fn deleted_prefer_return_representation_entity_unavailable() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"deleteUser":{"success":true,"entity":null}}}"#);
let prefer = PreferHeader {
return_representation: true,
..Default::default()
};
let resp = formatter.format_deleted(&result, "deleteUser", &prefer, &empty_headers());
assert_eq!(resp.status, StatusCode::NO_CONTENT);
assert!(resp.body.is_none());
assert_eq!(
resp.headers.get("preference-applied").unwrap().to_str().unwrap(),
"return=minimal"
);
assert_eq!(
resp.headers.get("x-preference-fallback").unwrap().to_str().unwrap(),
"entity-unavailable"
);
}
#[test]
fn deleted_prefer_return_minimal_overrides_entity_config() {
let config = entity_delete_config(); let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result =
v(r#"{"data":{"deleteUser":{"success":true,"entity":{"id":1,"name":"Alice"}}}}"#);
let prefer = PreferHeader {
return_minimal: true,
..Default::default()
};
let resp = formatter.format_deleted(&result, "deleteUser", &prefer, &empty_headers());
assert_eq!(resp.status, StatusCode::NO_CONTENT);
assert!(resp.body.is_none());
assert_eq!(
resp.headers.get("preference-applied").unwrap().to_str().unwrap(),
"return=minimal"
);
}
#[test]
fn deleted_has_request_id() {
let config = default_config();
let formatter = RestResponseFormatter::new(&config, "/rest/v1");
let result = v(r#"{"data":{"deleteUser":{"success":true}}}"#);
let prefer = PreferHeader::default();
let headers = headers_with_request_id("del-99");
let resp = formatter.format_deleted(&result, "deleteUser", &prefer, &headers);
assert_eq!(resp.headers.get("x-request-id").unwrap().to_str().unwrap(), "del-99");
}
#[test]
fn error_response_has_structured_body() {
let err = RestError::not_found("User 42 not found");
let resp = RestResponseFormatter::format_error(&err, &empty_headers(), None);
assert_eq!(resp.status, StatusCode::NOT_FOUND);
let body = resp.body.unwrap();
assert_eq!(body["error"]["code"], "NOT_FOUND");
assert_eq!(body["error"]["message"], "User 42 not found");
}
#[test]
fn error_response_has_request_id() {
let err = RestError::bad_request("Invalid");
let headers = headers_with_request_id("err-1");
let resp = RestResponseFormatter::format_error(&err, &headers, None);
assert_eq!(resp.headers.get("x-request-id").unwrap().to_str().unwrap(), "err-1");
}
#[test]
fn error_405_has_allow_header() {
let err = RestError::method_not_allowed();
let allowed = [HttpMethod::Get, HttpMethod::Post, HttpMethod::Delete];
let resp = RestResponseFormatter::format_error(&err, &empty_headers(), Some(&allowed));
assert_eq!(resp.status, StatusCode::METHOD_NOT_ALLOWED);
let allow = resp.headers.get("allow").unwrap().to_str().unwrap();
assert!(allow.contains("GET"));
assert!(allow.contains("POST"));
assert!(allow.contains("DELETE"));
}
#[test]
fn error_405_no_allow_when_methods_not_provided() {
let err = RestError::method_not_allowed();
let resp = RestResponseFormatter::format_error(&err, &empty_headers(), None);
assert!(resp.headers.get("allow").is_none());
}
#[test]
fn error_non_405_no_allow_header() {
let err = RestError::not_found("Not found");
let allowed = [HttpMethod::Get];
let resp = RestResponseFormatter::format_error(&err, &empty_headers(), Some(&allowed));
assert!(resp.headers.get("allow").is_none());
}
#[test]
fn error_with_details() {
let err = RestError::unprocessable_entity(
"Validation failed",
json!({
"fields": [
{ "field": "email", "reason": "required for PUT" }
]
}),
);
let resp = RestResponseFormatter::format_error(&err, &empty_headers(), None);
let body = resp.body.unwrap();
assert_eq!(body["error"]["details"]["fields"][0]["field"], "email");
}
#[test]
fn offset_links_middle_page() {
let links = build_offset_links("/rest/v1/users", 10, 20, Some(100));
assert_eq!(links["self"], "/rest/v1/users?limit=10&offset=20");
assert_eq!(links["first"], "/rest/v1/users?limit=10&offset=0");
assert_eq!(links["next"], "/rest/v1/users?limit=10&offset=30");
assert_eq!(links["prev"], "/rest/v1/users?limit=10&offset=10");
assert_eq!(links["last"], "/rest/v1/users?limit=10&offset=90");
}
#[test]
fn offset_links_first_page_null_prev() {
let links = build_offset_links("/rest/v1/users", 10, 0, Some(50));
assert!(links["prev"].is_null());
assert_eq!(links["next"], "/rest/v1/users?limit=10&offset=10");
}
#[test]
fn offset_links_last_page_null_next() {
let links = build_offset_links("/rest/v1/users", 10, 40, Some(42));
assert!(links["next"].is_null());
}
#[test]
fn offset_links_no_total_omits_last() {
let links = build_offset_links("/rest/v1/users", 10, 0, None);
assert!(links.get("last").is_none());
assert_eq!(links["next"], "/rest/v1/users?limit=10&offset=10");
}
#[test]
fn offset_links_empty_collection() {
let links = build_offset_links("/rest/v1/users", 10, 0, Some(0));
assert!(links["next"].is_null()); assert_eq!(links["last"], "/rest/v1/users?limit=10&offset=0");
}
#[test]
fn cursor_links_with_end_cursor() {
let data = json!({
"edges": [{"cursor": "c1", "node": {"id": 1}}],
"pageInfo": {"hasNextPage": true, "hasPreviousPage": false, "endCursor": "c1"}
});
let links = build_cursor_links("/rest/v1/posts", Some(10), None, &data);
assert_eq!(links["self"], "/rest/v1/posts?first=10");
assert_eq!(links["next"], "/rest/v1/posts?first=10&after=c1");
}
#[test]
fn cursor_links_no_next_when_last_page() {
let data = json!({
"edges": [],
"pageInfo": {"hasNextPage": false, "hasPreviousPage": true}
});
let links = build_cursor_links("/rest/v1/posts", Some(10), Some("prev_cursor"), &data);
assert!(links.get("next").is_none());
assert_eq!(links["self"], "/rest/v1/posts?first=10&after=prev_cursor");
}
#[test]
fn extract_single_data_from_envelope() {
let result = v(r#"{"data":{"user":{"id":1,"name":"Alice"}}}"#);
let data = extract_single_data(&result).unwrap();
assert_eq!(data["id"], 1);
assert_eq!(data["name"], "Alice");
}
#[test]
fn extract_single_data_unwraps_first_field() {
let result = v(r#"{"data":{"someQuery":{"value":42}}}"#);
let data = extract_single_data(&result).unwrap();
assert_eq!(data["value"], 42);
}
#[test]
fn extract_mutation_data_extracts_entity() {
let result = v(r#"{"data":{"createUser":{"entity":{"id":3,"name":"Charlie"}}}}"#);
let data = extract_mutation_data(&result).unwrap();
assert_eq!(data["id"], 3);
}
#[test]
fn extract_mutation_data_null_entity_returns_full_response() {
let result = v(r#"{"data":{"deleteUser":{"success":true,"entity":null}}}"#);
let data = extract_mutation_data(&result).unwrap();
assert_eq!(data["success"], true);
}
#[test]
fn extract_delete_entity_present() {
let result =
v(r#"{"data":{"deleteUser":{"success":true,"entity":{"id":1,"name":"Alice"}}}}"#);
let entity = extract_delete_entity(&result, "deleteUser").unwrap();
assert_eq!(entity["id"], 1);
}
#[test]
fn extract_delete_entity_null() {
let result = v(r#"{"data":{"deleteUser":{"success":true,"entity":null}}}"#);
assert!(extract_delete_entity(&result, "deleteUser").is_none());
}
#[test]
fn extract_delete_entity_missing() {
let result = v(r#"{"data":{"deleteUser":{"success":true}}}"#);
assert!(extract_delete_entity(&result, "deleteUser").is_none());
}
#[test]
fn format_id_integer() {
assert_eq!(format_id_for_url(&json!(42)), "42");
}
#[test]
fn format_id_string() {
assert_eq!(
format_id_for_url(&json!("550e8400-e29b-41d4-a716-446655440000")),
"550e8400-e29b-41d4-a716-446655440000"
);
}
}