Skip to content

feat: add forward-compatible enum and union runtime primitives#152

Open
OmarAlJarrah wants to merge 1 commit into
mainfrom
feat/forward-compatible-enums-and-unions
Open

feat: add forward-compatible enum and union runtime primitives#152
OmarAlJarrah wants to merge 1 commit into
mainfrom
feat/forward-compatible-enums-and-unions

Conversation

@OmarAlJarrah

Copy link
Copy Markdown
Member

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. This adds two hand-writable serde primitives that a DTO author can target today (and a generator could target later) while staying forward-compatible.

OpenEnum<K, V>

An open value type over a single wire string, built on the two-enum pattern:

  • A closed Known enum the build understands, and a parallel Value enum carrying a lint-clean UNKNOWN sentinel (no underscore-prefixed marker).
  • Deserialization never throws. value() is total (returns the UNKNOWN sentinel for unrecognised input); knownOrNull() returns null; known() throws only when a call-site genuinely demands a recognised variant.
  • The original wire string is always retained in rawValue, so an unknown value re-serializes unchanged.
  • Equality follows the wire (raw string), not subclass identity.

OpenUnion<VIS>

A private-constructor union with forward-compat and Java-8 safety:

  • One nullable slot plus a typed accessor per variant — no sealed/permits.
  • Visitor dispatch: accept(visitor) walks the subclass-supplied arm list in declaration order and routes the first populated arm to its visitor case, falling back to Visitor.unknown(rawValue) (throws by default, overridable) for an unrecognised variant.
  • A retained raw node (rawValue, format-agnostic) on every instance, including the unknown case, so an unrecognised shape survives a round-trip instead of being dropped.

Both live in sdk-core/serde with zero new dependencies. Each ships with a hand-written concrete subclass in its test, matching how a generated type would extend the base.

Closes #54
Closes #53

Tests

Both primitives have thorough unit tests including the required cases:

  • OpenEnumTest: unknown-value deserializes without throwing and round-trips its raw string; known() throws on unknown; total value() classification; raw-value equality (including two unknowns comparing equal).
  • OpenUnionTest: visitor dispatch per variant; unknown variant retains its raw node; default unknown() throws; a visitor overriding unknown() handles the forward-compat path.

Gated build (scoped, run locally with --no-daemon)

./gradlew :sdk-core:test :sdk-core:ktlintCheck :sdk-core:detekt --no-daemon   # BUILD SUCCESSFUL
./gradlew :sdk-core:apiDump --no-daemon                                       # regenerated sdk-core.api (committed)
./gradlew :sdk-core:apiCheck --no-daemon                                      # BUILD SUCCESSFUL

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.
@OmarAlJarrah

Copy link
Copy Markdown
Member Author

Adds two forward-compatibility value types to the serde package: OpenEnum (an open enum over a wire string that retains rawValue and never throws on unknowns) and OpenUnion (a visitor-dispatched open union with nullable per-variant arms and a default-throwing unknown() escape hatch). Both are abstract bases for hand-written or generated DTOs, with regenerated API snapshots and unit tests. Solid design and good coverage overall — a couple of things to tighten before merging.

Issues

OpenUnion.accept advertises an unsafe call through the base Visitorsdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenUnion.kt:89-98
accept is declared as fun <R> accept(visitor: Visitor<R>) over the base OpenUnion.Visitor, then does arm.dispatch(visitor as VIS, value) under @Suppress("UNCHECKED_CAST"). Because the parameter is the base type rather than the concrete VIS bound, the compiler will happily accept any Visitor<R> — e.g. an anonymous OpenUnion.Visitor<String> that only overrides unknown() — which then throws ClassCastException at runtime the moment a populated arm dispatches into a concrete case. Normal call sites passing the concrete VIS visitor are fine, but the public signature invites the unsafe form and turns a compile error into a runtime crash. Narrowing accept to require VIS (or overriding it in concrete subclasses) would close the hole, and it's worth a test for the mismatched-visitor case.

OpenEnum re-resolves the known variant on every accessorsdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/OpenEnum.kt:89-112
value(), knownOrNull(), known(), and the isUnknown getter each call resolveKnown(rawValue) afresh, and the reference implementation resolves via a linear scan over Known.entries. Since the instance is immutable and rawValue never changes, this re-scans on every call. Caching the result once (e.g. a lazy val) would avoid the repeated O(n) work. Not a correctness issue, just a minor inefficiency on an otherwise immutable value type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generate forward-compatible enums (open value + known/value pair) Generate unions as private-ctor + per-variant accessors + visitor (Java-8 safe)

1 participant