From 87771ae95b2451a6bcc9f9a5b7dade66d2be499c Mon Sep 17 00:00:00 2001 From: Sebastian Spaink Date: Mon, 18 May 2026 12:38:48 -0500 Subject: [PATCH 1/3] refactor that decouples opa-evaluator from Jackson Signed-off-by: Sebastian Spaink --- README.md | 41 ++-- opa-builtins/README.md | 8 +- .../opa-builtins-json/build.gradle.kts | 3 + .../opa/ast/builtin/impls/JsonBuiltins.java | 9 +- .../opa-builtins-token/build.gradle.kts | 3 + .../opa/ast/builtin/impls/TokenBuiltins.java | 4 +- opa-evaluator/README.md | 32 +-- opa-evaluator/build.gradle.kts | 10 +- .../opa/ast/types/RegoArray.java | 32 --- .../opa/ast/types/RegoBigInt.java | 2 - .../opa/ast/types/RegoBoolean.java | 2 - .../opa/ast/types/RegoDecimal.java | 2 - .../opa/ast/types/RegoInt32.java | 2 - .../opa/ast/types/RegoNull.java | 2 - .../opa/ast/types/RegoObject.java | 92 +------- .../opa/ast/types/RegoSet.java | 2 - .../opa/ast/types/RegoString.java | 2 - .../opa/ast/types/RegoUndefined.java | 2 - .../open_policy_agent/opa/bundle/Bundle.java | 8 +- .../opa/bundle/BundleAssembler.java | 17 +- .../opa/bundle/BundleParser.java | 21 ++ .../opa/mapper/AnnotationIntrospector.java | 55 +++++ .../opa/mapper/AnnotationIntrospectors.java | 25 ++ .../opa/mapper/ClassInfo.java | 117 +++------ .../opa/mapper/CreatorInfo.java | 16 +- .../mapper/DefaultAnnotationIntrospector.java | 53 +++++ .../opa/mapper/RegoMapper.java | 6 +- .../opa/rego/Capabilities.java | 26 +- .../open_policy_agent/opa/rego/Engine.java | 93 +++----- .../opa/storage/AbstractStore.java | 12 +- .../builtin/CapabilitiesGeneratorTest.java | 9 +- .../bundle/FileSystemBundleLoaderTest.java | 12 +- .../NdBuiltinCacheTrackingTest.java | 2 +- .../opa/ir/ComplianceTest.java | 4 +- .../opa/ir/EvaluatorTest.java | 2 +- .../opa/rego/CrossBundleDataTest.java | 24 +- .../opa/rego/EngineEvaluateTest.java | 24 +- .../opa/rego/EngineHotReloadTest.java | 32 +-- .../opa/rego/EngineTest.java | 4 +- .../opa/rego/JsonNodeBridge.java | 38 +++ .../opa/storage/ConflictingRootsTest.java | 16 +- .../JacksonAnnotationIntrospector.java | 120 ++++++++++ .../opa/jackson/JacksonBundleParser.java | 34 +++ .../opa/jackson/JacksonCapabilities.java | 30 +++ .../opa/jackson/RegoValueModule.java | 222 ++++++++++++++++++ .../com.fasterxml.jackson.databind.Module | 1 + ....open_policy_agent.opa.bundle.BundleParser | 1 + ...cy_agent.opa.mapper.AnnotationIntrospector | 1 + opa-services/README.md | 10 +- opa-services/build.gradle.kts | 4 +- .../io/github/open_policy_agent/opa/Opa.java | 15 +- .../opa/plugins/DecisionLogPlugin.java | 5 +- .../opa/plugins/StatusPlugin.java | 5 +- .../opa/OpaHotReloadTest.java | 24 +- .../opa/bundle/TarballBundleLoaderTest.java | 2 +- 55 files changed, 884 insertions(+), 456 deletions(-) create mode 100644 opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleParser.java create mode 100644 opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospector.java create mode 100644 opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java create mode 100644 opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/DefaultAnnotationIntrospector.java create mode 100644 opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/JsonNodeBridge.java create mode 100644 opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonAnnotationIntrospector.java create mode 100644 opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonBundleParser.java create mode 100644 opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonCapabilities.java create mode 100644 opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/RegoValueModule.java create mode 100644 opa-jackson/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module create mode 100644 opa-jackson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.bundle.BundleParser create mode 100644 opa-jackson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.mapper.AnnotationIntrospector diff --git a/README.md b/README.md index ca3f57b3..d631dba9 100644 --- a/README.md +++ b/README.md @@ -112,11 +112,10 @@ The Engine API provides direct policy evaluation without plugin infrastructure. (when using FileSystemBundleLoader, the IR plan.json is expected to be in the given path) ```java -import io.github.open-policy-agent.rego.Engine; -import io.github.open-policy-agent.bundle.FileSystemBundleLoader; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.open_policy_agent.opa.rego.Engine; +import io.github.open_policy_agent.opa.bundle.FileSystemBundleLoader; import java.util.List; +import java.util.Map; // Build the engine with the example policy bundle Engine engine = new Engine.Builder() @@ -128,11 +127,11 @@ Engine engine = new Engine.Builder() Engine.PreparedQuery query = engine.prepareForEvaluation().build(); // Evaluate with input - alice is an authorized reader -ObjectMapper mapper = new ObjectMapper(); -JsonNode input = mapper.readTree("{\"user\": \"alice\", \"action\": \"read\"}"); -List results = query.eval(input); +Map input = Map.of("user", "alice", "action", "read"); +List results = query.eval(input); -boolean allowed = results.get(0).get("result").asBoolean(); // true +@SuppressWarnings("unchecked") +boolean allowed = (Boolean) ((Map) results.get(0)).get("result"); // true ``` ### Opa API (Full Runtime) @@ -143,8 +142,8 @@ The Opa API provides a complete OPA runtime with plugin support. Best for produc #### Opa API with Programmatic Config ```java -import io.github.open-policy-agent.Opa; -import io.github.open-policy-agent.config.Config; +import io.github.open_policy_agent.opa.Opa; +import io.github.open_policy_agent.opa.config.Config; Config config = new Config() .addService(new Config.ServiceConfig() @@ -170,7 +169,7 @@ boolean allowed = decision.getResult().asBoolean(); // true ```java -import io.github.open-policy-agent.Opa; +import io.github.open_policy_agent.opa.Opa; // Initialize with a YAML configuration file Opa opa = new Opa.Builder() @@ -373,8 +372,8 @@ Engine.PreparedQuery query = engine.prepareForEvaluation() .build(); // Evaluate many times -for (JsonNode input : inputs) { - List results = query.eval(input); +for (Object input : inputs) { + List results = query.eval(input); } ``` @@ -419,7 +418,7 @@ Engine engine = new Engine.Builder() engine.refresh(); // Next evaluation uses the new policy; data is already live -List results = engine.evaluate(ctx, input); +List results = engine.evaluate(ctx, input); ``` ### PreparedQuery behavior @@ -441,7 +440,7 @@ Engine.PreparedQuery newPq = engine.prepareForEvaluation().build(); Register custom builtin functions to extend policy capabilities: ```java -import io.github.open-policy-agent.ast.types.*; +import io.github.open_policy_agent.opa.ast.types.*; Engine engine = new Engine.Builder() .withBundleLoader(new FileSystemBundleLoader("authz", Path.of("/policy"))) @@ -470,8 +469,8 @@ allow if { OPA's `print()` builtin is supported for debugging policy evaluation. By default, print output is written via the `Logger` interface. Configure a custom `PrintHook` to redirect output: ```java -import io.github.open-policy-agent.rego.PrintHook; -import io.github.open-policy-agent.logging.Logger; +import io.github.open_policy_agent.opa.rego.PrintHook; +import io.github.open_policy_agent.opa.logging.Logger; // Use a Logger instance Logger myLogger = new Logger.StandardLogger(); @@ -503,12 +502,12 @@ When evaluated, this prints: `evaluating user: alice action: read` All SDK exceptions extend `OpaException` and support contextual information via `.withContext()`: ```java -import io.github.open-policy-agent.OpaException; -import io.github.open-policy-agent.PolicyNotFoundException; -import io.github.open-policy-agent.EvaluationException; +import io.github.open_policy_agent.opa.OpaException; +import io.github.open_policy_agent.opa.ir.PolicyNotFoundException; +import io.github.open_policy_agent.opa.ir.EvaluationException; try { - List results = query.eval(input); + List results = query.eval(input); } catch (PolicyNotFoundException e) { System.err.println("Policy not found: " + e.getMessage()); } catch (EvaluationException e) { diff --git a/opa-builtins/README.md b/opa-builtins/README.md index 40280ba8..abbcb394 100644 --- a/opa-builtins/README.md +++ b/opa-builtins/README.md @@ -102,9 +102,9 @@ Note: String builtins (`contains`, `concat`, `split`, `sprintf`, `trim`, etc.) a Implement the `BuiltinProvider` interface and register via ServiceLoader: ```java -import io.github.openpolicyagent.opa.ast.builtin.BuiltinProvider; -import io.github.openpolicyagent.opa.rego.EvaluationContext; -import io.github.openpolicyagent.opa.ast.types.*; +import io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider; +import io.github.open_policy_agent.opa.rego.EvaluationContext; +import io.github.open_policy_agent.opa.ast.types.*; import java.util.Map; import java.util.function.BiFunction; @@ -121,7 +121,7 @@ public class MyBuiltinProvider implements BuiltinProvider { } ``` -Register in `META-INF/services/io.github.openpolicyagent.opa.ast.builtin.BuiltinProvider`: +Register in `META-INF/services/io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider`: ``` com.example.MyBuiltinProvider diff --git a/opa-builtins/opa-builtins-json/build.gradle.kts b/opa-builtins/opa-builtins-json/build.gradle.kts index bb22cfcd..3505493d 100644 --- a/opa-builtins/opa-builtins-json/build.gradle.kts +++ b/opa-builtins/opa-builtins-json/build.gradle.kts @@ -14,6 +14,9 @@ dependencies { implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + // RegoValueModule (auto-registered via Jackson SPI) provides (de)serializers for the AST + // types so they don't need to carry annotations. + runtimeOnly(project(":opa-jackson")) } java { diff --git a/opa-builtins/opa-builtins-json/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/JsonBuiltins.java b/opa-builtins/opa-builtins-json/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/JsonBuiltins.java index b49e8f91..47b1f2eb 100644 --- a/opa-builtins/opa-builtins-json/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/JsonBuiltins.java +++ b/opa-builtins/opa-builtins-json/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/JsonBuiltins.java @@ -83,9 +83,11 @@ public void serialize(RegoNull value, JsonGenerator gen, SerializerProvider seri }; static { - JSON_MAPPER = new ObjectMapper(); + // findAndRegisterModules picks up RegoValueModule (from opa-jackson) via SPI so + // RegoString/RegoArray/RegoObject etc. (de)serialize without annotations on the AST types. + JSON_MAPPER = new ObjectMapper().findAndRegisterModules(); - // Register custom serializers for Rego numeric types + // Register custom serializers for Rego numeric types (overrides RegoValueModule defaults). SimpleModule module = new SimpleModule(); module.addSerializer(RegoDecimal.class, REGO_DECIMAL_SERIALIZER); module.addSerializer(RegoBigInt.class, REGO_BIG_INT_SERIALIZER); @@ -101,7 +103,8 @@ public void serialize(RegoNull value, JsonGenerator gen, SerializerProvider seri YAML_MAPPER = new ObjectMapper( - YAMLFactory.builder().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER).build()); + YAMLFactory.builder().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER).build()) + .findAndRegisterModules(); YAML_MAPPER.registerModule(yamlModule); } diff --git a/opa-builtins/opa-builtins-token/build.gradle.kts b/opa-builtins/opa-builtins-token/build.gradle.kts index 06c0acf8..5d5e2d54 100644 --- a/opa-builtins/opa-builtins-token/build.gradle.kts +++ b/opa-builtins/opa-builtins-token/build.gradle.kts @@ -12,6 +12,9 @@ dependencies { implementation("com.nimbusds:nimbus-jose-jwt:10.5") implementation("org.bouncycastle:bcpkix-jdk18on:1.82") implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0") + // RegoValueModule provides Jackson (de)serialization for RegoObject/RegoArray/etc. + // Discovered automatically via Jackson's findAndRegisterModules() SPI. + runtimeOnly(project(":opa-jackson")) } java { diff --git a/opa-builtins/opa-builtins-token/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/TokenBuiltins.java b/opa-builtins/opa-builtins-token/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/TokenBuiltins.java index 3b483947..20a3a22f 100644 --- a/opa-builtins/opa-builtins-token/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/TokenBuiltins.java +++ b/opa-builtins/opa-builtins-token/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/TokenBuiltins.java @@ -41,7 +41,9 @@ public class TokenBuiltins implements BuiltinProvider { private static final RegoString CERT_PROPERTY = new RegoString("cert"); private static final RegoString SECRET_PROPERTY = new RegoString("secret"); private static final RegoObject BLANK_OBJECT = new RegoObject(); - private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + // Auto-register RegoValueModule (and any other Jackson modules on the classpath) via SPI so + // RegoObject (de)serialization works without the AST types carrying Jackson annotations. + private static final ObjectMapper JSON_MAPPER = new ObjectMapper().findAndRegisterModules(); static { Security.addProvider(new BouncyCastleProvider()); diff --git a/opa-evaluator/README.md b/opa-evaluator/README.md index 6bf546a2..2ad7e0b4 100644 --- a/opa-evaluator/README.md +++ b/opa-evaluator/README.md @@ -9,11 +9,10 @@ The evaluator module provides the `Engine` class for direct policy evaluation. I ## Usage ```java -import io.github.openpolicyagent.opa.rego.Engine; -import io.github.openpolicyagent.opa.bundle.FileSystemBundleLoader; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.open_policy_agent.opa.rego.Engine; +import io.github.open_policy_agent.opa.bundle.FileSystemBundleLoader; import java.util.List; +import java.util.Map; Engine engine = new Engine.Builder() .withBundleLoader(new FileSystemBundleLoader("authz", Path.of("policy"))) @@ -22,14 +21,13 @@ Engine engine = new Engine.Builder() Engine.PreparedQuery query = engine.prepareForEvaluation().build(); -ObjectMapper mapper = new ObjectMapper(); -JsonNode input = mapper.readTree("{\"user\": \"alice\"}"); -List results = query.eval(input); +Map input = Map.of("user", "alice"); +List results = query.eval(input); ``` ### POJO Input/Output -The Engine supports typed input and output via Jackson: +The Engine supports typed input and output: ```java List results = engine.prepareForEvaluation() @@ -40,27 +38,29 @@ List results = engine.prepareForEvaluation() ### Multiple Queries ```java +Map input = Map.of("user", "alice"); + // Default query Engine.PreparedQuery allowQuery = engine.prepareForEvaluation().build(); -List allowResults = allowQuery.eval(input); +List allowResults = allowQuery.eval(input); // Override with a different query Engine.PreparedQuery denyQuery = engine.prepareForEvaluation() .withEntrypoint("example/deny") .build(); -List denyResults = denyQuery.eval(input); +List denyResults = denyQuery.eval(input); ``` ### Metrics and Profiling ```java -import io.github.openpolicyagent.opa.metrics.Metrics; -import io.github.openpolicyagent.opa.tracing.Profiler; +import io.github.open_policy_agent.opa.metrics.Metrics; +import io.github.open_policy_agent.opa.tracing.Profiler; Metrics metrics = new SimpleMetrics(); Profiler profiler = new Profiler(); -List results = engine.prepareForEvaluation() +List results = engine.prepareForEvaluation() .withMetrics(metrics) .withProfiler(profiler) .build() @@ -70,7 +70,7 @@ List results = engine.prepareForEvaluation() ### Custom Builtins ```java -import io.github.openpolicyagent.opa.ast.types.*; +import io.github.open_policy_agent.opa.ast.types.*; Engine engine = new Engine.Builder() .withBundleLoader(new FileSystemBundleLoader("authz", Path.of("/policy"))) @@ -99,7 +99,7 @@ public class MyBuiltinProvider implements BuiltinProvider { } ``` -Register in `META-INF/services/io.github.openpolicyagent.opa.ast.builtin.BuiltinProvider`. +Register in `META-INF/services/io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider`. ### PolicyReader SPI @@ -122,7 +122,7 @@ The Engine supports hot-reloading of policies and data, following the same seman engine.refresh(); // picks up new policy from store // Direct evaluate uses the new policy immediately -List results = engine.evaluate(ctx, input); +List results = engine.evaluate(ctx, input); // Existing PreparedQuery still uses old policy -- re-prepare to pick up changes Engine.PreparedQuery freshPq = engine.prepareForEvaluation().build(); diff --git a/opa-evaluator/build.gradle.kts b/opa-evaluator/build.gradle.kts index fbd65e25..e1b0160c 100644 --- a/opa-evaluator/build.gradle.kts +++ b/opa-evaluator/build.gradle.kts @@ -9,10 +9,12 @@ repositories { } dependencies { - // Jackson is a compile-only dependency: the Engine JsonNode API, Bundle.manifest, - // Capabilities, and IR class annotations require it at compile time. - // At runtime, jackson-databind is provided transitively via the opa-jackson module. - compileOnly("com.fasterxml.jackson.core:jackson-databind:2.17.0") + // The evaluator has no direct dependency on a JSON library. JSON IO is provided by external + // modules through SPIs: + // - PolicyReader (io.github.open_policy_agent.opa.ir) + // - BundleParser (io.github.open_policy_agent.opa.bundle) + // - AnnotationIntrospector (io.github.open_policy_agent.opa.mapper) + // The opa-jackson module supplies a Jackson-backed implementation of all three. testImplementation(project(":opa-jackson")) testImplementation("com.fasterxml.jackson.core:jackson-databind:2.17.0") diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoArray.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoArray.java index ac820f05..dbcb7bfd 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoArray.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoArray.java @@ -1,9 +1,5 @@ package io.github.open_policy_agent.opa.ast.types; -import com.fasterxml.jackson.annotation.JsonValue; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.math.BigInteger; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -38,34 +34,6 @@ public boolean contains(RegoValue value) { } } - public static RegoArray fromJsonNode(ObjectMapper mapper, JsonNode arrayNode) throws Exception { - RegoArray array = new RegoArray(); - for (JsonNode element : arrayNode) { - if (element.isObject()) { - array.addValue(mapper.treeToValue(element, RegoObject.class)); - } else if (element.isArray()) { - array.addValue(fromJsonNode(mapper, element)); - } else if (element.isTextual()) { - array.addValue(new RegoString(element.asText())); - } else if (element.isNumber()) { - if (element.isIntegralNumber()) { - array.addValue(new RegoBigInt(BigInteger.valueOf(element.asLong()))); - } else { - array.addValue(new RegoDecimal(element.doubleValue())); - } - } else if (element.isBoolean()) { - array.addValue(RegoBoolean.of(element.asBoolean())); - } else if (element.isNull()) { - array.addValue(RegoNull.INSTANCE); - } else { - throw new UnsupportedOperationException( - "Unsupported JSON element type: " + element.getNodeType()); - } - } - return array; - } - - @JsonValue public List getValue() { return values; } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoBigInt.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoBigInt.java index 48b875cc..87161f7a 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoBigInt.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoBigInt.java @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.ast.types; -import com.fasterxml.jackson.annotation.JsonValue; import java.math.BigInteger; import java.util.Objects; @@ -19,7 +18,6 @@ public void setValue(BigInteger i) { this.value = i; } - @JsonValue public BigInteger getValue() { return value; } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoBoolean.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoBoolean.java index 3821a7b6..1708e342 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoBoolean.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoBoolean.java @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.ast.types; -import com.fasterxml.jackson.annotation.JsonValue; public class RegoBoolean implements RegoValue { @@ -13,7 +12,6 @@ private RegoBoolean(boolean value) { this.value = value; } - @JsonValue public boolean getValue() { return value; } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoDecimal.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoDecimal.java index 4d02891a..83d45bce 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoDecimal.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoDecimal.java @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.ast.types; -import com.fasterxml.jackson.annotation.JsonValue; import java.math.BigInteger; import java.util.Objects; @@ -22,7 +21,6 @@ public RegoDecimal(Float f) { this.value = f.doubleValue(); } - @JsonValue public Double getValue() { return value; } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoInt32.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoInt32.java index 3cafbac1..32ec0a3e 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoInt32.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoInt32.java @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.ast.types; -import com.fasterxml.jackson.annotation.JsonValue; import java.math.BigInteger; import java.util.Objects; @@ -48,7 +47,6 @@ public void setValue(Integer i) { this.value = i; } - @JsonValue public Integer getValue() { return value; } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoNull.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoNull.java index a5630cf0..0bdd0fa7 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoNull.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoNull.java @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.ast.types; -import com.fasterxml.jackson.annotation.JsonValue; public class RegoNull implements RegoValue { @@ -8,7 +7,6 @@ public class RegoNull implements RegoValue { private RegoNull() {} - @JsonValue private Object getProperty() { return null; } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoObject.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoObject.java index e7954ee1..f066e8fe 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoObject.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoObject.java @@ -1,13 +1,10 @@ package io.github.open_policy_agent.opa.ast.types; -import com.fasterxml.jackson.annotation.JsonAnySetter; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonValue; import java.util.*; import java.util.stream.Stream; public class RegoObject implements RegoValue { - @JsonIgnore private final Map value; + private final Map value; public RegoObject() { this.value = new LinkedHashMap<>(); @@ -29,24 +26,7 @@ public boolean hasProperty(RegoValue property) { return value.containsKey(property); } - /** - * For JSON serialization - Jackson uses this to convert to JSON. We convert RegoValue keys to - * strings for JSON compatibility, and sort them to match OPA's output format. - */ - @JsonValue - public Map getPropertiesAsStringMap() { - // For JSON serialization, convert keys to strings and sort them - // OPA (written in Go) outputs JSON with sorted keys, so we match that behavior - Map stringMap = new TreeMap<>(); - for (Map.Entry entry : value.entrySet()) { - String key = keyToString(entry.getKey()); - stringMap.put(key, entry.getValue()); - } - return stringMap; - } - /** Get the internal map with RegoValue keys. This is used for runtime operations. */ - @JsonIgnore public Map getProperties() { return value; } @@ -56,33 +36,12 @@ public RegoValue setProp(RegoValue property, RegoValue value) { } /** - * For JSON deserialization - Jackson calls this for each property. We convert string keys to - * RegoString for internal storage. + * Programmatic setter used by callers that want to add a property by string name. The + * {@code opa-jackson} {@code RegoValueModule} converts JSON values directly during + * deserialization, so this method no longer needs to dispatch on a generic {@link Object}. */ - @JsonAnySetter - public void setProperty(String name, Object value) { - // Convert the raw Object to appropriate RegoValue - RegoValue regoValue = convertToRegoValue(value); - // Try to parse the key as a number, otherwise use as string - RegoValue key = parseKeyFromString(name); - this.value.put(key, regoValue); - } - - private RegoValue parseKeyFromString(String key) { - // JSON object keys are always strings per the JSON spec. - // OPA coerces integer references to string keys during data traversal (handled in the - // evaluator's DotStmt), not at deserialization time. - return new RegoString(key); - } - - private String keyToString(RegoValue key) { - if (key instanceof RegoString) { - return ((RegoString) key).getValue(); - } else if (key instanceof RegoNumber) { - return ((RegoNumber) key).getBigIntValue().toString(); - } else { - return key.toString(); - } + public void setProperty(String name, RegoValue value) { + this.value.put(new RegoString(name), value); } public Stream> stream() { @@ -103,45 +62,6 @@ public Object nativeValue() { return nativeMap; } - private RegoValue convertToRegoValue(Object value) { - if (value == null) { - return RegoNull.INSTANCE; - } else if (value instanceof RegoValue) { - return (RegoValue) value; - } else if (value instanceof Boolean) { - return RegoBoolean.of((Boolean) value); - } else if (value instanceof String) { - return new RegoString((String) value); - } else if (value instanceof Integer) { - return RegoInt32.of((Integer) value); - } else if (value instanceof Long) { - return new RegoBigInt((Long) value); - } else if (value instanceof java.math.BigDecimal) { - // BigDecimal from Jackson when USE_BIG_DECIMAL_FOR_FLOATS is enabled - return new RegoDecimal(((java.math.BigDecimal) value).doubleValue()); - } else if (value instanceof Double || value instanceof Float) { - // Handle Double and Float - return new RegoDecimal(((Number) value).doubleValue()); - } else if (value instanceof Map) { - RegoObject obj = new RegoObject(); - for (Map.Entry entry : ((Map) value).entrySet()) { - if (entry.getKey() instanceof String) { - obj.setProperty((String) entry.getKey(), convertToRegoValue(entry.getValue())); - } - } - return obj; - } else if (value instanceof java.util.List) { - RegoArray array = new RegoArray(); - for (Object item : (java.util.List) value) { - array.addValue(convertToRegoValue(item)); - } - return array; - } else { - // For numbers and other types, convert to string for now - return new RegoString(value.toString()); - } - } - /** * Asymmetric recursive union of two objects. Conflicts are resolved by choosing the value from * the right-hand object (other). When both values are objects, they are recursively merged. diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoSet.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoSet.java index c3186630..bc4b9566 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoSet.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoSet.java @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.ast.types; -import com.fasterxml.jackson.annotation.JsonValue; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -34,7 +33,6 @@ public boolean contains(RegoValue value) { } } - @JsonValue @SuppressWarnings("PMD.CompareObjectsWithEquals") public Set getValue() { if (!sorted) { diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoString.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoString.java index c19ebaeb..e4a9e783 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoString.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoString.java @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.ast.types; -import com.fasterxml.jackson.annotation.JsonValue; import java.math.BigInteger; import java.util.Objects; @@ -12,7 +11,6 @@ public RegoString(String value) { this.value = value; } - @JsonValue public String getValue() { return value; } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoUndefined.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoUndefined.java index 73b409fa..d067476c 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoUndefined.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ast/types/RegoUndefined.java @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.ast.types; -import com.fasterxml.jackson.annotation.JsonValue; public class RegoUndefined implements RegoValue { @@ -8,7 +7,6 @@ public class RegoUndefined implements RegoValue { private RegoUndefined() {} - @JsonValue private Object getProperty() { return null; } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/Bundle.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/Bundle.java index 60e49bf8..3c883b06 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/Bundle.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/Bundle.java @@ -1,14 +1,12 @@ package io.github.open_policy_agent.opa.bundle; -import com.fasterxml.jackson.databind.JsonNode; import java.util.HashMap; import java.util.Map; import io.github.open_policy_agent.opa.ir.policy.Policy; public class Bundle { - // public RegoObject data; public final Policy irPolicy; - public final JsonNode manifest; + public final Map manifest; public final Map rego; private Bundle(Builder builder) { @@ -19,7 +17,7 @@ private Bundle(Builder builder) { public static class Builder { private final Map rego = new HashMap<>(); - private JsonNode manifest; + private Map manifest; private Policy irPolicy; public Builder withRego(String path, String rego) { @@ -27,7 +25,7 @@ public Builder withRego(String path, String rego) { return this; } - public Builder withManifest(JsonNode manifest) { + public Builder withManifest(Map manifest) { this.manifest = manifest; return this; } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java index 37329789..b3c965a4 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java @@ -4,8 +4,6 @@ import io.github.open_policy_agent.opa.ast.types.RegoString; import io.github.open_policy_agent.opa.ir.PolicyReader; import io.github.open_policy_agent.opa.storage.Store; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; @@ -34,7 +32,6 @@ * } */ public class BundleAssembler { - private static final ObjectMapper MAPPER = new ObjectMapper(); static final PolicyReader POLICY_READER = ServiceLoader.load(PolicyReader.class) .findFirst() @@ -44,6 +41,15 @@ public class BundleAssembler { "No PolicyReader implementation found on the classpath. " + "Add a module that provides PolicyReader (e.g. opa-jackson).")); + static final BundleParser BUNDLE_PARSER = + ServiceLoader.load(BundleParser.class) + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + "No BundleParser implementation found on the classpath. " + + "Add a module that provides BundleParser (e.g. opa-jackson).")); + private final Bundle.Builder builder = new Bundle.Builder(); private RegoObject data; private boolean hasContent; @@ -64,8 +70,7 @@ public void loadPlan(InputStream in) throws IOException { * @param in the data.json input stream */ public void loadData(String path, InputStream in) throws IOException { - JsonNode root = MAPPER.readTree(in); - RegoObject parsed = MAPPER.treeToValue(root, RegoObject.class); + RegoObject parsed = BUNDLE_PARSER.parseData(in); if (data == null) { data = new RegoObject(); @@ -101,7 +106,7 @@ public void loadData(String path, InputStream in) throws IOException { /** Load bundle metadata from a {@code .manifest} stream. */ public void loadManifest(InputStream in) throws IOException { - builder.withManifest(MAPPER.readTree(in)); + builder.withManifest(BUNDLE_PARSER.parseManifest(in)); } /** Add a Rego source file by its relative path. */ diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleParser.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleParser.java new file mode 100644 index 00000000..cb0b5496 --- /dev/null +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleParser.java @@ -0,0 +1,21 @@ +package io.github.open_policy_agent.opa.bundle; + +import io.github.open_policy_agent.opa.ast.types.RegoObject; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +/** + * SPI for parsing bundle JSON streams without exposing a specific JSON library to the evaluator. + * + *

Register implementations via {@link java.util.ServiceLoader}. The {@code opa-jackson} module + * provides a Jackson-based implementation. + */ +public interface BundleParser { + + /** Parse a {@code data.json} stream into a {@link RegoObject}. */ + RegoObject parseData(InputStream in) throws IOException; + + /** Parse a {@code .manifest} stream into a plain Map tree (no third-party JSON types). */ + Map parseManifest(InputStream in) throws IOException; +} diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospector.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospector.java new file mode 100644 index 00000000..4c57a96f --- /dev/null +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospector.java @@ -0,0 +1,55 @@ +package io.github.open_policy_agent.opa.mapper; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +/** + * SPI for resolving annotation-driven property metadata on user POJOs without binding the + * evaluator to a specific JSON library. Implementations may inspect Jackson, Gson, or any other + * annotation set; the evaluator's mapper consults this introspector when discovering properties, + * creators, and visibility. + * + *

Discovered via {@link java.util.ServiceLoader}. The {@code opa-jackson} module provides a + * Jackson-aware implementation. If no implementation is found, {@link DefaultAnnotationIntrospector} + * is used, which honors only standard JavaBean conventions. + */ +public interface AnnotationIntrospector { + + /** + * Return the JSON name override for a property, or {@code null} if no annotation overrides the + * default (bean-derived) name. Either {@code getter} or {@code backingField} may be null. + */ + String findPropertyName(Method getter, Field backingField); + + /** Return true if a property is annotated as ignored. Either argument may be null. */ + boolean isIgnored(Method getter, Field backingField); + + /** Return true if a property is annotated to be omitted when null. Either argument may be null. */ + boolean isNonNullInclude(Method getter, Field backingField); + + /** Return the JSON name for a constructor/factory parameter, or {@code null} if not annotated. */ + String findCreatorParamName(Parameter param); + + /** Return true if a constructor is annotated as a JSON creator (in non-delegating mode). */ + boolean isJsonCreator(Constructor ctor); + + /** Return true if a static factory method is annotated as a JSON creator (in non-delegating mode). */ + boolean isJsonCreator(Method method); + + /** Return field-visibility override declared on the class, or {@code null} for the default. */ + Visibility findFieldVisibility(Class clazz); + + /** Return true if a method is marked as the single value for the enclosing type. */ + boolean isJsonValue(Method method); + + /** Field-visibility levels (matches Jackson's {@code JsonAutoDetect.Visibility} semantics). */ + enum Visibility { + ANY, + NON_PRIVATE, + PROTECTED_AND_PUBLIC, + PUBLIC_ONLY, + NONE + } +} diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java new file mode 100644 index 00000000..21fa9c4b --- /dev/null +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java @@ -0,0 +1,25 @@ +package io.github.open_policy_agent.opa.mapper; + +import java.util.ServiceLoader; + +/** + * Static accessor for the active {@link AnnotationIntrospector}. Discovers an implementation via + * {@link ServiceLoader}; if none is registered, falls back to {@link DefaultAnnotationIntrospector} + * (which performs no annotation lookups and so honors only JavaBean conventions). + */ +final class AnnotationIntrospectors { + + private static final AnnotationIntrospector INSTANCE = load(); + + private AnnotationIntrospectors() {} + + static AnnotationIntrospector get() { + return INSTANCE; + } + + private static AnnotationIntrospector load() { + return ServiceLoader.load(AnnotationIntrospector.class) + .findFirst() + .orElseGet(DefaultAnnotationIntrospector::new); + } +} diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/ClassInfo.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/ClassInfo.java index 99f79c35..16a4c1f4 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/ClassInfo.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/ClassInfo.java @@ -1,11 +1,5 @@ package io.github.open_policy_agent.opa.mapper; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -22,18 +16,22 @@ * Cached introspection metadata for a JavaBean class. Built once per class, then reused for all * subsequent conversions. * - *

Property discovery follows Jackson conventions: + *

Property discovery follows JavaBean conventions: * *

    *
  1. JavaBean getters ({@code getX()}/{@code isX()}) — always discovered *
  2. Public fields — discovered if no getter exists for the same property - *
  3. {@code @JsonProperty}-annotated fields — discovered regardless of visibility + *
  4. Annotation-tagged fields (per the active {@link AnnotationIntrospector}) — discovered + * regardless of visibility *
* - *

Jackson annotations ({@code @JsonProperty}, {@code @JsonIgnore}, {@code @JsonInclude}) - * override defaults when present on the getter or the backing field. + *

Annotation-driven overrides (property name, ignore, NON_NULL inclusion, creator, visibility) + * are resolved through the {@link AnnotationIntrospector} SPI so this class has no direct + * dependency on a specific JSON library. */ final class ClassInfo { + private static final AnnotationIntrospector INTROSPECTOR = AnnotationIntrospectors.get(); + private final List properties; private final Constructor noArgConstructor; private final CreatorInfo creatorInfo; @@ -89,16 +87,16 @@ static ClassInfo buildFor(Class clazz) { claimedFieldNames.add(backingField.getName()); } - // Check @JsonIgnore on getter or field - if (hasAnnotation(method, backingField, JsonIgnore.class)) { + // Check if the property is annotated as ignored (via getter or backing field) + if (INTROSPECTOR.isIgnored(method, backingField)) { continue; } - // Resolve JSON name: @JsonProperty overrides derived name + // Resolve JSON name: annotation override beats derived name String jsonName = resolveJsonName(method, backingField, propertyName); - // Check @JsonInclude(NON_NULL) on getter or field - boolean includeNonNull = hasIncludeNonNull(method, backingField); + // Check if NON_NULL inclusion is annotated on getter or field + boolean includeNonNull = INTROSPECTOR.isNonNullInclude(method, backingField); Class rawType = method.getReturnType(); Type genericType = method.getGenericReturnType(); @@ -117,11 +115,10 @@ static ClassInfo buildFor(Class clazz) { } // Phase 2: Discover properties via fields not already found through getters. - // Honors @JsonAutoDetect(fieldVisibility) when present; otherwise defaults to - // public fields and @JsonProperty-annotated fields. - JsonAutoDetect.Visibility fieldVisibility = resolveFieldVisibility(clazz); + // Field visibility comes from the introspector when present; otherwise PUBLIC_ONLY. + AnnotationIntrospector.Visibility fieldVisibility = resolveFieldVisibility(clazz); for (Field field : getAllFields(clazz)) { - if (field.isAnnotationPresent(JsonIgnore.class)) { + if (INTROSPECTOR.isIgnored(null, field)) { continue; } if (Modifier.isStatic(field.getModifiers())) { @@ -133,23 +130,19 @@ static ClassInfo buildFor(Class clazz) { continue; } - boolean hasJsonProperty = field.isAnnotationPresent(JsonProperty.class); - if (!hasJsonProperty && !isFieldVisible(field, fieldVisibility)) { + String annotatedName = INTROSPECTOR.findPropertyName(null, field); + boolean hasAnnotatedName = annotatedName != null && !annotatedName.isEmpty(); + if (!hasAnnotatedName && !isFieldVisible(field, fieldVisibility)) { continue; } - JsonProperty jp = field.getAnnotation(JsonProperty.class); - String jsonName = (jp != null && !jp.value().isEmpty()) ? jp.value() : field.getName(); + String jsonName = hasAnnotatedName ? annotatedName : field.getName(); if (discoveredNames.contains(jsonName)) { continue; } - boolean includeNonNull = false; - JsonInclude ji = field.getAnnotation(JsonInclude.class); - if (ji != null && ji.value() == JsonInclude.Include.NON_NULL) { - includeNonNull = true; - } + boolean includeNonNull = INTROSPECTOR.isNonNullInclude(null, field); if (!setAccessibleQuietly(field)) { continue; @@ -168,17 +161,14 @@ static ClassInfo buildFor(Class clazz) { return new ClassInfo(props, ctor, creatorInfo); } - /** Resolve field visibility from {@code @JsonAutoDetect}, defaulting to PUBLIC_ONLY. */ - private static JsonAutoDetect.Visibility resolveFieldVisibility(Class clazz) { - JsonAutoDetect ann = clazz.getAnnotation(JsonAutoDetect.class); - if (ann != null && ann.fieldVisibility() != JsonAutoDetect.Visibility.DEFAULT) { - return ann.fieldVisibility(); - } - return JsonAutoDetect.Visibility.PUBLIC_ONLY; + /** Resolve field visibility from the introspector, defaulting to PUBLIC_ONLY. */ + private static AnnotationIntrospector.Visibility resolveFieldVisibility(Class clazz) { + AnnotationIntrospector.Visibility v = INTROSPECTOR.findFieldVisibility(clazz); + return v != null ? v : AnnotationIntrospector.Visibility.PUBLIC_ONLY; } /** Check if a field is visible under the given visibility level. */ - private static boolean isFieldVisible(Field field, JsonAutoDetect.Visibility visibility) { + private static boolean isFieldVisible(Field field, AnnotationIntrospector.Visibility visibility) { switch (visibility) { case ANY: return true; @@ -293,41 +283,9 @@ private static Field findBackingField(Class clazz, String propertyName) { return null; } - private static boolean hasAnnotation( - Method getter, Field field, Class annotationType) { - if (getter.isAnnotationPresent(annotationType)) { - return true; - } - return field != null && field.isAnnotationPresent(annotationType); - } - private static String resolveJsonName(Method getter, Field field, String defaultName) { - // Method annotation takes precedence - JsonProperty methodAnnotation = getter.getAnnotation(JsonProperty.class); - if (methodAnnotation != null && !methodAnnotation.value().isEmpty()) { - return methodAnnotation.value(); - } - if (field != null) { - JsonProperty fieldAnnotation = field.getAnnotation(JsonProperty.class); - if (fieldAnnotation != null && !fieldAnnotation.value().isEmpty()) { - return fieldAnnotation.value(); - } - } - return defaultName; - } - - private static boolean hasIncludeNonNull(Method getter, Field field) { - JsonInclude methodAnnotation = getter.getAnnotation(JsonInclude.class); - if (methodAnnotation != null - && methodAnnotation.value() == JsonInclude.Include.NON_NULL) { - return true; - } - if (field != null) { - JsonInclude fieldAnnotation = field.getAnnotation(JsonInclude.class); - return fieldAnnotation != null - && fieldAnnotation.value() == JsonInclude.Include.NON_NULL; - } - return false; + String name = INTROSPECTOR.findPropertyName(getter, field); + return (name != null && !name.isEmpty()) ? name : defaultName; } private static Method findSetter(Class clazz, String propertyName, Class type) { @@ -364,17 +322,13 @@ private static Class boxingAlternative(Class type) { } /** - * Scan for a {@code @JsonCreator} annotated constructor or static factory method. Each parameter - * must have {@code @JsonProperty} with an explicit name. Returns null if none found. + * Scan for an annotated creator constructor or static factory method via the introspector. Each + * parameter must have an annotated name. Returns null if none found. */ private static CreatorInfo findJsonCreator(Class clazz) { // Check constructors first for (Constructor ctor : clazz.getDeclaredConstructors()) { - if (!ctor.isAnnotationPresent(JsonCreator.class)) { - continue; - } - JsonCreator annotation = ctor.getAnnotation(JsonCreator.class); - if (annotation.mode() == JsonCreator.Mode.DELEGATING) { + if (!INTROSPECTOR.isJsonCreator(ctor)) { continue; } List params = resolveCreatorParams(ctor.getParameters()); @@ -385,16 +339,12 @@ private static CreatorInfo findJsonCreator(Class clazz) { // Check static factory methods for (Method method : clazz.getDeclaredMethods()) { - if (!method.isAnnotationPresent(JsonCreator.class)) { + if (!INTROSPECTOR.isJsonCreator(method)) { continue; } if (!Modifier.isStatic(method.getModifiers())) { continue; } - JsonCreator annotation = method.getAnnotation(JsonCreator.class); - if (annotation.mode() == JsonCreator.Mode.DELEGATING) { - continue; - } List params = resolveCreatorParams(method.getParameters()); if (params != null && setAccessibleQuietly(method)) { return CreatorInfo.forFactory(method, params); @@ -405,8 +355,7 @@ private static CreatorInfo findJsonCreator(Class clazz) { } /** - * Resolve creator parameters. Returns null if any parameter lacks a {@code @JsonProperty} with a - * non-empty value. + * Resolve creator parameters. Returns null if any parameter lacks an annotated name. */ private static List resolveCreatorParams(Parameter[] parameters) { List params = new ArrayList<>(); diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/CreatorInfo.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/CreatorInfo.java index f711da41..3787ed86 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/CreatorInfo.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/CreatorInfo.java @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.mapper; -import com.fasterxml.jackson.annotation.JsonProperty; import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.lang.reflect.Parameter; @@ -9,8 +8,9 @@ import java.util.List; /** - * Metadata for a {@code @JsonCreator} constructor or static factory method: the callable itself plus - * the ordered list of parameter names and types. + * Metadata for an annotated creator constructor or static factory method: the callable itself plus + * the ordered list of parameter names and types. Annotation-driven name resolution goes through + * the {@link AnnotationIntrospector} SPI so this class doesn't depend on a specific JSON library. */ final class CreatorInfo { private final Constructor constructor; @@ -79,15 +79,15 @@ Type getGenericType() { } /** - * Resolve the JSON property name from a {@code @JsonProperty} annotation on a parameter. - * Returns null if the annotation is missing or has an empty value. + * Resolve the JSON property name from the active {@link AnnotationIntrospector}. Returns null + * if no annotated name is present. */ static CreatorParam fromParameter(Parameter param) { - JsonProperty jp = param.getAnnotation(JsonProperty.class); - if (jp == null || jp.value().isEmpty()) { + String name = AnnotationIntrospectors.get().findCreatorParamName(param); + if (name == null || name.isEmpty()) { return null; } - return new CreatorParam(jp.value(), param.getType(), param.getParameterizedType()); + return new CreatorParam(name, param.getType(), param.getParameterizedType()); } } } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/DefaultAnnotationIntrospector.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/DefaultAnnotationIntrospector.java new file mode 100644 index 00000000..25fe27ec --- /dev/null +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/DefaultAnnotationIntrospector.java @@ -0,0 +1,53 @@ +package io.github.open_policy_agent.opa.mapper; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +/** + * Bean-only {@link AnnotationIntrospector}: returns no overrides, so callers fall back to standard + * JavaBean property discovery. Used when no Jackson implementation is on the classpath. + */ +final class DefaultAnnotationIntrospector implements AnnotationIntrospector { + + @Override + public String findPropertyName(Method getter, Field backingField) { + return null; + } + + @Override + public boolean isIgnored(Method getter, Field backingField) { + return false; + } + + @Override + public boolean isNonNullInclude(Method getter, Field backingField) { + return false; + } + + @Override + public String findCreatorParamName(Parameter param) { + return null; + } + + @Override + public boolean isJsonCreator(Constructor ctor) { + return false; + } + + @Override + public boolean isJsonCreator(Method method) { + return false; + } + + @Override + public Visibility findFieldVisibility(Class clazz) { + return null; + } + + @Override + public boolean isJsonValue(Method method) { + return false; + } +} diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/RegoMapper.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/RegoMapper.java index 9e4850ee..1f598d05 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/RegoMapper.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/RegoMapper.java @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.mapper; -import com.fasterxml.jackson.annotation.JsonValue; import java.lang.reflect.Array; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; @@ -169,9 +168,10 @@ public T fromRegoValue(RegoValue value, Class type) { // --- Forward conversion helpers --- private RegoValue enumToRegoValue(Enum e) { - // Check for @JsonValue method on the enum class + // Check for an @JsonValue (or equivalent) method on the enum class via the introspector + AnnotationIntrospector introspector = AnnotationIntrospectors.get(); for (Method method : e.getClass().getDeclaredMethods()) { - if (method.isAnnotationPresent(JsonValue.class) && method.getParameterCount() == 0) { + if (introspector.isJsonValue(method) && method.getParameterCount() == 0) { try { method.setAccessible(true); Object result = method.invoke(e); diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Capabilities.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Capabilities.java index 5621d2e6..70c23a03 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Capabilities.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Capabilities.java @@ -1,25 +1,16 @@ package io.github.open_policy_agent.opa.rego; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; import java.util.ArrayList; import java.util.List; import io.github.open_policy_agent.opa.ast.builtin.Descriptor; /** - * Represents OPA capabilities, including available builtin functions. Can be loaded from JSON files - * matching the opa-cap.json format. + * Represents OPA capabilities, including available builtin functions. Loaded from JSON files + * matching the opa-cap.json format. Use {@code io.github.open_policy_agent.opa.jackson.JacksonCapabilities} + * (in the opa-jackson module) for JSON IO. */ public class Capabilities { - private static final ObjectMapper MAPPER = new ObjectMapper(); - - static { - MAPPER.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL); - } - - @JsonProperty("builtins") public List builtins = new ArrayList<>(); public Capabilities() {} @@ -27,15 +18,4 @@ public Capabilities() {} public Capabilities(List builtins) { this.builtins = builtins; } - - /** Load capabilities from a JSON string */ - public static Capabilities fromJson(String json) throws IOException { - return MAPPER.readValue(json, Capabilities.class); - } - - /** Convert capabilities to JSON string */ - public String toJson() throws IOException { - - return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(this); - } } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Engine.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Engine.java index 2c35b75c..deeae854 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Engine.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Engine.java @@ -15,8 +15,6 @@ import io.github.open_policy_agent.opa.storage.Store; import io.github.open_policy_agent.opa.tracing.Profiler; import io.github.open_policy_agent.opa.tracing.QueryTracer; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.HashMap; @@ -60,14 +58,13 @@ * .withEntrypoint("example/allow") * .build(); * - * JsonNode input = mapper.readTree("{\"user\": \"alice\"}"); - * List results = engine.prepareForEvaluation() + * Map input = Map.of("user", "alice"); + * List results = engine.prepareForEvaluation() * .build() - * .eval(input); + * .eval(input, MyResult.class); * } */ public class Engine { - private static final ObjectMapper MAPPER = new ObjectMapper(); private static final RegoMapper REGO_MAPPER = new RegoMapper(); private volatile Evaluator evaluator; @@ -129,28 +126,27 @@ public PreparedQuery.Builder prepareForEvaluation() { } /** - * Evaluate with a JsonNode input and return JsonNode results. Uses the engine's current policy - * (updated by {@link #refresh()}) and reads data live from the store. + * Evaluate with a POJO input and return raw result objects. Each result is the full + * evaluation output (typically a single-key map wrapping the decision). Use this overload + * when the caller needs the wrapper structure (e.g., for re-serialization). * * @param ctx the evaluation context - * @param input the input as a Jackson JsonNode - * @return list of JsonNode results + * @param pojoInput the input as a POJO (any JavaBean-compatible object, Map, List, etc.) + * @return list of result objects (Map/List/primitive trees) */ - public List evaluate(EvaluationContext ctx, JsonNode input) { - RegoObject regoInput = parseJsonInput(ctx, input); + public List evaluate(EvaluationContext ctx, Object pojoInput) { + RegoObject regoInput = parsePojoInput(ctx, pojoInput); RegoValue[] results = evaluateCore(null, ctx, regoInput); - return marshalJsonResults(ctx, results); + return marshalRawResults(ctx, results); } /** - * Evaluate with a POJO input and return typed results. This bypasses intermediate JsonNode - * allocations by converting the POJO directly to RegoValue and the results directly to the target - * type. Uses the engine's current policy (updated by {@link #refresh()}) and reads data live from - * the store. + * Evaluate with a POJO input and return typed results. Uses the engine's current policy + * (updated by {@link #refresh()}) and reads data live from the store. * * @param the result type * @param ctx the evaluation context - * @param pojoInput the input as a POJO (any JavaBean-compatible object) + * @param pojoInput the input as a POJO (any JavaBean-compatible object, Map, List, etc.) * @param resultType the class of the desired result type * @return list of typed results */ @@ -160,11 +156,11 @@ public List evaluate(EvaluationContext ctx, Object pojoInput, Class re return marshalPojoResults(ctx, results, resultType); } - List evaluateWithPreparedPlan( - PreparedPlan preparedPlan, EvaluationContext ctx, JsonNode input) { - RegoObject regoInput = parseJsonInput(ctx, input); + List evaluateWithPreparedPlan( + PreparedPlan preparedPlan, EvaluationContext ctx, Object pojoInput) { + RegoObject regoInput = parsePojoInput(ctx, pojoInput); RegoValue[] results = evaluateCore(preparedPlan, ctx, regoInput); - return marshalJsonResults(ctx, results); + return marshalRawResults(ctx, results); } List evaluateWithPreparedPlan( @@ -190,17 +186,6 @@ private RegoValue[] evaluateCore(PreparedPlan plan, EvaluationContext ctx, RegoV } } - private RegoObject parseJsonInput(EvaluationContext ctx, JsonNode input) { - try { - ctx.metrics.timer("rego_parse_json_input").start(); - return MAPPER.treeToValue(input, RegoObject.class); - } catch (Exception e) { - throw new RuntimeException("Failed to parse JsonNode input", e); - } finally { - ctx.metrics.timer("rego_parse_json_input").stop(); - } - } - private RegoObject parsePojoInput(EvaluationContext ctx, Object input) { try { ctx.metrics.timer("rego_parse_pojo_input").start(); @@ -212,19 +197,6 @@ private RegoObject parsePojoInput(EvaluationContext ctx, Object input) { } } - private List marshalJsonResults(EvaluationContext ctx, RegoValue[] results) { - try { - ctx.metrics.timer("rego_marshal_json_results").start(); - List jsonResults = new ArrayList<>(results.length); - for (RegoValue result : results) { - jsonResults.add(MAPPER.valueToTree(result)); - } - return jsonResults; - } finally { - ctx.metrics.timer("rego_marshal_json_results").stop(); - } - } - private List marshalPojoResults( EvaluationContext ctx, RegoValue[] results, Class resultType) { try { @@ -242,6 +214,19 @@ private List marshalPojoResults( } } + private List marshalRawResults(EvaluationContext ctx, RegoValue[] results) { + try { + ctx.metrics.timer("rego_marshal_raw_results").start(); + List rawResults = new ArrayList<>(results.length); + for (RegoValue result : results) { + rawResults.add(REGO_MAPPER.fromRegoValue(result, Object.class)); + } + return rawResults; + } finally { + ctx.metrics.timer("rego_marshal_raw_results").stop(); + } + } + private static RegoValue unwrapResultKey(RegoValue value) { if (value instanceof RegoObject) { RegoValue inner = ((RegoObject) value).getProperty("result"); @@ -464,19 +449,19 @@ private PreparedQuery(Builder builder) { } /** - * Evaluate with a JsonNode input and return JsonNode results. Uses the policy captured at - * preparation time and reads data live from the store. + * Evaluate with a POJO input and return raw result objects. Each result is the full + * evaluation output (typically a single-key map wrapping the decision). Use this overload + * when the caller needs the wrapper structure. * - * @param input the input as a Jackson JsonNode - * @return list of JsonNode results + * @param input the input as a POJO (any JavaBean-compatible object, Map, List, etc.) + * @return list of result objects (Map/List/primitive trees) */ - public List eval(JsonNode input) { + public List eval(Object input) { return engine.evaluateWithPreparedPlan(preparedPlan, contextBuilder.build(), input); } /** - * Evaluate with a POJO input and return typed results. This is the most efficient - * evaluation path — it bypasses all intermediate JsonNode allocations. Uses the policy captured + * Evaluate with a POJO input and return typed results. Uses the policy captured * at preparation time and reads data live from the store. * *
{@code
@@ -484,7 +469,7 @@ public List eval(JsonNode input) {
      * }
* * @param the result type - * @param input the input as a POJO (any JavaBean-compatible object) + * @param input the input as a POJO (any JavaBean-compatible object, Map, List, etc.) * @param resultType the class of the desired result type * @return list of typed results */ diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/storage/AbstractStore.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/storage/AbstractStore.java index b5fd36fc..870e9c81 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/storage/AbstractStore.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/storage/AbstractStore.java @@ -47,8 +47,8 @@ public synchronized Map getBundles() { public String getDefaultEntrypoint() { return bundles.values().stream() .filter(bundle -> bundle.manifest != null) - .filter(bundle -> bundle.manifest.has("default_decision")) - .map(bundle -> bundle.manifest.get("default_decision").asText()) + .filter(bundle -> bundle.manifest.containsKey("default_decision")) + .map(bundle -> String.valueOf(bundle.manifest.get("default_decision"))) .findFirst() .orElse(""); } @@ -77,10 +77,10 @@ public String getDefaultEntrypoint() { * @return the root path (empty string for global root) */ private String extractRoot(Bundle bundle) { - if (bundle.manifest != null && bundle.manifest.has("roots")) { - var rootsNode = bundle.manifest.get("roots"); - if (rootsNode.isArray() && !rootsNode.isEmpty()) { - return rootsNode.get(0).asText(); + if (bundle.manifest != null && bundle.manifest.get("roots") instanceof java.util.List) { + java.util.List roots = (java.util.List) bundle.manifest.get("roots"); + if (!roots.isEmpty()) { + return String.valueOf(roots.get(0)); } } // No manifest or no roots field, default to empty root (global data root) diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/CapabilitiesGeneratorTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/CapabilitiesGeneratorTest.java index 946cf382..d3c7a0dc 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/CapabilitiesGeneratorTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ast/builtin/CapabilitiesGeneratorTest.java @@ -107,7 +107,7 @@ public void testCapabilitiesJsonSerializationWorks() throws IOException { Capabilities capabilities = BuiltinRegistry.generateCapabilities(); // Should be able to serialize to JSON - String json = capabilities.toJson(); + String json = io.github.open_policy_agent.opa.jackson.JacksonCapabilities.toJson(capabilities); assertNotNull(json); assertFalse(json.isEmpty()); @@ -118,7 +118,9 @@ public void testCapabilitiesJsonSerializationWorks() throws IOException { StringBuilder errorMsg = new StringBuilder("JSON contains null field values. Problematic builtins:\n"); for (Descriptor d : capabilities.builtins) { - String individualJson = new Capabilities(List.of(d)).toJson(); + String individualJson = + io.github.open_policy_agent.opa.jackson.JacksonCapabilities.toJson( + new Capabilities(List.of(d))); if (individualJson.contains(": null")) { errorMsg.append(" - ").append(d.name).append("\n"); errorMsg @@ -131,7 +133,8 @@ public void testCapabilitiesJsonSerializationWorks() throws IOException { } // Should be able to deserialize back - Capabilities deserialized = Capabilities.fromJson(json); + Capabilities deserialized = + io.github.open_policy_agent.opa.jackson.JacksonCapabilities.fromJson(json); assertNotNull(deserialized); assertEquals(capabilities.builtins.size(), deserialized.builtins.size()); } diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoaderTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoaderTest.java index 25971eb6..6e59227e 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoaderTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoaderTest.java @@ -21,7 +21,7 @@ class FileSystemBundleLoaderTest { - private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule()); private Path testResource(String name) { return Paths.get( @@ -77,7 +77,7 @@ void load_withManifest(@TempDir Path dir) throws IOException { Bundle bundle = new FileSystemBundleLoader("test", dir).load(store); assertNotNull(bundle.manifest); - assertEquals("abc123", bundle.manifest.get("revision").asText()); + assertEquals("abc123", bundle.manifest.get("revision")); } @Test @@ -212,7 +212,9 @@ void load_integration_engineEvaluates(@TempDir Path dir) throws IOException { JsonNode input = MAPPER.readTree( "{\"user\":{\"id\":\"alice\",\"groups\":[]}}"); - List results = engine.prepareForEvaluation().build().eval(input); + List results = + io.github.open_policy_agent.opa.rego.JsonNodeBridge.eval( + engine.prepareForEvaluation().build(), input); assertEquals(1, results.size()); assertTrue(results.get(0).has("result")); @@ -242,7 +244,9 @@ void load_integration_nestedDataEvaluates(@TempDir Path dir) throws IOException JsonNode input = MAPPER.readTree( "{\"user\":{\"id\":\"bob\",\"groups\":[\"super\"]}}"); - List results = engine.prepareForEvaluation().build().eval(input); + List results = + io.github.open_policy_agent.opa.rego.JsonNodeBridge.eval( + engine.prepareForEvaluation().build(), input); assertEquals(1, results.size()); assertTrue(results.get(0).get("result").asBoolean(), diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/integration/NdBuiltinCacheTrackingTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/integration/NdBuiltinCacheTrackingTest.java index a5c04a24..7db0428a 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/integration/NdBuiltinCacheTrackingTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/integration/NdBuiltinCacheTrackingTest.java @@ -124,7 +124,7 @@ public void testJsonSerialization() throws Exception { Map> ndCache = ctx.getNdCacheValues(); // Serialize to JSON (simulating what DecisionLogPlugin does) - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule()); JsonNode cacheNode = mapper.valueToTree(ndCache); // This test verifies that the cache values can be serialized diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ComplianceTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ComplianceTest.java index d12d477d..2815f3df 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ComplianceTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ComplianceTest.java @@ -119,7 +119,7 @@ public static Stream getComplianceTestData() throws IOException { .map(Path::toFile) .flatMap(f -> { try { - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule()); JsonNode root = mapper.readTree(f); List cases = new ArrayList<>(); root.get("cases").forEach(cases::add); @@ -344,7 +344,7 @@ private static RegoValue jsonNodeToRegoValue( @MethodSource("getComplianceTestData") public void testEvaluate(String caseName, JsonNode root) { try { - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule()); if (root.has("skip") && root.get("skip").asBoolean()) { System.out.println("skipping: " + caseName); diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/EvaluatorTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/EvaluatorTest.java index df5dc868..ce588014 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/EvaluatorTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/EvaluatorTest.java @@ -32,7 +32,7 @@ class EvaluatorTest { private static final PolicyReader policyReader = ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow(); - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final ObjectMapper objectMapper = new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule()); @Test void evaluate_BreakStmt_IndexZero() throws IOException { diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/CrossBundleDataTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/CrossBundleDataTest.java index 9bc7e576..255cf86a 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/CrossBundleDataTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/CrossBundleDataTest.java @@ -4,12 +4,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.ServiceLoader; import org.junit.jupiter.api.BeforeAll; @@ -34,7 +34,7 @@ */ class CrossBundleDataTest { - private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule()); private static final PolicyReader POLICY_READER = ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow(); private static final String ENTRYPOINT = "authz/allow"; @@ -54,16 +54,14 @@ static void loadPolicy() throws IOException { } private static Bundle createBundleWithRoot(String root) { - ObjectNode manifest = MAPPER.createObjectNode(); - ArrayNode rootsArray = manifest.putArray("roots"); - rootsArray.add(root); + Map manifest = new HashMap<>(); + manifest.put("roots", List.of(root)); return new Bundle.Builder().withManifest(manifest).build(); } private static Bundle createPolicyBundleWithRoot(String root) { - ObjectNode manifest = MAPPER.createObjectNode(); - ArrayNode rootsArray = manifest.putArray("roots"); - rootsArray.add(root); + Map manifest = new HashMap<>(); + manifest.put("roots", List.of(root)); return new Bundle.Builder().withIrPolicy(authzPolicy).withManifest(manifest).build(); } @@ -97,7 +95,7 @@ void policyFromOneBundleCanReferenceDataFromAnother() throws IOException { Engine engine = new Engine.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); - List results = pq.eval(bobInGroup("super")); + List results = JsonNodeBridge.eval(pq, bobInGroup("super")); assertTrue( resultBoolean(results), @@ -119,7 +117,7 @@ void policyDeniedWhenCrossBundleDataMissing() throws IOException { Engine engine = new Engine.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); - List results = pq.eval(bobInGroup("super")); + List results = JsonNodeBridge.eval(pq, bobInGroup("super")); assertFalse( resultBoolean(results), @@ -142,7 +140,7 @@ void crossBundleDataUpdatesAreVisibleLive() throws IOException { Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); // Initially denied - assertFalse(resultBoolean(pq.eval(bobInGroup("super"))), "bob should be denied initially"); + assertFalse(resultBoolean(JsonNodeBridge.eval(pq, bobInGroup("super"))), "bob should be denied initially"); // Update data bundle with privileged group — no engine refresh needed RegoObject updatedData = @@ -151,7 +149,7 @@ void crossBundleDataUpdatesAreVisibleLive() throws IOException { // Data changes are live assertTrue( - resultBoolean(pq.eval(bobInGroup("super"))), + resultBoolean(JsonNodeBridge.eval(pq, bobInGroup("super"))), "bob should be allowed after data-bundle update — cross-bundle data is live"); } } diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineEvaluateTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineEvaluateTest.java index 96652e51..f74bad80 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineEvaluateTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineEvaluateTest.java @@ -45,7 +45,7 @@ */ class EngineEvaluateTest { - private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule()); private static final PolicyReader POLICY_READER = ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow(); private static final String ENTRYPOINT = "authz/allow"; @@ -100,7 +100,7 @@ void allowedByUserId_alice() throws IOException { Engine engine = buildEngine("{}"); EvaluationContext ctx = new EvaluationContext.Builder().withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, input("alice")); + List results = JsonNodeBridge.eval(engine, ctx, input("alice")); assertTrue(resultBoolean(results)); } @@ -110,7 +110,7 @@ void allowedByUserId_kurt() throws IOException { Engine engine = buildEngine("{}"); EvaluationContext ctx = new EvaluationContext.Builder().withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, input("kurt")); + List results = JsonNodeBridge.eval(engine, ctx, input("kurt")); assertTrue(resultBoolean(results)); } @@ -120,7 +120,7 @@ void allowedByPrivilegedGroup() throws IOException { Engine engine = buildEngine("{\"groups\":{\"admin\":{\"privileged\":true}}}"); EvaluationContext ctx = new EvaluationContext.Builder().withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, input("bob", "admin")); + List results = JsonNodeBridge.eval(engine, ctx, input("bob", "admin")); assertTrue(resultBoolean(results)); } @@ -130,7 +130,7 @@ void deniedForUnknownUser() throws IOException { Engine engine = buildEngine("{}"); EvaluationContext ctx = new EvaluationContext.Builder().withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, input("bob")); + List results = JsonNodeBridge.eval(engine, ctx, input("bob")); assertFalse(resultBoolean(results)); } @@ -140,7 +140,7 @@ void deniedWhenGroupNotPrivileged() throws IOException { Engine engine = buildEngine("{\"groups\":{\"basic\":{}}}"); EvaluationContext ctx = new EvaluationContext.Builder().withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, input("bob", "basic")); + List results = JsonNodeBridge.eval(engine, ctx, input("bob", "basic")); assertFalse(resultBoolean(results)); } @@ -150,7 +150,7 @@ void allowedByBothUserIdAndGroup() throws IOException { Engine engine = buildEngine("{\"groups\":{\"admin\":{\"privileged\":true}}}"); EvaluationContext ctx = new EvaluationContext.Builder().withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, input("alice", "admin")); + List results = JsonNodeBridge.eval(engine, ctx, input("alice", "admin")); assertTrue(resultBoolean(results)); } @@ -164,7 +164,7 @@ void allowedByUserId() throws IOException { Engine engine = buildEngine("{}"); Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); - List results = pq.eval(input("alice")); + List results = JsonNodeBridge.eval(pq, input("alice")); assertTrue(resultBoolean(results)); } @@ -174,7 +174,7 @@ void deniedForUnknownUser() throws IOException { Engine engine = buildEngine("{}"); Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); - List results = pq.eval(input("bob")); + List results = JsonNodeBridge.eval(pq, input("bob")); assertFalse(resultBoolean(results)); } @@ -185,7 +185,7 @@ void allowedByPrivilegedGroup() throws IOException { buildEngine("{\"groups\":{\"super\":{\"privileged\":true}}}"); Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); - List results = pq.eval(input("bob", "super")); + List results = JsonNodeBridge.eval(pq, input("bob", "super")); assertTrue(resultBoolean(results)); } @@ -195,7 +195,7 @@ void deniedWhenGroupNotPrivileged() throws IOException { Engine engine = buildEngine("{\"groups\":{\"basic\":{}}}"); Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); - List results = pq.eval(input("bob", "basic")); + List results = JsonNodeBridge.eval(pq, input("bob", "basic")); assertFalse(resultBoolean(results)); } @@ -227,7 +227,7 @@ void matchesCliTestData() throws IOException { Engine engine = new Engine.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); - List results = pq.eval(inputJson); + List results = JsonNodeBridge.eval(pq, inputJson); // CLI input has id=alicex (not alice/kurt) but groups=["super"] // data has groups.super.privileged=true, so it should be allowed via group rule diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineHotReloadTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineHotReloadTest.java index 4c9e82cf..ea35a33a 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineHotReloadTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineHotReloadTest.java @@ -29,7 +29,7 @@ */ class EngineHotReloadTest { - private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule()); private static final PolicyReader POLICY_READER = ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow(); private static final String ENTRYPOINT = "authz/allow"; @@ -91,7 +91,7 @@ void evaluate_doesNotPickUpNewPolicy_beforeRefresh() throws IOException { EvaluationContext ctx = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, aliceInput()); + List results = JsonNodeBridge.eval(engine, ctx, aliceInput()); assertTrue(resultBoolean(results), "alice should be allowed by the original policy"); // Now update the store with a deny policy (default allow := false) @@ -100,7 +100,7 @@ void evaluate_doesNotPickUpNewPolicy_beforeRefresh() throws IOException { EvaluationContext ctx2 = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List staleResults = engine.evaluate(ctx2, aliceInput()); + List staleResults = JsonNodeBridge.eval(engine, ctx2, aliceInput()); assertTrue( resultBoolean(staleResults), @@ -117,7 +117,7 @@ void refresh_picksUpNewPolicy() throws IOException { EvaluationContext ctx = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, aliceInput()); + List results = JsonNodeBridge.eval(engine, ctx, aliceInput()); assertTrue(resultBoolean(results), "alice should be allowed by the original policy"); // Update the store with a deny policy (default allow := false) @@ -128,7 +128,7 @@ void refresh_picksUpNewPolicy() throws IOException { EvaluationContext ctx2 = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List refreshedResults = engine.evaluate(ctx2, aliceInput()); + List refreshedResults = JsonNodeBridge.eval(engine, ctx2, aliceInput()); assertFalse( resultBoolean(refreshedResults), "after refresh, deny policy should return false"); } @@ -145,7 +145,7 @@ void dataChanges_areVisibleWithoutRefresh() throws IOException { JsonNode bobInput = MAPPER.readTree("{\"user\":{\"id\":\"bob\",\"groups\":[\"super\"]}}"); EvaluationContext ctx = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, bobInput); + List results = JsonNodeBridge.eval(engine, ctx, bobInput); assertFalse(resultBoolean(results), "bob should be denied with no privileged groups"); // Update ONLY data (same policy) -- add privileged group @@ -156,7 +156,7 @@ void dataChanges_areVisibleWithoutRefresh() throws IOException { // Evaluate WITHOUT calling refresh() -- data should be visible immediately EvaluationContext ctx2 = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List liveResults = engine.evaluate(ctx2, bobInput); + List liveResults = JsonNodeBridge.eval(engine, ctx2, bobInput); assertTrue( resultBoolean(liveResults), "data changes should be visible without refresh (live from store)"); @@ -176,7 +176,7 @@ void refresh_picksUpPolicyChange_dataAlreadyLive() throws IOException { JsonNode bobInput = MAPPER.readTree("{\"user\":{\"id\":\"bob\",\"groups\":[\"super\"]}}"); EvaluationContext ctx = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, bobInput); + List results = JsonNodeBridge.eval(engine, ctx, bobInput); assertTrue(resultBoolean(results), "bob should be allowed via privileged group"); // Update store with deny policy (data doesn't matter -- policy always returns false) @@ -186,7 +186,7 @@ void refresh_picksUpPolicyChange_dataAlreadyLive() throws IOException { // Without refresh, old policy still allows bob EvaluationContext ctx2 = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List staleResults = engine.evaluate(ctx2, bobInput); + List staleResults = JsonNodeBridge.eval(engine, ctx2, bobInput); assertTrue(resultBoolean(staleResults), "policy should be stale without refresh"); // After refresh, deny policy takes effect @@ -194,7 +194,7 @@ void refresh_picksUpPolicyChange_dataAlreadyLive() throws IOException { EvaluationContext ctx3 = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List refreshedResults = engine.evaluate(ctx3, bobInput); + List refreshedResults = JsonNodeBridge.eval(engine, ctx3, bobInput); assertFalse( resultBoolean(refreshedResults), "after refresh, deny policy should return false regardless of data"); @@ -215,7 +215,7 @@ void preparedQuery_dataChanges_areVisibleWithoutRefresh() throws IOException { // Bob is denied (no privileged groups) JsonNode bobInput = MAPPER.readTree("{\"user\":{\"id\":\"bob\",\"groups\":[\"super\"]}}"); - assertFalse(resultBoolean(pq.eval(bobInput)), "bob should be denied with no data"); + assertFalse(resultBoolean(JsonNodeBridge.eval(pq, bobInput)), "bob should be denied with no data"); // Update data only -- add privileged group RegoObject newData = @@ -224,7 +224,7 @@ void preparedQuery_dataChanges_areVisibleWithoutRefresh() throws IOException { // PreparedQuery picks up data changes without refresh (data is live from store) assertTrue( - resultBoolean(pq.eval(bobInput)), + resultBoolean(JsonNodeBridge.eval(pq, bobInput)), "PreparedQuery should see live data changes without refresh"); } @@ -237,7 +237,7 @@ void preparedQuery_retainsOldPolicy_afterRefresh() throws IOException { Engine engine = new Engine.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); - assertTrue(resultBoolean(pq.eval(aliceInput())), "alice allowed initially"); + assertTrue(resultBoolean(JsonNodeBridge.eval(pq, aliceInput())), "alice allowed initially"); // Update store with deny policy + refresh engine Bundle denyBundle = new Bundle.Builder().withIrPolicy(denyPolicy()).build(); @@ -246,7 +246,7 @@ void preparedQuery_retainsOldPolicy_afterRefresh() throws IOException { // The OLD PreparedQuery still uses its warmed plan from the original policy assertTrue( - resultBoolean(pq.eval(aliceInput())), + resultBoolean(JsonNodeBridge.eval(pq, aliceInput())), "existing PreparedQuery retains old policy even after engine.refresh()"); } @@ -259,7 +259,7 @@ void preparedQuery_picksUpNewPolicy_afterRePreparation() throws IOException { Engine engine = new Engine.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); Engine.PreparedQuery oldPq = engine.prepareForEvaluation().build(); - assertTrue(resultBoolean(oldPq.eval(aliceInput())), "alice allowed initially"); + assertTrue(resultBoolean(JsonNodeBridge.eval(oldPq, aliceInput())), "alice allowed initially"); // Update store with deny policy + refresh engine Bundle denyBundle = new Bundle.Builder().withIrPolicy(denyPolicy()).build(); @@ -270,7 +270,7 @@ void preparedQuery_picksUpNewPolicy_afterRePreparation() throws IOException { Engine.PreparedQuery newPq = engine.prepareForEvaluation().build(); assertFalse( - resultBoolean(newPq.eval(aliceInput())), + resultBoolean(JsonNodeBridge.eval(newPq, aliceInput())), "re-prepared PreparedQuery should use the new deny policy"); } } diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineTest.java index 4542436b..5784d123 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineTest.java @@ -30,7 +30,7 @@ class EngineTest { private static final PolicyReader policyReader = ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow(); - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final ObjectMapper objectMapper = new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule()); @Test void engine_builder_requiresStore() { @@ -110,7 +110,7 @@ void preparedQuery_eval_returnsResults() throws IOException { Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); JsonNode input = objectMapper.readTree("{}"); - List results = pq.eval(input); + List results = JsonNodeBridge.eval(pq, input); assertNotNull(results); } diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/JsonNodeBridge.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/JsonNodeBridge.java new file mode 100644 index 00000000..8eda4d66 --- /dev/null +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/JsonNodeBridge.java @@ -0,0 +1,38 @@ +package io.github.open_policy_agent.opa.rego; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.open_policy_agent.opa.jackson.RegoValueModule; +import java.util.ArrayList; +import java.util.List; + +/** + * Test helper: bridges between {@link JsonNode} and the Object-based Engine API. + * + *

Engine no longer accepts/returns JsonNode directly (the evaluator module is Jackson-free). + * Tests that prefer to express inputs as JsonNode use this bridge to convert at the boundary. + */ +public final class JsonNodeBridge { + private static final ObjectMapper MAPPER = + new ObjectMapper().registerModule(new RegoValueModule()); + + private JsonNodeBridge() {} + + public static List eval(Engine engine, EvaluationContext ctx, JsonNode input) { + Object pojoInput = MAPPER.convertValue(input, Object.class); + return wrap(engine.evaluate(ctx, pojoInput)); + } + + public static List eval(Engine.PreparedQuery pq, JsonNode input) { + Object pojoInput = MAPPER.convertValue(input, Object.class); + return wrap(pq.eval(pojoInput)); + } + + private static List wrap(List raw) { + List out = new ArrayList<>(raw.size()); + for (Object o : raw) { + out.add(MAPPER.valueToTree(o)); + } + return out; + } +} diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/storage/ConflictingRootsTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/storage/ConflictingRootsTest.java index d839f32a..499ea121 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/storage/ConflictingRootsTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/storage/ConflictingRootsTest.java @@ -2,9 +2,10 @@ import static org.junit.jupiter.api.Assertions.*; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import io.github.open_policy_agent.opa.ast.types.RegoObject; @@ -14,12 +15,10 @@ public class ConflictingRootsTest { private Store store; - private ObjectMapper mapper; @BeforeEach void setUp() { store = new InMem(); - mapper = new ObjectMapper(); } // Helper method to create a bundle with specified roots @@ -27,11 +26,12 @@ private Bundle createBundleWithRoots(String... roots) { Bundle.Builder builder = new Bundle.Builder(); if (roots.length > 0) { - ObjectNode manifest = mapper.createObjectNode(); - ArrayNode rootsArray = manifest.putArray("roots"); + Map manifest = new HashMap<>(); + List rootList = new ArrayList<>(); for (String root : roots) { - rootsArray.add(root); + rootList.add(root); } + manifest.put("roots", rootList); builder.withManifest(manifest); } diff --git a/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonAnnotationIntrospector.java b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonAnnotationIntrospector.java new file mode 100644 index 00000000..9fffddf0 --- /dev/null +++ b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonAnnotationIntrospector.java @@ -0,0 +1,120 @@ +package io.github.open_policy_agent.opa.jackson; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; +import io.github.open_policy_agent.opa.mapper.AnnotationIntrospector; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +/** + * Jackson-backed {@link AnnotationIntrospector}. Reads {@code @JsonProperty}, + * {@code @JsonIgnore}, {@code @JsonInclude}, {@code @JsonCreator}, {@code @JsonAutoDetect}, and + * {@code @JsonValue} from user POJOs so the evaluator's {@code RegoMapper} can mirror Jackson's + * binding rules. + * + *

Discovered via {@link java.util.ServiceLoader}; consumers don't reference this class + * directly. + */ +public class JacksonAnnotationIntrospector implements AnnotationIntrospector { + + @Override + public String findPropertyName(Method getter, Field backingField) { + if (getter != null) { + JsonProperty jp = getter.getAnnotation(JsonProperty.class); + if (jp != null && !jp.value().isEmpty()) { + return jp.value(); + } + } + if (backingField != null) { + JsonProperty jp = backingField.getAnnotation(JsonProperty.class); + if (jp != null && !jp.value().isEmpty()) { + return jp.value(); + } + // Even an empty @JsonProperty marks a field as discovered (without renaming it). + if (backingField.isAnnotationPresent(JsonProperty.class)) { + return backingField.getName(); + } + } + return null; + } + + @Override + public boolean isIgnored(Method getter, Field backingField) { + return hasAnnotation(getter, backingField, JsonIgnore.class); + } + + @Override + public boolean isNonNullInclude(Method getter, Field backingField) { + if (getter != null) { + JsonInclude ji = getter.getAnnotation(JsonInclude.class); + if (ji != null && ji.value() == JsonInclude.Include.NON_NULL) { + return true; + } + } + if (backingField != null) { + JsonInclude ji = backingField.getAnnotation(JsonInclude.class); + return ji != null && ji.value() == JsonInclude.Include.NON_NULL; + } + return false; + } + + @Override + public String findCreatorParamName(Parameter param) { + JsonProperty jp = param.getAnnotation(JsonProperty.class); + return jp != null ? jp.value() : null; + } + + @Override + public boolean isJsonCreator(Constructor ctor) { + JsonCreator jc = ctor.getAnnotation(JsonCreator.class); + return jc != null && jc.mode() != JsonCreator.Mode.DELEGATING; + } + + @Override + public boolean isJsonCreator(Method method) { + JsonCreator jc = method.getAnnotation(JsonCreator.class); + return jc != null && jc.mode() != JsonCreator.Mode.DELEGATING; + } + + @Override + public Visibility findFieldVisibility(Class clazz) { + JsonAutoDetect ann = clazz.getAnnotation(JsonAutoDetect.class); + if (ann == null || ann.fieldVisibility() == JsonAutoDetect.Visibility.DEFAULT) { + return null; + } + switch (ann.fieldVisibility()) { + case ANY: + return Visibility.ANY; + case NON_PRIVATE: + return Visibility.NON_PRIVATE; + case PROTECTED_AND_PUBLIC: + return Visibility.PROTECTED_AND_PUBLIC; + case NONE: + return Visibility.NONE; + case PUBLIC_ONLY: + default: + return Visibility.PUBLIC_ONLY; + } + } + + @Override + public boolean isJsonValue(Method method) { + return method.isAnnotationPresent(JsonValue.class); + } + + private static boolean hasAnnotation( + Method getter, Field field, Class annotationType) { + if (getter != null && getter.isAnnotationPresent(annotationType)) { + return true; + } + return field != null && field.isAnnotationPresent(annotationType); + } +} diff --git a/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonBundleParser.java b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonBundleParser.java new file mode 100644 index 00000000..73d72aba --- /dev/null +++ b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonBundleParser.java @@ -0,0 +1,34 @@ +package io.github.open_policy_agent.opa.jackson; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.open_policy_agent.opa.ast.types.RegoObject; +import io.github.open_policy_agent.opa.bundle.BundleParser; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +/** + * Jackson-backed {@link BundleParser}. + * + *

Discovered via {@link java.util.ServiceLoader} — consumers don't need to instantiate this + * class directly. + */ +public class JacksonBundleParser implements BundleParser { + + private static final ObjectMapper MAPPER = + new ObjectMapper().registerModule(new RegoValueModule()); + private static final TypeReference> MAP_TYPE = + new TypeReference<>() {}; + + @Override + public RegoObject parseData(InputStream in) throws IOException { + return MAPPER.readValue(in, RegoObject.class); + } + + @Override + public Map parseManifest(InputStream in) throws IOException { + return MAPPER.readValue(in, MAP_TYPE); + } +} diff --git a/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonCapabilities.java b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonCapabilities.java new file mode 100644 index 00000000..04bf6b00 --- /dev/null +++ b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/JacksonCapabilities.java @@ -0,0 +1,30 @@ +package io.github.open_policy_agent.opa.jackson; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.open_policy_agent.opa.rego.Capabilities; +import java.io.IOException; + +/** + * Jackson-backed JSON IO for {@link Capabilities}. + * + *

The evaluator's {@code Capabilities} class is a pure POJO with no Jackson dependency. + * This helper provides the read/write convenience using Jackson. + */ +public final class JacksonCapabilities { + + private static final ObjectMapper MAPPER = + new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + + private JacksonCapabilities() {} + + /** Parse a {@link Capabilities} from a JSON string. */ + public static Capabilities fromJson(String json) throws IOException { + return MAPPER.readValue(json, Capabilities.class); + } + + /** Serialize a {@link Capabilities} to a pretty-printed JSON string. */ + public static String toJson(Capabilities capabilities) throws IOException { + return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(capabilities); + } +} diff --git a/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/RegoValueModule.java b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/RegoValueModule.java new file mode 100644 index 00000000..d7686e42 --- /dev/null +++ b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/RegoValueModule.java @@ -0,0 +1,222 @@ +package io.github.open_policy_agent.opa.jackson; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import io.github.open_policy_agent.opa.ast.types.RegoArray; +import io.github.open_policy_agent.opa.ast.types.RegoBigInt; +import io.github.open_policy_agent.opa.ast.types.RegoBoolean; +import io.github.open_policy_agent.opa.ast.types.RegoDecimal; +import io.github.open_policy_agent.opa.ast.types.RegoInt32; +import io.github.open_policy_agent.opa.ast.types.RegoNull; +import io.github.open_policy_agent.opa.ast.types.RegoNumber; +import io.github.open_policy_agent.opa.ast.types.RegoObject; +import io.github.open_policy_agent.opa.ast.types.RegoSet; +import io.github.open_policy_agent.opa.ast.types.RegoString; +import io.github.open_policy_agent.opa.ast.types.RegoUndefined; +import io.github.open_policy_agent.opa.ast.types.RegoValue; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Iterator; +import java.util.Map; +import java.util.TreeMap; + +/** + * Jackson {@link SimpleModule} that teaches an {@link ObjectMapper} how to (de)serialize the OPA + * Rego AST value types. Replaces the in-source {@code @JsonValue}/{@code @JsonAnySetter} + * annotations the AST types previously carried, so the evaluator module has no Jackson + * dependency. + * + *

Usage: + * + *

{@code
+ * ObjectMapper mapper = new ObjectMapper().registerModule(new RegoValueModule());
+ * String json = mapper.writeValueAsString(regoValue);
+ * RegoObject obj = mapper.readValue(json, RegoObject.class);
+ * }
+ */ +public class RegoValueModule extends SimpleModule { + + public RegoValueModule() { + super("rego-value"); + addSerializer(RegoString.class, new RegoStringSerializer()); + addSerializer(RegoInt32.class, new RegoInt32Serializer()); + addSerializer(RegoBigInt.class, new RegoBigIntSerializer()); + addSerializer(RegoDecimal.class, new RegoDecimalSerializer()); + addSerializer(RegoBoolean.class, new RegoBooleanSerializer()); + addSerializer(RegoNull.class, new RegoNullSerializer()); + addSerializer(RegoUndefined.class, new RegoUndefinedSerializer()); + addSerializer(RegoArray.class, new RegoArraySerializer()); + addSerializer(RegoSet.class, new RegoSetSerializer()); + addSerializer(RegoObject.class, new RegoObjectSerializer()); + addDeserializer(RegoObject.class, new RegoObjectDeserializer()); + } + + private static final class RegoStringSerializer extends JsonSerializer { + @Override + public void serialize(RegoString v, JsonGenerator g, SerializerProvider p) throws IOException { + g.writeString(v.getValue()); + } + } + + private static final class RegoInt32Serializer extends JsonSerializer { + @Override + public void serialize(RegoInt32 v, JsonGenerator g, SerializerProvider p) throws IOException { + g.writeNumber(v.getValue()); + } + } + + private static final class RegoBigIntSerializer extends JsonSerializer { + @Override + public void serialize(RegoBigInt v, JsonGenerator g, SerializerProvider p) throws IOException { + g.writeNumber(v.getValue()); + } + } + + private static final class RegoDecimalSerializer extends JsonSerializer { + @Override + public void serialize(RegoDecimal v, JsonGenerator g, SerializerProvider p) throws IOException { + g.writeNumber(v.getValue()); + } + } + + private static final class RegoBooleanSerializer extends JsonSerializer { + @Override + public void serialize(RegoBoolean v, JsonGenerator g, SerializerProvider p) throws IOException { + g.writeBoolean(v.getValue()); + } + } + + private static final class RegoNullSerializer extends JsonSerializer { + @Override + public void serialize(RegoNull v, JsonGenerator g, SerializerProvider p) throws IOException { + g.writeNull(); + } + } + + private static final class RegoUndefinedSerializer extends JsonSerializer { + @Override + public void serialize(RegoUndefined v, JsonGenerator g, SerializerProvider p) + throws IOException { + g.writeNull(); + } + } + + private static final class RegoArraySerializer extends JsonSerializer { + @Override + public void serialize(RegoArray v, JsonGenerator g, SerializerProvider p) throws IOException { + g.writeStartArray(); + for (RegoValue item : v.getValues()) { + p.defaultSerializeValue(item, g); + } + g.writeEndArray(); + } + } + + private static final class RegoSetSerializer extends JsonSerializer { + @Override + public void serialize(RegoSet v, JsonGenerator g, SerializerProvider p) throws IOException { + g.writeStartArray(); + for (RegoValue item : v.getValue()) { + p.defaultSerializeValue(item, g); + } + g.writeEndArray(); + } + } + + private static final class RegoObjectSerializer extends JsonSerializer { + @Override + public void serialize(RegoObject v, JsonGenerator g, SerializerProvider p) throws IOException { + // OPA (Go) emits sorted keys; preserve that for round-trip fidelity. + Map sorted = new TreeMap<>(); + for (Map.Entry entry : v.getProperties().entrySet()) { + sorted.put(keyToString(entry.getKey()), entry.getValue()); + } + g.writeStartObject(); + for (Map.Entry entry : sorted.entrySet()) { + g.writeFieldName(entry.getKey()); + p.defaultSerializeValue(entry.getValue(), g); + } + g.writeEndObject(); + } + + private static String keyToString(RegoValue key) { + if (key instanceof RegoString) { + return ((RegoString) key).getValue(); + } + if (key instanceof RegoNumber) { + return ((RegoNumber) key).getBigIntValue().toString(); + } + return key.toString(); + } + } + + private static final class RegoObjectDeserializer extends JsonDeserializer { + @Override + public RegoObject deserialize(JsonParser jp, DeserializationContext ctx) throws IOException { + JsonNode root = jp.getCodec().readTree(jp); + if (!root.isObject()) { + throw new IOException("expected JSON object for RegoObject, got " + root.getNodeType()); + } + RegoObject obj = new RegoObject(); + Iterator> fields = root.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + obj.setProp(new RegoString(field.getKey()), convertNode(field.getValue())); + } + return obj; + } + + private static RegoValue convertNode(JsonNode node) throws IOException { + if (node == null || node.isNull()) { + return RegoNull.INSTANCE; + } + if (node.isTextual()) { + return new RegoString(node.asText()); + } + if (node.isBoolean()) { + return RegoBoolean.of(node.asBoolean()); + } + if (node.isIntegralNumber()) { + return new RegoBigInt(node.bigIntegerValue()); + } + if (node.isFloatingPointNumber()) { + return new RegoDecimal(node.doubleValue()); + } + if (node.isNumber()) { + BigDecimal bd = node.decimalValue(); + try { + BigInteger bi = bd.toBigIntegerExact(); + return new RegoBigInt(bi); + } catch (ArithmeticException ignored) { + return new RegoDecimal(bd.doubleValue()); + } + } + if (node.isArray()) { + RegoArray arr = new RegoArray(); + for (JsonNode element : node) { + arr.addValue(convertNode(element)); + } + return arr; + } + if (node.isObject()) { + RegoObject obj = new RegoObject(); + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + obj.setProp(new RegoString(field.getKey()), convertNode(field.getValue())); + } + return obj; + } + throw new IOException("unsupported JSON node type: " + node.getNodeType()); + } + } +} diff --git a/opa-jackson/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/opa-jackson/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module new file mode 100644 index 00000000..b6b9774b --- /dev/null +++ b/opa-jackson/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module @@ -0,0 +1 @@ +io.github.open_policy_agent.opa.jackson.RegoValueModule diff --git a/opa-jackson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.bundle.BundleParser b/opa-jackson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.bundle.BundleParser new file mode 100644 index 00000000..3e502198 --- /dev/null +++ b/opa-jackson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.bundle.BundleParser @@ -0,0 +1 @@ +io.github.open_policy_agent.opa.jackson.JacksonBundleParser diff --git a/opa-jackson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.mapper.AnnotationIntrospector b/opa-jackson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.mapper.AnnotationIntrospector new file mode 100644 index 00000000..bbec4cd3 --- /dev/null +++ b/opa-jackson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.mapper.AnnotationIntrospector @@ -0,0 +1 @@ +io.github.open_policy_agent.opa.jackson.JacksonAnnotationIntrospector diff --git a/opa-services/README.md b/opa-services/README.md index d8e487f9..647ed651 100644 --- a/opa-services/README.md +++ b/opa-services/README.md @@ -11,7 +11,7 @@ The services module provides the `Opa` class, which wraps the core evaluator wit ### Configuration File ```java -import io.github.openpolicyagent.opa.Opa; +import io.github.open_policy_agent.opa.Opa; Opa opa = new Opa.Builder() .withConfigFile("opa-config.yaml") @@ -27,8 +27,8 @@ String decisionId = result.getId(); ### Programmatic Configuration ```java -import io.github.openpolicyagent.opa.Opa; -import io.github.openpolicyagent.opa.config.Config; +import io.github.open_policy_agent.opa.Opa; +import io.github.open_policy_agent.opa.config.Config; Config config = new Config() .addService(new Config.ServiceConfig() @@ -124,8 +124,8 @@ opa.close(); ### Custom Plugins ```java -import io.github.openpolicyagent.opa.plugins.Plugin; -import io.github.openpolicyagent.opa.plugins.PluginManager; +import io.github.open_policy_agent.opa.plugins.Plugin; +import io.github.open_policy_agent.opa.plugins.PluginManager; Plugin myPlugin = new Plugin() { @Override diff --git a/opa-services/build.gradle.kts b/opa-services/build.gradle.kts index a595538c..029a0a19 100644 --- a/opa-services/build.gradle.kts +++ b/opa-services/build.gradle.kts @@ -13,13 +13,15 @@ dependencies { implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") implementation("org.apache.commons:commons-compress:1.28.0") - // opa-jackson provides the PolicyReader SPI implementation at runtime. + // opa-jackson provides the PolicyReader/BundleParser SPI implementations and the + // RegoValueModule. Test code in this module bridges JsonNode <-> RegoObject via that module. runtimeOnly(project(":opa-jackson")) testImplementation("org.junit.jupiter:junit-jupiter:5.10.1") testImplementation("org.junit.jupiter:junit-jupiter-params:5.8.2") testImplementation("org.assertj:assertj-core:3.27.6") testImplementation("org.mockito:mockito-core:5.16.1") + testImplementation(project(":opa-jackson")) } tasks.test { diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java index 5d213a45..27b492d1 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java @@ -97,6 +97,7 @@ public class Opa { private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); private final String id; private final Logger logger; @@ -187,12 +188,16 @@ private void shutdown() { public DecisionResult makeDecision(DecisionOptions options) { String decisionId = resolveDecisionId(options); EvaluationContext ctx = buildContext(options); - List result = engine.evaluate(ctx, options.getInput()); + // Engine now operates on POJO trees (Map/List/primitives) so the evaluator stays Jackson-free. + // Bridge JsonNode <-> POJO here at the opa-services boundary. + Object pojoInput = JSON_MAPPER.convertValue(options.getInput(), Object.class); + List rawResults = engine.evaluate(ctx, pojoInput); + JsonNode resultNode = JSON_MAPPER.valueToTree(rawResults.get(0)); - logDecision(decisionId, options.getInput(), result.get(0), options, ctx); + logDecision(decisionId, options.getInput(), resultNode, options, ctx); return new DecisionResult() - .setResult(result.get(0)) + .setResult(resultNode) .setId(decisionId) .setProvenance(buildProvenance()); } @@ -286,9 +291,9 @@ private Provenance buildProvenance() { Map bundleProvenance = new HashMap<>(); for (Map.Entry entry : store.getBundles().entrySet()) { Bundle bundle = entry.getValue(); - if (bundle.manifest != null && bundle.manifest.has("revision")) { + if (bundle.manifest != null && bundle.manifest.containsKey("revision")) { Provenance.ProvenanceBundle pb = new Provenance.ProvenanceBundle(); - pb.setRevision(bundle.manifest.get("revision").asText()); + pb.setRevision(String.valueOf(bundle.manifest.get("revision"))); bundleProvenance.put(entry.getKey(), pb); } } diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DecisionLogPlugin.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DecisionLogPlugin.java index 1d88ed51..7384467e 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DecisionLogPlugin.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DecisionLogPlugin.java @@ -374,8 +374,9 @@ private ObjectNode buildDecisionEvent( ObjectNode bundlesNode = MAPPER.createObjectNode(); for (Map.Entry entry : bundles.entrySet()) { ObjectNode bundleInfo = MAPPER.createObjectNode(); - if (entry.getValue().manifest != null && entry.getValue().manifest.has("revision")) { - bundleInfo.put("revision", entry.getValue().manifest.get("revision").asText()); + if (entry.getValue().manifest != null + && entry.getValue().manifest.containsKey("revision")) { + bundleInfo.put("revision", String.valueOf(entry.getValue().manifest.get("revision"))); } bundlesNode.set(entry.getKey(), bundleInfo); } diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java index a7fd85a6..b6184113 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java @@ -173,8 +173,9 @@ private ObjectNode buildStatusReport() { if (storeBundles != null) { for (Map.Entry entry : storeBundles.entrySet()) { ObjectNode bundleInfo = MAPPER.createObjectNode(); - if (entry.getValue().manifest != null && entry.getValue().manifest.has("revision")) { - bundleInfo.put("revision", entry.getValue().manifest.get("revision").asText()); + if (entry.getValue().manifest != null + && entry.getValue().manifest.containsKey("revision")) { + bundleInfo.put("revision", String.valueOf(entry.getValue().manifest.get("revision"))); } bundleInfo.put("active", true); bundles.set(entry.getKey(), bundleInfo); diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/OpaHotReloadTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/OpaHotReloadTest.java index e87c6ed8..74ee9435 100644 --- a/opa-services/src/test/java/io/github/open_policy_agent/opa/OpaHotReloadTest.java +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/OpaHotReloadTest.java @@ -17,6 +17,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.ServiceLoader; @@ -31,7 +32,7 @@ */ class OpaHotReloadTest { - private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule()); private static final PolicyReader POLICY_READER = ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow(); @@ -75,6 +76,17 @@ private static Policy allowPolicy() throws IOException { return POLICY_READER.read(new ByteArrayInputStream(ALLOW_PLAN.getBytes())); } + /** Bridge from Engine's POJO results to JsonNode for test assertions. */ + private static List evalJson(Engine engine, EvaluationContext ctx, JsonNode input) { + Object pojoInput = MAPPER.convertValue(input, Object.class); + List raw = engine.evaluate(ctx, pojoInput); + List out = new ArrayList<>(raw.size()); + for (Object o : raw) { + out.add(MAPPER.valueToTree(o)); + } + return out; + } + @Test void bundleActivationListener_triggersEngineRefresh() throws IOException { Store store = new InMem(); @@ -99,7 +111,7 @@ void bundleActivationListener_triggersEngineRefresh() throws IOException { JsonNode input = MAPPER.readTree("{}"); EvaluationContext ctx = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, input); + List results = evalJson(engine, ctx, input); assertNotNull(results); assertFalse(results.isEmpty(), "allow policy should produce results"); @@ -110,7 +122,7 @@ void bundleActivationListener_triggersEngineRefresh() throws IOException { EvaluationContext ctx2 = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List refreshedResults = engine.evaluate(ctx2, input); + List refreshedResults = evalJson(engine, ctx2, input); assertFalse( refreshedResults.get(0).get("result").asBoolean(), "after bundle activation, deny policy should return false"); @@ -141,7 +153,7 @@ void bundleActivationListener_policyChangeRequiresRefresh_dataIsLive() throws IO JsonNode input = MAPPER.readTree("{}"); EvaluationContext ctx = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List results = engine.evaluate(ctx, input); + List results = evalJson(engine, ctx, input); assertTrue(results.get(0).get("result").asBoolean(), "allow policy should return true"); RegoObject newData = MAPPER.readValue("{\"key\":\"updated\"}", RegoObject.class); @@ -150,7 +162,7 @@ void bundleActivationListener_policyChangeRequiresRefresh_dataIsLive() throws IO EvaluationContext ctx2 = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List dataResults = engine.evaluate(ctx2, input); + List dataResults = evalJson(engine, ctx2, input); assertTrue( dataResults.get(0).get("result").asBoolean(), "data changes are live -- policy still allows (same allow policy, updated data)"); @@ -162,7 +174,7 @@ void bundleActivationListener_policyChangeRequiresRefresh_dataIsLive() throws IO EvaluationContext ctx3 = new EvaluationContext.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build(); - List policyResults = engine.evaluate(ctx3, input); + List policyResults = evalJson(engine, ctx3, input); assertFalse( policyResults.get(0).get("result").asBoolean(), "policy change requires notification/refresh to take effect"); diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/bundle/TarballBundleLoaderTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/bundle/TarballBundleLoaderTest.java index 2df0888c..cca97528 100644 --- a/opa-services/src/test/java/io/github/open_policy_agent/opa/bundle/TarballBundleLoaderTest.java +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/bundle/TarballBundleLoaderTest.java @@ -78,7 +78,7 @@ void load_withManifest() throws IOException { Bundle bundle = new TarballBundleLoader("test", tarball).load(store); assertNotNull(bundle.manifest); - assertEquals("abc123", bundle.manifest.get("revision").asText()); + assertEquals("abc123", bundle.manifest.get("revision")); } @Test From a70fb8c2a05860e06f17b861d1cbd32eb51343f9 Mon Sep 17 00:00:00 2001 From: Sebastian Spaink Date: Tue, 19 May 2026 13:06:26 -0500 Subject: [PATCH 2/3] add tests Signed-off-by: Sebastian Spaink --- opa-evaluator/build.gradle.kts | 8 +- .../opa/bundle/BundleAssembler.java | 53 ++-- .../opa/ir/stmts/BaseStmt.java | 15 +- .../opa/mapper/AnnotationIntrospectors.java | 27 +- .../open_policy_agent/opa/rego/Engine.java | 30 +- .../opa/rego/EngineEvaluateTest.java | 112 ++++++++ .../opa/rego/EngineTest.java | 29 ++ .../engine/testdata/decision-policy.json | 1 + .../engine/testdata/decision-policy.rego | 27 ++ .../opa/jackson/RegoValueModule.java | 15 +- .../JacksonAnnotationIntrospectorTest.java | 260 ++++++++++++++++++ .../opa/jackson/RegoValueModuleTest.java | 188 +++++++++++++ 12 files changed, 721 insertions(+), 44 deletions(-) create mode 100644 opa-evaluator/src/test/resources/engine/testdata/decision-policy.json create mode 100644 opa-evaluator/src/test/resources/engine/testdata/decision-policy.rego create mode 100644 opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/JacksonAnnotationIntrospectorTest.java create mode 100644 opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/RegoValueModuleTest.java diff --git a/opa-evaluator/build.gradle.kts b/opa-evaluator/build.gradle.kts index e1b0160c..95bdde6f 100644 --- a/opa-evaluator/build.gradle.kts +++ b/opa-evaluator/build.gradle.kts @@ -10,12 +10,8 @@ repositories { dependencies { // The evaluator has no direct dependency on a JSON library. JSON IO is provided by external - // modules through SPIs: - // - PolicyReader (io.github.open_policy_agent.opa.ir) - // - BundleParser (io.github.open_policy_agent.opa.bundle) - // - AnnotationIntrospector (io.github.open_policy_agent.opa.mapper) - // The opa-jackson module supplies a Jackson-backed implementation of all three. - + // modules through SPIs (see Engine javadoc); opa-jackson is one such implementation, used here + // for testing. testImplementation(project(":opa-jackson")) testImplementation("com.fasterxml.jackson.core:jackson-databind:2.17.0") testImplementation("org.junit.jupiter:junit-jupiter:5.10.1") diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java index b3c965a4..c57a2ed3 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java @@ -7,6 +7,8 @@ import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; import java.util.ServiceLoader; /** @@ -32,23 +34,42 @@ * } */ public class BundleAssembler { - static final PolicyReader POLICY_READER = - ServiceLoader.load(PolicyReader.class) - .findFirst() - .orElseThrow( - () -> - new IllegalStateException( - "No PolicyReader implementation found on the classpath. " - + "Add a module that provides PolicyReader (e.g. opa-jackson).")); + static final PolicyReader POLICY_READER = loadSingleton(PolicyReader.class); - static final BundleParser BUNDLE_PARSER = - ServiceLoader.load(BundleParser.class) - .findFirst() - .orElseThrow( - () -> - new IllegalStateException( - "No BundleParser implementation found on the classpath. " - + "Add a module that provides BundleParser (e.g. opa-jackson).")); + static final BundleParser BUNDLE_PARSER = loadSingleton(BundleParser.class); + + /** + * Loads exactly one implementation of the given SPI from the classpath. Throws if zero or more + * than one implementation is registered, since either case produces an ambiguous runtime. + */ + private static T loadSingleton(Class spi) { + List impls = new ArrayList<>(); + for (T impl : ServiceLoader.load(spi)) { + impls.add(impl); + } + if (impls.isEmpty()) { + throw new IllegalStateException( + "No " + + spi.getSimpleName() + + " implementation found on the classpath. Add a module that provides " + + spi.getSimpleName() + + " (e.g. opa-jackson)."); + } + if (impls.size() > 1) { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < impls.size(); i++) { + if (i > 0) names.append(", "); + names.append(impls.get(i).getClass().getName()); + } + throw new IllegalStateException( + "Multiple " + + spi.getSimpleName() + + " implementations found on the classpath: " + + names + + ". Only one provider may be registered."); + } + return impls.get(0); + } private final Bundle.Builder builder = new Bundle.Builder(); private RegoObject data; diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ir/stmts/BaseStmt.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ir/stmts/BaseStmt.java index ffcba360..28562000 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ir/stmts/BaseStmt.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/ir/stmts/BaseStmt.java @@ -8,6 +8,7 @@ public abstract class BaseStmt implements Stmt { private int file; // index of source filename private int col; // column in the source file private int row; // row in the source file + private Location cachedLocation; protected BaseStmt(int file, int col, int row) { this.file = file; @@ -23,12 +24,19 @@ public Location setLocation(int file, int row, int col) { this.file = file; this.col = col; this.row = row; - return new Location(file, col, row); + Location loc = new Location(file, col, row); + this.cachedLocation = loc; + return loc; } @Override public Location getLocation() { - return new Location(file, col, row); + Location loc = cachedLocation; + if (loc == null) { + loc = new Location(file, col, row); + cachedLocation = loc; + } + return loc; } public int getFile() { @@ -37,6 +45,7 @@ public int getFile() { public void setFile(int file) { this.file = file; + this.cachedLocation = null; } public int getCol() { @@ -45,6 +54,7 @@ public int getCol() { public void setCol(int col) { this.col = col; + this.cachedLocation = null; } public int getRow() { @@ -53,6 +63,7 @@ public int getRow() { public void setRow(int row) { this.row = row; + this.cachedLocation = null; } /** Helper method to extract local value from an Operand if it contains a LocalVal */ diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java index 21fa9c4b..68c237c6 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java @@ -1,11 +1,14 @@ package io.github.open_policy_agent.opa.mapper; +import java.util.ArrayList; +import java.util.List; import java.util.ServiceLoader; /** * Static accessor for the active {@link AnnotationIntrospector}. Discovers an implementation via * {@link ServiceLoader}; if none is registered, falls back to {@link DefaultAnnotationIntrospector} - * (which performs no annotation lookups and so honors only JavaBean conventions). + * (which performs no annotation lookups and so honors only JavaBean conventions). Throws if more + * than one implementation is registered, to avoid an ambiguous runtime. */ final class AnnotationIntrospectors { @@ -18,8 +21,24 @@ static AnnotationIntrospector get() { } private static AnnotationIntrospector load() { - return ServiceLoader.load(AnnotationIntrospector.class) - .findFirst() - .orElseGet(DefaultAnnotationIntrospector::new); + List impls = new ArrayList<>(); + for (AnnotationIntrospector impl : ServiceLoader.load(AnnotationIntrospector.class)) { + impls.add(impl); + } + if (impls.isEmpty()) { + return new DefaultAnnotationIntrospector(); + } + if (impls.size() > 1) { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < impls.size(); i++) { + if (i > 0) names.append(", "); + names.append(impls.get(i).getClass().getName()); + } + throw new IllegalStateException( + "Multiple AnnotationIntrospector implementations found on the classpath: " + + names + + ". Only one provider may be registered."); + } + return impls.get(0); } } diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Engine.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Engine.java index deeae854..b12e9eaf 100644 --- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Engine.java +++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/rego/Engine.java @@ -63,6 +63,22 @@ * .build() * .eval(input, MyResult.class); * } + * + *

JSON IO is pluggable. The evaluator module has no direct dependency on a JSON + * library. JSON parsing and POJO introspection are provided by external modules through SPIs + * discovered via {@link java.util.ServiceLoader}: + * + *

    + *
  • {@link io.github.open_policy_agent.opa.ir.PolicyReader} — parses {@code plan.json} + *
  • {@link io.github.open_policy_agent.opa.bundle.BundleParser} — parses {@code data.json} + * and {@code .manifest} + *
  • {@link io.github.open_policy_agent.opa.mapper.AnnotationIntrospector} — reads JSON + * binding annotations on user POJOs + *
+ * + *

The {@code opa-jackson} module supplies a Jackson-backed implementation of all three. To + * use a different JSON library, implement these SPIs and register them via {@code + * META-INF/services}. */ public class Engine { private static final RegoMapper REGO_MAPPER = new RegoMapper(); @@ -135,7 +151,7 @@ public PreparedQuery.Builder prepareForEvaluation() { * @return list of result objects (Map/List/primitive trees) */ public List evaluate(EvaluationContext ctx, Object pojoInput) { - RegoObject regoInput = parsePojoInput(ctx, pojoInput); + RegoValue regoInput = parseInput(ctx, pojoInput); RegoValue[] results = evaluateCore(null, ctx, regoInput); return marshalRawResults(ctx, results); } @@ -151,21 +167,21 @@ public List evaluate(EvaluationContext ctx, Object pojoInput) { * @return list of typed results */ public List evaluate(EvaluationContext ctx, Object pojoInput, Class resultType) { - RegoObject regoInput = parsePojoInput(ctx, pojoInput); + RegoValue regoInput = parseInput(ctx, pojoInput); RegoValue[] results = evaluateCore(null, ctx, regoInput); return marshalPojoResults(ctx, results, resultType); } List evaluateWithPreparedPlan( PreparedPlan preparedPlan, EvaluationContext ctx, Object pojoInput) { - RegoObject regoInput = parsePojoInput(ctx, pojoInput); + RegoValue regoInput = parseInput(ctx, pojoInput); RegoValue[] results = evaluateCore(preparedPlan, ctx, regoInput); return marshalRawResults(ctx, results); } List evaluateWithPreparedPlan( PreparedPlan preparedPlan, EvaluationContext ctx, Object pojoInput, Class resultType) { - RegoObject regoInput = parsePojoInput(ctx, pojoInput); + RegoValue regoInput = parseInput(ctx, pojoInput); RegoValue[] results = evaluateCore(preparedPlan, ctx, regoInput); return marshalPojoResults(ctx, results, resultType); } @@ -186,10 +202,12 @@ private RegoValue[] evaluateCore(PreparedPlan plan, EvaluationContext ctx, RegoV } } - private RegoObject parsePojoInput(EvaluationContext ctx, Object input) { + private RegoValue parseInput(EvaluationContext ctx, Object input) { try { ctx.metrics.timer("rego_parse_pojo_input").start(); - return REGO_MAPPER.toRegoObject(input); + // OPA supports any value type as input (object, array, string, etc.), so we + // convert via toRegoValue rather than forcing a top-level RegoObject. + return REGO_MAPPER.toRegoValue(input); } catch (Exception e) { throw new RuntimeException("Failed to parse POJO input", e); } finally { diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineEvaluateTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineEvaluateTest.java index f74bad80..95fe00ba 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineEvaluateTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineEvaluateTest.java @@ -342,4 +342,116 @@ public void setGroups(List groups) { } } } + + /** + * Tests {@link Engine.PreparedQuery#eval(Object, Class)} against a policy that returns a + * structured object so the result type isn't just a primitive. + */ + @Nested + class StructuredResultEval { + + private static final String DECISION_ENTRYPOINT = "authz/decision"; + + private Engine buildDecisionEngine() throws IOException { + File policyFile = + new File( + Objects.requireNonNull( + EngineEvaluateTest.class + .getClassLoader() + .getResource("engine/testdata/decision-policy.json")) + .getFile()); + Policy decisionPolicy = POLICY_READER.read(Files.newInputStream(policyFile.toPath())); + Store store = new InMem(); + Bundle bundle = new Bundle.Builder().withIrPolicy(decisionPolicy).build(); + store.write(DECISION_ENTRYPOINT, bundle, new RegoObject()); + return new Engine.Builder().withStore(store).withEntrypoint(DECISION_ENTRYPOINT).build(); + } + + @Test + void preparedQuery_pojoResultType_aliceAllowed() throws IOException { + Engine engine = buildDecisionEngine(); + Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); + + AuthzInput input = new AuthzInput(); + input.setUser(new AuthzInput.User("alice", List.of())); + + List results = pq.eval(input, Decision.class); + + assertNotNull(results); + assertFalse(results.isEmpty()); + Decision decision = results.get(0); + assertTrue(decision.isAllowed()); + assertTrue("alice".equals(decision.getUserId())); + assertTrue("matched-user-id".equals(decision.getReason())); + } + + @Test + void preparedQuery_pojoResultType_unknownUserDenied() throws IOException { + Engine engine = buildDecisionEngine(); + Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); + + AuthzInput input = new AuthzInput(); + input.setUser(new AuthzInput.User("eve", List.of())); + + List results = pq.eval(input, Decision.class); + + assertNotNull(results); + assertFalse(results.isEmpty()); + Decision decision = results.get(0); + assertFalse(decision.isAllowed()); + assertTrue("eve".equals(decision.getUserId())); + assertTrue("denied".equals(decision.getReason())); + } + + @Test + void directEvaluate_pojoResultType_aliceAllowed() throws IOException { + Engine engine = buildDecisionEngine(); + EvaluationContext ctx = + new EvaluationContext.Builder().withEntrypoint(DECISION_ENTRYPOINT).build(); + + AuthzInput input = new AuthzInput(); + input.setUser(new AuthzInput.User("alice", List.of())); + + List results = engine.evaluate(ctx, input, Decision.class); + + assertNotNull(results); + assertFalse(results.isEmpty()); + assertTrue(results.get(0).isAllowed()); + } + } + + public static class Decision { + private boolean allowed; + + @com.fasterxml.jackson.annotation.JsonProperty("user_id") + private String userId; + + private String reason; + + public Decision() {} + + public boolean isAllowed() { + return allowed; + } + + public void setAllowed(boolean allowed) { + this.allowed = allowed; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + } } diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineTest.java index 5784d123..b40d1e5b 100644 --- a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineTest.java +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/rego/EngineTest.java @@ -115,6 +115,35 @@ void preparedQuery_eval_returnsResults() throws IOException { assertNotNull(results); } + @Test + void preparedQuery_eval_acceptsNonDictionaryInput() throws IOException { + // OPA supports any input value type (string, number, array, etc.) — not just objects. + // This test loads a real policy and evaluates it with a top-level non-dict input to + // verify the engine doesn't force inputs through a RegoObject conversion. + File jsonFile = + new File( + Objects.requireNonNull( + getClass() + .getClassLoader() + .getResource("ir/testdata/policy-verify-BreakStmt.json")) + .getFile()); + + Policy policy = policyReader.read(Files.newInputStream(jsonFile.toPath())); + Store store = new InMem(); + Bundle bundle = new Bundle.Builder().withIrPolicy(policy).build(); + store.write("policy", bundle, new RegoObject()); + + Engine engine = new Engine.Builder().withStore(store).withEntrypoint("policy").build(); + Engine.PreparedQuery pq = engine.prepareForEvaluation().build(); + + // String input (non-dict). + assertNotNull(pq.eval("hello")); + // List input (non-dict). + assertNotNull(pq.eval(List.of(1, 2, 3))); + // Number input (non-dict). + assertNotNull(pq.eval(42)); + } + @Test void preparedQuery_builder_acceptsMetrics() { Store store = new InMem(); diff --git a/opa-evaluator/src/test/resources/engine/testdata/decision-policy.json b/opa-evaluator/src/test/resources/engine/testdata/decision-policy.json new file mode 100644 index 00000000..08a3b543 --- /dev/null +++ b/opa-evaluator/src/test/resources/engine/testdata/decision-policy.json @@ -0,0 +1 @@ +{"static":{"strings":[{"value":"result"},{"value":"user"},{"value":"id"},{"value":"alice"},{"value":"matched-user-id"},{"value":"denied"},{"value":"allowed"},{"value":"reason"},{"value":"user_id"}],"files":[{"value":"decision-policy.rego"}]},"plans":{"plans":[{"name":"authz/decision","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.decision","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":2,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":3,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":3},"object":4,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":4,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.authz.allowed","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":19}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":2,"row":20}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":4},"key":{"type":"string_index","value":2},"target":5,"file":0,"col":2,"row":20}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":5},"b":{"type":"string_index","value":3},"file":0,"col":2,"row":20}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":19}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":19}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":19}}]},{"stmts":[{"type":"IsUndefinedStmt","stmt":{"source":2,"file":0,"col":9,"row":17}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":false},"target":2,"file":0,"col":9,"row":17}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":9,"row":17}}]}],"path":["g0","authz","allowed"]},{"name":"g0.data.authz.reason","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":25}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":2,"row":26}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":4},"key":{"type":"string_index","value":2},"target":5,"file":0,"col":2,"row":26}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":5},"b":{"type":"string_index","value":3},"file":0,"col":2,"row":26}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"string_index","value":4},"target":3,"file":0,"col":1,"row":25}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":25}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":25}}]},{"stmts":[{"type":"IsUndefinedStmt","stmt":{"source":2,"file":0,"col":9,"row":23}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"string_index","value":5},"target":2,"file":0,"col":9,"row":23}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":9,"row":23}}]}],"path":["g0","authz","reason"]},{"name":"g0.data.authz.decision","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":11}},{"type":"CallStmt","stmt":{"func":"g0.data.authz.allowed","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":4,"file":0,"col":13,"row":12}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":4},"target":5,"file":0,"col":13,"row":12}},{"type":"CallStmt","stmt":{"func":"g0.data.authz.reason","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":0,"col":12,"row":14}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":6},"target":7,"file":0,"col":12,"row":14}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":8,"file":0,"col":13,"row":13}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":8},"key":{"type":"string_index","value":2},"target":9,"file":0,"col":13,"row":13}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":9},"target":10,"file":0,"col":13,"row":13}},{"type":"MakeObjectStmt","stmt":{"target":11,"file":0,"col":13,"row":11}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":6},"value":{"type":"local","value":5},"object":11,"file":0,"col":13,"row":11}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":7},"value":{"type":"local","value":7},"object":11,"file":0,"col":13,"row":11}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":8},"value":{"type":"local","value":10},"object":11,"file":0,"col":13,"row":11}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":11},"target":12,"file":0,"col":13,"row":11}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":12},"target":3,"file":0,"col":1,"row":11}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":11}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":11}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":11}}]}],"path":["g0","authz","decision"]}]}} \ No newline at end of file diff --git a/opa-evaluator/src/test/resources/engine/testdata/decision-policy.rego b/opa-evaluator/src/test/resources/engine/testdata/decision-policy.rego new file mode 100644 index 00000000..ed393f49 --- /dev/null +++ b/opa-evaluator/src/test/resources/engine/testdata/decision-policy.rego @@ -0,0 +1,27 @@ +# This policy returns a structured object decision rather than a primitive, +# so tests can exercise Engine.PreparedQuery#eval(input, Class) with a POJO +# result type. +# +# To regenerate the IR plan, compile with: +# opa build -t plan -e authz/decision -o bundle.tar.gz decision-policy.rego +# Then extract /plan.json from the bundle tarball. + +package authz + +decision := { + "allowed": allowed, + "user_id": input.user.id, + "reason": reason, +} + +default allowed := false + +allowed if { + input.user.id == "alice" +} + +default reason := "denied" + +reason := "matched-user-id" if { + input.user.id == "alice" +} diff --git a/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/RegoValueModule.java b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/RegoValueModule.java index d7686e42..640bc1e6 100644 --- a/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/RegoValueModule.java +++ b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/RegoValueModule.java @@ -19,7 +19,6 @@ import io.github.open_policy_agent.opa.ast.types.RegoObject; import io.github.open_policy_agent.opa.ast.types.RegoSet; import io.github.open_policy_agent.opa.ast.types.RegoString; -import io.github.open_policy_agent.opa.ast.types.RegoUndefined; import io.github.open_policy_agent.opa.ast.types.RegoValue; import java.io.IOException; @@ -35,6 +34,11 @@ * annotations the AST types previously carried, so the evaluator module has no Jackson * dependency. * + *

Only a {@link RegoObject} deserializer is registered. Reading JSON directly into a typed + * Rego value (i.e. {@code mapper.readValue(json, T.class)}) is only used with {@link RegoObject} + * as the target type; the deserializer recursively builds nested {@link RegoValue}s of the right + * subtype from the JSON tree, so no separate per-type deserializer is required. + * *

Usage: * *

{@code
@@ -53,7 +57,6 @@ public RegoValueModule() {
     addSerializer(RegoDecimal.class, new RegoDecimalSerializer());
     addSerializer(RegoBoolean.class, new RegoBooleanSerializer());
     addSerializer(RegoNull.class, new RegoNullSerializer());
-    addSerializer(RegoUndefined.class, new RegoUndefinedSerializer());
     addSerializer(RegoArray.class, new RegoArraySerializer());
     addSerializer(RegoSet.class, new RegoSetSerializer());
     addSerializer(RegoObject.class, new RegoObjectSerializer());
@@ -102,14 +105,6 @@ public void serialize(RegoNull v, JsonGenerator g, SerializerProvider p) throws
     }
   }
 
-  private static final class RegoUndefinedSerializer extends JsonSerializer {
-    @Override
-    public void serialize(RegoUndefined v, JsonGenerator g, SerializerProvider p)
-        throws IOException {
-      g.writeNull();
-    }
-  }
-
   private static final class RegoArraySerializer extends JsonSerializer {
     @Override
     public void serialize(RegoArray v, JsonGenerator g, SerializerProvider p) throws IOException {
diff --git a/opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/JacksonAnnotationIntrospectorTest.java b/opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/JacksonAnnotationIntrospectorTest.java
new file mode 100644
index 00000000..c41fdef0
--- /dev/null
+++ b/opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/JacksonAnnotationIntrospectorTest.java
@@ -0,0 +1,260 @@
+package io.github.open_policy_agent.opa.jackson;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
+import io.github.open_policy_agent.opa.mapper.AnnotationIntrospector;
+import io.github.open_policy_agent.opa.mapper.AnnotationIntrospector.Visibility;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import org.junit.jupiter.api.Test;
+
+class JacksonAnnotationIntrospectorTest {
+
+  private final AnnotationIntrospector introspector = new JacksonAnnotationIntrospector();
+
+  // --- findPropertyName ---
+
+  static class GetterRenamed {
+    private String value;
+
+    @JsonProperty("renamed")
+    public String getValue() {
+      return value;
+    }
+  }
+
+  @Test
+  void findPropertyName_readsRenameOnGetter() throws Exception {
+    Method getter = GetterRenamed.class.getMethod("getValue");
+    assertThat(introspector.findPropertyName(getter, null)).isEqualTo("renamed");
+  }
+
+  static class FieldRenamed {
+    @JsonProperty("alt") public String value;
+  }
+
+  @Test
+  void findPropertyName_readsRenameOnField() throws Exception {
+    Field field = FieldRenamed.class.getField("value");
+    assertThat(introspector.findPropertyName(null, field)).isEqualTo("alt");
+  }
+
+  static class FieldMarkedDiscovered {
+    @JsonProperty public String value;
+  }
+
+  @Test
+  void findPropertyName_emptyJsonPropertyOnFieldUsesFieldName() throws Exception {
+    Field field = FieldMarkedDiscovered.class.getField("value");
+    assertThat(introspector.findPropertyName(null, field)).isEqualTo("value");
+  }
+
+  static class NoAnnotation {
+    public String value;
+
+    public String getValue() {
+      return value;
+    }
+  }
+
+  @Test
+  void findPropertyName_returnsNullWhenUnannotated() throws Exception {
+    Method getter = NoAnnotation.class.getMethod("getValue");
+    Field field = NoAnnotation.class.getField("value");
+    assertThat(introspector.findPropertyName(getter, field)).isNull();
+    assertThat(introspector.findPropertyName(null, null)).isNull();
+  }
+
+  // --- isIgnored ---
+
+  static class IgnoredGetter {
+    @JsonIgnore
+    public String getValue() {
+      return null;
+    }
+  }
+
+  static class IgnoredField {
+    @JsonIgnore public String value;
+  }
+
+  @Test
+  void isIgnored_detectsAnnotationOnGetterOrField() throws Exception {
+    assertThat(introspector.isIgnored(IgnoredGetter.class.getMethod("getValue"), null)).isTrue();
+    assertThat(introspector.isIgnored(null, IgnoredField.class.getField("value"))).isTrue();
+    assertThat(introspector.isIgnored(NoAnnotation.class.getMethod("getValue"), null)).isFalse();
+    assertThat(introspector.isIgnored(null, null)).isFalse();
+  }
+
+  // --- isNonNullInclude ---
+
+  static class NonNullGetter {
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public String getValue() {
+      return null;
+    }
+  }
+
+  static class NonNullField {
+    @JsonInclude(JsonInclude.Include.NON_NULL) public String value;
+  }
+
+  static class AlwaysIncludedGetter {
+    @JsonInclude(JsonInclude.Include.ALWAYS)
+    public String getValue() {
+      return null;
+    }
+  }
+
+  @Test
+  void isNonNullInclude_truthOnlyWhenNonNullPolicySet() throws Exception {
+    assertThat(introspector.isNonNullInclude(NonNullGetter.class.getMethod("getValue"), null))
+        .isTrue();
+    assertThat(introspector.isNonNullInclude(null, NonNullField.class.getField("value"))).isTrue();
+    assertThat(
+            introspector.isNonNullInclude(
+                AlwaysIncludedGetter.class.getMethod("getValue"), null))
+        .isFalse();
+    assertThat(introspector.isNonNullInclude(null, null)).isFalse();
+  }
+
+  // --- findCreatorParamName ---
+
+  static class WithCreator {
+    public WithCreator(@JsonProperty("foo") String foo, String bar) {}
+  }
+
+  @Test
+  void findCreatorParamName_readsAnnotationOnParameter() throws NoSuchMethodException {
+    Constructor ctor = WithCreator.class.getConstructor(String.class, String.class);
+    Parameter[] params = ctor.getParameters();
+    assertThat(introspector.findCreatorParamName(params[0])).isEqualTo("foo");
+    // Unannotated parameter -> null.
+    assertThat(introspector.findCreatorParamName(params[1])).isNull();
+  }
+
+  // --- isJsonCreator (constructor) ---
+
+  static class CreatorPropsCtor {
+    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+    public CreatorPropsCtor(@JsonProperty("x") String x) {}
+  }
+
+  static class CreatorDelegatingCtor {
+    @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
+    public CreatorDelegatingCtor(String x) {}
+  }
+
+  static class NoCreatorCtor {
+    public NoCreatorCtor(String x) {}
+  }
+
+  @Test
+  void isJsonCreator_constructor_acceptsPropertiesNotDelegating() throws Exception {
+    assertThat(
+            introspector.isJsonCreator(
+                CreatorPropsCtor.class.getConstructor(String.class)))
+        .isTrue();
+    assertThat(
+            introspector.isJsonCreator(
+                CreatorDelegatingCtor.class.getConstructor(String.class)))
+        .isFalse();
+    assertThat(introspector.isJsonCreator(NoCreatorCtor.class.getConstructor(String.class)))
+        .isFalse();
+  }
+
+  // --- isJsonCreator (method) ---
+
+  static class CreatorFactory {
+    @JsonCreator
+    public static CreatorFactory of(@JsonProperty("v") String v) {
+      return new CreatorFactory();
+    }
+
+    @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
+    public static CreatorFactory delegating(String v) {
+      return new CreatorFactory();
+    }
+
+    public static CreatorFactory plain(String v) {
+      return new CreatorFactory();
+    }
+  }
+
+  @Test
+  void isJsonCreator_method_acceptsPropertiesNotDelegating() throws NoSuchMethodException {
+    assertThat(introspector.isJsonCreator(CreatorFactory.class.getMethod("of", String.class)))
+        .isTrue();
+    assertThat(
+            introspector.isJsonCreator(
+                CreatorFactory.class.getMethod("delegating", String.class)))
+        .isFalse();
+    assertThat(introspector.isJsonCreator(CreatorFactory.class.getMethod("plain", String.class)))
+        .isFalse();
+  }
+
+  // --- findFieldVisibility ---
+
+  @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
+  static class AnyVisibility {}
+
+  @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.NON_PRIVATE)
+  static class NonPrivateVisibility {}
+
+  @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.PROTECTED_AND_PUBLIC)
+  static class ProtectedAndPublicVisibility {}
+
+  @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.NONE)
+  static class NoneVisibility {}
+
+  @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.PUBLIC_ONLY)
+  static class PublicOnlyVisibility {}
+
+  @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.DEFAULT)
+  static class DefaultVisibility {}
+
+  static class Unannotated {}
+
+  @Test
+  void findFieldVisibility_mapsAllJacksonLevels() {
+    assertThat(introspector.findFieldVisibility(AnyVisibility.class)).isEqualTo(Visibility.ANY);
+    assertThat(introspector.findFieldVisibility(NonPrivateVisibility.class))
+        .isEqualTo(Visibility.NON_PRIVATE);
+    assertThat(introspector.findFieldVisibility(ProtectedAndPublicVisibility.class))
+        .isEqualTo(Visibility.PROTECTED_AND_PUBLIC);
+    assertThat(introspector.findFieldVisibility(NoneVisibility.class)).isEqualTo(Visibility.NONE);
+    assertThat(introspector.findFieldVisibility(PublicOnlyVisibility.class))
+        .isEqualTo(Visibility.PUBLIC_ONLY);
+    // DEFAULT should yield null so the caller falls back to JavaBean conventions.
+    assertThat(introspector.findFieldVisibility(DefaultVisibility.class)).isNull();
+    // Class without @JsonAutoDetect.
+    assertThat(introspector.findFieldVisibility(Unannotated.class)).isNull();
+  }
+
+  // --- isJsonValue ---
+
+  static class WithJsonValue {
+    @JsonValue
+    public String single() {
+      return "x";
+    }
+
+    public String other() {
+      return "y";
+    }
+  }
+
+  @Test
+  void isJsonValue_detectsAnnotation() throws NoSuchMethodException {
+    assertThat(introspector.isJsonValue(WithJsonValue.class.getMethod("single"))).isTrue();
+    assertThat(introspector.isJsonValue(WithJsonValue.class.getMethod("other"))).isFalse();
+  }
+}
diff --git a/opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/RegoValueModuleTest.java b/opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/RegoValueModuleTest.java
new file mode 100644
index 00000000..7dc2a520
--- /dev/null
+++ b/opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/RegoValueModuleTest.java
@@ -0,0 +1,188 @@
+package io.github.open_policy_agent.opa.jackson;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.github.open_policy_agent.opa.ast.types.RegoArray;
+import io.github.open_policy_agent.opa.ast.types.RegoBigInt;
+import io.github.open_policy_agent.opa.ast.types.RegoBoolean;
+import io.github.open_policy_agent.opa.ast.types.RegoDecimal;
+import io.github.open_policy_agent.opa.ast.types.RegoInt32;
+import io.github.open_policy_agent.opa.ast.types.RegoNull;
+import io.github.open_policy_agent.opa.ast.types.RegoObject;
+import io.github.open_policy_agent.opa.ast.types.RegoSet;
+import io.github.open_policy_agent.opa.ast.types.RegoString;
+import io.github.open_policy_agent.opa.ast.types.RegoValue;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class RegoValueModuleTest {
+
+  private final ObjectMapper mapper = new ObjectMapper().registerModule(new RegoValueModule());
+
+  // --- Serialization (one test per RegoValue subtype) ---
+
+  @Test
+  void serialize_regoString() throws IOException {
+    assertThat(mapper.writeValueAsString(new RegoString("hello"))).isEqualTo("\"hello\"");
+  }
+
+  @Test
+  void serialize_regoInt32() throws IOException {
+    assertThat(mapper.writeValueAsString(RegoInt32.of(42))).isEqualTo("42");
+  }
+
+  @Test
+  void serialize_regoBigInt() throws IOException {
+    assertThat(mapper.writeValueAsString(new RegoBigInt(new BigInteger("12345678901234567890"))))
+        .isEqualTo("12345678901234567890");
+  }
+
+  @Test
+  void serialize_regoDecimal() throws IOException {
+    assertThat(mapper.writeValueAsString(new RegoDecimal(3.14))).isEqualTo("3.14");
+  }
+
+  @Test
+  void serialize_regoBoolean() throws IOException {
+    assertThat(mapper.writeValueAsString(RegoBoolean.TRUE)).isEqualTo("true");
+    assertThat(mapper.writeValueAsString(RegoBoolean.FALSE)).isEqualTo("false");
+  }
+
+  @Test
+  void serialize_regoNull() throws IOException {
+    assertThat(mapper.writeValueAsString(RegoNull.INSTANCE)).isEqualTo("null");
+  }
+
+  @Test
+  void serialize_regoArray_preservesOrder() throws IOException {
+    RegoArray arr = new RegoArray();
+    arr.addValue(new RegoString("a"));
+    arr.addValue(RegoInt32.of(1));
+    arr.addValue(RegoBoolean.TRUE);
+    assertThat(mapper.writeValueAsString(arr)).isEqualTo("[\"a\",1,true]");
+  }
+
+  @Test
+  void serialize_regoSet_emittedAsJsonArray() throws IOException {
+    RegoSet set = new RegoSet(false);
+    set.addValue(new RegoString("a"));
+    String json = mapper.writeValueAsString(set);
+    assertThat(json).startsWith("[").endsWith("]");
+    assertThat(json).contains("\"a\"");
+  }
+
+  @Test
+  void serialize_regoObject_sortsKeys() throws IOException {
+    RegoObject obj = new RegoObject();
+    obj.setProp(new RegoString("z"), new RegoString("last"));
+    obj.setProp(new RegoString("a"), new RegoString("first"));
+    obj.setProp(new RegoString("m"), new RegoString("middle"));
+    // Keys are emitted sorted to match OPA Go runtime output.
+    assertThat(mapper.writeValueAsString(obj))
+        .isEqualTo("{\"a\":\"first\",\"m\":\"middle\",\"z\":\"last\"}");
+  }
+
+  @Test
+  void serialize_regoObject_numericKeysCoercedToString() throws IOException {
+    RegoObject obj = new RegoObject();
+    obj.setProp(new RegoBigInt(BigInteger.valueOf(7)), new RegoString("seven"));
+    assertThat(mapper.writeValueAsString(obj)).isEqualTo("{\"7\":\"seven\"}");
+  }
+
+  @Test
+  void serialize_nestedStructure() throws IOException {
+    RegoObject obj = new RegoObject();
+    RegoArray arr = new RegoArray();
+    arr.addValue(RegoInt32.of(1));
+    arr.addValue(RegoNull.INSTANCE);
+    obj.setProp(new RegoString("items"), arr);
+    obj.setProp(new RegoString("ok"), RegoBoolean.TRUE);
+    assertThat(mapper.writeValueAsString(obj))
+        .isEqualTo("{\"items\":[1,null],\"ok\":true}");
+  }
+
+  // --- Deserialization ---
+
+  @Test
+  void deserialize_regoObject_simple() throws IOException {
+    RegoObject obj = mapper.readValue("{\"name\":\"alice\",\"age\":30}", RegoObject.class);
+    assertThat(((RegoString) obj.getProperty(new RegoString("name"))).getValue()).isEqualTo("alice");
+    RegoValue age = obj.getProperty(new RegoString("age"));
+    assertThat(age).isInstanceOf(RegoBigInt.class);
+    assertThat(((RegoBigInt) age).getValue()).isEqualTo(BigInteger.valueOf(30));
+  }
+
+  @Test
+  void deserialize_regoObject_nested() throws IOException {
+    String json =
+        "{\"user\":{\"id\":\"alice\",\"groups\":[\"admin\",\"user\"]},\"active\":true,\"score\":1.5}";
+    RegoObject obj = mapper.readValue(json, RegoObject.class);
+
+    RegoValue user = obj.getProperty(new RegoString("user"));
+    assertThat(user).isInstanceOf(RegoObject.class);
+    RegoObject userObj = (RegoObject) user;
+    assertThat(((RegoString) userObj.getProperty(new RegoString("id"))).getValue())
+        .isEqualTo("alice");
+
+    RegoValue groups = userObj.getProperty(new RegoString("groups"));
+    assertThat(groups).isInstanceOf(RegoArray.class);
+    assertThat(((RegoArray) groups).getValues())
+        .hasSize(2)
+        .containsExactly(new RegoString("admin"), new RegoString("user"));
+
+    assertThat(obj.getProperty(new RegoString("active"))).isEqualTo(RegoBoolean.TRUE);
+    assertThat(obj.getProperty(new RegoString("score"))).isInstanceOf(RegoDecimal.class);
+  }
+
+  @Test
+  void deserialize_regoObject_handlesNullValue() throws IOException {
+    RegoObject obj = mapper.readValue("{\"missing\":null}", RegoObject.class);
+    assertThat(obj.getProperty(new RegoString("missing"))).isEqualTo(RegoNull.INSTANCE);
+  }
+
+  @Test
+  void deserialize_regoObject_rejectsNonObjectInput() {
+    assertThatThrownBy(() -> mapper.readValue("[1,2,3]", RegoObject.class))
+        .isInstanceOf(IOException.class)
+        .hasMessageContaining("expected JSON object");
+  }
+
+  @Test
+  void roundTrip_complexObject() throws IOException {
+    RegoObject obj = new RegoObject();
+    obj.setProp(new RegoString("name"), new RegoString("alice"));
+    obj.setProp(new RegoString("count"), new RegoBigInt(BigInteger.valueOf(7)));
+    obj.setProp(new RegoString("active"), RegoBoolean.TRUE);
+    RegoArray tags = new RegoArray();
+    tags.addValue(new RegoString("admin"));
+    tags.addValue(new RegoString("user"));
+    obj.setProp(new RegoString("tags"), tags);
+
+    String json = mapper.writeValueAsString(obj);
+    RegoObject restored = mapper.readValue(json, RegoObject.class);
+
+    assertThat(((RegoString) restored.getProperty(new RegoString("name"))).getValue())
+        .isEqualTo("alice");
+    assertThat(((RegoBigInt) restored.getProperty(new RegoString("count"))).getValue())
+        .isEqualTo(BigInteger.valueOf(7));
+    assertThat(restored.getProperty(new RegoString("active"))).isEqualTo(RegoBoolean.TRUE);
+    RegoArray restoredTags = (RegoArray) restored.getProperty(new RegoString("tags"));
+    assertThat(restoredTags.getValues())
+        .containsExactly(new RegoString("admin"), new RegoString("user"));
+  }
+
+  @Test
+  void deserialize_viaConvertValue_fromMap() {
+    // Useful pattern for tests/bridges that build inputs as plain Maps/Lists.
+    Map source = Map.of("a", 1, "b", List.of("x", "y"));
+    RegoObject obj = mapper.convertValue(source, RegoObject.class);
+    assertThat(obj.getProperty(new RegoString("a"))).isInstanceOf(RegoBigInt.class);
+    assertThat(((RegoArray) obj.getProperty(new RegoString("b"))).getValues())
+        .containsExactly(new RegoString("x"), new RegoString("y"));
+  }
+}

From 12cda24636b272b9c16fae78692cc80b3dafeceb Mon Sep 17 00:00:00 2001
From: Sebastian Spaink 
Date: Tue, 19 May 2026 13:33:19 -0500
Subject: [PATCH 3/3] gjson implementation for annotations

Signed-off-by: Sebastian Spaink 
---
 opa-gson/build.gradle.kts                     |  25 +++
 .../opa/gson/GsonAnnotationIntrospector.java  | 130 +++++++++++++++
 ...cy_agent.opa.mapper.AnnotationIntrospector |   1 +
 .../gson/GsonAnnotationIntrospectorTest.java  | 154 ++++++++++++++++++
 settings.gradle.kts                           |   1 +
 5 files changed, 311 insertions(+)
 create mode 100644 opa-gson/build.gradle.kts
 create mode 100644 opa-gson/src/main/java/io/github/open_policy_agent/opa/gson/GsonAnnotationIntrospector.java
 create mode 100644 opa-gson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.mapper.AnnotationIntrospector
 create mode 100644 opa-gson/src/test/java/io/github/open_policy_agent/opa/gson/GsonAnnotationIntrospectorTest.java

diff --git a/opa-gson/build.gradle.kts b/opa-gson/build.gradle.kts
new file mode 100644
index 00000000..50bb97c3
--- /dev/null
+++ b/opa-gson/build.gradle.kts
@@ -0,0 +1,25 @@
+plugins {
+    `java-library`
+}
+
+repositories {
+    mavenCentral()
+}
+
+dependencies {
+    api(project(":opa-evaluator"))
+    implementation("com.google.code.gson:gson:2.11.0")
+
+    testImplementation("org.junit.jupiter:junit-jupiter:5.10.1")
+    testImplementation("org.assertj:assertj-core:3.27.6")
+}
+
+tasks.test {
+    useJUnitPlatform()
+}
+
+java {
+    toolchain {
+        languageVersion = JavaLanguageVersion.of(11)
+    }
+}
diff --git a/opa-gson/src/main/java/io/github/open_policy_agent/opa/gson/GsonAnnotationIntrospector.java b/opa-gson/src/main/java/io/github/open_policy_agent/opa/gson/GsonAnnotationIntrospector.java
new file mode 100644
index 00000000..450022d2
--- /dev/null
+++ b/opa-gson/src/main/java/io/github/open_policy_agent/opa/gson/GsonAnnotationIntrospector.java
@@ -0,0 +1,130 @@
+package io.github.open_policy_agent.opa.gson;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+import io.github.open_policy_agent.opa.mapper.AnnotationIntrospector;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Parameter;
+
+/**
+ * Gson-backed {@link AnnotationIntrospector}.
+ *
+ * 

Gson's annotation surface is intentionally narrower than Jackson's. The mapping below + * documents what's expressible: + * + * + * + * + * + * + * + * + * + * + *
SPI method → Gson concept
SPI methodGson equivalent
{@code findPropertyName}{@code @SerializedName} on field (Gson does not + * inspect getters for the property name).
{@code isIgnored}The Java {@code transient} keyword on the field, which + * Gson honors. Gson has no dedicated {@code @JsonIgnore} analogue; finer-grained + * exclusion uses an {@code ExclusionStrategy} configured on the {@code GsonBuilder}, + * which is out of band of the SPI.
{@code isNonNullInclude}Not expressible. Gson controls null + * inclusion globally via {@code GsonBuilder.serializeNulls()}, not per field.
{@code findCreatorParamName}, {@code isJsonCreator}Not expressible. + * Gson uses {@code InstanceCreator} or {@code TypeAdapter} registered on the + * {@code GsonBuilder}, not annotations. {@code @SerializedName} cannot target + * parameters either (its declared targets are {@code FIELD} and {@code METHOD}).
{@code findFieldVisibility}Not expressible. Gson configures + * visibility via {@code GsonBuilder.excludeFieldsWithModifiers(...)}, not annotations. + * Gson's default is to serialize all non-{@code transient}, non-{@code static} fields, + * which corresponds to {@link Visibility#ANY}; we leave that decision to the caller and + * return {@code null} so JavaBean defaults apply.
{@code isJsonValue}Not expressible. Gson uses + * {@code @JsonAdapter} or registered {@code TypeAdapter}s, not annotations on a + * single value method.
+ * + *

The narrow expressible surface is the point of having this implementation: it shows the + * SPI doesn't force a Gson backend to grow Jackson features — methods that + * don't apply return {@code null}/{@code false}, and {@code RegoMapper} falls back to + * JavaBean defaults for those concerns. + * + *

Discovered via {@link java.util.ServiceLoader}; consumers don't reference this class + * directly. Note: only one {@link AnnotationIntrospector} may be registered at a time, so + * {@code opa-gson} and {@code opa-jackson} are mutually exclusive on the classpath. + */ +public class GsonAnnotationIntrospector implements AnnotationIntrospector { + + @Override + public String findPropertyName(Method getter, Field backingField) { + if (backingField != null) { + SerializedName ann = backingField.getAnnotation(SerializedName.class); + if (ann != null && !ann.value().isEmpty()) { + return ann.value(); + } + } + // Gson does not consult getters for property names; bean discovery picks them up. + return null; + } + + @Override + public boolean isIgnored(Method getter, Field backingField) { + // Gson respects the `transient` keyword (and `static`) when serializing fields. + if (backingField != null) { + int mods = backingField.getModifiers(); + if (Modifier.isTransient(mods)) { + return true; + } + } + return false; + } + + @Override + public boolean isNonNullInclude(Method getter, Field backingField) { + // Gson controls null inclusion globally via GsonBuilder.serializeNulls(); the annotation + // surface carries no per-property signal we can return here. + return false; + } + + @Override + public String findCreatorParamName(Parameter param) { + // Gson has no @JsonCreator-equivalent and @SerializedName cannot target parameters + // (Gson's @Target is FIELD, METHOD only). No annotation to inspect here. + return null; + } + + @Override + public boolean isJsonCreator(Constructor ctor) { + // Gson does not use annotations to mark creators; InstanceCreator is registered on the + // GsonBuilder. No annotation to inspect. + return false; + } + + @Override + public boolean isJsonCreator(Method method) { + return false; + } + + @Override + public Visibility findFieldVisibility(Class clazz) { + // Gson has no @JsonAutoDetect-equivalent. Returning null defers to JavaBean defaults in + // RegoMapper, which is the right behavior: callers configure Gson's visibility on the + // GsonBuilder, which doesn't propagate to this SPI. + return null; + } + + @Override + public boolean isJsonValue(Method method) { + // No Gson annotation marks a single value-producing method. Custom TypeAdapter is the + // Gson-idiomatic way to handle this, registered on the GsonBuilder. + return false; + } + + /** + * Returns true if the field carries Gson's {@link Expose @Expose} annotation. Not part of + * the SPI — exposed for tests/diagnostics. {@code @Expose} is only meaningful when + * the consuming {@code Gson} instance was built with + * {@code GsonBuilder.excludeFieldsWithoutExposeAnnotation()}, which is configured outside + * the introspector. + */ + static boolean hasExpose(Field field) { + return field != null && field.isAnnotationPresent(Expose.class); + } +} diff --git a/opa-gson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.mapper.AnnotationIntrospector b/opa-gson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.mapper.AnnotationIntrospector new file mode 100644 index 00000000..59fc552b --- /dev/null +++ b/opa-gson/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.mapper.AnnotationIntrospector @@ -0,0 +1 @@ +io.github.open_policy_agent.opa.gson.GsonAnnotationIntrospector diff --git a/opa-gson/src/test/java/io/github/open_policy_agent/opa/gson/GsonAnnotationIntrospectorTest.java b/opa-gson/src/test/java/io/github/open_policy_agent/opa/gson/GsonAnnotationIntrospectorTest.java new file mode 100644 index 00000000..db39fbb5 --- /dev/null +++ b/opa-gson/src/test/java/io/github/open_policy_agent/opa/gson/GsonAnnotationIntrospectorTest.java @@ -0,0 +1,154 @@ +package io.github.open_policy_agent.opa.gson; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; +import io.github.open_policy_agent.opa.mapper.AnnotationIntrospector; +import io.github.open_policy_agent.opa.mapper.AnnotationIntrospector.Visibility; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import org.junit.jupiter.api.Test; + +class GsonAnnotationIntrospectorTest { + + private final AnnotationIntrospector introspector = new GsonAnnotationIntrospector(); + + // --- findPropertyName --- + + static class FieldRenamed { + @SerializedName("alt") public String value; + } + + @Test + void findPropertyName_readsSerializedNameOnField() throws Exception { + Field field = FieldRenamed.class.getField("value"); + assertThat(introspector.findPropertyName(null, field)).isEqualTo("alt"); + } + + static class GetterRenamed { + private String value; + + @SerializedName("ignored") // Gson does not read getters for the property name. + public String getValue() { + return value; + } + } + + @Test + void findPropertyName_ignoresAnnotationOnGetter() throws Exception { + // Documents the SPI gap: Gson's annotation discovery model is field-only, so a + // @SerializedName on a getter does not influence property naming. The Jackson + // introspector reads getters; Gson's does not. + Method getter = GetterRenamed.class.getMethod("getValue"); + assertThat(introspector.findPropertyName(getter, null)).isNull(); + } + + static class NoAnnotation { + public String value; + + public String getValue() { + return value; + } + } + + @Test + void findPropertyName_returnsNullWhenUnannotated() throws Exception { + Field field = NoAnnotation.class.getField("value"); + assertThat(introspector.findPropertyName(null, field)).isNull(); + assertThat(introspector.findPropertyName(null, null)).isNull(); + } + + // --- isIgnored --- + + static class TransientField { + public transient String secret; + public String visible; + } + + @Test + void isIgnored_respectsTransientModifier() throws Exception { + Field secret = TransientField.class.getField("secret"); + Field visible = TransientField.class.getField("visible"); + assertThat(introspector.isIgnored(null, secret)).isTrue(); + assertThat(introspector.isIgnored(null, visible)).isFalse(); + } + + @Test + void isIgnored_returnsFalseWhenFieldNullOrUnannotated() throws Exception { + // No equivalent of @JsonIgnore in Gson's annotation set — getters can't be ignored + // through annotations, only through ExclusionStrategy on the builder. + Method getter = NoAnnotation.class.getMethod("getValue"); + assertThat(introspector.isIgnored(getter, null)).isFalse(); + assertThat(introspector.isIgnored(null, null)).isFalse(); + } + + // --- Methods Gson cannot express --- + + @Test + void isNonNullInclude_alwaysFalse_gsonControlsThisGlobally() throws Exception { + // Documents that the SPI question "should this property omit nulls?" cannot be answered + // from Gson annotations — null inclusion is a builder-level concern. + Field field = NoAnnotation.class.getField("value"); + assertThat(introspector.isNonNullInclude(null, field)).isFalse(); + assertThat(introspector.isNonNullInclude(null, null)).isFalse(); + } + + @Test + void isJsonCreator_alwaysFalse_gsonHasNoCreatorAnnotation() throws Exception { + Constructor ctor = NoAnnotation.class.getDeclaredConstructor(); + Method method = NoAnnotation.class.getMethod("getValue"); + assertThat(introspector.isJsonCreator(ctor)).isFalse(); + assertThat(introspector.isJsonCreator(method)).isFalse(); + } + + @Test + void findFieldVisibility_alwaysNull_gsonHasNoVisibilityAnnotation() { + // Defers to RegoMapper's JavaBean defaults; visibility configuration on Gson lives on + // the GsonBuilder, not in annotations. + assertThat(introspector.findFieldVisibility(NoAnnotation.class)).isNull(); + assertThat(introspector.findFieldVisibility(FieldRenamed.class)).isNull(); + } + + @Test + void isJsonValue_alwaysFalse_gsonUsesTypeAdapters() throws Exception { + Method method = NoAnnotation.class.getMethod("getValue"); + assertThat(introspector.isJsonValue(method)).isFalse(); + } + + // --- findCreatorParamName --- + + static class WithCreator { + public WithCreator(String foo, String bar) {} + } + + @Test + void findCreatorParamName_alwaysNull_gsonHasNoParamAnnotation() throws NoSuchMethodException { + // Documents the SPI gap: Gson's @SerializedName cannot target parameters (its declared + // @Target is FIELD, METHOD only), and Gson's annotation set has no @JsonCreator analogue. + // Constructor injection is configured via InstanceCreator on the GsonBuilder, not via + // annotations, so this method has no annotation to read. + Constructor ctor = WithCreator.class.getConstructor(String.class, String.class); + Parameter[] params = ctor.getParameters(); + assertThat(introspector.findCreatorParamName(params[0])).isNull(); + assertThat(introspector.findCreatorParamName(params[1])).isNull(); + } + + // --- @Expose helper (out-of-SPI but useful for diagnostics) --- + + static class ExposedFields { + @Expose public String visible; + public String hidden; + } + + @Test + void hasExpose_packagePrivateHelperReturnsAnnotationPresence() throws Exception { + assertThat(GsonAnnotationIntrospector.hasExpose(ExposedFields.class.getField("visible"))) + .isTrue(); + assertThat(GsonAnnotationIntrospector.hasExpose(ExposedFields.class.getField("hidden"))) + .isFalse(); + assertThat(GsonAnnotationIntrospector.hasExpose(null)).isFalse(); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 7b1ad68f..e481c68e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,6 +14,7 @@ rootProject.name = "java-opa-sdk" include("opa-evaluator") include("opa-jackson") +include("opa-gson") include("opa-services") include("opa-builtins") include("opa-builtins:opa-builtins-time")