Skip to content

Commit

Permalink
Add support for multiple scripting languages to the Script Engine
Browse files Browse the repository at this point in the history
A Java utility (ScriptEngineUtility) is provided to share functionality between the scripts.
This functionality is written in Java, which essentially allows the scripts to execute Java code without having to actually be Java.

The arguments passed to and return results passed from the scripts are expected to be in JSON string format.
The ScriptEngineUtility should be used to convert to/from the JSON string to a JSON object for the script to process.

Two new exceptions are provided:
- ScriptEngineUnsupported = Error when the language (by file extension) is not available in the ScriptEngineType enum.
- ScriptEngineLoadFailed = Error when the engine does not exist (module is not added to the pom.xml or something went wrong while loading).

A pre-processor is added for handling the passed scripts.
This is necessary namely for Python, which must have the correct number of spaces/tabs prepended to it.
Currently, this is set to two spaces (both the engine.py and the pre-process method must have the same design).
Other languages can potentially utilize this pre-processor, but only Python currently gets pre-processed.

The engine.* resource files (such as engine.js) provide the engine template.
The util.* resource files (such as util.js) provide additional utilities that are language dependent and therefore are not available in the ScriptEngineUtility.

The following languages are supported:
- Javascript (.js)
- Java (.java)
- Python (.py)
- Groovy (.groovy)

The following languages could be supported, if problems are resolved:
- Ruby (.rb) (via JRuby)
- Perl (.pl) (via Rakudo)

Ruby is currently not working due to `java.lang.NoSuchFieldError: O_TMPFILE`.
More research is needed and an upstream bugreport is referenced below.

Perl could potentially be made to work via Rakudo, but is there a maven repository to add to the pom.xml?

The engine.rb and engine.pl are stubbed out and have not been tested.
Once the languages are imported and working properly, those engine.* files may need to be updated.

see: jruby/jruby#5334
see: https://rakudo.org/
  • Loading branch information
kaladay committed Aug 13, 2019
1 parent d7297be commit 5e4a462
Show file tree
Hide file tree
Showing 28 changed files with 371 additions and 965 deletions.
17 changes: 17 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,23 @@
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

<dependency>
<groupId>org.apache-extras.beanshell</groupId>
<artifactId>bsh</artifactId>
<version>2.0b6</version>
</dependency>

<dependency>
<groupId>org.python</groupId>
<artifactId>jython-standalone</artifactId>
<version>2.7.1b3</version>
</dependency>

<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-jsr223</artifactId>
</dependency>

</dependencies>

<distributionManagement>
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/org/folio/rest/exception/ScriptEngineLoadFailed.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.folio.rest.exception;

public class ScriptEngineLoadFailed extends Exception {
private static final long serialVersionUID = 5176444311634173282L;

private static final String MESSAGE = "The Scripting Engine, %s, failed to load.";

public ScriptEngineLoadFailed(String extension) {
super(String.format(MESSAGE, extension));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.folio.rest.exception;

public class ScriptEngineUnsupported extends Exception {
private static final long serialVersionUID = 2176459311584173842L;

private static final String MESSAGE = "The Scripting Engine, %s, is not supported.";

public ScriptEngineUnsupported(String extension) {
super(String.format(MESSAGE, extension));
}

}
30 changes: 30 additions & 0 deletions src/main/java/org/folio/rest/model/ScriptEngineType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.folio.rest.model;

/**
* Languages supported for use by scripting engine.
*
* This alone does not necessarily make the language available.
* Additional changes to the pom.xml may be necessary to get any particular language working.
*/
public enum ScriptEngineType {
NONE(null),
GROOVY("groovy"),
JAVA("java"),
JS("js"),
PERL("pl"),
PYTHON("py"),
RUBY("rb");

public final String extension;

/**
* Initialize the scripting engine.
*
* @param extension
* The language extension name, lower cased.
*/
private ScriptEngineType(String extension) {
this.extension = extension;
}

}
140 changes: 122 additions & 18 deletions src/main/java/org/folio/rest/service/ScriptEngineService.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package org.folio.rest.service;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
Expand All @@ -11,48 +13,150 @@
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;

import org.folio.rest.exception.ScriptEngineLoadFailed;
import org.folio.rest.exception.ScriptEngineUnsupported;
import org.folio.rest.model.ScriptEngineType;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;

/**
* Execute custom scripts for supported languages.
*/
@Service
public class ScriptEngineService {
private static final String UTILS_PREFIX = "scripts/utils.";
private static final String ENGINE_PREFIX = "scripts/engine.";

private static final Map<ScriptEngineType, String> SCRIPT_ENGINE_TYPES = new EnumMap<ScriptEngineType, String>(ScriptEngineType.class);
{{{
for (ScriptEngineType type : ScriptEngineType.values()) {
SCRIPT_ENGINE_TYPES.put(type, type.extension);
}
}}}

private ScriptEngineManager scriptEngineManager;

private Map<String, ScriptEngine> scriptEngines;

private String scriptTemplate = "var %s = function(inArgs) {var args = JSON.parse(inArgs); var returnObj = {}; %s return JSON.stringify(returnObj);}";

public ScriptEngineService() {
configureScriptEngines();
}

/**
* Initialize the engines for this class.
*/
private void configureScriptEngines() {
scriptEngineManager = new ScriptEngineManager();
scriptEngines = new HashMap<String, ScriptEngine>();
}

public void registerScript(String type, String name, String script) throws ScriptException, IOException {
Optional<ScriptEngine> maypeScriptEngine = Optional.ofNullable(scriptEngines.get(type));
if(!maypeScriptEngine.isPresent()) {
ScriptEngine newEngine = scriptEngineManager.getEngineByExtension(type);
scriptEngines.put(type, newEngine);
if(type.equals("js")) {
String javascriptUtilsContent = StreamUtils.copyToString( new ClassPathResource("scripts/javascriptUtils.js").getInputStream(), Charset.defaultCharset() );
newEngine.eval(javascriptUtilsContent);
/**
* Register a script for later execution.
*
* @param extension
* The extension of the language associated with the desired engine.
* @param name
* The name of the script function to execute.
* @param script
* The code associated with the given language that will be executed when the script engine for the given extension is run.
* @throws ScriptException
*
* @throws IOException
* @throws ScriptEngineUnsupported
* @throws NoSuchMethodException
* @throws ScriptEngineLoadFailed
*/
public void registerScript(String extension, String name, String script) throws ScriptException, IOException, ScriptEngineUnsupported, NoSuchMethodException, ScriptEngineLoadFailed {
if (!SCRIPT_ENGINE_TYPES.containsValue(extension)) {
throw new ScriptEngineUnsupported(extension);
}

Optional<ScriptEngine> maybeScriptEngine = Optional.ofNullable(scriptEngines.get(extension));

if (!maybeScriptEngine.isPresent()) {
ScriptEngine newEngine = scriptEngineManager.getEngineByExtension(extension);

if (newEngine == null) {
throw new ScriptEngineLoadFailed(extension);
}

scriptEngines.put(extension, newEngine);

if (SCRIPT_ENGINE_TYPES.containsValue(extension)) {
newEngine.eval(loadScript(UTILS_PREFIX + extension));
}
maypeScriptEngine = Optional.of(newEngine);
}
ScriptEngine scriptEngine = maypeScriptEngine.get();
scriptEngine.eval(String.format(scriptTemplate, name, script));


maybeScriptEngine = Optional.of(newEngine);
}

ScriptEngine scriptEngine = maybeScriptEngine.get();
scriptEngine.eval(String.format(loadScript(ENGINE_PREFIX + extension), name, preprocessScript(script, extension)));
}

public Object runScript(String type, String name, Object ...args)
throws NoSuchMethodException, ScriptException {
Invocable invocable = (Invocable) scriptEngines.get(type);
/**
* Execute a registered script.
*
* @param extension
* The extension of the language associated with the desired engine.
* @param name
* The name of the script function to execute.
* @param args
* Additional arguments to pass to the script function.
*
* @return
* The return results of the executed script.
* This will often be a JSON encoded String.
*
* @throws NoSuchMethodException
* @throws ScriptException
*/
public Object runScript(String extension, String name, Object ...args) throws NoSuchMethodException, ScriptException {
Invocable invocable = (Invocable) scriptEngines.get(extension);
return invocable.invokeFunction(name, args);
}

/**
* Load a script from the resources directory.
*
* @param filename
* The path to a file, within the resource directory.
*
* @return
* The contents of the script file.
*
* @throws IOException
*/
private String loadScript(String filename) throws IOException {
InputStream inputStream = new ClassPathResource(filename).getInputStream();
return StreamUtils.copyToString(inputStream, Charset.defaultCharset());
}

/**
* Pre-process a script to ensure that it is more friendly for any exceptional language.
*
* For example, Python is space/tab conscious, so make the tabs consistent with the two spaces used in the engine.py.
*
* @param script
* The script to potentially pre-process.
* @param extension
* The extension representing the language of the script.
*
* @return
* The pre-processed script.
*/
private String preprocessScript(String script, String extension) {

if (extension.equals(ScriptEngineType.PYTHON.extension)) {
// Ensure that 2 leading spaces exist before newlines and that there are no trailing white spaces.
String processed = script.replace("\r\n", "\n");
processed = script.replace("\r", "\n");
processed = script.replace("\n", "\n ");
processed.trim();
return processed;
}

return script;
}

}
112 changes: 112 additions & 0 deletions src/main/java/org/folio/rest/utility/ScriptEngineUtility.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package org.folio.rest.utility;

import java.util.regex.Pattern;

import org.camunda.bpm.engine.impl.util.json.JSONObject;

/**
* A collection of utilities methods intended to be directly used by scripts called by the scripting engine.
*/
public class ScriptEngineUtility {
private static final String EMAIL_REGEX = "^\\w+([\\.-]?\\w+)*@\\w+([\\.-]?\\w+)*(\\.\\w{2,3})+$";
private static final String PHONE_REGEX = "^[\\+]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{4,6}$";
private static final String URL_REGEX = "(http(s)?:\\\\\\/\\\\\\/.)?(www\\.)?[-a-zA-Z0-9@:%._\\\\\\+~#=]{2,256}\\\\\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\\\\\+.~#?&//=]*)";

/**
* Check if a given string might be an e-mail.
*
* @param string
* The string to check.
*
* @return
* TRUE if matched, FALSE otherwise.
*/
public boolean isEmailLike(String string) {
return string.matches(EMAIL_REGEX);
}

/**
* Check if a given string is a phone number.
*
* @param string
* The string to check.
*
* @return
* TRUE if matched, FALSE otherwise.
*/
public boolean isPhone(String string) {
Pattern pattern = Pattern.compile(PHONE_REGEX, Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
return pattern.matcher(string).find();
}

/**
* Check if a given string might be a URL.
*
* @param string
* The string to check.
*
* @return
* TRUE if matched, FALSE otherwise.
*/
public boolean isURLLike(String string) {
boolean isLikeUrl = string.toLowerCase().indexOf("www.") != -1;

isLikeUrl = isLikeUrl && string.toLowerCase().indexOf(".org") != -1;
isLikeUrl = isLikeUrl && string.toLowerCase().indexOf(".edu") != -1;
isLikeUrl = isLikeUrl && string.toLowerCase().indexOf(".net") != -1;
isLikeUrl = isLikeUrl && string.toLowerCase().indexOf(".us") != -1;
isLikeUrl = isLikeUrl && string.toLowerCase().indexOf(".io") != -1;
isLikeUrl = isLikeUrl && string.toLowerCase().indexOf(".co") != -1;

return isLikeUrl;
}

/**
* Check if a given string is a valid URL.
*
* @param string
* The string to check.
*
* @return
* TRUE if matched, FALSE otherwise.
*/
public boolean isValidUrl(String string) {
return string.matches(URL_REGEX);
}

/**
* Creating JSONObject.
*
* @return
* A generated JSON object.
*/
public JSONObject createJson() {
return new JSONObject();
}

/**
* Decode a JSON string into a JSONObject.
*
* @param json
* The JSON string to decode.
*
* @return
* A generated JSON object, containing the decoded JSON string.
*/
public JSONObject decodeJson(String json) {
return new JSONObject(json);
}

/**
* Encode a JSONObject into a JSON string.
*
* @param json
* The JSONObject to encode.
*
* @return
* A String containing the encoded JSON data.
*/
public String encodeJson(JSONObject json) {
return json.toString(2);
}
}
9 changes: 9 additions & 0 deletions src/main/resources/scripts/engine.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import org.folio.rest.utility.ScriptEngineUtility

def %s(String inArgs) {
def scriptEngineUtility = new ScriptEngineUtility();
def args = scriptEngineUtility.decodeJson(inArgs);
def returnObj = scriptEngineUtility.createJson();
%s
return scriptEngineUtility.encodeJson(returnObj);
}
10 changes: 10 additions & 0 deletions src/main/resources/scripts/engine.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import org.camunda.bpm.engine.impl.util.json.JSONObject;
import org.folio.rest.utility.ScriptEngineUtility;

public String %s(String inArgs) {
ScriptEngineUtility scriptEngineUtility = new ScriptEngineUtility();
JSONObject args = scriptEngineUtility.decodeJson(inArgs);
JSONObject returnObj = scriptEngineUtility.createJson();
%s
return scriptEngineUtility.encodeJson(returnObj);
}

0 comments on commit 5e4a462

Please sign in to comment.