Skip to content

docs(downgrader): OpenAPI Generator Kotlin reads a map of nullable enum $refs as the enum - #245

Merged
dinwwwh merged 26 commits into
mainfrom
claude/project-thread-fvev8j
Oct 31, 2026
Merged

dinwwwh merged 26 commits into
mainfrom
claude/project-thread-fvev8j

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 31, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

Summary

A record whose values are a nullable $ref to an enum, such as z.record(z.string(), Status.nullable()) with Status registered by .meta({ id }), has additionalProperties: { anyOf: [{ $ref }, { type: "null" }] } in 3.1. In 3.0 that becomes a oneOf of the $ref and the "null" branch, as designed.

OpenAPI Generator 7.26 merges those two branches into an inline enum (the SIMPLIFY_ONEOF_ANYOF_ENUM normalizer the README already names). Its Kotlin client then types a map of an inline enum as the enum itself, so the property is generated as val v: Request.V and not as a map. Every map is rejected, with Moshi and with kotlinx.serialization alike:

JsonDataException: Expected a string but was BEGIN_OBJECT at path $.v

From 3.1, the same property is Map<String, E?> and every value round-trips.

The README line about OpenAPI Generator leaving null out of array items and map values listed Kotlin. In 7.26, Kotlin keeps null for array items (inline or $ref), so this PR moves Kotlin out of that sentence and adds one about the map.

What was checked

I compiled each shape with -g kotlin (Gradle) from the 3.1 input and from the 3.0 output, then decoded and re-encoded valid instances with the client's serializer.

Shape 3.1 3.0
required or optional map of nullable enum $ref Map<String, E?>, ok the enum, rejects every map
map of non-null enum $ref ok ok
map of nullable string ok ok
array of nullable enum, $ref or inline ok ok
map of inline enum, nullable or not the enum in both versions same

With --openapi-normalizer SIMPLIFY_ONEOF_ANYOF_ENUM=false, the 3.0 map is kept, but its values become a wrapper class that still rejects every non-null value. So the README doesn't suggest the flag here.

Only README text changes. uncheck passes.


Generated by Claude Code

claude added 26 commits October 31, 2026 09:23
…list $ref limitation

- progenitor reads an anyOf with two string-taking branches, the "null"
  branch counted as one, as a struct that takes only objects.
- NSwag's undefined type for a $ref to a oneOf depends on its first branch.
- $refs into the items or properties of a multi-type `type` list dangle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…ray/record gap

- A $ref into the allOf of a schema that also holds a $ref resolves one
  member off, since that $ref becomes the first allOf member.
- datamodel-code-generator, even with --strict-nullable, leaves null out
  of an array item or map value that is a nullable array or record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
openapi-zod-client 1.18 drops `nullable: true` from an anyOf branch, as a
nullable tuple member or union member becomes; its bundled Swagger
Parser rejects openapi 3.1.2, while 3.1.1 reads fine and keeps null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…y enum and byte getter note

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…lable tuple member

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…ull, unions)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…m and byte branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…of z.null()

OpenAPI Generator's PHP client writes no allowed values for the enum of
an array item or map value that is { type: "null" } alone, which the
downgrader writes as { type: "string", nullable: true, enum: [null] },
so it throws on any such array or map that is not empty. Its 3.1 path
lets null through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…4 arrays

Kiota's TypeScript client reads every value of a oneOf of objects with
the first branch. In 3.0 a const tag becomes a one-value enum, which the
first branch rejects, so a value from any other branch of a Zod
discriminated union loses its tag. It also reads an array of format:
byte strings, which z.array(z.base64()) becomes, as one byte array and
drops it; the C# client throws on such an array. From 3.1 Kiota keeps
both as plain strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…e records

It keeps null on a nullable record only as an object's property whose
values reject null. As a component, an array item, a map value, a branch
of an anyOf or oneOf, or with values that may be null, the record's type
leaves out null. It reads the 3.1 anyOf with a null branch in all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…le enums

OpenAPI Generator 7.26 merges the oneOf a nullable enum becomes into one
enum, and its go-server then checks that property or array item with an
AssertstringRequired function it never writes. The existing normalizer
flag avoids it. The 3.1 anyOf with a null branch compiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…on byte and null-only shapes

The Spring server, like the Rust client, does not compile a required
nullable format: byte property (Arrays.equals on a JsonNullable<byte[]>).
It also writes the empty enum the Java clients write for type: null
alone, and the Python FastAPI server writes `class X(, Enum)` for such
a component. All of them compile from the 3.1 input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…te string with a pattern

Zod's z.base64().nullable() becomes a nullable format: byte string that
keeps its pattern. OpenAPI Generator 7.26's rust-axum server then checks
the pattern with a function that takes a ByteArray, while the field is a
Nullable<ByteArray>, so the property does not compile, required or
optional. 3.1 input compiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
… rejects base64 strings

A contentEncoding: base64 string, such as Zod's z.base64(), becomes a
format: byte string. OpenAPI Generator 7.26's Kotlin client types it as
a ByteArray. With serializationLibrary=kotlinx_serialization on a JVM
library (jvm-okhttp4, jvm-ktor, jvm-retrofit2, jvm-vertx), no base64
serializer is registered, so kotlinx.serialization reads and writes it
as an array of numbers and rejects the base64 string. Moshi registers a
base64 adapter, and the multiplatform library uses Base64ByteArray.
3.1 input keeps the string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
…um $refs as the enum

A record whose values are a nullable $ref to an enum, such as a
z.record of a registered z.enum with .nullable(), becomes an
additionalProperties oneOf of the $ref and the "null" branch.
OpenAPI Generator 7.26 merges those branches into an inline enum,
and its Kotlin client types a map of an inline enum as that enum, so
the property becomes the enum and every map is rejected (Moshi and
kotlinx.serialization alike). From 3.1 it stays Map<String, E?>.

Kotlin 7.26 keeps null for array items, so it moves out of the
sentence about leaving null out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY
@dinwwwh dinwwwh self-assigned this Oct 31, 2026
@codecov

codecov Bot commented Oct 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 31, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 29 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/project-thread-fvev8j (3742761) with main (63c5996)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Kotlin moved out of the null-dropping sentence — the OpenAPI Generator limitations bullet now names only TypeScript and Swift as clients that "leave null out of the type of an array item or map value" that is a nullable $ref or enum.
  • Added Kotlin map-of-nullable-enum note — in 7.26, OAG's Kotlin client reads a map whose values are a nullable $ref to an enum (e.g. z.record of a registered z.enum with .nullable()) as that enum, rejecting every such map; the caveat now says 3.1 "keeps the null and the map".

The new claim checks out against OpenAPI Generator v7.26.0: kotlin-client/data_class_{req,opt}_var.mustache has no {{#isMap}} branch inside {{#isEnum}}, so a map-of-enum property is typed as the enum and the Map is dropped (upstream issue #25453, open fix #25474). The path runs through the default SIMPLIFY_ONEOF_ANYOF_ENUM merge already documented one bullet above, which turns the nullable $ref into an inline enum. uncheck packages/downgrader/README.md passes.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit d834226 into main Oct 31, 2026
11 checks passed
dinwwwh added a commit that referenced this pull request Oct 31, 2026
…rays or maps of nullable enum $refs (#248)

<!-- claude-projects-attribution: {"github_login":"dinwwwh"} -->
_Requested by **dinwwwh** · [project
thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYEAptv6n54e8QDjz18AXHAoD)_

## Summary

Docs only. Extends the README note on array items and map values that
are a nullable `$ref` or enum (line 266) with OpenAPI Generator 7.26's
`kotlin-server` (Ktor) generator.

In 3.0, an array item or map value that is a nullable `$ref` to an enum
becomes `oneOf: [{ $ref: E }, { type: string, nullable: true, enum:
[null] }]`. Zod writes that for `z.array(E.nullable())` or
`z.record(z.string(), E.nullable())` with a registered `E`. The
`SIMPLIFY_ONEOF_ANYOF_ENUM` normalizer turns that into an inline enum,
and Kotlin's data class template then collapses the container into the
enum. This is the same root cause as the Kotlin client note from #245.
`kotlin-server` writes:

```kotlin
data class NreitemRequest(val v: NreitemRequest.V) {
    enum class V(val value: kotlin.collections.List<kotlin.String>) { a("a"), b("b"); }
}
```

That fails `compileKotlin`: `Argument type mismatch: actual type is
'String', but 'List<String>' was expected` (`Map<String, String>` for
the map).

Checked:

- The 3.1 input compiles for both shapes.
- With `--openapi-normalizer SIMPLIFY_ONEOF_ANYOF_ENUM=false`, the 3.0
output compiles too.
- Inline nullable enum items and maps fail in 3.1 as well, so the note
stays on `$ref`s, as the sentence before it does.
- `kotlin-spring` compiles both versions.

Found by the code-generator hunt, round cg45. That round added a
`kotlin-server` compile check over every test shape: Gradle through the
Google Maven mirror, with `AppMain.kt` removed.

## Test plan

- [x] `pnpm run check`
- [x] Reproduced with OpenAPI Generator 7.26.0 (`gradle compileKotlin`)
on the 3.1 input, the downgrader's 3.0 output, and the 3.0 output with
the normalizer off

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VVaw7SHxsMExLa1nyEQJVY)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

2 participants