proxmox/proxmox-notify/src/renderer/mod.rs
Lukas Wagner 4865711339 notify: add template rendering
This commit adds template rendering to the `proxmox-notify` crate, based
on the `handlebars` crate.

Title and body of a notification are rendered using any `properties`
passed along with the notification. There are also a few helpers,
allowing to render tables from `serde_json::Value`.

'Value' renderers. These can also be used in table cells using the
'renderer' property in a table schema:
  - {{human-bytes val}}
    Render bytes with human-readable units (base 2)
  - {{duration val}}
    Render a duration (based on seconds)
  - {{timestamp val}}
    Render a unix-epoch (based on seconds)

There are also a few 'block-level' helpers.
  - {{table val}}
    Render a table from given val (containing a schema for the columns,
    as well as the table data)
  - {{object val}}
    Render a value as a pretty-printed json
  - {{heading_1 val}}
    Render a top-level heading
  - {{heading_2 val}}
    Render a not-so-top-level heading
  - {{verbatim val}} or {{/verbatim}}<content>{{#verbatim}}
    Do not reflow text. NOP for plain text, but for HTML output the text
    will be contained in a <pre> with a regular font.
  - {{verbatim-monospaced val}} or
      {{/verbatim-monospaced}}<content>{{#verbatim-monospaced}}
    Do not reflow text. NOP for plain text, but for HTML output the text
    will be contained in a <pre> with a monospaced font.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
2023-07-24 10:25:48 +02:00

367 lines
10 KiB
Rust

//! Module for rendering notification templates.
use handlebars::{
Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext,
RenderError as HandlebarsRenderError,
};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::Error;
use proxmox_human_byte::HumanByte;
use proxmox_time::TimeSpan;
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)
///
/// Will return `None` if `val` does not contain a number.
fn value_to_byte_size(val: &Value) -> Option<String> {
let size = val.as_f64()?;
Some(format!("{}", HumanByte::new_binary(size)))
}
/// Render a serde_json::Value as a duration.
/// The value is expected to contain the duration in seconds.
///
/// Will return `None` if `val` does not contain a number.
fn value_to_duration(val: &Value) -> Option<String> {
let duration = val.as_u64()?;
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.
///
/// Will return `None` if `val` does not contain a number.
fn value_to_timestamp(val: &Value) -> Option<String> {
let timestamp = val.as_i64()?;
proxmox_time::strftime_local("%F %H:%M:%S", timestamp).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) -> Result<String, HandlebarsRenderError> {
match self {
ValueRenderFunction::HumanBytes => value_to_byte_size(value),
ValueRenderFunction::Duration => value_to_duration(value),
ValueRenderFunction::Timestamp => value_to_timestamp(value),
}
.ok_or_else(|| {
HandlebarsRenderError::new(format!(
"could not render value {value} with renderer {self:?}"
))
})
}
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 renderers for notification templates.
#[derive(Copy, Clone)]
pub enum TemplateRenderer {
/// Render to HTML code
Html,
/// Render to plain text
Plaintext,
}
impl TemplateRenderer {
fn prefix(&self) -> &str {
match self {
TemplateRenderer::Html => "<html>\n<body>\n",
TemplateRenderer::Plaintext => "",
}
}
fn postfix(&self) -> &str {
match self {
TemplateRenderer::Html => "\n</body>\n</html>",
TemplateRenderer::Plaintext => "",
}
}
fn block_render_fns(&self) -> BlockRenderFunctions {
match self {
TemplateRenderer::Html => html::block_render_functions(),
TemplateRenderer::Plaintext => plaintext::block_render_functions(),
}
}
fn escape_fn(&self) -> fn(&str) -> String {
match self {
TemplateRenderer::Html => handlebars::html_escape,
TemplateRenderer::Plaintext => handlebars::no_escape,
}
}
}
type HelperFn = dyn HelperDef + Send + Sync;
struct BlockRenderFunctions {
table: Box<HelperFn>,
verbatim_monospaced: Box<HelperFn>,
object: Box<HelperFn>,
heading_1: Box<HelperFn>,
heading_2: Box<HelperFn>,
verbatim: Box<HelperFn>,
}
impl BlockRenderFunctions {
fn register_helpers(self, handlebars: &mut Handlebars) {
handlebars.register_helper("table", self.table);
handlebars.register_helper("verbatim", self.verbatim);
handlebars.register_helper("verbatim-monospaced", self.verbatim_monospaced);
handlebars.register_helper("object", self.object);
handlebars.register_helper("heading-1", self.heading_1);
handlebars.register_helper("heading-2", self.heading_2);
}
}
fn render_template_impl(
template: &str,
properties: Option<&Value>,
renderer: TemplateRenderer,
) -> Result<String, Error> {
let properties = properties.unwrap_or(&Value::Null);
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);
let rendered_template = handlebars
.render_template(template, properties)
.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 [TemplateRenderer]
/// for available options).
pub fn render_template(
renderer: TemplateRenderer,
template: &str,
properties: Option<&Value>,
) -> Result<String, Error> {
let mut rendered_template = String::from(renderer.prefix());
rendered_template.push_str(&render_template_impl(template, properties, renderer)?);
rendered_template.push_str(renderer.postfix());
Ok(rendered_template)
}
#[macro_export]
macro_rules! define_helper_with_prefix_and_postfix {
($name:ident, $pre:expr, $post:expr) => {
fn $name<'reg, 'rc>(
h: &Helper<'reg, 'rc>,
handlebars: &'reg Handlebars,
context: &'rc Context,
render_context: &mut RenderContext<'reg, 'rc>,
out: &mut dyn Output,
) -> HelperResult {
use handlebars::Renderable;
let block_text = h.template();
let param = h.param(0);
out.write($pre)?;
match (param, block_text) {
(None, Some(block_text)) => {
block_text.render(handlebars, context, render_context, out)
}
(Some(param), None) => {
let value = param.value();
let text = value.as_str().ok_or_else(|| {
HandlebarsRenderError::new(format!("value {value} is not a string"))
})?;
out.write(text)?;
Ok(())
}
(Some(_), Some(_)) => Err(HandlebarsRenderError::new(
"Cannot use parameter and template at the same time",
)),
(None, None) => Err(HandlebarsRenderError::new(
"Neither parameter nor template was provided",
)),
}?;
out.write($post)?;
Ok(())
}
};
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_render_template() -> Result<(), Error> {
let properties = json!({
"dur": 12345,
"size": 1024 * 15,
"table": {
"schema": {
"columns": [
{
"id": "col1",
"label": "Column 1"
},
{
"id": "col2",
"label": "Column 2"
}
]
},
"data": [
{
"col1": "val1",
"col2": "val2"
},
{
"col1": "val3",
"col2": "val4"
},
]
}
});
let template = r#"
{{heading-1 "Hello World"}}
{{heading-2 "Hello World"}}
{{human-bytes size}}
{{duration dur}}
{{table table}}"#;
let expected_plaintext = r#"
Hello World
===========
Hello World
-----------
15 KiB
3h 25min 45s
Column 1 Column 2
val1 val2
val3 val4
"#;
let rendered_plaintext =
render_template(TemplateRenderer::Plaintext, template, Some(&properties))?;
// Let's not bother about testing the HTML output, too fragile.
assert_eq!(rendered_plaintext, expected_plaintext);
Ok(())
}
}