This is a Java port of the Common-Expression-Language (CEL).
The CEL specification can be found here.
The easiest way to get started is to add a dependency to your Maven project
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectnessie.cel</groupId>
<artifactId>cel-bom</artifactId>
<version>0.5.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectnessie.cel</groupId>
<artifactId>cel-tools</artifactId>
</dependency>
</dependencies>or Gradle project.
dependencies {
implementation(enforcedPlatform("org.projectnessie.cel:cel-bom:0.5.3"))
implementation("org.projectnessie.cel:cel-tools")
}(Note: cel-bom is available for CEL-Java version 0.3.0 and newer.)
The cel-tools artifact provides a simple entry point ScriptHost to produce Script instances.
A very simple start:
import com.google.api.expr.v1alpha1.Decl;
import java.util.HashMap;
import java.util.Map;
import org.projectnessie.cel.checker.Decls;
import org.projectnessie.cel.tools.Script;
import org.projectnessie.cel.tools.ScriptHost;
public class MyClass {
public void myScriptUsage() {
// Build the script factory
ScriptHost scriptHost = ScriptHost.newBuilder().build();
// create the script, will be parsed and checked
Script script = scriptHost.buildScript("x + ' ' + y")
.withDeclarations(
// Variable declarations - we need `x` and `y` in this example
Decls.newVar("x", Decls.String),
Decls.newVar("y", Decls.String))
.build();
Map<String, Object> arguments = new HashMap<>();
arguments.put("x", "hello");
arguments.put("y", "world");
String result = script.execute(String.class, arguments);
System.out.println(result); // Prints "hello world"
}
}Protobuf (via com.google.protobuf:protobuf-java) objects and schema is supported out of the box.
syntax = "proto3";
message MyPojo {
string Property1 = 1;
}public class MyClass {
public Boolean evalWithProtobuf() {
ScriptHost scriptHost = ScriptHost.newBuilder().build();
Script script =
scriptHost
.buildScript("inp.Property1 == checkName")
.withDeclarations(
// protobuf types need the type's full name
Decls.newVar("inp", Decls.newObjectType(MyPojo.getDescriptor().getFullName())),
Decls.newVar("checkName", Decls.String))
// protobuf types need the default instance
.withTypes(MyPojo.getDefaultInstance())
.build();
MyPojo pojo = MyPojo.newBuilder().setProperty1("test").build();
String checkName = "test";
Map<String, Object> arguments = new HashMap<>();
arguments.put("inp", pojo);
arguments.put("checkName", checkName);
Boolean result = script.execute(Boolean.class, arguments);
return result;
}
}It is also possible to use plain Java and Jackson objects as arguments by using the
org.projectnessie.cel.types.jackson.JacksonRegistry in org.projectnessie.cel:cel-jackson.
Code sample similar to the one above. It takes a user-provided object type MyInput.
import org.projectnessie.cel.types.jackson.JacksonRegistry;
public class MyClass {
public Boolean evalWithJacksonObject(MyInput input, String checkName) {
// Build the script factory
ScriptHost scriptHost = ScriptHost.newBuilder()
// IMPORTANT: use the Jackson registry
.registry(JacksonRegistry.newRegistry())
.build();
// Create the script, will be parsed and checked.
// It checks whether the property `name` in the "Jackson-ized" class `MyInput` is
// equal to the value of `checkName`.
Script script = scriptHost.buildScript("inp.name == checkName")
// Variable declarations - we need `inp` + `checkName` in this example
.withDeclarations(
// types for Jackson need the fully qualified class name
Decls.newVar("inp", Decls.newObjectType(MyInput.class.getName())),
Decls.newVar("checkName", Decls.String))
// Register our Jackson object input type (as a java.lang.Class)
.withTypes(MyInput.class)
.build();
Map<String, Object> arguments = new HashMap<>();
arguments.put("inp", input);
arguments.put("checkName", checkName);
Boolean result = script.execute(Boolean.class, arguments);
return result;
}
}Note that the Jackson field-names are used as property names in CEL-Java. It is not necessary to annotate "plain Java" classes with Jackson annotations.
To use the JacksonRegistry in your application code, add the cel-jackson dependency in
addition to cel-core or cel-tools.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectnessie.cel</groupId>
<artifactId>cel-bom</artifactId>
<version>0.5.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectnessie.cel</groupId>
<artifactId>cel-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.projectnessie.cel</groupId>
<artifactId>cel-tools</artifactId>
</dependency>
</dependencies>or Gradle project.
dependencies {
implementation(enforcedPlatform("org.projectnessie.cel:cel-bom:0.5.3"))
implementation("org.projectnessie.cel:cel-tools")
implementation("org.projectnessie.cel:cel-jackson")
}The org.projectnessie.cel:cel-standalone contains everything from CEL-Java and has no dependencies.
It comes with relocated protobuf dependencies.
Using cel-standalone is especially useful when your project requires different versions of
protobuf-java.
If you need CEL-Java's Jackson functionality, include the Jackson dependencies in your project.
Use either cel-tools or cel-standalone - never both!
The Common Expression Language allows simple computations against data structures.
Project Nessie aims to use CEL to enforce security policies and for various filtering expressions.
This Java implementation of CEL is based on the CEL-Go implementation.
Typed data structures should be defined using protobuf, but arbitrary data structures using
Java wrapper data types (like java.lang.Long/Double/String), lists (java.util.List) and maps
(java.util.Map) work, too.
The following example expression (from the CEL-Go codelab exercise7)
jwt.extra_claims.exists(c, c.startsWith('group'))
&& jwt.extra_claims.filter(c, c.startsWith('group'))
.all(c, jwt.extra_claims[c]
.all(g, g.endsWith('@acme.co')))can be used to check whether the 'extra_claims' map of a JWT contains an entry with a key starting
with group and a value ending with @acme.co.
The JWT argument can be expressed using a non-protobuf data structure representing the JSON-web-token:
import java.util.List;
import java.util.Map;
Map<String, Object> jwt = Map.of(
"jwt", Map.of(
"sub", "serviceAccount:[email protected]",
"aud", "my-project",
"iss", "auth.acme.com:12350",
"extra_claims", Map.of(
"group1", List.of("[email protected]", "[email protected]"),
"labels", List.of("metadata", "prod", "pii"),
"groupN", List.of("[email protected]")
)
)
);Note that the CEL type system
has 2 64-bit integer types: a signed 64-bit integer int and an unsigned 64-bit integer uint.
Objects/fields of different types must be explicitly casted in CEL. The "primitive" Java wrapper
type class for the 64-bit unsigned uint in CEL-Java is org.projectnessie.cel.common.ULong.
If you do not explicitly define a uint type or indirectly use uint via protobuf, you will
probably never notice it.
CEL-Java does not support access arbitrary Java classes. This means, you cannot access "standard Java functionality" from a CEL expression nor is it intended or planned to do so.
CEL is intentionally non-turing-complete, this means it ends in a finite amount of time, has no loops or other "blocking" operations.
You can however provide own custom functionality as a library, which then provides functions to CEL scripts running in environments that have been configured to use that library.
Custom functions can be easily added by implementing the org.projectnessie.cel.Library
interface. The interface provides the necessary declarations (function definitions) via
List<EnvOption> getCompileOptions() and the function implementations via
List<ProgramOption> getProgramOptions(). Examples are
here (StdLibrary class),
here (StringsLb class),
here (MyLib class),
here and
here
The CEL-Java repo uses git submodules to pull in required APIs from Google and the CEL-spec. Those submodules are required to build the CEL-Java project.
You need to run git submodule init and git submodule update after a fresh clone of this repo.
Build requirements:
- Java 21 or newer, it's a Gradle-wrapper build (it's fast ;) )
Runtime requirements:
- Java 8 or newer
./gradlew publishToMavenLocal deploy the current development to the local Maven repo, in
case you want to pull it the CEL-Java "snapshot" artifacts another project.
./gradlew test builds the production code and runs the unit tests.
The project uses the Google Java code style and uses the Spotless plugin. Run
./gradlew spotlessApply to fix formatting issues.
To run the CEL-spec conformance tests, Go, the bazel build tool plus toolchains are required.
Form the CEL-Java repo, just run conformance/run-conformance-tests.sh. That script performs
the necessary Gradle and bazel builds.
- JSON extension (see spec and for example
nonFiniteincom_github_golang_protobuf/jsonpb/decode.go, around line 441) - Encoders extension (like in Go), not difficult to port to Java, it's just work to be done at some point.
Java does not have a native (primitive) type "unsigned int/long" or uint32/uint64.
Support for the CEL type uint is therefore a bit more work in Java.
To maintain conformance to the CEL-spec, the CEL-Java implementation treats CEL's uint type
differently. This means, that for example the expression 123 == 123u is not true, but
123u == 123u and 123 == 123 are.
TL;DR If you have a uint32/uint64 in your protobuf objects or use uints in your CEL
expression, you must wrap those with the org.projectnessie.cel.common.ULong type.
Rounding/truncating of numeric values, especially when converting the CEL type double to
int or uint. The CEL spec says: CEL provides no way to control the finer points of
floating-point arithmetic, such as expression evaluation, rounding mode, or exception handling.
However, any two not-a-number values will compare equal even if their underlying properties are
different. (see spec).
The technical situation is ambiguous. The CEL-Go unit test
common/types/double_test.go/TestDoubleConvertToType asserts on the result -5 for the CEL
expression int(-4.5), because CEL-Go uses the math.Round(float64) function.
Since the CEL-spec is not clear, and the CEL-conformance-tests assert on double-to-int "truncation"
(aka think Java-ish: double doubleValue; long res = (long) doubleValue;), the CEL-Java
implementation just implements the functionality that passes the CEL-spec conformance tests.
(Note: the implementation of Go's math.Round(float64) behaves differently to Java's
Math.round(double) (or Math.rint()) and a 1:1 port of the CEL-Go behavior is rather not
that trivial.)
Note: The CEL-Go implementation does not pass the CEL-spec conformance tests:
--- FAIL: TestSimpleFile/conversions/int/double_truncate (0.01s)
simple_test.go:219: double_truncate: Eval got [int64_value:2], want [int64_value:1]
--- FAIL: TestSimpleFile/conversions/int/double_truncate_neg (0.01s)
simple_test.go:219: double_truncate_neg: Eval got [int64_value:-8], want [int64_value:-7]
--- FAIL: TestSimpleFile/conversions/int/double_half_pos (0.01s)
simple_test.go:219: double_half_pos: Eval got [int64_value:12], want [int64_value:11]
--- FAIL: TestSimpleFile/conversions/int/double_half_neg (0.01s)
simple_test.go:219: double_half_neg: Eval got [int64_value:-4], want [int64_value:-3]
--- FAIL: TestSimpleFile/conversions/uint/double_truncate (0.01s)
simple_test.go:219: double_truncate: Eval got [uint64_value:2], want [uint64_value:1]
--- FAIL: TestSimpleFile/conversions/uint/double_half (0.01s)
simple_test.go:219: double_half: Eval got [uint64_value:26], want [uint64_value:25]