dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.49k stars 10.03k forks source link

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

Open ascott18 opened 1 day ago

ascott18 commented 1 day ago

Is there an existing issue for this?

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

    [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; }
    }
``` { "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

fredriks-eltele commented 17 hours ago

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 commented 15 hours ago

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.