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

Open
#107,508 8 comments 3 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
35/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Stale
Tech stack
csharp, json

Research direction

Start with the GetJsonSchemaAsNode reproduction and read EnumConverter.cs at the linked source location, then inspect JsonSchemaExporterOptions and the TransformSchemaNode workaround. Done means a supported configuration can emit enum values for FlagsAttribute enums, including nullable cases, without breaking the existing non-flags behavior.

Written by the indexing model from the issue text.

Description

area-System.Text.Json enhancement help wanted
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

Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from dotnet/runtime

All issues in dotnet/runtime

Similar issues

More C# issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.