Skip to content

Commit 1658650

Browse files
authored
Add Bytes deserialization module (#3070)
Add Bytes deserialization module to enable equality when deserializing untyped binary data
1 parent 303fdd3 commit 1658650

File tree

4 files changed

+236
-0
lines changed

4 files changed

+236
-0
lines changed

changelog/@unreleased/pr-3070.v2.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
type: feature
2+
feature:
3+
description: Add Bytes deserialization module (as opt-in) to enable equality when
4+
deserializing untyped binary data
5+
links:
6+
- https://github.com/palantir/conjure-java-runtime/pull/3070

conjure-java-jackson-serialization/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ dependencies {
1515
implementation 'com.fasterxml.jackson.core:jackson-core'
1616
implementation 'com.google.code.findbugs:jsr305'
1717
implementation 'com.google.guava:guava'
18+
implementation 'com.palantir.conjure.java:conjure-lib'
1819
implementation "com.palantir.safe-logging:logger"
1920
implementation 'com.palantir.safe-logging:safe-logging'
2021
implementation 'com.palantir.tritium:tritium-registry'
2122
implementation 'io.dropwizard.metrics:metrics-core'
2223

24+
testImplementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml"
2325
testImplementation "org.assertj:assertj-core"
2426
testImplementation 'org.junit.jupiter:junit-jupiter'
2527
testImplementation 'org.junit.jupiter:junit-jupiter-api'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.palantir.conjure.java.serialization;
18+
19+
import com.fasterxml.jackson.core.JsonParser;
20+
import com.fasterxml.jackson.core.JsonToken;
21+
import com.fasterxml.jackson.databind.DeserializationContext;
22+
import com.fasterxml.jackson.databind.deser.std.UntypedObjectDeserializer;
23+
import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
24+
import com.fasterxml.jackson.databind.module.SimpleModule;
25+
import com.palantir.conjure.java.lib.Bytes;
26+
import java.io.IOException;
27+
import javax.annotation.Nullable;
28+
29+
/**
30+
* Jackson module to deserialize binary data into {@link Bytes} rather than {@code byte[]} when the target type is
31+
* {@link Object}, which guarantees immutability and implements equality correctly.
32+
*/
33+
public final class BinaryBytesDeserializationModule extends SimpleModule {
34+
35+
public BinaryBytesDeserializationModule() {
36+
super(BinaryBytesDeserializationModule.class.getCanonicalName());
37+
38+
addDeserializer(Object.class, new BytesObjectDeserializer());
39+
}
40+
41+
private static final class BytesObjectDeserializer extends UntypedObjectDeserializer {
42+
BytesObjectDeserializer() {
43+
// Use the default behaviour for lists and maps
44+
super(null, null);
45+
}
46+
47+
@Override
48+
public Object deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException {
49+
Bytes maybeResult = maybeDeserializeEmbeddedByteArray(parser);
50+
if (maybeResult != null) {
51+
return maybeResult;
52+
}
53+
return super.deserialize(parser, ctxt);
54+
}
55+
56+
@Override
57+
public Object deserialize(JsonParser parser, DeserializationContext ctxt, Object intoValue) throws IOException {
58+
Bytes maybeResult = maybeDeserializeEmbeddedByteArray(parser);
59+
if (maybeResult != null) {
60+
return maybeResult;
61+
}
62+
return super.deserialize(parser, ctxt, intoValue);
63+
}
64+
65+
@Override
66+
public Object deserializeWithType(
67+
JsonParser parser, DeserializationContext ctxt, TypeDeserializer typeDeserializer) throws IOException {
68+
Bytes maybeResult = maybeDeserializeEmbeddedByteArray(parser);
69+
if (maybeResult != null) {
70+
return maybeResult;
71+
}
72+
return super.deserializeWithType(parser, ctxt, typeDeserializer);
73+
}
74+
75+
@Nullable
76+
private Bytes maybeDeserializeEmbeddedByteArray(JsonParser parser) throws IOException {
77+
JsonToken token = parser.currentToken();
78+
if (token == JsonToken.VALUE_EMBEDDED_OBJECT) {
79+
if (parser.getEmbeddedObject() instanceof byte[] embeddedBytes) {
80+
return Bytes.from(embeddedBytes);
81+
}
82+
}
83+
return null;
84+
}
85+
}
86+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.palantir.conjure.java.serialization;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
23+
import com.palantir.conjure.java.lib.Bytes;
24+
import java.io.IOException;
25+
import java.nio.charset.StandardCharsets;
26+
import java.util.Base64;
27+
import java.util.Map;
28+
import java.util.Set;
29+
import java.util.UUID;
30+
import org.junit.jupiter.api.Test;
31+
32+
public final class BinaryBytesDeserializationModuleTest {
33+
34+
@Test
35+
void deserializesRawByteArrayToBytes() throws IOException {
36+
ObjectMapper smileMapper = withBinaryDeserializationModule(ObjectMappers.newSmileClientObjectMapper());
37+
38+
byte[] value = {1, 2, 3, 4};
39+
byte[] serialized = smileMapper.writeValueAsBytes(value);
40+
41+
Object deserialized = smileMapper.readValue(serialized, Object.class);
42+
assertThat(deserialized).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
43+
}
44+
45+
@Test
46+
void deserializesBytesNestedInMap() throws IOException {
47+
ObjectMapper smileMapper = withBinaryDeserializationModule(ObjectMappers.newSmileClientObjectMapper());
48+
49+
Map<Object, Object> object =
50+
Map.of("foo", "bar", "num", 2, "obj", Map.of("bytes", Bytes.from(new byte[] {1, 2, 3})));
51+
byte[] serialized = smileMapper.writeValueAsBytes(object);
52+
53+
Object deserialized = smileMapper.readValue(serialized, Object.class);
54+
assertThat(deserialized).isEqualTo(object);
55+
}
56+
57+
@Test
58+
void deserializesNestedObjectFields() throws IOException {
59+
@SuppressWarnings({"DangerousRecordArrayField", "ArrayRecordComponent"}) // Intentional
60+
record RecordContainingObject(Object obj, byte[] byteArray, Bytes bytes) {}
61+
62+
ObjectMapper smileMapper = withBinaryDeserializationModule(ObjectMappers.newSmileClientObjectMapper());
63+
64+
RecordContainingObject obj = new RecordContainingObject(
65+
new byte[] {1, 2, 3}, new byte[] {4, 5, 6}, Bytes.from(new byte[] {7, 8, 9}));
66+
byte[] serialized = smileMapper.writeValueAsBytes(obj);
67+
68+
RecordContainingObject deserialized = smileMapper.readValue(serialized, RecordContainingObject.class);
69+
assertThat(deserialized.obj()).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(new byte[] {1, 2, 3}));
70+
assertThat(deserialized.byteArray()).containsExactly(obj.byteArray());
71+
assertThat(deserialized.bytes()).isEqualTo(obj.bytes());
72+
}
73+
74+
@Test
75+
void deserializedBinaryUuidsAreEqual() throws IOException {
76+
ObjectMapper smileMapper = withBinaryDeserializationModule(ObjectMappers.newSmileClientObjectMapper());
77+
78+
// UUIDs are serialized as binary by default, and are the case where we have observed equality issues without
79+
// this module
80+
Set<UUID> uuids = Set.of(new UUID(0, 1), new UUID(2, 3), new UUID(4, 5));
81+
byte[] serialized = smileMapper.writeValueAsBytes(uuids);
82+
83+
Object first = smileMapper.readValue(serialized, Object.class);
84+
Object second = smileMapper.readValue(serialized, Object.class);
85+
assertThat(first).isEqualTo(second);
86+
}
87+
88+
@Test
89+
void deserializesToByteArrayWhenSpecificallyRequested() throws IOException {
90+
ObjectMapper smileMapper = withBinaryDeserializationModule(ObjectMappers.newSmileClientObjectMapper());
91+
92+
byte[] value = {1, 2, 3, 4};
93+
byte[] serialized = smileMapper.writeValueAsBytes(value);
94+
95+
byte[] deserialized = smileMapper.readValue(serialized, byte[].class);
96+
assertThat(deserialized).isInstanceOf(byte[].class).containsExactly(value);
97+
}
98+
99+
@Test
100+
void deserializesCborBinaryAsBytes() throws IOException {
101+
ObjectMapper cborMapper = withBinaryDeserializationModule(ObjectMappers.newCborClientObjectMapper());
102+
103+
byte[] value = {1, 2, 3, 4};
104+
byte[] serialized = cborMapper.writeValueAsBytes(value);
105+
106+
Object deserialized = cborMapper.readValue(serialized, Object.class);
107+
assertThat(deserialized).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
108+
}
109+
110+
@Test
111+
void deserializesYamlBinaryAsBytes() throws IOException {
112+
record TestData(Object testData) {}
113+
114+
ObjectMapper yamlMapper =
115+
withBinaryDeserializationModule(YAMLMapper.builder().build());
116+
117+
byte[] value = new byte[] {1, 2, 3};
118+
String base64Value = new String(Base64.getEncoder().encode(value), StandardCharsets.UTF_8);
119+
String yaml = "testData: !!binary " + base64Value;
120+
121+
TestData deserialized = yamlMapper.readValue(yaml, TestData.class);
122+
assertThat(deserialized.testData()).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
123+
}
124+
125+
@Test
126+
void doesNotAffectJsonDeserialization() throws IOException {
127+
ObjectMapper jsonMapper = withBinaryDeserializationModule(ObjectMappers.newClientObjectMapper());
128+
129+
byte[] value = {1, 2, 3, 4};
130+
byte[] serialized = jsonMapper.writeValueAsBytes(value);
131+
132+
// Binary data should be written to JSON as a base64 string, and should deserialize to that string
133+
Object deserialized = jsonMapper.readValue(serialized, Object.class);
134+
assertThat(deserialized)
135+
.isInstanceOf(String.class)
136+
.isEqualTo(new String(Base64.getEncoder().encode(value), StandardCharsets.UTF_8));
137+
}
138+
139+
private ObjectMapper withBinaryDeserializationModule(ObjectMapper rawMapper) {
140+
return rawMapper.registerModule(new BinaryBytesDeserializationModule());
141+
}
142+
}

0 commit comments

Comments
 (0)