dotnet/runtime

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

Open

#107 508 ouverte le 8 sept. 2024

Voir sur GitHub
 (8 commentaires) (2 réactions) (0 assignés)C# (5 445 forks)batch import
area-System.Text.Jsonenhancementhelp wanted

Métriques du dépôt

Stars
 (17 886 stars)
Métriques de merge PR
 (Merge moyen 12j 11h) (661 PRs mergées en 30 j)

Description

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

Guide contributeur