Skip to content

Commit edeb8c2

Browse files
committed
add proto serialization utils
1 parent a2ec565 commit edeb8c2

File tree

4 files changed

+269
-0
lines changed

4 files changed

+269
-0
lines changed

databricks-sdk-java/pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,5 +123,17 @@
123123
<version>1.10.4</version>
124124
<scope>provided</scope>
125125
</dependency>
126+
<!-- Google Protocol Buffers -->
127+
<dependency>
128+
<groupId>com.google.protobuf</groupId>
129+
<artifactId>protobuf-java</artifactId>
130+
<version>3.24.2</version>
131+
</dependency>
132+
<!-- Google Protocol Buffers Utilities -->
133+
<dependency>
134+
<groupId>com.google.protobuf</groupId>
135+
<artifactId>protobuf-java-util</artifactId>
136+
<version>3.24.2</version>
137+
</dependency>
126138
</dependencies>
127139
</project>
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package com.databricks.sdk.core.utils;
2+
3+
import com.fasterxml.jackson.core.JsonGenerator;
4+
import com.fasterxml.jackson.core.JsonParser;
5+
import com.fasterxml.jackson.databind.DeserializationContext;
6+
import com.fasterxml.jackson.databind.JsonDeserializer;
7+
import com.fasterxml.jackson.databind.JsonSerializer;
8+
import com.fasterxml.jackson.databind.SerializerProvider;
9+
import com.fasterxml.jackson.databind.module.SimpleModule;
10+
import com.google.protobuf.Duration;
11+
import com.google.protobuf.FieldMask;
12+
import com.google.protobuf.Timestamp;
13+
import com.google.protobuf.util.Durations;
14+
import com.google.protobuf.util.Timestamps;
15+
import java.io.IOException;
16+
17+
/** Jackson module for serializing and deserializing Google Protocol Buffers types. */
18+
public class ProtobufModule extends SimpleModule {
19+
20+
public ProtobufModule() {
21+
super("ProtobufModule");
22+
23+
// FieldMask serializers.
24+
addSerializer(FieldMask.class, new FieldMaskSerializer());
25+
addDeserializer(FieldMask.class, new FieldMaskDeserializer());
26+
27+
// Duration serializers.
28+
addSerializer(Duration.class, new DurationSerializer());
29+
addDeserializer(Duration.class, new DurationDeserializer());
30+
31+
// Timestamp serializers.
32+
addSerializer(Timestamp.class, new TimestampSerializer());
33+
addDeserializer(Timestamp.class, new TimestampDeserializer());
34+
}
35+
36+
/** Serializes FieldMask using simple string joining to preserve original casing. */
37+
public static class FieldMaskSerializer extends JsonSerializer<FieldMask> {
38+
@Override
39+
public void serialize(FieldMask fieldMask, JsonGenerator gen, SerializerProvider serializers)
40+
throws IOException {
41+
// Unlike the Google API, we preserve the original casing of the field paths.
42+
gen.writeString(String.join(",", fieldMask.getPathsList()));
43+
}
44+
}
45+
46+
/** Deserializes FieldMask using simple string splitting to preserve original casing. */
47+
public static class FieldMaskDeserializer extends JsonDeserializer<FieldMask> {
48+
@Override
49+
public FieldMask deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
50+
String pathsString = p.getValueAsString();
51+
if (pathsString == null || pathsString.trim().isEmpty()) {
52+
return FieldMask.getDefaultInstance();
53+
}
54+
55+
// Unlike the Google API, we preserve the original casing of the field paths.
56+
FieldMask.Builder builder = FieldMask.newBuilder();
57+
String[] paths = pathsString.split(",");
58+
for (String path : paths) {
59+
String trimmedPath = path.trim();
60+
if (!trimmedPath.isEmpty()) {
61+
builder.addPaths(trimmedPath);
62+
}
63+
}
64+
return builder.build();
65+
}
66+
}
67+
68+
/** Serializes Duration using Google's built-in utility. */
69+
public static class DurationSerializer extends JsonSerializer<Duration> {
70+
@Override
71+
public void serialize(Duration duration, JsonGenerator gen, SerializerProvider serializers)
72+
throws IOException {
73+
gen.writeString(Durations.toString(duration));
74+
}
75+
}
76+
77+
/** Deserializes Duration using Google's built-in utility. */
78+
public static class DurationDeserializer extends JsonDeserializer<Duration> {
79+
@Override
80+
public Duration deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
81+
String durationString = p.getValueAsString();
82+
if (durationString == null || durationString.trim().isEmpty()) {
83+
return Duration.getDefaultInstance();
84+
}
85+
86+
try {
87+
return Durations.parse(durationString.trim());
88+
} catch (Exception e) {
89+
throw new IOException("Invalid duration format: " + durationString, e);
90+
}
91+
}
92+
}
93+
94+
/** Serializes Timestamp using Google's built-in utility. */
95+
public static class TimestampSerializer extends JsonSerializer<Timestamp> {
96+
@Override
97+
public void serialize(Timestamp timestamp, JsonGenerator gen, SerializerProvider serializers)
98+
throws IOException {
99+
gen.writeString(Timestamps.toString(timestamp));
100+
}
101+
}
102+
103+
/** Deserializes Timestamp using Google's built-in utility. */
104+
public static class TimestampDeserializer extends JsonDeserializer<Timestamp> {
105+
@Override
106+
public Timestamp deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
107+
String timestampString = p.getValueAsString();
108+
if (timestampString == null || timestampString.trim().isEmpty()) {
109+
return Timestamp.getDefaultInstance();
110+
}
111+
112+
try {
113+
return Timestamps.parse(timestampString.trim());
114+
} catch (Exception e) {
115+
throw new IOException("Invalid timestamp format: " + timestampString, e);
116+
}
117+
}
118+
}
119+
}

databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/SerDeUtils.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public static ObjectMapper createMapper() {
1414
mapper
1515
.registerModule(new JavaTimeModule())
1616
.registerModule(new GuavaModule())
17+
.registerModule(new ProtobufModule())
1718
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
1819
.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
1920
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package com.databricks.sdk.core.utils;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import com.databricks.sdk.core.ApiClient;
6+
import com.fasterxml.jackson.annotation.JsonProperty;
7+
import com.fasterxml.jackson.core.JsonProcessingException;
8+
import com.fasterxml.jackson.databind.ObjectMapper;
9+
import com.google.protobuf.Duration;
10+
import com.google.protobuf.FieldMask;
11+
import com.google.protobuf.Timestamp;
12+
import java.util.stream.Stream;
13+
import org.junit.jupiter.params.ParameterizedTest;
14+
import org.junit.jupiter.params.provider.*;
15+
16+
public class ProtobufModuleTest {
17+
18+
// Helper wrapper classes for individual protobuf types.
19+
public static class FieldMaskWrapper {
20+
@JsonProperty("mask")
21+
public FieldMask mask;
22+
}
23+
24+
public static class DurationWrapper {
25+
@JsonProperty("duration")
26+
public Duration duration;
27+
}
28+
29+
public static class TimestampWrapper {
30+
@JsonProperty("timestamp")
31+
public Timestamp timestamp;
32+
}
33+
34+
// FieldMask Parameterized Tests.
35+
@ParameterizedTest
36+
@ValueSource(
37+
strings = {
38+
"",
39+
"user.name,user.email",
40+
"profile.snake_case,profile.avatar",
41+
"profile.displayName,profile.avatar,settings.theme",
42+
"nested.deep.field",
43+
"nested.deep.field.value",
44+
"complex.nested.deep.path.with.multiple.levels"
45+
})
46+
public void testFieldMaskSerializationAndRoundtrip(String pathsString)
47+
throws JsonProcessingException {
48+
// Create original FieldMask.
49+
FieldMask.Builder builder = FieldMask.newBuilder();
50+
if (!pathsString.isEmpty()) {
51+
for (String path : pathsString.split(",")) {
52+
builder.addPaths(path.trim());
53+
}
54+
}
55+
FieldMask original = builder.build();
56+
57+
// Test serialization.
58+
FieldMaskWrapper wrapper = new FieldMaskWrapper();
59+
wrapper.mask = original;
60+
61+
String json = new ApiClient().serialize(wrapper);
62+
String expectedJson = "{\"mask\":\"" + pathsString + "\"}";
63+
assertEquals(expectedJson, json);
64+
65+
// Test roundtrip (deserialize and verify).
66+
ObjectMapper mapper = SerDeUtils.createMapper();
67+
FieldMaskWrapper deserialized = mapper.readValue(json, FieldMaskWrapper.class);
68+
assertEquals(original.getPathsList(), deserialized.mask.getPathsList());
69+
}
70+
71+
// Duration Parameterized Tests.
72+
static Stream<Arguments> durationTestCases() {
73+
return Stream.of(
74+
Arguments.of(0L, 0, "0s"),
75+
Arguments.of(1L, 0, "1s"),
76+
Arguments.of(30L, 0, "30s"),
77+
Arguments.of(3661L, 0, "3661s"), // 1 hour 1 minute 1 second
78+
Arguments.of(0L, 500_000_000, "0.500s"), // 0.5 seconds
79+
Arguments.of(1L, 500_000_000, "1.500s"), // 1.5 seconds
80+
Arguments.of(30L, 3, "30.000000003s") // 30 seconds + 3 nanoseconds
81+
);
82+
}
83+
84+
@ParameterizedTest
85+
@MethodSource("durationTestCases")
86+
public void testDurationSerializationAndRoundtrip(
87+
long seconds, int nanos, String expectedDurationString) throws JsonProcessingException {
88+
Duration original = Duration.newBuilder().setSeconds(seconds).setNanos(nanos).build();
89+
90+
DurationWrapper wrapper = new DurationWrapper();
91+
wrapper.duration = original;
92+
93+
// Test serialization.
94+
String json = new ApiClient().serialize(wrapper);
95+
String expectedJson = "{\"duration\":\"" + expectedDurationString + "\"}";
96+
assertEquals(expectedJson, json);
97+
98+
// Test roundtrip (deserialize and verify).
99+
ObjectMapper mapper = SerDeUtils.createMapper();
100+
DurationWrapper deserialized = mapper.readValue(json, DurationWrapper.class);
101+
assertEquals(original.getSeconds(), deserialized.duration.getSeconds());
102+
assertEquals(original.getNanos(), deserialized.duration.getNanos());
103+
}
104+
105+
// Timestamp Parameterized Tests.
106+
static Stream<Arguments> timestampTestCases() {
107+
return Stream.of(
108+
Arguments.of(0L, 0, "1970-01-01T00:00:00Z"), // Unix epoch
109+
Arguments.of(1717756800L, 0, "2024-06-07T10:40:00Z"), // Test timestamp
110+
Arguments.of(1609459200L, 0, "2021-01-01T00:00:00Z"), // New Year 2021
111+
Arguments.of(1577836800L, 0, "2020-01-01T00:00:00Z"), // New Year 2020
112+
Arguments.of(1640995200L, 500_000_000, "2022-01-01T00:00:00.500Z"), // With nanoseconds
113+
Arguments.of(253402300799L, 999_999_999, "9999-12-31T23:59:59.999999999Z") // Far future
114+
);
115+
}
116+
117+
@ParameterizedTest
118+
@MethodSource("timestampTestCases")
119+
public void testTimestampSerializationAndRoundtrip(
120+
long seconds, int nanos, String expectedTimestampString) throws JsonProcessingException {
121+
Timestamp original = Timestamp.newBuilder().setSeconds(seconds).setNanos(nanos).build();
122+
123+
TimestampWrapper wrapper = new TimestampWrapper();
124+
wrapper.timestamp = original;
125+
126+
// Test serialization.
127+
String json = new ApiClient().serialize(wrapper);
128+
String expectedJson = "{\"timestamp\":\"" + expectedTimestampString + "\"}";
129+
assertEquals(expectedJson, json);
130+
131+
// Test roundtrip (deserialize and verify).
132+
ObjectMapper mapper = SerDeUtils.createMapper();
133+
TimestampWrapper deserialized = mapper.readValue(json, TimestampWrapper.class);
134+
assertEquals(original.getSeconds(), deserialized.timestamp.getSeconds());
135+
assertEquals(original.getNanos(), deserialized.timestamp.getNanos());
136+
}
137+
}

0 commit comments

Comments
 (0)