Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

.NET 9 OpenAPI produces lots of duplicate schemas for the same object #58968

Closed
1 task done
ascott18 opened this issue Nov 15, 2024 · 44 comments
Closed
1 task done

.NET 9 OpenAPI produces lots of duplicate schemas for the same object #58968

ascott18 opened this issue Nov 15, 2024 · 44 comments
Assignees
Labels
area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi
Milestone

Comments

@ascott18
Copy link
Contributor

ascott18 commented Nov 15, 2024

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

A very simple circular/recursive data model produces a ton of duplicate schema definitions like Object, Object2, Object3, Object4, and so on.

Expected Behavior

Each class in C# is represented in the OpenAPI schema only once, as was the case with Swashbuckle.

Steps To Reproduce

  • Brand new .NET 9 Web API from template, with OpenAPI enabled.
  • Replace WeatherForecastController:
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        [HttpGet("GetParent")]
        public ParentObject GetParent() => new();

        [HttpGet("GetChild")]
        public ChildObject GetChild() => new();
    }

    public class ParentObject
    {
        public int Id { get; set; }
        public List<ChildObject> Children { get; set; } = [];
    }

    public class ChildObject
    {
        public int Id { get; set; }
        public ParentObject? Parent { get; set; }
    }
  • Launch app
  • Look at /openapi/v1.json
  • Observe ChildObject2, ParentObject2, ParentObject3, which shouldn't exist.
{
  "openapi": "3.0.1",
  "info": {
    "title": "aspnet9api | v1",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://localhost:7217"
    },
    {
      "url": "http://localhost:5067"
    }
  ],
  "paths": {
    "/WeatherForecast/GetParent": {
      "get": {
        "tags": [
          "WeatherForecast"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/ParentObject"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ParentObject"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/ParentObject"
                }
              }
            }
          }
        }
      }
    },
    "/WeatherForecast/GetChild": {
      "get": {
        "tags": [
          "WeatherForecast"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/ChildObject2"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChildObject2"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChildObject2"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ChildObject": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "parent": {
            "$ref": "#/components/schemas/ParentObject2"
          }
        }
      },
      "ChildObject2": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "parent": {
            "$ref": "#/components/schemas/ParentObject3"
          }
        }
      },
      "ParentObject": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "children": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChildObject"
            }
          }
        }
      },
      "ParentObject2": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "children": {
            "$ref": "#/components/schemas/#/properties/children"
          }
        },
        "nullable": true
      },
      "ParentObject3": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "children": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChildObject"
            }
          }
        },
        "nullable": true
      }
    }
  },
  "tags": [
    {
      "name": "WeatherForecast"
    }
  ]
}

.NET Version

9.0.100

Anything else?

In my real-world application, the worst offending model has been duplicated 39 times:
Image

@dotnet-issue-labeler dotnet-issue-labeler bot added the area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates label Nov 15, 2024
ascott18 added a commit to IntelliTect/Coalesce that referenced this issue Nov 15, 2024
@fredriks-eltele
Copy link

Did some fiddling as we too meet this problem, but without circular references.

Crafted the most minimal repro I could:

[ApiController]
[Route("[controller]")]
public class BadSchemaController : ControllerBase
{
    public record Root(Branch Prop1, Branch Prop2, Branch Prop3);
    public record Branch(Thing Thing);
    public record Thing();
    [HttpGet(Name = "GetBadSchema")]
    public Root Get() => throw new NotImplementedException();
}

This results in the type Branch2, which is used by Prop2 and Prop3.
Interestingly, if public record Branch(Thing Thing) is replaced by public record Branch(string StringThing), the problem does not occur.

.NET 9.0.100 with <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />

@fredriks-eltele
Copy link

Okay so OpenaApiSchemaService.CreateSchema calls into System.Text.Json.JsonSchemaExporter.GetJsonSchemaAsNode, which calls MapJsonSchemaCore.

During processing of Prop1, everything goes normally and we get a {Type = Branch, Kind = Object}.
Subsequent processing will short-circuit in MapJsonSchemaCore since state.TryGetExistingJsonPointer() will succeed, so new JsonSchema { Ref = existingJsonPointer } is returned. Type is never changed, so it defaults to JsonSchemaType.Any, which at some point becomes null back in OpenApi-land.

Back in the OpenApiSchemaService when trying to add Prop2, equality is checked with OpenApiSchemaComparer.Equals (OpenApiSchemaStore.AddOrUpdateSchemaByReference() {... if (SchemasByReference.TryGetValue(schema, out var referenceId) || captureSchemaByRef) ... } where SchemasByReference has the schema comparer as its equality comparer)

The very first check after null-checks and reference equality checks in OpenApiSchemaComparer.Equals is x.Type == y.Type. This fails since "object" == null is false), the objects are deemed not the same, and SchemasByReference[schema] = $"{targetReferenceId}{counter}" puts us in duplicate-land.

I'm not sure if this actually points in the right direction towards a solution (naïve idea being to set the Type when returning the Ref JsonSchema), I was just bored during my lunch break and wanted to look a bit deeper into it.

@csteenbock
Copy link

I am also seeing this behavior. I've noticed that the duplicates aren't perfectly identical either. You can see that teachers and students are correct for one of these, while principal is correct for the other. BTW, this generated schemas for 6 different SchoolDtos...

"SchoolDto": {
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "name": {
      "type": "string"
    },
    "principal": {
      "$ref": "#/components/schemas/PrincipalDto"
    },
    "streetAddress": {
      "type": "string"
    },
    "city": {
      "type": "string"
    },
    "state": {
      "type": "string"
    },
    "zipCode": {
      "type": "string"
    },
    "teachers": {
      "$ref": "#/components/schemas/#/items/properties/teacher/properties/school/properties/principal/properties/school/properties/teachers"
    },
    "students": {
      "$ref": "#/components/schemas/#/items/properties/teacher/properties/school/properties/principal/properties/school/properties/students"
    }
  },
  "nullable": true
},
"SchoolDto2": {
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "name": {
      "type": "string"
    },
    "principal": {
      "$ref": "#/components/schemas/#/items/properties/teacher/properties/school/properties/principal"
    },
    "streetAddress": {
      "type": "string"
    },
    "city": {
      "type": "string"
    },
    "state": {
      "type": "string"
    },
    "zipCode": {
      "type": "string"
    },
    "teachers": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/TeacherDto2"
      }
    },
    "students": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/#/items"
      }
    }
  },
  "nullable": true
}

@gedbac
Copy link

gedbac commented Nov 19, 2024

Same problem inside Azure-Samples/eShopOnAzure. Nested types are not treated as same types.

Image

@akamor
Copy link

akamor commented Nov 21, 2024

We see it as well. Interestingly, we can make the problem go away if we remove our Description attribute from above each property which uses the type being duplicated. I mention this because I haven't seen anyone else mention it.

public abstract class Model
{
    public SomeEnum GeneratorDefault { get; set; } = SomeEnum.Value1;

    public SomeEnum OtherGeneratorDefault { get; set; } = SomeEnum.Value2;
}

yields a single schema object for SomeEnum. If we add a [Description()] for both properties then all of a suddent we get two schema objects, one called SomeEnum and the other called SomeEnum1.

Anyone have a reasonable workaround for this?

@akamor
Copy link

akamor commented Nov 22, 2024

Has anyone come up with a viable workaround here? I was planning on addressing it with a DocumentTransformer but unfortunately the Document transformer, while it is called after my schema transformers, does not seem to have access to the schema objects.

document.Components.Schemas

remains empty when the DocumentTranformer.TransformAsync is invoked.

@mikekistler
Copy link
Contributor

I also encountered this problem in eShop. In that case and maybe others it seems to come down to a difference in the "nullable" attribute in two uses of the schema. When the schema is for a property, it is marked as "nullable: true" because System.Text.Json accepts the value "null" and treats it as "not provided". When the schema is for the "items" of an array, "null" is not allowed so the schema defaults to "nullable: false".

I found that I could fix the duplicate schema problem in eShop with a schema transformer that removes nullable: true from any properties that are "optional" (not listed in the "required" keyword of the schema). The transformer is pretty small so I'll just include the code here in case anyone else wants to try it:

    options.AddSchemaTransformer((schema, context, cancellationToken) =>
    {
        if (schema.Properties is not null)
        {
            foreach (var property in schema.Properties)
            {
                if (schema.Required?.Contains(property.Key) != true)
                {
                    property.Value.Nullable = false;
                }
            }
        }
        return Task.CompletedTask;
    });

@motuzko
Copy link

motuzko commented Nov 27, 2024

Same here with minimal API:

https://github.com/motuzko/open-api-bug

As the result, a set of duplicate entities (CurrencyValuesWithUsd/CurrencyValuesWithUsd2) is generated in the OpenAPI spec

@emiliovmq
Copy link

emiliovmq commented Dec 2, 2024

Hi, any viable solution here? We're dealing with the same issue here. If we do:

/// <summary>
/// Defines the staff information.
/// </summary>
[Description("Defines the staff information.")]
public sealed class Staff
{
    /// <summary>
    /// Gets the staff identifier.
    /// </summary>
    [Description("The staff identifier.")]
    [JsonRequired]
    [Required]
    public int Id { get; init; }

    /// <summary>
    /// Gets the staff first name.
    /// </summary>
    [Description("The staff first name.")]
    [JsonRequired]
    [Required]
    [MaxLength(30)]
    public string FirstName { get; init; } = default!;

    /// <summary>
    /// Gets the staff middle name.
    /// </summary>
    [Description("The staff middle name.")]
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    [MaxLength(30)]
    public string? MiddleName { get; init; }

    /// <summary>
    /// Gets the staff last name.
    /// </summary>
    [Description("The staff last name.")]
    [JsonRequired]
    [Required]
    [MaxLength(30)]
    public string LastName { get; init; } = default!;

    /// <summary>
    /// Gets the position code.
    /// </summary>
    [Description("The position code.")]
    [JsonRequired]
    [Required]
    [MaxLength(3)]
    public string PositionCode { get; init; } = default!;

    /// <summary>
    /// Gets the position name.
    /// </summary>
    [Description("The position name.")]
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    [MaxLength(30)]
    public string? PositionName { get; init; }

    /// <summary>
    /// Gets a value indicating whether the position is for a manager or not.
    /// </summary>
    [Description("A value indicating whether the position is for a manager or not.")]
    [JsonRequired]
    [Required]
    public bool IsManager { get; init; }
}

it works!!! It generates only one scheme for Staff

"Staff": {
        "required": [
          "id",
          "firstName",
          "lastName",
          "positionCode",
          "isManager"
        ],
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "The staff identifier.",
            "format": "int32"
          },
          "firstName": {
            "maxLength": 30,
            "type": "string",
            "description": "The staff first name."
          },
          "middleName": {
            "maxLength": 30,
            "type": "string",
            "description": "The staff middle name.",
            "nullable": true
          },
          "lastName": {
            "maxLength": 30,
            "type": "string",
            "description": "The staff last name."
          },
          "positionCode": {
            "maxLength": 3,
            "type": "string",
            "description": "The position code."
          },
          "positionName": {
            "maxLength": 30,
            "type": "string",
            "description": "The position name.",
            "nullable": true
          },
          "isManager": {
            "type": "boolean",
            "description": "A value indicating whether the position is for a manager or not."
          }
        },
        "description": "Defines the staff information."
      },

But if we do (as it should be, not the other way):

/// <summary>
/// Defines the position.
/// </summary>
[Description("Defines the position.")]
public sealed class Position
{
    /// <summary>
    /// Gets the position code.
    /// </summary>
    [Description("The position code.")]
    [JsonRequired]
    [Required]
    [MaxLength(3)]
    public string Code { get; init; } = default!;

    /// <summary>
    /// Gets the position name.
    /// </summary>
    [Description("The position name.")]
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    [MaxLength(30)]
    public string? Name { get; init; }

    /// <summary>
    /// Gets a value indicating whether the position is for a manager or not.
    /// </summary>
    [Description("A value indicating whether the position is for a manager or not.")]
    [JsonRequired]
    [Required]
    public bool IsManager { get; init; }
}

/// <summary>
/// Defines the staff information.
/// </summary>
[Description("Defines the staff information.")]
public sealed class Staff
{
    /// <summary>
    /// Gets the staff identifier.
    /// </summary>
    [Description("The staff identifier.")]
    [JsonRequired]
    [Required]
    public int Id { get; init; }

    /// <summary>
    /// Gets the staff first name.
    /// </summary>
    [Description("The staff first name.")]
    [JsonRequired]
    [Required]
    [MaxLength(30)]
    public string FirstName { get; init; } = default!;

    /// <summary>
    /// Gets the staff middle name.
    /// </summary>
    [Description("The staff middle name.")]
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    [MaxLength(30)]
    public string? MiddleName { get; init; }

    /// <summary>
    /// Gets the staff last name.
    /// </summary>
    [Description("The staff last name.")]
    [JsonRequired]
    [Required]
    [MaxLength(30)]
    public string LastName { get; init; } = default!;

    /// <summary>
    /// Gets the position.
    /// </summary>
    [JsonRequired]
    [Required]
    public Position Position { get; init; } = default!;
}

It does not work; 2 schemes are generated for Staff

"Position": {
        "required": [
          "code",
          "isManager"
        ],
        "type": "object",
        "properties": {
          "code": {
            "maxLength": 3,
            "type": "string",
            "description": "The position code."
          },
          "name": {
            "maxLength": 30,
            "type": "string",
            "description": "The position name.",
            "nullable": true
          },
          "isManager": {
            "type": "boolean",
            "description": "A value indicating whether the position is for a manager or not."
          }
        },
        "description": "Defines the position."
      },
"Staff": {
        "required": [
          "id",
          "firstName",
          "lastName",
          "position"
        ],
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "The staff identifier.",
            "format": "int32"
          },
          "firstName": {
            "maxLength": 30,
            "type": "string",
            "description": "The staff first name."
          },
          "middleName": {
            "maxLength": 30,
            "type": "string",
            "description": "The staff middle name.",
            "nullable": true
          },
          "lastName": {
            "maxLength": 30,
            "type": "string",
            "description": "The staff last name."
          },
          "position": {
            "$ref": "#/components/schemas/Position"
          }
        },
        "description": "Defines the staff information."
      },
      "Staff2": {
        "required": [
          "id",
          "firstName",
          "lastName",
          "position"
        ],
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "The staff identifier.",
            "format": "int32"
          },
          "firstName": {
            "maxLength": 30,
            "type": "string",
            "description": "The staff first name."
          },
          "middleName": {
            "maxLength": 30,
            "type": "string",
            "description": "The staff middle name.",
            "nullable": true
          },
          "lastName": {
            "maxLength": 30,
            "type": "string",
            "description": "The staff last name."
          },
          "position": {
            "$ref": "#/components/schemas/#/properties/details/items/properties/staff/properties/position"
          }
        },
        "description": "Defines the staff information."
      }

Any idea?

@ascott18
Copy link
Contributor Author

ascott18 commented Dec 2, 2024

@captainsafia Sorry for the ping, but I know you worked a lot on the OpenAPI stuff. Wasn't sure if you'd seen this yet - it essentially makes the entire feature completely unusable beyond the most trivial applications.

@jankaltenecker
Copy link

Schemas are not reused for nullable and all kind of metadata differences (description, example, minItems, minLength, pattern, ...).

For this model:

public enum MyEnum
{
    Value1,
    Value2
}

public class MyModel
{
    public MyEnum MyEnum { get; init; }
    public MyEnum? MyEnumNullable { get; init; }
    [Description("Description")]
    public MyEnum MyEnumWithDescription { get; init; }
    [Description("Description")]
    public MyEnum? MyEnumNullableWithDescription { get; init; }
    /// <example>Value1</example>
    [OpenApiExample("Value1")]
    public MyEnum MyEnumWithExample { get; init; }
    /// <example>Value1</example>
    [OpenApiExample("Value1")]
    public MyEnum? MyEnumNullableWithExample { get; init; }
    /// <example>Value1</example>
    [OpenApiExample("Value1")]
    [Description("Description")]
    public MyEnum MyEnumWithDescriptionAndExample { get; init; }
    /// <example>Value1</example>
    [OpenApiExample("Value1")]
    [Description("Description")]
    public MyEnum? MyEnumNullableWithDescriptionAndExample { get; init; }
}

I get this with Swashbuckle generator:

  • Only 1 schema for MyEnum
  • Metadata is inlined
  • "allOf" is used
      "MyEnum": {
        "enum": [
          "Value1",
          "Value2"
        ],
        "type": "string"
      },

      "MyModel": {
        "type": "object",
        "properties": {
          "myEnum": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MyEnum"
              }
            ]
          },
          "myEnumNullable": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MyEnum"
              }
            ],
            "nullable": true
          },
          "myEnumWithDescription": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MyEnum"
              }
            ],
            "description": "Description"
          },
          "myEnumNullableWithDescription": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MyEnum"
              }
            ],
            "description": "Description",
            "nullable": true
          },
          "myEnumWithExample": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MyEnum"
              }
            ],
            "example": "Value1"
          },
          "myEnumNullableWithExample": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MyEnum"
              }
            ],
            "nullable": true,
            "example": "Value1"
          },
          "myEnumWithDescriptionAndExample": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MyEnum"
              }
            ],
            "description": "Description",
            "example": "Value1"
          },
          "myEnumNullableWithDescriptionAndExample": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MyEnum"
              }
            ],
            "description": "Description",
            "nullable": true,
            "example": "Value1"
          }
        },
        "additionalProperties": false
      }

And this with .NET generator:

      "MyEnum": {
        "type": "integer"
      },
      "MyEnum2": {
        "type": "integer",
        "description": "Description"
      },
      "MyEnum3": {
        "type": "integer",
        "example": "Value1"
      },
      "MyEnum4": {
        "type": "integer",
        "description": "Description",
        "example": "Value1"
      },
      "NullableOfMyEnum": {
        "type": "integer",
        "nullable": true
      },
      "NullableOfMyEnum2": {
        "type": "integer",
        "description": "Description",
        "nullable": true
      },
      "NullableOfMyEnum3": {
        "type": "integer",
        "nullable": true,
        "example": "Value1"
      },
      "NullableOfMyEnum4": {
        "type": "integer",
        "description": "Description",
        "nullable": true,
        "example": "Value1"
      },

      "MyModel": {
        "type": "object",
        "properties": {
          "myEnum": {
            "$ref": "#/components/schemas/MyEnum"
          },
          "myEnumNullable": {
            "$ref": "#/components/schemas/NullableOfMyEnum"
          },
          "myEnumWithDescription": {
            "$ref": "#/components/schemas/MyEnum2"
          },
          "myEnumNullableWithDescription": {
            "$ref": "#/components/schemas/NullableOfMyEnum2"
          },
          "myEnumWithExample": {
            "$ref": "#/components/schemas/MyEnum3"
          },
          "myEnumNullableWithExample": {
            "$ref": "#/components/schemas/NullableOfMyEnum3"
          },
          "myEnumWithDescriptionAndExample": {
            "$ref": "#/components/schemas/MyEnum4"
          },
          "myEnumNullableWithDescriptionAndExample": {
            "$ref": "#/components/schemas/NullableOfMyEnum4"
          }
        }
      },

@jaliyaudagedara
Copy link

jaliyaudagedara commented Dec 16, 2024

Has anyone come up with a viable workaround here? I was planning on addressing it with a DocumentTransformer but unfortunately the Document transformer, while it is called after my schema transformers, does not seem to have access to the schema objects.

document.Components.Schemas

remains empty when the DocumentTranformer.TransformAsync is invoked.

@akamor, yeah, I was trying to use a DocumentTransformer hoping it would have access to Schemas, but surprisingly it doesn't 😢. I'd think DocumentTransformer to run last, so the consumer has access to full document for custom overrides, but that needs to be discussed separately (could be another issue).

@captainsafia
Copy link
Member

Hi everyone! First of all, thank you for your patience here. Most important info: this bug has been fixed in .NET 10 and back-ported to .NET 9. You'll find it in the next servicing release for .NET 9.

Why did this bug happen? The crux of the issue comes from the incompatibility between schemas generated by System.Text.Json, which comply strictly with the JSON Schema specification, and those expected by the OpenAPI.NET package which are a superset of JSON Schema. STJ uses relative references to capture recursive or duplicate type references. OpenAPI.NET does not recognize these as equivalent which results in duplicate schemas.

In .NET 9, we fix this by introducing logic to our custom comparers to treat relative references as equivalent to any generated type.

In .NET 10, we're upgrading to the new version of the OpenAPI.NET package which adds in built-in support for being able to resolve these relative references and detect "loop-backs" in the schema model.

I'll keep this issue open until the next .NET 9 servicing release ships. In the meantime, happy to answer any questions about the issue.

@willieburger
Copy link

Thank you - looking forward to the new servicing release.

@jaliyaudagedara
Copy link

@captainsafia, thanks. Seems wasn't included in 9.0.1 and labeled for 9.0.2. Looking forward to it!

.

@akamor
Copy link

akamor commented Jan 15, 2025

@captainsafia Thanks for fixing this. One other issue that was pointed out by myself and others when trying to work-around this is that the DocumentTransformer does not have access to the Schemas modified by the SchemaTransformer (even though it is called afterwards).

document.Components.Schemas

This seems a bit odd and I'm wondering if that is by design or also a bug?

@motuzko
Copy link

motuzko commented Jan 16, 2025

Tried 9.0.1 - the bug is still there. Any info on when 9.0.2 will be released?

@fuegans4213
Copy link

We have the same problem here and i confirm that is not included in

9.0.1

@UltraWelfare
Copy link

@JustArchi I wrote this powershell script to rewrite parts of the generated JSON spec:
FixSpec.ps1

You can weave it into your MSBuild project using this target:

Thank you!

For everyone on the same road as me, this requires to have the document generated at build time.
The MS Documentation says:

To add support for generating OpenAPI documents at build time, install the Microsoft.Extensions.ApiDescription.Server package:
Upon installation, this package will automatically generate the Open API document(s) associated with the application during build and populate them into the application's output directory.

For my project this couldn't be further from the truth. I had to add these following parameters to property group:

<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>bin\$(Configuration)\$(TargetFramework)</OpenApiDocumentsDirectory>

I don't know why these aren't listed in the MS documentation.

@mikekistler
Copy link
Contributor

@UltraWelfare Is your project a .NET 9 project? If so, please open a separate issue for this, as the behavior should be as it is documented -- no extra configuration parameters needed.

@mguinness
Copy link

If anybody finds a workaround that we can use before the patch lands in 9.0.2, I'd appreciate it as well.

Thanks for resolving this issue, we're blocked by it as well 👍

Have you tried the schema transformer in #58968 (comment), it worked for me.

@JustArchi
Copy link

JustArchi commented Feb 4, 2025

@mguinness I've given it a try but unfortunately the dupes are still generated in my case. It's especially visible since I also have another custom transformer that runs on all enums, and with the issue mentioned here the dupe property doesn't have transformer executed for it, resulting in following json generated:

      "EUIMode": {
        "type": "integer"
      },
      "EUIMode2": {
        "type": "integer",
        "x-definition": {
          "VGUI": 0,
          "Tenfoot": 1,
          "Mobile": 2,
          "Web": 3,
          "ClientUI": 4,
          "MobileChat": 5,
          "EmbeddedClient": 6,
          "Unknown": -1
        }
      },

@domints
Copy link

domints commented Feb 8, 2025

@UltraWelfare Is your project a .NET 9 project? If so, please open a separate issue for this, as the behavior should be as it is documented -- no extra configuration parameters needed.

Doesn't happen for me either. Plain basic .NET 9 project:

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>7d4d266c-4c89-4f84-b9fe-09f8448ff306</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="MediatR" Version="12.4.1" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.1" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.1" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.1">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Scalar.AspNetCore" Version="2.0.2" />
  </ItemGroup>

EDIT: nvm. I didn't notice in this message, that I need another nuget. However installing it made the build fail, I have yet to see what is it complaining about.
It's saying System.NotSupportedException: Database type <> is not supported (yet?).

Considering all things around this OpenAPI stuff, it feels like it was a bit rushed to be honest...

@mikekistler
Copy link
Contributor

@domints There are some tricky aspects to generating the OpenAPI at build time. These is documentation about this:

Customizing run-time behavior during build-time document generation

Have a look there to see if that helps. If not please file an issue on the docs so we can expand or clarify that information.

@thoekstraorama
Copy link

@mikekistler
Is there a possibility to use appsettings.development.json while generating a document? Currently my build is failing because I'm missing a required section. I would prefer not to use the solution provided in the link below as I have all the required sections in the appsettings.

@domints There are some tricky aspects to generating the OpenAPI at build time. These is documentation about this:

Customizing run-time behavior during build-time document generation

Have a look there to see if that helps. If not please file an issue on the docs so we can expand or clarify that information.

@danroth27 danroth27 marked this as a duplicate of #59677 Feb 10, 2025
@danroth27 danroth27 marked this as a duplicate of #59427 Feb 10, 2025
@danroth27 danroth27 added this to the 9.0.x milestone Feb 10, 2025
@akamor
Copy link

akamor commented Feb 11, 2025

Has anyone confirmed the fix in 9.0.2 which was just released? I am still seeing the issue of duplicate schema objects.

@adamthewilliam
Copy link

adamthewilliam commented Feb 11, 2025

@akamor I am experiencing this issue with 9.0.2 as well. I only get this issue when using the MSBuild OpenApi document generator and it works perfectly fine with swaggergen... None of the above suggested fixes work.

public record SelectOptionResponseDto(string Label, Guid Value)
{
    public bool? Disabled { get; set; }
    
    public Dictionary<string, string>? Metadata { get; set; }
    
    public IReadOnlyList<SelectOptionResponseDto>? Children { get; set; }
}
"SelectOptionResponseDto": {
        "required": [
          "label",
          "value"
        ],
        "type": "object",
        "properties": {
          "label": {
            "type": "string"
          },
          "value": {
            "type": "string",
            "format": "uuid"
          },
          "disabled": {
            "type": "boolean"
          },
          "metadata": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "children": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/#/items"
            }
          }
        }
      },

@domints
Copy link

domints commented Feb 11, 2025

@akamor I am experiencing this issue with 9.0.2 as well. I only get this issue when using the MSBuild OpenApi document generator and it works perfectly fine with swaggergen... None of the above suggested fixes work.

Yup, got the recurring model issue as well. As for the duplicate schemas - it somehow fixed itself when I was reworking DTOs in my app, on 9.0.1, so can't test if it got fixed on 9.0.2

@akamor
Copy link

akamor commented Feb 11, 2025

@captainsafia I'm still seeing the issue. Specifically on a model that references an enum value on two different properties I'm seeing the enum repeated twice in the schema section of the JSON document.

@jaliyaudagedara
Copy link

jaliyaudagedara commented Feb 12, 2025

Still seeing the same issue. Number of duplications have reduced, but still there are duplicates.

Sample: https://github.com/jaliyaudagedara/repro-aspnetcore-openapi-dup-schemas

With 9.0.0
Image

With 9.0.2
Image

@mguinness
Copy link

Updated to <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" /> and not working, still need workaround from #58968 (comment).

@Chrille79
Copy link

Issue #58915 has been closed in favor of this one, but it describes a different problem where the references are invalid in the schema. That issue also remains unresolved in version 9.0.2

@adamthewilliam
Copy link

adamthewilliam commented Feb 12, 2025

My solution is just using Scalar UI with swagger.json. If you'd like to do the same, I have provided an example of my ServiceCollection extension methods that I'm using. The important thing is to use this fluent method WithOpenApiRoutePattern("/swagger/v1/swagger.json") to use your swagger.json with scalar.

private static void AddOpenApiConfiguration(this IServiceCollection services)
    {
        services.AddOpenApi();
        
        services.AddEndpointsApiExplorer();
        services.ConfigureOptions<ConfigureSwaggerOptions>();
        
        services.AddSwaggerGen(options =>
        {
            options.SchemaGeneratorOptions.SupportNonNullableReferenceTypes = true;

            options.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
            {
                Type = SecuritySchemeType.Http,
                Scheme = "bearer",
                BearerFormat = "JWT",
                Description = "JWT Authorization header using the Bearer scheme."
            });

            options.AddSecurityRequirement(new OpenApiSecurityRequirement
            {
                {
                    new OpenApiSecurityScheme
                    {
                        Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
                    },
                    []
                }
            });

            options.SwaggerDoc("v1",
                new OpenApiInfo
                {
                    Title = "V1 API",
                    Version = "v1",
                    Description = "Description",
                    TermsOfService = new Uri("https://scalar-swagger-example.com"),
                }
            );
        });
    }
    
    public static void AddScalarConfiguration(this WebApplication app)
    {
        if (app.Environment.IsDevelopment())
        {
            app.MapScalarApiReference(options =>
            {
                options
                    .WithTitle("V1 API")
                    .WithTheme(ScalarTheme.DeepSpace)
                    .WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient)
                    .WithOpenApiRoutePattern("/swagger/v1/swagger.json");

                options.Servers ??= new List<ScalarServer>();
                options.Servers.Add(new ScalarServer("http://localhost:5063"));
            });
        }
    }

Make sure you invoke the above AddOpenApiConfiguration() extension on your service collection and also have something like this in your program.cs:

    if (app.Environment.IsDevelopment())
    {
        app.UseSwagger();
        app.MapOpenApi().CacheOutput();
        app.AddScalarConfiguration(); 
    }

@haverjes
Copy link

Issue #58915 has been closed in favor of this one, but it describes a different problem where the references are invalid in the schema. That issue also remains unresolved in version 9.0.2

I get the same problem. Specifically when multiple collections of a schema are present:

public class TestOpenApiResult
{
    public List<SingleItemDataResult> FirstListOfItems { get; init; }
    public SingleItemDataResult MainItem { get; init; }
    public SingleItemDataResult SecondMainItem { get; init; }
    public List<SingleItemDataResult> SecondListOfItems {get; init;}
    public SingleItemDataResult[] FirstArrayOfItems {get; init;}
}

Results in the following invalid schema:

"TestOpenApiResult": {
        "type": "object",
        "properties": {
          "firstListOfItems": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SingleItemDataResult"
            },
            "nullable": true
          },
          "mainItem": {
            "$ref": "#/components/schemas/SingleItemDataResult2"
          },
          "secondMainItem": {
            "$ref": "#/components/schemas/SingleItemDataResult2"
          },
          "secondListOfItems": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/#/items/properties/firstListOfItems/items"
            },
            "nullable": true
          },
          "firstArrayOfItems": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/#/items/properties/firstListOfItems/items"
            },
            "nullable": true
          }
        },
        "nullable": true
      }

This output was generated using 9.0.2, and is completely unchanged from 9.0.1

We can prevent the duplicate with a schema transformer to force all instances of SingleItemDataResult to be nullable, but the invalid entries are proving to be a blocking issue with client generation.

@JustArchi
Copy link

In my case .NET 9.0.2 resolved the issue with duplicate enums I suffered from mostly, so it seems the situation at least got better 👍

@captainsafia
Copy link
Member

Hello everyone! Thanks for trying out the 9.0.2 release that was shipped yesterday.

Thanks to @jaliyaudagedara and @haverjes for sharing repros here. It appears that the issues you are running into deal with the same kinds of object types. I've pulled out the bug report for this into #60339.

Issue #58915 has been closed in favor of this one, but it describes a different problem where the references are invalid in the schema. That issue also remains unresolved in version 9.0.2

Upon further inspection, it appears that this is actually the case because the schemas that you are representing are distinct. One is a nullable type and the other is a non-nullable type.

@captainsafia I'm still seeing the issue. Specifically on a model that references an enum value on two different properties I'm seeing the enum repeated twice in the schema section of the JSON document.

Similar to the above, is there a chance that the nullability differs between the two properties you're comparing here? If so, the schemas are not actually duplicates since they differ in nullability. In this case, you may want to consider using the CreateSchemaId option to modify the name used for non-nullable vs. nullable enums in your project.

@vitasystems
Copy link

I do not use "nullable types" feature in any of my projects. Never have.

This is still an issue in 9.0.2 even very simple classes are not being handled properly.

Image

Feels like it somehow does not take into account the full C# type name when generating the schemas so it ends up with many "clones" of the same class.

By the way this works properly in Swashbuckle.

I dont mean to beat a dead horse here. But i think the whole "remove swagger from templates" and "we have a new official open api generator" thing was a bit hasty.

Even the configuration of the Json options does not work still.... It only takes into account Minimal API which I would bet money it is not what most people are using... Not even for greenfield dev.

This feature is like super important I would even say it is core to any API in modern times. The fact that this has never worked on any version of NET 9 feels like it needs more testing and maybe go back to alpha.

@captainsafia
Copy link
Member

I do not use "nullable types" feature in any of my projects. Never have.

@vitasystems Based on the screenshot you shared, it looks like what you're running into is a variant of #60339. If you're able to share more repro details over there, that might help affirm if my hunch is correct.

Even the configuration of the Json options does not work still.... It only takes into account Minimal API which I would bet money it is not what most people are using... Not even for greenfield dev.

Yes, this is unfortunately a known issue. MVC has long has its own set of JSON options that are optimized for MVC-specific binding APIs. We introduced ConfigureHttpJsonOptions in .NET 7 to provide a way for JSON options to be configured for components outside of minimal APIs. This includes the OpenAPI package, but other features as well like the ProblemDetails service and the exception middleware. Simply put, we need a way to customize JSON behavior throughout ASP.NET Core and relying on MVC's JSON options type means you have to bring in higher-level MVC assemblies into lower-level APIs which is a no go.

I dont mean to beat a dead horse here. But i think the whole "remove swagger from templates" and "we have a new official open api generator" thing was a bit hasty. This feature is like super important I would even say it is core to any API in modern times. The fact that this has never worked on any version of NET 9 feels like it needs more testing and maybe go back to alpha.

I totally understand your frustration here. Nobody likes bugs and it's disappointing when things don't behave the way they should. However, that shouldn't be taken as a reflection of a feature not being important. Having OpenAPI support built-in to the framework has helped generate some activity in this space, like support for JSON Schema in STJ and OpenAPI 3.1 support and .NET-centric packages for UIs like Scalar, that ultimately accrues to a net benefit for the ecosystem as a whole.

@Was85
Copy link

Was85 commented Feb 13, 2025

I Have a problem with the example below:

[Route("api/[controller]")]
[ApiController]
public class DemoController : ControllerBase
{
   [HttpPost(Name = "GetPersons")]
   public IActionResult GetPersons(List<Person> persons)
   {
       return Ok(persons);
   }
}

public class Person
{
   public string Name { get; set; }
   public int Age { get; set; }

   public List<Course> Courses { get; set; }
}

public class Course 
{
   public string Name { get; set; }
   public int Grade { get; set; }

   public List<Person> Persons { get; set; }   
}

OpenApi json:

{
  "openapi": "3.0.1",
  "info": {
    "title": "WebApplication1 | v1",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://localhost:7271"
    },
    {
      "url": "http://localhost:5253"
    }
  ],
  "paths": {
    "/api/Demo": {
      "post": {
        "tags": [
          "Demo"
        ],
        "operationId": "GetPersons",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Person"
                }
              }
            },
            "text/json": {
              "schema": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Person"
                }
              }
            },
            "application/*+json": {
              "schema": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Person"
                }
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Course": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "grade": {
            "type": "integer",
            "format": "int32"
          },
          "persons": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/#/items"
            }
          }
        }
      },
      "Person": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "age": {
            "type": "integer",
            "format": "int32"
          },
          "courses": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Course"
            }
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "Demo"
    }
  ]
}

However, the issue disappears when the parameter is not a List

[HttpPost(Name = "GetPerson")]
public IActionResult GetPerson(Person person)
{
return Ok(person);
}

@captainsafia
Copy link
Member

OK! Thank you for all the follow-up feedback here. I've investigated the reports provided and it seems like all the reports are variants of #60339.

I've verified that the bug does not repro in .NET 10, which uses the new version of Microsoft.OpenApi that has built-in support for JSON schema references.

I've got a PR out to release/9.0 so that this can be included in the next servicing release that we can snap to.

I'll close this issue out in favor of tracking this new bug instance on the aforementioned issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi
Projects
None yet
Development

No branches or pull requests