use handlebars::{
Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, RenderError,
RenderErrorReason, Renderable, ScopedJson, StringOutput, handlebars_helper,
};
use serde::Serialize;
use serde_json::Value as Json;
use std::str::FromStr;
use thiserror::Error;
use toml::value::Table;
type TablePartition = Vec<(String, toml::Value)>;
#[derive(Debug, Error)]
enum JsonError {
#[error("query failure for expression '{expression}'")]
JsonQueryFailure {
expression: String,
source: jmespath::JmespathError,
},
#[error("fail to convert '{input}'")]
ToJsonValueError {
input: String,
source: serde_json::error::Error,
},
#[error("data format unknown '{format}'")]
DataFormatUnknown { format: String },
}
fn to_nested_error<E>(cause: E) -> RenderError
where
E: std::error::Error + Send + Sync + 'static,
{
RenderErrorReason::NestedError(Box::new(cause)).into()
}
fn to_other_error<T: AsRef<str>>(desc: T) -> RenderError {
RenderErrorReason::Other(desc.as_ref().to_string()).into()
}
#[derive(Debug, Clone)]
enum DataFormat {
Json,
JsonPretty,
Yaml,
Toml,
TomlPretty,
}
impl FromStr for DataFormat {
type Err = JsonError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"json_pretty" => Ok(Self::JsonPretty),
"yaml" => Ok(Self::Yaml),
"toml" => Ok(Self::Toml),
"toml_pretty" => Ok(Self::TomlPretty),
_ => Err(JsonError::DataFormatUnknown {
format: s.to_string(),
}),
}
}
}
fn to_opt_res<T, E>(v: Result<Option<T>, E>) -> Option<Result<T, E>> {
match v {
Err(e) => Some(Err(e)),
Ok(v) => v.map(Ok),
}
}
fn to_ordored_toml_value(data: &Json) -> Result<Option<toml::Value>, RenderError> {
match data {
Json::String(v) => Ok(Some(toml::Value::from(v.as_str()))),
Json::Array(v) => v
.iter()
.filter_map(|i| to_opt_res(to_ordored_toml_value(i)))
.collect::<Result<Vec<_>, _>>()
.map(|a| Some(toml::Value::Array(a))),
Json::Object(obj) => obj
.iter()
.filter_map(|kv| {
to_opt_res(to_ordored_toml_value(kv.1)).map(|rnv| rnv.map(|nv| (kv.0.to_owned(), nv)))
})
.collect::<Result<Table, _>>()
.map(|m| Some(toml::Value::Table(sort_toml_map(m)))),
Json::Number(v) => {
if v.is_i64() {
Ok(Some(toml::Value::Integer(v.as_i64().unwrap())))
} else if let Some(x) = v.as_f64() {
Ok(Some(toml::Value::Float(x)))
} else {
Err(to_other_error(format!(
"to_toml: can not convert a Json Number: {v}"
)))
}
}
Json::Bool(v) => Ok(Some(toml::Value::Boolean(*v))),
Json::Null => Ok(None),
}
}
fn sort_toml_map(data: Table) -> Table {
let (tables, non_tables): (TablePartition, TablePartition) =
data.into_iter().partition(|v| v.1.is_table());
let (arrays, others): (TablePartition, TablePartition) =
non_tables.into_iter().partition(|v| v.1.is_array());
let mut m = Table::new();
m.extend(others);
m.extend(arrays);
m.extend(tables);
m
}
impl DataFormat {
fn read_string(&self, data: &str) -> Result<Json, RenderError> {
if data.is_empty() {
return Ok(Json::String(String::new()));
}
match self {
DataFormat::Json | DataFormat::JsonPretty => serde_json::from_str(data).map_err(to_nested_error),
DataFormat::Yaml => serde_yaml::from_str(data).map_err(to_nested_error),
DataFormat::Toml | DataFormat::TomlPretty => toml::from_str(data).map_err(to_nested_error),
}
}
fn write_string(&self, data: &Json) -> Result<String, RenderError> {
match data {
Json::Null => Ok(String::new()),
Json::String(c) if c.is_empty() => Ok(String::new()),
_ => match self {
DataFormat::Json => serde_json::to_string(data).map_err(to_nested_error),
DataFormat::JsonPretty => serde_json::to_string_pretty(data).map_err(to_nested_error),
DataFormat::Yaml => serde_yaml::to_string(data)
.map_err(to_nested_error)
.map(|s| s.trim_start_matches("---\n").to_string()),
DataFormat::Toml => {
let data_toml = to_ordored_toml_value(data)?;
toml::to_string(&data_toml).map_err(to_nested_error)
}
DataFormat::TomlPretty => {
let data_toml = to_ordored_toml_value(data)?;
toml::to_string_pretty(&data_toml).map_err(to_nested_error)
}
},
}
}
}
#[allow(clippy::result_large_err)]
fn json_query<T: Serialize, E: AsRef<str>>(expr: E, data: T) -> Result<Json, JsonError> {
let res = jmespath::compile(expr.as_ref())
.and_then(|e| e.search(data))
.map_err(|source| JsonError::JsonQueryFailure {
expression: expr.as_ref().to_string(),
source,
})?;
serde_json::to_value(res.as_ref()).map_err(|source| JsonError::ToJsonValueError {
input: format!("{res:?}"),
source,
})
}
fn find_data_format(h: &Helper) -> Result<DataFormat, RenderError> {
let param = h
.hash_get("format")
.and_then(|v| v.value().as_str())
.unwrap_or("json");
DataFormat::from_str(param).map_err(to_nested_error)
}
fn find_str_param(pos: usize, h: &Helper) -> Result<String, RenderError> {
h.param(pos)
.ok_or_else(|| to_other_error(format!("param {pos} (the string) not found")))
.map(|v| v.value().as_str().unwrap_or("").to_owned())
}
#[allow(non_camel_case_types)]
struct str_to_json_fct;
impl HelperDef for str_to_json_fct {
fn call_inner<'reg: 'rc, 'rc>(
&self,
h: &Helper<'rc>,
_: &'reg Handlebars,
_: &'rc Context,
_: &mut RenderContext<'reg, 'rc>,
) -> Result<ScopedJson<'reg>, RenderError> {
let data: String = find_str_param(0, h)?;
let format = find_data_format(h)?;
let result = format.read_string(&data)?;
Ok(ScopedJson::Derived(result))
}
}
#[allow(non_camel_case_types)]
struct json_to_str_fct;
impl HelperDef for json_to_str_fct {
fn call_inner<'reg: 'rc, 'rc>(
&self,
h: &Helper<'rc>,
_: &'reg Handlebars,
_: &'rc Context,
_: &mut RenderContext<'reg, 'rc>,
) -> Result<ScopedJson<'reg>, RenderError> {
let format = find_data_format(h)?;
let data = h
.param(0)
.ok_or_else(|| to_other_error("param 0 (the json) not found"))
.map(|v| v.value())?;
let result = format.write_string(data)?;
Ok(ScopedJson::Derived(Json::String(result)))
}
}
#[allow(non_camel_case_types)]
struct json_str_query_fct;
impl HelperDef for json_str_query_fct {
fn call_inner<'reg: 'rc, 'rc>(
&self,
h: &Helper<'rc>,
_: &'reg Handlebars,
_: &'rc Context,
_: &mut RenderContext<'reg, 'rc>,
) -> Result<ScopedJson<'reg>, RenderError> {
let format = find_data_format(h)?;
let expr = find_str_param(0, h)?;
let data_str = find_str_param(1, h)?;
let data = format.read_string(&data_str)?;
let result = json_query(expr, data).map_err(to_nested_error).and_then(|v| {
let output_format = if v.is_array() || v.is_object() {
format
} else {
DataFormat::Json
};
output_format.write_string(&v).map(|s| {
if v.is_array() || v.is_object() {
s
} else {
s.trim().to_owned()
}
})
})?;
Ok(ScopedJson::Derived(Json::String(result)))
}
}
fn from_json_block<'reg, 'rc>(
h: &Helper<'rc>,
r: &'reg Handlebars,
ctx: &'rc Context,
rc: &mut RenderContext<'reg, 'rc>,
out: &mut dyn Output,
) -> HelperResult {
let format = find_data_format(h)?;
let mut content = StringOutput::default();
h.template()
.map(|t| t.render(r, ctx, rc, &mut content))
.unwrap_or(Ok(()))?;
let data = DataFormat::Json.read_string(&content.into_string().map_err(to_nested_error)?)?;
let res = format.write_string(&data)?;
out.write(&res).map_err(to_nested_error)
}
fn to_json_block<'reg, 'rc>(
h: &Helper<'rc>,
r: &'reg Handlebars,
ctx: &'rc Context,
rc: &mut RenderContext<'reg, 'rc>,
out: &mut dyn Output,
) -> HelperResult {
let format = find_data_format(h)?;
let mut content = StringOutput::default();
h.template()
.map(|t| t.render(r, ctx, rc, &mut content))
.unwrap_or(Ok(()))?;
let data = format.read_string(&content.into_string().map_err(to_nested_error)?)?;
let res = DataFormat::JsonPretty.write_string(&data)?;
out.write(&res).map_err(RenderError::from)
}
handlebars_helper!(json_query_fct: |expr: str, data: Json| json_query(expr, data).map_err(to_nested_error)?);
pub(crate) fn register(handlebars: &mut Handlebars) {
handlebars.register_helper("json_to_str", Box::new(json_to_str_fct));
handlebars.register_helper("str_to_json", Box::new(str_to_json_fct));
handlebars.register_helper("from_json", Box::new(from_json_block));
handlebars.register_helper("to_json", Box::new(to_json_block));
handlebars.register_helper("json_query", Box::new(json_query_fct));
handlebars.register_helper("json_str_query", Box::new(json_str_query_fct));
}
#[cfg(test)]
mod tests {
use super::*;
fn render(tmpl: &str) -> String {
let mut hb = Handlebars::new();
hb.register_escape_fn(handlebars::no_escape);
register(&mut hb);
hb.render_template(tmpl, &Json::Null).unwrap()
}
#[test]
fn empty_input_returns_empty() {
assert_eq!(render(r##"{{ json_to_str "" }}"##), "");
assert_eq!(render(r##"{{ str_to_json "" }}"##), "");
assert_eq!(render(r##"{{ json_query "foo" "" }}"##), "");
assert_eq!(render(r##"{{ json_str_query "foo" "" }}"##), "");
}
#[test]
fn null_input_returns_empty() {
assert_eq!(render(r##"{{ json_to_str null }}"##), "");
assert_eq!(render(r##"{{ str_to_json null }}"##), "");
}
#[test]
fn json_to_str_roundtrip() {
assert_eq!(render(r##"{{ json_to_str {} }}"##), "{}");
assert_eq!(
render(r##"{{ json_to_str {"foo":{"bar":{"baz":true}}} }}"##),
r##"{"foo":{"bar":{"baz":true}}}"##
);
assert_eq!(
render(r##"{{ json_to_str ( str_to_json "{\"foo\":true}" ) }}"##),
r##"{"foo":true}"##
);
}
#[test]
fn json_query_extracts_field() {
assert_eq!(
render(r##"{{ json_to_str ( json_query "foo" {"foo":{"bar":{"baz":true}}} ) }}"##),
r##"{"bar":{"baz":true}}"##
);
}
#[test]
fn json_str_query_on_yaml() {
assert_eq!(
render(r##"{{ json_str_query "foo.bar.baz" "foo:\n bar:\n baz: true\n" format="yaml"}}"##),
"true"
);
}
#[test]
fn json_str_query_on_toml() {
assert_eq!(
render(r##"{{ json_str_query "foo.bar.baz" "[foo.bar]\nbaz=true\n" format="toml"}}"##),
"true"
);
}
#[test]
fn to_json_block_wraps_rendered_content() {
assert_eq!(
render(r##"{{#to_json}}{"foo":{"bar":{"baz":true}}}{{/to_json}}"##),
"{\n \"foo\": {\n \"bar\": {\n \"baz\": true\n }\n }\n}"
);
}
#[test]
fn from_json_block_converts_to_yaml() {
assert_eq!(
render(r##"{{#from_json format="yaml"}}{"foo":{"bar":true}}{{/from_json}}"##),
"foo:\n bar: true\n"
);
}
#[test]
fn data_format_symmetry() {
for (fmt, data) in [
(DataFormat::Json, r##"{"foo":{"bar":{"baz":true}}}"##),
(DataFormat::Toml, "[foo.bar]\nbaz = true\n"),
] {
let actual = fmt.write_string(&fmt.read_string(data).unwrap()).unwrap();
assert_eq!(actual, data);
}
}
}