Skip to content

Commit 2e3da56

Browse files
committed
feat(serde): own a SerdeException type and reject lenient scalar coercion
Two hardening changes to the serde layer. 1. Stable SPI failure type. The `serde` SPI declared no exception type, so the Jackson adapter let `com.fasterxml.jackson.*` exceptions escape across the zero-dependency `Serde` boundary. Callers coding against the SPI could not catch a stable type, and the abstraction leaked its backing library. Add an open `SerdeException : RuntimeException` to `sdk-core`'s serde package, plus `SerializationException` / `DeserializationException` for the write and read directions. The Jackson adapter now catches `JsonProcessingException` (the root of Jackson's parse/mapping failure hierarchy) at every SPI method and rethrows as the matching SDK type with the original chained as the cause. A genuine stream `IOException` still propagates unchanged, and the buffer overload's bounds checks remain `IndexOutOfBoundsException`. 2. Strict scalar coercion on the default mapper. Jackson's defaults silently reshape mismatched scalars: a wire string "5" coerced into a numeric field, numbers into strings, booleans across types. That masks malformed payloads. The SDK's default `ObjectMapper` now disables `MapperFeature.ALLOW_COERCION_OF_SCALARS` and sets per-type coercion to `Fail` for the cross-shape pairs (string -> int/float/boolean, boolean <-> int, and int/float/boolean -> string), built through `JsonMapper.builder().withCoercionConfig(...)`. Numeric widening (int -> floating-point) and correctly typed payloads are unaffected, and the auto-detect features Kotlin data-class binding relies on are left untouched. This applies only to the SDK default mapper, never to a caller-supplied one. This is a pre-1.0 behaviour change: payloads whose JSON shape does not match the target type now fail instead of binding to a quietly wrong value.
1 parent ea0cc81 commit 2e3da56

7 files changed

Lines changed: 484 additions & 17 deletions

File tree

sdk-core/api/sdk-core.api

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2163,6 +2163,13 @@ public final class org/dexpace/sdk/core/pipeline/step/retry/RetryStep : org/dexp
21632163
public final class org/dexpace/sdk/core/pipeline/step/retry/RetryStep$Companion {
21642164
}
21652165

2166+
public class org/dexpace/sdk/core/serde/DeserializationException : org/dexpace/sdk/core/serde/SerdeException {
2167+
public fun <init> ()V
2168+
public fun <init> (Ljava/lang/String;)V
2169+
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
2170+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
2171+
}
2172+
21662173
public abstract interface class org/dexpace/sdk/core/serde/Deserializer {
21672174
public abstract fun deserialize (Ljava/io/InputStream;Ljava/lang/Class;)Ljava/lang/Object;
21682175
public abstract fun deserialize (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;
@@ -2174,6 +2181,20 @@ public abstract interface class org/dexpace/sdk/core/serde/Serde {
21742181
public abstract fun getSerializer ()Lorg/dexpace/sdk/core/serde/Serializer;
21752182
}
21762183

2184+
public class org/dexpace/sdk/core/serde/SerdeException : java/lang/RuntimeException {
2185+
public fun <init> ()V
2186+
public fun <init> (Ljava/lang/String;)V
2187+
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
2188+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
2189+
}
2190+
2191+
public class org/dexpace/sdk/core/serde/SerializationException : org/dexpace/sdk/core/serde/SerdeException {
2192+
public fun <init> ()V
2193+
public fun <init> (Ljava/lang/String;)V
2194+
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
2195+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
2196+
}
2197+
21772198
public abstract interface class org/dexpace/sdk/core/serde/Serializer {
21782199
public abstract fun serialize (Ljava/lang/Object;)Ljava/lang/String;
21792200
public abstract fun serialize (Ljava/lang/Object;Ljava/io/OutputStream;)V
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright (c) 2026 dexpace and Omar Aljarrah
3+
*
4+
* Licensed under the MIT License. See LICENSE in the project root.
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
package org.dexpace.sdk.core.serde
9+
10+
/**
11+
* Stable SDK-owned failure type for the [Serde] SPI.
12+
*
13+
* The serde abstraction is format-agnostic: callers code against [Serializer] / [Deserializer]
14+
* without knowing whether the bytes on the wire are turned into objects by Jackson, Gson, or a
15+
* hand-rolled codec. If an adapter let its underlying library's exception escape (Jackson's
16+
* `com.fasterxml.jackson.*`, say), every consumer's `catch` clause would have to name that library
17+
* — re-coupling the SDK to an implementation detail the SPI exists to hide, and breaking any caller
18+
* the day the backing library changes.
19+
*
20+
* `SerdeException` closes that seam: adapters catch their own library's failures and rethrow as a
21+
* `SerdeException` (or one of its subtypes), always chaining the original as [cause] so the
22+
* underlying diagnostic is never lost. Consumers catch a single, stable SDK type.
23+
*
24+
* ## Why `RuntimeException` and not a checked exception
25+
*
26+
* Serialization failures are almost always programming or contract errors (an unserializable
27+
* object graph, a payload that does not match the target type) rather than recoverable I/O
28+
* conditions, and the [Serializer] / [Deserializer] signatures are deliberately unchecked so Java
29+
* callers are not forced to wrap every round-trip in `try`/`catch`. Making this unchecked keeps the
30+
* SPI ergonomic in both Kotlin and Java.
31+
*
32+
* The class is `open` so adapters and service-client codegen can introduce more specific subtypes;
33+
* [SerializationException] and [DeserializationException] cover the write and read directions out
34+
* of the box.
35+
*
36+
* @param message Human-readable description of the failure.
37+
* @param cause The underlying error (typically the backing library's exception), or `null`.
38+
*/
39+
public open class SerdeException
40+
@JvmOverloads
41+
constructor(
42+
message: String? = null,
43+
cause: Throwable? = null,
44+
) : RuntimeException(message, cause)
45+
46+
/**
47+
* A [SerdeException] raised while **encoding** a value to the wire format — for example an object
48+
* graph the backing codec cannot represent (a reference cycle, an unmapped type).
49+
*
50+
* @param message Human-readable description of the failure.
51+
* @param cause The underlying error (typically the backing library's exception), or `null`.
52+
*/
53+
public open class SerializationException
54+
@JvmOverloads
55+
constructor(
56+
message: String? = null,
57+
cause: Throwable? = null,
58+
) : SerdeException(message, cause)
59+
60+
/**
61+
* A [SerdeException] raised while **decoding** wire bytes / strings / streams into a typed value —
62+
* for example a malformed document or a payload whose shape does not match the target type.
63+
*
64+
* @param message Human-readable description of the failure.
65+
* @param cause The underlying error (typically the backing library's exception), or `null`.
66+
*/
67+
public open class DeserializationException
68+
@JvmOverloads
69+
constructor(
70+
message: String? = null,
71+
cause: Throwable? = null,
72+
) : SerdeException(message, cause)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright (c) 2026 dexpace and Omar Aljarrah
3+
*
4+
* Licensed under the MIT License. See LICENSE in the project root.
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
package org.dexpace.sdk.core.serde
9+
10+
import kotlin.test.Test
11+
import kotlin.test.assertEquals
12+
import kotlin.test.assertNull
13+
import kotlin.test.assertSame
14+
import kotlin.test.assertTrue
15+
16+
class SerdeExceptionTest {
17+
@Test
18+
fun `SerdeException is a RuntimeException so callers need no checked-exception plumbing`() {
19+
// Reflective check (not a compile-time-foldable `is`) that the supertype is RuntimeException.
20+
assertTrue(RuntimeException::class.java.isAssignableFrom(SerdeException::class.java))
21+
}
22+
23+
@Test
24+
fun `SerdeException carries message and chained cause`() {
25+
val root = IllegalStateException("root")
26+
val ex = SerdeException("wrapped", root)
27+
assertEquals("wrapped", ex.message)
28+
assertSame(root, ex.cause)
29+
}
30+
31+
@Test
32+
fun `SerdeException message-only constructor leaves cause null`() {
33+
val ex = SerdeException("just a message")
34+
assertEquals("just a message", ex.message)
35+
assertNull(ex.cause)
36+
}
37+
38+
@Test
39+
fun `SerializationException is a SerdeException`() {
40+
val ex = SerializationException("write failed", RuntimeException("inner"))
41+
assertTrue(SerdeException::class.java.isAssignableFrom(SerializationException::class.java))
42+
assertEquals("write failed", ex.message)
43+
}
44+
45+
@Test
46+
fun `DeserializationException is a SerdeException`() {
47+
val ex = DeserializationException("read failed", RuntimeException("inner"))
48+
assertTrue(SerdeException::class.java.isAssignableFrom(DeserializationException::class.java))
49+
assertEquals("read failed", ex.message)
50+
}
51+
52+
@Test
53+
fun `SerdeException is open so adapters and codegen can subclass it`() {
54+
// A bespoke subclass compiles only if SerdeException is `open`; this also documents the
55+
// extension point for adapter-specific error types.
56+
class CustomSerdeException(message: String) : SerdeException(message)
57+
assertTrue(SerdeException::class.java.isAssignableFrom(CustomSerdeException::class.java))
58+
}
59+
}

sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappers.kt

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@ package org.dexpace.sdk.serde.jackson
1010
import com.fasterxml.jackson.core.JsonGenerator
1111
import com.fasterxml.jackson.core.JsonParser
1212
import com.fasterxml.jackson.databind.DeserializationFeature
13+
import com.fasterxml.jackson.databind.MapperFeature
1314
import com.fasterxml.jackson.databind.ObjectMapper
1415
import com.fasterxml.jackson.databind.SerializationFeature
16+
import com.fasterxml.jackson.databind.cfg.CoercionAction
17+
import com.fasterxml.jackson.databind.cfg.CoercionInputShape
18+
import com.fasterxml.jackson.databind.cfg.MutableCoercionConfig
19+
import com.fasterxml.jackson.databind.json.JsonMapper
20+
import com.fasterxml.jackson.databind.type.LogicalType
1521
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
1622
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
1723
import com.fasterxml.jackson.module.kotlin.KotlinModule
@@ -28,6 +34,14 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule
2834
* every consumer to ship a new SDK release on each backward-compatible server change.
2935
* - [SerializationFeature.WRITE_DATES_AS_TIMESTAMPS] **disabled** — emits ISO-8601 strings
3036
* rather than numeric epoch millis, matching every public REST API.
37+
* - [MapperFeature.ALLOW_COERCION_OF_SCALARS] **disabled** plus per-type [CoercionAction.Fail]
38+
* rules — Jackson's defaults silently reshape mismatched scalars (a wire string `"5"` becomes a
39+
* numeric field, a number becomes a string, and so on), which masks malformed payloads. The SDK
40+
* rejects those cross-shape coercions so a contract violation surfaces as a failure rather than a
41+
* quietly wrong value. Numeric widening (an integer into a floating-point field) and genuinely
42+
* typed values are unaffected. See [lockDownScalarCoercion]. Auto-detection features
43+
* (`AUTO_DETECT_*`) are deliberately left at their defaults: Kotlin data-class binding relies on
44+
* them, and any further tightening belongs to codegen, which owns its own mapper.
3145
*
3246
* Modules registered, in order:
3347
* - [KotlinModule] — Kotlin data classes, default-argument support, value classes.
@@ -47,11 +61,19 @@ public object JacksonObjectMappers {
4761
* `enable`/`disable` features) but should treat the result as their own instance to mutate.
4862
*/
4963
public fun defaultObjectMapper(): ObjectMapper {
50-
val mapper = ObjectMapper()
51-
mapper.registerModule(KotlinModule.Builder().build())
52-
mapper.registerModule(JavaTimeModule())
53-
mapper.registerModule(Jdk8Module())
54-
mapper.registerModule(TristateModule())
64+
// Built via JsonMapper.builder() so the MapperFeature toggle and coercion config are set
65+
// at build time — the runtime ObjectMapper.configure(MapperFeature, ...) setter is
66+
// deprecated in 2.18, and withCoercionConfig is the supported coercion entry point.
67+
val builder =
68+
JsonMapper
69+
.builder()
70+
.addModule(KotlinModule.Builder().build())
71+
.addModule(JavaTimeModule())
72+
.addModule(Jdk8Module())
73+
.addModule(TristateModule())
74+
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
75+
applyStrictScalarCoercion(builder)
76+
val mapper = builder.build()
5577
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
5678
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
5779
// Caller-owned streams must not be closed by the mapper. Both write and read paths
@@ -61,4 +83,45 @@ public object JacksonObjectMappers {
6183
mapper.factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE)
6284
return mapper
6385
}
86+
87+
/**
88+
* Reject the lenient cross-shape scalar coercions Jackson enables by default, so a payload
89+
* whose JSON shape does not match the target type fails loudly instead of being silently
90+
* reshaped into a wrong value.
91+
*
92+
* This is the second half of the lockdown (the first being [MapperFeature.ALLOW_COERCION_OF_SCALARS]
93+
* disabled by the caller): per-[LogicalType] [CoercionAction.Fail] rules for the specific
94+
* cross-shape pairs, applied through [com.fasterxml.jackson.databind.cfg.MapperBuilder.withCoercionConfig]
95+
* so the rejection holds regardless of how a given scalar target consults coercion config:
96+
*
97+
* - string → integer / floating-point / boolean (the headline `"5"` → numeric case),
98+
* - boolean ↔ integer,
99+
* - integer / floating-point / boolean → string.
100+
*
101+
* Untouched on purpose: numeric **widening** (an integer JSON value into a floating-point
102+
* field) stays legal because it is a representation-preserving conversion, not a shape mismatch;
103+
* and genuinely typed values bind exactly as before.
104+
*/
105+
private fun applyStrictScalarCoercion(builder: JsonMapper.Builder) {
106+
fun JsonMapper.Builder.failOn(
107+
target: LogicalType,
108+
vararg shapes: CoercionInputShape,
109+
): JsonMapper.Builder =
110+
withCoercionConfig(target) { cfg: MutableCoercionConfig ->
111+
shapes.forEach { shape -> cfg.setCoercion(shape, CoercionAction.Fail) }
112+
}
113+
114+
builder
115+
// string "5"/"1.5"/"true" must not flow into numeric or boolean fields.
116+
.failOn(LogicalType.Integer, CoercionInputShape.String, CoercionInputShape.Boolean)
117+
.failOn(LogicalType.Float, CoercionInputShape.String)
118+
.failOn(LogicalType.Boolean, CoercionInputShape.String, CoercionInputShape.Integer)
119+
// a non-string scalar must not be stringified into a textual field.
120+
.failOn(
121+
LogicalType.Textual,
122+
CoercionInputShape.Integer,
123+
CoercionInputShape.Float,
124+
CoercionInputShape.Boolean,
125+
)
126+
}
64127
}

0 commit comments

Comments
 (0)