From 1baa360015be8cd587eab0650a3aee7a5448ff61 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 05:46:08 +0300 Subject: [PATCH] feat: add forward-compatible enum and union runtime primitives Closed enums and Kotlin sealed unions are a poor fit for evolving HTTP APIs: a new server-side variant crashes older clients on deserialization, and `sealed`/`permits` does not compile to Java-8 bytecode. Add two hand-writable serde primitives that a DTO author (or, later, a generator) can target while staying forward-compatible. OpenEnum is an open value type over a single wire string built around the two-enum pattern: a closed Known enum the build understands and a parallel Value enum with a lint-clean UNKNOWN sentinel. Deserialization never throws; value() is total, known() throws only when a caller demands a recognised variant, and the raw string is always retained so an unknown value round-trips back unchanged. OpenUnion is a private-constructor union with one nullable slot plus typed accessor per variant, visitor dispatch over an ordered arm list, and a retained raw node on every instance. accept() routes the active arm to its visitor case, falling back to Visitor.unknown (throws by default) for an unrecognised variant whose raw shape is still preserved. No sealed/permits, so it is Java-8 safe. Both live in sdk-core/serde with zero new dependencies. --- sdk-core/api/sdk-core.api | 37 +++++ .../org/dexpace/sdk/core/serde/OpenEnum.kt | 128 ++++++++++++++++ .../org/dexpace/sdk/core/serde/OpenUnion.kt | 140 ++++++++++++++++++ .../dexpace/sdk/core/serde/OpenEnumTest.kt | 106 +++++++++++++ .../dexpace/sdk/core/serde/OpenUnionTest.kt | 123 +++++++++++++++ 5 files changed, 534 insertions(+) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenEnum.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenUnion.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/OpenEnumTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/OpenUnionTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..2d424313 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -2273,6 +2273,43 @@ 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/OpenEnum { + protected fun (Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getRawValue ()Ljava/lang/String; + public fun hashCode ()I + public final fun isUnknown ()Z + public final fun known ()Ljava/lang/Enum; + public final fun knownOrNull ()Ljava/lang/Enum; + protected abstract fun knownToValue (Ljava/lang/Enum;)Ljava/lang/Enum; + protected abstract fun resolveKnown (Ljava/lang/String;)Ljava/lang/Enum; + public fun toString ()Ljava/lang/String; + protected abstract fun unknownValue ()Ljava/lang/Enum; + public final fun value ()Ljava/lang/Enum; +} + +public abstract class org/dexpace/sdk/core/serde/OpenUnion { + protected fun (Ljava/lang/Object;)V + public final fun accept (Lorg/dexpace/sdk/core/serde/OpenUnion$Visitor;)Ljava/lang/Object; + protected abstract fun getArms ()Ljava/util/List; + public final fun getRawValue ()Ljava/lang/Object; + public final fun isUnknown ()Z +} + +public final class org/dexpace/sdk/core/serde/OpenUnion$Arm { + public fun (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)V + public final fun dispatch (Lorg/dexpace/sdk/core/serde/OpenUnion$Visitor;Ljava/lang/Object;)Ljava/lang/Object; + public final fun getValue ()Ljava/lang/Object; +} + +public abstract interface class org/dexpace/sdk/core/serde/OpenUnion$Visitor { + public fun unknown (Ljava/lang/Object;)Ljava/lang/Object; +} + +public final class org/dexpace/sdk/core/serde/OpenUnion$Visitor$DefaultImpls { + public static fun unknown (Lorg/dexpace/sdk/core/serde/OpenUnion$Visitor;Ljava/lang/Object;)Ljava/lang/Object; +} + 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/OpenEnum.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenEnum.kt new file mode 100644 index 00000000..cc3abd39 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenEnum.kt @@ -0,0 +1,128 @@ +/* + * 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 + +/** + * Forward-compatible enum primitive: an **open** value type over a single wire string that never + * throws during deserialization, even when the server sends a value this SDK build has never heard + * of. + * + * Closed Kotlin/Java enums are a poor fit for evolving HTTP APIs: a server that adds a new variant + * breaks every older client the moment that variant reaches the wire, because `valueOf` throws. + * This type follows the two-enum pattern used by hand-written and generated DTOs alike: + * + * - A closed [Known] enum, generated/hand-written by the caller, enumerates exactly the variants + * this build understands. + * - A parallel [Value] enum mirrors [Known] and adds a single `UNKNOWN` sentinel for any value + * outside the closed set. The sentinel is a lint-clean name, not an underscore-prefixed marker, + * so it reads naturally in a `when`. + * - The original wire string is always retained in [rawValue], so an unknown value round-trips + * back to the server unchanged rather than being silently dropped. + * + * Concrete enum types subclass [OpenEnum] with a private constructor and expose `@JvmStatic` + * factories plus public constants, e.g.: + * + * ```kotlin + * public class ReasoningEffort private constructor( + * rawValue: String, + * ) : OpenEnum(rawValue) { + * public enum class Known(public val wireValue: String) { LOW("low"), MEDIUM("medium"), HIGH("high") } + * public enum class Value { LOW, MEDIUM, HIGH, UNKNOWN } + * + * public companion object { + * @JvmField public val LOW: ReasoningEffort = ReasoningEffort("low") + * @JvmStatic public fun of(value: String): ReasoningEffort = ReasoningEffort(value) + * } + * + * override fun knownToValue(known: Known): Value = Value.valueOf(known.name) + * override fun resolveKnown(rawValue: String): Known? = Known.entries.firstOrNull { it.wireValue == rawValue } + * override fun unknownValue(): Value = Value.UNKNOWN + * } + * ``` + * + * The deserializer for such a type calls the `of`-style factory with the raw string and never + * inspects whether it is known; the *consumer* decides whether to branch on [value] (total, + * including the unknown sentinel) or to demand a [known] (throws on unknown). Encoding always emits + * [rawValue]. + * + * Instances are immutable. Equality is by [rawValue] only — two instances with the same raw string + * are equal regardless of subclass identity, mirroring how the wire treats them. + * + * @param K the closed enum of variants this build understands. + * @param V the parallel enum mirroring [K] plus an unknown sentinel. + */ +public abstract class OpenEnum, V : Enum> protected constructor( + rawValue: String, +) { + /** + * The exact string received on the wire (or supplied by the caller). Always preserved so an + * unrecognised value can be re-serialized unchanged instead of being lost. + */ + public val rawValue: String = rawValue + + /** + * Maps a [Known][K] variant to its mirror in [V]. Implementations typically delegate to + * `Value.valueOf(known.name)` when the two enums share variant names. + */ + protected abstract fun knownToValue(known: K): V + + /** + * Resolves [rawValue] back to a [Known][K] variant, or `null` if the raw string is outside the + * closed set. Implementations match on whatever wire spelling the variant uses. + */ + protected abstract fun resolveKnown(rawValue: String): K? + + /** Returns the [V] sentinel that represents "value outside the closed set". */ + protected abstract fun unknownValue(): V + + /** + * Total classifier over [V]: returns the mirrored known variant when [rawValue] is recognised, + * otherwise the [unknownValue] sentinel. Never throws — use this when handling an enum + * exhaustively (e.g. a `when` with an `UNKNOWN` arm) so a new server variant degrades + * gracefully instead of crashing. + */ + public fun value(): V { + val known = resolveKnown(rawValue) + return if (known == null) unknownValue() else knownToValue(known) + } + + /** + * Returns the known variant for [rawValue], or `null` if the value is outside the closed set. + * The nullable counterpart to [known] for callers that prefer to branch rather than catch. + */ + public fun knownOrNull(): K? = resolveKnown(rawValue) + + /** + * Returns the known variant for [rawValue], throwing if the value is unknown to this build. + * + * Use this only at call-sites that genuinely cannot proceed with an unrecognised value; + * prefer [value] or [knownOrNull] on any path that must survive server-side evolution. + * + * @throws IllegalStateException if [rawValue] is not a recognised variant. + */ + public fun known(): K = resolveKnown(rawValue) ?: error("Unknown value for ${javaClass.simpleName}: $rawValue") + + /** `true` when [rawValue] does not correspond to any known variant of this build. */ + public val isUnknown: Boolean + get() = resolveKnown(rawValue) == null + + /** + * Equal when the other value is an [OpenEnum] carrying the same [rawValue]. Subclass identity + * is intentionally ignored: equality follows the wire, where only the string matters. + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is OpenEnum<*, *>) return false + return rawValue == other.rawValue + } + + override fun hashCode(): Int = rawValue.hashCode() + + /** Renders [rawValue] verbatim so logs and `toString` round-trip the wire form. */ + override fun toString(): String = rawValue +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenUnion.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenUnion.kt new file mode 100644 index 00000000..40518b51 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenUnion.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 + +/** + * Forward-compatible union primitive: a private-constructor value type that holds exactly one of + * several variants, dispatches through a [Visitor], and retains the raw wire node so an + * unrecognised shape survives a round-trip instead of being dropped. + * + * Kotlin `sealed` classes with `permits` do not compile to Java-8 bytecode, and a closed sum type + * crashes the moment a server adds a variant an older client cannot model. This base sidesteps both + * problems: + * + * - **One nullable slot per variant.** A concrete union subclasses [OpenUnion] with a private + * constructor, stores each arm in its own nullable field, and exposes a typed accessor per arm + * (returning `null` when that arm is not active). No `sealed`/`permits`, so it is Java-8 safe. + * - **Visitor dispatch.** [accept] walks the subclass's [arms] in declaration order and routes the + * first non-null arm to its visitor case via [Arm.dispatch]; if no arm is active (an unknown + * variant the deserializer could not map), it routes to [Visitor.unknown], whose default + * implementation throws. Generated/hand-written visitors override only the cases they care about. + * - **Retained raw node.** Every parsed union keeps the decoded wire value in [rawValue] + * (format-agnostic `Any?` — a decoded map/list/scalar, or whatever the active deserializer + * produced). An unknown variant carries no typed arm but still carries its [rawValue], so a + * serializer can re-emit it verbatim. + * + * Sketch of a concrete two-arm union (a generator would emit this; it is fully hand-writable): + * + * ```kotlin + * public class StringOrNumber private constructor( + * private val string: String?, + * private val number: Long?, + * rawValue: Any?, + * ) : OpenUnion>(rawValue) { + * public fun asString(): String? = string + * public fun asNumber(): Long? = number + * + * public interface Visitor : OpenUnion.Visitor { + * public fun visitString(value: String): R + * public fun visitNumber(value: Long): R + * } + * + * override val arms: List>> = listOf( + * Arm(string) { v, value -> v.visitString(value as String) }, + * Arm(number) { v, value -> v.visitNumber(value as Long) }, + * ) + * + * public companion object { + * @JvmStatic public fun ofString(value: String): StringOrNumber = StringOrNumber(value, null, value) + * @JvmStatic public fun ofNumber(value: Long): StringOrNumber = StringOrNumber(null, value, value) + * @JvmStatic public fun ofUnknown(rawValue: Any?): StringOrNumber = StringOrNumber(null, null, rawValue) + * } + * } + * ``` + * + * Instances are immutable. The base holds no mutable state beyond the variant slots supplied at + * construction. + * + * @param VIS the concrete visitor type this union dispatches to. It must extend [Visitor]. + */ +public abstract class OpenUnion> protected constructor( + rawValue: Any?, +) { + /** + * The decoded wire node this union was parsed from, format-agnostic. Retained on every variant + * — including the unknown case that has no typed arm — so a serializer can re-emit the original + * shape and forward-compatibility is preserved across the round-trip. + */ + public val rawValue: Any? = rawValue + + /** + * The ordered variant slots of this union, supplied by the concrete subclass. [accept] selects + * the first [Arm] whose value is non-null. Order is the subclass's declaration order, which is + * also the precedence used when (pathologically) more than one arm is populated. + */ + protected abstract val arms: List> + + /** + * Dispatches to the visitor case for the active variant, or to [Visitor.unknown] when no arm is + * populated (an unrecognised variant). Returns whatever the selected case returns. + * + * @param visitor the visitor to dispatch into. Its `unknown` case handles the forward-compat + * path; the default [Visitor.unknown] throws. + */ + public fun accept(visitor: Visitor): R { + for (arm in arms) { + val value = arm.value + if (value != null) { + @Suppress("UNCHECKED_CAST") + return arm.dispatch(visitor as VIS, value) as R + } + } + return visitor.unknown(rawValue) + } + + /** `true` when no variant arm is populated — i.e. this union holds an unrecognised shape. */ + public val isUnknown: Boolean + get() = arms.all { it.value == null } + + /** + * One variant slot: the (possibly null) value for that arm plus the dispatch closure that + * routes a non-null value to the matching visitor case. The subclass builds one [Arm] per + * variant; the [dispatch] closure is responsible for the cast back to the arm's concrete type. + * + * @param VIS the concrete visitor type, threaded through from the enclosing union. + */ + public class Arm>( + /** The value for this arm, or `null` when this arm is not the active variant. */ + public val value: Any?, + private val dispatcher: (visitor: VIS, value: Any) -> Any?, + ) { + /** Routes a confirmed-non-null [value] into the matching case on [visitor]. */ + public fun dispatch( + visitor: VIS, + value: Any, + ): Any? = dispatcher(visitor, value) + } + + /** + * Base visitor every concrete union visitor extends. Concrete unions add one `visitXxx` case + * per variant; this base supplies only the forward-compatibility escape hatch. + * + * @param R the result type produced by every case. + */ + public interface Visitor { + /** + * Invoked when the union holds an unrecognised variant. Override to handle unknown server + * shapes gracefully (e.g. ignore, log, or inspect [rawValue]); the default throws, matching + * the "fail loudly unless you opted in" stance. + * + * @param rawValue the retained raw wire node of the unrecognised variant. + * @throws IllegalStateException by default. + */ + public fun unknown(rawValue: Any?): R = error("Unknown union variant: $rawValue") + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/OpenEnumTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/OpenEnumTest.kt new file mode 100644 index 00000000..7b24c307 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/OpenEnumTest.kt @@ -0,0 +1,106 @@ +/* + * 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.assertTrue +import kotlin.test.fail + +class OpenEnumTest { + /** + * Hand-written concrete enum exercising the [OpenEnum] base exactly as a generated type would: + * a closed [Known] enum, a parallel [Value] enum with an `UNKNOWN` sentinel, and a private + * constructor with `of`-style factories plus public constants. + */ + private class ReasoningEffort private constructor( + rawValue: String, + ) : OpenEnum(rawValue) { + enum class Known(val wireValue: String) { + LOW("low"), + MEDIUM("medium"), + HIGH("high"), + } + + enum class Value { + LOW, + MEDIUM, + HIGH, + UNKNOWN, + } + + override fun knownToValue(known: Known): Value = Value.valueOf(known.name) + + override fun resolveKnown(rawValue: String): Known? = Known.entries.firstOrNull { it.wireValue == rawValue } + + override fun unknownValue(): Value = Value.UNKNOWN + + companion object { + val LOW: ReasoningEffort = ReasoningEffort("low") + val HIGH: ReasoningEffort = ReasoningEffort("high") + + fun of(value: String): ReasoningEffort = ReasoningEffort(value) + } + } + + @Test + fun `known value resolves to its Known variant`() { + assertEquals(ReasoningEffort.Known.LOW, ReasoningEffort.LOW.known()) + assertEquals(ReasoningEffort.Known.HIGH, ReasoningEffort.of("high").known()) + } + + @Test + fun `value() returns the mirrored Value for known inputs`() { + assertEquals(ReasoningEffort.Value.LOW, ReasoningEffort.of("low").value()) + assertEquals(ReasoningEffort.Value.MEDIUM, ReasoningEffort.of("medium").value()) + } + + @Test + fun `unknown server value deserializes without throwing and round-trips its raw string`() { + val parsed = ReasoningEffort.of("ultra") + + // The point of the primitive: no throw on an unrecognised value. + assertTrue(parsed.isUnknown) + assertEquals(ReasoningEffort.Value.UNKNOWN, parsed.value()) + assertNull(parsed.knownOrNull()) + // Raw string preserved so it can be re-serialized unchanged. + assertEquals("ultra", parsed.rawValue) + assertEquals("ultra", parsed.toString()) + } + + @Test + fun `known() throws on an unknown value`() { + try { + ReasoningEffort.of("ultra").known() + fail("expected IllegalStateException") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("ultra")) + } + } + + @Test + fun `isUnknown is false for recognised values`() { + assertFalse(ReasoningEffort.LOW.isUnknown) + assertEquals(ReasoningEffort.Known.LOW, ReasoningEffort.LOW.knownOrNull()) + } + + @Test + fun `equality and hashCode follow the raw value, not subclass identity`() { + assertEquals(ReasoningEffort.LOW, ReasoningEffort.of("low")) + assertEquals(ReasoningEffort.LOW.hashCode(), ReasoningEffort.of("low").hashCode()) + assertFalse(ReasoningEffort.LOW == ReasoningEffort.HIGH) + } + + @Test + fun `unknown values compare equal when their raw strings match`() { + assertEquals(ReasoningEffort.of("ultra"), ReasoningEffort.of("ultra")) + assertFalse(ReasoningEffort.of("ultra") == ReasoningEffort.of("mega")) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/OpenUnionTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/OpenUnionTest.kt new file mode 100644 index 00000000..499e1b80 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/OpenUnionTest.kt @@ -0,0 +1,123 @@ +/* + * 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.assertTrue +import kotlin.test.fail + +class OpenUnionTest { + /** + * Hand-written concrete union exercising the [OpenUnion] base exactly as a generated type + * would: a private constructor, one nullable slot + typed accessor per arm, a visitor extending + * [OpenUnion.Visitor], and an `ofUnknown` factory that retains the raw node. + */ + private class StringOrNumber private constructor( + private val string: String?, + private val number: Long?, + rawValue: Any?, + ) : OpenUnion>(rawValue) { + fun asString(): String? = string + + fun asNumber(): Long? = number + + interface Visitor : OpenUnion.Visitor { + fun visitString(value: String): R + + fun visitNumber(value: Long): R + } + + override val arms: List>> = + listOf( + Arm(string) { v, value -> v.visitString(value as String) }, + Arm(number) { v, value -> v.visitNumber(value as Long) }, + ) + + companion object { + fun ofString(value: String): StringOrNumber = StringOrNumber(value, null, value) + + fun ofNumber(value: Long): StringOrNumber = StringOrNumber(null, value, value) + + fun ofUnknown(rawValue: Any?): StringOrNumber = StringOrNumber(null, null, rawValue) + } + } + + private class Renderer : StringOrNumber.Visitor { + override fun visitString(value: String): String = "string:$value" + + override fun visitNumber(value: Long): String = "number:$value" + } + + @Test + fun `accept dispatches to the string case for a string variant`() { + val union = StringOrNumber.ofString("hello") + + assertEquals("hello", union.asString()) + assertNull(union.asNumber()) + assertEquals("string:hello", union.accept(Renderer())) + } + + @Test + fun `accept dispatches to the number case for a number variant`() { + val union = StringOrNumber.ofNumber(42L) + + assertEquals(42L, union.asNumber()) + assertNull(union.asString()) + assertEquals("number:42", union.accept(Renderer())) + } + + @Test + fun `unknown variant retains its raw node and is not lost`() { + val raw = mapOf("type" to "future", "payload" to 7) + val union = StringOrNumber.ofUnknown(raw) + + assertTrue(union.isUnknown) + assertNull(union.asString()) + assertNull(union.asNumber()) + // Raw node preserved so a serializer can re-emit the unrecognised shape verbatim. + assertEquals(raw, union.rawValue) + } + + @Test + fun `accept routes an unknown variant to the default unknown() which throws`() { + val union = StringOrNumber.ofUnknown("mystery") + + try { + union.accept(Renderer()) + fail("expected IllegalStateException from default unknown()") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("mystery")) + } + } + + @Test + fun `a visitor overriding unknown() handles the forward-compat path without throwing`() { + val raw = listOf(1, 2, 3) + val union = StringOrNumber.ofUnknown(raw) + + val tolerant = + object : StringOrNumber.Visitor { + override fun visitString(value: String): String = "string:$value" + + override fun visitNumber(value: Long): String = "number:$value" + + override fun unknown(rawValue: Any?): String = "unknown:$rawValue" + } + + assertEquals("unknown:[1, 2, 3]", union.accept(tolerant)) + } + + @Test + fun `populated variants report isUnknown false`() { + assertFalse(StringOrNumber.ofString("x").isUnknown) + assertFalse(StringOrNumber.ofNumber(1L).isUnknown) + } +}