Mustache: Add util functions to render JSON and join array values

This pull request adds two util functions to the Mustache templating engine:
- {{#toJson}}my_map{{/toJson}} to render a Map parameter as a JSON string
- {{#join}}my_iterable{{/join}} to render any iterable (including arrays) as a comma separated list of values like `1, 2, 3`. It's also possible de change the default delimiter (comma) to something else.

closes #18970
This commit is contained in:
Tanguy Leroux 2016-06-14 15:14:54 +02:00
parent b97ea9954c
commit 4820d49120
7 changed files with 635 additions and 116 deletions

View File

@ -89,6 +89,89 @@ which is rendered as:
}
------------------------------------------
[float]
===== Concatenating array of values
The `{{#join}}array{{/join}}` function can be used to concatenate the
values of an array as a comma delimited string:
[source,js]
------------------------------------------
GET /_search/template
{
"inline": {
"query": {
"match": {
"emails": "{{#join}}emails{{/join}}"
}
}
},
"params": {
"emails": [ "username@email.com", "lastname@email.com" ]
}
}
------------------------------------------
which is rendered as:
[source,js]
------------------------------------------
{
"query" : {
"match" : {
"emails" : "username@email.com,lastname@email.com"
}
}
}
------------------------------------------
The function also accepts a custom delimiter:
[source,js]
------------------------------------------
GET /_search/template
{
"inline": {
"query": {
"range": {
"born": {
"gte" : "{{date.min}}",
"lte" : "{{date.max}}",
"format": "{{#join delimiter='||'}}date.formats{{/join delimiter='||'}}"
}
}
}
},
"params": {
"date": {
"min": "2016",
"max": "31/12/2017",
"formats": ["dd/MM/yyyy", "yyyy"]
}
}
}
------------------------------------------
which is rendered as:
[source,js]
------------------------------------------
{
"query" : {
"range" : {
"born" : {
"gte" : "2016",
"lte" : "31/12/2017",
"format" : "dd/MM/yyyy||yyyy"
}
}
}
}
------------------------------------------
[float]
===== Default values
@ -140,6 +223,46 @@ for `end`:
}
------------------------------------------
[float]
===== Converting parameters to JSON
The `{{toJson}}parameter{{/toJson}}` function can be used to convert parameters
like maps and array to their JSON representation:
[source,js]
------------------------------------------
{
"inline": "{\"query\":{\"bool\":{\"must\": {{#toJson}}clauses{{/toJson}} }}}",
"params": {
"clauses": [
{ "term": "foo" },
{ "term": "bar" }
]
}
}
------------------------------------------
which is rendered as:
[source,js]
------------------------------------------
{
"query" : {
"bool" : {
"must" : [
{
"term" : "foo"
},
{
"term" : "bar"
}
]
}
}
}
------------------------------------------
[float]
===== Conditional clauses

View File

@ -0,0 +1,279 @@
/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.elasticsearch.script.mustache;
import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.github.mustachejava.Code;
import com.github.mustachejava.DefaultMustacheFactory;
import com.github.mustachejava.DefaultMustacheVisitor;
import com.github.mustachejava.Mustache;
import com.github.mustachejava.MustacheException;
import com.github.mustachejava.MustacheVisitor;
import com.github.mustachejava.TemplateContext;
import com.github.mustachejava.codes.IterableCode;
import com.github.mustachejava.codes.WriteCode;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentType;
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class CustomMustacheFactory extends DefaultMustacheFactory {
private final BiConsumer<String, Writer> encoder;
public CustomMustacheFactory(boolean escaping) {
super();
setObjectHandler(new CustomReflectionObjectHandler());
if (escaping) {
this.encoder = new JsonEscapeEncoder();
} else {
this.encoder = new NoEscapeEncoder();
}
}
@Override
public void encode(String value, Writer writer) {
encoder.accept(value, writer);
}
@Override
public MustacheVisitor createMustacheVisitor() {
return new CustomMustacheVisitor(this);
}
class CustomMustacheVisitor extends DefaultMustacheVisitor {
public CustomMustacheVisitor(DefaultMustacheFactory df) {
super(df);
}
@Override
public void iterable(TemplateContext templateContext, String variable, Mustache mustache) {
if (ToJsonCode.match(variable)) {
list.add(new ToJsonCode(templateContext, df, mustache, variable));
} else if (JoinerCode.match(variable)) {
list.add(new JoinerCode(templateContext, df, mustache));
} else if (CustomJoinerCode.match(variable)) {
list.add(new CustomJoinerCode(templateContext, df, mustache, variable));
} else {
list.add(new IterableCode(templateContext, df, mustache, variable));
}
}
}
/**
* Base class for custom Mustache functions
*/
static abstract class CustomCode extends IterableCode {
private final String code;
public CustomCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String code) {
super(tc, df, mustache, extractVariableName(code, mustache, tc));
this.code = Objects.requireNonNull(code);
}
@Override
public Writer execute(Writer writer, final List<Object> scopes) {
Object resolved = get(scopes);
writer = handle(writer, createFunction(resolved), scopes);
appendText(writer);
return writer;
}
@Override
protected void tag(Writer writer, String tag) throws IOException {
writer.write(tc.startChars());
writer.write(tag);
writer.write(code);
writer.write(tc.endChars());
}
protected abstract Function<String, String> createFunction(Object resolved);
/**
* At compile time, this function extracts the name of the variable:
* {{#toJson}}variable_name{{/toJson}}
*/
protected static String extractVariableName(String fn, Mustache mustache, TemplateContext tc) {
Code[] codes = mustache.getCodes();
if (codes == null || codes.length != 1) {
throw new MustacheException("Mustache function [" + fn + "] must contain one and only one identifier");
}
try (StringWriter capture = new StringWriter()) {
// Variable name is in plain text and has type WriteCode
if (codes[0] instanceof WriteCode) {
codes[0].execute(capture, Collections.emptyList());
return capture.toString();
} else {
codes[0].identity(capture);
return capture.toString();
}
} catch (IOException e) {
throw new MustacheException("Exception while parsing mustache function [" + fn + "] at line " + tc.line(), e);
}
}
}
/**
* This function renders {@link Iterable} and {@link Map} as their JSON representation
*/
static class ToJsonCode extends CustomCode {
private static final String CODE = "toJson";
public ToJsonCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable) {
super(tc, df, mustache, CODE);
if (CODE.equalsIgnoreCase(variable) == false) {
throw new MustacheException("Mismatch function code [" + CODE + "] cannot be applied to [" + variable + "]");
}
}
@Override
@SuppressWarnings("unchecked")
protected Function<String, String> createFunction(Object resolved) {
return s -> {
if (resolved == null) {
return null;
}
try (XContentBuilder builder = XContentBuilder.builder(XContentType.JSON.xContent())) {
if (resolved == null) {
builder.nullValue();
} else if (resolved instanceof Iterable) {
builder.startArray();
for (Object o : (Iterable) resolved) {
builder.value(o);
}
builder.endArray();
} else if (resolved instanceof Map) {
builder.map((Map<String, ?>) resolved);
} else {
// Do not handle as JSON
return oh.stringify(resolved);
}
return builder.string();
} catch (IOException e) {
throw new MustacheException("Failed to convert object to JSON", e);
}
};
}
static boolean match(String variable) {
return CODE.equalsIgnoreCase(variable);
}
}
/**
* This function concatenates the values of an {@link Iterable} using a given delimiter
*/
static class JoinerCode extends CustomCode {
protected static final String CODE = "join";
private static final String DEFAULT_DELIMITER = ",";
private final String delimiter;
public JoinerCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String delimiter) {
super(tc, df, mustache, CODE);
this.delimiter = delimiter;
}
public JoinerCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache) {
this(tc, df, mustache, DEFAULT_DELIMITER);
}
@Override
protected Function<String, String> createFunction(Object resolved) {
return s -> {
if (s == null) {
return null;
} else if (resolved instanceof Iterable) {
StringJoiner joiner = new StringJoiner(delimiter);
for (Object o : (Iterable) resolved) {
joiner.add(oh.stringify(o));
}
return joiner.toString();
}
return s;
};
}
static boolean match(String variable) {
return CODE.equalsIgnoreCase(variable);
}
}
static class CustomJoinerCode extends JoinerCode {
private static final Pattern PATTERN = Pattern.compile("^(?:" + CODE + " delimiter='(.*)')$");
public CustomJoinerCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable) {
super(tc, df, mustache, extractDelimiter(variable));
}
private static String extractDelimiter(String variable) {
Matcher matcher = PATTERN.matcher(variable);
if (matcher.find()) {
return matcher.group(1);
}
throw new MustacheException("Failed to extract delimiter for join function");
}
static boolean match(String variable) {
return PATTERN.matcher(variable).matches();
}
}
class NoEscapeEncoder implements BiConsumer<String, Writer> {
@Override
public void accept(String s, Writer writer) {
try {
writer.write(s);
} catch (IOException e) {
throw new MustacheException("Failed to encode value: " + s);
}
}
}
class JsonEscapeEncoder implements BiConsumer<String, Writer> {
@Override
public void accept(String s, Writer writer) {
try {
writer.write(JsonStringEncoder.getInstance().quoteAsString(s));
} catch (IOException e) {
throw new MustacheException("Failed to escape and encode value: " + s);
}
}
}
}

View File

@ -1,41 +0,0 @@
/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.elasticsearch.script.mustache;
import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.github.mustachejava.DefaultMustacheFactory;
import com.github.mustachejava.MustacheException;
import java.io.IOException;
import java.io.Writer;
/**
* A MustacheFactory that does simple JSON escaping.
*/
final class JsonEscapingMustacheFactory extends DefaultMustacheFactory {
@Override
public void encode(String value, Writer writer) {
try {
writer.write(JsonStringEncoder.getInstance().quoteAsString(value));
} catch (IOException e) {
throw new MustacheException("Failed to encode value: " + value);
}
}
}

View File

@ -18,8 +18,8 @@
*/
package org.elasticsearch.script.mustache;
import com.github.mustachejava.DefaultMustacheFactory;
import com.github.mustachejava.Mustache;
import com.github.mustachejava.MustacheFactory;
import org.elasticsearch.SpecialPermission;
import org.elasticsearch.common.Nullable;
import org.elasticsearch.common.component.AbstractComponent;
@ -29,8 +29,8 @@ import org.elasticsearch.common.io.stream.BytesStreamOutput;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.script.CompiledScript;
import org.elasticsearch.script.ExecutableScript;
import org.elasticsearch.script.ScriptEngineService;
import org.elasticsearch.script.GeneralScriptException;
import org.elasticsearch.script.ScriptEngineService;
import org.elasticsearch.script.SearchScript;
import org.elasticsearch.search.lookup.SearchLookup;
@ -89,21 +89,13 @@ public final class MustacheScriptEngineService extends AbstractComponent impleme
* */
@Override
public Object compile(String templateName, String templateSource, Map<String, String> params) {
String contentType = params.getOrDefault(CONTENT_TYPE_PARAM, JSON_CONTENT_TYPE);
final DefaultMustacheFactory mustacheFactory;
switch (contentType){
case PLAIN_TEXT_CONTENT_TYPE:
mustacheFactory = new NoneEscapingMustacheFactory();
break;
case JSON_CONTENT_TYPE:
default:
// assume that the default is json encoding:
mustacheFactory = new JsonEscapingMustacheFactory();
break;
}
mustacheFactory.setObjectHandler(new CustomReflectionObjectHandler());
final MustacheFactory factory = new CustomMustacheFactory(isJsonEscapingEnabled(params));
Reader reader = new FastStringReader(templateSource);
return mustacheFactory.compile(reader, "query-template");
return factory.compile(reader, "query-template");
}
private boolean isJsonEscapingEnabled(Map<String, String> params) {
return JSON_CONTENT_TYPE.equals(params.getOrDefault(CONTENT_TYPE_PARAM, JSON_CONTENT_TYPE));
}
@Override
@ -168,12 +160,9 @@ public final class MustacheScriptEngineService extends AbstractComponent impleme
if (sm != null) {
sm.checkPermission(SPECIAL_PERMISSION);
}
AccessController.doPrivileged(new PrivilegedAction<Void>() {
@Override
public Void run() {
((Mustache) template.compiled()).execute(writer, vars);
return null;
}
AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
((Mustache) template.compiled()).execute(writer, vars);
return null;
});
} catch (Exception e) {
logger.error("Error running {}", e, template);

View File

@ -1,40 +0,0 @@
/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.elasticsearch.script.mustache;
import com.github.mustachejava.DefaultMustacheFactory;
import com.github.mustachejava.MustacheException;
import java.io.IOException;
import java.io.Writer;
/**
* A MustacheFactory that does no string escaping.
*/
final class NoneEscapingMustacheFactory extends DefaultMustacheFactory {
@Override
public void encode(String value, Writer writer) {
try {
writer.write(value);
} catch (IOException e) {
throw new MustacheException("Failed to encode value: " + value);
}
}
}

View File

@ -18,6 +18,7 @@
*/
package org.elasticsearch.script.mustache;
import com.github.mustachejava.MustacheFactory;
import org.elasticsearch.common.bytes.BytesReference;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.script.CompiledScript;
@ -39,12 +40,12 @@ import static org.hamcrest.Matchers.equalTo;
*/
public class MustacheScriptEngineTests extends ESTestCase {
private MustacheScriptEngineService qe;
private JsonEscapingMustacheFactory escaper;
private MustacheFactory factory;
@Before
public void setup() {
qe = new MustacheScriptEngineService(Settings.Builder.EMPTY_SETTINGS);
escaper = new JsonEscapingMustacheFactory();
factory = new CustomMustacheFactory(true);
}
public void testSimpleParameterReplace() {
@ -75,12 +76,12 @@ public class MustacheScriptEngineTests extends ESTestCase {
public void testEscapeJson() throws IOException {
{
StringWriter writer = new StringWriter();
escaper.encode("hello \n world", writer);
factory.encode("hello \n world", writer);
assertThat(writer.toString(), equalTo("hello \\n world"));
}
{
StringWriter writer = new StringWriter();
escaper.encode("\n", writer);
factory.encode("\n", writer);
assertThat(writer.toString(), equalTo("\\n"));
}
@ -135,7 +136,7 @@ public class MustacheScriptEngineTests extends ESTestCase {
expect.append(escapedChars[charIndex]);
}
StringWriter target = new StringWriter();
escaper.encode(writer.toString(), target);
factory.encode(writer.toString(), target);
assertThat(expect.toString(), equalTo(target.toString()));
}
}

View File

@ -19,13 +19,16 @@
package org.elasticsearch.script.mustache;
import com.github.mustachejava.Mustache;
import com.github.mustachejava.MustacheException;
import org.elasticsearch.common.bytes.BytesReference;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentHelper;
import org.elasticsearch.script.CompiledScript;
import org.elasticsearch.script.ExecutableScript;
import org.elasticsearch.script.ScriptEngineService;
import org.elasticsearch.script.ScriptService;
import org.elasticsearch.test.ESTestCase;
import org.hamcrest.Matcher;
import java.util.Arrays;
import java.util.Collections;
@ -38,6 +41,8 @@ import java.util.Set;
import static java.util.Collections.singleton;
import static java.util.Collections.singletonMap;
import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
import static org.elasticsearch.script.ScriptService.ScriptType.INLINE;
import static org.elasticsearch.script.mustache.MustacheScriptEngineService.CONTENT_TYPE_PARAM;
import static org.elasticsearch.script.mustache.MustacheScriptEngineService.JSON_CONTENT_TYPE;
import static org.elasticsearch.script.mustache.MustacheScriptEngineService.PLAIN_TEXT_CONTENT_TYPE;
@ -45,6 +50,8 @@ import static org.hamcrest.Matchers.both;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.isEmptyOrNullString;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.notNullValue;
public class MustacheTests extends ESTestCase {
@ -59,7 +66,7 @@ public class MustacheTests extends ESTestCase {
Map<String, Object> params = Collections.singletonMap("boost_val", "0.2");
Mustache mustache = (Mustache) engine.compile(null, template, Collections.emptyMap());
CompiledScript compiledScript = new CompiledScript(ScriptService.ScriptType.INLINE, "my-name", "mustache", mustache);
CompiledScript compiledScript = new CompiledScript(INLINE, "my-name", "mustache", mustache);
ExecutableScript result = engine.executable(compiledScript, params);
assertEquals(
"Mustache templating broken",
@ -71,7 +78,7 @@ public class MustacheTests extends ESTestCase {
public void testArrayAccess() throws Exception {
String template = "{{data.0}} {{data.1}}";
CompiledScript mustache = new CompiledScript(ScriptService.ScriptType.INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
CompiledScript mustache = new CompiledScript(INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
Map<String, Object> vars = new HashMap<>();
Object data = randomFrom(
new String[] { "foo", "bar" },
@ -97,7 +104,7 @@ public class MustacheTests extends ESTestCase {
public void testArrayInArrayAccess() throws Exception {
String template = "{{data.0.0}} {{data.0.1}}";
CompiledScript mustache = new CompiledScript(ScriptService.ScriptType.INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
CompiledScript mustache = new CompiledScript(INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
Map<String, Object> vars = new HashMap<>();
Object data = randomFrom(
new String[][] { new String[] { "foo", "bar" }},
@ -114,7 +121,7 @@ public class MustacheTests extends ESTestCase {
public void testMapInArrayAccess() throws Exception {
String template = "{{data.0.key}} {{data.1.key}}";
CompiledScript mustache = new CompiledScript(ScriptService.ScriptType.INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
CompiledScript mustache = new CompiledScript(INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
Map<String, Object> vars = new HashMap<>();
Object data = randomFrom(
new Object[] { singletonMap("key", "foo"), singletonMap("key", "bar") },
@ -142,7 +149,7 @@ public class MustacheTests extends ESTestCase {
// json string escaping enabled:
Map<String, String> params = randomBoolean() ? Collections.emptyMap() : Collections.singletonMap(CONTENT_TYPE_PARAM, JSON_CONTENT_TYPE);
Mustache mustache = (Mustache) engine.compile(null, "{ \"field1\": \"{{value}}\"}", Collections.emptyMap());
CompiledScript compiledScript = new CompiledScript(ScriptService.ScriptType.INLINE, "name", "mustache", mustache);
CompiledScript compiledScript = new CompiledScript(INLINE, "name", "mustache", mustache);
ExecutableScript executableScript = engine.executable(compiledScript, Collections.singletonMap("value", "a \"value\""));
BytesReference rawResult = (BytesReference) executableScript.run();
String result = rawResult.toUtf8();
@ -150,7 +157,7 @@ public class MustacheTests extends ESTestCase {
// json string escaping disabled:
mustache = (Mustache) engine.compile(null, "{ \"field1\": \"{{value}}\"}", Collections.singletonMap(CONTENT_TYPE_PARAM, PLAIN_TEXT_CONTENT_TYPE));
compiledScript = new CompiledScript(ScriptService.ScriptType.INLINE, "name", "mustache", mustache);
compiledScript = new CompiledScript(INLINE, "name", "mustache", mustache);
executableScript = engine.executable(compiledScript, Collections.singletonMap("value", "a \"value\""));
rawResult = (BytesReference) executableScript.run();
result = rawResult.toUtf8();
@ -162,7 +169,7 @@ public class MustacheTests extends ESTestCase {
List<String> randomList = Arrays.asList(generateRandomStringArray(10, 20, false));
String template = "{{data.array.size}} {{data.list.size}}";
CompiledScript mustache = new CompiledScript(ScriptService.ScriptType.INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
CompiledScript mustache = new CompiledScript(INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
Map<String, Object> data = new HashMap<>();
data.put("array", randomArrayValues);
data.put("list", randomList);
@ -177,4 +184,205 @@ public class MustacheTests extends ESTestCase {
String expectedString = String.format(Locale.ROOT, "%s %s", randomArrayValues.length, randomList.size());
assertThat(bytes.toUtf8(), equalTo(expectedString));
}
public void testPrimitiveToJSON() throws Exception {
String template = "{{#toJson}}ctx{{/toJson}}";
assertScript(template, Collections.singletonMap("ctx", "value"), equalTo("value"));
assertScript(template, Collections.singletonMap("ctx", ""), equalTo(""));
assertScript(template, Collections.singletonMap("ctx", true), equalTo("true"));
assertScript(template, Collections.singletonMap("ctx", 42), equalTo("42"));
assertScript(template, Collections.singletonMap("ctx", 42L), equalTo("42"));
assertScript(template, Collections.singletonMap("ctx", 42.5f), equalTo("42.5"));
assertScript(template, Collections.singletonMap("ctx", null), equalTo(""));
template = "{{#toJson}}.{{/toJson}}";
assertScript(template, Collections.singletonMap("ctx", "value"), equalTo("{\"ctx\":\"value\"}"));
assertScript(template, Collections.singletonMap("ctx", ""), equalTo("{\"ctx\":\"\"}"));
assertScript(template, Collections.singletonMap("ctx", true), equalTo("{\"ctx\":true}"));
assertScript(template, Collections.singletonMap("ctx", 42), equalTo("{\"ctx\":42}"));
assertScript(template, Collections.singletonMap("ctx", 42L), equalTo("{\"ctx\":42}"));
assertScript(template, Collections.singletonMap("ctx", 42.5f), equalTo("{\"ctx\":42.5}"));
assertScript(template, Collections.singletonMap("ctx", null), equalTo("{\"ctx\":null}"));
}
public void testSimpleMapToJSON() throws Exception {
Map<String, Object> human0 = new HashMap<>();
human0.put("age", 42);
human0.put("name", "John Smith");
human0.put("height", 1.84);
Map<String, Object> ctx = Collections.singletonMap("ctx", human0);
assertScript("{{#toJson}}.{{/toJson}}", ctx, equalTo("{\"ctx\":{\"name\":\"John Smith\",\"age\":42,\"height\":1.84}}"));
assertScript("{{#toJson}}ctx{{/toJson}}", ctx, equalTo("{\"name\":\"John Smith\",\"age\":42,\"height\":1.84}"));
assertScript("{{#toJson}}ctx.name{{/toJson}}", ctx, equalTo("John Smith"));
}
public void testMultipleMapsToJSON() throws Exception {
Map<String, Object> human0 = new HashMap<>();
human0.put("age", 42);
human0.put("name", "John Smith");
human0.put("height", 1.84);
Map<String, Object> human1 = new HashMap<>();
human1.put("age", 27);
human1.put("name", "Dave Smith");
human1.put("height", 1.71);
Map<String, Object> humans = new HashMap<>();
humans.put("first", human0);
humans.put("second", human1);
Map<String, Object> ctx = Collections.singletonMap("ctx", humans);
assertScript("{{#toJson}}.{{/toJson}}", ctx,
equalTo("{\"ctx\":{\"first\":{\"name\":\"John Smith\",\"age\":42,\"height\":1.84},\"second\":{\"name\":\"Dave Smith\",\"age\":27,\"height\":1.71}}}"));
assertScript("{{#toJson}}ctx{{/toJson}}", ctx,
equalTo("{\"first\":{\"name\":\"John Smith\",\"age\":42,\"height\":1.84},\"second\":{\"name\":\"Dave Smith\",\"age\":27,\"height\":1.71}}"));
assertScript("{{#toJson}}ctx.first{{/toJson}}", ctx,
equalTo("{\"name\":\"John Smith\",\"age\":42,\"height\":1.84}"));
assertScript("{{#toJson}}ctx.second{{/toJson}}", ctx,
equalTo("{\"name\":\"Dave Smith\",\"age\":27,\"height\":1.71}"));
}
public void testSimpleArrayToJSON() throws Exception {
String[] array = new String[]{"one", "two", "three"};
Map<String, Object> ctx = Collections.singletonMap("array", array);
assertScript("{{#toJson}}.{{/toJson}}", ctx, equalTo("{\"array\":[\"one\",\"two\",\"three\"]}"));
assertScript("{{#toJson}}array{{/toJson}}", ctx, equalTo("[\"one\",\"two\",\"three\"]"));
assertScript("{{#toJson}}array.0{{/toJson}}", ctx, equalTo("one"));
assertScript("{{#toJson}}array.1{{/toJson}}", ctx, equalTo("two"));
assertScript("{{#toJson}}array.2{{/toJson}}", ctx, equalTo("three"));
assertScript("{{#toJson}}array.size{{/toJson}}", ctx, equalTo("3"));
}
public void testSimpleListToJSON() throws Exception {
List<String> list = Arrays.asList("one", "two", "three");
Map<String, Object> ctx = Collections.singletonMap("ctx", list);
assertScript("{{#toJson}}.{{/toJson}}", ctx, equalTo("{\"ctx\":[\"one\",\"two\",\"three\"]}"));
assertScript("{{#toJson}}ctx{{/toJson}}", ctx, equalTo("[\"one\",\"two\",\"three\"]"));
assertScript("{{#toJson}}ctx.0{{/toJson}}", ctx, equalTo("one"));
assertScript("{{#toJson}}ctx.1{{/toJson}}", ctx, equalTo("two"));
assertScript("{{#toJson}}ctx.2{{/toJson}}", ctx, equalTo("three"));
assertScript("{{#toJson}}ctx.size{{/toJson}}", ctx, equalTo("3"));
}
public void testsUnsupportedTagsToJson() {
MustacheException e = expectThrows(MustacheException.class, () -> compile("{{#toJson}}{{foo}}{{bar}}{{/toJson}}"));
assertThat(e.getMessage(), containsString("Mustache function [toJson] must contain one and only one identifier"));
e = expectThrows(MustacheException.class, () -> compile("{{#toJson}}{{/toJson}}"));
assertThat(e.getMessage(), containsString("Mustache function [toJson] must contain one and only one identifier"));
}
public void testEmbeddedToJSON() throws Exception {
XContentBuilder builder = jsonBuilder().startObject()
.startArray("bulks")
.startObject()
.field("index", "index-1")
.field("type", "type-1")
.field("id", 1)
.endObject()
.startObject()
.field("index", "index-2")
.field("type", "type-2")
.field("id", 2)
.endObject()
.endArray()
.endObject();
Map<String, Object> ctx = Collections.singletonMap("ctx", XContentHelper.convertToMap(builder.bytes(), false).v2());
assertScript("{{#ctx.bulks}}{{#toJson}}.{{/toJson}}{{/ctx.bulks}}", ctx,
equalTo("{\"index\":\"index-1\",\"id\":1,\"type\":\"type-1\"}{\"index\":\"index-2\",\"id\":2,\"type\":\"type-2\"}"));
assertScript("{{#ctx.bulks}}<{{#toJson}}id{{/toJson}}>{{/ctx.bulks}}", ctx,
equalTo("<1><2>"));
}
public void testSimpleArrayJoin() throws Exception {
String template = "{{#join}}array{{/join}}";
assertScript(template, Collections.singletonMap("array", new String[]{"one", "two", "three"}), equalTo("one,two,three"));
assertScript(template, Collections.singletonMap("array", new int[]{1, 2, 3}), equalTo("1,2,3"));
assertScript(template, Collections.singletonMap("array", new long[]{1L, 2L, 3L}), equalTo("1,2,3"));
assertScript(template, Collections.singletonMap("array", new double[]{1.5, 2.5, 3.5}), equalTo("1.5,2.5,3.5"));
assertScript(template, Collections.singletonMap("array", new boolean[]{true, false, true}), equalTo("true,false,true"));
assertScript(template, Collections.singletonMap("array", new boolean[]{true, false, true}), equalTo("true,false,true"));
}
public void testEmbeddedArrayJoin() throws Exception {
XContentBuilder builder = jsonBuilder().startObject()
.startArray("people")
.startObject()
.field("name", "John Smith")
.startArray("emails")
.value("john@smith.com")
.value("john.smith@email.com")
.value("jsmith@email.com")
.endArray()
.endObject()
.startObject()
.field("name", "John Doe")
.startArray("emails")
.value("john@doe.com")
.value("john.doe@email.com")
.value("jdoe@email.com")
.endArray()
.endObject()
.endArray()
.endObject();
Map<String, Object> ctx = Collections.singletonMap("ctx", XContentHelper.convertToMap(builder.bytes(), false).v2());
assertScript("{{#join}}ctx.people.0.emails{{/join}}", ctx,
equalTo("john@smith.com,john.smith@email.com,jsmith@email.com"));
assertScript("{{#join}}ctx.people.1.emails{{/join}}", ctx,
equalTo("john@doe.com,john.doe@email.com,jdoe@email.com"));
assertScript("{{#ctx.people}}to: {{#join}}emails{{/join}};{{/ctx.people}}", ctx,
equalTo("to: john@smith.com,john.smith@email.com,jsmith@email.com;to: john@doe.com,john.doe@email.com,jdoe@email.com;"));
}
public void testJoinWithToJson() {
Map<String, Object> params = Collections.singletonMap("terms",
Arrays.asList(singletonMap("term", "foo"), singletonMap("term", "bar")));
assertScript("{{#join}}{{#toJson}}terms{{/toJson}}{{/join}}", params,
equalTo("[{\"term\":\"foo\"},{\"term\":\"bar\"}]"));
}
public void testsUnsupportedTagsJoin() {
MustacheException e = expectThrows(MustacheException.class, () -> compile("{{#join}}{{/join}}"));
assertThat(e.getMessage(), containsString("Mustache function [join] must contain one and only one identifier"));
e = expectThrows(MustacheException.class, () -> compile("{{#join delimiter='a'}}{{/join delimiter='b'}}"));
assertThat(e.getMessage(), containsString("Mismatched start/end tags"));
}
public void testJoinWithCustomDelimiter() {
Map<String, Object> params = Collections.singletonMap("params", Arrays.asList(1, 2, 3, 4));
assertScript("{{#join delimiter=''}}params{{/join delimiter=''}}", params, equalTo("1234"));
assertScript("{{#join delimiter=','}}params{{/join delimiter=','}}", params, equalTo("1,2,3,4"));
assertScript("{{#join delimiter='/'}}params{{/join delimiter='/'}}", params, equalTo("1/2/3/4"));
assertScript("{{#join delimiter=' and '}}params{{/join delimiter=' and '}}", params, equalTo("1 and 2 and 3 and 4"));
}
private void assertScript(String script, Map<String, Object> vars, Matcher<Object> matcher) {
Object result = engine.executable(new CompiledScript(INLINE, "inline", "mustache", compile(script)), vars).run();
assertThat(result, notNullValue());
assertThat(result, instanceOf(BytesReference.class));
assertThat(((BytesReference) result).toUtf8(), matcher);
}
private Object compile(String script) {
assertThat("cannot compile null or empty script", script, not(isEmptyOrNullString()));
return engine.compile(null, script, Collections.emptyMap());
}
}