use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const MAX_TEMPLATE_BYTES: usize = 4096;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ChannelErrorBody {
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
}
pub type ErrorBodies = BTreeMap<String, ChannelErrorBody>;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Segment {
Literal(String),
Status,
Code,
Message,
RequestId,
Channel,
Timestamp,
}
pub struct RenderContext<'a> {
pub status: u16,
pub code: &'a str,
pub message: &'a str,
pub channel: &'a str,
}
fn placeholder_at(rest: &str) -> Option<(&str, usize)> {
let body = rest.strip_prefix('{')?;
let end = body.find('}')?;
let name = &body[..end];
let identifier = !name.is_empty() && name.bytes().all(|b| b.is_ascii_lowercase() || b == b'_');
identifier.then_some((name, end + 2))
}
fn parse(template: &str) -> Result<Vec<Segment>, String> {
let mut segments = Vec::new();
let mut literal = String::new();
let mut rest = template;
while !rest.is_empty() {
if let Some(tail) = rest.strip_prefix("{{").or_else(|| rest.strip_prefix("}}")) {
literal.push(rest.as_bytes()[0] as char);
rest = tail;
continue;
}
if let Some((name, consumed)) = placeholder_at(rest) {
if !literal.is_empty() {
segments.push(Segment::Literal(std::mem::take(&mut literal)));
}
segments.push(match name {
"status" => Segment::Status,
"code" => Segment::Code,
"message" => Segment::Message,
"request_id" => Segment::RequestId,
"channel" => Segment::Channel,
"timestamp" => Segment::Timestamp,
other => {
return Err(format!(
"unknown placeholder '{{{other}}}' — expected one of \
status, code, message, request_id, channel, timestamp"
));
}
});
rest = &rest[consumed..];
continue;
}
let ch = rest.chars().next().expect("non-empty");
literal.push(ch);
rest = &rest[ch.len_utf8()..];
}
if !literal.is_empty() {
segments.push(Segment::Literal(literal));
}
Ok(segments)
}
pub fn render(template: &str, ctx: &RenderContext<'_>) -> Option<String> {
let segments = parse(template).ok()?;
let mut out = String::with_capacity(template.len() + 64);
for segment in segments {
match segment {
Segment::Literal(s) => out.push_str(&s),
Segment::Status => out.push_str(&ctx.status.to_string()),
Segment::Code => out.push_str(ctx.code),
Segment::Message => out.push_str(ctx.message),
Segment::RequestId => {
out.push_str(&crate::server::request_context::request_id().unwrap_or_default())
}
Segment::Channel => out.push_str(ctx.channel),
Segment::Timestamp => out
.push_str(&chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
}
}
Some(out)
}
pub fn lookup(bodies: &ErrorBodies, status: u16) -> Option<&ChannelErrorBody> {
bodies
.get(&status.to_string())
.or_else(|| bodies.get("default"))
}
pub fn validate(key: &str, entry: &ChannelErrorBody) -> Result<(), String> {
if key != "default" {
let status: u16 = key
.parse()
.map_err(|_| format!("key '{key}' must be an HTTP status code or \"default\""))?;
if !(400..=599).contains(&status) {
return Err(format!(
"key '{key}' must be a 4xx or 5xx status — the platform decides the \
status, so only error responses can be shaped"
));
}
}
if entry.body.len() > MAX_TEMPLATE_BYTES {
return Err(format!(
"body is {} bytes, over the {MAX_TEMPLATE_BYTES}-byte cap",
entry.body.len()
));
}
parse(&entry.body).map_err(|e| format!("body: {e}"))?;
let content_type = entry.content_type.as_deref().unwrap_or("application/json");
if axum::http::HeaderValue::from_str(content_type).is_err() {
return Err(format!(
"content_type '{content_type}' is not a valid header value"
));
}
if content_type.contains("json") {
let probe = RenderContext {
status: 400,
code: "PROBE",
message: "probe",
channel: "probe",
};
let rendered = render(&entry.body, &probe).ok_or("body does not compile")?;
if serde_json::from_str::<serde_json::Value>(&rendered).is_err() {
return Err(format!(
"body does not render as valid JSON, but content_type is '{content_type}'"
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> RenderContext<'static> {
RenderContext {
status: 401,
code: "UNAUTHORIZED",
message: "Unauthorized",
channel: "login",
}
}
#[test]
fn a_template_renders_every_placeholder() {
let out = render(
r#"{"status":{status},"error":"{code}","message":"{message}","ch":"{channel}"}"#,
&ctx(),
)
.expect("renders");
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(parsed["status"], 401);
assert_eq!(parsed["error"], "UNAUTHORIZED");
assert_eq!(parsed["message"], "Unauthorized");
assert_eq!(parsed["ch"], "login");
}
#[test]
fn the_placeholder_set_is_closed() {
let err = parse("{mesage}").expect_err("unknown placeholder");
assert!(err.contains("unknown placeholder"), "{err}");
assert!(
err.contains("message"),
"the message names the valid set: {err}"
);
assert!(parse("{details}").is_err());
}
#[test]
fn json_braces_are_literal_and_placeholders_are_narrow() {
assert_eq!(
render(r#"{"a":{"b":1},"c":[{}]}"#, &ctx()).as_deref(),
Some(r#"{"a":{"b":1},"c":[{}]}"#)
);
assert_eq!(render("{ {status}", &ctx()).as_deref(), Some("{ 401"));
assert_eq!(render("{123}", &ctx()).as_deref(), Some("{123}"));
assert_eq!(render("{status", &ctx()).as_deref(), Some("{status"));
assert_eq!(
render("{{literal}} {status}", &ctx()).as_deref(),
Some("{literal} 401")
);
}
#[test]
fn lookup_prefers_the_exact_status_then_default() {
let mut bodies = ErrorBodies::new();
bodies.insert(
"default".to_string(),
ChannelErrorBody {
body: "d".into(),
content_type: None,
},
);
bodies.insert(
"401".to_string(),
ChannelErrorBody {
body: "specific".into(),
content_type: None,
},
);
assert_eq!(lookup(&bodies, 401).expect("found").body, "specific");
assert_eq!(lookup(&bodies, 429).expect("found").body, "d");
assert!(lookup(&ErrorBodies::new(), 401).is_none());
}
#[test]
fn validation_refuses_what_cannot_work() {
let ok = ChannelErrorBody {
body: r#"{"m":"{message}"}"#.into(),
content_type: None,
};
assert!(validate("401", &ok).is_ok());
assert!(validate("default", &ok).is_ok());
assert!(validate("nope", &ok).is_err());
assert!(
validate("200", &ok).is_err(),
"the platform owns the status"
);
assert!(validate("399", &ok).is_err());
assert!(validate("600", &ok).is_err());
let bad_json = ChannelErrorBody {
body: "not json at all".into(),
content_type: None,
};
let err = validate("401", &bad_json).expect_err("must refuse");
assert!(err.contains("valid JSON"), "{err}");
let as_text = ChannelErrorBody {
body: "not json at all".into(),
content_type: Some("text/plain".into()),
};
assert!(validate("401", &as_text).is_ok());
let huge = ChannelErrorBody {
body: "x".repeat(MAX_TEMPLATE_BYTES + 1),
content_type: Some("text/plain".into()),
};
assert!(validate("401", &huge).is_err());
}
#[test]
fn one_status_yields_one_body_whatever_the_cause() {
let mut bodies = ErrorBodies::new();
bodies.insert(
"401".to_string(),
ChannelErrorBody {
body: r#"{"e":"{code}"}"#.into(),
content_type: None,
},
);
let entry = lookup(&bodies, 401).expect("the 401 entry");
let a = render(&entry.body, &ctx()).expect("renders");
let b = render(&entry.body, &ctx()).expect("renders");
assert_eq!(a, b);
}
}