Skip to content

anyOf over string schemas generates #[serde(flatten)] on String, causing runtime error “can only flatten structs and maps (got a string)” #895

@mgrabina

Description

@mgrabina

What happened
When an OpenAPI schema uses anyOf to allow either a string (with an allOf/$ref that resolves to type: string) or another string $ref, Progenitor generates a Rust struct with two optional fields, both annotated with #[serde(flatten)]. At runtime, serializing or deserializing this type panics with:

Error("can only flatten structs and maps (got a string)", line: 0, column: 0)

Minimal repro OpenAPI fragment

components:
  schemas:
    AppData:
      type: string
      description: String encoding of a JSON object (UTF-8).
    AppDataHash:
      type: string
      pattern: '^0x[0-9a-fA-F]{64}$'

    OrderCreation:
      type: object
      required: [appData]
      properties:
        appData:
          description: This field comes in two forms for backward compatibility.
          anyOf:
            - title: Full App Data
              description: String encoding of a JSON object.
              type: string
              allOf: [{ $ref: '#/components/schemas/AppData' }]
            - $ref: '#/components/schemas/AppDataHash'

You can use CoW's too. See OrderCreation AppData

Generated code (problematic)

#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
pub struct OrderCreationAppData {
    #[serde(flatten, default, skip_serializing_if = "Option::is_none")]
    pub subtype_0: Option<AppData>,      // AppData = String
    #[serde(flatten, default, skip_serializing_if = "Option::is_none")]
    pub subtype_1: Option<AppDataHash>,  // AppDataHash = String
}

Why this is wrong
Serde’s flatten only works when the field is a map/struct; it cannot flatten primitives like String. This is documented behavior. Attempting to serialize/deserialize yields the panic above. ([serde.rs][2])

Expected code
For anyOf/oneOf where all variants resolve to non-object types (e.g., strings), the generator should emit an untagged enum (or even a single String newtype if indistinguishable at runtime). For example:

#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
#[serde(untagged)]
pub enum OrderCreationAppData {
    Full(String),  // stringified JSON object
    Hash(String),  // 0x… hash
}

Alternatively, if an enum is undesirable here, generate one String (no flatten) and let the server-side validation discriminate — but under no circumstance emit flatten on a String.

How to reproduce

  1. Generate with Progenitor against the schema snippet above.

  2. Serialize an instance:

    let v = types::OrderCreationAppData {
        subtype_0: Some("{}".to_string().into()),
        subtype_1: None,
    };
    serde_json::to_string(&v).unwrap(); // panics
  3. Observe: Error("can only flatten structs and maps (got a string)").

Environment

  • Progenitor version: latest

Workarounds

  • Build request body as serde_json::Value and insert "appData" as a plain string.
  • Define a local DTO that mirrors the API (appData: String) and serialize that for requests.
  • Avoid serializing the generated OrderCreationAppData type until this is fixed.

Related

  • StackOverflow report showing the same crash path with Progenitor & anyOf including a string. ([Stack Overflow][1])
  • Serde docs on flatten limitations. ([serde.rs][2])

Proposed fix
In Typify/Progenitor’s codegen for oneOf/anyOf:

  1. Detect when all variants resolve to non-object schemas (e.g., primitives, strings, numbers).
  2. Do not emit a struct-with-flatten.
  3. Prefer #[serde(untagged)] enum for these unions; or, if variants are the same primitive type, consider a simple newtype around that primitive (with optional validation).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions