From 34d4884972a49a29bc2f817d9f6d900c4a2e7e73 Mon Sep 17 00:00:00 2001 From: Sanajit Jana Date: Tue, 23 Jun 2026 14:48:17 +0530 Subject: [PATCH] feat(opa-gson): add MetricsTypeAdapterFactory mirroring opa-jackson's MetricsModule Signed-off-by: Sanajit Jana --- .../opa/gson/MetricsTypeAdapterFactory.java | 102 ++++++++++++++++++ .../gson/MetricsTypeAdapterFactoryTest.java | 53 +++++++++ 2 files changed, 155 insertions(+) create mode 100644 opa-gson/src/main/java/io/github/open_policy_agent/opa/gson/MetricsTypeAdapterFactory.java create mode 100644 opa-gson/src/test/java/io/github/open_policy_agent/opa/gson/MetricsTypeAdapterFactoryTest.java diff --git a/opa-gson/src/main/java/io/github/open_policy_agent/opa/gson/MetricsTypeAdapterFactory.java b/opa-gson/src/main/java/io/github/open_policy_agent/opa/gson/MetricsTypeAdapterFactory.java new file mode 100644 index 00000000..b945f118 --- /dev/null +++ b/opa-gson/src/main/java/io/github/open_policy_agent/opa/gson/MetricsTypeAdapterFactory.java @@ -0,0 +1,102 @@ +package io.github.open_policy_agent.opa.gson; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import io.github.open_policy_agent.opa.metrics.Metrics; +import java.io.IOException; + +/** + * Gson {@link TypeAdapterFactory} that serializes {@link Metrics} types to match the JSON shape + * produced by OPA's decision logs ({@code timer__ns} convention). + * + *
    + *
  • {@link Metrics.Timer} → nanoseconds (long) + *
  • {@link Metrics.Counter} → integer value + *
  • {@link Metrics.Histogram} → its {@link Metrics.Histogram.Values} object + *
+ * + *

Usage: + *

{@code
+ * Gson gson = new GsonBuilder()
+ *     .registerTypeAdapterFactory(new MetricsTypeAdapterFactory())
+ *     .create();
+ * String json = gson.toJson(timer);
+ * }
+ */ +public class MetricsTypeAdapterFactory implements TypeAdapterFactory { + + @SuppressWarnings("unchecked") + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (Metrics.Timer.class.isAssignableFrom(type.getRawType())) { + return (TypeAdapter) new TimerAdapter(); + } + if (Metrics.Counter.class.isAssignableFrom(type.getRawType())) { + return (TypeAdapter) new CounterAdapter(); + } + if (Metrics.Histogram.class.isAssignableFrom(type.getRawType())) { + return (TypeAdapter) new HistogramAdapter(gson); + } + return null; + } + + private static final class TimerAdapter extends TypeAdapter { + @Override + public void write(JsonWriter out, Metrics.Timer timer) throws IOException { + if (timer == null) { + out.nullValue(); + } else { + out.value(timer.value().toNanos()); + } + } + + @Override + public Metrics.Timer read(JsonReader in) { + throw new UnsupportedOperationException("Deserialization of Metrics.Timer is not supported"); + } + } + + private static final class CounterAdapter extends TypeAdapter { + @Override + public void write(JsonWriter out, Metrics.Counter counter) throws IOException { + if (counter == null) { + out.nullValue(); + } else { + out.value(counter.value()); + } + } + + @Override + public Metrics.Counter read(JsonReader in) { + throw new UnsupportedOperationException( + "Deserialization of Metrics.Counter is not supported"); + } + } + + private static final class HistogramAdapter extends TypeAdapter { + private final TypeAdapter valuesAdapter; + + HistogramAdapter(Gson gson) { + this.valuesAdapter = gson.getAdapter(Metrics.Histogram.Values.class); + } + + @Override + public void write(JsonWriter out, Metrics.Histogram histogram) throws IOException { + if (histogram == null) { + out.nullValue(); + } else { + valuesAdapter.write(out, histogram.value()); + } + } + + @Override + public Metrics.Histogram read(JsonReader in) { + throw new UnsupportedOperationException( + "Deserialization of Metrics.Histogram is not supported"); + } + } +} diff --git a/opa-gson/src/test/java/io/github/open_policy_agent/opa/gson/MetricsTypeAdapterFactoryTest.java b/opa-gson/src/test/java/io/github/open_policy_agent/opa/gson/MetricsTypeAdapterFactoryTest.java new file mode 100644 index 00000000..7d87cef2 --- /dev/null +++ b/opa-gson/src/test/java/io/github/open_policy_agent/opa/gson/MetricsTypeAdapterFactoryTest.java @@ -0,0 +1,53 @@ +package io.github.open_policy_agent.opa.gson; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.github.open_policy_agent.opa.metrics.Metrics; +import io.github.open_policy_agent.opa.metrics.SimpleMetrics; +import org.junit.jupiter.api.Test; + +/** + * Parallel to opa-jackson's MetricsModuleTest. Verifies that MetricsTypeAdapterFactory produces + * JSON matching OPA's decision log shape ({@code timer__ns} = nanoseconds as a long). + */ +class MetricsTypeAdapterFactoryTest { + + private final Gson gson = + new GsonBuilder().registerTypeAdapterFactory(new MetricsTypeAdapterFactory()).create(); + + @Test + void timer_serializesAsNanoseconds() { + SimpleMetrics metrics = new SimpleMetrics(); + Metrics.Timer timer = metrics.timer("rego_query_eval"); + timer.start(); + timer.stop(); + + String json = gson.toJson(timer, Metrics.Timer.class); + + // Must be a plain number (nanoseconds), matching DecisionLogPlugin's timer.value().toNanos() + long nanos = timer.value().toNanos(); + assertThat(json).isEqualTo(String.valueOf(nanos)); + } + + @Test + void timer_jsonShapeMatchesDirectNanosSerialization() { + SimpleMetrics metrics = new SimpleMetrics(); + Metrics.Timer timer = metrics.timer("rego_query_eval"); + timer.start(); + timer.stop(); + + String timerJson = gson.toJson(timer, Metrics.Timer.class); + String nanosJson = gson.toJson(timer.value().toNanos()); + + // Serializing the Timer must produce exactly what serializing its nanosecond value produces. + assertThat(timerJson).isEqualTo(nanosJson); + } + + @Test + void timer_null_serializesAsNull() { + String json = gson.toJson(null, Metrics.Timer.class); + assertThat(json).isEqualTo("null"); + } +}