Skip to content

Commit

Permalink
Merge pull request #408 from amazonlinux/join-map-helper
Browse files Browse the repository at this point in the history
thar-be-settings: allow formatting maps into configuration files
  • Loading branch information
tjkirch authored Oct 15, 2019
2 parents d98805d + 288d55e commit 2680258
Show file tree
Hide file tree
Showing 4 changed files with 290 additions and 219 deletions.
289 changes: 287 additions & 2 deletions workspaces/api/thar-be-settings/src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// text at render time.

use handlebars::{Context, Handlebars, Helper, Output, RenderContext, RenderError};
use serde_json::value::Value;
use snafu::{OptionExt, ResultExt};

/// Potential errors during helper execution
Expand Down Expand Up @@ -33,15 +34,23 @@ mod error {
// handlebars::JsonValue is a serde_json::Value, which implements
// the 'Display' trait and should provide valuable context
#[snafu(display(
"Invalid (non-string) base64 template value: '{}' in template {}",
"Invalid base64 template value, expected {}, got '{}' in template {}",
expected,
value,
template
))]
InvalidTemplateValue {
expected: &'static str,
value: handlebars::JsonValue,
template: String,
},

#[snafu(display(
"Missing data and fail-if-missing was set; see given line/col in template '{}'",
template,
))]
MissingTemplateData { template: String },

#[snafu(display(
"Unable to base64 decode string '{}' in template '{}': '{}'",
base64_string,
Expand Down Expand Up @@ -130,6 +139,7 @@ pub fn base64_decode(

// Create an &str from the serde_json::Value
let base64_str = base64_value.as_str().context(error::InvalidTemplateValue {
expected: "string",
value: base64_value.to_owned(),
template: template_name.to_owned(),
})?;
Expand All @@ -155,8 +165,183 @@ pub fn base64_decode(
Ok(())
}

/// `join_map` lets you join together strings in a map with given characters, for example when
/// you're writing values out to a configuration file.
///
/// The map is expected to be a single level deep, with string keys and string values.
///
/// The first parameter is the character to use to join keys to values; the second parameter is the
/// character to use to join pairs; the third parameter is the name of the map. The third
/// parameter is a literal string that describes the behavior you want if the map is missing from
/// settings; "fail-if-missing" to fail the template, or "no-fail-if-missing" to continue but write
/// out nothing for this invocation of the helper.
///
/// Example:
/// {{ join_map "=" "," "fail-if-missing" map }}
/// ...where `map` is: {"hi": "there", "whats": "up"}
/// ...will produce: "hi=there,whats=up"
pub fn join_map(
helper: &Helper<'_, '_>,
_: &Handlebars,
_: &Context,
renderctx: &mut RenderContext<'_>,
out: &mut dyn Output,
) -> Result<(), RenderError> {
trace!("Starting join_map helper");
let template_name = renderctx
.get_root_template_name()
.map(|i| i.to_string())
.unwrap_or_else(|| "dynamic template".to_string());
trace!("Template name: {}", &template_name);

trace!("Number of params: {}", helper.params().len());
if helper.params().len() != 4 {
return Err(RenderError::from(
error::TemplateHelperError::IncorrectNumberOfParams {
expected: 4,
received: helper.params().len(),
helper: helper.name().to_string(),
template: template_name,
},
));
}

// Pull out the parameters and confirm their types
let join_key_val = helper
.param(0)
.map(|v| v.value())
.context(error::Internal {
msg: "Missing param after confirming there are enough",
})?;
let join_key = join_key_val
.as_str()
.with_context(|| error::InvalidTemplateValue {
expected: "string",
value: join_key_val.to_owned(),
template: template_name.to_owned(),
})?;
trace!("Character used to join keys to values: {}", join_key);

let join_pairs_val = helper
.param(1)
.map(|v| v.value())
.context(error::Internal {
msg: "Missing param after confirming there are enough",
})?;
let join_pairs = join_pairs_val
.as_str()
.with_context(|| error::InvalidTemplateValue {
expected: "string",
value: join_pairs_val.to_owned(),
template: template_name.to_owned(),
})?;
trace!("Character used to join pairs: {}", join_pairs);

let fail_behavior_val = helper
.param(2)
.map(|v| v.value())
.context(error::Internal {
msg: "Missing param after confirming there are enough",
})?;
let fail_behavior_str =
fail_behavior_val
.as_str()
.with_context(|| error::InvalidTemplateValue {
expected: "string",
value: join_pairs_val.to_owned(),
template: template_name.to_owned(),
})?;
let fail_if_missing = match fail_behavior_str {
"fail-if-missing" => true,
"no-fail-if-missing" => false,
_ => {
return Err(RenderError::from(
error::TemplateHelperError::InvalidTemplateValue {
expected: "fail-if-missing or no-fail-if-missing",
value: fail_behavior_val.to_owned(),
template: template_name.to_owned(),
},
))
}
};
trace!(
"Will we fail if missing the specified map: {}",
fail_if_missing
);

let map_value = helper
.param(3)
.map(|v| v.value())
.context(error::Internal {
msg: "Missing param after confirming there are enough",
})?;
// If the requested setting is not set, we check the user's requested fail-if-missing behavior
// to determine whether to fail hard or just write nothing quietly.
if !map_value.is_object() {
if fail_if_missing {
return Err(RenderError::from(
error::TemplateHelperError::MissingTemplateData {
template: template_name.to_owned(),
},
));
} else {
return Ok(());
}
}
let map = map_value.as_object().context(error::Internal {
msg: "Already confirmed map is_object but as_object failed",
})?;
trace!("Map to join: {:?}", map);

// Join the key/value pairs with requested string
let mut pairs = Vec::new();
for (key, val_value) in map.into_iter() {
// We don't want the JSON form of scalars, we want the Display form of the Rust type inside.
let val = match val_value {
// these ones Display as their simple scalar selves
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => s.to_string(),
// Null not supported; probably don't want blanks in config files, and we don't have a
// use for this yet; consider carefully if/when we do
Value::Null => {
return Err(RenderError::from(
error::TemplateHelperError::InvalidTemplateValue {
expected: "non-null",
value: val_value.to_owned(),
template: template_name.to_owned(),
},
))
}
// composite types unsupported
Value::Array(_) | Value::Object(_) => {
return Err(RenderError::from(
error::TemplateHelperError::InvalidTemplateValue {
expected: "scalar",
value: val_value.to_owned(),
template: template_name.to_owned(),
},
))
}
};

// Do the actual key/value join.
pairs.push(format!("{}{}{}", key, join_key, val));
}

// Join all pairs with the given string.
let joined = pairs.join(join_pairs);
trace!("Joined output: {}", joined);

// Write the string out to the template
out.write(&joined).context(error::TemplateWrite {
template: template_name.to_owned(),
})?;
Ok(())
}

#[cfg(test)]
mod test {
mod test_base64_decode {
use super::*;
use handlebars::TemplateRenderError;
use serde::Serialize;
Expand Down Expand Up @@ -208,3 +393,103 @@ mod test {
.is_err());
}
}

#[cfg(test)]
mod test_join_map {
use super::*;
use handlebars::TemplateRenderError;
use serde::Serialize;
use serde_json::json;

// A thin wrapper around the handlebars render_template method that includes
// setup and registration of helpers
fn setup_and_render_template<T>(tmpl: &str, data: &T) -> Result<String, TemplateRenderError>
where
T: Serialize,
{
let mut registry = Handlebars::new();
registry.register_helper("join_map", Box::new(join_map));

registry.render_template(tmpl, data)
}

#[test]
fn single_pair() {
let result = setup_and_render_template(
"{{join_map \"=\" \",\" \"fail-if-missing\" map}}",
&json!({"map": {"hi": "there"}}),
)
.unwrap();
assert_eq!(result, "hi=there")
}

#[test]
fn basic() {
let result = setup_and_render_template(
"{{join_map \"=\" \",\" \"fail-if-missing\" map}}",
&json!({"map": {"hi": "there", "whats": "up"}}),
)
.unwrap();
assert_eq!(result, "hi=there,whats=up")
}

#[test]
fn number() {
let result = setup_and_render_template(
"{{join_map \"=\" \",\" \"fail-if-missing\" map}}",
&json!({"map": {"hi": 42}}),
)
.unwrap();
assert_eq!(result, "hi=42")
}

#[test]
fn boolean() {
let result = setup_and_render_template(
"{{join_map \"=\" \",\" \"fail-if-missing\" map}}",
&json!({"map": {"hi": true}}),
)
.unwrap();
assert_eq!(result, "hi=true")
}

#[test]
fn invalid_nested_map() {
setup_and_render_template(
"{{join_map \"=\" \",\" \"fail-if-missing\" map}}",
&json!({"map": {"hi": {"too": "deep"}}}),
)
.unwrap_err();
}

#[test]
fn fail_if_missing() {
setup_and_render_template(
"{{join_map \"=\" \",\" \"fail-if-missing\" map}}",
&json!({}),
)
// Requested failure if map was missing, should fail
.unwrap_err();
}

#[test]
fn no_fail_if_missing() {
let result = setup_and_render_template(
"{{join_map \"=\" \",\" \"no-fail-if-missing\" map}}",
&json!({}),
)
.unwrap();
// Requested no failure even if map was missing, should get no output
assert_eq!(result, "")
}

#[test]
fn invalid_fail_if_missing() {
setup_and_render_template(
"{{join_map \"=\" \",\" \"sup\" map}}",
&json!({}),
)
// Invalid failure mode 'sup'
.unwrap_err();
}
}
2 changes: 1 addition & 1 deletion workspaces/api/thar-be-settings/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ fn write_config_files(

// Get all settings values for config file templates
debug!("Requesting settings values");
let settings = settings::get_settings_from_template(&args.socket_path, &template_registry)?;
let settings = settings::get_settings_from_template(&args.socket_path)?;

// Ensure all files render properly
info!("Rendering config files...");
Expand Down
15 changes: 1 addition & 14 deletions workspaces/api/thar-be-settings/src/settings.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
use itertools::join;
use std::path::Path;

use crate::client;
use crate::template::TemplateKeys;
use crate::Result;

use apiserver::model;
Expand All @@ -11,24 +9,13 @@ use apiserver::model;
/// their values from the API
pub fn get_settings_from_template<P>(
socket_path: P,
registry: &handlebars::Handlebars,
) -> Result<model::Settings>
where
P: AsRef<Path>,
{
// Using the template registry, pull the keys out of the templates
// and query the API to get a structure of Settings which we can
// use to render the templates
debug!("Gathering keys from configuration file templates");
let settings_to_query = registry.get_all_template_keys()?;

debug!("Requesting settings values for template keys");
let query = join(&settings_to_query, ",");

// Query the settings
debug!("Querying API for settings data");
let settings: model::Settings =
client::get_json(socket_path, "/settings", Some(("keys", query)))?;
client::get_json(socket_path, "/settings", None as Option<(String, String)>)?;

trace!("Settings values: {:?}", settings);
Ok(settings)
Expand Down
Loading

0 comments on commit 2680258

Please sign in to comment.