dotnet/runtime

JsonSchemaExporter.GetJsonSchemaAsNode cannot output array of fields for enum types with FlagsAttribute

Open

#107,508 创建于 2024年9月8日

在 GitHub 查看
 (8 评论) (2 反应) (0 负责人)C# (5,445 fork)batch import
area-System.Text.Jsonenhancementhelp wanted

仓库指标

Star
 (17,886 star)
PR 合并指标
 (平均合并 12天 11小时) (30 天内合并 661 个 PR)

描述

Description

According to the JSON Schema specification, enumerations are expected to select a single (possibly null) element from a list of exclusive elements, like System.DayOfWeek, and are not intended to be treated as bit fields like System.IO.FileAccess.

https://json-schema.org/draft/2020-12/json-schema-validation#name-enum

The current implementation is as follows:

  • By default, enumerations are output as integer type
  • If you register JsonStringEnumConverter to JsonSerializerOptions.Converters:
    • For enum types without FlagsAttribute, the list of fields is output with the "enum" key. ("type" is not output)
    • For enum types with FlagsAttribute, it is output as string type.

As the comments in the source code say, it is understandable that some precision is sacrificed for simplicity.

https://github.com/dotnet/runtime/blob/v9.0.0-preview.7.24405.7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs#L505

Treating an enumeration as a bit field is an implementation issue. It may be preferable to provide a list of possible values.

If you need to pass multiple values, you can define a separate array for example. Also, there are some combinations that are not allowed in bit fields, but these are not something that should be defined in a JSON Schema.

Therefore, I think it should be possible to output the list of fields with the "enum" key even for enumerations with FlagsAttribute by configuration.

Related issue: #107501

Reproduction Steps

Uses System.Text.Json 9.0.0-preview.7.24405.7. Run the following code:

var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
	Converters = { new JsonStringEnumConverter() }, // If not defined, it will be output as integer type.
	PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
	RespectNullableAnnotations = true,
	TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
	WriteIndented = true
};

var exporterOptions = new JsonSchemaExporterOptions
{
	TreatNullObliviousAsNonNullable = true,
};

var schema = serializerOptions.GetJsonSchemaAsNode(typeof(TestModel), exporterOptions);
Console.WriteLine(schema.ToJsonString(serializerOptions));

public class TestModel
{
	public required DayOfWeek? EnumRequiredAllowNull { get; set; }
	public required DayOfWeek EnumRequiredNotNull { get; set; }
	public required FileAccess? EnumWithFlagsRequiredAllowNull { get; set; }
	public required FileAccess EnumWithFlagsRequiredNotNull { get; set; }
}

Expected behavior

This does not have to be the default behavior, but the configuration should support it.

{
  "type": "object",
  "properties": {
    "enum_required_allow_null": {
      "enum": [
        "Sunday",
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday",
        null
      ]
    },
    "enum_required_not_null": {
      "enum": [
        "Sunday",
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday"
      ]
    },
    "enum_with_flags_required_allow_null": {
      "enum": [
        "Read",
        "Write",
        "ReadWrite",
        null
      ]
    },
    "enum_with_flags_required_not_null": {
      "enum": [
        "Read",
        "Write",
        "ReadWrite"
      ]
    }
  },
  "required": [
    "enum_required_allow_null",
    "enum_required_not_null",
    "enum_with_flags_required_allow_null",
    "enum_with_flags_required_not_null"
  ]
}

Actual behavior

  • If you register JsonStringEnumConverter to JsonSerializerOptions.Converters:
    • For enum types without FlagsAttribute, the list of fields is output with the "enum" key. ("type" is not output)
    • For enum types with FlagsAttribute, it is output as string type.
{
  "type": "object",
  "properties": {
    "enum_required_allow_null": {
      "enum": [
        "Sunday",
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday",
        null
      ]
    },
    "enum_required_not_null": {
      "enum": [
        "Sunday",
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday"
      ]
    },
    "enum_with_flags_required_allow_null": {
      "type": [
        "string",
        "null"
      ]
    },
    "enum_with_flags_required_not_null": {
      "type": "string"
    }
  },
  "required": [
    "enum_required_allow_null",
    "enum_required_not_null",
    "enum_with_flags_required_allow_null",
    "enum_with_flags_required_not_null"
  ]
}

Regression?

No response

Known Workarounds

Uses JsonSchemaExporterOptions.TransformSchemaNode. I generated the "Expected behavior" schema with the code below.

var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
  Converters = { new JsonStringEnumConverter() },
  WriteIndented = true,
  NumberHandling = JsonNumberHandling.Strict,
  PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
  RespectNullableAnnotations = true,
  TypeInfoResolver = new DefaultJsonTypeInfoResolver()
};

var exporterOptions = new JsonSchemaExporterOptions
{
  TreatNullObliviousAsNonNullable = true,
  TransformSchemaNode = OnTransformSchemaNode
};

var schema = serializerOptions.GetJsonSchemaAsNode(typeof(TestModel), exporterOptions);
Console.WriteLine(schema.ToJsonString(serializerOptions));

JsonNode OnTransformSchemaNode(JsonSchemaExporterContext context, JsonNode node)
{
  if(context.TypeInfo.Kind != JsonTypeInfoKind.Object)
  {
    return node;
  }

  var current = node.AsObject();

  if(!current.TryGetPropertyValue("properties", out var properties))
  {
    return current;
  }

  if(properties is not JsonObject propertiesObject)
  {
    return current;
  }

  // Searches within "properties" and if the property type is enum, removes "type" and outputs "enum".
  for(int i = 0; i < propertiesObject.Count; i++)
  {
    if(propertiesObject[i] is not JsonObject property)
    {
      continue;
    }

    if(!IsEnumType(context.TypeInfo, property.GetPropertyName(), out var enumType, out var isNullable))
    {
      continue;
    }

    TryUpdateEnum(property, enumType, isNullable);
  }

  return current;
}

void TryUpdateEnum(JsonObject property, Type enumType, bool isNullable)
{
  if(property.TryGetPropertyValue("type", out _))
  {
    property.Remove("type");
  }

  if(!property.TryGetPropertyValue("enum", out _))
  {
    var values = new JsonArray();
    foreach(var value in Enum.GetNames(enumType))
    {
      values.Add(value);
    }

    if(isNullable)
    {
      values.Add(null);
    }

    property.Add("enum", values);
  }
}

bool IsEnumType(JsonTypeInfo typeInfo, string propertyName, [NotNullWhen(true)] out Type? type, out bool isNullable)
{
  type = null;
  isNullable = false;

  // Determines if a property is an enum type, including case that it is nullable type.
  var t = typeInfo.Properties.FirstOrDefault(r => r.Name == propertyName);

  if(t == null)
  {
    return false;
  }

  var underlyingType = Nullable.GetUnderlyingType(t.PropertyType);

  type = (t is { PropertyType.IsEnum: true }) ? t.PropertyType :
      (underlyingType is { IsEnum: true }) ? underlyingType : null;
  isNullable = underlyingType is not null;

  return type != null;
}

public class TestModel
{
  public required DayOfWeek? EnumRequiredAllowNull { get; set; }
  public required DayOfWeek EnumRequiredNotNull { get; set; }
  public required FileAccess? EnumWithFlagsRequiredAllowNull { get; set; }
  public required FileAccess EnumWithFlagsRequiredNotNull { get; set; }
}

Configuration

No response

Other information

No response

贡献者指南