Skip to content

Commit

Permalink
feat: add ability to define helpers
Browse files Browse the repository at this point in the history
This commit doesn't yet add support for extensions to call helpers, but
it does add the HelperDef trait needed to define them, as well as a
procedural macro to make it easier to define a helper given a function.
  • Loading branch information
cbgbt committed Oct 24, 2023
1 parent 1dcc730 commit f94d62f
Show file tree
Hide file tree
Showing 7 changed files with 285 additions and 0 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[workspace]
members = [
"bottlerocket-settings-sdk",
"bottlerocket-template-helper",
]
1 change: 1 addition & 0 deletions bottlerocket-settings-sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ license = "Apache-2.0 OR MIT"
edition = "2021"

[dependencies]
bottlerocket-template-helper = { path = "../bottlerocket-template-helper", version = "0.1" }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1.0"
Expand Down
116 changes: 116 additions & 0 deletions bottlerocket-settings-sdk/src/helper/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Provides types for creating custom helper functions for use in Bottlerocket's templating engine.
//!
//! See the documentation of [`HelperDef`] for more information.
pub use bottlerocket_template_helper::template_helper;

/// This trait allows users to create custom helper functions for use in Bottlerocket's templating
/// configuration system.
///
/// Helpers are used to run arbitrary rendering code when writing config files.
///
/// # Helper Definitions
/// Any type that implements [`HelperDef`] can be used as a helper. You can use the
/// [`template_helper`] annotation to generate a function that implements [`HelperDef`] for you, so
/// long as:
/// * Your function arguments implement [`serde::Deserialize`]
/// * Your return value is a `Result<T, E>` where `T` implements [`serde::Serialize`]
/// and `E` implements `Into<Box<dyn std::error::Error>>`.
///
/// # Example
///
/// ```
/// use bottlerocket_settings_sdk::helper::{HelperDef, template_helper};
/// use serde_json::json;
///
/// #[template_helper(ident = join_strings_helper)]
/// fn join_strings(lhs: String, rhs: String) -> Result<String, anyhow::Error> {
/// Ok(format!("{}{}", lhs, rhs))
/// }
///
/// assert_eq!(
/// join_strings_helper.helper_fn(vec![json!("hello "), json!("world")]).unwrap(),
/// json!("hello world")
/// );
///
/// ```
pub trait HelperDef {
/// Executes the helper.
///
/// All inputs are provided as a list of JSON values, and a resulting JSON value is expected as
/// output.
fn helper_fn(&self, args: Vec<serde_json::Value>) -> Result<serde_json::Value, HelperError>;
}

impl<F: Fn(Vec<serde_json::Value>) -> Result<serde_json::Value, HelperError>> HelperDef for F {
fn helper_fn(&self, args: Vec<serde_json::Value>) -> Result<serde_json::Value, HelperError> {
self(args)
}
}

#[macro_export]
/// Creates a map of helper names to helper definitions.
///
/// This macro is useful for providing template helpers from a settings model:
///
/// ```
/// # use std::collections::HashMap;
/// use bottlerocket_settings_sdk::{
/// HelperDef, provide_template_helpers, template_helper};
///
/// #[template_helper(ident = exclaim_helper)]
/// fn exclaim(s: String) -> Result<String, anyhow::Error> {
/// Ok(format!("{}!", s))
/// }
///
/// fn template_helpers() -> HashMap<String, Box<dyn HelperDef>> {
/// provide_template_helpers! {
/// "exclaim" => exclaim_helper,
/// }
/// }
/// ```
macro_rules! provide_template_helpers {
($($helper_name:expr => $helper:ident),* $(,)?) => {
{
let mut helpers = std::collections::HashMap::new();
$(
helpers.insert(
$helper_name.to_string(),
Box::new($helper)as Box<dyn bottlerocket_settings_sdk::HelperDef>
);
)*
helpers
}
};
}

mod error {
#![allow(missing_docs)]
use snafu::Snafu;

/// Error type used in helper definitions.
#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum HelperError {
#[snafu(display(
"Helper called with incorrect arity: expected {} args, but {} provided",
expected_args,
provided_args
))]
Arity {
expected_args: usize,
provided_args: usize,
},

#[snafu(display("Failed to execute helper: {}", source))]
HelperExecute {
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},

#[snafu(display("Failed to parse incoming value from JSON: {}", source))]
JSONParse { source: serde_json::Error },

#[snafu(display("Failed to parse outgoing value to JSON: {}", source))]
JSONSerialize { source: serde_json::Error },
}
}
pub use error::HelperError;
2 changes: 2 additions & 0 deletions bottlerocket-settings-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ tool wishes to invoke a settings extension and parse the output.
pub mod cli;
#[cfg(feature = "extension")]
pub mod extension;
pub mod helper;
#[cfg(feature = "extension")]
pub mod migrate;
pub mod model;

#[cfg(feature = "extension")]
pub use crate::extension::SettingsExtension;
pub use helper::{HelperDef, HelperError};
#[cfg(feature = "extension")]
pub use migrate::{
LinearMigrator, LinearMigratorExtensionBuilder, LinearMigratorModel, LinearlyMigrateable,
Expand Down
23 changes: 23 additions & 0 deletions bottlerocket-template-helper/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "bottlerocket-template-helper"
version = "0.1.0"
license = "Apache-2.0 OR MIT"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lib]
path = "src/lib.rs"
proc-macro = true

[dependencies]
darling = "0.20"
proc-macro2 = "1"
quote = "1"
serde_json = "1"
syn = { version = "1", default-features = false, features = ["full", "parsing", "printing", "proc-macro", "visit-mut"] }


[dev-dependencies]
anyhow = "1"
bottlerocket-settings-sdk = { path = "../bottlerocket-settings-sdk", version = "0.1" }
104 changes: 104 additions & 0 deletions bottlerocket-template-helper/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! This crate provides a procedural macro for defining template helpers in settings extensions.
//! See the documentation in [`bottlerocket-settings-sdk::helper`] for more information.
use darling::{ast::NestedMeta, FromMeta};
use proc_macro::TokenStream;
use quote::quote;
use syn::{self, FnArg, ItemFn};

#[derive(FromMeta)]
struct MacroArgs {
ident: syn::Ident,
vis: Option<String>,
}

/// Defines a [`bottlerocket-settings-sdk::helper::HelperDef`] based on a given function.
///
/// This macro requires:
/// * Your function arguments implement [`serde::Deserialize`]
/// * Your return value is a `Result<T, E>` where `T` implements [`serde::Serialize`]
/// and `E` implements `Into<Box<dyn std::error::Error>>`.
///
/// To define a `HelperDef` called `my_helper` based on a function, you could do something like:
///
/// ```
/// use bottlerocket_settings_sdk::helper::{HelperDef, template_helper};
///
/// #[template_helper(ident = my_helper)]
/// fn help_with(list_of_things: Vec<String>) -> Result<Vec<String>, anyhow::Error> {
/// Ok(list_of_things
/// .into_iter()
/// .map(|s| format!("Helped with '{s}'!"))
/// .collect())
/// }
/// ```
#[proc_macro_attribute]
pub fn template_helper(args: TokenStream, input: TokenStream) -> TokenStream {
let args: MacroArgs =
MacroArgs::from_list(&NestedMeta::parse_meta_list(args.into()).unwrap()).unwrap();

let helper_fn_name = args.ident;

let fn_ast: ItemFn = syn::parse2(input.into()).unwrap();
let fn_name = fn_ast.sig.ident.clone();

let num_args = fn_ast.sig.inputs.len();
let arg_types: Vec<Box<syn::Type>> = fn_ast
.sig
.inputs
.iter()
.map(|arg| match arg {
FnArg::Receiver(_) => {
panic!("template_helper macro does not work on methods that take `self`")
}
FnArg::Typed(t) => t.ty.clone(),
})
.collect();

let mut helper_fn: ItemFn = syn::parse2(quote! {
fn #helper_fn_name(
args: Vec<serde_json::Value>,
) -> std::result::Result<
serde_json::Value,
bottlerocket_settings_sdk::HelperError
> {
if args.len() != #num_args {
return Err(bottlerocket_settings_sdk::HelperError::Arity {
expected_args: #num_args,
provided_args: args.len(),
});
}

// Call the input function with our dynamically generated list of arguments.
// We know that `args` is the correct length because we checked above, so we can let
// the macro unwrap values that it takes.
let mut args = args.into_iter();
#fn_name(#(
{
let arg: #arg_types = match serde_json::from_value(args.next().unwrap()) {
Ok(parsed) => parsed,
Err(e) => return Err(bottlerocket_settings_sdk::HelperError::JSONParse { source: e })
};
arg
}
),*)
.map_err(|e| bottlerocket_settings_sdk::HelperError::HelperExecute {
source: e.into(),
})
.and_then(|result| serde_json::to_value(result).map_err(|e| {
bottlerocket_settings_sdk::HelperError::JSONSerialize { source: e }
}))
}
})
.unwrap();

if let Some(visibility) = args.vis {
helper_fn.vis = syn::parse_str(&visibility).unwrap();
}

quote! {
#fn_ast

#helper_fn
}
.into()
}
38 changes: 38 additions & 0 deletions bottlerocket-template-helper/tests/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use anyhow::Result;
use bottlerocket_settings_sdk::HelperDef;
use bottlerocket_template_helper::template_helper;
use serde_json::json;

#[template_helper(ident = join_strings_helper)]
fn join_strings(lhs: String, rhs: String) -> Result<String> {
Ok(lhs + &rhs)
}

#[test]
fn call_join_strings() {
assert_eq!(
join_strings_helper
.helper_fn(vec![json!("hello "), json!("world!")])
.unwrap(),
json!("hello world!"),
);

assert!(join_strings_helper(vec![json!("too"), json!("many"), json!("args")]).is_err());

assert!(join_strings_helper
.helper_fn(vec![json!("too"), json!("many"), json!("args")])
.is_err());

assert!(join_strings_helper(vec![json!("too few args")]).is_err());
}

#[template_helper(ident = no_args_helper)]
fn no_args() -> Result<String> {
Ok(String::new())
}

#[test]
fn call_no_args() {
assert_eq!(no_args_helper(vec![]).unwrap(), json!(""));
assert!(no_args_helper(vec![json!("sneaky arg")]).is_err());
}

0 comments on commit f94d62f

Please sign in to comment.