From 2e3da561a8062559d3818dfe0d92256f34fce91b Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 22:40:08 +0300 Subject: [PATCH 1/3] 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. --- sdk-core/api/sdk-core.api | 21 +++ .../dexpace/sdk/core/serde/SerdeException.kt | 72 +++++++++ .../sdk/core/serde/SerdeExceptionTest.kt | 59 ++++++++ .../sdk/serde/jackson/JacksonObjectMappers.kt | 73 ++++++++- .../dexpace/sdk/serde/jackson/JacksonSerde.kt | 70 +++++++-- .../serde/jackson/JacksonObjectMappersTest.kt | 67 +++++++++ .../sdk/serde/jackson/JacksonSerdeTest.kt | 139 ++++++++++++++++++ 7 files changed, 484 insertions(+), 17 deletions(-) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/SerdeException.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/SerdeExceptionTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index de3a5d14..25a673a9 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -2163,6 +2163,13 @@ public final class org/dexpace/sdk/core/pipeline/step/retry/RetryStep : org/dexp public final class org/dexpace/sdk/core/pipeline/step/retry/RetryStep$Companion { } +public class org/dexpace/sdk/core/serde/DeserializationException : org/dexpace/sdk/core/serde/SerdeException { + public fun ()V + public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + public abstract interface class org/dexpace/sdk/core/serde/Deserializer { public abstract fun deserialize (Ljava/io/InputStream;Ljava/lang/Class;)Ljava/lang/Object; 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 { public abstract fun getSerializer ()Lorg/dexpace/sdk/core/serde/Serializer; } +public class org/dexpace/sdk/core/serde/SerdeException : java/lang/RuntimeException { + public fun ()V + public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public class org/dexpace/sdk/core/serde/SerializationException : org/dexpace/sdk/core/serde/SerdeException { + public fun ()V + public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + public abstract interface class org/dexpace/sdk/core/serde/Serializer { public abstract fun serialize (Ljava/lang/Object;)Ljava/lang/String; public abstract fun serialize (Ljava/lang/Object;Ljava/io/OutputStream;)V diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/SerdeException.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/SerdeException.kt new file mode 100644 index 00000000..3b919f38 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/SerdeException.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.serde + +/** + * Stable SDK-owned failure type for the [Serde] SPI. + * + * The serde abstraction is format-agnostic: callers code against [Serializer] / [Deserializer] + * without knowing whether the bytes on the wire are turned into objects by Jackson, Gson, or a + * hand-rolled codec. If an adapter let its underlying library's exception escape (Jackson's + * `com.fasterxml.jackson.*`, say), every consumer's `catch` clause would have to name that library + * — re-coupling the SDK to an implementation detail the SPI exists to hide, and breaking any caller + * the day the backing library changes. + * + * `SerdeException` closes that seam: adapters catch their own library's failures and rethrow as a + * `SerdeException` (or one of its subtypes), always chaining the original as [cause] so the + * underlying diagnostic is never lost. Consumers catch a single, stable SDK type. + * + * ## Why `RuntimeException` and not a checked exception + * + * Serialization failures are almost always programming or contract errors (an unserializable + * object graph, a payload that does not match the target type) rather than recoverable I/O + * conditions, and the [Serializer] / [Deserializer] signatures are deliberately unchecked so Java + * callers are not forced to wrap every round-trip in `try`/`catch`. Making this unchecked keeps the + * SPI ergonomic in both Kotlin and Java. + * + * The class is `open` so adapters and service-client codegen can introduce more specific subtypes; + * [SerializationException] and [DeserializationException] cover the write and read directions out + * of the box. + * + * @param message Human-readable description of the failure. + * @param cause The underlying error (typically the backing library's exception), or `null`. + */ +public open class SerdeException + @JvmOverloads + constructor( + message: String? = null, + cause: Throwable? = null, + ) : RuntimeException(message, cause) + +/** + * A [SerdeException] raised while **encoding** a value to the wire format — for example an object + * graph the backing codec cannot represent (a reference cycle, an unmapped type). + * + * @param message Human-readable description of the failure. + * @param cause The underlying error (typically the backing library's exception), or `null`. + */ +public open class SerializationException + @JvmOverloads + constructor( + message: String? = null, + cause: Throwable? = null, + ) : SerdeException(message, cause) + +/** + * A [SerdeException] raised while **decoding** wire bytes / strings / streams into a typed value — + * for example a malformed document or a payload whose shape does not match the target type. + * + * @param message Human-readable description of the failure. + * @param cause The underlying error (typically the backing library's exception), or `null`. + */ +public open class DeserializationException + @JvmOverloads + constructor( + message: String? = null, + cause: Throwable? = null, + ) : SerdeException(message, cause) diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/SerdeExceptionTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/SerdeExceptionTest.kt new file mode 100644 index 00000000..431af188 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/SerdeExceptionTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.serde + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SerdeExceptionTest { + @Test + fun `SerdeException is a RuntimeException so callers need no checked-exception plumbing`() { + // Reflective check (not a compile-time-foldable `is`) that the supertype is RuntimeException. + assertTrue(RuntimeException::class.java.isAssignableFrom(SerdeException::class.java)) + } + + @Test + fun `SerdeException carries message and chained cause`() { + val root = IllegalStateException("root") + val ex = SerdeException("wrapped", root) + assertEquals("wrapped", ex.message) + assertSame(root, ex.cause) + } + + @Test + fun `SerdeException message-only constructor leaves cause null`() { + val ex = SerdeException("just a message") + assertEquals("just a message", ex.message) + assertNull(ex.cause) + } + + @Test + fun `SerializationException is a SerdeException`() { + val ex = SerializationException("write failed", RuntimeException("inner")) + assertTrue(SerdeException::class.java.isAssignableFrom(SerializationException::class.java)) + assertEquals("write failed", ex.message) + } + + @Test + fun `DeserializationException is a SerdeException`() { + val ex = DeserializationException("read failed", RuntimeException("inner")) + assertTrue(SerdeException::class.java.isAssignableFrom(DeserializationException::class.java)) + assertEquals("read failed", ex.message) + } + + @Test + fun `SerdeException is open so adapters and codegen can subclass it`() { + // A bespoke subclass compiles only if SerdeException is `open`; this also documents the + // extension point for adapter-specific error types. + class CustomSerdeException(message: String) : SerdeException(message) + assertTrue(SerdeException::class.java.isAssignableFrom(CustomSerdeException::class.java)) + } +} diff --git a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappers.kt b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappers.kt index ca190f22..ac44ab46 100644 --- a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappers.kt +++ b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappers.kt @@ -10,8 +10,14 @@ package org.dexpace.sdk.serde.jackson import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.MapperFeature import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.databind.cfg.CoercionAction +import com.fasterxml.jackson.databind.cfg.CoercionInputShape +import com.fasterxml.jackson.databind.cfg.MutableCoercionConfig +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.databind.type.LogicalType import com.fasterxml.jackson.datatype.jdk8.Jdk8Module import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.KotlinModule @@ -28,6 +34,14 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule * every consumer to ship a new SDK release on each backward-compatible server change. * - [SerializationFeature.WRITE_DATES_AS_TIMESTAMPS] **disabled** — emits ISO-8601 strings * rather than numeric epoch millis, matching every public REST API. + * - [MapperFeature.ALLOW_COERCION_OF_SCALARS] **disabled** plus per-type [CoercionAction.Fail] + * rules — Jackson's defaults silently reshape mismatched scalars (a wire string `"5"` becomes a + * numeric field, a number becomes a string, and so on), which masks malformed payloads. The SDK + * rejects those cross-shape coercions so a contract violation surfaces as a failure rather than a + * quietly wrong value. Numeric widening (an integer into a floating-point field) and genuinely + * typed values are unaffected. See [lockDownScalarCoercion]. Auto-detection features + * (`AUTO_DETECT_*`) are deliberately left at their defaults: Kotlin data-class binding relies on + * them, and any further tightening belongs to codegen, which owns its own mapper. * * Modules registered, in order: * - [KotlinModule] — Kotlin data classes, default-argument support, value classes. @@ -47,11 +61,19 @@ public object JacksonObjectMappers { * `enable`/`disable` features) but should treat the result as their own instance to mutate. */ public fun defaultObjectMapper(): ObjectMapper { - val mapper = ObjectMapper() - mapper.registerModule(KotlinModule.Builder().build()) - mapper.registerModule(JavaTimeModule()) - mapper.registerModule(Jdk8Module()) - mapper.registerModule(TristateModule()) + // Built via JsonMapper.builder() so the MapperFeature toggle and coercion config are set + // at build time — the runtime ObjectMapper.configure(MapperFeature, ...) setter is + // deprecated in 2.18, and withCoercionConfig is the supported coercion entry point. + val builder = + JsonMapper + .builder() + .addModule(KotlinModule.Builder().build()) + .addModule(JavaTimeModule()) + .addModule(Jdk8Module()) + .addModule(TristateModule()) + .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS) + applyStrictScalarCoercion(builder) + val mapper = builder.build() mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // Caller-owned streams must not be closed by the mapper. Both write and read paths @@ -61,4 +83,45 @@ public object JacksonObjectMappers { mapper.factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE) return mapper } + + /** + * Reject the lenient cross-shape scalar coercions Jackson enables by default, so a payload + * whose JSON shape does not match the target type fails loudly instead of being silently + * reshaped into a wrong value. + * + * This is the second half of the lockdown (the first being [MapperFeature.ALLOW_COERCION_OF_SCALARS] + * disabled by the caller): per-[LogicalType] [CoercionAction.Fail] rules for the specific + * cross-shape pairs, applied through [com.fasterxml.jackson.databind.cfg.MapperBuilder.withCoercionConfig] + * so the rejection holds regardless of how a given scalar target consults coercion config: + * + * - string → integer / floating-point / boolean (the headline `"5"` → numeric case), + * - boolean ↔ integer, + * - integer / floating-point / boolean → string. + * + * Untouched on purpose: numeric **widening** (an integer JSON value into a floating-point + * field) stays legal because it is a representation-preserving conversion, not a shape mismatch; + * and genuinely typed values bind exactly as before. + */ + private fun applyStrictScalarCoercion(builder: JsonMapper.Builder) { + fun JsonMapper.Builder.failOn( + target: LogicalType, + vararg shapes: CoercionInputShape, + ): JsonMapper.Builder = + withCoercionConfig(target) { cfg: MutableCoercionConfig -> + shapes.forEach { shape -> cfg.setCoercion(shape, CoercionAction.Fail) } + } + + builder + // string "5"/"1.5"/"true" must not flow into numeric or boolean fields. + .failOn(LogicalType.Integer, CoercionInputShape.String, CoercionInputShape.Boolean) + .failOn(LogicalType.Float, CoercionInputShape.String) + .failOn(LogicalType.Boolean, CoercionInputShape.String, CoercionInputShape.Integer) + // a non-string scalar must not be stringified into a textual field. + .failOn( + LogicalType.Textual, + CoercionInputShape.Integer, + CoercionInputShape.Float, + CoercionInputShape.Boolean, + ) + } } diff --git a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerde.kt b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerde.kt index a5fb3298..1895233b 100644 --- a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerde.kt +++ b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerde.kt @@ -9,11 +9,15 @@ package org.dexpace.sdk.serde.jackson import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper +import org.dexpace.sdk.core.serde.DeserializationException import org.dexpace.sdk.core.serde.Deserializer import org.dexpace.sdk.core.serde.Serde +import org.dexpace.sdk.core.serde.SerializationException import org.dexpace.sdk.core.serde.Serializer +import java.io.IOException import java.io.InputStream import java.io.OutputStream @@ -51,26 +55,34 @@ public class JacksonSerde private constructor( /** * Deserialize [input] into a value of the type captured by [type]. This overload is the * "correct" entry point for parametric or otherwise erased generic targets. + * + * @throws DeserializationException if [input] is malformed or does not match [type]. */ public fun deserializeAs( input: String, type: TypeReference, - ): T = mapper.readValue(input, type) + ): T = deserializing { mapper.readValue(input, type) } - /** Deserialize from a [ByteArray]. See [deserializeAs] for type-reference rationale. */ + /** + * Deserialize from a [ByteArray]. See [deserializeAs] for type-reference rationale. + * + * @throws DeserializationException if [input] is malformed or does not match [type]. + */ public fun deserializeAs( input: ByteArray, type: TypeReference, - ): T = mapper.readValue(input, type) + ): T = deserializing { mapper.readValue(input, type) } /** * Deserialize by streaming from [inputStream]. The implementation reads to EOF but does * **not** close the stream — the caller retains ownership. + * + * @throws DeserializationException if the payload is malformed or does not match [type]. */ public fun deserializeAs( inputStream: InputStream, type: TypeReference, - ): T = mapper.readValue(inputStream, type) + ): T = deserializing { mapper.readValue(inputStream, type) } public companion object { /** Build a [JacksonSerde] backed by an [ObjectMapper] with the SDK-correct defaults. */ @@ -100,9 +112,9 @@ public class JacksonSerde private constructor( internal class JacksonSerializer internal constructor( private val mapper: ObjectMapper, ) : Serializer { - override fun serialize(input: Any): String = mapper.writeValueAsString(input) + override fun serialize(input: Any): String = serializing { mapper.writeValueAsString(input) } - override fun serializeToByteArray(input: Any): ByteArray = mapper.writeValueAsBytes(input) + override fun serializeToByteArray(input: Any): ByteArray = serializing { mapper.writeValueAsBytes(input) } override fun serialize( input: Any, @@ -113,7 +125,7 @@ internal class JacksonSerializer internal constructor( // mutated and the stream is left open after close()/flush — see JacksonSerde.from(...). val generator = mapper.factory.createGenerator(outputStream).disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET) generator.use { gen -> - mapper.writeValue(gen, input) + serializing { mapper.writeValue(gen, input) } } } @@ -124,13 +136,14 @@ internal class JacksonSerializer internal constructor( ): Int { // Render to bytes then memcpy into the caller's buffer at [offset], returning the count so // the caller can slice the valid prefix. Overflow / bad offset throws IndexOutOfBoundsException - // per the Serializer contract; bounds-check is cheap. + // per the Serializer contract; bounds-check is cheap — and stays outside the encode wrapper so + // a bounds error is never re-wrapped as a SerializationException. if (offset < 0 || offset > buffer.size) { throw IndexOutOfBoundsException( "offset $offset out of bounds for buffer of size ${buffer.size}.", ) } - val encoded = mapper.writeValueAsBytes(input) + val encoded = serializing { mapper.writeValueAsBytes(input) } val remaining = buffer.size - offset if (encoded.size > remaining) { throw IndexOutOfBoundsException( @@ -158,12 +171,12 @@ internal class JacksonDeserializer internal constructor( override fun deserialize( input: String, type: Class, - ): T = mapper.readValue(input, type) + ): T = deserializing { mapper.readValue(input, type) } override fun deserialize( input: ByteArray, type: Class, - ): T = mapper.readValue(input, type) + ): T = deserializing { mapper.readValue(input, type) } override fun deserialize( inputStream: InputStream, @@ -173,7 +186,40 @@ internal class JacksonDeserializer internal constructor( // a caller-supplied mapper (see JacksonSerde.from) is not mutated and the stream stays open. val parser = mapper.factory.createParser(inputStream).disable(JsonParser.Feature.AUTO_CLOSE_SOURCE) return parser.use { p -> - mapper.readValue(p, type) + deserializing { mapper.readValue(p, type) } } } } + +/** + * Run an encode [block], translating any Jackson [JsonProcessingException] into a + * [SerializationException] so no `com.fasterxml.*` type escapes the `Serde` SPI. The original + * Jackson failure is chained as the cause. + * + * [JsonProcessingException] is the root of Jackson's databind/streaming failure hierarchy and a + * subtype of [IOException]; catching it specifically leaves a genuine stream [IOException] (a real + * write failure, not a serde problem) to propagate untouched, matching the caller-owned-stream + * contract on [Serializer.serialize]. + */ +private inline fun serializing(block: () -> T): T = + try { + block() + } catch (e: JsonProcessingException) { + throw SerializationException(e.originalMessage ?: e.message, e) + } + +/** + * Run a decode [block], translating any Jackson [JsonProcessingException] into a + * [DeserializationException] so no `com.fasterxml.*` type escapes the `Serde` SPI. The original + * Jackson failure is chained as the cause. + * + * As with [serializing], only [JsonProcessingException] (malformed input, shape/type mismatch) is + * wrapped; a genuine stream [IOException] propagates unchanged so existing `catch (IOException)` + * sites on the read path keep working. + */ +private inline fun deserializing(block: () -> T): T = + try { + block() + } catch (e: JsonProcessingException) { + throw DeserializationException(e.originalMessage ?: e.message, e) + } diff --git a/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappersTest.kt b/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappersTest.kt index 45f7715a..1c7a24e4 100644 --- a/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappersTest.kt +++ b/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappersTest.kt @@ -7,10 +7,12 @@ package org.dexpace.sdk.serde.jackson +import com.fasterxml.jackson.databind.exc.MismatchedInputException import com.fasterxml.jackson.module.kotlin.readValue import java.time.Instant import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class JacksonObjectMappersTest { @@ -18,6 +20,12 @@ class JacksonObjectMappersTest { data class WithInstant(val occurredAt: Instant) + data class Numbers(val count: Int, val ratio: Double) + + data class Flag(val enabled: Boolean) + + data class Label(val text: String) + @Test fun `default mapper deserializes JSON with extra field successfully`() { val mapper = JacksonObjectMappers.defaultObjectMapper() @@ -49,4 +57,63 @@ class JacksonObjectMappersTest { // Distinct mappers — caller-owned, no shared mutable state. assertTrue(a !== b) } + + // ----- Coercion lockdown (#24): mismatched scalar shapes must fail, not coerce silently ----- + + @Test + fun `string for an integer field is rejected, not coerced`() { + val mapper = JacksonObjectMappers.defaultObjectMapper() + // The headline case: a wire string "5" must NOT slip into a numeric field. + assertFailsWith { + mapper.readValue("""{"count":"5","ratio":1.5}""") + } + } + + @Test + fun `string for a floating-point field is rejected, not coerced`() { + val mapper = JacksonObjectMappers.defaultObjectMapper() + assertFailsWith { + mapper.readValue("""{"count":5,"ratio":"1.5"}""") + } + } + + @Test + fun `string for a boolean field is rejected, not coerced`() { + val mapper = JacksonObjectMappers.defaultObjectMapper() + assertFailsWith { + mapper.readValue("""{"enabled":"true"}""") + } + } + + @Test + fun `number for a string field is rejected, not coerced`() { + val mapper = JacksonObjectMappers.defaultObjectMapper() + assertFailsWith { + mapper.readValue