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
    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