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.45k stars 10.03k forks source link

Custom types with converters cause schema generation to fail #57041

Closed Auros closed 3 months ago

Auros commented 3 months ago

Is there an existing issue for this?

Describe the bug

Using the .NET 9 version of Microsoft.AspNetCore.OpenApi (9.0.0-rc.1.24376.4), in the document generation process, upon encountering a non-standard type with a custom converter that serializes into a primitive JSON value (JsonTypeInfo.Kind is None), the generator will throw an exception related to System.Text.Json's schemas.

Expected Behavior

When a custom type with a custom converter that serializes into a JSON primitive is detected in the schema, it should have a way to represent that type as a string or number or whatever the value is supposed to be.

Steps To Reproduce

I'm using the Ulid type as an example (serializes into a string), but here is a minimal repro.

Exceptions (if any)

System.InvalidOperationException: The node must be of type 'JsonObject'.
   at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_NodeWrongType(ReadOnlySpan`1 supportedTypeNames)
   at System.Text.Json.Nodes.JsonNode.AsObject()
   at System.Text.Json.Nodes.JsonNode.set_Item(String propertyName, JsonNode value)
   at Microsoft.AspNetCore.OpenApi.JsonNodeSchemaExtensions.ApplySchemaReferenceId(JsonNode schema, JsonSchemaExporterContext context, Func`2 createSchemaReferenceId)
   at Microsoft.AspNetCore.OpenApi.OpenApiSchemaService.<>c__DisplayClass0_0.<.ctor>b__2(JsonSchemaExporterContext context, JsonNode schema)
   at System.Text.Json.Schema.JsonSchema.<ToJsonNode>g__CompleteSchema|104_0(JsonNode schema, <>c__DisplayClass104_0&)
   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.GetParametersAsync(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.100-rc.1.24377.5

Anything else?

I would have assumed a SchemaTransformer could have been used to override the type I wanted (like outlined in #56448), but it appears all transformers run after the initial document gets generated. The underlying schema generation process seems to rely on JsonSchema, which is both an internal API and only explicitly implements supports the common BCL types (primitives, string, Guid, Uri, etc.), so it can't really be overridden at the moment.

I personally think an API that allows setting a custom delegate (something similar but exactly Func<Type, OpenApiSchema?>) and having that delegate be checked first before falling back to JsonSchema could be an option. As far as I can tell, currently every type exposed to the schema gets run through a converter.GetSchema(), and if that returns null and if it's a JSON primitive, it returns the True JsonNode type instead of returning a JsonObject, thus causing the exception).

captainsafia commented 3 months ago

@Auros Thanks for taking the time to craft such a great bug report! 🙇🏽

You've identified the big crux with the problem at the moment: outside of the TransformNode API, the underlying JsonSchemaExporter doesn't provide a way for types to describe how their schemas should be generated. The notion of a MapType method like you mentioned has been documented before (see https://github.com/dotnet/aspnetcore/issues/56448). At the moment I'm inclined to maintain a smaller surface area for schema-related modifications in M.A.OpenAPI in favor of pushing for more customizations at the System.Text.Json level. This will make it a little bit easier to manage the implementation as we work towards OpenAPI v3.1 support which supports JsonSchema more fully. I hope that some of the underlying API improvements will come in .NET 10.

For .NET 9, I think the best place to land is to fix the issue that is causing the exception to be thrown and rely on schema tranformers to fix up the schema as needed.