package dgm.configuration.javascript; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Optional; import com.tinkerpop.blueprints.*; import dgm.JSONUtilities; import dgm.configuration.*; import dgm.modules.elasticsearch.ResolvedPathElement; import dgm.exceptions.ConfigurationException; import dgm.Subgraph; import dgm.graphs.Subgraphs; import org.elasticsearch.action.get.GetResponse; import org.mozilla.javascript.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import dgm.trees.Tree; import dgm.trees.Trees; import java.io.*; import java.util.HashMap; import java.util.Map; import static dgm.GraphUtilities.toJSON; /** * Load configuration from javascript files in a directory */ public class JavascriptConfiguration implements Configuration { public static final String FIXTURES_DIR_NAME = "fixtures"; private final Map<String,JavascriptIndexConfig> indices = new HashMap<String,JavascriptIndexConfig>(); private JavascriptFixtureConfiguration fixtureConfig; private static final Logger log = LoggerFactory.getLogger(JavascriptConfiguration.class); public JavascriptConfiguration(ObjectMapper om, File directory, File... libraries) throws IOException { final File[] directories = directory.listFiles(); if (directories == null) throw new ConfigurationException("Configuration directory " + directory.getCanonicalPath() + " does not exist"); for(File dir : directory.listFiles()) { // skip non directories if(!dir.isDirectory()) continue; // each subdirectory encodes an index final String dirname = dir.getName(); if (FIXTURES_DIR_NAME.equals(dirname)) { fixtureConfig = new JavascriptFixtureConfiguration(dir); log.debug(fixtureConfig.toString()); } else indices.put(dirname, new JavascriptIndexConfig(om, dirname, dir, libraries)); } } @Override public Map<String, ? extends IndexConfig> indices() { return indices; } @Override public FixtureConfiguration getFixtureConfiguration() { return fixtureConfig; } } class JavascriptIndexConfig implements IndexConfig { private static final Logger log = LoggerFactory.getLogger(JavascriptIndexConfig.class); final String index; final Scriptable scope; final Map<String,JavascriptTypeConfig> types = new HashMap<String,JavascriptTypeConfig>(); /** * Scan, filter and watch a directory for correct javascript files. * * @param index The elastic search index to write to * @param directory Directory to watch for files * @throws IOException */ public JavascriptIndexConfig(ObjectMapper om, String index, File directory, File... libraries) throws IOException { this.index = index; Scriptable scope = null; try { final Context cx = Context.enter(); // create standard ECMA scope scope = cx.initStandardObjects(); // load libraries for(File lib : libraries) loadLib(cx, scope, lib); final Object jsLogger = Context.javaToJS(new JSLogger(), scope); ScriptableObject.putProperty(scope, "log", jsLogger); // close the root scope for modifications cx.seal(scope); // non recursively load all configuration files final FilenameFilter filenameFilter = new FilenameFilter() { @Override public boolean accept(File dir, String name) { if(name.endsWith(".conf.js")) return true; log.error("File [{}] in config dir [{}] has wrong name format and is ignored. Proper format: [target type].conf.js", name, dir.getAbsolutePath()); return false; } }; final File[] configFiles = directory.listFiles(filenameFilter); if (configFiles == null) throw new ConfigurationException("Configuration directory " + directory.getCanonicalPath() + " can not be read"); for(File file: configFiles) { log.debug("Found config file [{}] for index [{}]", file.getCanonicalFile(), index); final Reader reader = new FileReader(file); final String fn = file.getCanonicalPath(); final String type = file.getName().replaceFirst(".conf.js", ""); final Scriptable typeConfig = (Scriptable) compile(cx, scope, reader, fn); types.put(type, new JavascriptTypeConfig(om, type, scope, typeConfig, this)); } } finally { Context.exit(); } if (scope == null) throw new RuntimeException("Scope failed to initialize"); this.scope = scope; } @Override public String name() { return index; } @Override public Map<String,? extends TypeConfig> types() { return types; } private Object compile(Context cx, Scriptable scope, Reader reader, String fn) throws IOException { // compile and execute into the scope return cx.compileReader(reader, fn, 0, null).exec(cx, scope); } private Object loadLibFromResource(Context cx, Scriptable scope, Class<?> cls, String fn) throws IOException { // get loader relative to this class final Reader reader = new InputStreamReader(cls.getResourceAsStream(fn), "UTF-8"); return compile(cx, scope, reader, fn); } private Object loadLib(Context cx, Scriptable scope, File f) throws IOException { // load file from filesystem final FileReader reader = new FileReader(f); return compile(cx, scope, reader, f.getName()); } @Override public String toString() { return "JavascriptIndexConfig(index=" + index +")"; } } class JavascriptTypeConfig implements TypeConfig { private static final Logger log = LoggerFactory.getLogger(JavascriptTypeConfig.class); final IndexConfig indexConfig; final String type; final Scriptable script; final Function filter; final Function extract; final Function transform; final String sourceIndex; final String sourceType; final ObjectMapper objectMapper; final Map<String,WalkConfig> walks = new HashMap<String, WalkConfig>(); public JavascriptTypeConfig(ObjectMapper objectMapper, String type, Scriptable scope, Scriptable script, IndexConfig indexConfig) throws IOException { this.objectMapper = objectMapper; this.type = type; this.script = script; this.indexConfig = indexConfig; log.debug("Creating config for type [{}] in index [{}]", type, indexConfig.name()); try { Context.enter(); // filter & graph extraction functions filter = (Function) fetchObjectOrNull("filter"); extract = (Function) fetchObjectOrNull("extract"); transform = (Function) fetchObjectOrNull("transform"); //TODO: null check, invalid configuration, error handling sourceIndex = ScriptableObject.getTypedProperty(script, "sourceIndex", String.class); sourceType = ScriptableObject.getTypedProperty(script, "sourceType", String.class); // add the walks final Scriptable walks = (Scriptable) fetchObjectOrNull("walks"); if (walks != null) { for (Object id : ScriptableObject.getPropertyIds(walks)) { final String walkName = id.toString(); // get the walk object final Scriptable walk = (Scriptable) ScriptableObject.getProperty(walks, walkName); final Direction direction = Direction.valueOf(ScriptableObject.getProperty(walk, "direction").toString()); final Scriptable properties = (Scriptable) ScriptableObject.getProperty(walk, "properties"); final JavascriptWalkConfig walkCfg = new JavascriptWalkConfig(objectMapper, walkName, direction, this, scope, properties); this.walks.put(walkName, walkCfg); } } else { log.debug("No walks found in configuration"); } } finally { Context.exit(); } } private Object fetchObjectOrNull(String field) { final Object obj = ScriptableObject.getProperty(script, field); // field not specified in script if(obj == UniqueTag.NOT_FOUND) return null; return obj; } @Override public String name() { return type; } @Override public Subgraph extract(JsonNode document) { if(document == null) throw new NullPointerException("Must pass in non-null value to extract(..)"); if (extract == null) { log.debug("Not extracting subgraph because no extract() function is configured"); return Subgraphs.EMPTY_SUBGRAPH; } final Context cx = Context.enter(); // extract graph components final JavascriptSubgraph sg = new JavascriptSubgraph(objectMapper, cx, script); final Object obj = JSONUtilities.toJSONObject(cx, script, document.toString()); extract.call(cx, script, null, new Object[]{obj, sg}); Context.exit(); return sg.subgraph; } @Override public boolean filter(JsonNode document) { if(filter == null) return true; boolean result = false; try { final Context cx = Context.enter(); final Object doc = JSONUtilities.toJSONObject(cx, script, document.toString()); result = Context.toBoolean(filter.call(cx, script, null, new Object[]{doc})); } finally { Context.exit(); } return result; } @Override public JsonNode transform(JsonNode document) { if(transform == null) { log.trace("No transformation function is configured, processing document as-is."); return document; } try { final Context cx = Context.enter(); final Object doc = JSONUtilities.toJSONObject(cx, script, document.toString()); final Object result = transform.call(cx, script, null, new Object[]{doc}); return JSONUtilities.fromJSONObject(objectMapper, cx, script, result); } catch (IOException e) { //TODO: and what about error handling??? throw new RuntimeException("Could not transform the input document." , e); } finally { Context.exit(); } } @Override public IndexConfig index() { return indexConfig; } @Override public String targetType() { return name(); } @Override public String sourceIndex() { return sourceIndex; } @Override public String sourceType() { return sourceType; } @Override public String targetIndex() { return index().name(); } @Override public Map<String, WalkConfig> walks() { return walks; } } class JavascriptWalkConfig implements WalkConfig { final String walkName; final Direction direction; final TypeConfig typeCfg; // TODO use guava immutables final Map<String, JavascriptPropertyConfig> properties = new HashMap<String,JavascriptPropertyConfig>(); public JavascriptWalkConfig(ObjectMapper om, String walkName, Direction direction, TypeConfig typeCfg, Scriptable scope, Scriptable propertyScriptable) { this.walkName = walkName; this.direction = direction; this.typeCfg = typeCfg; try { Context.enter(); // add all the properties for(Object id : ScriptableObject.getPropertyIds(propertyScriptable)) { final String propertyName = id.toString(); final Scriptable property = (Scriptable)ScriptableObject.getProperty(propertyScriptable, propertyName); final Function reduce = (Function)ScriptableObject.getProperty(property, "reduce"); final boolean nested = ScriptableObject.getProperty(property, "nested").toString().equals("true"); this.properties.put(propertyName, new JavascriptPropertyConfig(om, propertyName, nested, reduce, scope, this)); } } finally { Context.exit(); } } @Override public Direction direction() { return direction; } @Override public TypeConfig type() { return typeCfg; } @Override public Map<String,? extends PropertyConfig> properties() { return properties; } @Override public String name() { return walkName; } } class JavascriptPropertyConfig implements PropertyConfig { final String name; final boolean nested; final Function reduce; final Scriptable scope; final WalkConfig walkConfig; final ObjectMapper om; public JavascriptPropertyConfig(ObjectMapper om, String name, boolean nested, Function reduce, Scriptable scope, WalkConfig walkConfig) { this.om = om; this.nested = nested; this.name = name; this.reduce = reduce; this.scope = scope; this.walkConfig = walkConfig; } @Override public String name() { return this.name; } @Override public JsonNode reduce(Tree<ResolvedPathElement> tree) { JsonNode result = null; final com.google.common.base.Function<ResolvedPathElement, JsonNode> resultToString = new com.google.common.base.Function<ResolvedPathElement, JsonNode>() { @Override public JsonNode apply(ResolvedPathElement input) { try { final Optional<GetResponse> getResponse = input.getResponse(); final ObjectNode n = om.createObjectNode(); if(getResponse.isPresent()) { final String getResponseString = getResponse.get().getSourceAsString(); n.put("exists", true); n.put("value", om.readTree(getResponseString)); } else { n.put("exists", false); } final Edge edge = input.edge(); final Vertex vertex = input.vertex(); if (edge != null) n.put("edge", toJSON(om, edge)); if (vertex != null) n.put("vertex", toJSON(om, vertex)); return n; } catch (IOException e) { final ObjectNode n = om.createObjectNode(); n.put("exception", e.getClass().getCanonicalName()); n.put("message", e.getMessage()); return n; } } }; final Tree<JsonNode> jsonTree = Trees.map(resultToString, tree); try { final Context cx = Context.enter(); final JsonNode jtree = Trees.toJsonTree(om, jsonTree); final Object jsobject = JSONUtilities.toJSONObject(cx, scope, jtree.toString()); // call our "reduction" function final Object obj = reduce.call(cx, scope, null, new Object[] { jsobject }); // TODO this is inefficient and silly... // convert javascript object to JSON string final String objectJson = (String) NativeJSON.stringify(cx, scope, obj, null, null); // convert JSON string to JsonNode result = om.readTree(objectJson); } catch (JsonProcessingException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } finally { Context.exit(); } return result; } @Override public WalkConfig walk() { return walkConfig; } }