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.15k stars 9.92k forks source link

TimeSpan shown as object in OpenAPI definition is not compatible with System.Text.Json serialization #54526

Closed christiannagel closed 5 months ago

christiannagel commented 5 months ago

Is there an existing issue for this?

Describe the bug

Creating a minimal APIs project using an object containing a TimeSpan such as

public record class Test(Guid Id, TimeSpan Duration);

Creates this OpenAPI definition:

   "schemas": {
            "Test": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "string",
                        "format": "uuid"
                    },
                    "duration": {
                        "$ref": "#/components/schemas/TimeSpan"
                    }
                },
                "additionalProperties": false
            },
            "TimeSpan": {
                "type": "object",
                "properties": {
                    "ticks": {
                        "type": "integer",
                        "format": "int64"
                    },
                    "days": {
                        "type": "integer",
                        "format": "int32",
                        "readOnly": true
                    },
                    "hours": {
                        "type": "integer",
                        "format": "int32",
                        "readOnly": true
                    },
                    "milliseconds": {
                        "type": "integer",
                        "format": "int32",
                        "readOnly": true
                    },
                    "microseconds": {
                        "type": "integer",
                        "format": "int32",
                        "readOnly": true
                    },
                    "nanoseconds": {
                        "type": "integer",
                        "format": "int32",
                        "readOnly": true
                    },
                    "minutes": {
                        "type": "integer",
                        "format": "int32",
                        "readOnly": true
                    },
                    "seconds": {
                        "type": "integer",
                        "format": "int32",
                        "readOnly": true
                    },
                    "totalDays": {
                        "type": "number",
                        "format": "double",
                        "readOnly": true
                    },
                    "totalHours": {
                        "type": "number",
                        "format": "double",
                        "readOnly": true
                    },
                    "totalMilliseconds": {
                        "type": "number",
                        "format": "double",
                        "readOnly": true
                    },
                    "totalMicroseconds": {
                        "type": "number",
                        "format": "double",
                        "readOnly": true
                    },
                    "totalNanoseconds": {
                        "type": "number",
                        "format": "double",
                        "readOnly": true
                    },
                    "totalMinutes": {
                        "type": "number",
                        "format": "double",
                        "readOnly": true
                    },
                    "totalSeconds": {
                        "type": "number",
                        "format": "double",
                        "readOnly": true
                    }
                },
                "additionalProperties": false
            }
        }

Sending a POST request passing this information fails with a BadHttpRequestException because the System.Text.Json does not expect an object.

Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter "Test test" from the request body as JSON.
 ---> System.Text.Json.JsonException: The JSON value could not be converted to Test. Path: $.duration | LineNumber: 2 | BytePositionInLine: 15.
 ---> System.InvalidOperationException: Cannot get the value of a token type 'StartObject' as a string.

Expected Behavior

I would expect this to run. Having an HTTP client calling the API succeeds, just the OpenAPI document that's created is not compatible and does not succeed calling the API. Same behavior when the JSON source generator is used.

Steps To Reproduce

Create a Web API project (see this repo: https://github.com/christiannagel/issues-timestamp)

using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.MapGet("/test", () =>
{
    return new Test(Guid.NewGuid(), TimeSpan.FromSeconds(10));
});

app.MapPost("/test", (Test test) =>
{
    Console.WriteLine(test.Id);
    Console.WriteLine(test.Duration);
    return Results.Ok();
});

app.Run();

public record class Test(Guid Id, TimeSpan Duration);

Calling the API with a .NET client (also part of the source code repo) succeeds. The issue is with the Swagger OpenAPI creation, and might be an issue with https://github.com/domaindrivendev/Swashbuckle.AspNetCore or https://github.com/microsoft/OpenAPI.NET

Exceptions (if any)

Exception:

Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter "Test test" from the request body as JSON. ---> System.Text.Json.JsonException: The JSON value could not be converted to Test. Path: $.duration | LineNumber: 2 | BytePositionInLine: 15. ---> System.InvalidOperationException: Cannot get the value of a token type 'StartObject' as a string. at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_ExpectedString(JsonTokenType tokenType) at System.Text.Json.Serialization.Converters.TimeSpanConverter.Read(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options) at System.Text.Json.Serialization.JsonConverter1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue) at System.Text.Json.Serialization.Converters.SmallObjectWithParameterizedConstructorConverter5.TryRead[TArg](ReadStack& state, Utf8JsonReader& reader, JsonParameterInfo jsonParameterInfo, TArg& arg) at System.Text.Json.Serialization.Converters.SmallObjectWithParameterizedConstructorConverter5.ReadAndCacheConstructorArgument(ReadStack& state, Utf8JsonReader& reader, JsonParameterInfo jsonParameterInfo) at System.Text.Json.Serialization.Converters.ObjectWithParameterizedConstructorConverter1.ReadConstructorArgumentsWithContinuation(ReadStack& state, Utf8JsonReader& reader, JsonSerializerOptions options) at System.Text.Json.Serialization.Converters.ObjectWithParameterizedConstructorConverter1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value) at System.Text.Json.Serialization.JsonConverter1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue) at System.Text.Json.Serialization.JsonConverter1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state) --- End of inner exception stack trace --- at System.Text.Json.ThrowHelper.ReThrowWithPath(ReadStack& state, Utf8JsonReader& reader, Exception ex) at System.Text.Json.Serialization.JsonConverter1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state) at System.Text.Json.Serialization.Metadata.JsonTypeInfo1.ContinueDeserialize(ReadBufferState& bufferState, JsonReaderState& jsonReaderState, ReadStack& readStack) at System.Text.Json.Serialization.Metadata.JsonTypeInfo1.DeserializeAsync(Stream utf8Json, CancellationToken cancellationToken) at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.DeserializeAsObjectAsync(Stream utf8Json, CancellationToken cancellationToken) at Microsoft.AspNetCore.Http.HttpRequestJsonExtensions.ReadFromJsonAsync(HttpRequest request, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken) at Microsoft.AspNetCore.Http.HttpRequestJsonExtensions.ReadFromJsonAsync(HttpRequest request, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken) at Microsoft.AspNetCore.Http.RequestDelegateFactory.gTryReadBodyAsync|102_0(HttpContext httpContext, Type bodyType, String parameterTypeName, String parameterName, Boolean allowEmptyRequestBody, Boolean throwOnBadRequest, JsonTypeInfo jsonTypeInfo) --- End of inner exception stack trace --- at Microsoft.AspNetCore.Http.RequestDelegateFactory.Log.InvalidJsonRequestBody(HttpContext httpContext, String parameterTypeName, String parameterName, Exception exception, Boolean shouldThrow) at Microsoft.AspNetCore.Http.RequestDelegateFactory.g__TryReadBodyAsync|102_0(HttpContext httpContext, Type bodyType, String parameterTypeName, String parameterName, Boolean allowEmptyRequestBody, Boolean throwOnBadRequest, JsonTypeInfo jsonTypeInfo) at Microsoft.AspNetCore.Http.RequestDelegateFactory.<>cDisplayClass102_2.<b__2>d.MoveNext() --- End of stack trace from previous location --- at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext) at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider) at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

.NET Version

8.0.200

Anything else?

.NET SDK: Version: 8.0.200 Commit: 438cab6a9d Workload version: 8.0.200-manifests.5295d9b5

Runtime Environment: OS Name: Windows OS Version: 10.0.22631 OS Platform: Windows RID: win-x64 Base Path: C:\Program Files\dotnet\sdk\8.0.200\

.NET workloads installed: [aspire] Installation Source: SDK 8.0.200 Manifest Version: 8.0.0-preview.5.24162.5/8.0.100 Manifest Path: C:\Program Files\dotnet\sdk-manifests\8.0.100\microsoft.net.sdk.aspire\8.0.0-preview.5.24162.5\WorkloadManifest.json Install Type: FileBased

[maui-windows] Installation Source: VS 17.9.34622.214, VS 17.10.34707.107 Manifest Version: 8.0.10-ci.net8.10300/8.0.100 Manifest Path: C:\Program Files\dotnet\sdk-manifests\8.0.100\microsoft.net.sdk.maui\8.0.10-ci.net8.10300\WorkloadManifest.json Install Type: FileBased

[android] Installation Source: VS 17.9.34622.214, VS 17.10.34707.107 Manifest Version: 34.0.91/8.0.100 Manifest Path: C:\Program Files\dotnet\sdk-manifests\8.0.100\microsoft.net.sdk.android\34.0.91\WorkloadManifest.json Install Type: FileBased

[ios] Installation Source: VS 17.9.34622.214, VS 17.10.34707.107 Manifest Version: 17.2.8257-ci.main/8.0.100 Manifest Path: C:\Program Files\dotnet\sdk-manifests\8.0.100\microsoft.net.sdk.ios\17.2.8257-ci.main\WorkloadManifest.json Install Type: FileBased

[maccatalyst] Installation Source: VS 17.9.34622.214, VS 17.10.34707.107 Manifest Version: 17.2.8257-ci.main/8.0.100 Manifest Path: C:\Program Files\dotnet\sdk-manifests\8.0.100\microsoft.net.sdk.maccatalyst\17.2.8257-ci.main\WorkloadManifest.json Install Type: FileBased

[wasm-tools-net7] Installation Source: VS 17.9.34622.214 Manifest Version: 8.0.3/8.0.100 Manifest Path: C:\Program Files\dotnet\sdk-manifests\8.0.100\microsoft.net.workload.mono.toolchain.net7\8.0.3\WorkloadManifest.json Install Type: FileBased

Host: Version: 8.0.2 Architecture: x64 Commit: 1381d5ebd2

.NET SDKs installed: 8.0.200 [C:\Program Files\dotnet\sdk]

.NET runtimes installed: Microsoft.AspNetCore.App 6.0.27 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 7.0.16 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 8.0.2 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.NETCore.App 6.0.27 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 7.0.16 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 8.0.2 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.WindowsDesktop.App 6.0.27 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App] Microsoft.WindowsDesktop.App 7.0.16 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App] Microsoft.WindowsDesktop.App 8.0.2 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]

Other architectures found: x86 [C:\Program Files (x86)\dotnet] registered at [HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x86\InstallLocation]

Environment variables: Not set

global.json file: Not found

Learn more: https://aka.ms/dotnet/info

Download .NET: https://aka.ms/dotnet/download

captainsafia commented 5 months ago

@christiannagel Thanks for reporting this issue!

We published an announcement earlier this week regarding our OpenAPI support in ASP.NET Core. TL;DR: we're adding built-in support for OpenAPI document generation and removing Swashbuckle.AspNetCore from the templates.

I've verified that our schema generation handles things like DateOnly and TimeSpan correctly.

Since this issue is in an external component, I'm going to go ahead and close it. If you run into a similar issue when we ship our built-in support, please let us now.

christiannagel commented 5 months ago

@captainsafia thanks, looking forward to the built-in support for OpenAPI document generation