use std::io::{self, Write};
use serde::Serialize;
use saddle_core::{request_context::UnifiedContext, json_pointer::JsonPointer};
use crate::{SummaryProfile, SummaryFormat, MissingField};
pub trait ContextRecordPayload: Serialize {
fn contains(&self, path: &str) -> bool;
fn write_selected<W: Write>(&self, path: &str, writer: &mut W) -> serde_json::Result<()>;
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextRecord<'a, P: ContextRecordPayload> {
pub record_schema: &'static str,
pub timestamp: u128,
pub level: &'static str,
pub event: &'static str,
pub context: UnifiedContext<'a>,
pub payload: P,
}
impl<P: ContextRecordPayload> ContextRecord<'_, P> {
fn split<'a>(&self, path: &'a str) -> Option<(saddle_core::json_pointer::PointerSegment<'a>, &'a str)> {
let key = JsonPointer::parse(path).ok()?.segments().next()?;
let rest = &path[1..]; Some((key, rest.find('/').map_or("", |at| &rest[at..])))
}
pub fn contains(&self, path: &str) -> bool {
if path.is_empty() { return true; }
let Some((key, rest)) = self.split(path) else { return false; };
if key.matches("context") { return self.context.with_selected(rest, |value| value.is_some()).unwrap_or(false); }
if key.matches("payload") { return self.payload.contains(rest); }
rest.is_empty() && ["recordSchema", "timestamp", "level", "event"].iter().any(|name| key.matches(name))
}
pub fn write_selected<W: Write>(&self, path: &str, writer: &mut W) -> serde_json::Result<()> {
if path.is_empty() { return serde_json::to_writer(writer, self); }
let Some((key, rest)) = self.split(path) else { return serde_json::to_writer(writer, &()); };
if key.matches("context") {
return self.context.with_selected(rest, |value| serde_json::to_writer(writer, &value))
.map_err(|_| serde_json::Error::io(io::ErrorKind::InvalidInput.into()))?;
}
if key.matches("payload") { return self.payload.write_selected(rest, writer); }
if !rest.is_empty() { return serde_json::to_writer(writer, &()); }
if key.matches("recordSchema") { return serde_json::to_writer(writer, &self.record_schema); }
if key.matches("timestamp") { return serde_json::to_writer(writer, &self.timestamp); }
if key.matches("level") { return serde_json::to_writer(writer, &self.level); }
if key.matches("event") { return serde_json::to_writer(writer, &self.event); }
serde_json::to_writer(writer, &())
}
pub fn write_summary<W: Write>(&self, profile: &SummaryProfile, writer: &mut W) -> serde_json::Result<()> {
match profile.format {
SummaryFormat::Json => {
writer.write_all(b"{").map_err(serde_json::Error::io)?;
let mut first = true;
for (alias, path) in &profile.fields {
if profile.missing == MissingField::Omit && !self.contains(path) { continue; }
if !first { writer.write_all(b",").map_err(serde_json::Error::io)?; }
first = false;
serde_json::to_writer(&mut *writer, alias)?;
writer.write_all(b":").map_err(serde_json::Error::io)?;
self.write_selected(path, writer)?;
}
writer.write_all(b"}").map_err(serde_json::Error::io)
}
SummaryFormat::Text => {
let mut remainder = profile.template.as_deref().unwrap_or("");
while let Some(start) = remainder.find("${") {
writer.write_all(remainder[..start].as_bytes()).map_err(serde_json::Error::io)?;
remainder = &remainder[start + 2..];
let end = remainder.find('}').expect("startup validated template");
let path = &profile.fields[&remainder[..end]];
if profile.missing != MissingField::Omit || self.contains(path) { self.write_selected(path, writer)?; }
remainder = &remainder[end + 1..];
}
writer.write_all(remainder.as_bytes()).map_err(serde_json::Error::io)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[derive(Serialize)]
struct Payload { value: &'static str }
impl ContextRecordPayload for Payload {
fn contains(&self, path: &str) -> bool { matches!(path, "" | "/value") }
fn write_selected<W: Write>(&self, path: &str, writer: &mut W) -> serde_json::Result<()> {
match path { "" => serde_json::to_writer(writer, self), "/value" => serde_json::to_writer(writer, &self.value), _ => serde_json::to_writer(writer, &()) }
}
}
#[test]
fn text_values_cannot_inject_lines_and_json_missing_is_distinct_from_null() {
let root = saddle_core::RequestRootPublisher::create(saddle_core::ContextLabel::checked("app").unwrap(), saddle_core::ContextFact::NotEstablished).unwrap();
let view = root.reference().view(saddle_core::RequestLocalFacts::new(saddle_core::RequestViewPhase::Reading));
let record = ContextRecord { record_schema: "1.0", timestamp: 1, level: "info", event: "stage",
context: view.unified_context(), payload: Payload { value: "quote\"\n\r\u{0000}\\tail" } };
let mut profile = SummaryProfile { name: "safe".into(), enabled: true, format: SummaryFormat::Text,
fields: BTreeMap::from([("v".into(), "/payload/value".into()), ("m".into(), "/context/business/missing".into())]),
missing: MissingField::Null, template: Some("value=${v} missing=${m}".into()) };
let mut output = Vec::new(); record.write_summary(&profile, &mut output).unwrap();
assert_eq!(std::str::from_utf8(&output).unwrap(), "value=\"quote\\\"\\n\\r\\u0000\\\\tail\" missing=null");
assert!(!output.contains(&b'\n')); assert!(!output.contains(&b'\r')); assert!(!output.contains(&0));
profile.format = SummaryFormat::Json; profile.template = None; profile.missing = MissingField::Omit;
output.clear(); record.write_summary(&profile, &mut output).unwrap();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["v"], record.payload.value); assert!(json.get("m").is_none());
profile.missing = MissingField::Null; output.clear(); record.write_summary(&profile, &mut output).unwrap();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap(); assert_eq!(json.get("m"), Some(&serde_json::Value::Null));
}
}
#[doc(hidden)]
pub struct OwnedUnifiedContext {
pub(crate) bytes: saddle_admission::ExactStored<Vec<u8>>,
}
impl Serialize for OwnedUnifiedContext {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let raw: &serde_json::value::RawValue = serde_json::from_slice(self.bytes.get())
.map_err(serde::ser::Error::custom)?;
raw.serialize(serializer)
}
}