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
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.
Description
When a
oneOfschema 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 throwsSerializationExceptionat runtime.Minimal spec — a
oneOfof single-key wrapper objects, nodiscriminator(a recursive file-tree union: aDirectorycontains moreNodes):Wire payload actually sent by the server (externally tagged — the tag is the wrapper key):
{ "File": { "name": "a.txt", "sizeBytes": 12 } }Generated model:
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: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 aJsonContentPolymorphicSerializer(Hierarchy.kt:44-51,ModelGenerator.kt:281-306). TheoneOf-wrapper path should do the analogous thing instead of pretending the data is internally tagged.Proposed fix
Emit a custom
KSerializerfor the wrapperoneOf(external tagging) rather than a synthetic@JsonClassDiscriminator("type"), mirroring theanyOf-without-discriminator path:(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
oneOfof single-key wrapper objects generates a model that deserializes the externally-tagged wire payload ({"TypeName": {...}}){"TypeName": {...}}), not{"type": "TypeName", ...}Directorycontainingchildren: Node[])@JsonClassDiscriminator("type")is emitted for this pattern unless the server actually sends internal taggingNodefile-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.4and currentmaster(8ee8053) — the buggydetectAndUnwrapOneOfWrappersis present in both.