Skip to content

oneOf wrapper (externally-tagged) unions generate an internally-tagged model that cannot deserialize the wire payload #104

Description

@mfabisiak

Description

When a oneOf schema uses the externally-tagged wrapper pattern — each variant is a single-property object whose key is the type tag ({"File": {...}}) — the generator produces a sealed interface annotated with @JsonClassDiscriminator("type"), i.e. an internally-tagged model ({"type": "File", ...}). No serializer is generated to bridge the two shapes, so the generated deserializer can never parse the payloads the schema describes and throws SerializationException at runtime.

Minimal spec — a oneOf of single-key wrapper objects, no discriminator (a recursive file-tree union: a Directory contains more Nodes):

"Node": {
  "oneOf": [
    { "title": "File",      "properties": { "File":      { "type": "object", "properties": { "name": {"type":"string"}, "sizeBytes": {"type":"integer"} }, "required": ["name","sizeBytes"] } }, "required": ["File"] },
    { "title": "Directory", "properties": { "Directory": { "type": "object", "properties": { "name": {"type":"string"}, "children": {"type":"array","items":{"$ref":"#/components/schemas/Node"}} }, "required": ["name","children"] } }, "required": ["Directory"] }
  ]
}

Wire payload actually sent by the server (externally tagged — the tag is the wrapper key):

{ "File": { "name": "a.txt", "sizeBytes": 12 } }

Generated model:

@Serializable
@JsonClassDiscriminator("type")          // expects the tag INSIDE the object
sealed interface Node {
  @Serializable @SerialName("File")
  data class File(val name: String, val sizeBytes: Int) : Node
  @Serializable @SerialName("Directory")
  data class Directory(val name: String, val children: List<Node>) : Node
}

To deserialize, this model requires { "type": "File", "name": "a.txt", "sizeBytes": 12 } — which the server never sends. Decoding the real { "File": {...} } fails because kotlinx looks for a "type" field that isn't there.

Root cause

SpecParser.detectAndUnwrapOneOfWrappers (core/.../parser/SpecParser.kt:414-458) correctly detects the externally-tagged wrapper pattern, but then unwraps it and synthesizes an internally-tagged discriminator:

// SpecParser.kt:457
unwrapped.values.toList() to Discriminator(propertyName = "type", mapping = mapping)

That synthetic discriminator flows into ModelGenerator.generateSealedHierarchy (core/.../gen/model/ModelGenerator.kt:169-176), which stamps @JsonClassDiscriminator("type") on the interface. The schema is rewritten to the internally-tagged representation, but nothing performs the corresponding transformation on the runtime payload — no custom serializer is emitted for the wrapper shape.

For contrast, anyOf-without-discriminator already handles a natively-unsupported polymorphism form correctly by generating a JsonContentPolymorphicSerializer (Hierarchy.kt:44-51, ModelGenerator.kt:281-306). The oneOf-wrapper path should do the analogous thing instead of pretending the data is internally tagged.

Proposed fix

Emit a custom KSerializer for the wrapper oneOf (external tagging) rather than a synthetic @JsonClassDiscriminator("type"), mirroring the anyOf-without-discriminator path:

@Serializable(with = NodeSerializer::class)
sealed interface Node { /* variants */ }

object NodeSerializer : JsonContentPolymorphicSerializer<Node>(Node::class) {
    override fun selectDeserializer(element: JsonElement) =
        when (element.jsonObject.keys.single()) {           // discriminate on the wrapper KEY
            "File"      -> Node.File.serializer()
            "Directory" -> Node.Directory.serializer()
            else -> error("unknown variant")
        }
    // + unwrap/rewrap { "TypeName": {...} } <-> variant object in deserialize/serialize
}

(Alternatively: keep the wrapper un-unwrapped in the parser and generate a serializer that reads/writes the wrapper directly.) Serialization (encode) must round-trip back to the wrapper shape, not the "type" shape.

Acceptance criteria

  • A oneOf of single-key wrapper objects generates a model that deserializes the externally-tagged wire payload ({"TypeName": {...}})
  • Encoding round-trips back to the wrapper shape ({"TypeName": {...}}), not {"type": "TypeName", ...}
  • Nested/recursive variants work (e.g. Directory containing children: Node[])
  • No @JsonClassDiscriminator("type") is emitted for this pattern unless the server actually sends internal tagging
  • Covered by a test (a small wrapper-union fixture, e.g. the Node file-tree above)

Discovered in

Generating a client from a real-world spec that uses the externally-tagged wrapper pattern: the generated API call fails to deserialize server responses. Reproduced against justworks v0.2.4 and current master (8ee8053) — the buggy detectAndUnwrapOneOfWrappers is present in both.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workinghelp wantedExtra attention is needed

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions