Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions sdk-core/api/sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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 <init> (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 <init> (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;
Expand Down
128 changes: 128 additions & 0 deletions sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenEnum.kt
Original file line number Diff line number Diff line change
@@ -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<ReasoningEffort.Known, ReasoningEffort.Value>(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<K : Enum<K>, V : Enum<V>> 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
}
140 changes: 140 additions & 0 deletions sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenUnion.kt
Original file line number Diff line number Diff line change
@@ -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<StringOrNumber.Visitor<*>>(rawValue) {
* public fun asString(): String? = string
* public fun asNumber(): Long? = number
*
* public interface Visitor<out R> : OpenUnion.Visitor<R> {
* public fun visitString(value: String): R
* public fun visitNumber(value: Long): R
* }
*
* override val arms: List<Arm<Visitor<*>>> = 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<VIS : OpenUnion.Visitor<*>> 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<Arm<VIS>>

/**
* 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 <R> accept(visitor: Visitor<R>): 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<VIS : Visitor<*>>(
/** 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<out R> {
/**
* 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")
}
}
Loading
Loading