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.4k stars 10k forks source link

Microsoft.AspNetCore.OpenApi fails to generate document when using polymorphic types #58213

Open keenjus opened 3 weeks ago

keenjus commented 3 weeks ago

Is there an existing issue for this?

Describe the bug

Using polymorphic types and returning both the base class and derived class in separate controller actions causes OpenApi document generation to fail.

Document generation succeeds if you disable/remove public ComponentDto GetComponent() action in the provided example repository.

Also it seems like something is wrong with the generated document (when the exception causing controller action is removed). At most I would expect two model schemas to be generated

but it also generates a weird looking SectionDto schema which seems like a weird mix of ComponentDto & SectionDto

Expected Behavior

OpenApi document generation should not fail and should generate correct schemas when using polymorphic types.

Steps To Reproduce

https://github.com/keenjus/OpenApiStuff/tree/7d736b83d4ddf5b4208580b9d265d690f082d7b4

Launch the project and navigate to http://localhost:5052/openapi/v1.json. Should fail with "ArgumentException: An item with the same key has already been added. Key: ComponentDtoSectionDto "

Exceptions (if any)

ArgumentException: An item with the same key has already been added. Key: ComponentDtoSectionDto
    System.Collections.Generic.Dictionary<TKey, TValue>.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
    System.Collections.Generic.Dictionary<TKey, TValue>.Add(TKey key, TValue value)
    Microsoft.AspNetCore.OpenApi.OpenApiSchemaReferenceTransformer.TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
    Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.ApplyTransformersAsync(OpenApiDocument document, IServiceProvider scopedServiceProvider, CancellationToken cancellationToken)
    Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOpenApiDocumentAsync(IServiceProvider scopedServiceProvider, CancellationToken cancellationToken)
    Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOpenApiDocumentAsync(IServiceProvider scopedServiceProvider, CancellationToken cancellationToken)
    Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions+<>c__DisplayClass0_0+<<MapOpenApi>b__0>d.MoveNext()
    Microsoft.AspNetCore.Http.Generated.<GeneratedRouteBuilderExtensions_g>F56B68D2B55B5B7B373BA2E4796D897848BC0F04A969B1AF6260183E8B9E0BAF2__GeneratedRouteBuilderExtensionsCore+<>c__DisplayClass2_0+<<MapGet0>g__RequestHandler|5>d.MoveNext()
    Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
    Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
    Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

.NET Version

9.0.100-rc.1.24452.12

Anything else?

Microsoft.AspNetCore.OpenApi 9.0.0-rtm.24501.7

captainsafia commented 3 weeks ago

@keenjus Thanks for filing this issue, Martin! Your repro checks out for me.

I believe the hiccup here is related to the polymorphic subtype referencing the parent in the generic type argument for the collection. I'll add a test case to our suite for this and see what the fix would look like.

ComponentDtoSectionDto - does Microsoft.AspNetCore.OpenApi support custom names? I don't like the auto-generated one.

We do support customizing the names for types, but unfortunately at the moment it won't allow you to modify the name of the polymorphic type entirely (e.g. BaseType + Subtype). :/

What kind of name would you prefer to see here?

keenjus commented 3 weeks ago

Ideally I would like derived types to use their type names, so instead of ComponentDtoSectionDto it would be just SectionDto (Swashbuckle does it this way when using the same polymorphic types).

Full control over generated names would also be nice, so the current name generation wouldn't need changing.

captainsafia commented 2 weeks ago

I believe the hiccup here is related to the polymorphic subtype referencing the parent in the generic type argument for the collection. I'll add a test case to our suite for this and see what the fix would look like.

Following up on this: I'm moving this out of 9.0.0 since I need to spend a little bit more time fleshing out the fix and the window for bringing changes in .NET 9 is closing soon. This will have to come in a servicing patch for 9.0.

Full control over generated names would also be nice, so the current name generation wouldn't need changing.

Other folks have asked for this so I'll open a separate API proposal for it.

marinasundstrom commented 2 days ago

@captainsafia How do I influence the naming of schemas based on the .NET type? To remove a "Dto" suffix. I can't seem to find that in the documentation. I've tried the known transformers but no luck.

I'm moving my 20+ services from NSwag.

An equivalent to this:


    public class CustomSchemaNameGenerator : ISchemaNameGenerator
    {
        public string Generate(Type type)
        {
            if (type.IsGenericType)
            {
                return $"{type.Name.Replace("`1", string.Empty)}Of{GenerateName(type.GetGenericArguments().First())}";
            }
            return GenerateName(type);
        }

        private static string GenerateName(Type type)
        {
            return type.Name
                .Replace("Dto", string.Empty);
            //.Replace("Command", string.Empty)
            //.Replace("Query", string.Empty);
        }
    }
marinasundstrom commented 2 days ago

@captainsafia

I dug into the source code and found that the actual name that will be used as reference id, or key, in the completed schema is stored under Annotations with the key x-schema-id.

I can modify that like so:

        options.AddSchemaTransformer(static (schema, context, ct) =>
        {
            const string SchemaId = "x-schema-id";

            if (schema.Annotations?.TryGetValue(SchemaId, out var referenceIdObject) == true
                 && referenceIdObject is string newReferenceId)
            {
                newReferenceId = GenerateSchemaName(context.JsonTypeInfo.Type);

                schema.Annotations[SchemaId] = newReferenceId;

                Console.WriteLine(newReferenceId);
            }

            return Task.CompletedTask;
        });

The type OpenApiConstants is not public. Otherwise, I would have used OpenApiConstants.SchemaId.

I initially wondered why the doc.Component.Schema wasn't set in the Document Transformer. But found out that the last step of creating the document is this special transformer OpenApiSchemaReferenceTransformer that runs last.

Anyway, I'm starting to understand how this works. Not that different from any compiler having to keep track of references to types, members, and variables etc. 🙂