diff --git a/databricks-sdk-java/pom.xml b/databricks-sdk-java/pom.xml index 5b612a3a4..beacf47b5 100644 --- a/databricks-sdk-java/pom.xml +++ b/databricks-sdk-java/pom.xml @@ -123,5 +123,17 @@ 1.10.4 provided + + + com.google.protobuf + protobuf-java + 3.24.2 + + + + com.google.protobuf + protobuf-java-util + 3.24.2 + diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/ProtobufModule.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/ProtobufModule.java new file mode 100644 index 000000000..c28e095e5 --- /dev/null +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/ProtobufModule.java @@ -0,0 +1,119 @@ +package com.databricks.sdk.core.utils; + +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.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.google.protobuf.Duration; +import com.google.protobuf.FieldMask; +import com.google.protobuf.Timestamp; +import com.google.protobuf.util.Durations; +import com.google.protobuf.util.Timestamps; +import java.io.IOException; + +/** Jackson module for serializing and deserializing Google Protocol Buffers types. */ +public class ProtobufModule extends SimpleModule { + + public ProtobufModule() { + super("ProtobufModule"); + + // FieldMask serializers. + addSerializer(FieldMask.class, new FieldMaskSerializer()); + addDeserializer(FieldMask.class, new FieldMaskDeserializer()); + + // Duration serializers. + addSerializer(Duration.class, new DurationSerializer()); + addDeserializer(Duration.class, new DurationDeserializer()); + + // Timestamp serializers. + addSerializer(Timestamp.class, new TimestampSerializer()); + addDeserializer(Timestamp.class, new TimestampDeserializer()); + } + + /** Serializes FieldMask using simple string joining to preserve original casing. */ + public static class FieldMaskSerializer extends JsonSerializer { + @Override + public void serialize(FieldMask fieldMask, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + // Unlike the Google API, we preserve the original casing of the field paths. + gen.writeString(String.join(",", fieldMask.getPathsList())); + } + } + + /** Deserializes FieldMask using simple string splitting to preserve original casing. */ + public static class FieldMaskDeserializer extends JsonDeserializer { + @Override + public FieldMask deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String pathsString = p.getValueAsString(); + if (pathsString == null || pathsString.trim().isEmpty()) { + return FieldMask.getDefaultInstance(); + } + + // Unlike the Google API, we preserve the original casing of the field paths. + FieldMask.Builder builder = FieldMask.newBuilder(); + String[] paths = pathsString.split(","); + for (String path : paths) { + String trimmedPath = path.trim(); + if (!trimmedPath.isEmpty()) { + builder.addPaths(trimmedPath); + } + } + return builder.build(); + } + } + + /** Serializes Duration using Google's built-in utility. */ + public static class DurationSerializer extends JsonSerializer { + @Override + public void serialize(Duration duration, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeString(Durations.toString(duration)); + } + } + + /** Deserializes Duration using Google's built-in utility. */ + public static class DurationDeserializer extends JsonDeserializer { + @Override + public Duration deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String durationString = p.getValueAsString(); + if (durationString == null || durationString.trim().isEmpty()) { + return Duration.getDefaultInstance(); + } + + try { + return Durations.parse(durationString.trim()); + } catch (Exception e) { + throw new IOException("Invalid duration format: " + durationString, e); + } + } + } + + /** Serializes Timestamp using Google's built-in utility. */ + public static class TimestampSerializer extends JsonSerializer { + @Override + public void serialize(Timestamp timestamp, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeString(Timestamps.toString(timestamp)); + } + } + + /** Deserializes Timestamp using Google's built-in utility. */ + public static class TimestampDeserializer extends JsonDeserializer { + @Override + public Timestamp deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String timestampString = p.getValueAsString(); + if (timestampString == null || timestampString.trim().isEmpty()) { + return Timestamp.getDefaultInstance(); + } + + try { + return Timestamps.parse(timestampString.trim()); + } catch (Exception e) { + throw new IOException("Invalid timestamp format: " + timestampString, e); + } + } + } +} diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/SerDeUtils.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/SerDeUtils.java index 8fd484996..ce1a84a2c 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/SerDeUtils.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/SerDeUtils.java @@ -14,6 +14,7 @@ public static ObjectMapper createMapper() { mapper .registerModule(new JavaTimeModule()) .registerModule(new GuavaModule()) + .registerModule(new ProtobufModule()) .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false) .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true) diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/utils/ProtobufModuleTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/utils/ProtobufModuleTest.java new file mode 100644 index 000000000..6a335535e --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/utils/ProtobufModuleTest.java @@ -0,0 +1,137 @@ +package com.databricks.sdk.core.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import com.databricks.sdk.core.ApiClient; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.protobuf.Duration; +import com.google.protobuf.FieldMask; +import com.google.protobuf.Timestamp; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.*; + +public class ProtobufModuleTest { + + // Helper wrapper classes for individual protobuf types. + public static class FieldMaskWrapper { + @JsonProperty("mask") + public FieldMask mask; + } + + public static class DurationWrapper { + @JsonProperty("duration") + public Duration duration; + } + + public static class TimestampWrapper { + @JsonProperty("timestamp") + public Timestamp timestamp; + } + + // FieldMask Parameterized Tests. + @ParameterizedTest + @ValueSource( + strings = { + "", + "user.name,user.email", + "profile.snake_case,profile.avatar", + "profile.displayName,profile.avatar,settings.theme", + "nested.deep.field", + "nested.deep.field.value", + "complex.nested.deep.path.with.multiple.levels" + }) + public void testFieldMaskSerializationAndRoundtrip(String pathsString) + throws JsonProcessingException { + // Create original FieldMask. + FieldMask.Builder builder = FieldMask.newBuilder(); + if (!pathsString.isEmpty()) { + for (String path : pathsString.split(",")) { + builder.addPaths(path.trim()); + } + } + FieldMask original = builder.build(); + + // Test serialization. + FieldMaskWrapper wrapper = new FieldMaskWrapper(); + wrapper.mask = original; + + String json = new ApiClient().serialize(wrapper); + String expectedJson = "{\"mask\":\"" + pathsString + "\"}"; + assertEquals(expectedJson, json); + + // Test roundtrip (deserialize and verify). + ObjectMapper mapper = SerDeUtils.createMapper(); + FieldMaskWrapper deserialized = mapper.readValue(json, FieldMaskWrapper.class); + assertEquals(original.getPathsList(), deserialized.mask.getPathsList()); + } + + // Duration Parameterized Tests. + static Stream durationTestCases() { + return Stream.of( + Arguments.of(0L, 0, "0s"), + Arguments.of(1L, 0, "1s"), + Arguments.of(30L, 0, "30s"), + Arguments.of(3661L, 0, "3661s"), // 1 hour 1 minute 1 second + Arguments.of(0L, 500_000_000, "0.500s"), // 0.5 seconds + Arguments.of(1L, 500_000_000, "1.500s"), // 1.5 seconds + Arguments.of(30L, 3, "30.000000003s") // 30 seconds + 3 nanoseconds + ); + } + + @ParameterizedTest + @MethodSource("durationTestCases") + public void testDurationSerializationAndRoundtrip( + long seconds, int nanos, String expectedDurationString) throws JsonProcessingException { + Duration original = Duration.newBuilder().setSeconds(seconds).setNanos(nanos).build(); + + DurationWrapper wrapper = new DurationWrapper(); + wrapper.duration = original; + + // Test serialization. + String json = new ApiClient().serialize(wrapper); + String expectedJson = "{\"duration\":\"" + expectedDurationString + "\"}"; + assertEquals(expectedJson, json); + + // Test roundtrip (deserialize and verify). + ObjectMapper mapper = SerDeUtils.createMapper(); + DurationWrapper deserialized = mapper.readValue(json, DurationWrapper.class); + assertEquals(original.getSeconds(), deserialized.duration.getSeconds()); + assertEquals(original.getNanos(), deserialized.duration.getNanos()); + } + + // Timestamp Parameterized Tests. + static Stream timestampTestCases() { + return Stream.of( + Arguments.of(0L, 0, "1970-01-01T00:00:00Z"), // Unix epoch + Arguments.of(1717756800L, 0, "2024-06-07T10:40:00Z"), // Test timestamp + Arguments.of(1609459200L, 0, "2021-01-01T00:00:00Z"), // New Year 2021 + Arguments.of(1577836800L, 0, "2020-01-01T00:00:00Z"), // New Year 2020 + Arguments.of(1640995200L, 500_000_000, "2022-01-01T00:00:00.500Z"), // With nanoseconds + Arguments.of(253402300799L, 999_999_999, "9999-12-31T23:59:59.999999999Z") // Far future + ); + } + + @ParameterizedTest + @MethodSource("timestampTestCases") + public void testTimestampSerializationAndRoundtrip( + long seconds, int nanos, String expectedTimestampString) throws JsonProcessingException { + Timestamp original = Timestamp.newBuilder().setSeconds(seconds).setNanos(nanos).build(); + + TimestampWrapper wrapper = new TimestampWrapper(); + wrapper.timestamp = original; + + // Test serialization. + String json = new ApiClient().serialize(wrapper); + String expectedJson = "{\"timestamp\":\"" + expectedTimestampString + "\"}"; + assertEquals(expectedJson, json); + + // Test roundtrip (deserialize and verify). + ObjectMapper mapper = SerDeUtils.createMapper(); + TimestampWrapper deserialized = mapper.readValue(json, TimestampWrapper.class); + assertEquals(original.getSeconds(), deserialized.timestamp.getSeconds()); + assertEquals(original.getNanos(), deserialized.timestamp.getNanos()); + } +}