Skip to content

Commit

Permalink
Templates can define custom Liquid filters
Browse files Browse the repository at this point in the history
Signed-off-by: itowlson <ivan.towlson@fermyon.com>
  • Loading branch information
itowlson authored and etehtsea committed Oct 3, 2022
1 parent 13f7916 commit 186d254
Show file tree
Hide file tree
Showing 18 changed files with 363 additions and 7 deletions.
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions crates/templates/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,10 @@ tokio = { version = "1.10", features = [ "fs", "process", "rt", "macros" ] }
toml = "0.5"
url = "2.2.2"
walkdir = "2"
wasmtime = { version = "0.39.1", features = [ "async" ] }
wasmtime-wasi = "0.39.1"

[dependencies.wit-bindgen-wasmtime]
git = "https://github.com/bytecodealliance/wit-bindgen"
rev = "cb871cfa1ee460b51eb1d144b175b9aab9c50aba"
features = ["async"]
166 changes: 166 additions & 0 deletions crates/templates/src/custom_filters.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
use std::{
fmt::{Debug, Display},
path::Path,
sync::{Arc, RwLock},
};

use anyhow::Context;
use liquid_core::{Filter, ParseFilter, Runtime, ValueView};
use wasmtime::{Engine, Linker, Module, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder};

wit_bindgen_wasmtime::import!({paths: ["./wit/custom-filter.wit"]});

struct CustomFilterContext {
wasi: WasiCtx,
data: custom_filter::CustomFilterData,
}

impl CustomFilterContext {
fn new() -> Self {
Self {
wasi: WasiCtxBuilder::new().build(),
data: custom_filter::CustomFilterData {},
}
}
}

#[derive(Clone)]
pub(crate) struct CustomFilterParser {
name: String,
wasm_store: Arc<RwLock<Store<CustomFilterContext>>>,
exec: Arc<custom_filter::CustomFilter<CustomFilterContext>>,
}

impl CustomFilterParser {
pub(crate) fn load(name: &str, wasm_path: &Path) -> anyhow::Result<Self> {
let wasm = std::fs::read(&wasm_path).with_context(|| {
format!("Failed loading custom filter from {}", wasm_path.display())
})?;

let ctx = CustomFilterContext::new();
let engine = Engine::default();
let mut store = Store::new(&engine, ctx);
let mut linker = Linker::new(&engine);
wasmtime_wasi::add_to_linker(&mut linker, |ctx: &mut CustomFilterContext| &mut ctx.wasi)
.with_context(|| format!("Setting up WASI for custom filter {}", name))?;
let module = Module::new(&engine, &wasm)
.with_context(|| format!("Creating Wasm module for custom filter {}", name))?;
let instance = linker
.instantiate(&mut store, &module)
.with_context(|| format!("Instantiating Wasm module for custom filter {}", name))?;
let filter_exec =
custom_filter::CustomFilter::new(&mut store, &instance, |ctx| &mut ctx.data)
.with_context(|| format!("Loading Wasm executor for custom filer {}", name))?;

Ok(Self {
name: name.to_owned(),
wasm_store: Arc::new(RwLock::new(store)),
exec: Arc::new(filter_exec),
})
}
}

impl Debug for CustomFilterParser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CustomFilterParser")
.field("name", &self.name)
.finish()
}
}

impl ParseFilter for CustomFilterParser {
fn parse(
&self,
_arguments: liquid_core::parser::FilterArguments,
) -> liquid_core::Result<Box<dyn Filter>> {
Ok(Box::new(CustomFilter {
name: self.name.to_owned(),
wasm_store: self.wasm_store.clone(),
exec: self.exec.clone(),
}))
}

fn reflection(&self) -> &dyn liquid_core::FilterReflection {
self
}
}

const EMPTY: [liquid_core::parser::ParameterReflection; 0] = [];

impl liquid_core::FilterReflection for CustomFilterParser {
fn name(&self) -> &str {
&self.name
}

fn description(&self) -> &str {
""
}

fn positional_parameters(&self) -> &'static [liquid_core::parser::ParameterReflection] {
&EMPTY
}

fn keyword_parameters(&self) -> &'static [liquid_core::parser::ParameterReflection] {
&EMPTY
}
}

struct CustomFilter {
name: String,
wasm_store: Arc<RwLock<Store<CustomFilterContext>>>,
exec: Arc<custom_filter::CustomFilter<CustomFilterContext>>,
}

impl Debug for CustomFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CustomFilter")
.field("name", &self.name)
.finish()
}
}

impl Display for CustomFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.name)
}
}

impl Filter for CustomFilter {
fn evaluate(
&self,
input: &dyn ValueView,
_runtime: &dyn Runtime,
) -> Result<liquid::model::Value, liquid_core::error::Error> {
let mut store = self
.wasm_store
.write()
.map_err(|e| liquid_err(format!("Failed to get custom filter Wasm store: {}", e)))?;
let input_str = self.liquid_value_as_string(input)?;
match self.exec.exec(&mut *store, &input_str) {
Ok(Ok(text)) => Ok(to_liquid_value(text)),
Ok(Err(s)) => Err(liquid_err(s)),
Err(trap) => Err(liquid_err(format!("{:?}", trap))),
}
}
}

impl CustomFilter {
fn liquid_value_as_string(&self, input: &dyn ValueView) -> Result<String, liquid::Error> {
let str = input.as_scalar().map(|s| s.into_cow_str()).ok_or_else(|| {
liquid_err(format!(
"Filter '{}': no input or input is not a string",
self.name
))
})?;
Ok(str.to_string())
}
}

fn to_liquid_value(value: String) -> liquid::model::Value {
liquid::model::Value::Scalar(liquid::model::Scalar::from(value))
}

fn liquid_err(text: String) -> liquid_core::error::Error {
liquid_core::error::Error::with_msg(text)
}
1 change: 1 addition & 0 deletions crates/templates/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#![deny(missing_docs)]

mod constraints;
mod custom_filters;
mod directory;
mod environment;
mod filters;
Expand Down
50 changes: 50 additions & 0 deletions crates/templates/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,11 @@ mod tests {
PathBuf::from(crate_dir).join("..").join("..")
}

fn test_data_root() -> PathBuf {
let crate_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(crate_dir).join("tests")
}

const TPLS_IN_THIS: usize = 8;

#[tokio::test]
Expand Down Expand Up @@ -687,4 +692,49 @@ mod tests {
.unwrap();
assert!(spin_toml.contains("route = \"/...\""));
}

#[tokio::test]
async fn can_use_custom_filter_in_template() {
let temp_dir = tempdir().unwrap();
let store = TemplateStore::new(temp_dir.path());
let manager = TemplateManager { store };
let source = TemplateSource::File(test_data_root());

manager
.install(&source, &InstallOptions::default(), &DiscardingReporter)
.await
.unwrap();

let template = manager.get("testing-custom-filter").unwrap().unwrap();

let dest_temp_dir = tempdir().unwrap();
let output_dir = dest_temp_dir.path().join("myproj");
let values = [
("p1".to_owned(), "biscuits".to_owned()),
("p2".to_owned(), "nomnomnom".to_owned()),
]
.into_iter()
.collect();
let options = RunOptions {
output_path: output_dir.clone(),
name: "custom-filter-test".to_owned(),
values,
accept_defaults: false,
};

template
.run(options)
.silent()
.await
.execute()
.await
.unwrap();

let message = tokio::fs::read_to_string(output_dir.join("test.txt"))
.await
.unwrap();
assert!(message.contains("p1/studly = bIsCuItS"));
assert!(message.contains("p2/studly = nOmNoMnOm"));
assert!(message.contains("p1/clappy = b👏i👏s👏c👏u👏i👏t👏s"));
}
}
8 changes: 8 additions & 0 deletions crates/templates/src/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub(crate) struct RawTemplateManifestV1 {
pub id: String,
pub description: Option<String>,
pub parameters: Option<IndexMap<String, RawParameter>>,
pub custom_filters: Option<Vec<RawCustomFilter>>,
}

#[derive(Debug, Deserialize)]
Expand All @@ -29,6 +30,13 @@ pub(crate) struct RawParameter {
pub pattern: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub(crate) struct RawCustomFilter {
pub name: String,
pub wasm: String,
}

pub(crate) fn parse_manifest_toml(text: impl AsRef<str>) -> anyhow::Result<RawTemplateManifest> {
toml::from_str(text.as_ref()).context("Failed to parse template manifest TOML")
}
16 changes: 10 additions & 6 deletions crates/templates/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ impl Run {
let template_content_files = Self::collect_all_content(&from)?;
// TODO: okay we do want to do *some* parsing here because we don't want
// to prompt if the template bodies are garbage
let template_contents = Self::read_all(template_content_files)?;
let template_contents = self.read_all(template_content_files)?;
Self::to_output_paths(&from, to, template_contents)
}
};
Expand Down Expand Up @@ -225,8 +225,8 @@ impl Run {
}

// TODO: async when we know where things sit
fn read_all(paths: Vec<PathBuf>) -> anyhow::Result<Vec<(PathBuf, TemplateContent)>> {
let template_parser = Self::template_parser();
fn read_all(&self, paths: Vec<PathBuf>) -> anyhow::Result<Vec<(PathBuf, TemplateContent)>> {
let template_parser = self.template_parser();
let contents = paths
.iter()
.map(std::fs::read)
Expand Down Expand Up @@ -329,11 +329,15 @@ impl Run {
pathdiff::diff_paths(source, src_dir).map(|rel| (dest_dir.join(rel), cont))
}

fn template_parser() -> liquid::Parser {
liquid::ParserBuilder::with_stdlib()
fn template_parser(&self) -> liquid::Parser {
let mut builder = liquid::ParserBuilder::with_stdlib()
.filter(crate::filters::KebabCaseFilterParser)
.filter(crate::filters::PascalCaseFilterParser)
.filter(crate::filters::SnakeCaseFilterParser)
.filter(crate::filters::SnakeCaseFilterParser);
for filter in self.template.custom_filters() {
builder = builder.filter(filter);
}
builder
.build()
.expect("can't fail due to no partials support")
}
Expand Down
9 changes: 9 additions & 0 deletions crates/templates/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ pub(crate) struct TemplateLayout {
}

const METADATA_DIR_NAME: &str = "metadata";
const FILTERS_DIR_NAME: &str = "filters";
const CONTENT_DIR_NAME: &str = "content";

const MANIFEST_FILE_NAME: &str = "spin-template.toml";
Expand All @@ -86,6 +87,14 @@ impl TemplateLayout {
self.template_dir.join(METADATA_DIR_NAME)
}

pub fn filters_dir(&self) -> PathBuf {
self.metadata_dir().join(FILTERS_DIR_NAME)
}

pub fn filter_path(&self, filename: &str) -> PathBuf {
self.filters_dir().join(filename)
}

pub fn manifest_path(&self) -> PathBuf {
self.metadata_dir().join(MANIFEST_FILE_NAME)
}
Expand Down
Loading

0 comments on commit 186d254

Please sign in to comment.