oxidecomputer / oxidecomputer/progenitor

Stack Overflow in Code Generation with Circular oneOf + discriminator Schemas

Open
#1,252 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
1k
Forks
136
Avg merge
8h 36m
Merged PRs (30d)
14

Description

Summary

Progenitor encounters a stack overflow when generating Rust code from OpenAPI specs that use a common inheritance pattern with discriminators: a base schema with both oneOf and discriminator properties, where the subtypes in oneOf extend the base schema via allOf. This is a valid OpenAPI pattern used for polymorphic type discrimination but causes progenitor's code generator to recurse infinitely.

Full disclosure: i'm new to progenitor, so this could be a limitation of my understanding. Happy to be corrected if i've got the wrong end of the stick here!

Reproduction

Minimal OpenAPI Spec
{
  "openapi": "3.0.0",
  "info": {
    "title": "Minimal Circular Reference Example",
    "version": "1.0.0"
  },
  "paths": {
    "/profiles": {
      "get": {
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BaseProfile" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "BaseProfile": {
        "type": "object",
        "required": ["name", "profileType"],
        "properties": {
          "name": { "type": "string" },
          "profileType": { "type": "string", "enum": ["TypeA", "TypeB"] }
        },
        "discriminator": {
          "propertyName": "profileType",
          "mapping": {
            "TypeA": "#/components/schemas/ProfileTypeA",
            "TypeB": "#/components/schemas/ProfileTypeB"
          }
        },
        "oneOf": [
          { "$ref": "#/components/schemas/ProfileTypeA" },
          { "$ref": "#/components/schemas/ProfileTypeB" }
        ]
      },
      "ProfileTypeA": {
        "type": "object",
        "allOf": [
          { "$ref": "#/components/schemas/BaseProfile" },
          { "type": "object", "properties": { "fieldA": { "type": "string" } } }
        ]
      },
      "ProfileTypeB": {
        "type": "object",
        "allOf": [
          { "$ref": "#/components/schemas/BaseProfile" },
          { "type": "object", "properties": { "fieldB": { "type": "string" } } }
        ]
      }
    }
  }
}
The Circular Reference Pattern
BaseProfile
  ├─ Has: oneOf [ProfileTypeA, ProfileTypeB]
  └─ Has: discriminator { propertyName: "profileType", mapping: {...} }

ProfileTypeA
  └─ Has: allOf [
      { $ref: BaseProfile },
      { properties: fieldA }
    ]

ProfileTypeB
  └─ Has: allOf [
      { $ref: BaseProfile },
      { properties: fieldB }
    ]

Recursion path: BaseProfileoneOfProfileTypeAallOfBaseProfile → (infinite loop)

Impact

This is a real limitation encountered when working with OpenAPI specs generated from code-first frameworks. The pattern itself is:

  • Documented in the OpenAPI 3.0 specification as a supported approach for inheritance and polymorphism using discriminator with oneOf/anyOf and allOf for schema composition[^1]
  • Naturally produced by code-first generators that convert inheritance hierarchies from object-oriented languages to OpenAPI schemas

When encountered, this pattern makes it difficult or impossible for Rust developers to use progenitor to generate clients from specs that implement polymorphic types with discriminators—a pattern explicitly documented in the OpenAPI specification for this purpose.

[^1]: See Swagger docs: Inheritance and Polymorphism and OpenAPI 3.0.3 Specification: Schema Composition

How These Specs Arise Naturally

This circular pattern is not a mistake or edge case—it's a natural byproduct of code-first OpenAPI generation from object-oriented codebases.

Code-First Frameworks That Generate This Pattern

Several popular code-first OpenAPI generators produce this exact pattern:

1. Java Spring Boot + OpenAPI Generator (Most Common)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "profileType")
@JsonSubTypes({
    @JsonSubTypes.Type(value = ProfileTypeA.class, name = "TypeA"),
    @JsonSubTypes.Type(value = ProfileTypeB.class, name = "TypeB")
})
public abstract class BaseProfile {
    public String name;
    public String profileType;
}

public class ProfileTypeA extends BaseProfile {
    public String fieldA;
}

public class ProfileTypeB extends BaseProfile {
    public String fieldB;
}

Generated OpenAPI: The generator creates:

  • BaseProfile schema with discriminator (for @JsonTypeInfo)
  • oneOf constraint listing subtypes (for @JsonSubTypes)
  • Subtype schemas use allOf to extend BaseProfile (for inheritance)
2. .NET / C# with NSwag or Swashbuckle
[JsonPolymorphic(TypeDiscriminatorPropertyName = "profileType")]
[JsonDerivedType(typeof(ProfileTypeA), typeDiscriminator: "TypeA")]
[JsonDerivedType(typeof(ProfileTypeB), typeDiscriminator: "TypeB")]
public abstract class BaseProfile {
    public string Name { get; set; }
    public string ProfileType { get; set; }
}

public class ProfileTypeA : BaseProfile {
    public string FieldA { get; set; }
}

Generated OpenAPI: Same discriminator + oneOf + allOf pattern.

3. Node.js/TypeScript with tsoa, Nestia, or OpenAPI-ts-codegen
export type BaseProfile = ProfileTypeA | ProfileTypeB;

export interface ProfileTypeA {
    discriminator: "TypeA";
    name: string;
    fieldA: string;
}

export interface ProfileTypeB {
    discriminator: "TypeB";
    name: string;
    fieldB: string;
}

Generates oneOf directly, and if the generator is sophisticated enough to add a base interface/class for common properties, it will use allOf with the base.

4. Go with ogen or oapi-codegen
type BaseProfile interface {
    BaseProfileUnmarshaler
}

type ProfileTypeA struct {
    Name   string `json:"name"`
    FieldA string `json:"fieldA"`
}

type ProfileTypeB struct {
    Name   string `json:"name"`
    FieldB string `json:"fieldB"`
}

These also generate oneOf with discriminators.

Why Frameworks Generate This Pattern

When a code-first generator encounters an inheritance hierarchy with polymorphism, it must:

  1. Preserve inheritance → Uses allOf to represent "extends"
  2. Enable deserialization → Uses discriminator to tell the deserializer which concrete type to instantiate
  3. Describe the type system → Uses oneOf to enumerate possible subtypes
  4. Maintain semantic equivalence → Generate schemas that accurately represent the object model

This naturally produces:

Base (oneOf: [Sub1, Sub2]) + (discriminator)
Sub1 (allOf: [Base, {own fields}])
Sub2 (allOf: [Base, {own fields}])
Example: Backend with Inheritance Hierarchies

Consider a backend (in C++, Java, or similar) with object-oriented type hierarchies:

class BaseObjective {
    string name;
    string objectiveType;  // discriminator
};

class ObjectiveA : public BaseObjective {
    // type-specific fields
};

class ObjectiveB : public BaseObjective {
    // type-specific fields
};
// ... more subtypes

A code-first OpenAPI generator converts this to:

  • BaseObjective schema with discriminator and oneOf listing all subtypes
  • Each concrete objective (ObjectiveA, ObjectiveB, etc.) uses allOf to extend BaseObjective
  • This creates circular references: Base → oneOf → ObjectiveA → allOf → Base

This is semantically correct and accurately represents the actual type hierarchy. However, progenitor cannot generate Rust code from it without handling the circular reference.

Current Workaround

Until this is fixed in progenitor, I've been using this workaround to pre-process the OpenAPI spec before code generation. Might be useful for others.:

//! build.rs

/// Remove circular oneOf from base schemas in OpenAPI spec
///
/// Pattern: Base schema has both oneOf and discriminator, and its subtypes
/// extend it via allOf. This causes infinite recursion in progenitor.
/// Removing the oneOf (keeping discriminator) allows generation to succeed.
fn remove_circular_oneof_patterns(
    mut schemas: serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
    fn extract_schema_refs(items: &[serde_json::Value]) -> HashSet<String> {
        items
            .iter()
            .filter_map(|item| item.get("$ref"))
            .filter_map(|ref_val| ref_val.as_str())
            .filter_map(|ref_str| ref_str.strip_prefix("#/components/schemas/"))
            .map(|s| s.to_string())
            .collect()
    }

    // Build extends_map: which schemas extend which base schemas
    let extends_map: HashMap<String, HashSet<String>> = schemas
        .iter()
        .filter_map(|(name, schema)| {
            schema
                .get("allOf")
                .and_then(|v| v.as_array())
                .map(|items| (name.clone(), extract_schema_refs(items)))
        })
        .filter(|(_, refs)| !refs.is_empty())
        .collect();

    // Find circular base schemas (those with oneOf where subtypes extend back)
    let circular_bases: HashSet<String> = schemas
        .iter()
        .filter_map(|(schema_name, schema)| {
            schema
                .get("oneOf")
                .and_then(|v| v.as_array())
                .and_then(|_| schema.get("discriminator"))
                .map(|_| schema_name.clone())
        })
        .filter(|schema_name| {
            schemas
                .get(schema_name)
                .and_then(|s| s.get("oneOf"))
                .and_then(|v| v.as_array())
                .map(|items| extract_schema_refs(items))
                .iter()
                .flat_map(|refs| refs.iter())
                .any(|subtype| {
                    extends_map
                        .get(subtype)
                        .map_or(false, |extended| extended.contains(schema_name))
                })
        })
        .collect();

    // Remove oneOf from circular bases
    for base_schema in circular_bases {
        if let Some(serde_json::Value::Object(obj)) = schemas.get_mut(&base_schema) {
            if obj.remove("oneOf").is_some() {
                eprintln!(
                    "note: Removed circular oneOf from schema '{}' to prevent stack overflow",
                    base_schema
                );
            }
        }
    }

    schemas
}

Why this works: The discriminator property alone is sufficient for serde/progenitor to generate correct code. The oneOf is redundant in this pattern and only causes recursion. Removing it keeps all necessary type information while breaking the cycle.

Progenitor's type resolution logic likely:

  1. Encounters BaseProfile with oneOf constraint
  2. Resolves references in oneOf array → finds ProfileTypeA, ProfileTypeB
  3. Processes ProfileTypeA, which has allOf extending BaseProfile
  4. Recursively resolves BaseProfile again → returns to step 1
  5. Never reaches a base case, causing stack overflow

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the minimal OpenAPI spec and recursion path in the issue, then trace progenitor's schema resolution for oneOf and allOf references. Verify the behavior against the circular discriminator pattern and ensure generation no longer overflows; the issue names no repository files or tests to run.

Written by the indexing model from the issue text.

Assessment

Tech stack
openapi, rust
Domain
devtools
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.