Repository: palantir/conjure-java-runtime
Pull request #3070: Add Bytes deserialization module (BinaryBytesDeserializationModule)
Author: login: jackwickham
Labels: name: merge when ready
name: autorelease
Created: 2025-03-03T13:27:48Z | Merged: 2025-03-03T19:50:42Z

## Before this PR
When deserializing binary data (from a smile or cbor input) as an unknown type (`Object`), jackson defaults to `byte[]`, which does not provide a reasonable `equals` implementation. This means that two objects deserialized from the same input will not be equal to each other.

## After this PR
<!-- User-facing outcomes this PR delivers go below -->
==COMMIT_MSG==
Add Bytes deserialization module to enable equality when deserializing untyped binary data
==COMMIT_MSG==

## Possible downsides?
I had to pick up a dependency on conjure-lib, which wasn't used by this repo before (other than as a dependency of the generated code).

We aren't using this module in any CJR code, just making it available for clients to enable, because enabling it by default could break clients that introspect the deserialized data.


Diff:
diff --git a/changelog/@unreleased/pr-3070.v2.yml b/changelog/@unreleased/pr-3070.v2.yml
new file mode 100644
index 000000000..4a476e1cc
--- /dev/null
+++ b/changelog/@unreleased/pr-3070.v2.yml
@@ -0,0 +1,6 @@
+type: feature
+feature:
+  description: Add Bytes deserialization module (as opt-in) to enable equality when
+    deserializing untyped binary data
+  links:
+  - https://github.com/palantir/conjure-java-runtime/pull/3070
diff --git a/conjure-java-jackson-serialization/build.gradle b/conjure-java-jackson-serialization/build.gradle
index 6b77563c2..ea306a714 100644
--- a/conjure-java-jackson-serialization/build.gradle
+++ b/conjure-java-jackson-serialization/build.gradle
@@ -15,11 +15,13 @@ dependencies {
     implementation 'com.fasterxml.jackson.core:jackson-core'
     implementation 'com.google.code.findbugs:jsr305'
     implementation 'com.google.guava:guava'
+    implementation 'com.palantir.conjure.java:conjure-lib'
     implementation "com.palantir.safe-logging:logger"
     implementation 'com.palantir.safe-logging:safe-logging'
     implementation 'com.palantir.tritium:tritium-registry'
     implementation 'io.dropwizard.metrics:metrics-core'
 
+    testImplementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml"
     testImplementation "org.assertj:assertj-core"
     testImplementation 'org.junit.jupiter:junit-jupiter'
     testImplementation 'org.junit.jupiter:junit-jupiter-api'
diff --git a/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/BinaryBytesDeserializationModule.java b/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/BinaryBytesDeserializationModule.java
new file mode 100644
index 000000000..315387601
--- /dev/null
+++ b/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/BinaryBytesDeserializationModule.java
@@ -0,0 +1,86 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.conjure.java.serialization;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.deser.std.UntypedObjectDeserializer;
+import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.palantir.conjure.java.lib.Bytes;
+import java.io.IOException;
+import javax.annotation.Nullable;
+
+/**
+ * Jackson module to deserialize binary data into {@link Bytes} rather than {@code byte[]} when the target type is
+ * {@link Object}, which guarantees immutability and implements equality correctly.
+ */
+public final class BinaryBytesDeserializationModule extends SimpleModule {
+
+    public BinaryBytesDeserializationModule() {
+        super(BinaryBytesDeserializationModule.class.getCanonicalName());
+
+        addDeserializer(Object.class, new BytesObjectDeserializer());
+    }
+
+    private static final class BytesObjectDeserializer extends UntypedObjectDeserializer {
+        BytesObjectDeserializer() {
+            // Use the default behaviour for lists and maps
+            super(null, null);
+        }
+
+        @Override
+        public Object deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException {
+            Bytes maybeResult = maybeDeserializeEmbeddedByteArray(parser);
+            if (maybeResult != null) {
+                return maybeResult;
+            }
+            return super.deserialize(parser, ctxt);
+        }
+
+        @Override
+        public Object deserialize(JsonParser parser, DeserializationContext ctxt, Object intoValue) throws IOException {
+            Bytes maybeResult = maybeDeserializeEmbeddedByteArray(parser);
+            if (maybeResult != null) {
+                return maybeResult;
+            }
+            return super.deserialize(parser, ctxt, intoValue);
+        }
+
+        @Override
+        public Object deserializeWithType(
+                JsonParser parser, DeserializationContext ctxt, TypeDeserializer typeDeserializer) throws IOException {
+            Bytes maybeResult = maybeDeserializeEmbeddedByteArray(parser);
+            if (maybeResult != null) {
+                return maybeResult;
+            }
+            return super.deserializeWithType(parser, ctxt, typeDeserializer);
+        }
+
+        @Nullable
+        private Bytes maybeDeserializeEmbeddedByteArray(JsonParser parser) throws IOException {
+            JsonToken token = parser.currentToken();
+            if (token == JsonToken.VALUE_EMBEDDED_OBJECT) {
+                if (parser.getEmbeddedObject() instanceof byte[] embeddedBytes) {
+                    return Bytes.from(embeddedBytes);
+                }
+            }
+            return null;
+        }
+    }
+}
diff --git a/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/BinaryBytesDeserializationModuleTest.java b/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/BinaryBytesDeserializationModuleTest.java
new file mode 100644
index 000000000..424a1b014
--- /dev/null
+++ b/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/BinaryBytesDeserializationModuleTest.java
@@ -0,0 +1,142 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.conjure.java.serialization;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
+import com.palantir.conjure.java.lib.Bytes;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+public final class BinaryBytesDeserializationModuleTest {
+
+    @Test
+    void deserializesRawByteArrayToBytes() throws IOException {
+        ObjectMapper smileMapper = withBinaryDeserializationModule(ObjectMappers.newSmileClientObjectMapper());
+
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = smileMapper.writeValueAsBytes(value);
+
+        Object deserialized = smileMapper.readValue(serialized, Object.class);
+        assertThat(deserialized).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
+    }
+
+    @Test
+    void deserializesBytesNestedInMap() throws IOException {
+        ObjectMapper smileMapper = withBinaryDeserializationModule(ObjectMappers.newSmileClientObjectMapper());
+
+        Map<Object, Object> object =
+                Map.of("foo", "bar", "num", 2, "obj", Map.of("bytes", Bytes.from(new byte[] {1, 2, 3})));
+        byte[] serialized = smileMapper.writeValueAsBytes(object);
+
+        Object deserialized = smileMapper.readValue(serialized, Object.class);
+        assertThat(deserialized).isEqualTo(object);
+    }
+
+    @Test
+    void deserializesNestedObjectFields() throws IOException {
+        @SuppressWarnings({"DangerousRecordArrayField", "ArrayRecordComponent"}) // Intentional
+        record RecordContainingObject(Object obj, byte[] byteArray, Bytes bytes) {}
+
+        ObjectMapper smileMapper = withBinaryDeserializationModule(ObjectMappers.newSmileClientObjectMapper());
+
+        RecordContainingObject obj = new RecordContainingObject(
+                new byte[] {1, 2, 3}, new byte[] {4, 5, 6}, Bytes.from(new byte[] {7, 8, 9}));
+        byte[] serialized = smileMapper.writeValueAsBytes(obj);
+
+        RecordContainingObject deserialized = smileMapper.readValue(serialized, RecordContainingObject.class);
+        assertThat(deserialized.obj()).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(new byte[] {1, 2, 3}));
+        assertThat(deserialized.byteArray()).containsExactly(obj.byteArray());
+        assertThat(deserialized.bytes()).isEqualTo(obj.bytes());
+    }
+
+    @Test
+    void deserializedBinaryUuidsAreEqual() throws IOException {
+        ObjectMapper smileMapper = withBinaryDeserializationModule(ObjectMappers.newSmileClientObjectMapper());
+
+        // UUIDs are serialized as binary by default, and are the case where we have observed equality issues without
+        // this module
+        Set<UUID> uuids = Set.of(new UUID(0, 1), new UUID(2, 3), new UUID(4, 5));
+        byte[] serialized = smileMapper.writeValueAsBytes(uuids);
+
+        Object first = smileMapper.readValue(serialized, Object.class);
+        Object second = smileMapper.readValue(serialized, Object.class);
+        assertThat(first).isEqualTo(second);
+    }
+
+    @Test
+    void deserializesToByteArrayWhenSpecificallyRequested() throws IOException {
+        ObjectMapper smileMapper = withBinaryDeserializationModule(ObjectMappers.newSmileClientObjectMapper());
+
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = smileMapper.writeValueAsBytes(value);
+
+        byte[] deserialized = smileMapper.readValue(serialized, byte[].class);
+        assertThat(deserialized).isInstanceOf(byte[].class).containsExactly(value);
+    }
+
+    @Test
+    void deserializesCborBinaryAsBytes() throws IOException {
+        ObjectMapper cborMapper = withBinaryDeserializationModule(ObjectMappers.newCborClientObjectMapper());
+
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = cborMapper.writeValueAsBytes(value);
+
+        Object deserialized = cborMapper.readValue(serialized, Object.class);
+        assertThat(deserialized).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
+    }
+
+    @Test
+    void deserializesYamlBinaryAsBytes() throws IOException {
+        record TestData(Object testData) {}
+
+        ObjectMapper yamlMapper =
+                withBinaryDeserializationModule(YAMLMapper.builder().build());
+
+        byte[] value = new byte[] {1, 2, 3};
+        String base64Value = new String(Base64.getEncoder().encode(value), StandardCharsets.UTF_8);
+        String yaml = "testData: !!binary " + base64Value;
+
+        TestData deserialized = yamlMapper.readValue(yaml, TestData.class);
+        assertThat(deserialized.testData()).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
+    }
+
+    @Test
+    void doesNotAffectJsonDeserialization() throws IOException {
+        ObjectMapper jsonMapper = withBinaryDeserializationModule(ObjectMappers.newClientObjectMapper());
+
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = jsonMapper.writeValueAsBytes(value);
+
+        // Binary data should be written to JSON as a base64 string, and should deserialize to that string
+        Object deserialized = jsonMapper.readValue(serialized, Object.class);
+        assertThat(deserialized)
+                .isInstanceOf(String.class)
+                .isEqualTo(new String(Base64.getEncoder().encode(value), StandardCharsets.UTF_8));
+    }
+
+    private ObjectMapper withBinaryDeserializationModule(ObjectMapper rawMapper) {
+        return rawMapper.registerModule(new BinaryBytesDeserializationModule());
+    }
+}


Reviews:
user: login: carterkozak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T13:50:12Z
user: login: bjlaub
body: lgtm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T19:39:13Z

Review comments:
diff_hunk: @@ -0,0 +1,124 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.conjure.java.serialization;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
+import com.palantir.conjure.java.lib.Bytes;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public final class BinaryBytesDeserializationModuleTest {
+
+    private ObjectMapper smileMapper;
+
+    @BeforeEach
+    void before() {
+        smileMapper = ObjectMappers.newSmileClientObjectMapper();
+        smileMapper.registerModule(new BinaryBytesDeserializationModule());
+    }
+
+    @Test
+    void deserializesRawByteArrayToBytes() throws IOException {
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = smileMapper.writeValueAsBytes(value);
+
+        Object deserialized = smileMapper.readValue(serialized, Object.class);
+        assertThat(deserialized).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
+    }
+
+    @Test
+    void deserializesBytesNestedInMap() throws IOException {
+        Map<Object, Object> object =
+                Map.of("foo", "bar", "num", 2, "obj", Map.of("bytes", Bytes.from(new byte[] {1, 2, 3})));
+        byte[] serialized = smileMapper.writeValueAsBytes(object);
+
+        Object deserialized = smileMapper.readValue(serialized, Object.class);
+        assertThat(deserialized).isEqualTo(object);
+    }
+
+    @Test
+    void deserializesNestedObjectFields() throws IOException {
+        RecordContainingObject obj = new RecordContainingObject(
+                new byte[] {1, 2, 3}, new byte[] {4, 5, 6}, Bytes.from(new byte[] {7, 8, 9}));
+        byte[] serialized = smileMapper.writeValueAsBytes(obj);
+
+        RecordContainingObject deserialized = smileMapper.readValue(serialized, RecordContainingObject.class);
+        assertThat(deserialized.obj()).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(new byte[] {1, 2, 3}));
+        assertThat(deserialized.byteArray()).containsExactly(obj.byteArray());
+        assertThat(deserialized.bytes()).isEqualTo(obj.bytes());
+    }
+
+    @Test
+    void deserializedBinaryUuidsAreEqual() throws IOException {
+        // UUIDs are serialized as binary by default, and are the case where we have observed equality issues without
+        // this module
+        Set<UUID> uuids = Set.of(new UUID(0, 1), new UUID(2, 3), new UUID(4, 5));
+        byte[] serialized = smileMapper.writeValueAsBytes(uuids);
+
+        Object first = smileMapper.readValue(serialized, Object.class);
+        Object second = smileMapper.readValue(serialized, Object.class);
+        assertThat(first).isEqualTo(second);
+    }
+
+    @Test
+    void deserializesToByteArrayWhenSpecificallyRequested() throws IOException {
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = smileMapper.writeValueAsBytes(value);
+
+        byte[] deserialized = smileMapper.readValue(serialized, byte[].class);
+        assertThat(deserialized).isInstanceOf(byte[].class).containsExactly(value);
+    }
+
+    @Test
+    void deserializesCborBinaryAsBytes() throws IOException {
+        ObjectMapper cborMapper = ObjectMappers.newCborClientObjectMapper();
+        cborMapper.registerModule(new BinaryBytesDeserializationModule());
+
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = cborMapper.writeValueAsBytes(value);
+
+        Object deserialized = cborMapper.readValue(serialized, Object.class);
+        assertThat(deserialized).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
+    }
+
+    @Test
+    void deserializesYamlBinaryAsBytes() throws IOException {
+        ObjectMapper yamlMapper = YAMLMapper.builder().build();
+        yamlMapper.registerModule(new BinaryBytesDeserializationModule());
+
+        byte[] value = new byte[] {1, 2, 3};
+        String base64Value = new String(Base64.getEncoder().encode(value), StandardCharsets.UTF_8);
+        String yaml = "testData: !!binary " + base64Value;
+
+        Object deserialized = yamlMapper.readValue(yaml, Object.class);
+        assertThat(deserialized).isInstanceOf(Map.class);
+        Map<?, ?> deserializedMap = (Map<?, ?>) deserialized;
+        assertThat(deserializedMap.get("testData")).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
+    }
+
+    @SuppressWarnings({"DangerousRecordArrayField", "ArrayRecordComponent"}) // Intentional
+    private record RecordContainingObject(Object obj, byte[] byteArray, Bytes bytes) {}
path: conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/BinaryBytesDeserializationModuleTest.java
user: login: carterkozak
body: I think we can define this within `deserializesNestedObjectFields` for readability
created_at: 2025-03-03T13:45:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,124 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.conjure.java.serialization;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
+import com.palantir.conjure.java.lib.Bytes;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public final class BinaryBytesDeserializationModuleTest {
+
+    private ObjectMapper smileMapper;
+
+    @BeforeEach
+    void before() {
+        smileMapper = ObjectMappers.newSmileClientObjectMapper();
+        smileMapper.registerModule(new BinaryBytesDeserializationModule());
+    }
+
+    @Test
+    void deserializesRawByteArrayToBytes() throws IOException {
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = smileMapper.writeValueAsBytes(value);
+
+        Object deserialized = smileMapper.readValue(serialized, Object.class);
+        assertThat(deserialized).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
+    }
+
+    @Test
+    void deserializesBytesNestedInMap() throws IOException {
+        Map<Object, Object> object =
+                Map.of("foo", "bar", "num", 2, "obj", Map.of("bytes", Bytes.from(new byte[] {1, 2, 3})));
+        byte[] serialized = smileMapper.writeValueAsBytes(object);
+
+        Object deserialized = smileMapper.readValue(serialized, Object.class);
+        assertThat(deserialized).isEqualTo(object);
+    }
+
+    @Test
+    void deserializesNestedObjectFields() throws IOException {
+        RecordContainingObject obj = new RecordContainingObject(
+                new byte[] {1, 2, 3}, new byte[] {4, 5, 6}, Bytes.from(new byte[] {7, 8, 9}));
+        byte[] serialized = smileMapper.writeValueAsBytes(obj);
+
+        RecordContainingObject deserialized = smileMapper.readValue(serialized, RecordContainingObject.class);
+        assertThat(deserialized.obj()).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(new byte[] {1, 2, 3}));
+        assertThat(deserialized.byteArray()).containsExactly(obj.byteArray());
+        assertThat(deserialized.bytes()).isEqualTo(obj.bytes());
+    }
+
+    @Test
+    void deserializedBinaryUuidsAreEqual() throws IOException {
+        // UUIDs are serialized as binary by default, and are the case where we have observed equality issues without
+        // this module
+        Set<UUID> uuids = Set.of(new UUID(0, 1), new UUID(2, 3), new UUID(4, 5));
+        byte[] serialized = smileMapper.writeValueAsBytes(uuids);
+
+        Object first = smileMapper.readValue(serialized, Object.class);
+        Object second = smileMapper.readValue(serialized, Object.class);
+        assertThat(first).isEqualTo(second);
+    }
+
+    @Test
+    void deserializesToByteArrayWhenSpecificallyRequested() throws IOException {
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = smileMapper.writeValueAsBytes(value);
+
+        byte[] deserialized = smileMapper.readValue(serialized, byte[].class);
+        assertThat(deserialized).isInstanceOf(byte[].class).containsExactly(value);
+    }
+
+    @Test
+    void deserializesCborBinaryAsBytes() throws IOException {
+        ObjectMapper cborMapper = ObjectMappers.newCborClientObjectMapper();
+        cborMapper.registerModule(new BinaryBytesDeserializationModule());
+
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = cborMapper.writeValueAsBytes(value);
+
+        Object deserialized = cborMapper.readValue(serialized, Object.class);
+        assertThat(deserialized).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
+    }
+
+    @Test
+    void deserializesYamlBinaryAsBytes() throws IOException {
+        ObjectMapper yamlMapper = YAMLMapper.builder().build();
+        yamlMapper.registerModule(new BinaryBytesDeserializationModule());
+
+        byte[] value = new byte[] {1, 2, 3};
+        String base64Value = new String(Base64.getEncoder().encode(value), StandardCharsets.UTF_8);
+        String yaml = "testData: !!binary " + base64Value;
+
+        Object deserialized = yamlMapper.readValue(yaml, Object.class);
+        assertThat(deserialized).isInstanceOf(Map.class);
+        Map<?, ?> deserializedMap = (Map<?, ?>) deserialized;
path: conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/BinaryBytesDeserializationModuleTest.java
user: login: carterkozak
body: I might define a record for type safety, avoiding the cast to `Map`:
```java
private record TestData(Object testData) {}
```
created_at: 2025-03-03T13:46:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,124 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.conjure.java.serialization;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
+import com.palantir.conjure.java.lib.Bytes;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public final class BinaryBytesDeserializationModuleTest {
+
+    private ObjectMapper smileMapper;
+
+    @BeforeEach
+    void before() {
+        smileMapper = ObjectMappers.newSmileClientObjectMapper();
+        smileMapper.registerModule(new BinaryBytesDeserializationModule());
path: conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/BinaryBytesDeserializationModuleTest.java
user: login: carterkozak
body: Slight preference to either make this a factory method returning a smile mapper, or copy/paste it into the relevant tests.
Not a strong opinion though, It's just a little odd when a test declares fields that aren't used in every test.
created_at: 2025-03-03T13:48:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,124 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.conjure.java.serialization;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
+import com.palantir.conjure.java.lib.Bytes;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public final class BinaryBytesDeserializationModuleTest {
+
+    private ObjectMapper smileMapper;
+
+    @BeforeEach
+    void before() {
+        smileMapper = ObjectMappers.newSmileClientObjectMapper();
+        smileMapper.registerModule(new BinaryBytesDeserializationModule());
+    }
+
+    @Test
+    void deserializesRawByteArrayToBytes() throws IOException {
+        byte[] value = {1, 2, 3, 4};
+        byte[] serialized = smileMapper.writeValueAsBytes(value);
+
+        Object deserialized = smileMapper.readValue(serialized, Object.class);
+        assertThat(deserialized).isInstanceOf(Bytes.class).isEqualTo(Bytes.from(value));
+    }
path: conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/BinaryBytesDeserializationModuleTest.java
user: login: carterkozak
body: Thoughts on adding a similar test using the JSON mapper? The result will be a string, not bytes, but a test should make that clear (and we definitely don't want to start coercing strings into `Bytes` objects!
created_at: 2025-03-03T13:49:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: changelog-app[bot]
created_at: 2025-03-03T13:27:52Z
body: ### Generate changelog in <changelog-dir>`changelog/@unreleased`</changelog-dir>
<details>
<summary>What do the change types mean?</summary>

- `feature`: A new feature of the service.
- `improvement`: An incremental improvement in the functionality or operation of the service.
- `fix`: Remedies the incorrect behaviour of a component of the service in a backwards-compatible way.
- `break`: Has the potential to break consumers of this service's API, inclusive of both Palantir services
and external consumers of the service's API (e.g. customer-written software or integrations).
- `deprecation`: Advertises the intention to remove service functionality without any change to the
operation of the service itself.
- `manualTask`: Requires the possibility of manual intervention (running a script, eyeballing configuration,
performing database surgery, ...) at the time of upgrade for it to succeed.
- `migration`: A fully automatic upgrade migration task with no engineer input required.

_Note: only one type should be chosen._
</details>
<details>
<summary>How are new versions calculated?</summary>

- ❗The `break` and `manual task` changelog types will result in a major release!
- 🐛 The `fix` changelog type will result in a minor release in most cases, and a patch release version for patch branches. This behaviour is configurable in autorelease.
- ✨ All others will result in a minor version release.
</details>

**Type**
<changelog-type>
- [x] Feature
- [ ] Improvement
- [ ] Fix
- [ ] Break
- [ ] Deprecation
- [ ] Manual task
- [ ] Migration
</changelog-type>

**Description**
<changelog-description>
Add Bytes deserialization module (as opt-in) to enable equality when deserializing untyped binary data
</changelog-description><changelog-links></changelog-links><changelog-tags></changelog-tags>

**Check the box to generate changelog(s)**
- [x] Generate changelog entry

author_association: NONE
user: login: autorelease3[bot]
created_at: 2025-03-03T19:50:50Z
body: Released [8.18.0](https://github.com/palantir/conjure-java-runtime/releases/tag/8.18.0)

author_association: NONE