JsonSchemaExporter.GetJsonSchemaAsNode is inconsistent with required properties + constructors + record structs

Open
#116,172 7 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
48/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Stale
Tech stack
csharp

Research direction

Start in src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporter.cs at the heuristic near line 247 and reproduce the issue with the AClass and AStruct examples. Compare JsonTypeInfo.IsRequired, GetJsonSchemaAsNode output, and deserialization for both record forms; done means the generated required properties consistently reflect runtime behavior.

Written by the indexing model from the issue text.

Description

area-System.Text.Json
Description

JsonSchemaExporer.GetJsonSchemaAsNode has an extra heuristic that will mark constructor parameters of classes as required in the schema it creates.

This means that it does not match the runtime behavior. Properties are marked as required that are not required at runtime.

However, this heuristic does not apply to structs. Switching the same definition between record class and record struct results in different schemas but the same deserialization behavior. (See repro).

This is similar to the discussion here: https://github.com/dotnet/extensions/issues/6080

The JsonTypeInfo is different for record struct and record because the record struct will have a parameterless constructor. As a result there are behavior differences when constructor parameter definitions/attributes result in different schemas for classes and structs.

--

This heuristic is unhelpful for my scenario 😢 and in an ideal world I could turn it off entirely.

I'm using the schemas to understand what the serializer will accept, and to diff changes to our code over time for breaking change detection. Any place where the schema generation is adding additional heuristics or assumptions that don't match the runtime behavior is another thing I have to work around.

I discovered this as part of a change where someone flipped a class to a struct, and the properties became non-required in the schema.

Reproduction Steps
using System.Text.Json;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;


var opts = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
};

void Test<T>(string json)
{
    JsonTypeInfo info = JsonSerializerOptions.Default.GetTypeInfo(typeof(T));
    foreach (var property in info.Properties)
    {
        if (property.IsRequired)
        {
            Console.WriteLine($"Property {property.Name} in {typeof(T).Name} is required.");
        }
        else
        {
            Console.WriteLine($"Property {property.Name} in {typeof(T).Name} is optional.");
        }
    }

    var schema = info.GetJsonSchemaAsNode();
    Console.WriteLine($"JSON Schema for {typeof(T).Name}:\n{schema.ToJsonString(opts)}");

    try
    {
        var obj = JsonSerializer.Deserialize<T>(json);
        Console.WriteLine($"Deserialized {typeof(T).Name} successfully: {obj}");
    }
    catch (JsonException ex)
    {
        Console.WriteLine($"Failed to deserialize {typeof(T).Name}: {ex.Message}");
    }
}

var json = "{}";
Test<AClass>(json);
Test<AStruct>(json);

public sealed record AClass(string Name, DateTimeOffset Birthday);
public readonly record struct AStruct(string Name, DateTimeOffset Birthday);
Expected behavior

I'd really like the schema output to match the runtime behavior. In this example that would be AClass having no required properties.

Actual behavior
Property Name in AClass is optional.
Property Birthday in AClass is optional.
JSON Schema for AClass:
{
  "type": [
    "object",
    "null"
  ],
  "properties": {
    "Name": {
      "type": "string"
    },
    "Birthday": {
      "type": "string",
      "format": "date-time"
    }
  },
  "required": [
    "Name",
    "Birthday"
  ]
}
Deserialized AClass successfully: AClass { Name = , Birthday = 1/1/0001 12:00:00 AM +00:00 }
Property Name in AStruct is optional.
Property Birthday in AStruct is optional.
JSON Schema for AStruct:
{
  "type": "object",
  "properties": {
    "Name": {
      "type": "string"
    },
    "Birthday": {
      "type": "string",
      "format": "date-time"
    }
  }
}
Deserialized AStruct successfully: AStruct { Name = , Birthday = 1/1/0001 12:00:00 AM +00:00 }
Regression?

No response

Known Workarounds

I could TransformSchemaNode and re-implement my own version of required.

Configuration
.NET SDK:
 Version:           9.0.300
 Commit:            15606fe0a8
 Workload version:  9.0.300-manifests.c678e91b
 MSBuild version:   17.14.5+edd3bbf37
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.