Skip to content

Commit 6ed1331

Browse files
committed
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, #19119
1 parent 5301ee3 commit 6ed1331

File tree

7 files changed

+663
-119
lines changed

7 files changed

+663
-119
lines changed
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.elasticsearch.script.mustache;
21+
22+
import com.fasterxml.jackson.core.io.JsonStringEncoder;
23+
import com.github.mustachejava.Code;
24+
import com.github.mustachejava.DefaultMustacheFactory;
25+
import com.github.mustachejava.DefaultMustacheVisitor;
26+
import com.github.mustachejava.Mustache;
27+
import com.github.mustachejava.MustacheException;
28+
import com.github.mustachejava.MustacheVisitor;
29+
import com.github.mustachejava.TemplateContext;
30+
import com.github.mustachejava.codes.IterableCode;
31+
import com.github.mustachejava.codes.WriteCode;
32+
import com.google.common.base.Function;
33+
import org.elasticsearch.common.xcontent.XContentBuilder;
34+
import org.elasticsearch.common.xcontent.XContentType;
35+
36+
import java.io.IOException;
37+
import java.io.StringWriter;
38+
import java.io.Writer;
39+
import java.util.Collections;
40+
import java.util.Iterator;
41+
import java.util.Map;
42+
import java.util.Objects;
43+
import java.util.regex.Matcher;
44+
import java.util.regex.Pattern;
45+
46+
public class CustomMustacheFactory extends DefaultMustacheFactory {
47+
48+
private final Encoder encoder;
49+
50+
public CustomMustacheFactory(boolean escaping) {
51+
super();
52+
setObjectHandler(new CustomReflectionObjectHandler());
53+
if (escaping) {
54+
this.encoder = new JsonEscapeEncoder();
55+
} else {
56+
this.encoder = new NoEscapeEncoder();
57+
}
58+
}
59+
60+
@Override
61+
public void encode(String value, Writer writer) {
62+
encoder.accept(value, writer);
63+
}
64+
65+
@Override
66+
public MustacheVisitor createMustacheVisitor() {
67+
return new CustomMustacheVisitor(this);
68+
}
69+
70+
class CustomMustacheVisitor extends DefaultMustacheVisitor {
71+
72+
public CustomMustacheVisitor(DefaultMustacheFactory df) {
73+
super(df);
74+
}
75+
76+
@Override
77+
public void iterable(TemplateContext templateContext, String variable, Mustache mustache) {
78+
if (ToJsonCode.match(variable)) {
79+
list.add(new ToJsonCode(templateContext, df, mustache, variable));
80+
} else if (JoinerCode.match(variable)) {
81+
list.add(new JoinerCode(templateContext, df, mustache));
82+
} else if (CustomJoinerCode.match(variable)) {
83+
list.add(new CustomJoinerCode(templateContext, df, mustache, variable));
84+
} else {
85+
list.add(new IterableCode(templateContext, df, mustache, variable));
86+
}
87+
}
88+
}
89+
90+
/**
91+
* Base class for custom Mustache functions
92+
*/
93+
static abstract class CustomCode extends IterableCode {
94+
95+
private final String code;
96+
97+
public CustomCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String code) {
98+
super(tc, df, mustache, extractVariableName(code, mustache, tc));
99+
this.code = Objects.requireNonNull(code);
100+
}
101+
102+
@Override
103+
public Writer execute(Writer writer, Object[] scopes) {
104+
Object resolved = get(scopes);
105+
writer = handle(writer, createFunction(resolved), scopes);
106+
appendText(writer);
107+
return writer;
108+
}
109+
110+
@Override
111+
protected void tag(Writer writer, String tag) throws IOException {
112+
writer.write(tc.startChars());
113+
writer.write(tag);
114+
writer.write(code);
115+
writer.write(tc.endChars());
116+
}
117+
118+
protected abstract Function createFunction(Object resolved);
119+
120+
/**
121+
* At compile time, this function extracts the name of the variable:
122+
* {{#toJson}}variable_name{{/toJson}}
123+
*/
124+
protected static String extractVariableName(String fn, Mustache mustache, TemplateContext tc) {
125+
Code[] codes = mustache.getCodes();
126+
if (codes == null || codes.length != 1) {
127+
throw new MustacheException("Mustache function [" + fn + "] must contain one and only one identifier");
128+
}
129+
130+
try (StringWriter capture = new StringWriter()) {
131+
// Variable name is in plain text and has type WriteCode
132+
if (codes[0] instanceof WriteCode) {
133+
codes[0].execute(capture, Collections.emptyList());
134+
return capture.toString();
135+
} else {
136+
codes[0].identity(capture);
137+
return capture.toString();
138+
}
139+
} catch (IOException e) {
140+
throw new MustacheException("Exception while parsing mustache function [" + fn + "] at line " + tc.line(), e);
141+
}
142+
}
143+
}
144+
145+
/**
146+
* This function renders {@link Iterable} and {@link Map} as their JSON representation
147+
*/
148+
static class ToJsonCode extends CustomCode {
149+
150+
private static final String CODE = "toJson";
151+
152+
public ToJsonCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable) {
153+
super(tc, df, mustache, CODE);
154+
if (CODE.equalsIgnoreCase(variable) == false) {
155+
throw new MustacheException("Mismatch function code [" + CODE + "] cannot be applied to [" + variable + "]");
156+
}
157+
}
158+
159+
@Override
160+
@SuppressWarnings("unchecked")
161+
protected Function<String, String> createFunction(final Object resolved) {
162+
return new Function() {
163+
@Override
164+
public String apply(Object s) {
165+
if (resolved == null) {
166+
return null;
167+
}
168+
try (XContentBuilder builder = XContentBuilder.builder(XContentType.JSON.xContent())) {
169+
if (resolved instanceof Iterable) {
170+
builder.startArray();
171+
for (Object o : (Iterable) resolved) {
172+
builder.value(o);
173+
}
174+
builder.endArray();
175+
} else if (resolved instanceof Map) {
176+
builder.map((Map<String, ?>) resolved);
177+
} else {
178+
// Do not handle as JSON
179+
return oh.stringify(resolved);
180+
}
181+
return builder.string();
182+
} catch (IOException e) {
183+
throw new MustacheException("Failed to convert object to JSON", e);
184+
}
185+
}
186+
};
187+
}
188+
static boolean match(String variable) {
189+
return CODE.equalsIgnoreCase(variable);
190+
}
191+
}
192+
193+
/**
194+
* This function concatenates the values of an {@link Iterable} using a given delimiter
195+
*/
196+
static class JoinerCode extends CustomCode {
197+
198+
protected static final String CODE = "join";
199+
private static final String DEFAULT_DELIMITER = ",";
200+
201+
private final String delimiter;
202+
203+
public JoinerCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String delimiter) {
204+
super(tc, df, mustache, CODE);
205+
this.delimiter = delimiter;
206+
}
207+
208+
public JoinerCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache) {
209+
this(tc, df, mustache, DEFAULT_DELIMITER);
210+
}
211+
212+
@Override
213+
@SuppressWarnings("unchecked")
214+
protected Function<String, String> createFunction(final Object resolved) {
215+
return new Function() {
216+
@Override
217+
public Object apply(Object s) {
218+
if (s == null) {
219+
return null;
220+
} else if (resolved instanceof Iterable) {
221+
StringBuilder joiner = new StringBuilder();
222+
Iterator it = ((Iterable) resolved).iterator();
223+
while (it.hasNext()) {
224+
joiner.append(oh.stringify(it.next()));
225+
if (it.hasNext()) {
226+
joiner.append(delimiter);
227+
}
228+
}
229+
return joiner.toString();
230+
}
231+
return s;
232+
}
233+
};
234+
}
235+
236+
static boolean match(String variable) {
237+
return CODE.equalsIgnoreCase(variable);
238+
}
239+
}
240+
241+
static class CustomJoinerCode extends JoinerCode {
242+
243+
private static final Pattern PATTERN = Pattern.compile("^(?:" + CODE + " delimiter='(.*)')$");
244+
245+
public CustomJoinerCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable) {
246+
super(tc, df, mustache, extractDelimiter(variable));
247+
}
248+
249+
private static String extractDelimiter(String variable) {
250+
Matcher matcher = PATTERN.matcher(variable);
251+
if (matcher.find()) {
252+
return matcher.group(1);
253+
}
254+
throw new MustacheException("Failed to extract delimiter for join function");
255+
}
256+
257+
static boolean match(String variable) {
258+
return PATTERN.matcher(variable).matches();
259+
}
260+
}
261+
262+
class NoEscapeEncoder implements Encoder {
263+
264+
@Override
265+
public void accept(String s, Writer writer) {
266+
try {
267+
writer.write(s);
268+
} catch (IOException e) {
269+
throw new MustacheException("Failed to encode value: " + s);
270+
}
271+
}
272+
}
273+
274+
class JsonEscapeEncoder implements Encoder {
275+
276+
@Override
277+
public void accept(String s, Writer writer) {
278+
try {
279+
writer.write(JsonStringEncoder.getInstance().quoteAsString(s));
280+
} catch (IOException e) {
281+
throw new MustacheException("Failed to escape and encode value: " + s);
282+
}
283+
}
284+
}
285+
286+
interface Encoder {
287+
void accept(String s, Writer writer);
288+
}
289+
}

core/src/main/java/org/elasticsearch/script/mustache/JsonEscapingMustacheFactory.java

Lines changed: 0 additions & 41 deletions
This file was deleted.

core/src/main/java/org/elasticsearch/script/mustache/MustacheScriptEngineService.java

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,8 @@
1818
*/
1919
package org.elasticsearch.script.mustache;
2020

21-
import java.io.Reader;
22-
import java.lang.ref.SoftReference;
23-
import java.util.Collections;
24-
import java.util.Map;
25-
21+
import com.github.mustachejava.Mustache;
22+
import com.github.mustachejava.MustacheFactory;
2623
import org.elasticsearch.common.Nullable;
2724
import org.elasticsearch.common.component.AbstractComponent;
2825
import org.elasticsearch.common.inject.Inject;
@@ -37,8 +34,10 @@
3734
import org.elasticsearch.script.SearchScript;
3835
import org.elasticsearch.search.lookup.SearchLookup;
3936

40-
import com.github.mustachejava.Mustache;
41-
import com.github.mustachejava.DefaultMustacheFactory;
37+
import java.io.Reader;
38+
import java.lang.ref.SoftReference;
39+
import java.util.Collections;
40+
import java.util.Map;
4241

4342
/**
4443
* Main entry point handling template registration, compilation and
@@ -88,25 +87,14 @@ public MustacheScriptEngineService(Settings settings) {
8887
* */
8988
@Override
9089
public Object compile(String template, Map<String, String> params) {
91-
String contentType = params.get(CONTENT_TYPE_PARAM);
92-
if (contentType == null) {
93-
contentType = JSON_CONTENT_TYPE;
94-
}
95-
96-
final DefaultMustacheFactory mustacheFactory;
97-
switch (contentType){
98-
case PLAIN_TEXT_CONTENT_TYPE:
99-
mustacheFactory = new NoneEscapingMustacheFactory();
100-
break;
101-
case JSON_CONTENT_TYPE:
102-
default:
103-
// assume that the default is json encoding:
104-
mustacheFactory = new JsonEscapingMustacheFactory();
105-
break;
106-
}
107-
mustacheFactory.setObjectHandler(new CustomReflectionObjectHandler());
90+
final MustacheFactory factory = new CustomMustacheFactory(isJsonEscapingEnabled(params));
10891
Reader reader = new FastStringReader(template);
109-
return mustacheFactory.compile(reader, "query-template");
92+
return factory.compile(reader, "query-template");
93+
}
94+
95+
private boolean isJsonEscapingEnabled(Map<String, String> params) {
96+
String contentType = params.get(CONTENT_TYPE_PARAM);
97+
return contentType == null || JSON_CONTENT_TYPE.equals(contentType);
11098
}
11199

112100
@Override

0 commit comments

Comments
 (0)