blob: 6a9c1da28c206020b7fd1988e96b864dd3d900e7 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF 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.apache.geronimo.microprofile.reporter.storage.templating;
import static java.util.Locale.ROOT;
import static java.util.stream.Collectors.joining;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Function;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
@ApplicationScoped
public class TemplatingEngine {
private final ConcurrentMap<AccessorKey, Function<Object, Object>> accessors = new ConcurrentHashMap<>();
private final ConcurrentMap<TemplateKey, Collection<Function<Object, String>>> templates = new ConcurrentHashMap<>();
@Inject
private TemplateHelper templateHelper;
// simple passthrough impl with these specificites
// - @include(template)
// - @include(template,newModel1=someDataToPassthrough1,newModel=2someDataToPassthrough1)
// - @each($collectionVar,templatePath)
// - $var from data with dot notation support
public Function<Object, String> compileIfNeeded(final String template, final Function<String, String> templateLoader) {
final Collection<Function<Object, String>> segments = templates.computeIfAbsent(new TemplateKey(templateLoader, template),
key -> precompile(key.template, key.loader));
return data -> segments.stream().map(it -> it.apply(data)).collect(joining());
}
private Collection<Function<Object, String>> precompile(final String template,
final Function<String, String> templateLoader) {
final Collection<Function<Object, String>> segments = new ArrayList<>();
final StringBuilder builder = new StringBuilder();
boolean escaped = false;
final char[] chars = template.toCharArray();
String substring;
for (int i = 0; i < chars.length; i++) {
final char current = chars[i];
if (escaped) {
builder.append(current);
escaped = false;
} else if (current == '\\') { // escaping
escaped = true;
} else if (current == '$') { // variable
final String value = builder.toString();
segments.add(ctx -> value);
builder.setLength(0);
final StringBuilder variable = new StringBuilder();
for (int j = i + 1; j < chars.length; j++) {
if (!Character.isJavaIdentifierPart(chars[j]) && chars[j] != '.') {
break;
}
variable.append(chars[j]);
}
i += variable.length();
final String varName = variable.toString();
segments.add(data -> {
final Object interpolated = interpolate(varName, data);
if (interpolated != null) {
return String.valueOf(interpolated);
}
return "";
});
} else if ((substring = template.substring(i)).startsWith("/*")) { // comment
final int end = template.indexOf("*/", i);
if (end < 0) {
throw new IllegalArgumentException("No comment end at index " + i + " for:\n" + template);
}
i = end + "*/".length();
} else if (substring.startsWith("@include(")) {
final String value = builder.toString();
segments.add(data -> value);
builder.setLength(0);
final int end = findEndingParenthesis(chars, i + "@include(".length() + 1);
if (end < 0) {
throw new IllegalArgumentException("Missing ')' token for @include at position " + i + " for:\n" + template);
}
final String tplPath = template.substring(i + "@include(".length(), end);
i = end;
segments.add(data -> {
final String interpolated = compileIfNeeded(tplPath, templateLoader).apply(data); // todo: compose segments
if (interpolated == null) {
return "";
}
final Object includeData;
final String templatePath;
if (interpolated.contains(",")) {
final String[] split = interpolated.split(",");
templatePath = split[0];
final Map<String, Object> map = new HashMap<>();
includeData = map;
for (int j = 1; j < split.length; j++) {
final String[] config = split[j].split("=");
if (config.length != 2) {
throw new IllegalArgumentException(
"Passed data during a directive (@include) must set their alias, ex: name=foo.bar");
}
map.put(config[0], interpolate(config[1], data));
}
} else {
templatePath = interpolated;
includeData = data;
}
return compileIfNeeded(templateLoader.apply(templatePath), templateLoader).apply(includeData);
});
} else if (substring.startsWith("@escape(")) {
i = handleFn("escape", template, templateLoader, segments, builder, chars, i, templateHelper::escape);
} else if (substring.startsWith("@attributify(")) {
i = handleFn("attributify", template, templateLoader, segments, builder, chars, i,
v -> v.toLowerCase(ROOT).replace(' ', '-'));
} else if (substring.startsWith("@url(")) {
i = handleFn("url", template, templateLoader, segments, builder, chars, i, v -> {
try {
return URLEncoder.encode(v, "UTF-8");
} catch (final UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
});
} else if (substring.startsWith("@lowercase(")) {
i = handleFn("lowercase", template, templateLoader, segments, builder, chars, i, v -> v.toLowerCase(ROOT));
} else if (substring.startsWith("@each(")) {
final String value = builder.toString();
segments.add(ctx -> value);
builder.setLength(0);
final int end = findEndingParenthesis(chars, i + "@each(".length() + 1);
if (end < 0) {
throw new IllegalArgumentException("Missing ')' token for @each at position " + i +
" for:\n" + template);
}
final int startConfig = i + "@each(".length();
final String config = template.substring(startConfig, end);
i = end;
final int sep = config.indexOf(",");
if (sep < 0) {
throw new IllegalArgumentException(
"Bad configuration for @each, first parameter is the variable, second the template at index " +
i + ", for:\n" + template);
}
final String variableName = config.substring(config.startsWith("$") ? 1 : 0, sep);
final Function<Object, String> tplProvider;
final String tpl = config.substring(sep + 1);
if (tpl.startsWith("inline:")) {
final String completeTpl = tpl.substring("inline:".length());
tplProvider = data -> completeTpl;
} else {
tplProvider = data -> templateLoader.apply(compileIfNeeded(tpl, templateLoader).apply(data));
}
segments.add(data -> {
final Object collection = interpolate(variableName, data);
if (collection == null) {
return "";
}
final Iterator<?> it;
if (Collection.class.isInstance(collection)) {
it = ((Collection<Object>) collection).iterator();
} else if (Map.class.isInstance(collection)) {
it = ((Map<Object, Object>) collection).entrySet().iterator();
} else {
throw new IllegalArgumentException("Only Collection and Map can be used in @each, got " + collection);
}
final String compiled = tplProvider.apply(data);
final StringBuilder out = new StringBuilder();
while (it.hasNext()) {
final Object next = it.next();
final boolean hasNext = it.hasNext();
final Map<String, Object> subData = new HashMap<>();
subData.put("$value", next);
subData.put("hasNext", hasNext);
out.append(compileIfNeeded(compiled, templateLoader).apply(subData));
}
return out.toString();
});
} else if (substring.startsWith("@if(")) {
final String value = builder.toString();
segments.add(ctx -> value);
builder.setLength(0);
final int end = findEndingParenthesis(chars, i + "@if(".length() + 1);
if (end < 0) {
throw new IllegalArgumentException("Missing ')' token for @if at position " + i +
" for:\n" + template);
}
final String config = template.substring(i + "@if(".length(), end);
i = end;
final int sep = config.indexOf(",");
if (sep < 0) {
throw new IllegalArgumentException(
"Bad configuration for @if, first parameter is the falsy condition, second the template. At index " +
i + ", for:\n" + template);
}
final String variableName = config.substring(config.startsWith("$") ? 1 : 0, sep);
final String tpl = config.substring(sep + 1);
segments.add(data -> {
final Object condition = interpolate(variableName, data);
if (condition == null) {
return "";
}
final String conditionStr = String.valueOf(condition);
if ("false".equalsIgnoreCase(conditionStr) || conditionStr.isEmpty()) {
return "";
}
final String compiled = tpl.startsWith("inline:") ?
tpl.substring("inline:".length()) :
templateLoader.apply(compileIfNeeded(tpl, templateLoader).apply(data));
return compileIfNeeded(compiled, templateLoader).apply(data);
});
} else {
builder.append(current);
}
}
if (builder.length() > 0) {
final String value = builder.toString();
segments.add(ctx -> value);
}
return segments;
}
private int handleFn(final String name, final String template, final Function<String, String> templateLoader,
final Collection<Function<Object, String>> segments, final StringBuilder builder,
final char[] chars, final int currentIndex, final Function<String, String> impl) {
final String value = builder.toString();
segments.add(data -> value);
builder.setLength(0);
final int end = findEndingParenthesis(chars, currentIndex + name .length() + 2 /*@ and (*/ + 1);
if (end < 0) {
throw new IllegalArgumentException("Missing ')' token for @" + name + " at position " + currentIndex + " for:\n" + template);
}
final String toEscape = template.substring(currentIndex + name.length() + 2, end);
segments.add(data -> {
final String escapableValue = compileIfNeeded(toEscape, templateLoader).apply(data);
if (escapableValue == null) {
return "";
}
return impl.apply(escapableValue);
});
return end;
}
private int findEndingParenthesis(final char[] chars, final int from) {
int remaining = 1;
for (int i = from; i < chars.length; i++) {
if (chars[i] == ')' && --remaining == 0) {
return i;
} else if (chars[i] == '(') {
remaining++;
}
}
return -1;
}
private Object getVariable(final Object registry, final String name) {
if (registry == null) {
return registry;
}
// map handling
if (Map.class.isInstance(registry)) {
return Map.class.cast(registry).get(name);
}
final Class<?> registryClass = registry.getClass();
// array handling - foo.1.name syntax
if (registryClass.isArray()) {
return Array.get(registry, Integer.parseInt(name));
}
return accessors.computeIfAbsent(new AccessorKey(registryClass, name), key -> {
// try getter
try {
final Method method = key.type
.getMethod("get" + Character.toUpperCase(key.name.charAt(0)) + key.name.substring(1));
if (!method.isAccessible()) {
method.setAccessible(true);
}
return o -> {
try {
return method.invoke(o);
} catch (final IllegalAccessException e) {
throw new IllegalStateException(e);
} catch (final InvocationTargetException e) {
throw new IllegalStateException(e.getTargetException());
}
};
} catch (final NoSuchMethodException e) {
// no-op
}
// try field
try {
final Field field = key.type.getDeclaredField(key.name);
if (!field.isAccessible()) {
field.setAccessible(true);
}
return o -> {
try {
return field.get(o);
} catch (final IllegalAccessException e) {
throw new IllegalStateException(e);
}
};
} catch (final Exception e) {
// no-op
}
return o -> null;
}).apply(registry);
}
private Object interpolate(final String string, final Object data) {
final String[] segments = string.split("\\.");
Object registry = data;
for (final String it : segments) {
final Object variable = getVariable(registry, it);
if (variable == null) {
return null;
}
registry = variable;
}
return registry;
}
public void clean() {
templates.clear();
}
private static class TemplateContext {
private final Function<String, String> loader;
private final Object data;
private TemplateContext(final Function<String, String> loader, final Object data) {
this.loader = loader;
this.data = data;
}
}
private static class AccessorKey {
private final Class<?> type;
private final String name;
private final int hash;
private AccessorKey(final Class<?> type, final String name) {
this.type = type;
this.name = name;
this.hash = Objects.hash(type, name);
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || AccessorKey.class != o.getClass()) {
return false;
}
final AccessorKey that = AccessorKey.class.cast(o);
return Objects.equals(type, that.type) && Objects.equals(name, that.name);
}
@Override
public int hashCode() {
return hash;
}
}
private static class TemplateKey {
private final Function<String, String> loader;
private final String template;
private final int hash;
private TemplateKey(final Function<String, String> loader, final String value) {
this.loader = loader;
this.template = value;
this.hash = Objects.hash(loader, value);
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || TemplateKey.class != o.getClass()) {
return false;
}
final TemplateKey that = TemplateKey.class.cast(o);
return Objects.equals(template, that.template) && Objects.equals(loader, that.loader);
}
@Override
public int hashCode() {
return hash;
}
}
}