Skip to content

Incorrect OpenAPI Schema Generation for Recursive Types #61139

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

Open
1 task done
sorcerb opened this issue Mar 24, 2025 · 6 comments
Open
1 task done

Incorrect OpenAPI Schema Generation for Recursive Types #61139

sorcerb opened this issue Mar 24, 2025 · 6 comments
Labels
area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc feature-openapi

Comments

@sorcerb
Copy link

sorcerb commented Mar 24, 2025

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

We encountered an issue where ASP.NET Core incorrectly generates OpenAPI json schema for recursive types. Specifically, the generated schema for a recursive model (Nav) is valid, but when attempting to define a separate object (Nav2) that references an array of items, it incorrectly resolves the $ref path.

Expected Behavior

The generated OpenAPI schema should correctly resolve $ref references for recursive types, ensuring Nav2.children.items properly refers to the Nav schema.

Steps To Reproduce

app.MapPost("/a", (Nav n) => "Hello World!");
app.MapPost("/b", (List<Nav> n) => "Hello World!");

public class Nav
{
    public long Id { get; set; }
    public List<Nav> Children { get; set; } = [];
}
"Nav": {
  "type": "object",
  "properties": {
	"id": {
	  "type": "integer",
	  "format": "int64"
	},
	"children": {
	  "type": "array",
	  "items": {
		"$ref": "#/components/schemas/Nav"
	  }
	}
  }
},
"Nav2": {
  "type": "object",
  "properties": {
	"id": {
	  "type": "integer",
	  "format": "int64"
	},
	"children": {
	  "type": "array",
	  "items": {
		"$ref": "#/components/schemas/#/items"   // <--- this should refers to the Nav, but actually, 
                                                         // this Nav2 is the same as Nav, so it won't be generated
	  }
	}
  }
},

Exceptions (if any)

No response

.NET Version

9.0.3

Related issues

Recursive data models inside collections yield invalid schema references #59879
.NET 9 OpenAPI produces lots of duplicate schemas for the same object #58968

@ghost ghost added the area-web-frameworks label Mar 24, 2025
@gfoidl gfoidl added feature-openapi area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc and removed area-web-frameworks labels Mar 24, 2025
@CodingBeagle
Copy link

We have this same issue, and it is making it difficult to use the generated spec file with automated deployment tools that actually expect... a valid OpenAPI spec file.

I see that there have been multiple related issues on this subject but nothing has been done about it.

@CodingBeagle
Copy link

CodingBeagle commented Apr 1, 2025

Btw, @sorcerb, I don't know if you're interested in hearing this, but you can temporarily fix this by using SchemaTransformers.

You can add a schema transformer to your OpenApiOptions instance which finds schemas containing "properties" in its reference ID, and then choose how to fix it (in our case using the supplied OpenApiSchemaTransformerContext to find the type which the property refers to, and then just set the reference ID to that type.

It's not necessarily super pretty, and there might be other ways of doing it better. But this works for our use case so far.

@iskandersierra
Copy link

I had to create a middleware for the /openapi/v1.json to manually parse/modify/re-serialize the OpenAPI spec. I leave my solution here as a workaround, if anyone needs a quick fix for now.

WARNING: This solution is not production ready with respect to memory management, because I'm serializing the whole OpenAPI spec to memory, no caching is being used, etc.

public class FixProjectsOpenApiMiddleware : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        if (context.Request.Path == "/openapi/v1.json")
        {
            var originalBody = context.Response.Body;
            using var memory = new MemoryStream();
            context.Response.Body = memory;

            await next(context);

            if (
                context.Response is { StatusCode: 200 }
                && MediaTypeHeaderValue.TryParse(
                    context.Response.Headers.ContentType.ToString(),
                    out var contentType
                )
                && contentType.MediaType == "application/json"
            )
            {
                memory.Position = 0;
                UpdateOpenApiV1Schema(memory);
                memory.Position = 0;
                context.Response.ContentLength = memory.Length;
            }

            await originalBody.WriteAsync(memory.GetBuffer().AsMemory(0, (int)memory.Length));
            context.Response.Body = originalBody;
        }
        else
        {
            await next(context);
        }
    }

    private static void UpdateOpenApiV1Schema(MemoryStream stream)
    {
        var jsonNode = JsonNode.Parse(stream);

        if (jsonNode is not null)
        {
            var resourcesSchema = jsonNode["components"]
                ?["schemas"]
                ?["Nav"]
                ?["properties"]
                ?["children"];
            if (resourcesSchema is not null)
            {
                resourcesSchema["items"] = new JsonObject
                {
                    ["$ref"] = "#/components/schemas/Nav",
                };

                stream.Position = 0;
                stream.SetLength(0);
                JsonSerializer.Serialize(
                    stream,
                    jsonNode,
                    new JsonSerializerOptions
                    {
                        WriteIndented = true,
                        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // Use with caution
                    }
                );
            }
        }
    }
}

It could be parameterized if more than one case is found in your API.

@damianog
Copy link

damianog commented Apr 22, 2025

Found this bug today with 9.04 version too and as suggested by @CodingBeagle I used a SchemaTransformer as workaraound

public class NavMenuFixSchemaTransformer : IOpenApiSchemaTransformer
{
    public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
    {
        if (context.JsonTypeInfo.Type == typeof(NavMenuItem))
        {
            if (schema.Properties.TryGetValue("children", out OpenApiSchema? value))
            {
                value.Nullable = false;
                value.Items = new OpenApiSchema
                {
                    Reference = new OpenApiReference
                    {
                        Id = nameof(NavMenuItem),
                        Type = ReferenceType.Schema
                    }
                };
            }
        }
        return Task.CompletedTask;
    }
}

and in the service initialization

        builder.Services.AddOpenApi(options =>
        {
            options.AddSchemaTransformer<NavMenuFixSchemaTransformer>();
        });

@sorcerb
Copy link
Author

sorcerb commented Apr 23, 2025

@damianog thankyou, it's work for me.

So is it a bug, or is it the expected behavior for OpenAPI? Should someone add a bug label to this issue?

@damianog
Copy link

Hi @sorcerb

here a generic version of my schema transformer to fix recursive types

public class RecursiveTypesSchemaTransformer : IOpenApiSchemaTransformer
{
    public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
    {
        var type = context.JsonTypeInfo.Type;
        var recursiveTypes = context.JsonTypeInfo.Properties
            .Where(p => 
                p.PropertyType.IsGenericType 
                && typeof(IEnumerable).IsAssignableFrom(p.PropertyType) 
                && p.PropertyType.GetGenericArguments().Contains(type)
            );
        
        foreach (var recursiveType in recursiveTypes)
        {
            if (schema.Properties.TryGetValue(recursiveType.Name, out OpenApiSchema? value))
            {
                value.Nullable = recursiveType.IsGetNullable;
                value.Items = new OpenApiSchema
                {
                    Reference = new OpenApiReference
                    {
                        Id = recursiveType.DeclaringType.Name,
                        Type = ReferenceType.Schema
                    }
                };
            }
        }

        return Task.CompletedTask;
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc feature-openapi
Projects
None yet
Development

No branches or pull requests

5 participants