diff --git a/Directory.Packages.props b/Directory.Packages.props index 8a594e0a..f6dbaf45 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,54 +1,52 @@ true - true - 4.8.0 + 5.3.0 4.3.0 - + - + - - - - - - - - - - - - - - - - - + + + + + + - + + + + + + + + + + + + + - - - \ No newline at end of file + diff --git a/src/PolyType.Examples/ConfigurationBinder/ConfigurationBinder.Builder.cs b/src/PolyType.Examples/ConfigurationBinder/ConfigurationBinder.Builder.cs index 3e504d58..c3e62956 100644 --- a/src/PolyType.Examples/ConfigurationBinder/ConfigurationBinder.Builder.cs +++ b/src/PolyType.Examples/ConfigurationBinder/ConfigurationBinder.Builder.cs @@ -313,15 +313,15 @@ private static IEnumerable> GetBuiltInParsers() yield return Create(text => float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); yield return Create(text => double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); yield return Create(text => decimal.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); - yield return Create(text => string.IsNullOrEmpty(text) ? null : text); + yield return Create(text => text); yield return Create(char.Parse); yield return Create(Guid.Parse); yield return Create(text => TimeSpan.Parse(text, CultureInfo.InvariantCulture)); yield return Create(text => DateTime.Parse(text, CultureInfo.InvariantCulture)); yield return Create(text => DateTimeOffset.Parse(text, CultureInfo.InvariantCulture)); - yield return Create(text => text is "" ? null : new Uri(text, UriKind.RelativeOrAbsolute)); - yield return Create(text => text is "" ? null : Version.Parse(text)); - yield return Create(text => text is "" ? null : Convert.FromBase64String(text)); + yield return Create(text => new Uri(text, UriKind.RelativeOrAbsolute)); + yield return Create(Version.Parse); + yield return Create(Convert.FromBase64String); #if NET yield return Create(text => UInt128.Parse(text, NumberStyles.Integer, CultureInfo.InvariantCulture)); yield return Create(text => Int128.Parse(text, NumberStyles.Integer, CultureInfo.InvariantCulture)); @@ -332,8 +332,6 @@ private static IEnumerable> GetBuiltInParsers() #endif yield return Create(text => - text is null ? new object() : - text is "" ? null : bool.TryParse(text, out bool boolResult) ? boolResult : int.TryParse(text, out int intResult) ? intResult : double.TryParse(text, out double doubleResult) ? doubleResult : @@ -367,6 +365,11 @@ private static Func CreateValueBinder(Func pars throw new InvalidOperationException(); } + if (section.Value is null && default(T) is null) + { + return default!; + } + try { return parser(section.Value!); @@ -379,11 +382,10 @@ private static Func CreateValueBinder(Func pars } private static Func CreateNotSupportedBinder() => - config => default(T) is null && IsNullConfiguration(config) ? default! : throw new NotSupportedException($"Type '{typeof(T)}' is not supported."); + config => IsNullConfiguration(config) ? default! : throw new NotSupportedException($"Type '{typeof(T)}' is not supported."); private static bool IsNullConfiguration(IConfiguration configuration) => - // https://github.com/dotnet/runtime/issues/36510 - configuration is IConfigurationSection { Value: "" } && + configuration is IConfigurationSection { Value: null } && !configuration.GetChildren().Any(); } diff --git a/src/PolyType.Examples/JsonSchema/JsonSchemaGenerator.cs b/src/PolyType.Examples/JsonSchema/JsonSchemaGenerator.cs index 10dd915b..22f6f51d 100644 --- a/src/PolyType.Examples/JsonSchema/JsonSchemaGenerator.cs +++ b/src/PolyType.Examples/JsonSchema/JsonSchemaGenerator.cs @@ -18,6 +18,8 @@ public static class JsonSchemaGenerator public static JsonObject Generate(ITypeShapeProvider typeShapeProvider) => Generate(typeShapeProvider.GetTypeShapeOrThrow()); + private const string MetaSchemaUri = "https://json-schema.org/draft/2020-12/schema"; + /// /// Generates a JSON schema using the specified shape. /// @@ -28,42 +30,7 @@ public static JsonObject Generate(ITypeShape typeShape) /// Generates a JSON schema using the specified method shape. /// public static JsonObject Generate(IMethodShape methodShape) - { - JsonObject? parameterSchemas = null; - JsonArray? requiredParams = null; - foreach (var parameter in methodShape.Parameters) - { - if (parameter.ParameterType.Type == typeof(CancellationToken)) - { - continue; - } - - (parameterSchemas ??= []).Add(parameter.Name, Generate(parameter.ParameterType)); - if (parameter.IsRequired) - { - (requiredParams ??= []).Add((JsonNode)parameter.Name); - } - } - - JsonObject functionSchema = new JsonObject - { - ["name"] = methodShape.Name, - ["type"] = "object", - }; - - if (parameterSchemas is not null) - { - functionSchema["properties"] = parameterSchemas; - } - - if (requiredParams is not null) - { - functionSchema["required"] = requiredParams; - } - - functionSchema["output"] = Generate(methodShape.ReturnType); - return functionSchema; - } + => new Generator().GenerateMethodSchema(methodShape); #if NET /// @@ -87,13 +54,57 @@ private sealed class Generator private readonly Dictionary<(Type, bool AllowNull), string> _locations = new(); private readonly List _path = new(); - public JsonObject GenerateSchema(ITypeShape typeShape, bool allowNull = true, bool cacheLocation = true) + public JsonObject GenerateMethodSchema(IMethodShape methodShape) + { + JsonObject? parameterSchemas = null; + JsonArray? requiredParams = null; + foreach (var parameter in methodShape.Parameters) + { + if (parameter.ParameterType.Type == typeof(CancellationToken)) + { + continue; + } + + Push("properties"); + Push(parameter.Name); + (parameterSchemas ??= []).Add(parameter.Name, GenerateSchema(parameter.ParameterType, depth: 1)); + Pop(); + Pop(); + if (parameter.IsRequired) + { + (requiredParams ??= []).Add((JsonNode)parameter.Name); + } + } + + JsonObject functionSchema = new JsonObject + { + ["name"] = methodShape.Name, + ["type"] = "object", + }; + + if (parameterSchemas is not null) + { + functionSchema["properties"] = parameterSchemas; + } + + if (requiredParams is not null) + { + functionSchema["required"] = requiredParams; + } + + Push("output"); + functionSchema["output"] = GenerateSchema(methodShape.ReturnType, depth: 1); + Pop(); + return CompleteDocument(functionSchema, allowNull: false, depth: 0); + } + + public JsonObject GenerateSchema(ITypeShape typeShape, bool allowNull = true, bool cacheLocation = true, int depth = 0) { allowNull = allowNull && IsNullableType(typeShape.Type); if (s_simpleTypeInfo.TryGetValue(typeShape.Type, out SimpleTypeJsonSchema simpleType)) { - return ApplyNullability(simpleType.ToSchemaDocument(), allowNull); + return CompleteDocument(simpleType.ToSchemaDocument(), allowNull, depth); } if (cacheLocation) @@ -125,12 +136,12 @@ public JsonObject GenerateSchema(ITypeShape typeShape, bool allowNull = true, bo break; case IOptionalTypeShape optionalShape: - schema = GenerateSchema(optionalShape.ElementType, cacheLocation: false); - ApplyNullability(schema, allowNull: true); + schema = GenerateSchema(optionalShape.ElementType, cacheLocation: false, depth: depth + 1); + allowNull = true; break; case ISurrogateTypeShape surrogateShape: - return GenerateSchema(surrogateShape.SurrogateType, cacheLocation: false); + return CompleteDocument(GenerateSchema(surrogateShape.SurrogateType, cacheLocation: false, depth: depth + 1), allowNull: false, depth); case IEnumerableTypeShape enumerableShape: for (int i = 0; i < enumerableShape.Rank; i++) @@ -138,7 +149,7 @@ public JsonObject GenerateSchema(ITypeShape typeShape, bool allowNull = true, bo Push("items"); } - schema = GenerateSchema(enumerableShape.ElementType); + schema = GenerateSchema(enumerableShape.ElementType, depth: depth + 1); for (int i = 0; i < enumerableShape.Rank; i++) { @@ -155,7 +166,7 @@ public JsonObject GenerateSchema(ITypeShape typeShape, bool allowNull = true, bo case IDictionaryTypeShape dictionaryShape: Push("additionalProperties"); - JsonObject additionalPropertiesSchema = GenerateSchema(dictionaryShape.ValueType); + JsonObject additionalPropertiesSchema = GenerateSchema(dictionaryShape.ValueType, depth: depth + 1); Pop(); schema = new JsonObject @@ -191,7 +202,7 @@ public JsonObject GenerateSchema(ITypeShape typeShape, bool allowNull = true, bo (associatedParameter is null || associatedParameter.IsNonNullable); Push(prop.Name); - JsonObject propSchema = GenerateSchema(prop.PropertyType, allowNull: !isNonNullable); + JsonObject propSchema = GenerateSchema(prop.PropertyType, allowNull: !isNonNullable, depth: depth + 1); Pop(); properties.Add(prop.Name, propSchema); @@ -220,7 +231,7 @@ public JsonObject GenerateSchema(ITypeShape typeShape, bool allowNull = true, bo foreach (IUnionCaseShape caseShape in unionShape.UnionCases) { Push($"{anyOf.Count}"); - JsonObject caseSchema = GenerateSchema(caseShape.UnionCaseType, cacheLocation: false); + JsonObject caseSchema = GenerateSchema(caseShape.UnionCaseType, cacheLocation: false, depth: depth + 1); Pop(); if (caseShape.UnionCaseType is IObjectTypeShape or IDictionaryTypeShape) @@ -274,7 +285,7 @@ public JsonObject GenerateSchema(ITypeShape typeShape, bool allowNull = true, bo if (!unionCasesContainBaseType) { Push($"{anyOf.Count}"); - JsonNode caseSchema = GenerateSchema(unionShape.BaseType, cacheLocation: false); + JsonNode caseSchema = GenerateSchema(unionShape.BaseType, cacheLocation: false, depth: depth + 1); Pop(); anyOf.Add(caseSchema); @@ -293,7 +304,7 @@ public JsonObject GenerateSchema(ITypeShape typeShape, bool allowNull = true, bo break; } - return ApplyNullability(schema, allowNull); + return CompleteDocument(schema, allowNull, depth); } private void Push(string name) @@ -306,11 +317,11 @@ private void Pop() _path.RemoveAt(_path.Count - 1); } - private static JsonObject ApplyNullability(JsonObject schema, bool allowNull) + private static JsonObject CompleteDocument(JsonObject schema, bool allowNull, int depth) { if (allowNull && schema.TryGetPropertyValue("type", out JsonNode? typeValue)) { - if (schema["type"] is JsonArray types) + if (typeValue is JsonArray types) { types.Add((JsonNode)"null"); } @@ -320,6 +331,11 @@ private static JsonObject ApplyNullability(JsonObject schema, bool allowNull) } } + if (depth == 0) + { + schema.Insert(0, "$schema", MetaSchemaUri); + } + return schema; } diff --git a/src/PolyType.Examples/PolyType.Examples.csproj b/src/PolyType.Examples/PolyType.Examples.csproj index 675676e0..8a75530e 100644 --- a/src/PolyType.Examples/PolyType.Examples.csproj +++ b/src/PolyType.Examples/PolyType.Examples.csproj @@ -29,8 +29,11 @@ - + + + + diff --git a/tests/PolyType.Benchmarks/Directory.Packages.props b/tests/PolyType.Benchmarks/Directory.Packages.props deleted file mode 100644 index e4f0f97b..00000000 --- a/tests/PolyType.Benchmarks/Directory.Packages.props +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/tests/PolyType.SourceGenerator.UnitTests/CompilationHelpers.cs b/tests/PolyType.SourceGenerator.UnitTests/CompilationHelpers.cs index 8c43b615..7801149a 100644 --- a/tests/PolyType.SourceGenerator.UnitTests/CompilationHelpers.cs +++ b/tests/PolyType.SourceGenerator.UnitTests/CompilationHelpers.cs @@ -104,11 +104,18 @@ public static Compilation CreateCompilation( .. additionalReferences, ]; + var options = new CSharpCompilationOptions(outputKind, nullableContextOptions: nullableContextOptions, allowUnsafe: true); +#if !NET + // On .NET Framework the test process may load newer BCL assemblies (e.g. System.Memory) + // than those PolyType was compiled against. CS1702 warns about the version mismatch, + // which is benign in an in-memory compilation used for source-generator testing. + options = options.WithSpecificDiagnosticOptions([new("CS1702", ReportDiagnostic.Suppress)]); +#endif return CSharpCompilation.Create( assemblyName, syntaxTrees: syntaxTrees, references: references, - options: new CSharpCompilationOptions(outputKind, nullableContextOptions: nullableContextOptions, allowUnsafe: true) + options: options ); } diff --git a/tests/PolyType.SourceGenerator.UnitTests/PolyType.SourceGenerator.UnitTests.csproj b/tests/PolyType.SourceGenerator.UnitTests/PolyType.SourceGenerator.UnitTests.csproj index a8aed884..53211c85 100644 --- a/tests/PolyType.SourceGenerator.UnitTests/PolyType.SourceGenerator.UnitTests.csproj +++ b/tests/PolyType.SourceGenerator.UnitTests/PolyType.SourceGenerator.UnitTests.csproj @@ -26,6 +26,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/PolyType.Tests/ConfigurationBinderTests.cs b/tests/PolyType.Tests/ConfigurationBinderTests.cs index 1a5a5cd0..4dc5f8f3 100644 --- a/tests/PolyType.Tests/ConfigurationBinderTests.cs +++ b/tests/PolyType.Tests/ConfigurationBinderTests.cs @@ -18,9 +18,16 @@ public void BoundResultEqualsOriginalValue(TestCase testCase) ITypeShape shape = providerUnderTest.ResolveShape(testCase); Func binder = ConfigurationBinderTS.Create(shape); IEqualityComparer comparer = StructuralEqualityComparer.Create(shape); - IConfiguration configuration = CreateConfiguration(testCase, shape); + (IConfiguration configuration, string json) = CreateConfiguration(testCase, shape); - if (testCase.Value is not null && !providerUnderTest.HasConstructor(testCase)) + // In Microsoft.Extensions.Configuration 10.x, JSON `null` literals and `{}` + // both surface as IConfigurationSection.Value == null with no children, so + // values whose JSON serialization collapses to one of these round-trip as + // the default of T. See: + // https://learn.microsoft.com/dotnet/core/compatibility/extensions/10.0/configuration-null-values-preserved + bool sectionIsNull = json is "null" or "{}"; + + if (!providerUnderTest.HasConstructor(testCase) && !sectionIsNull) { Assert.Throws(() => binder(configuration)); return; @@ -28,10 +35,14 @@ public void BoundResultEqualsOriginalValue(TestCase testCase) T? result = binder(configuration); - if (testCase.Value is "") + if (sectionIsNull) + { + Assert.Equal(default, result); + } + else if (json.Contains("{}")) { - // https://github.com/dotnet/runtime/issues/36510 - Assert.Null(result); + // Nested empty objects are indistinguishable from nulls in MEC 10.x + // so we settle for verifying that the binder runs without throwing. } else { @@ -39,7 +50,7 @@ public void BoundResultEqualsOriginalValue(TestCase testCase) } } - private static IConfiguration CreateConfiguration(TestCase testCase, ITypeShape shape) + private static (IConfiguration Configuration, string Json) CreateConfiguration(TestCase testCase, ITypeShape shape) { JsonConverter converter = JsonSerializerTS.CreateConverter(shape); string json = converter.Serialize(testCase.Value); @@ -53,7 +64,7 @@ private static IConfiguration CreateConfiguration(TestCase testCase, IType var builder = new ConfigurationBuilder(); using MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(rootJson)); builder.AddJsonStream(stream); - return builder.Build().GetSection("Root"); + return (builder.Build().GetSection("Root"), json); } } diff --git a/tests/PolyType.Tests/JsonSchemaTests.cs b/tests/PolyType.Tests/JsonSchemaTests.cs index b9e2d2b7..84f11534 100644 --- a/tests/PolyType.Tests/JsonSchemaTests.cs +++ b/tests/PolyType.Tests/JsonSchemaTests.cs @@ -1,4 +1,4 @@ -using Json.Schema; +using Json.Schema; using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; @@ -19,6 +19,9 @@ public void GeneratesExpectedSchema(ITestCase testCase) ITypeShape shape = providerUnderTest.ResolveShape(testCase); JsonObject schema = JsonSchemaGenerator.Generate(shape); + // Every root schema must declare the JSON Schema version. + Assert.Equal("https://json-schema.org/draft/2020-12/schema", (string?)schema["$schema"]); + switch (shape) { case IEnumTypeShape enumShape: @@ -54,13 +57,16 @@ public void GeneratesExpectedSchema(ITestCase testCase) AssertType("array"); JsonObject elementSchema = JsonSchemaGenerator.Generate(enumerableShape.ElementType); - for (int i = 0; i < enumerableShape.Rank; i++) schema = (JsonObject)schema["items"]!; - Assert.True(JsonNode.DeepEquals(elementSchema, schema)); + JsonNode? itemsSchema = schema; + for (int i = 0; i < enumerableShape.Rank; i++) itemsSchema = ((JsonObject)itemsSchema!)["items"]; + elementSchema.Remove("$schema"); + Assert.True(JsonNode.DeepEquals(elementSchema, itemsSchema)); break; case IDictionaryTypeShape dictionaryShape: AssertType("object"); JsonObject valueSchema = JsonSchemaGenerator.Generate(dictionaryShape.ValueType); + valueSchema.Remove("$schema"); Assert.True(JsonNode.DeepEquals(valueSchema, schema["additionalProperties"])); break; @@ -82,7 +88,8 @@ public void GeneratesExpectedSchema(ITestCase testCase) break; default: - Assert.Empty(schema); + Assert.Single(schema); + Assert.Contains("$schema", schema); break; } @@ -116,13 +123,14 @@ public void SchemaMatchesJsonSerializer(TestCase testCase) JsonObject schema = JsonSchemaGenerator.Generate(shape); string json = JsonSerializerTS.CreateConverter(shape).Serialize(testCase.Value); - JsonSchema jsonSchema = JsonSerializer.Deserialize(schema)!; + JsonSchema jsonSchema = JsonSchema.FromText(JsonSerializer.Serialize(schema)); EvaluationOptions options = new() { OutputFormat = OutputFormat.List }; - EvaluationResults results = jsonSchema.Evaluate(JsonNode.Parse(json), options); + using JsonDocument instanceDoc = JsonDocument.Parse(json); + EvaluationResults results = jsonSchema.Evaluate(instanceDoc.RootElement, options); if (!results.IsValid) { - IEnumerable errors = results.Details - .Where(d => d.HasErrors) + IEnumerable errors = (results.Details ?? []) + .Where(d => d.Errors is { Count: > 0 }) .SelectMany(d => d.Errors!.Select(error => $"Path:${d.InstanceLocation} {error.Key}:{error.Value}")); throw new XunitException($""" @@ -145,8 +153,9 @@ public void TestMethodShapeSchema() IMethodShape resetAsync = serviceShape.Methods.Single(m => m.Name == nameof(RpcService.ResetAsync)); JsonNode? actualSchema = JsonSchemaGenerator.Generate(getEventsAsync); - JsonNode? expectedSchema = JsonNode.Parse(""" + JsonNode? expectedSchema = JsonNode.Parse($$""" { + "$schema": "https://json-schema.org/draft/2020-12/schema", "name": "GetEventsAsync", "type": "object", "properties": { @@ -169,8 +178,9 @@ public void TestMethodShapeSchema() Assert.True(JsonNode.DeepEquals(expectedSchema, actualSchema)); actualSchema = JsonSchemaGenerator.Generate(resetAsync); - expectedSchema = JsonNode.Parse(""" + expectedSchema = JsonNode.Parse($$""" { + "$schema": "https://json-schema.org/draft/2020-12/schema", "name": "ResetAsync", "type": "object", "output": { }