use hyper::StatusCode;
use serde::Serialize;
use std::collections::BTreeMap;
use crate::error::{Error, Result};
use crate::http::{Request, Response};
use super::types::ListPage;
pub(crate) fn wants_json(req: &Request) -> bool {
if let Some(accept) = req.header("accept") {
for raw in accept.split(',') {
let mt = raw.trim();
let bare = mt.split(';').next().unwrap_or(mt).trim();
if bare.eq_ignore_ascii_case("application/json")
|| bare.eq_ignore_ascii_case("application/*")
{
return true;
}
if bare.eq_ignore_ascii_case("text/html") {
return false;
}
}
}
req.query()
.get("format")
.map(|s| s == "json")
.unwrap_or(false)
}
#[derive(Serialize)]
pub(crate) struct ListEnvelope {
pub rows: Vec<BTreeMap<String, serde_json::Value>>,
pub total: i64,
pub page: usize,
pub per_page: usize,
pub pages: usize,
}
pub(crate) fn list_envelope(
entry: &super::types::AdminEntry,
page_result: ListPage,
page: usize,
per_page: usize,
) -> ListEnvelope {
let total = page_result.total;
let pages = (total.max(1) as usize).div_ceil(per_page.max(1));
let rows = page_result
.rows
.into_iter()
.map(|row| {
let mut obj: BTreeMap<String, serde_json::Value> = BTreeMap::new();
obj.insert("id".into(), serde_json::Value::Number(row.id.into()));
for (i, f) in entry.fields.iter().enumerate() {
if f.name == "id" {
continue;
}
let cell = row.cells.get(i).cloned().unwrap_or_default();
obj.insert(f.name.to_string(), typed_cell(f, &cell));
}
obj
})
.collect();
ListEnvelope {
rows,
total,
page,
per_page,
pages,
}
}
pub(crate) fn detail_envelope(
entry: &super::types::AdminEntry,
row: super::types::EditRow,
) -> BTreeMap<String, serde_json::Value> {
let mut obj: BTreeMap<String, serde_json::Value> = BTreeMap::new();
obj.insert("id".into(), serde_json::Value::Number(row.id.into()));
for f in entry.fields {
if f.name == "id" {
continue;
}
let cell = row
.values
.iter()
.find(|(name, _)| name == f.name)
.map(|(_, v)| v.clone())
.unwrap_or_default();
obj.insert(f.name.to_string(), typed_cell(f, &cell));
}
obj
}
fn typed_cell(field: &super::types::AdminField, cell: &str) -> serde_json::Value {
use super::types::FieldType;
match field.field_type {
FieldType::Bool => serde_json::Value::Bool(cell == "true"),
FieldType::I32 | FieldType::I64 => match cell.parse::<i64>() {
Ok(n) => serde_json::Value::Number(n.into()),
Err(_) => serde_json::Value::String(cell.to_string()),
},
FieldType::OptionalI64 => {
if cell.is_empty() {
serde_json::Value::Null
} else {
match cell.parse::<i64>() {
Ok(n) => serde_json::Value::Number(n.into()),
Err(_) => serde_json::Value::String(cell.to_string()),
}
}
}
FieldType::OptionalString | FieldType::OptionalDateTime | FieldType::OptionalFilePath => {
if cell.is_empty() {
serde_json::Value::Null
} else {
serde_json::Value::String(cell.to_string())
}
}
FieldType::String | FieldType::DateTime | FieldType::FilePath => {
serde_json::Value::String(cell.to_string())
}
}
}
pub(crate) fn mutation_ok_envelope(admin_name: &str, id: i64, status: StatusCode) -> Response {
let body = serde_json::json!({
"ok": true,
"admin_name": admin_name,
"id": id,
});
let s = serde_json::to_string(&body)
.unwrap_or_else(|_| format!(r#"{{"ok":true,"admin_name":"{admin_name}","id":{id}}}"#));
Response::new(status, s).with_header("content-type", "application/json")
}
pub(crate) fn validation_errors_envelope(errors: Vec<String>) -> Response {
let body = serde_json::json!({
"errors": errors,
"status": 400,
});
let s = serde_json::to_string(&body).unwrap_or_else(|_| {
r#"{"errors":["validation envelope serialisation failed"],"status":400}"#.to_string()
});
Response::new(StatusCode::BAD_REQUEST, s).with_header("content-type", "application/json")
}
pub(crate) fn json_response<T: Serialize>(body: T) -> Result<Response> {
let s = serde_json::to_string(&body)
.map_err(|e| crate::error::Error::Internal(format!("json serialise: {e}")))?;
Ok(Response::json_raw(s))
}
pub(crate) fn json_error(err: Error) -> Response {
let status = StatusCode::from_u16(err.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = serde_json::json!({
"error": err.client_message(),
"status": err.status(),
});
let s = serde_json::to_string(&body).unwrap_or_else(|_| {
format!(
r#"{{"error":"json error envelope serialisation failed","status":{}}}"#,
err.status()
)
});
Response::new(status, s).with_header("content-type", "application/json")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::types::{AdminField, FieldType};
fn field(name: &'static str, ty: FieldType) -> AdminField {
AdminField {
name,
label: name,
field_type: ty,
editable: true,
relation: None,
choices: None,
}
}
#[test]
fn typed_cell_bool_round_trips() {
let f = field("active", FieldType::Bool);
assert_eq!(typed_cell(&f, "true"), serde_json::Value::Bool(true));
assert_eq!(typed_cell(&f, "false"), serde_json::Value::Bool(false));
assert_eq!(typed_cell(&f, ""), serde_json::Value::Bool(false));
}
#[test]
fn typed_cell_integers_parse() {
let f = field("count", FieldType::I64);
assert_eq!(typed_cell(&f, "42"), serde_json::json!(42));
let opt = field("count", FieldType::OptionalI64);
assert_eq!(typed_cell(&opt, ""), serde_json::Value::Null);
assert_eq!(typed_cell(&opt, "7"), serde_json::json!(7));
}
#[test]
fn typed_cell_garbage_in_int_column_surfaces_as_string() {
let f = field("count", FieldType::I64);
assert_eq!(typed_cell(&f, "abc"), serde_json::json!("abc"));
}
#[test]
fn typed_cell_optional_strings_null_on_empty() {
let f = field("subtitle", FieldType::OptionalString);
assert_eq!(typed_cell(&f, ""), serde_json::Value::Null);
assert_eq!(typed_cell(&f, "set"), serde_json::json!("set"));
}
#[test]
fn typed_cell_required_strings_stay_string() {
let f = field("title", FieldType::String);
assert_eq!(typed_cell(&f, ""), serde_json::json!(""));
assert_eq!(typed_cell(&f, "hi"), serde_json::json!("hi"));
}
#[test]
fn json_error_carries_status_code_and_content_type() {
let resp = json_error(Error::NotFound("clinics/9999".into()));
assert_eq!(resp.status.as_u16(), 404);
let ctype = resp
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.as_str())
.unwrap_or("");
assert_eq!(ctype, "application/json");
}
#[test]
fn json_error_envelope_shape_is_error_plus_status() {
let resp = json_error(Error::NotFound("clinics/9999".into()));
let body = std::str::from_utf8(&resp.body).expect("utf-8");
let v: serde_json::Value = serde_json::from_str(body).expect("valid json");
assert_eq!(v["status"], 404);
assert_eq!(v["error"], "clinics/9999");
}
#[test]
fn json_error_redacts_internal_message() {
let resp = json_error(Error::Internal("password in this string!".into()));
assert_eq!(resp.status.as_u16(), 500);
let body = std::str::from_utf8(&resp.body).expect("utf-8");
assert!(
!body.contains("password"),
"internal error detail leaked into client envelope: {body}",
);
let v: serde_json::Value = serde_json::from_str(body).expect("valid json");
assert_eq!(v["error"], "Internal Server Error");
assert_eq!(v["status"], 500);
}
#[test]
fn json_error_maps_each_status_variant() {
for (err, expected) in [
(Error::BadRequest("x".into()), 400),
(Error::Unauthorized("x".into()), 401),
(Error::Forbidden("x".into()), 403),
(Error::NotFound("x".into()), 404),
(Error::MethodNotAllowed("x".into()), 405),
(Error::Conflict("x".into()), 409),
(Error::Internal("x".into()), 500),
] {
let resp = json_error(err);
assert_eq!(resp.status.as_u16(), expected);
let v: serde_json::Value = serde_json::from_slice(&resp.body).expect("valid json");
assert_eq!(v["status"].as_u64(), Some(expected as u64));
}
}
#[test]
fn typed_cell_filepath_returns_string_or_null() {
let req = field("photo_path", FieldType::FilePath);
assert_eq!(typed_cell(&req, "abc.png"), serde_json::json!("abc.png"));
let opt = field("photo_path", FieldType::OptionalFilePath);
assert_eq!(typed_cell(&opt, ""), serde_json::Value::Null);
assert_eq!(typed_cell(&opt, "x.png"), serde_json::json!("x.png"));
}
#[test]
fn mutation_ok_envelope_carries_admin_name_id_and_status() {
let resp = mutation_ok_envelope("posts", 42, StatusCode::CREATED);
assert_eq!(resp.status, StatusCode::CREATED);
assert!(resp
.headers
.iter()
.any(|(k, v)| k == "content-type" && v == "application/json"));
let v: serde_json::Value = serde_json::from_slice(&resp.body).expect("valid json");
assert_eq!(v["ok"], serde_json::Value::Bool(true));
assert_eq!(v["admin_name"], serde_json::json!("posts"));
assert_eq!(v["id"], serde_json::json!(42));
}
#[test]
fn mutation_ok_envelope_supports_ok_status_for_update_delete() {
let resp = mutation_ok_envelope("widgets", 7, StatusCode::OK);
assert_eq!(resp.status, StatusCode::OK);
}
#[test]
fn validation_errors_envelope_shape_and_status() {
let resp = validation_errors_envelope(vec![
"Title: must not be empty".to_string(),
"Status: choose one of draft/published".to_string(),
]);
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&resp.body).expect("valid json");
assert_eq!(v["status"], serde_json::json!(400));
let errors = v["errors"].as_array().expect("errors is array");
assert_eq!(errors.len(), 2);
assert_eq!(errors[0], serde_json::json!("Title: must not be empty"));
}
}