From b7b56e1d6de4baefe054b2a4d765db95e85217fb Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 05:30:40 +0300 Subject: [PATCH] feat: add four-state JsonField and RawJson tree with additionalProperties pass-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a dependency-free four-state field model for forward-compatible deserialization, alongside an immutable additionalProperties holder for lossless unknown-field round-tripping. sdk-core (zero non-stdlib deps): - RawJson: an immutable JSON value tree (Obj/Arr/Str/Num/Bool/Null). Numbers keep their verbatim literal text so large ids and high-precision decimals round-trip without the Double-precision loss common in client SDKs. - JsonField: a sealed four-state container — Missing / Null / Known / Raw(RawJson). Raw is the "present but unbindable" escape hatch a generated, forward-compatible model needs; it preserves the original JSON instead of throwing or dropping it. Bidirectional, explicitly-lossy interop with Tristate documents the read-path (four-state) vs. PATCH-path (three-state) boundary so the two are never wrongly merged. - AdditionalProperties: an immutable, insertion-ordered snapshot of unknown properties (private ctor + Builder + newBuilder), the runtime primitive behind additionalProperties pass-through. sdk-serde-jackson: - JsonFieldModule mirrors TristateModule: contextual de/serializers, a bean-property writer that omits Missing fields, and RawJson<->Jackson node conversion that streams numbers verbatim. A value that fails to bind to T falls back to Raw rather than failing the parse. Registered in the default ObjectMapper. The default mapper already tolerates unknown fields; a model mixing in the any-setter/any-getter pattern now captures them into AdditionalProperties so a read-modify-write loop no longer silently drops server-added fields. Closes #50 Closes #52 --- sdk-core/api/sdk-core.api | 200 +++++++++++ .../sdk/core/serde/AdditionalProperties.kt | 127 +++++++ .../org/dexpace/sdk/core/serde/JsonField.kt | 201 +++++++++++ .../org/dexpace/sdk/core/serde/RawJson.kt | 235 +++++++++++++ .../core/serde/AdditionalPropertiesTest.kt | 119 +++++++ .../dexpace/sdk/core/serde/JsonFieldTest.kt | 134 +++++++ .../org/dexpace/sdk/core/serde/RawJsonTest.kt | 140 ++++++++ sdk-serde-jackson/api/sdk-serde-jackson.api | 9 + .../sdk/serde/jackson/JacksonObjectMappers.kt | 2 + .../sdk/serde/jackson/JsonFieldModule.kt | 331 ++++++++++++++++++ .../AdditionalPropertiesPassThroughTest.kt | 104 ++++++ .../sdk/serde/jackson/JsonFieldModuleTest.kt | 157 +++++++++ 12 files changed, 1759 insertions(+) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/AdditionalProperties.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/JsonField.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/RawJson.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/AdditionalPropertiesTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/JsonFieldTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/RawJsonTest.kt create mode 100644 sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JsonFieldModule.kt create mode 100644 sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/AdditionalPropertiesPassThroughTest.kt create mode 100644 sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JsonFieldModuleTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..b5e54495 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -2260,6 +2260,42 @@ 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 final class org/dexpace/sdk/core/serde/AdditionalProperties { + public static final field Companion Lorg/dexpace/sdk/core/serde/AdditionalProperties$Companion; + public static final field EMPTY Lorg/dexpace/sdk/core/serde/AdditionalProperties; + public synthetic fun (Ljava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun builder ()Lorg/dexpace/sdk/core/serde/AdditionalProperties$Builder; + public final fun contains (Ljava/lang/String;)Z + public static final fun empty ()Lorg/dexpace/sdk/core/serde/AdditionalProperties; + public fun equals (Ljava/lang/Object;)Z + public final fun get (Ljava/lang/String;)Lorg/dexpace/sdk/core/serde/RawJson; + public final fun getEntries ()Ljava/util/Map; + public final fun getKeys ()Ljava/util/Set; + public final fun getSize ()I + public fun hashCode ()I + public final fun isEmpty ()Z + public final fun newBuilder ()Lorg/dexpace/sdk/core/serde/AdditionalProperties$Builder; + public static final fun of (Ljava/util/Map;)Lorg/dexpace/sdk/core/serde/AdditionalProperties; + public final fun toRawJson ()Lorg/dexpace/sdk/core/serde/RawJson$Obj; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/serde/AdditionalProperties$Builder : org/dexpace/sdk/core/generics/Builder { + public fun ()V + public synthetic fun build ()Ljava/lang/Object; + public fun build ()Lorg/dexpace/sdk/core/serde/AdditionalProperties; + public final fun clear ()Lorg/dexpace/sdk/core/serde/AdditionalProperties$Builder; + public final fun put (Ljava/lang/String;Lorg/dexpace/sdk/core/serde/RawJson;)Lorg/dexpace/sdk/core/serde/AdditionalProperties$Builder; + public final fun putAll (Ljava/util/Map;)Lorg/dexpace/sdk/core/serde/AdditionalProperties$Builder; + public final fun remove (Ljava/lang/String;)Lorg/dexpace/sdk/core/serde/AdditionalProperties$Builder; +} + +public final class org/dexpace/sdk/core/serde/AdditionalProperties$Companion { + public final fun builder ()Lorg/dexpace/sdk/core/serde/AdditionalProperties$Builder; + public final fun empty ()Lorg/dexpace/sdk/core/serde/AdditionalProperties; + public final fun of (Ljava/util/Map;)Lorg/dexpace/sdk/core/serde/AdditionalProperties; +} + public class org/dexpace/sdk/core/serde/DeserializationException : org/dexpace/sdk/core/serde/SerdeException { public fun ()V public fun (Ljava/lang/String;)V @@ -2273,6 +2309,170 @@ public abstract interface class org/dexpace/sdk/core/serde/Deserializer { public abstract fun deserialize ([BLjava/lang/Class;)Ljava/lang/Object; } +public abstract class org/dexpace/sdk/core/serde/JsonField { + public static final field Companion Lorg/dexpace/sdk/core/serde/JsonField$Companion; + public final fun fold (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static final fun fromTristate (Lorg/dexpace/sdk/core/serde/Tristate;)Lorg/dexpace/sdk/core/serde/JsonField; + public final fun getOrNull ()Ljava/lang/Object; + public final fun isKnown ()Z + public final fun isMissing ()Z + public final fun isNull ()Z + public final fun isRaw ()Z + public static final fun known (Ljava/lang/Object;)Lorg/dexpace/sdk/core/serde/JsonField; + public static final fun missing ()Lorg/dexpace/sdk/core/serde/JsonField; + public static final fun nullValue ()Lorg/dexpace/sdk/core/serde/JsonField; + public static final fun ofNullable (Ljava/lang/Object;)Lorg/dexpace/sdk/core/serde/JsonField; + public static final fun raw (Lorg/dexpace/sdk/core/serde/RawJson;)Lorg/dexpace/sdk/core/serde/JsonField; + public final fun toTristate ()Lorg/dexpace/sdk/core/serde/Tristate; +} + +public final class org/dexpace/sdk/core/serde/JsonField$Companion { + public final fun fromTristate (Lorg/dexpace/sdk/core/serde/Tristate;)Lorg/dexpace/sdk/core/serde/JsonField; + public final fun known (Ljava/lang/Object;)Lorg/dexpace/sdk/core/serde/JsonField; + public final fun missing ()Lorg/dexpace/sdk/core/serde/JsonField; + public final fun nullValue ()Lorg/dexpace/sdk/core/serde/JsonField; + public final fun ofNullable (Ljava/lang/Object;)Lorg/dexpace/sdk/core/serde/JsonField; + public final fun raw (Lorg/dexpace/sdk/core/serde/RawJson;)Lorg/dexpace/sdk/core/serde/JsonField; +} + +public final class org/dexpace/sdk/core/serde/JsonField$Known : org/dexpace/sdk/core/serde/JsonField { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lorg/dexpace/sdk/core/serde/JsonField$Known; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/serde/JsonField$Known;Ljava/lang/Object;ILjava/lang/Object;)Lorg/dexpace/sdk/core/serde/JsonField$Known; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/serde/JsonField$Missing : org/dexpace/sdk/core/serde/JsonField { + public static final field INSTANCE Lorg/dexpace/sdk/core/serde/JsonField$Missing; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/serde/JsonField$Null : org/dexpace/sdk/core/serde/JsonField { + public static final field INSTANCE Lorg/dexpace/sdk/core/serde/JsonField$Null; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/serde/JsonField$Raw : org/dexpace/sdk/core/serde/JsonField { + public fun (Lorg/dexpace/sdk/core/serde/RawJson;)V + public final fun component1 ()Lorg/dexpace/sdk/core/serde/RawJson; + public final fun copy (Lorg/dexpace/sdk/core/serde/RawJson;)Lorg/dexpace/sdk/core/serde/JsonField$Raw; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/serde/JsonField$Raw;Lorg/dexpace/sdk/core/serde/RawJson;ILjava/lang/Object;)Lorg/dexpace/sdk/core/serde/JsonField$Raw; + public fun equals (Ljava/lang/Object;)Z + public final fun getJson ()Lorg/dexpace/sdk/core/serde/RawJson; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract class org/dexpace/sdk/core/serde/RawJson { + public static final field Companion Lorg/dexpace/sdk/core/serde/RawJson$Companion; + public final fun isNull ()Z +} + +public final class org/dexpace/sdk/core/serde/RawJson$Arr : org/dexpace/sdk/core/serde/RawJson { + public static final field Companion Lorg/dexpace/sdk/core/serde/RawJson$Arr$Companion; + public static final field EMPTY Lorg/dexpace/sdk/core/serde/RawJson$Arr; + public synthetic fun (Ljava/util/List;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun get (I)Lorg/dexpace/sdk/core/serde/RawJson; + public final fun getSize ()I + public final fun getValues ()Ljava/util/List; + public fun hashCode ()I + public final fun isEmpty ()Z + public static final fun of (Ljava/util/List;)Lorg/dexpace/sdk/core/serde/RawJson$Arr; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/serde/RawJson$Arr$Companion { + public final fun of (Ljava/util/List;)Lorg/dexpace/sdk/core/serde/RawJson$Arr; +} + +public final class org/dexpace/sdk/core/serde/RawJson$Bool : org/dexpace/sdk/core/serde/RawJson { + public static final field Companion Lorg/dexpace/sdk/core/serde/RawJson$Bool$Companion; + public static final field FALSE Lorg/dexpace/sdk/core/serde/RawJson$Bool; + public static final field TRUE Lorg/dexpace/sdk/core/serde/RawJson$Bool; + public fun (Z)V + public final fun component1 ()Z + public final fun copy (Z)Lorg/dexpace/sdk/core/serde/RawJson$Bool; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/serde/RawJson$Bool;ZILjava/lang/Object;)Lorg/dexpace/sdk/core/serde/RawJson$Bool; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Z + public fun hashCode ()I + public static final fun of (Z)Lorg/dexpace/sdk/core/serde/RawJson$Bool; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/serde/RawJson$Bool$Companion { + public final fun of (Z)Lorg/dexpace/sdk/core/serde/RawJson$Bool; +} + +public final class org/dexpace/sdk/core/serde/RawJson$Companion { +} + +public final class org/dexpace/sdk/core/serde/RawJson$Null : org/dexpace/sdk/core/serde/RawJson { + public static final field INSTANCE Lorg/dexpace/sdk/core/serde/RawJson$Null; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/serde/RawJson$Num : org/dexpace/sdk/core/serde/RawJson { + public static final field Companion Lorg/dexpace/sdk/core/serde/RawJson$Num$Companion; + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lorg/dexpace/sdk/core/serde/RawJson$Num; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/serde/RawJson$Num;Ljava/lang/String;ILjava/lang/Object;)Lorg/dexpace/sdk/core/serde/RawJson$Num; + public fun equals (Ljava/lang/Object;)Z + public final fun getLiteral ()Ljava/lang/String; + public fun hashCode ()I + public static final fun of (I)Lorg/dexpace/sdk/core/serde/RawJson$Num; + public static final fun of (J)Lorg/dexpace/sdk/core/serde/RawJson$Num; + public static final fun of (Ljava/math/BigDecimal;)Lorg/dexpace/sdk/core/serde/RawJson$Num; + public final fun toBigDecimal ()Ljava/math/BigDecimal; + public final fun toDouble ()D + public final fun toIntOrNull ()Ljava/lang/Integer; + public final fun toLongOrNull ()Ljava/lang/Long; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/serde/RawJson$Num$Companion { + public final fun of (I)Lorg/dexpace/sdk/core/serde/RawJson$Num; + public final fun of (J)Lorg/dexpace/sdk/core/serde/RawJson$Num; + public final fun of (Ljava/math/BigDecimal;)Lorg/dexpace/sdk/core/serde/RawJson$Num; +} + +public final class org/dexpace/sdk/core/serde/RawJson$Obj : org/dexpace/sdk/core/serde/RawJson { + public static final field Companion Lorg/dexpace/sdk/core/serde/RawJson$Obj$Companion; + public static final field EMPTY Lorg/dexpace/sdk/core/serde/RawJson$Obj; + public synthetic fun (Ljava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun contains (Ljava/lang/String;)Z + public fun equals (Ljava/lang/Object;)Z + public final fun get (Ljava/lang/String;)Lorg/dexpace/sdk/core/serde/RawJson; + public final fun getEntries ()Ljava/util/Map; + public final fun getKeys ()Ljava/util/Set; + public final fun getSize ()I + public fun hashCode ()I + public final fun isEmpty ()Z + public static final fun of (Ljava/util/Map;)Lorg/dexpace/sdk/core/serde/RawJson$Obj; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/serde/RawJson$Obj$Companion { + public final fun of (Ljava/util/Map;)Lorg/dexpace/sdk/core/serde/RawJson$Obj; +} + +public final class org/dexpace/sdk/core/serde/RawJson$Str : org/dexpace/sdk/core/serde/RawJson { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lorg/dexpace/sdk/core/serde/RawJson$Str; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/serde/RawJson$Str;Ljava/lang/String;ILjava/lang/Object;)Lorg/dexpace/sdk/core/serde/RawJson$Str; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class org/dexpace/sdk/core/serde/Serde { public abstract fun getDeserializer ()Lorg/dexpace/sdk/core/serde/Deserializer; public abstract fun getSerializer ()Lorg/dexpace/sdk/core/serde/Serializer; diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/AdditionalProperties.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/AdditionalProperties.kt new file mode 100644 index 00000000..f7e6d449 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/AdditionalProperties.kt @@ -0,0 +1,127 @@ +/* + * 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 + +/** + * An immutable, insertion-ordered snapshot of the unknown ("additional") JSON properties a model + * carried on the wire — the runtime primitive behind lossless `additionalProperties` pass-through. + * + * The SDK's default mapper does **not** fail on unknown fields (forward compatibility), but Jackson's + * default binding then *drops* them: a read-modify-write loop silently discards any property the + * client model has no field for, including server-added fields the client never meant to touch. A + * generated DTO that mixes [AdditionalProperties] in captures those leftover keys into this holder at + * deserialization and re-emits them on serialization, so the round-trip is lossless. + * + * Values are [RawJson] trees, so the holder stays dependency-free and preserves the exact JSON shape + * — including types the model would not otherwise understand. The map is insertion-ordered so a + * round-trip is byte-stable with respect to key order, and immutable so a built model cannot be + * mutated after the fact; use [newBuilder] to derive a modified copy. + * + * This is a hand-written runtime base usable today; it is also the exact shape a generator would + * target for `@JsonAnySetter`/`@JsonAnyGetter`-style pass-through. The adapter + * (`sdk-serde-jackson`'s `JsonFieldModule`) supplies the Jackson capture/emit wiring; this type + * holds no library dependency. + */ +public class AdditionalProperties private constructor( + private val properties: Map, +) { + /** The captured unknown properties as an insertion-ordered, read-only map. */ + public val entries: Map + get() = properties + + /** The captured property names, in insertion order. */ + public val keys: Set + get() = properties.keys + + /** Returns the captured value for [key], or `null` if no such property was present. */ + public operator fun get(key: String): RawJson? = properties[key] + + /** Returns `true` if a property named [key] was captured. */ + public operator fun contains(key: String): Boolean = properties.containsKey(key) + + /** Returns `true` if no additional properties were captured. */ + public fun isEmpty(): Boolean = properties.isEmpty() + + /** The number of captured properties. */ + public val size: Int + get() = properties.size + + /** Renders the captured properties as a [RawJson.Obj] for emission. */ + public fun toRawJson(): RawJson.Obj = RawJson.Obj.of(properties) + + /** Returns a [Builder] pre-filled with this snapshot's properties, for deriving a modified copy. */ + public fun newBuilder(): Builder = Builder(properties) + + override fun equals(other: Any?): Boolean = other is AdditionalProperties && other.properties == properties + + override fun hashCode(): Int = properties.hashCode() + + override fun toString(): String = "AdditionalProperties(${toRawJson()})" + + /** + * Builder for [AdditionalProperties]. Insertion order of [put] calls is preserved. Implements + * [Builder]<[AdditionalProperties]> per the SDK's immutable-type convention. + */ + public class Builder internal constructor( + initial: Map, + ) : org.dexpace.sdk.core.generics.Builder { + private val properties: LinkedHashMap = LinkedHashMap(initial) + + /** Creates an empty builder. */ + public constructor() : this(emptyMap()) + + /** Records the additional property [key] → [value], replacing any prior value for [key]. */ + public fun put( + key: String, + value: RawJson, + ): Builder = + apply { + properties[key] = value + } + + /** Records every entry of [values], in iteration order. */ + public fun putAll(values: Map): Builder = + apply { + properties.putAll(values) + } + + /** Removes the captured property [key], if present. */ + public fun remove(key: String): Builder = + apply { + properties.remove(key) + } + + /** Removes all captured properties. */ + public fun clear(): Builder = + apply { + properties.clear() + } + + override fun build(): AdditionalProperties = + if (properties.isEmpty()) EMPTY else AdditionalProperties(LinkedHashMap(properties)) + } + + public companion object { + /** An empty snapshot singleton. */ + @JvmField + public val EMPTY: AdditionalProperties = AdditionalProperties(emptyMap()) + + /** Returns the [EMPTY] snapshot. Convenience factory for default field values. */ + @JvmStatic + public fun empty(): AdditionalProperties = EMPTY + + /** Builds a snapshot from [properties], defensively copied and insertion-ordered. */ + @JvmStatic + public fun of(properties: Map): AdditionalProperties = + if (properties.isEmpty()) EMPTY else AdditionalProperties(LinkedHashMap(properties)) + + /** Creates an empty [Builder]. */ + @JvmStatic + public fun builder(): Builder = Builder() + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/JsonField.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/JsonField.kt new file mode 100644 index 00000000..b3ae937c --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/JsonField.kt @@ -0,0 +1,201 @@ +/* + * 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 + +/** + * Four-valued container for a model field that must distinguish, at the serialization boundary, + * between four mutually exclusive states: + * + * - [Missing] — the property was absent from the wire payload entirely. + * - [Null] — the property was present and explicitly `null`. + * - [Known] — the property was present with a value that bound cleanly to the declared type `T`. + * - [Raw] — the property was present but its JSON shape **did not** match `T` (a forward-compat + * or wrong-type payload), captured losslessly as a [RawJson] tree so it survives a round-trip. + * + * [Tristate] already models the first three of these (absent / null / present). What it lacks — and + * what a code generator targeting forward-compatible models needs — is the fourth, "present but + * unbindable", escape hatch. `JsonField` adds [Raw] so a deserializer never has to throw on a + * surprising payload and never has to drop it: the original JSON is preserved and re-emitted + * verbatim on write. + * + * Like [Tristate], all concrete Jackson (or other library) conversion lives in adapter modules + * (`sdk-serde-jackson`'s `JsonFieldModule`); `sdk-core` only exposes the abstract sum type and the + * dependency-free [RawJson] tree it carries, so generated DTOs can declare `JsonField` fields + * without pulling a JSON library onto the core surface. + * + * ### Tristate interop — read-path vs. PATCH-path + * + * `JsonField` and [Tristate] overlap but are **not interchangeable**, and merging them carelessly + * loses information: + * + * - **Read path (forward-compatible deserialization).** Use `JsonField`. A server can add fields + * or change a field's shape ahead of the client; [Raw] absorbs the unexpected without failing. + * [toTristate] collapses `JsonField` down to `Tristate` for callers that only care about the + * three classic states — but it is **lossy**: a [Raw] node cannot be represented as a `Tristate`, + * so [toTristate] folds it into [Tristate.Present] wrapping the [RawJson], preserving the value + * while flattening the "wrong type" signal. Pick the right tool for the layer. + * - **PATCH path (three-state write).** Use [Tristate]. A `PATCH` body has exactly three meaningful + * states — leave-alone / clear / set — and no use for a "wrong type" fourth state, which would be + * a client bug rather than a wire reality. [fromTristate] lifts a `Tristate` into the matching + * `JsonField` ([Tristate.Absent] → [Missing], [Tristate.Null] → [Null], [Tristate.Present] → + * [Known]); it never produces [Raw]. + * + * The boundary: deserialize into `JsonField`, but never feed a `JsonField` that might be [Raw] into + * a PATCH builder expecting a clean three-state `Tristate` — convert explicitly and decide what a + * [Raw] means for your write. + * + * Covariant in `T`, so `Known` is assignable to `JsonField`; the [Missing], + * [Null], and [Raw] variants carry no `T` and satisfy any `JsonField` target without a cast. + * + * Instances are immutable; equality is structural for [Known] / [Raw] and referential for the + * [Missing] / [Null] singletons. + */ +public sealed class JsonField { + /** + * Sentinel meaning "the field was not present in the wire payload". + * + * Distinct from [Null]: [Missing] means the key never appeared; [Null] means it appeared with a + * `null` value. + */ + public object Missing : JsonField() { + /** Stable, identity-free rendering so logs / assertions don't leak an identity hash. */ + override fun toString(): String = "Missing" + } + + /** + * Sentinel meaning "the field was present and explicitly null". + * + * A serializer must emit `"field": null`, not omit the key. + */ + public object Null : JsonField() { + /** Stable, identity-free rendering so logs / assertions don't leak an identity hash. */ + override fun toString(): String = "Null" + } + + /** + * The field was present and bound cleanly to the declared type. [value] is bounded `T : Any` so + * a `Known(null)` — which would be indistinguishable from [Null] — cannot be constructed. + */ + public data class Known(public val value: T) : JsonField() + + /** + * The field was present but its JSON shape did not match `T`, captured as a [RawJson] tree. + * + * This is the forward-compatibility escape hatch: a deserializer that cannot bind a payload to + * `T` parks the original JSON here instead of throwing or dropping it, so a read-modify-write + * round-trips the server's value unchanged. + */ + public data class Raw(public val json: RawJson) : JsonField() + + /** Returns `true` if this is [Missing]. */ + public val isMissing: Boolean + get() = this is Missing + + /** Returns `true` if this is [Null]. */ + public val isNull: Boolean + get() = this is Null + + /** Returns `true` if this is a [Known]. */ + public val isKnown: Boolean + get() = this is Known<*> + + /** Returns `true` if this is a [Raw]. */ + public val isRaw: Boolean + get() = this is Raw + + /** + * Returns [Known.value] when this is a [Known], or `null` for every other variant. + * + * Callers that must distinguish [Missing] / [Null] / [Raw] cannot use this — use [fold] or + * pattern-match the sealed hierarchy directly. + */ + public fun getOrNull(): T? = + when (this) { + is Known -> value + is Raw, Missing, Null -> null + } + + /** + * Folds this four-state field into a value of type [R] by applying the matching lambda. + * + * @param onMissing Invoked when this is [Missing]. + * @param onNull Invoked when this is [Null]. + * @param onKnown Invoked when this is [Known]; receives [Known.value]. + * @param onRaw Invoked when this is [Raw]; receives the [RawJson] tree. + * @return The result of the selected lambda. + */ + public inline fun fold( + onMissing: () -> R, + onNull: () -> R, + onKnown: (T) -> R, + onRaw: (RawJson) -> R, + ): R = + when (this) { + Missing -> onMissing() + Null -> onNull() + is Known -> onKnown(value) + is Raw -> onRaw(json) + } + + /** + * Collapses this four-state field onto the three-state [Tristate]. **Lossy for [Raw]**: a [Raw] + * node becomes `Tristate.Present(json)` (the [RawJson] survives, the "wrong type" signal does + * not). See the class KDoc on the read-path vs. PATCH-path boundary before using this. + */ + public fun toTristate(): Tristate = + when (this) { + Missing -> Tristate.Absent + Null -> Tristate.Null + is Known<*> -> Tristate.Present(value) + is Raw -> Tristate.Present(json) + } + + public companion object { + /** Returns [Missing]. Convenience factory for Java callers / generic call-sites. */ + @JvmStatic + public fun missing(): JsonField = Missing + + /** Returns [Null]. Convenience factory for Java callers / generic call-sites. */ + @JvmStatic + @JvmName("nullValue") + public fun nullValue(): JsonField = Null + + /** + * Wraps a non-null [value] in a [Known]. Bounded `T : Any`: a `Known(null)` would be an + * illegal state indistinguishable from [Null]. Use [Null] or [ofNullable] for nullable + * values. + */ + @JvmStatic + public fun known(value: T): JsonField = Known(value) + + /** Wraps a [RawJson] tree in a [Raw]. */ + @JvmStatic + public fun raw(json: RawJson): JsonField = Raw(json) + + /** + * Maps a nullable [value] to either [Known] (non-null) or [Null] (null). Cannot produce + * [Missing] (a value passed in has been "seen") nor [Raw] (a typed value is by definition + * well-shaped). + */ + @JvmStatic + public fun ofNullable(value: T?): JsonField = if (value == null) Null else Known(value) + + /** + * Lifts a [Tristate] into the matching `JsonField`, for the PATCH → read-model direction. + * Never produces [Raw]: [Tristate.Absent] → [Missing], [Tristate.Null] → [Null], + * [Tristate.Present] → [Known]. + */ + @JvmStatic + public fun fromTristate(tristate: Tristate): JsonField = + when (tristate) { + Tristate.Absent -> Missing + Tristate.Null -> Null + is Tristate.Present -> Known(tristate.value) + } + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/RawJson.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/RawJson.kt new file mode 100644 index 00000000..cdbeae15 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/RawJson.kt @@ -0,0 +1,235 @@ +/* + * 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 + +/** + * A dependency-free, immutable JSON value tree. + * + * `RawJson` is the SDK's library-agnostic representation of an arbitrary JSON value. It lets + * `sdk-core` (and the generated DTOs that target it) carry, store, and round-trip JSON shapes the + * model does not have a typed field for — unknown/forward-compatible properties, "wrong-type" + * payloads that should survive a read-modify-write loop, and the `additionalProperties` bucket of a + * schema — **without** dragging Jackson, Gson, or any other JSON library onto the core surface. + * + * The tree mirrors the JSON grammar exactly with six leaf/branch variants: + * + * - [Obj] — a JSON object, an **insertion-ordered** map of `String` → `RawJson`. + * - [Arr] — a JSON array, an ordered list of `RawJson`. + * - [Str] — a JSON string. + * - [Num] — a JSON number, stored as its **verbatim literal text** so no precision is lost. + * - [Bool] — a JSON `true` / `false`. + * - [Null] — a JSON `null`. + * + * Concrete conversion between this tree and a real parser/generator lives entirely in adapter + * modules (e.g. `sdk-serde-jackson`'s `JsonFieldModule`), exactly as [Tristate] keeps its Jackson + * wiring out of core. `sdk-core` only exposes the value algebra so callers can construct, inspect, + * and pattern-match a JSON value with zero third-party types. + * + * ### Number fidelity + * + * [Num] keeps the original numeric literal as text ([Num.literal]) rather than coercing to a + * `Double` or `BigDecimal` up front. This is deliberate: a `Double` round-trip would corrupt large + * `long` ids and high-precision decimals, the single most common lossy-JSON bug in client SDKs. + * Typed accessors ([Num.toBigDecimal], [Num.toLongOrNull], …) parse on demand. + * + * Instances are deeply immutable; [Obj] and [Arr] defensively copy their inputs and expose + * read-only views. Equality is structural throughout. + */ +public sealed class RawJson { + /** A JSON object: an insertion-ordered mapping of member name to value. */ + public class Obj private constructor( + private val members: Map, + ) : RawJson() { + /** The object members as an insertion-ordered, read-only map. */ + public val entries: Map + get() = members + + /** The member names, in insertion order. */ + public val keys: Set + get() = members.keys + + /** Returns the value for [key], or `null` if the object has no such member. */ + public operator fun get(key: String): RawJson? = members[key] + + /** Returns `true` if the object has a member named [key]. */ + public operator fun contains(key: String): Boolean = members.containsKey(key) + + /** Returns `true` if the object has no members. */ + public fun isEmpty(): Boolean = members.isEmpty() + + /** The number of members. */ + public val size: Int + get() = members.size + + override fun equals(other: Any?): Boolean = other is Obj && other.members == members + + override fun hashCode(): Int = members.hashCode() + + override fun toString(): String = + members.entries.joinToString(prefix = "{", postfix = "}") { (k, v) -> "${quote(k)}:$v" } + + public companion object { + /** An empty JSON object singleton. */ + @JvmField + public val EMPTY: Obj = Obj(emptyMap()) + + /** + * Builds an [Obj] from [members], preserving the iteration order of the supplied map. + * The input is defensively copied into an insertion-ordered map, so later mutation of + * the caller's collection cannot leak into the immutable tree. + */ + @JvmStatic + public fun of(members: Map): Obj = + if (members.isEmpty()) EMPTY else Obj(LinkedHashMap(members)) + } + } + + /** A JSON array: an ordered list of values. */ + public class Arr private constructor( + private val elements: List, + ) : RawJson() { + /** The array elements as a read-only list. */ + public val values: List + get() = elements + + /** Returns the element at [index]. Throws [IndexOutOfBoundsException] if out of range. */ + public operator fun get(index: Int): RawJson = elements[index] + + /** Returns `true` if the array has no elements. */ + public fun isEmpty(): Boolean = elements.isEmpty() + + /** The number of elements. */ + public val size: Int + get() = elements.size + + override fun equals(other: Any?): Boolean = other is Arr && other.elements == elements + + override fun hashCode(): Int = elements.hashCode() + + override fun toString(): String = elements.joinToString(prefix = "[", postfix = "]") + + public companion object { + /** An empty JSON array singleton. */ + @JvmField + public val EMPTY: Arr = Arr(emptyList()) + + /** Builds an [Arr] from [elements], defensively copied so the tree stays immutable. */ + @JvmStatic + public fun of(elements: List): Arr = if (elements.isEmpty()) EMPTY else Arr(ArrayList(elements)) + } + } + + /** A JSON string value. */ + public data class Str(public val value: String) : RawJson() { + override fun toString(): String = quote(value) + } + + /** + * A JSON number, stored as its verbatim source [literal] to preserve full precision. + * + * Typed accessors parse the literal on demand; they return `null` (rather than throwing) when + * the literal does not fit the requested type, so callers can probe a representation without a + * try/catch. + */ + public data class Num(public val literal: String) : RawJson() { + /** Parses the literal as a [java.math.BigDecimal] — the lossless numeric view. */ + public fun toBigDecimal(): java.math.BigDecimal = java.math.BigDecimal(literal) + + /** Parses the literal as a [Double]. Always succeeds (may be `Infinity` for huge values). */ + public fun toDouble(): Double = literal.toDouble() + + /** Parses the literal as a [Long], or `null` if it is not an integer in `Long` range. */ + public fun toLongOrNull(): Long? = literal.toLongOrNull() + + /** Parses the literal as an [Int], or `null` if it is not an integer in `Int` range. */ + public fun toIntOrNull(): Int? = literal.toIntOrNull() + + override fun toString(): String = literal + + public companion object { + /** Builds a [Num] from a [Long], using its canonical decimal rendering. */ + @JvmStatic + public fun of(value: Long): Num = Num(value.toString()) + + /** Builds a [Num] from an [Int], using its canonical decimal rendering. */ + @JvmStatic + public fun of(value: Int): Num = Num(value.toString()) + + /** Builds a [Num] from a [java.math.BigDecimal], using its canonical text. */ + @JvmStatic + public fun of(value: java.math.BigDecimal): Num = Num(value.toPlainString()) + } + } + + /** A JSON boolean value. */ + public data class Bool(public val value: Boolean) : RawJson() { + override fun toString(): String = value.toString() + + public companion object { + /** The JSON `true` singleton. */ + @JvmField + public val TRUE: Bool = Bool(true) + + /** The JSON `false` singleton. */ + @JvmField + public val FALSE: Bool = Bool(false) + + /** Returns [TRUE] or [FALSE] for [value]. */ + @JvmStatic + public fun of(value: Boolean): Bool = if (value) TRUE else FALSE + } + } + + /** The JSON `null` literal. */ + public object Null : RawJson() { + override fun toString(): String = "null" + } + + /** Returns `true` if this node is the JSON [Null] literal. */ + public val isNull: Boolean + get() = this === Null + + public companion object { + /** Radix for the `\uXXXX` escape's hexadecimal digits. */ + private const val HEX_RADIX = 16 + + /** A `\uXXXX` escape is always four hex digits, zero-padded. */ + private const val UNICODE_ESCAPE_DIGITS = 4 + + /** + * Minimal RFC 8259 string quoting for [toString] previews. This is a *rendering* helper for + * diagnostics, not a serializer — real emission is the adapter's job. It escapes the + * mandatory control set so a preview is always valid-looking JSON. + */ + private fun quote(raw: String): String { + val sb = StringBuilder(raw.length + 2) + sb.append('"') + for (ch in raw) { + when (ch) { + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + '\b' -> sb.append("\\b") + '\u000C' -> sb.append("\\f") + else -> + if (ch < ' ') { + sb + .append("\\u") + .append(ch.code.toString(radix = HEX_RADIX).padStart(UNICODE_ESCAPE_DIGITS, '0')) + } else { + sb.append(ch) + } + } + } + sb.append('"') + return sb.toString() + } + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/AdditionalPropertiesTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/AdditionalPropertiesTest.kt new file mode 100644 index 00000000..dbf05098 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/AdditionalPropertiesTest.kt @@ -0,0 +1,119 @@ +/* + * 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.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class AdditionalPropertiesTest { + @Test + fun `of empty returns the EMPTY singleton`() { + assertSame(AdditionalProperties.EMPTY, AdditionalProperties.of(emptyMap())) + assertSame(AdditionalProperties.EMPTY, AdditionalProperties.empty()) + assertTrue(AdditionalProperties.EMPTY.isEmpty()) + } + + @Test + fun `of preserves insertion order and exposes read-only views`() { + val props = + AdditionalProperties.of( + linkedMapOf( + "z" to RawJson.Num("1"), + "a" to RawJson.Str("two"), + ), + ) + assertEquals(listOf("z", "a"), props.keys.toList()) + assertEquals(2, props.size) + assertEquals(RawJson.Num("1"), props["z"]) + assertTrue("a" in props) + assertFalse("missing" in props) + assertNull(props["missing"]) + } + + @Test + fun `of defensively copies its input`() { + val mutable = linkedMapOf("k" to RawJson.Bool.TRUE) + val props = AdditionalProperties.of(mutable) + mutable["k"] = RawJson.Bool.FALSE + mutable["new"] = RawJson.Null + assertEquals(RawJson.Bool.TRUE, props["k"]) + assertEquals(1, props.size) + } + + @Test + fun `toRawJson renders the captured properties as an Obj`() { + val props = AdditionalProperties.of(linkedMapOf("k" to RawJson.Str("v"))) + assertEquals(RawJson.Obj.of(mapOf("k" to RawJson.Str("v"))), props.toRawJson()) + } + + @Test + fun `builder preserves put order and builds an immutable snapshot`() { + val props = + AdditionalProperties + .builder() + .put("first", RawJson.Num("1")) + .put("second", RawJson.Num("2")) + .build() + assertEquals(listOf("first", "second"), props.keys.toList()) + assertEquals(RawJson.Num("2"), props["second"]) + } + + @Test + fun `builder putAll remove and clear behave as expected`() { + val built = + AdditionalProperties + .builder() + .putAll(linkedMapOf("a" to RawJson.Null, "b" to RawJson.Null, "c" to RawJson.Null)) + .remove("b") + .build() + assertEquals(listOf("a", "c"), built.keys.toList()) + + val cleared = built.newBuilder().clear().build() + assertSame(AdditionalProperties.EMPTY, cleared) + } + + @Test + fun `newBuilder is prefilled and supports a derived modified copy`() { + val original = AdditionalProperties.of(linkedMapOf("a" to RawJson.Str("1"))) + val derived = original.newBuilder().put("b", RawJson.Str("2")).build() + // Original is unchanged (immutability) and the derived copy carries both keys in order. + assertEquals(listOf("a"), original.keys.toList()) + assertEquals(listOf("a", "b"), derived.keys.toList()) + } + + @Test + fun `put replaces an existing key without changing its position`() { + val props = + AdditionalProperties + .builder() + .put("a", RawJson.Num("1")) + .put("b", RawJson.Num("2")) + .put("a", RawJson.Num("9")) + .build() + assertEquals(listOf("a", "b"), props.keys.toList()) + assertEquals(RawJson.Num("9"), props["a"]) + } + + @Test + fun `equality is structural`() { + val a = AdditionalProperties.of(mapOf("k" to RawJson.Num("1"))) + val b = AdditionalProperties.of(mapOf("k" to RawJson.Num("1"))) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun `toString renders the captured properties`() { + val props = AdditionalProperties.of(linkedMapOf("k" to RawJson.Str("v"))) + assertEquals("""AdditionalProperties({"k":"v"})""", props.toString()) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/JsonFieldTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/JsonFieldTest.kt new file mode 100644 index 00000000..2be9d990 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/JsonFieldTest.kt @@ -0,0 +1,134 @@ +/* + * 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.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class JsonFieldTest { + // ----- Sentinel toString ----- + + @Test + fun `Missing toString is stable and identity-free`() { + assertEquals("Missing", JsonField.Missing.toString()) + assertFalse(JsonField.Missing.toString().contains("@")) + } + + @Test + fun `Null toString is stable and identity-free`() { + assertEquals("Null", JsonField.Null.toString()) + assertFalse(JsonField.Null.toString().contains("@")) + } + + @Test + fun `Known toString renders the data-class form`() { + assertEquals("Known(value=hi)", JsonField.Known("hi").toString()) + } + + // ----- predicates ----- + + @Test + fun `predicates report the active variant`() { + assertTrue(JsonField.Missing.isMissing) + assertTrue(JsonField.Null.isNull) + assertTrue(JsonField.Known(1).isKnown) + assertTrue(JsonField.Raw(RawJson.Str("x")).isRaw) + + val known: JsonField = JsonField.Known(1) + assertFalse(known.isMissing) + assertFalse(known.isNull) + assertFalse(known.isRaw) + } + + // ----- factories ----- + + @Test + fun `factories return the expected variants`() { + assertSame(JsonField.Missing, JsonField.missing()) + assertSame(JsonField.Null, JsonField.nullValue()) + assertEquals(JsonField.Known("v"), JsonField.known("v")) + assertEquals(JsonField.Raw(RawJson.Null), JsonField.raw(RawJson.Null)) + } + + @Test + fun `ofNullable routes null to Null and non-null to Known`() { + assertSame(JsonField.Null, JsonField.ofNullable(null)) + assertEquals(JsonField.Known("x"), JsonField.ofNullable("x")) + } + + @Test + fun `Known is never equal to Null or Missing or Raw`() { + val known: JsonField = JsonField.known("anything") + assertFalse(known == JsonField.Null) + assertFalse(known == JsonField.Missing) + assertFalse(known == JsonField.Raw(RawJson.Str("anything"))) + } + + // ----- getOrNull ----- + + @Test + fun `getOrNull yields value for Known and null for every other variant`() { + assertEquals("v", JsonField.known("v").getOrNull()) + assertEquals(null, JsonField.Null.getOrNull()) + assertEquals(null, JsonField.Missing.getOrNull()) + assertEquals(null, JsonField.Raw(RawJson.Str("v")).getOrNull()) + } + + // ----- fold ----- + + @Test + fun `fold dispatches to the matching branch`() { + fun label(f: JsonField): String = + f.fold( + onMissing = { "missing" }, + onNull = { "null" }, + onKnown = { "known:$it" }, + onRaw = { "raw:$it" }, + ) + assertEquals("missing", label(JsonField.Missing)) + assertEquals("null", label(JsonField.Null)) + assertEquals("known:7", label(JsonField.known(7))) + assertEquals("raw:\"x\"", label(JsonField.Raw(RawJson.Str("x")))) + } + + // ----- Tristate interop ----- + + @Test + fun `fromTristate lifts the three classic states and never produces Raw`() { + assertSame(JsonField.Missing, JsonField.fromTristate(Tristate.absent())) + assertSame(JsonField.Null, JsonField.fromTristate(Tristate.nullValue())) + assertEquals(JsonField.Known("v"), JsonField.fromTristate(Tristate.present("v"))) + } + + @Test + fun `toTristate maps Missing Null and Known directly`() { + assertSame(Tristate.Absent, JsonField.Missing.toTristate()) + assertSame(Tristate.Null, JsonField.Null.toTristate()) + assertEquals(Tristate.Present("v"), JsonField.known("v").toTristate()) + } + + @Test + fun `toTristate is lossy for Raw and folds it into Present of the RawJson`() { + val raw = RawJson.Arr.of(listOf(RawJson.Num("1"))) + val tristate = JsonField.Raw(raw).toTristate() + assertEquals(Tristate.Present(raw), tristate) + } + + @Test + fun `round-tripping a clean Tristate through JsonField is lossless`() { + val states: List> = + listOf(Tristate.absent(), Tristate.nullValue(), Tristate.present("v")) + for (state in states) { + val back = JsonField.fromTristate(state).toTristate() + assertEquals(state, back, "Round-trip changed $state") + } + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/RawJsonTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/RawJsonTest.kt new file mode 100644 index 00000000..d96d26f3 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/RawJsonTest.kt @@ -0,0 +1,140 @@ +/* + * 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 java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class RawJsonTest { + // ----- Obj ----- + + @Test + fun `Obj preserves insertion order and exposes read-only views`() { + val obj = + RawJson.Obj.of( + linkedMapOf( + "b" to RawJson.Str("1"), + "a" to RawJson.Str("2"), + ), + ) + assertEquals(listOf("b", "a"), obj.keys.toList()) + assertEquals(2, obj.size) + assertEquals(RawJson.Str("1"), obj["b"]) + assertTrue("a" in obj) + assertFalse("z" in obj) + assertNull(obj["z"]) + } + + @Test + fun `Obj of empty returns the EMPTY singleton`() { + assertSame(RawJson.Obj.EMPTY, RawJson.Obj.of(emptyMap())) + assertTrue(RawJson.Obj.EMPTY.isEmpty()) + } + + @Test + fun `Obj defensively copies its input`() { + val mutable = linkedMapOf("k" to RawJson.Bool.TRUE) + val obj = RawJson.Obj.of(mutable) + mutable["k"] = RawJson.Bool.FALSE + mutable["new"] = RawJson.Null + assertEquals(RawJson.Bool.TRUE, obj["k"]) + assertEquals(1, obj.size) + } + + @Test + fun `Obj equality is structural`() { + val a = RawJson.Obj.of(mapOf("k" to RawJson.Num("1"))) + val b = RawJson.Obj.of(mapOf("k" to RawJson.Num("1"))) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + // ----- Arr ----- + + @Test + fun `Arr exposes ordered read-only values`() { + val arr = RawJson.Arr.of(listOf(RawJson.Num("1"), RawJson.Num("2"))) + assertEquals(2, arr.size) + assertEquals(RawJson.Num("2"), arr[1]) + assertEquals(listOf(RawJson.Num("1"), RawJson.Num("2")), arr.values) + } + + @Test + fun `Arr of empty returns the EMPTY singleton`() { + assertSame(RawJson.Arr.EMPTY, RawJson.Arr.of(emptyList())) + assertTrue(RawJson.Arr.EMPTY.isEmpty()) + } + + @Test + fun `Arr defensively copies its input`() { + val mutable = mutableListOf(RawJson.Str("x")) + val arr = RawJson.Arr.of(mutable) + mutable.add(RawJson.Str("y")) + assertEquals(1, arr.size) + } + + // ----- Num fidelity ----- + + @Test + fun `Num preserves a large long id without precision loss`() { + val literal = "9007199254740993" // 2^53 + 1, lossy as a Double + val num = RawJson.Num(literal) + assertEquals(literal, num.literal) + assertEquals(9007199254740993L, num.toLongOrNull()) + assertEquals(BigDecimal(literal), num.toBigDecimal()) + } + + @Test + fun `Num typed accessors return null for out-of-range or non-integer literals`() { + assertNull(RawJson.Num("1.5").toLongOrNull()) + assertNull(RawJson.Num("1.5").toIntOrNull()) + assertNull(RawJson.Num("99999999999999999999").toLongOrNull()) + assertEquals(1.5, RawJson.Num("1.5").toDouble()) + } + + @Test + fun `Num factories render canonical literals`() { + assertEquals(RawJson.Num("42"), RawJson.Num.of(42)) + assertEquals(RawJson.Num("42"), RawJson.Num.of(42L)) + assertEquals(RawJson.Num("1.50"), RawJson.Num.of(BigDecimal("1.50"))) + } + + // ----- Bool / Null ----- + + @Test + fun `Bool of returns the matching singleton`() { + assertSame(RawJson.Bool.TRUE, RawJson.Bool.of(true)) + assertSame(RawJson.Bool.FALSE, RawJson.Bool.of(false)) + } + + @Test + fun `Null is a singleton and reports isNull`() { + assertTrue(RawJson.Null.isNull) + assertFalse(RawJson.Str("x").isNull) + assertEquals("null", RawJson.Null.toString()) + } + + // ----- toString rendering ----- + + @Test + fun `toString renders a JSON-shaped preview with escaping`() { + val tree = + RawJson.Obj.of( + linkedMapOf( + "name" to RawJson.Str("a\"b\nc"), + "list" to RawJson.Arr.of(listOf(RawJson.Num("1"), RawJson.Bool.TRUE, RawJson.Null)), + ), + ) + assertEquals("""{"name":"a\"b\nc", "list":[1, true, null]}""", tree.toString()) + } +} diff --git a/sdk-serde-jackson/api/sdk-serde-jackson.api b/sdk-serde-jackson/api/sdk-serde-jackson.api index f14795d4..c376b426 100644 --- a/sdk-serde-jackson/api/sdk-serde-jackson.api +++ b/sdk-serde-jackson/api/sdk-serde-jackson.api @@ -21,6 +21,15 @@ public final class org/dexpace/sdk/serde/jackson/JacksonSerde$Companion { public final fun withDefaults ()Lorg/dexpace/sdk/serde/jackson/JacksonSerde; } +public final class org/dexpace/sdk/serde/jackson/JsonFieldModule : com/fasterxml/jackson/databind/module/SimpleModule { + public static final field Companion Lorg/dexpace/sdk/serde/jackson/JsonFieldModule$Companion; + public fun ()V + public fun setupModule (Lcom/fasterxml/jackson/databind/Module$SetupContext;)V +} + +public final class org/dexpace/sdk/serde/jackson/JsonFieldModule$Companion { +} + public final class org/dexpace/sdk/serde/jackson/JsonResponseHandlerKt { public static final fun jsonHandler (Lorg/dexpace/sdk/core/serde/Serde;Ljava/lang/Class;)Lorg/dexpace/sdk/core/http/response/ResponseHandler; public static final fun jsonHandler (Lorg/dexpace/sdk/serde/jackson/JacksonSerde;Lcom/fasterxml/jackson/core/type/TypeReference;)Lorg/dexpace/sdk/core/http/response/ResponseHandler; 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 10dd30ac..03c619fb 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 @@ -48,6 +48,7 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule * - [JavaTimeModule] — `java.time.*` (Instant, OffsetDateTime, LocalDate, ...). * - [Jdk8Module] — `Optional`, `OptionalInt/Long/Double`, parameter-name introspection. * - [TristateModule] — `Tristate` (Absent / Null / Present) ser/de. + * - [JsonFieldModule] — `JsonField` (Missing / Null / Known / Raw) + `RawJson` ser/de. * * Each call to [defaultObjectMapper] returns a **fresh** mapper instance; sharing or mutating a * cached singleton would be a foot-gun (mappers carry mutable caches that interact poorly with @@ -71,6 +72,7 @@ public object JacksonObjectMappers { .addModule(JavaTimeModule()) .addModule(Jdk8Module()) .addModule(TristateModule()) + .addModule(JsonFieldModule()) .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS) applyStrictScalarCoercion(builder) val mapper = builder.build() diff --git a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JsonFieldModule.kt b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JsonFieldModule.kt new file mode 100644 index 00000000..5fc5fdcc --- /dev/null +++ b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JsonFieldModule.kt @@ -0,0 +1,331 @@ +/* + * 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.serde.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.BeanDescription +import com.fasterxml.jackson.databind.BeanProperty +import com.fasterxml.jackson.databind.DeserializationConfig +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializationConfig +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.deser.ContextualDeserializer +import com.fasterxml.jackson.databind.deser.Deserializers +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.module.SimpleSerializers +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier +import com.fasterxml.jackson.databind.ser.ContextualSerializer +import com.fasterxml.jackson.databind.type.TypeFactory +import org.dexpace.sdk.core.serde.JsonField +import org.dexpace.sdk.core.serde.RawJson + +/** + * Jackson module wiring [JsonField] and [RawJson] ser/de, plus the conversion between the + * dependency-free [RawJson] tree (defined in `sdk-core`) and Jackson's own node model. + * + * This mirrors `TristateModule` exactly: `sdk-core` owns the pure value types ([JsonField], + * [RawJson]); all Jackson coupling lives here in the adapter. + * + * ## JsonField's four states on the wire + * + * - [JsonField.Missing] ⟷ the key is missing from the JSON object entirely. + * - [JsonField.Null] ⟷ the key is present with a JSON `null`. + * - [JsonField.Known] ⟷ the key is present and the value bound cleanly to `T`. + * - [JsonField.Raw] ⟷ the key is present but the value did **not** bind to `T`; the original + * JSON is captured as a [RawJson] tree and re-emitted verbatim. + * + * The [JsonFieldDeserializer] first reads the value into an untyped [JsonNode], then attempts to + * convert that node into `T`. On success it produces [JsonField.Known]; on a binding failure + * (`JsonMappingException` / `IllegalArgumentException`) it falls back to [JsonField.Raw], so a + * shape the model did not anticipate is preserved rather than fatal. Missing keys default to + * [JsonField.Missing] (generated DTOs should default the field to it), and explicit null routes + * through `getNullValue` to [JsonField.Null]. + * + * Serialization mirrors `TristateModule`: [JsonFieldPropertyWriter] omits the property entirely + * for [JsonField.Missing]; the serializer writes Null as JSON `null`, Known through the parent + * mapper, and Raw by emitting the captured [RawJson] tree. + */ +public class JsonFieldModule : + SimpleModule(MODULE_NAME, com.fasterxml.jackson.core.Version(1, 0, 0, null, null, null)) { + public companion object { + private const val MODULE_NAME = "JsonFieldModule" + } + + override fun setupModule(context: SetupContext) { + super.setupModule(context) + context.addDeserializers(JsonFieldDeserializers()) + context.addSerializers( + SimpleSerializers().apply { + addSerializer(JsonField::class.java, JsonFieldSerializer()) + addSerializer(RawJson::class.java, RawJsonSerializer()) + }, + ) + context.addBeanSerializerModifier(JsonFieldSerializerModifier()) + } +} + +/** + * Resolver that returns a [JsonFieldDeserializer] for any [JsonField] target type, threading the + * concrete `T` through from the parametric [JavaType] (the same trick `TristateModule` uses). + */ +internal class JsonFieldDeserializers internal constructor() : Deserializers.Base() { + override fun findBeanDeserializer( + type: JavaType, + config: DeserializationConfig, + beanDesc: BeanDescription, + ): JsonDeserializer<*>? = + if (JsonField::class.java.isAssignableFrom(type.rawClass)) { + val inner: JavaType = + if (type.containedTypeCount() > 0) { + type.containedType(0) + } else { + TypeFactory.defaultInstance().constructType(Any::class.java) + } + JsonFieldDeserializer(inner) + } else { + null + } +} + +internal class JsonFieldDeserializer internal constructor( + private val innerType: JavaType?, +) : JsonDeserializer>(), ContextualDeserializer { + override fun createContextual( + ctxt: DeserializationContext, + property: BeanProperty?, + ): JsonDeserializer<*> { + val resolved: JavaType = + property?.type?.takeIf { JsonField::class.java.isAssignableFrom(it.rawClass) }?.let { wrapper -> + if (wrapper.containedTypeCount() > 0) wrapper.containedType(0) else null + } ?: innerType ?: TypeFactory.defaultInstance().constructType(Any::class.java) + return JsonFieldDeserializer(resolved) + } + + override fun deserialize( + p: JsonParser, + ctxt: DeserializationContext, + ): JsonField<*> { + if (p.currentToken() == JsonToken.VALUE_NULL) { + return JsonField.Null + } + // Read the value into a structure-preserving node first, then try to bind it to T. A bind + // failure must not be fatal: we park the original JSON as Raw so it round-trips. + val node: JsonNode = ctxt.readTree(p) + if (node.isNull) { + return JsonField.Null + } + val target = innerType + if (target == null || target.rawClass == Any::class.java) { + // No concrete target type: treat the node as Raw so nothing is lost or coerced. + return JsonField.Raw(RawJsonConversions.fromNode(node)) + } + return bindOrRaw(node, target, ctxt) + } + + private fun bindOrRaw( + node: JsonNode, + target: JavaType, + ctxt: DeserializationContext, + ): JsonField<*> = + try { + val value: Any? = ctxt.readTreeAsValue(node, target) + if (value == null) JsonField.Null else JsonField.Known(value) + } catch (_: com.fasterxml.jackson.databind.JsonMappingException) { + JsonField.Raw(RawJsonConversions.fromNode(node)) + } catch (_: IllegalArgumentException) { + JsonField.Raw(RawJsonConversions.fromNode(node)) + } + + /** Fires when the key is present with an explicit null. */ + override fun getNullValue(ctxt: DeserializationContext): JsonField<*> = JsonField.Null + + /** Fires when a missing key falls through to "absent" via property defaults. */ + override fun getEmptyValue(ctxt: DeserializationContext): JsonField<*> = JsonField.Missing +} + +/** + * Writes [JsonField.Null] as JSON `null`, [JsonField.Known] through the parent mapper, and + * [JsonField.Raw] by emitting its captured [RawJson]. [JsonField.Missing] is short-circuited by + * [JsonFieldPropertyWriter] in the bean-property path; at the top level (no wrapping bean) it is + * written as `null`, since JSON has no "missing" concept without an enclosing object. + */ +internal class JsonFieldSerializer internal constructor() : + JsonSerializer>(), ContextualSerializer { + override fun createContextual( + prov: SerializerProvider, + property: BeanProperty?, + ): JsonSerializer<*> = this + + override fun serialize( + value: JsonField<*>, + gen: JsonGenerator, + serializers: SerializerProvider, + ) { + when (value) { + JsonField.Missing, JsonField.Null -> gen.writeNull() + is JsonField.Known<*> -> serializers.defaultSerializeValue(value.value, gen) + is JsonField.Raw -> RawJsonConversions.write(value.json, gen) + } + } + + /** Honour `@JsonInclude(NON_EMPTY)` — a Missing field should be omitted. */ + override fun isEmpty( + provider: SerializerProvider?, + value: JsonField<*>?, + ): Boolean = value == null || value is JsonField.Missing + } + +/** + * Serializer for a bare [RawJson] tree (e.g. an `additionalProperties` bucket exposed as + * `RawJson`), so it emits as real JSON rather than via reflection. + */ +internal class RawJsonSerializer internal constructor() : JsonSerializer() { + override fun serialize( + value: RawJson, + gen: JsonGenerator, + serializers: SerializerProvider, + ) { + RawJsonConversions.write(value, gen) + } +} + +/** + * Rewrites every [JsonField]-typed bean property to use [JsonFieldPropertyWriter], which skips the + * field for [JsonField.Missing] (so Missing never collapses into Null on the wire). + */ +internal class JsonFieldSerializerModifier internal constructor() : BeanSerializerModifier() { + override fun changeProperties( + config: SerializationConfig, + beanDesc: BeanDescription, + beanProperties: MutableList, + ): MutableList { + for (i in beanProperties.indices) { + val writer = beanProperties[i] + if (JsonField::class.java.isAssignableFrom(writer.type.rawClass)) { + beanProperties[i] = JsonFieldPropertyWriter(writer) + } + } + return beanProperties + } +} + +/** + * A [BeanPropertyWriter] that omits the property entirely when its value is [JsonField.Missing], + * delegating to the standard writer for every other variant. + */ +internal class JsonFieldPropertyWriter internal constructor( + base: BeanPropertyWriter, +) : BeanPropertyWriter(base) { + override fun serializeAsField( + bean: Any, + gen: JsonGenerator, + prov: SerializerProvider, + ) { + if (get(bean) is JsonField.Missing) { + return + } + super.serializeAsField(bean, gen, prov) + } + + override fun serializeAsElement( + bean: Any, + gen: JsonGenerator, + prov: SerializerProvider, + ) { + if (get(bean) is JsonField.Missing) { + // Arrays cannot truly skip an element; emit null as the least-surprising fallback. + prov.defaultSerializeNull(gen) + return + } + super.serializeAsElement(bean, gen, prov) + } +} + +/** + * Bidirectional conversion between the dependency-free [RawJson] tree and Jackson's wire model. + * + * Reading goes through [JsonNode] (structure-preserving, no coercion); writing streams straight to a + * [JsonGenerator]. Numbers are carried as their verbatim text via [JsonGenerator.writeNumber] so a + * large `long` id or high-precision decimal round-trips losslessly. + */ +internal object RawJsonConversions { + fun fromNode(node: JsonNode): RawJson = + when { + node.isNull || node.isMissingNode -> RawJson.Null + node.isObject -> objFromNode(node as ObjectNode) + node.isArray -> arrFromNode(node as ArrayNode) + node.isTextual -> RawJson.Str(node.textValue()) + node.isBoolean -> RawJson.Bool.of(node.booleanValue()) + node.isNumber -> RawJson.Num(node.asText()) + else -> RawJson.Str(node.asText()) + } + + private fun objFromNode(node: ObjectNode): RawJson.Obj { + val members = LinkedHashMap(node.size()) + val fields = node.fields() + while (fields.hasNext()) { + val (key, value) = fields.next() + members[key] = fromNode(value) + } + return RawJson.Obj.of(members) + } + + private fun arrFromNode(node: ArrayNode): RawJson.Arr { + val elements = ArrayList(node.size()) + for (element in node) { + elements.add(fromNode(element)) + } + return RawJson.Arr.of(elements) + } + + fun write( + value: RawJson, + gen: JsonGenerator, + ) { + when (value) { + is RawJson.Null -> gen.writeNull() + is RawJson.Str -> gen.writeString(value.value) + is RawJson.Bool -> gen.writeBoolean(value.value) + is RawJson.Num -> gen.writeNumber(value.literal) + is RawJson.Arr -> writeArr(value, gen) + is RawJson.Obj -> writeObj(value, gen) + } + } + + private fun writeObj( + value: RawJson.Obj, + gen: JsonGenerator, + ) { + gen.writeStartObject() + for ((key, member) in value.entries) { + gen.writeFieldName(key) + write(member, gen) + } + gen.writeEndObject() + } + + private fun writeArr( + value: RawJson.Arr, + gen: JsonGenerator, + ) { + gen.writeStartArray() + for (element in value.values) { + write(element, gen) + } + gen.writeEndArray() + } +} diff --git a/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/AdditionalPropertiesPassThroughTest.kt b/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/AdditionalPropertiesPassThroughTest.kt new file mode 100644 index 00000000..bbc21272 --- /dev/null +++ b/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/AdditionalPropertiesPassThroughTest.kt @@ -0,0 +1,104 @@ +/* + * 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.serde.jackson + +import com.fasterxml.jackson.annotation.JsonAnyGetter +import com.fasterxml.jackson.annotation.JsonAnySetter +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.dexpace.sdk.core.serde.AdditionalProperties +import org.dexpace.sdk.core.serde.RawJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Demonstrates the `additionalProperties` pass-through primitive (issue #52): a model with one + * typed field captures every *unknown* key into an [AdditionalProperties] snapshot at + * deserialization and re-emits it on serialization, so a read-modify-write loop never silently + * drops a server-added field. + * + * The DTO below is hand-written but is exactly the shape a generator would emit: a Jackson + * `@JsonAnySetter` funnels unknown keys (as [RawJson] via the [RawJson] converter) into a builder, + * and a `@JsonAnyGetter` re-exposes them. The snapshot itself lives in `sdk-core` with no Jackson + * dependency. + */ +class AdditionalPropertiesPassThroughTest { + /** + * A model with one known field (`name`) and an [AdditionalProperties] bucket for everything else. + * The any-setter captures unknown keys; the any-getter re-emits them. The bucket is built + * lazily from a mutable builder while Jackson populates the bean, then frozen via [build]. + */ + class Model { + var name: String? = null + + @field:JsonIgnore + private val extras: AdditionalProperties.Builder = AdditionalProperties.builder() + + @JsonAnySetter + fun capture( + key: String, + value: JsonNode, + ) { + extras.put(key, RawJsonConversions.fromNode(value)) + } + + @get:JsonAnyGetter + val additionalProperties: Map + get() = build().entries + + @JsonIgnore + fun build(): AdditionalProperties = extras.build() + } + + private fun mapper(): ObjectMapper = JacksonObjectMappers.defaultObjectMapper() + + @Test + fun `unknown fields are captured into an AdditionalProperties snapshot`() { + val m = mapper() + val model: Model = + m.readValue( + """{"name":"widget","count":7,"tags":["a","b"],"nested":{"k":true}}""", + ) + assertEquals("widget", model.name) + + val extras = model.build() + assertEquals(setOf("count", "tags", "nested"), extras.keys) + assertEquals(RawJson.Num("7"), extras["count"]) + assertEquals(RawJson.Arr.of(listOf(RawJson.Str("a"), RawJson.Str("b"))), extras["tags"]) + assertTrue(extras["nested"] is RawJson.Obj) + } + + @Test + fun `unknown fields survive a read-modify-write round-trip losslessly`() { + val m = mapper() + val source = """{"name":"widget","serverAddedId":9007199254740993,"flag":true}""" + val model: Model = m.readValue(source) + + // Modify a known field; leave the unknown ones untouched. + model.name = "renamed" + + val written = m.writeValueAsString(model) + // The server-added fields — including the large id that a Double round-trip would corrupt — + // are preserved verbatim, and the modified known field is reflected. + val reparsed: Map = m.readValue(written) + assertEquals("renamed", reparsed["name"]) + assertEquals(9007199254740993L, reparsed["serverAddedId"]) + assertEquals(true, reparsed["flag"]) + } + + @Test + fun `a model with no unknown fields emits nothing extra`() { + val m = mapper() + val model: Model = m.readValue("""{"name":"only"}""") + assertTrue(model.build().isEmpty()) + assertEquals("""{"name":"only"}""", m.writeValueAsString(model)) + } +} diff --git a/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JsonFieldModuleTest.kt b/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JsonFieldModuleTest.kt new file mode 100644 index 00000000..b35bc583 --- /dev/null +++ b/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JsonFieldModuleTest.kt @@ -0,0 +1,157 @@ +/* + * 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.serde.jackson + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.dexpace.sdk.core.serde.JsonField +import org.dexpace.sdk.core.serde.RawJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JsonFieldModuleTest { + data class Holder( + val x: JsonField = JsonField.Missing, + ) + + data class IntHolder(val n: JsonField = JsonField.Missing) + + data class Inner(val k: String) + + data class NestedHolder(val inner: JsonField = JsonField.Missing) + + private fun mapper(): ObjectMapper = JacksonObjectMappers.defaultObjectMapper() + + // ----- Deserialization: the four states ----- + + @Test + fun `empty object deserializes the field as Missing`() { + val parsed: Holder = mapper().readValue("""{}""") + assertTrue(parsed.x is JsonField.Missing, "Expected Missing, got ${parsed.x}") + assertTrue(parsed.x.isMissing) + } + + @Test + fun `explicit null deserializes the field as Null`() { + val parsed: Holder = mapper().readValue("""{"x": null}""") + assertTrue(parsed.x is JsonField.Null, "Expected Null, got ${parsed.x}") + } + + @Test + fun `matching value deserializes the field as Known`() { + val parsed: Holder = mapper().readValue("""{"x": "value"}""") + assertEquals(JsonField.Known("value"), parsed.x) + } + + @Test + fun `wrong-typed value deserializes the field as Raw rather than failing`() { + // n is declared JsonField, but the wire sent an object — must not throw, must capture Raw. + val parsed: IntHolder = mapper().readValue("""{"n": {"unexpected": [1, 2]}}""") + val field = parsed.n + assertTrue(field is JsonField.Raw, "Expected Raw, got $field") + val raw = field.json + assertTrue(raw is RawJson.Obj) + val inner = raw["unexpected"] + assertTrue(inner is RawJson.Arr) + } + + @Test + fun `Known with parametric inner type deserializes correctly`() { + val parsed: IntHolder = mapper().readValue("""{"n": 42}""") + assertEquals(JsonField.Known(42), parsed.n) + } + + @Test + fun `Known with nested DTO inner type deserializes correctly`() { + val parsed: NestedHolder = mapper().readValue("""{"inner": {"k": "value"}}""") + assertEquals(JsonField.Known(Inner("value")), parsed.inner) + } + + // ----- Serialization: the four states ----- + + @Test + fun `Missing on serialize omits the field`() { + val json = mapper().writeValueAsString(Holder(x = JsonField.Missing)) + assertEquals("""{}""", json) + assertFalse(json.contains("\"x\"")) + } + + @Test + fun `Null on serialize writes JSON null`() { + val json = mapper().writeValueAsString(Holder(x = JsonField.Null)) + assertEquals("""{"x":null}""", json) + } + + @Test + fun `Known on serialize writes the inner value`() { + val json = mapper().writeValueAsString(Holder(x = JsonField.Known("hello"))) + assertEquals("""{"x":"hello"}""", json) + } + + @Test + fun `Raw on serialize emits the captured JSON verbatim`() { + val raw = + RawJson.Obj.of( + linkedMapOf( + "a" to RawJson.Num("9007199254740993"), + "b" to RawJson.Arr.of(listOf(RawJson.Bool.TRUE, RawJson.Null)), + ), + ) + val json = mapper().writeValueAsString(IntHolder(n = JsonField.Raw(raw))) + assertEquals("""{"n":{"a":9007199254740993,"b":[true,null]}}""", json) + } + + // ----- Round-trips ----- + + @Test + fun `round-trip preserves all four states for a string field`() { + val m = mapper() + val states: List> = + listOf( + JsonField.Missing, + JsonField.Null, + JsonField.Known("v"), + JsonField.Raw(RawJson.Num("12")), + ) + for (state in states) { + val original = Holder(x = state) + val asString = m.writeValueAsString(original) + val back: Holder = m.readValue(asString) + // Note: a Raw whose payload happens to match T binds back to Known on re-read; we test + // the genuinely-unbindable Raw separately. Here Raw(Num) into JsonField stays Raw. + assertEquals(original, back, "Round-trip failed for $state via $asString") + } + } + + @Test + fun `a large long id survives a wrong-type Raw round-trip without precision loss`() { + val m = mapper() + // Field is JsonField; the wire sends a huge number → Raw(Num) preserving the literal. + val parsed: Holder = m.readValue("""{"x": 9007199254740993}""") + assertEquals(JsonField.Raw(RawJson.Num("9007199254740993")), parsed.x) + val reEmitted = m.writeValueAsString(parsed) + assertEquals("""{"x":9007199254740993}""", reEmitted) + } + + // ----- bare RawJson serialization ----- + + @Test + fun `a bare RawJson value serializes to real JSON`() { + val tree = + RawJson.Obj.of( + linkedMapOf( + "s" to RawJson.Str("x"), + "n" to RawJson.Num("1.5"), + "arr" to RawJson.Arr.of(listOf(RawJson.Bool.FALSE)), + ), + ) + assertEquals("""{"s":"x","n":1.5,"arr":[false]}""", mapper().writeValueAsString(tree)) + } +}