Skip to content

Commit 79ad548

Browse files
authored
feat: own a SerdeException type and reject lenient scalar coercion (#93)
PR: #93
1 parent b6ccdc2 commit 79ad548

9 files changed

Lines changed: 607 additions & 22 deletions

File tree

sdk-core/api/sdk-core.api

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

2189+
public class org/dexpace/sdk/core/serde/DeserializationException : org/dexpace/sdk/core/serde/SerdeException {
2190+
public fun <init> ()V
2191+
public fun <init> (Ljava/lang/String;)V
2192+
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
2193+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
2194+
}
2195+
21892196
public abstract interface class org/dexpace/sdk/core/serde/Deserializer {
21902197
public abstract fun deserialize (Ljava/io/InputStream;Ljava/lang/Class;)Ljava/lang/Object;
21912198
public abstract fun deserialize (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;
@@ -2197,6 +2204,20 @@ public abstract interface class org/dexpace/sdk/core/serde/Serde {
21972204
public abstract fun getSerializer ()Lorg/dexpace/sdk/core/serde/Serializer;
21982205
}
21992206

2207+
public class org/dexpace/sdk/core/serde/SerdeException : java/lang/RuntimeException {
2208+
public fun <init> ()V
2209+
public fun <init> (Ljava/lang/String;)V
2210+
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
2211+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
2212+
}
2213+
2214+
public class org/dexpace/sdk/core/serde/SerializationException : org/dexpace/sdk/core/serde/SerdeException {
2215+
public fun <init> ()V
2216+
public fun <init> (Ljava/lang/String;)V
2217+
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
2218+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
2219+
}
2220+
22002221
public abstract interface class org/dexpace/sdk/core/serde/Serializer {
22012222
public abstract fun serialize (Ljava/lang/Object;)Ljava/lang/String;
22022223
public abstract fun serialize (Ljava/lang/Object;Ljava/io/OutputStream;)V

sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Deserializer.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,27 @@ import java.io.InputStream
2323
* For parametric targets (`List<MyDto>`, `Map<String, MyDto>`) the raw [Class] token is
2424
* insufficient; adapter modules expose their own type-reference entry points (e.g.
2525
* `sdk-serde-jackson`'s `JacksonSerde.deserializeAs`).
26+
*
27+
* Implementations surface decode failures as [DeserializationException] (a [SerdeException]
28+
* subtype), chaining the backing codec's error as the cause, so callers catch a single stable SDK
29+
* type without naming the underlying library.
2630
*/
2731
public interface Deserializer {
28-
/** Decode a complete document of [type] from the in-memory [input] string. */
32+
/**
33+
* Decode a complete document of [type] from the in-memory [input] string.
34+
*
35+
* @throws DeserializationException if [input] is malformed or does not match [type].
36+
*/
2937
public fun <T> deserialize(
3038
input: String,
3139
type: Class<T>,
3240
): T
3341

34-
/** Decode a complete document of [type] from the in-memory [input] byte array. */
42+
/**
43+
* Decode a complete document of [type] from the in-memory [input] byte array.
44+
*
45+
* @throws DeserializationException if [input] is malformed or does not match [type].
46+
*/
3547
public fun <T> deserialize(
3648
input: ByteArray,
3749
type: Class<T>,
@@ -40,6 +52,9 @@ public interface Deserializer {
4052
/**
4153
* Decode a complete document of [type] by streaming from [inputStream]. The implementation owns
4254
* reading to EOF but **does not** close the stream — the caller retains ownership.
55+
*
56+
* @throws DeserializationException if the payload is malformed or does not match [type]. A
57+
* genuine stream-read [java.io.IOException] propagates unwrapped.
4358
*/
4459
public fun <T> deserialize(
4560
inputStream: InputStream,
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)

sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Serializer.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,32 @@ import java.io.OutputStream
1717
* fresh `ByteArray`, stream into a caller-owned `OutputStream`, or stream into a caller-owned
1818
* scratch buffer. Stream / buffer overloads do **not** close their targets — the caller retains
1919
* ownership.
20+
*
21+
* Implementations surface encode failures as [SerializationException] (a [SerdeException] subtype),
22+
* chaining the backing codec's error as the cause, so callers catch a single stable SDK type
23+
* without naming the underlying library.
2024
*/
2125
public interface Serializer {
22-
/** Encode [input] and return the result as a new string. */
26+
/**
27+
* Encode [input] and return the result as a new string.
28+
*
29+
* @throws SerializationException if [input] cannot be encoded.
30+
*/
2331
public fun serialize(input: Any): String
2432

25-
/** Encode [input] and return the result as a freshly-allocated byte array. */
33+
/**
34+
* Encode [input] and return the result as a freshly-allocated byte array.
35+
*
36+
* @throws SerializationException if [input] cannot be encoded.
37+
*/
2638
public fun serializeToByteArray(input: Any): ByteArray
2739

28-
/** Stream [input]'s encoding into [outputStream]. The caller owns closing the stream. */
40+
/**
41+
* Stream [input]'s encoding into [outputStream]. The caller owns closing the stream.
42+
*
43+
* @throws SerializationException if [input] cannot be encoded. A genuine stream-write
44+
* [java.io.IOException] propagates unwrapped.
45+
*/
2946
public fun serialize(
3047
input: Any,
3148
outputStream: OutputStream,
@@ -38,6 +55,7 @@ public interface Serializer {
3855
* @throws IndexOutOfBoundsException when [offset] is negative or beyond [buffer]'s length, or
3956
* when the encoded payload does not fit in the remaining space (`buffer.size - offset`). The
4057
* buffer contents are unspecified after an overflow throw.
58+
* @throws SerializationException if [input] cannot be encoded.
4159
*/
4260
public fun serialize(
4361
input: Any,
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: 92 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,69 @@ 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+
* - floating-point → integer (the lossy `1.5` → `1` narrowing),
99+
* - boolean ↔ integer and boolean → floating-point,
100+
* - integer / floating-point / boolean → string,
101+
* - empty string → integer / floating-point / boolean (Jackson otherwise coerces `""` to a
102+
* null/zero scalar, masking a malformed payload).
103+
*
104+
* Untouched on purpose: numeric **widening** (an integer JSON value into a floating-point
105+
* field) stays legal because it is a representation-preserving conversion, not a shape mismatch;
106+
* and genuinely typed values bind exactly as before. The inverse — a floating-point JSON value
107+
* into an integer field — is rejected because it silently truncates (`1.5` → `1`). An empty
108+
* string into a textual field is left alone: `""` is a legitimate string value.
109+
*/
110+
private fun applyStrictScalarCoercion(builder: JsonMapper.Builder) {
111+
fun JsonMapper.Builder.failOn(
112+
target: LogicalType,
113+
vararg shapes: CoercionInputShape,
114+
): JsonMapper.Builder =
115+
withCoercionConfig(target) { cfg: MutableCoercionConfig ->
116+
shapes.forEach { shape -> cfg.setCoercion(shape, CoercionAction.Fail) }
117+
}
118+
119+
builder
120+
// string "5"/"1.5"/"true" (and an empty string) must not flow into numeric or boolean
121+
// fields, and a floating-point value must not be lossily narrowed into an integer field
122+
// (1.5 -> 1).
123+
.failOn(
124+
LogicalType.Integer,
125+
CoercionInputShape.String,
126+
CoercionInputShape.EmptyString,
127+
CoercionInputShape.Boolean,
128+
CoercionInputShape.Float,
129+
)
130+
.failOn(
131+
LogicalType.Float,
132+
CoercionInputShape.String,
133+
CoercionInputShape.EmptyString,
134+
CoercionInputShape.Boolean,
135+
)
136+
.failOn(
137+
LogicalType.Boolean,
138+
CoercionInputShape.String,
139+
CoercionInputShape.EmptyString,
140+
CoercionInputShape.Integer,
141+
)
142+
// a non-string scalar must not be stringified into a textual field. An empty string is a
143+
// valid string, so EmptyString is intentionally absent here.
144+
.failOn(
145+
LogicalType.Textual,
146+
CoercionInputShape.Integer,
147+
CoercionInputShape.Float,
148+
CoercionInputShape.Boolean,
149+
)
150+
}
64151
}

0 commit comments

Comments
 (0)