/cel-java

Common Expression Language for Java

Primary LanguageJavaApache License 2.0Apache-2.0

Java implementation of Common-Expression-Language (CEL)

CI Maven Central

This is a Java port of the Common-Expression-Language (CEL).

The CEL specification can be found here.

Getting started

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.4.4</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.4.4"))
  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 and Jackson and plain Java objects

Protobuf (via com.google.protobuf:protobuf-java) objects and schema is supported out of the box.

Protobuf example

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;
  }
}

Jackson example

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.4.4</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.4.4"))
  implementation("org.projectnessie.cel:cel-tools")
  implementation("org.projectnessie.cel:cel-jackson")
}

Dependency-free artifact

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!

Motivation to have a CEL-Java port

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:delegate@acme.co",
            "aud", "my-project",
            "iss", "auth.acme.com:12350",
            "extra_claims", Map.of(
                "group1", List.of("admin@acme.co", "analyst@acme.co"),
                "labels", List.of("metadata", "prod", "pii"),
                "groupN", List.of("forever@acme.co")
            )
        )
    );

Unsigned 64-bit uint

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.

Arbitrary Java classes

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.

Adding custom functions

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

Building and testing CEL-Java

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 11 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.

CEL-Java implementation specifics

Not yet implemented

  • JSON extension (see spec and for example nonFinite in com_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.

Unsigned integer

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.

Unclear double-to-int rounding behavior

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]