Skip to content
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

[Bug]: All enum values show up as "null" when using JSON Source Generation #3217

Open
lschloetterer opened this issue Jan 10, 2025 · 3 comments
Labels
bug help-wanted A change up for grabs for contributions from the community version-minor A change suitable for release in a minor version

Comments

@lschloetterer
Copy link

lschloetterer commented Jan 10, 2025

Describe the bug

When using this setting in the .csproj file:

<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>

All enum values are null

Expected behavior

Enum values are serialized to integers by default (or strings when using the JSON string enum converter)

Actual behavior

All enum values are null:
UI: "Available values : null, null, null"

swagger.json:

 "components": {
        "schemas": {
            "TimeRange": {
                "enum": [
                    null,
                    null,
                    null
                ],
                "type": "integer",
                "format": "int32"
            }
        }
    }

Steps to reproduce

  1. Disable reflection based serialization in .csproj:
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
  1. create JSON context:
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(TimeRange))]
[JsonSerializable(typeof(IEnumerable<WeatherForecast>))]
internal partial class SourceGenerationContext : JsonSerializerContext
{
}
  1. Register context in Program.cs:
builder.Services.AddControllers()
  .AddJsonOptions(o => o.JsonSerializerOptions.TypeInfoResolverChain.Add(SourceGenerationContext.Default));
  1. Add Controller with enum URL Parameter:
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{

  [HttpGet("by-range/{range}")]
  public IEnumerable<WeatherForecast> Get(TimeRange range)
  {

    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
      TemperatureC = Random.Shared.Next(-20, 55)
    })
    .ToArray();
  }
}
public class WeatherForecast
{
  public int TemperatureC { get; set; }
}
public enum TimeRange
{
  DAILY,
  WEEKLY,
  MONTHLY
}

Exception(s) (if any)

No response

Swashbuckle.AspNetCore version

7.2.0

.NET Version

8.0.401

Anything else?

No response

@martincostello martincostello self-assigned this Jan 12, 2025
@martincostello
Copy link
Collaborator

martincostello commented Jan 12, 2025

The nulls come from this code:

public static IOpenApiAny CreateFromJson(string json)
{
try
{
var jsonElement = JsonSerializer.Deserialize<JsonElement>(json);
return CreateFromJsonElement(jsonElement);
}
catch { }
return null;
}

The JsonSerializerOptions options are not passed to the call to JsonSerializer.Deserialize(), which then throws due to reflection-based serialization being disabled. This is swallowed by the catch block, which then falls through to return null.

There's no workaround for this - we need to do some non-trivial refactoring to get this passed through for System.Text.Json while still making sense for the abstractions associated with Newtonsoft.Json.

The problem is that we convert the enum to a JSON string using the relevant settings, but we then convert it back to a JsonElement to get the values to put in the enum schema description, but we don't have the relevant abstractions/API surface on DataContract to do the reverse.

Getting this to work properly in all cases doesn't look like a trivial piece of work, so I don't think there's a quick fix for this.

@martincostello martincostello added the help-wanted A change up for grabs for contributions from the community label Jan 12, 2025
@martincostello martincostello removed their assignment Jan 12, 2025
@martincostello martincostello added the version-minor A change suitable for release in a minor version label Jan 12, 2025
@martincostello
Copy link
Collaborator

There's no workaround for this

Actually, you should be able to use a Schema Filter to post-process the schema to fill in the values yourself:

internal class EnumSchemaFilter(IOptions<JsonOptions> options) : ISchemaFilter
{
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        if (context.Type.IsEnum)
        {
            schema.Enum = context.Type.GetEnumValues()
                .Cast<object>()
                .Select(ToJson)
                .Distinct()
                .Select(FromJson)
                .ToList();
        }

        string ToJson(object value)
            => JsonSerializer.Serialize(value, options.Value.JsonSerializerOptions);

        IOpenApiAny FromJson(string value)
            => CreateFromJson(value, options.Value.JsonSerializerOptions);
    }

    // Below code copied from OpenApiAnyFactory

    private static IOpenApiAny CreateFromJson(string json, JsonSerializerOptions options)
    {
        var element = JsonSerializer.Deserialize<JsonElement>(json, options);
        return CreateFromJsonElement(element);
    }

    private static IOpenApiAny CreateOpenApiArray(JsonElement jsonElement)
    {
        var openApiArray = new OpenApiArray();

        foreach (var item in jsonElement.EnumerateArray())
        {
            openApiArray.Add(CreateFromJsonElement(item));
        }

        return openApiArray;
    }

    private static IOpenApiAny CreateOpenApiObject(JsonElement jsonElement)
    {
        var openApiObject = new OpenApiObject();

        foreach (var property in jsonElement.EnumerateObject())
        {
            openApiObject.Add(property.Name, CreateFromJsonElement(property.Value));
        }

        return openApiObject;
    }

    private static IOpenApiAny CreateFromJsonElement(JsonElement jsonElement)
    {
        if (jsonElement.ValueKind == JsonValueKind.Null)
            return new OpenApiNull();

        if (jsonElement.ValueKind == JsonValueKind.True || jsonElement.ValueKind == JsonValueKind.False)
            return new OpenApiBoolean(jsonElement.GetBoolean());

        if (jsonElement.ValueKind == JsonValueKind.Number)
        {
            if (jsonElement.TryGetInt32(out int intValue))
                return new OpenApiInteger(intValue);

            if (jsonElement.TryGetInt64(out long longValue))
                return new OpenApiLong(longValue);

            if (jsonElement.TryGetSingle(out float floatValue) && !float.IsInfinity(floatValue))
                return new OpenApiFloat(floatValue);

            if (jsonElement.TryGetDouble(out double doubleValue))
                return new OpenApiDouble(doubleValue);
        }

        if (jsonElement.ValueKind == JsonValueKind.String)
            return new OpenApiString(jsonElement.ToString());

        if (jsonElement.ValueKind == JsonValueKind.Array)
            return CreateOpenApiArray(jsonElement);

        if (jsonElement.ValueKind == JsonValueKind.Object)
            return CreateOpenApiObject(jsonElement);

        throw new System.ArgumentException($"Unsupported value kind {jsonElement.ValueKind}");
    }
}

Obviously that isn't ideal, but it should work until we can make this work.

A short-term solution would be to add the ability to pass JsonSerializerOptions into the call to OpenApiAnyFactory.CreateFromJson() so the copy pasta isn't needed, which would simplify the filter to:

internal class EnumSchemaFilter(IOptions<JsonOptions> options) : ISchemaFilter
{
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        if (context.Type.IsEnum)
        {
            schema.Enum = context.Type.GetEnumValues()
                .Cast<object>()
                .Select(ToJson)
                .Distinct()
                .Select(FromJson)
                .ToList();
        }

        string ToJson(object value)
            => JsonSerializer.Serialize(value, options.Value.JsonSerializerOptions);

        IOpenApiAny FromJson(string value)
            => OpenApiAnyFactory.CreateFromJson(value, options.Value.JsonSerializerOptions);
    }
}

martincostello added a commit to martincostello/Swashbuckle.AspNetCore that referenced this issue Jan 12, 2025
- Add overload to `OpenApiAnyFactory.CreateFromJson()` that supports passing in a `JsonSerializerOptions` as a workaround for domaindrivendev#3217.
- Use concrete types as suggested by analyzers.
- Move unshipped APIs to shipped.
- Bump version to 7.3.0.
@lschloetterer
Copy link
Author

Thank you for the workaround, it seems to work fine for now.

For anyone stumbling across this, you also need to add

[JsonSerializable(typeof(JsonElement))]

to the JsonSerializerContext for the SchemaFilter to work

martincostello added a commit that referenced this issue Jan 20, 2025
- Add overload to `OpenApiAnyFactory.CreateFromJson()` that supports passing in a `JsonSerializerOptions` as a workaround for #3217.
- Use concrete types as suggested by analyzers.
- Move unshipped APIs to shipped.
- Bump version to 7.3.0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug help-wanted A change up for grabs for contributions from the community version-minor A change suitable for release in a minor version
Projects
None yet
Development

No branches or pull requests

2 participants