proxmox/proxmox-notify/src/renderer/mod.rs
Lukas Wagner 0517d7b94e notify: renderer: adapt to changes in proxmox-time
A recent commit [1] changed the `Display` implementation of `TimeSpan` such
that minutes are now displayed as `20m` instead  of `20min`.
This commit adapts the tests for the notification template renderer
accordingly.

[1] 19129960 ("time: display minute/month such that it can be parsed again")

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
2024-11-10 18:55:11 +01:00

341 lines
10 KiB
Rust

//! Module for rendering notification templates.
use std::time::Duration;
use handlebars::{
Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext,
RenderError as HandlebarsRenderError,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use proxmox_human_byte::HumanByte;
use proxmox_time::TimeSpan;
use crate::{context, Error};
mod html;
mod plaintext;
mod table;
/// Convert a serde_json::Value to a String.
///
/// The main difference between this and simply calling Value::to_string is that
/// this will print strings without double quotes
fn value_to_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
v => v.to_string(),
}
}
/// Render a `serde_json::Value` as a byte size with proper units (IEC, base 2).
/// Accepts `serde_json::Value::{Number,String}`.
///
/// Will return `None` if `val` does not contain a number/parseable string.
fn value_to_byte_size(val: &Value) -> Option<String> {
let size = match val {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.parse().ok(),
_ => None,
}?;
Some(format!("{}", HumanByte::new_binary(size)))
}
/// Render a serde_json::Value as a duration.
/// The value is expected to contain the duration in seconds.
/// Accepts `serde_json::Value::{Number,String}`.
///
/// Will return `None` if `val` does not contain a number/parseable string.
fn value_to_duration(val: &Value) -> Option<String> {
let duration = match val {
Value::Number(n) => n.as_u64(),
Value::String(s) => s.parse().ok(),
_ => None,
}?;
let time_span = TimeSpan::from(Duration::from_secs(duration));
Some(format!("{time_span}"))
}
/// Render as serde_json::Value as a timestamp.
/// The value is expected to contain the timestamp as a unix epoch.
/// Accepts `serde_json::Value::{Number,String}`.
///
/// Will return `None` if `val` does not contain a number/parseable string.
fn value_to_timestamp(val: &Value) -> Option<String> {
let timestamp = match val {
Value::Number(n) => n.as_i64(),
Value::String(s) => s.parse().ok(),
_ => None,
}?;
proxmox_time::strftime_local("%F %H:%M:%S", timestamp).ok()
}
fn handlebars_relative_percentage_helper(
h: &Helper,
_: &Handlebars,
_: &Context,
_rc: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {
let param0 = h
.param(0)
.and_then(|v| v.value().as_f64())
.ok_or_else(|| HandlebarsRenderError::new("relative-percentage: param0 not found"))?;
let param1 = h
.param(1)
.and_then(|v| v.value().as_f64())
.ok_or_else(|| HandlebarsRenderError::new("relative-percentage: param1 not found"))?;
if param1 == 0.0 {
out.write("-")?;
} else {
out.write(&format!("{:.2}%", (param0 * 100.0) / param1))?;
}
Ok(())
}
/// Available render functions for `serde_json::Values``
///
/// May be used as a handlebars helper, e.g.
/// ```text
/// {{human-bytes 1024}}
/// ```
///
/// Value renderer can also be used for rendering values in table columns:
/// ```text
/// let properties = json!({
/// "table": {
/// "schema": {
/// "columns": [
/// {
/// "label": "Size",
/// "id": "size",
/// "renderer": "human-bytes"
/// }
/// ],
/// },
/// "data" : [
/// {
/// "size": 1024 * 1024,
/// },
/// ]
/// }
/// });
/// ```
///
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ValueRenderFunction {
HumanBytes,
Duration,
Timestamp,
}
impl ValueRenderFunction {
fn render(&self, value: &Value) -> String {
match self {
ValueRenderFunction::HumanBytes => value_to_byte_size(value),
ValueRenderFunction::Duration => value_to_duration(value),
ValueRenderFunction::Timestamp => value_to_timestamp(value),
}
.unwrap_or_else(|| {
log::error!("could not render value {value} with renderer {self:?}");
String::from("ERROR")
})
}
fn register_helpers(handlebars: &mut Handlebars) {
ValueRenderFunction::HumanBytes.register_handlebars_helper(handlebars);
ValueRenderFunction::Duration.register_handlebars_helper(handlebars);
ValueRenderFunction::Timestamp.register_handlebars_helper(handlebars);
}
fn register_handlebars_helper(&'static self, handlebars: &mut Handlebars) {
// Use serde to get own kebab-case representation that is later used
// to register the helper, e.g. HumanBytes -> human-bytes
let tag = serde_json::to_string(self)
.expect("serde failed to serialize ValueRenderFunction enum");
// But as it's a string value, the generated string is quoted,
// so remove leading/trailing double quotes
let tag = tag
.strip_prefix('\"')
.and_then(|t| t.strip_suffix('\"'))
.expect("serde serialized string representation was not contained in double quotes");
handlebars.register_helper(
tag,
Box::new(
|h: &Helper,
_r: &Handlebars,
_: &Context,
_rc: &mut RenderContext,
out: &mut dyn Output|
-> HelperResult {
let param = h
.param(0)
.ok_or(HandlebarsRenderError::new("parameter not found"))?;
let value = param.value();
out.write(&self.render(value))?;
Ok(())
},
),
);
}
}
/// Available template types
#[derive(Copy, Clone)]
pub enum TemplateType {
/// HTML body template
HtmlBody,
/// Plaintext body template
PlaintextBody,
/// Subject template
Subject,
}
impl TemplateType {
fn file_suffix(&self) -> &'static str {
match self {
TemplateType::HtmlBody => "body.html.hbs",
TemplateType::PlaintextBody => "body.txt.hbs",
TemplateType::Subject => "subject.txt.hbs",
}
}
fn postprocess(&self, mut rendered: String) -> String {
if let Self::Subject = self {
rendered = rendered.replace('\n', " ");
}
rendered
}
fn block_render_fns(&self) -> BlockRenderFunctions {
match self {
TemplateType::HtmlBody => html::block_render_functions(),
TemplateType::Subject => plaintext::block_render_functions(),
TemplateType::PlaintextBody => plaintext::block_render_functions(),
}
}
fn escape_fn(&self) -> fn(&str) -> String {
match self {
TemplateType::PlaintextBody => handlebars::no_escape,
TemplateType::Subject => handlebars::no_escape,
TemplateType::HtmlBody => handlebars::html_escape,
}
}
}
type HelperFn = dyn HelperDef + Send + Sync;
struct BlockRenderFunctions {
table: Box<HelperFn>,
object: Box<HelperFn>,
}
impl BlockRenderFunctions {
fn register_helpers(self, handlebars: &mut Handlebars) {
handlebars.register_helper("table", self.table);
handlebars.register_helper("object", self.object);
}
}
fn render_template_impl(
template: &str,
data: &Value,
renderer: TemplateType,
) -> Result<String, Error> {
let mut handlebars = Handlebars::new();
handlebars.register_escape_fn(renderer.escape_fn());
let block_render_fns = renderer.block_render_fns();
block_render_fns.register_helpers(&mut handlebars);
ValueRenderFunction::register_helpers(&mut handlebars);
handlebars.register_helper(
"relative-percentage",
Box::new(handlebars_relative_percentage_helper),
);
let rendered_template = handlebars
.render_template(template, data)
.map_err(|err| Error::RenderError(err.into()))?;
Ok(rendered_template)
}
/// Render a template string.
///
/// The output format can be chosen via the `renderer` parameter (see [TemplateType]
/// for available options).
pub fn render_template(
mut ty: TemplateType,
template: &str,
data: &Value,
) -> Result<String, Error> {
let filename = format!("{template}-{suffix}", suffix = ty.file_suffix());
let template_string = context::context().lookup_template(&filename, None)?;
let (template_string, fallback) = match (template_string, ty) {
(None, TemplateType::HtmlBody) => {
ty = TemplateType::PlaintextBody;
let plaintext_filename = format!("{template}-{suffix}", suffix = ty.file_suffix());
log::info!("html template '{filename}' not found, falling back to plain text template '{plaintext_filename}'");
(
context::context().lookup_template(&plaintext_filename, None)?,
true,
)
}
(template_string, _) => (template_string, false),
};
let template_string = template_string.ok_or(Error::Generic(format!(
"could not load template '{template}'"
)))?;
let mut rendered = render_template_impl(&template_string, data, ty)?;
rendered = ty.postprocess(rendered);
if fallback {
rendered = format!(
"<html><body><pre>{}</pre></body></html>",
handlebars::html_escape(&rendered)
);
}
Ok(rendered)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_helpers() {
assert_eq!(value_to_byte_size(&json!(1024)), Some("1 KiB".to_string()));
assert_eq!(
value_to_byte_size(&json!("1024")),
Some("1 KiB".to_string())
);
assert_eq!(value_to_duration(&json!(60)), Some("1m".to_string()));
assert_eq!(value_to_duration(&json!("60")), Some("1m".to_string()));
// The rendered value is in localtime, so we only check if the result is `Some`...
// ... otherwise the test will break in another timezone :S
assert!(value_to_timestamp(&json!(60)).is_some());
assert!(value_to_timestamp(&json!("60")).is_some());
}
}