Skip to content

Commit 186d254

Browse files
itowlsonetehtsea
authored andcommitted
Templates can define custom Liquid filters
Signed-off-by: itowlson <ivan.towlson@fermyon.com>
1 parent 13f7916 commit 186d254

File tree

18 files changed

+363
-7
lines changed

18 files changed

+363
-7
lines changed

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/templates/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,10 @@ tokio = { version = "1.10", features = [ "fs", "process", "rt", "macros" ] }
3535
toml = "0.5"
3636
url = "2.2.2"
3737
walkdir = "2"
38+
wasmtime = { version = "0.39.1", features = [ "async" ] }
39+
wasmtime-wasi = "0.39.1"
40+
41+
[dependencies.wit-bindgen-wasmtime]
42+
git = "https://github.com/bytecodealliance/wit-bindgen"
43+
rev = "cb871cfa1ee460b51eb1d144b175b9aab9c50aba"
44+
features = ["async"]
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
use std::{
2+
fmt::{Debug, Display},
3+
path::Path,
4+
sync::{Arc, RwLock},
5+
};
6+
7+
use anyhow::Context;
8+
use liquid_core::{Filter, ParseFilter, Runtime, ValueView};
9+
use wasmtime::{Engine, Linker, Module, Store};
10+
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder};
11+
12+
wit_bindgen_wasmtime::import!({paths: ["./wit/custom-filter.wit"]});
13+
14+
struct CustomFilterContext {
15+
wasi: WasiCtx,
16+
data: custom_filter::CustomFilterData,
17+
}
18+
19+
impl CustomFilterContext {
20+
fn new() -> Self {
21+
Self {
22+
wasi: WasiCtxBuilder::new().build(),
23+
data: custom_filter::CustomFilterData {},
24+
}
25+
}
26+
}
27+
28+
#[derive(Clone)]
29+
pub(crate) struct CustomFilterParser {
30+
name: String,
31+
wasm_store: Arc<RwLock<Store<CustomFilterContext>>>,
32+
exec: Arc<custom_filter::CustomFilter<CustomFilterContext>>,
33+
}
34+
35+
impl CustomFilterParser {
36+
pub(crate) fn load(name: &str, wasm_path: &Path) -> anyhow::Result<Self> {
37+
let wasm = std::fs::read(&wasm_path).with_context(|| {
38+
format!("Failed loading custom filter from {}", wasm_path.display())
39+
})?;
40+
41+
let ctx = CustomFilterContext::new();
42+
let engine = Engine::default();
43+
let mut store = Store::new(&engine, ctx);
44+
let mut linker = Linker::new(&engine);
45+
wasmtime_wasi::add_to_linker(&mut linker, |ctx: &mut CustomFilterContext| &mut ctx.wasi)
46+
.with_context(|| format!("Setting up WASI for custom filter {}", name))?;
47+
let module = Module::new(&engine, &wasm)
48+
.with_context(|| format!("Creating Wasm module for custom filter {}", name))?;
49+
let instance = linker
50+
.instantiate(&mut store, &module)
51+
.with_context(|| format!("Instantiating Wasm module for custom filter {}", name))?;
52+
let filter_exec =
53+
custom_filter::CustomFilter::new(&mut store, &instance, |ctx| &mut ctx.data)
54+
.with_context(|| format!("Loading Wasm executor for custom filer {}", name))?;
55+
56+
Ok(Self {
57+
name: name.to_owned(),
58+
wasm_store: Arc::new(RwLock::new(store)),
59+
exec: Arc::new(filter_exec),
60+
})
61+
}
62+
}
63+
64+
impl Debug for CustomFilterParser {
65+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66+
f.debug_struct("CustomFilterParser")
67+
.field("name", &self.name)
68+
.finish()
69+
}
70+
}
71+
72+
impl ParseFilter for CustomFilterParser {
73+
fn parse(
74+
&self,
75+
_arguments: liquid_core::parser::FilterArguments,
76+
) -> liquid_core::Result<Box<dyn Filter>> {
77+
Ok(Box::new(CustomFilter {
78+
name: self.name.to_owned(),
79+
wasm_store: self.wasm_store.clone(),
80+
exec: self.exec.clone(),
81+
}))
82+
}
83+
84+
fn reflection(&self) -> &dyn liquid_core::FilterReflection {
85+
self
86+
}
87+
}
88+
89+
const EMPTY: [liquid_core::parser::ParameterReflection; 0] = [];
90+
91+
impl liquid_core::FilterReflection for CustomFilterParser {
92+
fn name(&self) -> &str {
93+
&self.name
94+
}
95+
96+
fn description(&self) -> &str {
97+
""
98+
}
99+
100+
fn positional_parameters(&self) -> &'static [liquid_core::parser::ParameterReflection] {
101+
&EMPTY
102+
}
103+
104+
fn keyword_parameters(&self) -> &'static [liquid_core::parser::ParameterReflection] {
105+
&EMPTY
106+
}
107+
}
108+
109+
struct CustomFilter {
110+
name: String,
111+
wasm_store: Arc<RwLock<Store<CustomFilterContext>>>,
112+
exec: Arc<custom_filter::CustomFilter<CustomFilterContext>>,
113+
}
114+
115+
impl Debug for CustomFilter {
116+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117+
f.debug_struct("CustomFilter")
118+
.field("name", &self.name)
119+
.finish()
120+
}
121+
}
122+
123+
impl Display for CustomFilter {
124+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125+
f.write_str(&self.name)
126+
}
127+
}
128+
129+
impl Filter for CustomFilter {
130+
fn evaluate(
131+
&self,
132+
input: &dyn ValueView,
133+
_runtime: &dyn Runtime,
134+
) -> Result<liquid::model::Value, liquid_core::error::Error> {
135+
let mut store = self
136+
.wasm_store
137+
.write()
138+
.map_err(|e| liquid_err(format!("Failed to get custom filter Wasm store: {}", e)))?;
139+
let input_str = self.liquid_value_as_string(input)?;
140+
match self.exec.exec(&mut *store, &input_str) {
141+
Ok(Ok(text)) => Ok(to_liquid_value(text)),
142+
Ok(Err(s)) => Err(liquid_err(s)),
143+
Err(trap) => Err(liquid_err(format!("{:?}", trap))),
144+
}
145+
}
146+
}
147+
148+
impl CustomFilter {
149+
fn liquid_value_as_string(&self, input: &dyn ValueView) -> Result<String, liquid::Error> {
150+
let str = input.as_scalar().map(|s| s.into_cow_str()).ok_or_else(|| {
151+
liquid_err(format!(
152+
"Filter '{}': no input or input is not a string",
153+
self.name
154+
))
155+
})?;
156+
Ok(str.to_string())
157+
}
158+
}
159+
160+
fn to_liquid_value(value: String) -> liquid::model::Value {
161+
liquid::model::Value::Scalar(liquid::model::Scalar::from(value))
162+
}
163+
164+
fn liquid_err(text: String) -> liquid_core::error::Error {
165+
liquid_core::error::Error::with_msg(text)
166+
}

crates/templates/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#![deny(missing_docs)]
44

55
mod constraints;
6+
mod custom_filters;
67
mod directory;
78
mod environment;
89
mod filters;

crates/templates/src/manager.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,11 @@ mod tests {
409409
PathBuf::from(crate_dir).join("..").join("..")
410410
}
411411

412+
fn test_data_root() -> PathBuf {
413+
let crate_dir = env!("CARGO_MANIFEST_DIR");
414+
PathBuf::from(crate_dir).join("tests")
415+
}
416+
412417
const TPLS_IN_THIS: usize = 8;
413418

414419
#[tokio::test]
@@ -687,4 +692,49 @@ mod tests {
687692
.unwrap();
688693
assert!(spin_toml.contains("route = \"/...\""));
689694
}
695+
696+
#[tokio::test]
697+
async fn can_use_custom_filter_in_template() {
698+
let temp_dir = tempdir().unwrap();
699+
let store = TemplateStore::new(temp_dir.path());
700+
let manager = TemplateManager { store };
701+
let source = TemplateSource::File(test_data_root());
702+
703+
manager
704+
.install(&source, &InstallOptions::default(), &DiscardingReporter)
705+
.await
706+
.unwrap();
707+
708+
let template = manager.get("testing-custom-filter").unwrap().unwrap();
709+
710+
let dest_temp_dir = tempdir().unwrap();
711+
let output_dir = dest_temp_dir.path().join("myproj");
712+
let values = [
713+
("p1".to_owned(), "biscuits".to_owned()),
714+
("p2".to_owned(), "nomnomnom".to_owned()),
715+
]
716+
.into_iter()
717+
.collect();
718+
let options = RunOptions {
719+
output_path: output_dir.clone(),
720+
name: "custom-filter-test".to_owned(),
721+
values,
722+
accept_defaults: false,
723+
};
724+
725+
template
726+
.run(options)
727+
.silent()
728+
.await
729+
.execute()
730+
.await
731+
.unwrap();
732+
733+
let message = tokio::fs::read_to_string(output_dir.join("test.txt"))
734+
.await
735+
.unwrap();
736+
assert!(message.contains("p1/studly = bIsCuItS"));
737+
assert!(message.contains("p2/studly = nOmNoMnOm"));
738+
assert!(message.contains("p1/clappy = b👏i👏s👏c👏u👏i👏t👏s"));
739+
}
690740
}

crates/templates/src/reader.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub(crate) struct RawTemplateManifestV1 {
1616
pub id: String,
1717
pub description: Option<String>,
1818
pub parameters: Option<IndexMap<String, RawParameter>>,
19+
pub custom_filters: Option<Vec<RawCustomFilter>>,
1920
}
2021

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

33+
#[derive(Debug, Deserialize)]
34+
#[serde(deny_unknown_fields, rename_all = "snake_case")]
35+
pub(crate) struct RawCustomFilter {
36+
pub name: String,
37+
pub wasm: String,
38+
}
39+
3240
pub(crate) fn parse_manifest_toml(text: impl AsRef<str>) -> anyhow::Result<RawTemplateManifest> {
3341
toml::from_str(text.as_ref()).context("Failed to parse template manifest TOML")
3442
}

crates/templates/src/run.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ impl Run {
140140
let template_content_files = Self::collect_all_content(&from)?;
141141
// TODO: okay we do want to do *some* parsing here because we don't want
142142
// to prompt if the template bodies are garbage
143-
let template_contents = Self::read_all(template_content_files)?;
143+
let template_contents = self.read_all(template_content_files)?;
144144
Self::to_output_paths(&from, to, template_contents)
145145
}
146146
};
@@ -225,8 +225,8 @@ impl Run {
225225
}
226226

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

332-
fn template_parser() -> liquid::Parser {
333-
liquid::ParserBuilder::with_stdlib()
332+
fn template_parser(&self) -> liquid::Parser {
333+
let mut builder = liquid::ParserBuilder::with_stdlib()
334334
.filter(crate::filters::KebabCaseFilterParser)
335335
.filter(crate::filters::PascalCaseFilterParser)
336-
.filter(crate::filters::SnakeCaseFilterParser)
336+
.filter(crate::filters::SnakeCaseFilterParser);
337+
for filter in self.template.custom_filters() {
338+
builder = builder.filter(filter);
339+
}
340+
builder
337341
.build()
338342
.expect("can't fail due to no partials support")
339343
}

crates/templates/src/store.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ pub(crate) struct TemplateLayout {
7171
}
7272

7373
const METADATA_DIR_NAME: &str = "metadata";
74+
const FILTERS_DIR_NAME: &str = "filters";
7475
const CONTENT_DIR_NAME: &str = "content";
7576

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

90+
pub fn filters_dir(&self) -> PathBuf {
91+
self.metadata_dir().join(FILTERS_DIR_NAME)
92+
}
93+
94+
pub fn filter_path(&self, filename: &str) -> PathBuf {
95+
self.filters_dir().join(filename)
96+
}
97+
8998
pub fn manifest_path(&self) -> PathBuf {
9099
self.metadata_dir().join(MANIFEST_FILE_NAME)
91100
}

0 commit comments

Comments
 (0)