domaindrivendev / Swashbuckle.AspNetCore

Swagger tools for documenting API's built on ASP.NET Core
MIT License
5.21k stars 1.3k forks source link

Enum string converter is not respected using .NET 6 minimal API #2293

Open ripvannwinkler opened 2 years ago

ripvannwinkler commented 2 years ago

Using Swashbuckle.AspNetCore 6.2.3 with ASP.NET 6 minimal API, I have configured JSON serializer options per the docs.

services.Configure<JsonOptions>(options =>
{
    options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
    options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    options.SerializerOptions.ReferenceHandler = ReferenceHandler.Preserve;
});

However, Swagger UI still reports enums as ints unless I add also use AddControllers().AddJsonOptions():

services.AddControllers()
    .AddJsonOptions(options =>
    {
            options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
            options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
            options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve; 
        });

Once the second block is added, the swagger UI output references the enum string values as expected. As far as I know, the latter simply does the former under the hood, but there seems to be some order of operations affecting the schema generation here?

ripvannwinkler commented 2 years ago

Looking into this some more, it looks like the problem may be in SwaggerGenServiceCollectionExtensions.cs, line 34:

#if (!NETSTANDARD2_0)
                var serializerOptions = s.GetService<IOptions<JsonOptions>>()?.Value?.JsonSerializerOptions
                    ?? new JsonSerializerOptions();
#else
                var serializerOptions = new JsonSerializerOptions();
#endif

The problem here is that the JsonOptions type is pulled from the Microsoft.AspNetCore.Mvc namespace while .NET 6 minimal APIs are documented to use JsonOptions from the Microsoft.AspNetCore.Http.Json namespace which this code seems blissfully unaware of. Not sure what the path forward is.

RCSandberg commented 2 years ago

I ran into the same problem and my colleague suggested the following as a workaround, which works for me:

using Microsoft.AspNetCore.Http.Json;
using MvcJsonOptions = Microsoft.AspNetCore.Mvc.JsonOptions;
.....
builder.Services.Configure<JsonOptions>(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.Configure<MvcJsonOptions>(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
ripvannwinkler commented 2 years ago

@RCSandberg yep, I ended up configuring both. I'm not sure there's a better way without added complexity. Microsoft has a recent history of duplicating entire namespaces for new framework/features. I get it for compatibility reasons, but it makes things like this a real pain.

bhp15973 commented 2 years ago

You can always use NewtonsoftJson it is in nuget <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.2" />

here are my settings .AddNewtonsoftJson(options => { options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; options.SerializerSettings.Converters.Add(new StringEnumConverter()); options.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc; });

wrkntwrkn commented 2 years ago

I ran into the same problem and my colleague suggested the following as a workaround, which works for me:

using Microsoft.AspNetCore.Http.Json;
using MvcJsonOptions = Microsoft.AspNetCore.Mvc.JsonOptions;
.....
builder.Services.Configure<JsonOptions>(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.Configure<MvcJsonOptions>(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));

Is this workaround working on version net6.0 (6.0.100)? Dosen't seem to work for me. Still get a serializer exception. Using System.Text.Json 6.0.2

An unhandled exception has occurred while executing the request. Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter "MessageRequest messageRequest" from the request body as JSON. ---> System.Text.Json.JsonException: The JSON value could not be converted to Domain.Shared.Enums.MessageTypeEnum. Path: $.messageType | LineNumber: 2 | BytePositionInLine: 28.

For now im passing in HttpRequest httpRequest and deserializing it myself, which is not ideal/clean.

wrkntwrkn commented 2 years ago

To anyone facing a similar issue as me, make sure you have the right namespace when registerting your serializer globally.

NOT - Microsoft.AspNetCore.Mvc - JsonOptions YES - Microsoft.AspNetCore.Http.Json - JsonOptions

It was in the docs but i missed it when migrating to minimal api's since the namings are ver similar.

stvwndr commented 1 year ago

This has worked for me:

using System.Text.Json.Serialization;

builder.Services.Configure(options => { options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });

[https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-6.0]

dstarosta commented 1 year ago

Swashbuckle.AspNetCore 6.4.0 picks up changes from "Microsoft.AspNetCore.Mvc.JsonOptions" options. However, minimal APIs are using "Microsoft.AspNetCore.Http.Json.JsonOptions". Therefore, both of them needs to be configured for Swagger schemas to match the API output.

It would be nice if Swashbuckle can be configured to use "Microsoft.AspNetCore.Http.Json.JsonOptions".

Diaskhan commented 1 year ago

In RicoSuter/NSwag got same problem, Fixed with downgrading to
<PackageReference Include="Microsoft.Extensions.Options" Version="3.1.0" />

with <PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" /> this bug fires!

jehrenzweig-leagueapps commented 1 year ago

@wrkntwrkn and @stvwndr helped lead me to the workaround that I'm using:

builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
    options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
builder.Services.Configure<Microsoft.AspNetCore.Mvc.JsonOptions>(options =>
{
    options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
stamahto commented 1 year ago

@wrkntwrkn and @stvwndr helped lead me to the workaround that I'm using:

builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
  options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
builder.Services.Configure<Microsoft.AspNetCore.Mvc.JsonOptions>(options =>
{
  options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});

Thank you, this works as expected!

ankitvijay commented 1 year ago

The below workaround works on the swagger schema.

builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
    options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
builder.Services.Configure<Microsoft.AspNetCore.Mvc.JsonOptions>(options =>
{
    options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});

However, I had to use AddContollers to get it working with the swagger example.

builder.Services.AddControllers()
    .AddJsonOptions(options =>
        options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));

Has anyone been able to get it working with the swagger example without adding AddControllers?

jxwaters commented 1 year ago

No luck for me on either front.

        srv.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
        {
            options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
        });
        srv.Configure<Microsoft.AspNetCore.Mvc.JsonOptions>(options =>
        {
            options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
        });

Swagger still shows enums as integers:

image

Here is how I add Swagger:

    private static IServiceCollection AddSwagger(this IServiceCollection srv)
    {
        srv.AddEndpointsApiExplorer();
        srv.AddSwaggerGen(
            options =>
            {
                options.MapType<decimal>(() => new OpenApiSchema { Type = "number", Format = "decimal" });
                options.AddSecurityDefinition(
                    JwtBearerDefaults.AuthenticationScheme,
                    new OpenApiSecurityScheme
                    {
                        Description =
                            "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"",
                        Name = "Authorization",
                        In = ParameterLocation.Header,
                        Type = SecuritySchemeType.ApiKey,
                        Scheme = JwtBearerDefaults.AuthenticationScheme
                    });

                options.AddSecurityRequirement(
                    new OpenApiSecurityRequirement
                    {
                        {
                            new OpenApiSecurityScheme
                            {
                                Reference = new OpenApiReference
                                {
                                    Type = ReferenceType.SecurityScheme, Id = JwtBearerDefaults.AuthenticationScheme
                                },
                                Scheme = "oauth2",
                                Name = JwtBearerDefaults.AuthenticationScheme,
                                In = ParameterLocation.Header,
                            },
                            new List<string>()
                        }
                    });
                options.SwaggerDoc(Constants.SwaggerDocVersion,
                    new OpenApiInfo
                    {
                        Title = "TrailheadTechnology API", Version = Constants.SwaggerDocVersion, Description = "Documents the REST API"
                    });

                options.IncludeXmlComments(Assembly.GetExecutingAssembly().Location.Replace("dll", "xml"), true);
                var docPath = typeof(TokenRequest).Assembly.Location.Replace("dll", "xml");
                options.IncludeXmlComments(docPath);

                options.SchemaFilter<EnumTypesSchemaFilter>(docPath);
                options.EnableAnnotations();

            }).AddSwaggerGenNewtonsoftSupport(); // explicit opt-in

        return srv;
    }

Where

    public class EnumTypesSchemaFilter : ISchemaFilter
    {
        private readonly XDocument _xmlComments;

        public EnumTypesSchemaFilter(string xmlPath)
        {
            if (File.Exists(xmlPath))
            {
                _xmlComments = XDocument.Load(xmlPath);
            }
        }

        public void Apply(OpenApiSchema schema, SchemaFilterContext context)
        {
            if (_xmlComments == null) return;

            if (schema.Enum != null && schema.Enum.Count > 0 &&
                context.Type != null && context.Type.IsEnum)
            {
                schema.Description += "<p>Members:</p><ul>";

                var fullTypeName = context.Type.FullName;

                foreach (var enumMemberName in schema.Enum.OfType<OpenApiString>().Select(v => v.Value))
                {
                    var fullEnumMemberName = $"F:{fullTypeName}.{enumMemberName}";

                    var enumMemberComments = _xmlComments.Descendants("member")
                        .FirstOrDefault(m => m.Attribute("name").Value.Equals(fullEnumMemberName, StringComparison.OrdinalIgnoreCase));
                    if (enumMemberComments == null) continue;

                    var summary = enumMemberComments.Descendants("summary").FirstOrDefault();
                    if (summary == null) continue;

                    schema.Description += $"<li><i>{enumMemberName}</i> - {summary.Value.Trim()}</li>";

                }

                schema.Description += "</ul>";
            }
        }
    }
wsy commented 1 year ago

I'm using .Net7, and this issue still exists. Here's my project setup:

<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.5" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.18.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />

Here's my code:

public record DemoObject
{
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public DemoEnum DemoProperty { get; init; }
}

public enum DemoEnum : int { PlanA = 9, PlanB = 13 }

[Route("api/[controller]")]
[ApiController]
public class DemoController : ControllerBase
{
    [HttpGet("demo")]
    public DemoObject DemoAction() => new() { DemoProperty = DemoEnum.PlanB };
}

As you can see, I decorated enum field with JsonConverterAttribute, using JsonStringEnumConverter as attribute parameter. If I hit "execute", I can get PlanB in response as expected. However, when generating "Example value", the json serializer serialized enum field as integer, as the screenshot below demonstrates.

image

Hulkstance commented 1 year ago

I'm using .Net7, and this issue still exists. Here's my project setup:

<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.5" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.18.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />

Here's my code:

public record DemoObject
{
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public DemoEnum DemoProperty { get; init; }
}

public enum DemoEnum : int { PlanA = 9, PlanB = 13 }

[Route("api/[controller]")]
[ApiController]
public class DemoController : ControllerBase
{
    [HttpGet("demo")]
    public DemoObject DemoAction() => new() { DemoProperty = DemoEnum.PlanB };
}

As you can see, I decorated enum field with JsonConverterAttribute, using JsonStringEnumConverter as attribute parameter. If I hit "execute", I can get PlanB in response as expected. However, when generating "Example value", the json serializer serialized enum field as integer, as the screenshot below demonstrates.

image

I can confirm that the issue still persists for me too.

mindekm commented 11 months ago

I managed to get this fully working for minimal API scenarios by decorating the enum type itself with JsonConverterAttribute.

Tested on NET7 and NET8 RC1:

using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;

// var builder = WebApplication.CreateSlimBuilder(args); -NET8-
var builder = WebApplication.CreateBuilder(args);

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

var app = builder.Build();

// OK
app.MapPost("postDemo", (RequestDto dto) => TypedResults.Ok(new ResponseDto(dto.Enum)));

// Does not work, query param schema is string
app.MapGet("getDemo1", (DemoEnum? enumParameter) => TypedResults.Ok(new ResponseDto(enumParameter)));

// OK
app.MapGet("getDemo2", ([FromQuery] DemoEnum? enumParameter) => TypedResults.Ok(new ResponseDto(enumParameter)));

// OK
app.MapGet("getDemo3", ([AsParameters] Parameters parameters) => TypedResults.Ok(new ResponseDto(parameters.EnumParameter)));

app.UseSwagger();
app.UseSwaggerUI();
app.Run();

public struct Parameters
{
    [FromQuery]
    public DemoEnum? EnumParameter { get; set; }
}

public sealed record RequestDto(DemoEnum? Enum);
public sealed record ResponseDto(DemoEnum? Enum);

// [JsonConverter(typeof(JsonStringEnumConverter<DemoEnum>))] -NET8-
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum DemoEnum
{
    FirstValue,
    SecondValue,
}
ccidral commented 11 months ago
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum DemoEnum

Thanks this works for me. But of course decorating all enums with that is far from ideal, it would be nice if ASPNET respected the serializer converter.

kriscremers commented 9 months ago

I managed to get this fully working for minimal API scenarios by decorating the enum type itself with JsonConverterAttribute.

Tested on NET7 and NET8 RC1:

using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;

// var builder = WebApplication.CreateSlimBuilder(args); -NET8-
var builder = WebApplication.CreateBuilder(args);

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

var app = builder.Build();

// OK
app.MapPost("postDemo", (RequestDto dto) => TypedResults.Ok(new ResponseDto(dto.Enum)));

// Does not work, query param schema is string
app.MapGet("getDemo1", (DemoEnum? enumParameter) => TypedResults.Ok(new ResponseDto(enumParameter)));

// OK
app.MapGet("getDemo2", ([FromQuery] DemoEnum? enumParameter) => TypedResults.Ok(new ResponseDto(enumParameter)));

// OK
app.MapGet("getDemo3", ([AsParameters] Parameters parameters) => TypedResults.Ok(new ResponseDto(parameters.EnumParameter)));

app.UseSwagger();
app.UseSwaggerUI();
app.Run();

public struct Parameters
{
    [FromQuery]
    public DemoEnum? EnumParameter { get; set; }
}

public sealed record RequestDto(DemoEnum? Enum);
public sealed record ResponseDto(DemoEnum? Enum);

// [JsonConverter(typeof(JsonStringEnumConverter<DemoEnum>))] -NET8-
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum DemoEnum
{
    FirstValue,
    SecondValue,
}

Thanks, this also works in NET6!

drmohundro commented 7 months ago

Just noting that another approach here is the below lines:

    builder.Services.AddTransient<ISerializerDataContractResolver>(_ => new JsonSerializerDataContractResolver(
        new JsonSerializerOptions {
             // set whatever default options for JSON serialization you want like enums, PropertyNamingPolicy, or whatever
        }
    ));

I think the comment at https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/2293#issuecomment-991870685 is correct that this is the bug because of this code: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/8f363f7359cb1cb8fa5de5195ec6d97aefaa16b3/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/SwaggerGenServiceCollectionExtensions.cs#L33-L43

The code in the #else block just uses the default JsonSerializerOptions if the code isn't NETSTANDARD2_0 which includes net8.0.

neptaco commented 7 months ago

The following method was used to collect the Serializer settings in one place.

builder.Services.TryAddTransient<ISerializerDataContractResolver>(s =>
{
    var httpJsonOptions = s.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
    var serializerOptions = httpJsonOptions.Value.SerializerOptions;
    return new JsonSerializerDataContractResolver(serializerOptions);
});

builder.Services.AddSwaggerGen(options =>
{
    // ...
});
uladz-zubrycki commented 6 months ago

Just checked with the latest Swashbuckle packages versions and this issue still relevant. Configuring both "classic" and "minimal" JsonOptions suggested above works, yet a bit bothering to have.

hanzllcc commented 5 months ago

The following method was used to collect the Serializer settings in one place.

builder.Services.TryAddTransient<ISerializerDataContractResolver>(s =>
{
    var httpJsonOptions = s.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
    var serializerOptions = httpJsonOptions.Value.SerializerOptions;
    return new JsonSerializerDataContractResolver(serializerOptions);
});

builder.Services.AddSwaggerGen(options =>
{
    // ...
});

It works. Thanks

github-actions[bot] commented 3 months ago

This issue is stale because it has been open for 60 days with no activity. It will be automatically closed in 14 days if no further updates are made.

uladz-zubrycki commented 3 months ago

A comment to prevent this one from closing due to inactivity, as I believe it's still applicable and does have all the information for the reproduction.

joaoalpalhao commented 2 months ago

Looking into this some more, it looks like the problem may be in SwaggerGenServiceCollectionExtensions.cs, line 34:

#if (!NETSTANDARD2_0)
                var serializerOptions = s.GetService<IOptions<JsonOptions>>()?.Value?.JsonSerializerOptions
                    ?? new JsonSerializerOptions();
#else
                var serializerOptions = new JsonSerializerOptions();
#endif

The problem here is that the JsonOptions type is pulled from the Microsoft.AspNetCore.Mvc namespace while .NET 6 minimal APIs are documented to use JsonOptions from the Microsoft.AspNetCore.Http.Json namespace which this code seems blissfully unaware of. Not sure what the path forward is.

Maybe the best option would be to have something like this: builder.Services.AddSwaggerGen(options => options.EnableMinimalApi());

soligto commented 1 month ago

Isn't it because AspNetCore.Mvc.JsonOptions.JsonSerializerOptions is never null?

https://github.com/dotnet/dotnet/blob/main/src/aspnetcore/src/Mvc/Mvc.Core/src/JsonOptions.cs#L35

https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/SwaggerGenServiceCollectionExtensions.cs#L81

#if !NETSTANDARD2_0
                serializerOptions =
                    _serviceProvider.GetService<IOptions<AspNetCore.Mvc.JsonOptions>>()?.Value?.JsonSerializerOptions
#if NET8_0_OR_GREATER
                    ?? _serviceProvider.GetService<IOptions<AspNetCore.Http.Json.JsonOptions>>()?.Value?.SerializerOptions
#endif
#if NET7_0_OR_GREATER
                    ?? JsonSerializerOptions.Default;
#else
                    ?? new JsonSerializerOptions();
#endif

This code will never get SerializerOptions from AspNetCore.Http.Json.JsonOptions.