Skip to content

ArgumentException with polymorphism in generating OpenAPI schema #57285

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

Closed
1 task done
maartenba opened this issue Aug 12, 2024 · 5 comments
Closed
1 task done

ArgumentException with polymorphism in generating OpenAPI schema #57285

maartenba opened this issue Aug 12, 2024 · 5 comments
Labels
area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi

Comments

@maartenba
Copy link

maartenba commented Aug 12, 2024

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

In 9.0.0-preview.6.24328.4, when creating OpenAPI schema that has a model with polymorphism, an exception is thrown.

Expected Behavior

Polymorphism works, and a valid OpenAPI schema is created.

Steps To Reproduce

Repro solution is attached: repro.zip

Alternatively, use this code:

using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi("v1", options =>
        {
           options.UseTransformer((document, context, cancellationToken) =>
            {
                document.Info = new()
                {
                    Title = "Animals API",
                    Version = "v1"
                };
                return Task.CompletedTask;
            });
        });

var app = builder.Build();

app.MapGroup("/api")
    .MapGet("/pets", () => new List<Animal> { new Cat(), new Dog() });

app.MapOpenApi();
app.Run();

[JsonPolymorphic(TypeDiscriminatorPropertyName = "animalType")]
[JsonDerivedType(typeof(Cat), "cat")]
[JsonDerivedType(typeof(Dog), "dog")]
abstract class Animal
{
    [Microsoft.OpenApi.Attributes.Display("Animal type")]
    [Required] 
    public string AnimalType { get; set; }
}

class Cat : Animal
{
}

class Dog : Animal
{
}

Next, request the /openapi/v1.json endpoint.

Exceptions (if any)

System.ArgumentException: An item with the same key has already been added. Key: animalType (Parameter 'key')
   at System.Collections.ThrowHelper.ThrowDuplicateKey[TKey](TKey key)
   at System.Collections.Generic.OrderedDictionary`2.TryInsert(Int32 index, TKey key, TValue value, InsertionBehavior behavior)
   at System.Collections.Generic.OrderedDictionary`2.Add(TKey key, TValue value)
   at System.Text.Json.Schema.JsonSchema.ToJsonNode(JsonSchemaExporterOptions options)
   at System.Text.Json.Schema.JsonSchema.ToJsonNode(JsonSchemaExporterOptions options)
   at System.Text.Json.Schema.JsonSchema.ToJsonNode(JsonSchemaExporterOptions options)
   at System.Text.Json.Schema.JsonSchemaExporter.GetJsonSchemaAsNode(JsonTypeInfo typeInfo, JsonSchemaExporterOptions exporterOptions)
   at System.Text.Json.Schema.JsonSchemaExporter.GetJsonSchemaAsNode(JsonSerializerOptions options, Type type, JsonSchemaExporterOptions exporterOptions)
   at Microsoft.AspNetCore.OpenApi.OpenApiSchemaService.CreateSchema(OpenApiSchemaKey key)
   at Microsoft.AspNetCore.OpenApi.OpenApiSchemaStore.GetOrAdd(OpenApiSchemaKey key, Func`2 valueFactory)
   at Microsoft.AspNetCore.OpenApi.OpenApiSchemaService.GetOrCreateSchemaAsync(Type type, ApiParameterDescription parameterDescription, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetResponseAsync(ApiDescription apiDescription, Int32 statusCode, ApiResponseType apiResponseType, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetResponsesAsync(ApiDescription description, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOperationAsync(ApiDescription description, HashSet`1 capturedTags, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOperationsAsync(IGrouping`2 descriptions, HashSet`1 capturedTags, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOpenApiPathsAsync(HashSet`1 capturedTags, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOpenApiDocumentAsync(CancellationToken cancellationToken)
   at Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions.<>c__DisplayClass0_0.<<MapOpenApi>b__0>d.MoveNext()
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Http.Generated.<GeneratedRouteBuilderExtensions_g>F56B68D2B55B5B7B373BA2E4796D897848BC0F04A969B1AF6260183E8B9E0BAF2__GeneratedRouteBuilderExtensionsCore.<>c__DisplayClass2_0.<<MapGet0>g__RequestHandler|5>d.MoveNext()
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

.NET Version

9.0.0-preview.6.24328.4 (.NET 9 preview 6)

Anything else?

No response

@dotnet-issue-labeler dotnet-issue-labeler bot added the old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels label Aug 12, 2024
@maartenba
Copy link
Author

...or is this coming in preview 7? (#54598)

@martincostello
Copy link
Member

I can repro this with .NET SDK 9.0.100-preview.7.24407.12 and Microsoft.AspNetCore.OpenApi 9.0.0-preview.7.24406.2:

System.ArgumentException: An item with the same key has already been added. Key: animalType (Parameter 'key')
   at System.Collections.ThrowHelper.ThrowDuplicateKey[TKey](TKey key)
   at System.Collections.Generic.OrderedDictionary`2.TryInsert(Int32 index, TKey key, TValue value, InsertionBehavior behavior)
   at System.Collections.Generic.OrderedDictionary`2.Add(TKey key, TValue value)
   at System.Text.Json.Schema.JsonSchema.ToJsonNode(JsonSchemaExporterOptions options)
   at System.Text.Json.Schema.JsonSchema.ToJsonNode(JsonSchemaExporterOptions options)
   at System.Text.Json.Schema.JsonSchema.ToJsonNode(JsonSchemaExporterOptions options)
   at System.Text.Json.Schema.JsonSchemaExporter.GetJsonSchemaAsNode(JsonTypeInfo typeInfo, JsonSchemaExporterOptions exporterOptions)
   at System.Text.Json.Schema.JsonSchemaExporter.GetJsonSchemaAsNode(JsonSerializerOptions options, Type type, JsonSchemaExporterOptions exporterOptions)
   at Microsoft.AspNetCore.OpenApi.OpenApiSchemaService.CreateSchema(OpenApiSchemaKey key)
   at Microsoft.AspNetCore.OpenApi.OpenApiSchemaStore.GetOrAdd(OpenApiSchemaKey key, Func`2 valueFactory)
   at Microsoft.AspNetCore.OpenApi.OpenApiSchemaService.GetOrCreateSchemaAsync(Type type, ApiParameterDescription parameterDescription, Boolean captureSchemaByRef, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetResponseAsync(ApiDescription apiDescription, Int32 statusCode, ApiResponseType apiResponseType, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetResponsesAsync(ApiDescription description, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOperationAsync(ApiDescription description, HashSet`1 capturedTags, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOperationsAsync(IGrouping`2 descriptions, HashSet`1 capturedTags, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOpenApiPathsAsync(HashSet`1 capturedTags, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOpenApiDocumentAsync(CancellationToken cancellationToken)
   at Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions.<>c__DisplayClass0_0.<<MapOpenApi>b__0>d.MoveNext()
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Http.Generated.<GeneratedRouteBuilderExtensions_g>F56B68D2B55B5B7B373BA2E4796D897848BC0F04A969B1AF6260183E8B9E0BAF2__GeneratedRouteBuilderExtensionsCore.<>c__DisplayClass2_0.<<MapGet0>g__RequestHandler|5>d.MoveNext()
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

@martincostello
Copy link
Member

martincostello commented Aug 12, 2024

Looking again at your object definitions, I don't think this is valid:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "animalType")]
[JsonDerivedType(typeof(Cat), "cat")]
[JsonDerivedType(typeof(Dog), "dog")]
abstract class Animal
{
    [Microsoft.OpenApi.Attributes.Display("Animal type")]
    [Required] 
    public string AnimalType { get; set; }
}

System.Text.Json needs the discriminator to not be a serializable property because it's "reserved" (I've run into this myself, and have code adjusted accordingly from finding that while debugging). I can't at-a-glance see where in the documentation that's explicitly called out.

If you make this edit:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "animalType")]
[JsonDerivedType(typeof(Cat), "cat")]
[JsonDerivedType(typeof(Dog), "dog")]
abstract class Animal
{
    [Microsoft.OpenApi.Attributes.Display("Animal type")]
    [Required]
+   [JsonIgnore]
    public string AnimalType { get; set; }
}

the exception changes to this:

System.InvalidOperationException: JsonPropertyInfo 'animalType' defined in type 'Animal' is marked required but does not specify a setter.
   at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_JsonPropertyRequiredAndNotDeserializable(JsonPropertyInfo jsonPropertyInfo)
   at System.Text.Json.Serialization.Metadata.JsonPropertyInfo.Configure()
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo.ConfigureProperties()
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo.Configure()
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo.<EnsureConfigured>g__ConfigureSynchronized|172_0()
   at System.Text.Json.JsonSerializerOptions.GetTypeInfoInternal(Type type, Boolean ensureConfigured, Nullable`1 ensureNotNull, Boolean resolveIfMutable, Boolean fallBackToNearestAncestorType)
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo.Configure()
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo.<EnsureConfigured>g__ConfigureSynchronized|172_0()
   at System.Text.Json.JsonSerializerOptions.GetTypeInfoInternal(Type type, Boolean ensureConfigured, Nullable`1 ensureNotNull, Boolean resolveIfMutable, Boolean fallBackToNearestAncestorType)
   at System.Text.Json.Schema.JsonSchemaExporter.GetJsonSchemaAsNode(JsonSerializerOptions options, Type type, JsonSchemaExporterOptions exporterOptions)
   at Microsoft.AspNetCore.OpenApi.OpenApiSchemaService.CreateSchema(OpenApiSchemaKey key)
   at Microsoft.AspNetCore.OpenApi.OpenApiSchemaStore.GetOrAdd(OpenApiSchemaKey key, Func`2 valueFactory)
   at Microsoft.AspNetCore.OpenApi.OpenApiSchemaService.GetOrCreateSchemaAsync(Type type, ApiParameterDescription parameterDescription, Boolean captureSchemaByRef, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetResponseAsync(ApiDescription apiDescription, Int32 statusCode, ApiResponseType apiResponseType, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetResponsesAsync(ApiDescription description, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOperationAsync(ApiDescription description, HashSet`1 capturedTags, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOperationsAsync(IGrouping`2 descriptions, HashSet`1 capturedTags, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOpenApiPathsAsync(HashSet`1 capturedTags, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.OpenApi.OpenApiDocumentService.GetOpenApiDocumentAsync(CancellationToken cancellationToken)
   at Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions.<>c__DisplayClass0_0.<<MapOpenApi>b__0>d.MoveNext()
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Http.Generated.<GeneratedRouteBuilderExtensions_g>F56B68D2B55B5B7B373BA2E4796D897848BC0F04A969B1AF6260183E8B9E0BAF2__GeneratedRouteBuilderExtensionsCore.<>c__DisplayClass2_0.<<MapGet0>g__RequestHandler|5>d.MoveNext()
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

If you make this edit, it renders the document:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "animalType")]
[JsonDerivedType(typeof(Cat), "cat")]
[JsonDerivedType(typeof(Dog), "dog")]
abstract class Animal
{
    [Microsoft.OpenApi.Attributes.Display("Animal type")]
-   [Required]
+   [JsonIgnore]
    public string AnimalType { get; set; }
}
/openapi/v1.json
{
  "openapi": "3.0.1",
  "info": {
    "title": "Animals API",
    "version": "v1"
  },
  "servers": [
    {
      "url": "https://localhost:50001"
    },
    {
      "url": "http://localhost:50000"
    }
  ],
  "paths": {
    "/api/pets": {
      "get": {
        "tags": [
          "TodoApp"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "required": [
                      "animalType"
                    ],
                    "type": "object",
                    "anyOf": [
                      {
                        "$ref": "#/components/schemas/AnimalCat"
                      },
                      {
                        "$ref": "#/components/schemas/AnimalDog"
                      }
                    ],
                    "discriminator": {
                      "propertyName": "animalType",
                      "mapping": {
                        "cat": "#/components/schemas/AnimalCat",
                        "dog": "#/components/schemas/AnimalDog"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "AnimalCat": {
        "properties": {
          "animalType": {
            "enum": [
              "cat"
            ],
            "type": "string"
          }
        }
      },
      "AnimalDog": {
        "properties": {
          "animalType": {
            "enum": [
              "dog"
            ],
            "type": "string"
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "TodoApp"
    }
  ]
}

That's not to say there isn't an issue here, I think maybe the schema and serializer behaviours are conflicting with each other.

@captainsafia
Copy link
Member

@maartenba Thanks for reporting this issue!

It looks like you're running into the same bug as #56575. I've had a really hard-time reproing this bug based on the details in the referenced issue. All my debugging would indicate that the issue is from the underlying JsonSchemaExporter APIs but we haven't been able to pin down where it is happening.

I'll take a look at your repro and see if it reveals something about the root cause that the first bug report on this didn't reveal.

In the meantime, @martincostello's workaround should help here...

@captainsafia
Copy link
Member

In the meantime, I'm going to go ahead and close this as a dupe in favor of keeping the conversation in the older issue.

@captainsafia captainsafia added area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc and removed old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels labels Aug 22, 2024
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 area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi
Projects
None yet
Development

No branches or pull requests

3 participants