Skip to content

Commit b93fbce

Browse files
committed
JVMCBC-1710 KCBC-200 Add Jackson3JsonSerializer
Motivation ---------- Support Jackson 3 as an optional serializer. Modifications ------------- Add optional dependency on Jackson 3 (tools.jackson). Add Jackson3JsonSerializer. Instead of duplicating JsonValueModule for Jackson 3, modify JsonValueSerializerWrapper to handle JsonObject/JsonArray type references. This shortcut lets Jackson3JsonSerializer behave the same as the Jackson 2 serializer (passes same tests). Limitations ----------- Does not support the `@Encrypted` annotation for Field-Level Encryption. Change-Id: I5f5c4a21efe6c4e9e05226d24a9ceba765b2b43d Reviewed-on: https://review.couchbase.org/c/couchbase-jvm-clients/+/238590 Tested-by: Build Bot <[email protected]> Reviewed-by: David Nault <[email protected]>
1 parent fa9e6a5 commit b93fbce

10 files changed

Lines changed: 358 additions & 14 deletions

File tree

config/checkstyle/checkstyle-basic.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
<module name="TreeWalker">
2727
<module name="IllegalImport">
2828
<!-- Prevent unintentional dependency on unbundled Jackson -->
29-
<property name="illegalPkgs" value="com.fasterxml.jackson"/>
29+
<property name="illegalPkgs" value="com.fasterxml.jackson,tools.jackson"/>
3030

3131
<!-- Warns that using certain classes from jctools (namely in org.jctools.queues)
3232
is not native image compatible -->

java-client/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
<artifactId>jackson-databind</artifactId>
3030
<optional>true</optional>
3131
</dependency>
32+
<dependency>
33+
<groupId>tools.jackson.core</groupId>
34+
<artifactId>jackson-databind</artifactId>
35+
<optional>true</optional>
36+
</dependency>
3237

3338
<!-- Test Dependencies -->
3439
<dependency>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright 2026 Couchbase, Inc.
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+
* https://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.couchbase.client.java.codec;
18+
19+
// CHECKSTYLE:OFF IllegalImport - Allow unbundled Jackson
20+
21+
import com.couchbase.client.core.error.DecodingFailureException;
22+
import com.couchbase.client.core.error.EncodingFailureException;
23+
import tools.jackson.databind.JavaType;
24+
import tools.jackson.databind.json.JsonMapper;
25+
26+
import static com.couchbase.client.core.logging.RedactableArgument.redactUser;
27+
import static java.nio.charset.StandardCharsets.UTF_8;
28+
import static java.util.Objects.requireNonNull;
29+
30+
/**
31+
* A serializer backed by a user-provided Jackson 3 {@code JsonMapper}.
32+
* <p>
33+
* In order to use this class, you must add Jackson 3 to your class path.
34+
* <p>
35+
* Example usage:
36+
* <pre>
37+
* var mapper = JsonMapper.builder().build();
38+
* var serializer = Jackson3JsonSerializer.create(mapper);
39+
*
40+
* // Set as default serializer when connecting
41+
* Cluster cluster = Cluster.connect(
42+
* connectionString,
43+
* ClusterOptions.clusterOptions(username, password)
44+
* .environment(env -> env
45+
* .jsonSerializer(serializer)
46+
* )
47+
* );
48+
* </pre>
49+
* <b>WARNING:</b> This serializer ignores the {@link com.couchbase.client.java.encryption.annotation.Encrypted}
50+
* annotation for automatic Field-Level Encryption (FLE). Automatic FLE with data binding requires using
51+
* {@link JacksonJsonSerializer} and Jackson 2, or the default serializer which uses a repackaged version of Jackson 2.
52+
*/
53+
public class Jackson3JsonSerializer implements JsonSerializer {
54+
private final JsonMapper mapper;
55+
56+
/**
57+
* Returns a new instance backed by the given mapper.
58+
*
59+
* @param mapper the custom JsonMapper to use.
60+
*/
61+
public static Jackson3JsonSerializer create(JsonMapper mapper) {
62+
return new Jackson3JsonSerializer(mapper);
63+
}
64+
65+
private Jackson3JsonSerializer(JsonMapper mapper) {
66+
this.mapper = requireNonNull(mapper);
67+
}
68+
69+
@Override
70+
public byte[] serialize(final Object input) {
71+
if (input instanceof byte[]) {
72+
return (byte[]) input;
73+
}
74+
75+
try {
76+
return mapper.writeValueAsBytes(input);
77+
} catch (Throwable t) {
78+
throw new EncodingFailureException("Serializing of content + " + redactUser(input) + " to JSON failed.", t);
79+
}
80+
}
81+
82+
@Override
83+
public <T> T deserialize(final Class<T> target, final byte[] input) {
84+
if (target.equals(byte[].class)) {
85+
return (T) input;
86+
}
87+
88+
try {
89+
return mapper.readValue(input, target);
90+
} catch (Throwable e) {
91+
throw new DecodingFailureException("Deserialization of content into target " + target
92+
+ " failed; encoded = " + redactUser(new String(input, UTF_8)), e);
93+
}
94+
}
95+
96+
@Override
97+
public <T> T deserialize(final TypeRef<T> target, final byte[] input) {
98+
try {
99+
JavaType type = mapper.getTypeFactory().constructType(target.type());
100+
return mapper.readValue(input, type);
101+
} catch (Throwable e) {
102+
throw new DecodingFailureException("Deserialization of content into target " + target
103+
+ " failed; encoded = " + redactUser(new String(input, UTF_8)), e);
104+
}
105+
}
106+
}

java-client/src/main/java/com/couchbase/client/java/codec/JacksonJsonSerializer.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
import static java.util.Objects.requireNonNull;
3636

3737
/**
38-
* A serializer backed by a user-provided Jackson {@code ObjectMapper}.
38+
* A serializer backed by a user-provided Jackson 2 {@code ObjectMapper}.
3939
* <p>
4040
* In order to use this class you must add Jackson to your class path.
4141
* <p>
@@ -52,7 +52,7 @@
5252
* mapper.registerModule(new JsonValueModule());
5353
*
5454
* ClusterEnvironment env = ClusterEnvironment.builder()
55-
* .jsonSerializer(new JacksonJsonSerializer(mapper))
55+
* .jsonSerializer(JacksonJsonSerializer.create(mapper))
5656
* .build();
5757
* </pre>
5858
* <p>
@@ -66,7 +66,7 @@
6666
*
6767
* ClusterEnvironment env = ClusterEnvironment.builder()
6868
* .cryptoManager(cryptoManager)
69-
* .jsonSerializer(new JacksonJsonSerializer(mapper))
69+
* .jsonSerializer(JacksonJsonSerializer.create(mapper))
7070
* .build();
7171
* </pre>
7272
*

java-client/src/main/java/com/couchbase/client/java/codec/JsonValueSerializerWrapper.java

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import com.couchbase.client.java.json.JacksonTransformers;
2222
import com.couchbase.client.java.json.JsonValue;
2323

24+
import java.lang.reflect.Type;
25+
2426
import static com.couchbase.client.core.logging.RedactableArgument.redactUser;
2527
import static java.nio.charset.StandardCharsets.UTF_8;
2628
import static java.util.Objects.requireNonNull;
@@ -50,19 +52,26 @@ public byte[] serialize(Object input) {
5052

5153
@Override
5254
public <T> T deserialize(Class<T> target, byte[] input) {
53-
if (JsonValue.class.isAssignableFrom(target)) {
54-
try {
55-
return JacksonTransformers.MAPPER.readValue(input, target);
56-
} catch (Exception e) {
57-
throw new DecodingFailureException("Deserialization of content into target " + target
58-
+ " failed; encoded = " + redactUser(new String(input, UTF_8)), e);
59-
}
60-
}
61-
return wrapped.deserialize(target, input);
55+
return JsonValue.class.isAssignableFrom(target)
56+
? deserializeJsonValue(target, input)
57+
: wrapped.deserialize(target, input);
6258
}
6359

6460
@Override
61+
@SuppressWarnings("unchecked")
6562
public <T> T deserialize(TypeRef<T> target, byte[] input) {
66-
return wrapped.deserialize(target, input);
63+
Type t = target.type();
64+
return t instanceof Class && JsonValue.class.isAssignableFrom((Class<?>) t)
65+
? deserializeJsonValue((Class<T>) t, input)
66+
: wrapped.deserialize(target, input);
67+
}
68+
69+
private <T> T deserializeJsonValue(Class<T> target, byte[] input) {
70+
try {
71+
return JacksonTransformers.MAPPER.readValue(input, target);
72+
} catch (Exception e) {
73+
throw new DecodingFailureException("Deserialization of content into target " + target
74+
+ " failed; encoded = " + redactUser(new String(input, UTF_8)), e);
75+
}
6776
}
6877
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2026 Couchbase, Inc.
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+
* https://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.couchbase.client.java.codec;
18+
19+
20+
import com.fasterxml.jackson.annotation.JsonProperty;
21+
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.condition.DisabledForJreRange;
23+
import org.junit.jupiter.api.condition.DisabledOnJre;
24+
import org.junit.jupiter.api.condition.EnabledOnJre;
25+
import org.junit.jupiter.api.condition.JRE;
26+
import tools.jackson.databind.json.JsonMapper;
27+
28+
import static java.nio.charset.StandardCharsets.UTF_8;
29+
import static org.junit.jupiter.api.Assertions.assertEquals;
30+
31+
@DisabledForJreRange(
32+
min = JRE.JAVA_8,
33+
max = JRE.JAVA_16,
34+
disabledReason = "Jackson 3 requires Java 17 or later."
35+
)
36+
class Jackson3JsonSerializerTest extends JsonSerializerTestBase {
37+
private static final JsonSerializer serializer = new JsonValueSerializerWrapper(
38+
Jackson3JsonSerializer.create(JsonMapper.shared())
39+
);
40+
41+
@Override
42+
protected JsonSerializer serializer() {
43+
return serializer;
44+
}
45+
46+
@Test
47+
void canUseDataBinding() {
48+
Thing thing = new Thing();
49+
thing.name = "foo";
50+
51+
byte[] jsonBytes = serializer.serialize(thing);
52+
assertEquals("{\"n\":\"foo\"}", new String(jsonBytes, UTF_8));
53+
54+
thing = serializer.deserialize(Thing.class, jsonBytes);
55+
assertEquals("foo", thing.name);
56+
57+
thing = serializer.deserialize(new TypeRef<Thing>() {}, jsonBytes);
58+
assertEquals("foo", thing.name);
59+
}
60+
61+
public static class Thing {
62+
@JsonProperty("n")
63+
public String name;
64+
}
65+
}

kotlin-client/pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@
9090
<artifactId>jackson-datatype-jdk8</artifactId>
9191
</dependency>
9292

93+
<!-- Optional, used by Jackson3JsonSerializer -->
94+
<dependency>
95+
<groupId>tools.jackson.core</groupId>
96+
<artifactId>jackson-databind</artifactId>
97+
<optional>true</optional>
98+
</dependency>
99+
<dependency>
100+
<groupId>tools.jackson.module</groupId>
101+
<artifactId>jackson-module-kotlin</artifactId>
102+
<optional>true</optional>
103+
</dependency>
104+
93105
<!-- Optional, used by KotlinxSerializationJsonSerializer -->
94106
<dependency>
95107
<groupId>org.jetbrains.kotlinx</groupId>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2026 Couchbase, Inc.
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.couchbase.client.kotlin.codec
18+
19+
import tools.jackson.databind.JavaType
20+
import tools.jackson.databind.json.JsonMapper
21+
22+
public class Jackson3JsonSerializer(private val mapper: JsonMapper) : JsonSerializer {
23+
24+
override fun <T> serialize(value: T, type: TypeRef<T>): ByteArray = mapper.writeValueAsBytes(value)
25+
26+
override fun <T> deserialize(json: ByteArray, type: TypeRef<T>): T {
27+
val javaType: JavaType = mapper.typeFactory.constructType(type.type)
28+
val result: T = mapper.readValue(json, javaType)
29+
if (result == null && !type.nullable) {
30+
throw NullPointerException("Can't deserialize null value into non-nullable type $type")
31+
}
32+
return result
33+
}
34+
}

0 commit comments

Comments
 (0)