This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push: new 295291820e9 CAMEL-21535: camel-groovy - Make it easier to validate groovy script for low-code tools (#16545) 295291820e9 is described below commit 295291820e9005f63a0fdeed70beba7a02f0ea67 Author: Claus Ibsen <claus.ib...@gmail.com> AuthorDate: Thu Dec 12 15:23:09 2024 +0100 CAMEL-21535: camel-groovy - Make it easier to validate groovy script for low-code tools (#16545) --- catalog/camel-catalog/pom.xml | 6 ++ .../org/apache/camel/catalog/CamelCatalogTest.java | 17 ++++ .../camel/language/groovy/GroovyExpression.java | 4 +- .../camel/language/groovy/GroovyLanguage.java | 23 +++++ .../language/groovy/GroovyValidationException.java | 63 ++++++++++++ .../camel/language/groovy/GroovyLanguageTest.java | 27 ++++++ .../camel/catalog/impl/AbstractCamelCatalog.java | 106 ++++++++++++++++++++- 7 files changed, 243 insertions(+), 3 deletions(-) diff --git a/catalog/camel-catalog/pom.xml b/catalog/camel-catalog/pom.xml index 5a130061c7d..1c4706eb213 100644 --- a/catalog/camel-catalog/pom.xml +++ b/catalog/camel-catalog/pom.xml @@ -104,6 +104,12 @@ <artifactId>camel-jsonpath</artifactId> <scope>test</scope> </dependency> + <!-- for testing groovy language --> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-groovy</artifactId> + <scope>test</scope> + </dependency> <!-- for testing activemq component --> <dependency> diff --git a/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java b/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java index 0863b9b0848..2b558750590 100644 --- a/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java +++ b/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java @@ -1126,6 +1126,23 @@ public class CamelCatalogTest { assertEquals("$.store.book[?(@.price < 10)]", result.getText()); } + @Test + public void testValidateGroovyLanguage() { + LanguageValidationResult result = catalog.validateLanguageExpression(null, "groovy", "4 * 3"); + assertTrue(result.isSuccess()); + assertEquals("4 * 3", result.getText()); + + var code = """ + var a = 123; + println a */ 2; + """; + result = catalog.validateLanguageExpression(null, "groovy", code); + assertFalse(result.isSuccess()); + assertEquals(code, result.getText()); + assertEquals(23, result.getIndex()); + assertEquals("Unexpected input: '*' @ line 2, column 11.", result.getShortError()); + } + @Test public void testSpringCamelContext() { String xml = catalog.springSchemaAsXml(); diff --git a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java index b5adb8215a6..ebbe38c75d9 100644 --- a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java +++ b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java @@ -62,7 +62,7 @@ public class GroovyExpression extends ExpressionSupport { } @SuppressWarnings("unchecked") - private Script instantiateScript(Exchange exchange, Map<String, Object> globalVariables) { + protected Script instantiateScript(Exchange exchange, Map<String, Object> globalVariables) { // Get the script from the cache, or create a new instance GroovyLanguage language = (GroovyLanguage) exchange.getContext().resolveLanguage("groovy"); Set<GroovyShellFactory> shellFactories = exchange.getContext().getRegistry().findByType(GroovyShellFactory.class); @@ -87,7 +87,7 @@ public class GroovyExpression extends ExpressionSupport { return ObjectHelper.newInstance(scriptClass, Script.class); } - private Binding createBinding(Exchange exchange, Map<String, Object> globalVariables) { + protected Binding createBinding(Exchange exchange, Map<String, Object> globalVariables) { Map<String, Object> map = new HashMap<>(globalVariables); ExchangeHelper.populateVariableMap(exchange, map, true); map.put("log", LOG); diff --git a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java index 3a170b24f8e..a03145305ff 100644 --- a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java +++ b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java @@ -23,11 +23,13 @@ import java.util.Map; import groovy.lang.Binding; import groovy.lang.GroovyShell; import groovy.lang.Script; +import org.apache.camel.Exchange; import org.apache.camel.Service; import org.apache.camel.spi.CamelEvent; import org.apache.camel.spi.EventNotifier; import org.apache.camel.spi.ScriptingLanguage; import org.apache.camel.spi.annotations.Language; +import org.apache.camel.support.DefaultExchange; import org.apache.camel.support.LRUCacheFactory; import org.apache.camel.support.ObjectHelper; import org.apache.camel.support.SimpleEventNotifierSupport; @@ -156,6 +158,27 @@ public class GroovyLanguage extends TypedLanguageSupport implements ScriptingLan return getCamelContext().getTypeConverter().convertTo(resultType, value); } + // use by tooling + public boolean validateExpression(String expression) throws GroovyValidationException { + final Exchange dummy = new DefaultExchange(getCamelContext()); + Map<String, Object> globalVariables = new HashMap<>(); + + try { + GroovyExpression ge = createExpression(expression); + Script script = ge.instantiateScript(dummy, globalVariables); + script.setBinding(ge.createBinding(dummy, globalVariables)); + script.run(); + } catch (Exception e) { + throw new GroovyValidationException(expression, e); + } + return true; + } + + // use by tooling + public boolean validatePredicate(String expression) throws GroovyValidationException { + return validateExpression(expression); + } + Class<Script> getScriptFromCache(String script) { final GroovyClassService cached = scriptCache.get(script); if (cached == null) { diff --git a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyValidationException.java b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyValidationException.java new file mode 100644 index 00000000000..c8f0d883f8f --- /dev/null +++ b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyValidationException.java @@ -0,0 +1,63 @@ +/* + * 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.camel.language.groovy; + +import java.io.LineNumberReader; +import java.io.StringReader; + +import org.codehaus.groovy.control.MultipleCompilationErrorsException; +import org.codehaus.groovy.control.messages.Message; +import org.codehaus.groovy.control.messages.SyntaxErrorMessage; + +/** + * Exception when validating groovy scripts. + */ +public class GroovyValidationException extends Exception { + + private final String script; + + public GroovyValidationException(String script, Throwable cause) { + super(cause); + this.script = script; + } + + public String getScript() { + return script; + } + + public int getIndex() { + if (getCause() instanceof MultipleCompilationErrorsException me) { + Message gm = me.getErrorCollector().getLastError(); + if (gm instanceof SyntaxErrorMessage sem) { + LineNumberReader lr = new LineNumberReader(new StringReader(script)); + int pos = -1; + for (int i = 1; i < sem.getCause().getLine(); i++) { + try { + String line = lr.readLine(); + pos += line.length() + System.lineSeparator().length(); + } catch (Exception e) { + // ignore + } + } + pos += sem.getCause().getStartColumn(); + return pos; + } + } + return -1; + } + +} diff --git a/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyLanguageTest.java b/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyLanguageTest.java index fb0540abe2d..9758b71d147 100644 --- a/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyLanguageTest.java +++ b/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyLanguageTest.java @@ -17,8 +17,14 @@ package org.apache.camel.language.groovy; import org.apache.camel.test.junit5.LanguageTestSupport; +import org.codehaus.groovy.control.MultipleCompilationErrorsException; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + public class GroovyLanguageTest extends LanguageTestSupport { @Test @@ -43,6 +49,27 @@ public class GroovyLanguageTest extends LanguageTestSupport { assertExpression("exchangeProperty.myProp2", 123); } + @Test + public void testValidateExpression() throws Exception { + GroovyLanguage g = new GroovyLanguage(); + g.setCamelContext(context); + + assertTrue(g.validateExpression("2 * 3")); + assertTrue(g.validateExpression("exchange.getExchangeId()")); + assertTrue(g.validatePredicate("2 * 3 > 4")); + + try { + g.validateExpression(""" + var a = 123; + println a */ 2; + """); + fail("Should throw error"); + } catch (GroovyValidationException e) { + assertEquals(23, e.getIndex()); + assertInstanceOf(MultipleCompilationErrorsException.class, e.getCause()); + } + } + @Override protected String getLanguageName() { return "groovy"; diff --git a/core/camel-core-catalog/src/main/java/org/apache/camel/catalog/impl/AbstractCamelCatalog.java b/core/camel-core-catalog/src/main/java/org/apache/camel/catalog/impl/AbstractCamelCatalog.java index f1d677d596c..238dde62451 100644 --- a/core/camel-core-catalog/src/main/java/org/apache/camel/catalog/impl/AbstractCamelCatalog.java +++ b/core/camel-core-catalog/src/main/java/org/apache/camel/catalog/impl/AbstractCamelCatalog.java @@ -16,6 +16,8 @@ */ package org.apache.camel.catalog.impl; +import java.io.LineNumberReader; +import java.io.StringReader; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.URI; @@ -1322,7 +1324,7 @@ public abstract class AbstractCamelCatalog { // if there are {{ }}} property placeholders then we need to resolve them to something else // as the simple parse cannot resolve them before parsing as we dont run the actual Camel application // with property placeholders setup so we need to dummy this by replace the {{ }} to something else - // therefore we use an more unlikely character: {{XXX}} to ~^XXX^~ + // therefore we use a more unlikely character: {{XXX}} to ~^XXX^~ String resolved = simple.replaceAll("\\{\\{(.+)\\}\\}", "~^$1^~"); LanguageValidationResult answer = new LanguageValidationResult(simple); @@ -1412,9 +1414,109 @@ public abstract class AbstractCamelCatalog { return answer; } + private LanguageValidationResult doValidateGroovy(ClassLoader classLoader, String groovy, boolean predicate) { + if (classLoader == null) { + classLoader = getClass().getClassLoader(); + } + + // if there are {{ }}} property placeholders then we need to resolve them to something else + // as the simple parse cannot resolve them before parsing as we dont run the actual Camel application + // with property placeholders setup so we need to dummy this by replace the {{ }} to something else + // therefore we use a more unlikely character: {{XXX}} to ~^XXX^~ + String resolved = groovy.replaceAll("\\{\\{(.+)\\}\\}", "~^$1^~"); + + LanguageValidationResult answer = new LanguageValidationResult(groovy); + + Object context; + Object instance = null; + Class<?> clazz; + + try { + // need a simple camel context for the groovy language parser to be able to parse + clazz = classLoader.loadClass("org.apache.camel.impl.engine.SimpleCamelContext"); + context = clazz.getDeclaredConstructor(boolean.class).newInstance(false); + clazz = classLoader.loadClass("org.apache.camel.language.groovy.GroovyLanguage"); + instance = clazz.getDeclaredConstructor().newInstance(); + clazz = classLoader.loadClass("org.apache.camel.CamelContext"); + instance.getClass().getMethod("setCamelContext", clazz).invoke(instance, context); + } catch (Exception e) { + clazz = null; + answer.setError(e.getMessage()); + } + + if (clazz != null) { + Throwable cause = null; + try { + if (predicate) { + instance.getClass().getMethod("validatePredicate", String.class).invoke(instance, resolved); + } else { + instance.getClass().getMethod("validateExpression", String.class).invoke(instance, resolved); + } + } catch (InvocationTargetException e) { + cause = e.getTargetException(); + } catch (Exception e) { + cause = e; + } + + if (cause != null) { + + // reverse ~^XXX^~ back to {{XXX}} + String errMsg = cause.getMessage(); + errMsg = errMsg.replaceAll("\\~\\^(.+)\\^\\~", "{{$1}}"); + + answer.setError(errMsg); + + // is it simple parser exception then we can grab the index where the problem is + if (cause.getClass().getName().equals("org.apache.camel.language.groovy.GroovyValidationException")) { + try { + // we need to grab the index field from those simple parser exceptions + Method method = cause.getClass().getMethod("getIndex"); + Object result = method.invoke(cause); + if (result != null) { + int index = (int) result; + answer.setIndex(index); + } + } catch (Exception i) { + // ignore + } + } + + // we need to grab the short message field from this simple syntax exception + if (answer.getShortError() == null) { + // fallback and try to make existing message short instead + String msg = answer.getError(); + // grab everything before " @ " which would be regarded as the short message + LineNumberReader lnr = new LineNumberReader(new StringReader(msg)); + try { + String line = lnr.readLine(); + do { + if (line.contains(" @ ")) { + // skip leading Scrip_xxxx.groovy: N: + if (line.startsWith("Script_") && StringHelper.countChar(line, ':') > 2) { + line = StringHelper.after(line, ":", line); + line = StringHelper.after(line, ":", line); + line = line.trim(); + } + answer.setShortError(line); + break; + } + line = lnr.readLine(); + } while (line != null); + } catch (Exception e) { + // ignore + } + } + } + } + + return answer; + } + public LanguageValidationResult validateLanguagePredicate(ClassLoader classLoader, String language, String text) { if ("simple".equals(language)) { return doValidateSimple(classLoader, text, true); + } else if ("groovy".equals(language)) { + return doValidateGroovy(classLoader, text, true); } else { return doValidateLanguage(classLoader, language, text, true); } @@ -1423,6 +1525,8 @@ public abstract class AbstractCamelCatalog { public LanguageValidationResult validateLanguageExpression(ClassLoader classLoader, String language, String text) { if ("simple".equals(language)) { return doValidateSimple(classLoader, text, false); + } else if ("groovy".equals(language)) { + return doValidateGroovy(classLoader, text, false); } else { return doValidateLanguage(classLoader, language, text, false); }