Repository navigation
fix(python): describe the wire shape in JSON schemas of tagged and flattened enums - #210
Conversation
…attened enums
Pydantic builds a model's JSON schema from its fields and never runs its
custom validators or serializers. Generated classes that reshape the wire
in those hooks advertised the shape of their Python fields instead, in both
validation and serialization mode:
- Externally tagged enums showed each variant's class, such as
`{field_0, field_1}` for a tuple variant, instead of `"Unit"`,
`{"Tuple": [a, b]}`, `{"Newtype": value}` and `{"Struct": {...}}`.
- Structs with a flattened enum showed a required nested `value`
property instead of the enum's keys next to the struct's own.
- Open enums listed their client-side unknown case as an object with
`tag` and `raw`.
The generated classes now describe the wire through
`__get_pydantic_json_schema__`, which calls a runtime helper:
`externally_tagged_json_schema`, `flattened_enum_json_schema` and
`known_variants_json_schema`. An open enum is described by its known
variants, as the server's schema describes it.
A flattened unit variant of an externally tagged enum now serializes as
`"Unit": null`, as serde writes it, instead of failing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7zEtTtfj5pVB2LpkShhWS
The enum compatibility fixture now also writes `known_samples.json`: serde's output for every known variant of each fixture. A new CI step round-trips each value through the generated output model and validates it against both the validation-mode and serialization-mode JSON schema. The serde samples of the codegen coverage client get the same check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7zEtTtfj5pVB2LpkShhWS
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
|
📖 Documentation Preview: https://reflectapi-docs-preview-pr-210.partly.workers.dev Updated automatically from commit 232b50f |
|
@claude review |
A struct that flattens an externally tagged enum of unit variants only got a
JSON schema no value matches: the enum is a string enum, so its schema stayed
a string inside the struct's object. It is now one `{"Tag": null}` object per
variant, as serde writes it. Its generated model also rejected that wire value,
handing the string enum `{"Tag": None}`; it now takes the tag.
An unknown enum case's schema was a bare marker, which JSON Schema reads as
matching anything, so an open enum generated without the
`known_variants_json_schema` hook (a client generated before it) became
untyped. The marker now matches nothing, and such an enum describes its known
variants only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7zEtTtfj5pVB2LpkShhWS
hardbyte
left a comment
There was a problem hiding this comment.
The wire-to-JSON-Schema rules now exist twice. They live once in openapi.rs (from reflectapi’s schema) and again in serde.py (from Pydantic’s schema plus hints from the generator). They agree today, and testing against serde’s samples is the right safeguard. Under the #129 direction, though, the variant shapes (unit/newtype/tuple/struct) should come from shared code next to #204’s Layout, so both generators read the same facts.
…n-schema-of-externally-tagged-and
Problem
A FastAPI app that returns generated models gets an OpenAPI spec that looks typed but describes the wrong JSON for externally tagged enums, structs with a flattened enum, and open enums.
Pydantic builds a JSON schema without running a model's custom validators or serializers (the same mechanism as #208). With no return type declared on the serializer, it describes the class's own fields. Several generated classes reshape the wire in those hooks, so their schemas describe the Python fields instead of the wire, in both validation mode (what the model accepts) and serialization mode (what it emits):
RootModelover variant classes)_parse_externally_tagged/_serialize_externally_tagged"Calm",{"Aggressive": [1.0, "x"]},{"Other": {...}}"Calm",{field_0, field_1},{description, notes}#[serde(flatten)]enum_unflatten_enum/_flatten_enum{"id": 7, "kind": "Dog", "breed": "corgi"}valueproperty:{"id": 7, "value": {...}}#[reflectapi(open)])_classify{tag: str, raw: any}, which is the client's in-memory placeholder, never a wire valueInternally and adjacently tagged enums (not flattened), untagged unions, field presence, and plain and partial structs were already right.
Example
Behavior.model_json_schema()(both modes):A struct
Envelope { id: u32, #[serde(flatten)] value: Animal }with an internally taggedAnimal:An optional flattened enum (
Option<Animal>withskip_serializing_if) becomesanyOf: [the enum, {"not": {"required": ["kind"]}}], that is, the enum or none of its keys. For an externally tagged enum the presence keys are the variant names, and a unit variant is{"Unit": null}, as serde writes it.These are the shapes reflectapi's own OpenAPI generator already produces for these representations.
Fix
The generated classes describe their wire shape through
__get_pydantic_json_schema__, which calls a runtime helper, in the same style as_parse_externally_tagged:externally_tagged_json_schemabuilds one alternative per variant from the(wire_name, shape)list the codegen emits, in the order of the root union. Struct variants reference the variant class's definition ($ref), tuple variants becomeprefixItems, newtype variants the inner value's schema.flattened_enum_json_schematakes the struct's default schema, removes the enum's field, and combines the rest with the enum's schema in anallOf. Optional groups may also be absent.known_variants_json_schemadrops the unknown case from an open enum's union.UnknownEnumValue's own schema is a marker that this helper removes; Pydantic then garbage-collects its unused definition.Hooks run for both modes, and none of them changes validation or serialization. Works on Pydantic 2.10.0 through 2.14 (the CI matrix floors and latest).
Open enums
An open enum is now described by its known variants only, like the server's schema describes it. Its model still accepts unknown variants (that is what open means), so the validation-mode schema is stricter than the model for those values; it is accurate for every known value. The alternative, an "anything" branch, would make every open enum an untyped value in generated OpenAPI clients. The
Unknownvalue in theOpenUnitfixture's schema is a real Rust variant, not the placeholder.Also fixed
A flattened unit variant of an externally tagged enum (
{"id": 7, "Unit": null}on the wire) was parsed but failed to serialize (TypeError: 'str' object is not a mapping)._flatten_enumnow writes it as"Unit": null.Tests
tests/python/test_json_schema.py). The enum compatibility fixture now writesknown_samples.json: serde's own output for every known variant of each of 23 fixtures (externally tagged with unit, newtype, tuple and struct variants; flattened internal, adjacent, external and fallback enums, required and optional; open and fallback enums; untagged; a nested envelope). Each value must round-trip through the generated output model and validate against both schemas. 48 of the 107 cases fail onmain; all pass here.test_wire.pyalso validates the codegen coverage client's serde samples against both schemas (untagged enums, which were fine; kept as a guard).tests/test_json_schema.pycovers the three helpers on hand-written classes in the generated style. Full runtime suite: 483 passed.python-codegen-smokejob passes locally for all four matrix entries (3.12 and 3.14, latest and minimum dependencies), pluscargo fmt,clippy -D warnings,cargo test --workspace, and the Rust compatibility crates.main(5 in the enum compatibility suite, 3 in the demo client tests) and none here. What remains is unknown-variant values of open and#[serde(other)]enums, by the choice above.Not changed
#[serde(other)]enums deliberately have no fallback variant (the existing request policy), and some flattened closed enums do not round-trip serde's wire today, independent of this change:FlattenAdjacentEnvelopeandNestedFlattenEnvelopereject their flattened form ("Field required: value"),OptionalFlattenEnumEnveloperejects{"id": 7}, andOptionalFlattenAdjacentEnvelopedrops a unit variant ({"id": 7, "kind": "Unit"}becomes{"id": 7}). Worth a separate issue.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q7zEtTtfj5pVB2LpkShhWS