Skip to content

Commit 23b4016

Browse files
Add Well Known Types serialization utils (#518)
## What changes are proposed in this pull request? Add Well Known Types serialization utils. This PR configures the default ObjectMapper for the Java SDK to serialize the Google Well Known Types: https://protobuf.dev/reference/protobuf/google.protobuf/ For Duration and Timestamp, we use the official protojson serialization. For FieldMask, we do introduce a custom serialization since the Google FieldMask serialization is not aligned with the expected server values. ## How is this tested? Added unit tests. NO_CHANGELOG=true
1 parent c8b5595 commit 23b4016

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)