diff --git a/src/libraries/System.Text.Json/ref/System.Text.Json.cs b/src/libraries/System.Text.Json/ref/System.Text.Json.cs index 440f36f30ecb14..6905b8d1e5dd52 100644 --- a/src/libraries/System.Text.Json/ref/System.Text.Json.cs +++ b/src/libraries/System.Text.Json/ref/System.Text.Json.cs @@ -907,7 +907,8 @@ public sealed partial class JsonSchemaExporterOptions { public JsonSchemaExporterOptions() { } public static System.Text.Json.Schema.JsonSchemaExporterOptions Default { get { throw null; } } - public System.Func? TransformSchemaNode { get; init; } + public System.Func? TransformSchemaNode { get { throw null; } init { } } + public bool TreatNullObliviousAsNonNullable { get { throw null; } init { } } } } namespace System.Text.Json.Serialization diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporter.cs index 6513ba6956d1f0..ca545fd0c5a381 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporter.cs @@ -349,7 +349,7 @@ JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) { bool isNullableSchema = propertyInfo != null ? propertyInfo.IsGetNullable || propertyInfo.IsSetNullable - : typeInfo.CanBeNull && !parentPolymorphicTypeIsNonNullable; + : typeInfo.CanBeNull && !parentPolymorphicTypeIsNonNullable && !state.ExporterOptions.TreatNullObliviousAsNonNullable; if (isNullableSchema) { diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporterOptions.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporterOptions.cs index c2bbdaa2d13e1a..1567ad9aded1ed 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporterOptions.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporterOptions.cs @@ -15,6 +15,16 @@ public sealed class JsonSchemaExporterOptions /// public static JsonSchemaExporterOptions Default { get; } = new(); + /// + /// Determines whether non-nullable schemas should be generated for null oblivious reference types. + /// + /// + /// Defaults to . Due to restrictions in the run-time representation of nullable reference types + /// most occurences are null oblivious and are treated as nullable by the serializer. A notable exception to that rule + /// are nullability annotations of field, property and constructor parameters which are represented in the contract metadata. + /// + public bool TreatNullObliviousAsNonNullable { get; init; } + /// /// Defines a callback that is invoked for every schema that is generated within the type graph. /// diff --git a/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs b/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs index 980287a004906d..13de2b5a0f85af 100644 --- a/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs +++ b/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs @@ -288,6 +288,27 @@ public static IEnumerable GetTestDataCore() } """); + // Same as above with non-nullable reference type handling + yield return new TestData( + Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, + AdditionalValues: [new() { Value = 1, Next = null }], + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "Value": { "type": "integer" }, + "Next": { + "type": ["object", "null"], + "properties": { + "Value": { "type": "integer" }, + "Next": { "$ref": "#/properties/Next" } + } + } + } + } + """, + Options: new() { TreatNullObliviousAsNonNullable = true }); + // Same as above but using an anchor-based reference scheme yield return new TestData( Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, @@ -398,6 +419,22 @@ public static IEnumerable GetTestDataCore() } """); + // Same as above but with non-nullable reference type handling + yield return new TestData( + Value: new() { Children = [new(), new() { Children = [] }] }, + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "Children": { + "type": "array", + "items": { "$ref" : "#" } + } + } + } + """, + Options: new() { TreatNullObliviousAsNonNullable = true }); + yield return new TestData( Value: new() { Children = new() { ["key1"] = new(), ["key2"] = new() { Children = new() { ["key3"] = new() } } } }, ExpectedJsonSchema: """ @@ -412,6 +449,22 @@ public static IEnumerable GetTestDataCore() } """); + // Same as above but with non-nullable reference type handling + yield return new TestData( + Value: new() { Children = new() { ["key1"] = new(), ["key2"] = new() { Children = new() { ["key3"] = new() } } } }, + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "Children": { + "type": "object", + "additionalProperties": { "$ref" : "#" } + } + } + } + """, + Options: new() { TreatNullObliviousAsNonNullable = true }); + yield return new TestData( Value: new() { X = 42 }, ExpectedJsonSchema: """ @@ -1390,7 +1443,7 @@ IEnumerable ITestData.GetTestDataForAllValues() { yield return this; - if (default(T) is null) + if (default(T) is null && Options?.TreatNullObliviousAsNonNullable != true) { yield return this with { Value = default, AdditionalValues = null }; } diff --git a/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.cs b/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.cs index a2e2c64116b91d..042e78ad2bd6e8 100644 --- a/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.cs @@ -41,6 +41,33 @@ public void TestTypes_SerializedValueMatchesGeneratedSchema(ITestData testData) AssertDocumentMatchesSchema(schema, instance); } + [Theory] + [InlineData(typeof(string), "string")] + [InlineData(typeof(int[]), "array")] + [InlineData(typeof(Dictionary), "object")] + [InlineData(typeof(SimplePoco), "object")] + public void TreatNullObliviousAsNonNullable_False_MarksReferenceTypesAsNullable(Type referenceType, string expectedType) + { + Assert.True(!referenceType.IsValueType); + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = false }; + JsonNode schema = Serializer.DefaultOptions.GetJsonSchemaAsNode(referenceType, config); + JsonArray arr = Assert.IsType(schema["type"]); + Assert.Equal([expectedType, "null"], arr.Select(e => (string)e!)); + } + + [Theory] + [InlineData(typeof(string), "string")] + [InlineData(typeof(int[]), "array")] + [InlineData(typeof(Dictionary), "object")] + [InlineData(typeof(SimplePoco), "object")] + public void TreatNullObliviousAsNonNullable_True_MarksReferenceTypesAsNonNullable(Type referenceType, string expectedType) + { + Assert.True(!referenceType.IsValueType); + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; + JsonNode schema = Serializer.DefaultOptions.GetJsonSchemaAsNode(referenceType, config); + Assert.Equal(expectedType, (string)schema["type"]!); + } + [Theory] [InlineData(typeof(Type))] [InlineData(typeof(MethodInfo))] @@ -95,6 +122,23 @@ public void ReferenceHandlePreserve_Enabled_ThrowsNotSupportedException() Assert.Contains("ReferenceHandler.Preserve", ex.Message); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void JsonSchemaExporterOptions_DefaultSettings(bool useSingleton) + { + JsonSchemaExporterOptions options = useSingleton ? JsonSchemaExporterOptions.Default : new(); + + Assert.False(options.TreatNullObliviousAsNonNullable); + Assert.Null(options.TransformSchemaNode); + } + + [Fact] + public void JsonSchemaExporterOptions_Default_IsSame() + { + Assert.Same(JsonSchemaExporterOptions.Default, JsonSchemaExporterOptions.Default); + } + protected void AssertValidJsonSchema(Type type, string expectedJsonSchema, JsonNode actualJsonSchema) { JsonNode? expectedJsonSchemaNode = JsonNode.Parse(expectedJsonSchema, documentOptions: new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });