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) + } +}