|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Immutable;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Binder.SourceGeneration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Xunit;
namespace ConfigurationSchemaGenerator.Tests;
public partial class GeneratorTests
{
private static readonly SyntaxTree s_implicitUsingsSyntaxTree = SyntaxFactory.ParseSyntaxTree(SourceText.From(
"""
// <auto-generated/>
global using global::System;
global using global::System.Collections.Generic;
global using global::System.IO;
global using global::System.Linq;
global using global::System.Net.Http;
global using global::System.Threading;
global using global::System.Threading.Tasks;
"""));
private static readonly SyntaxTree s_attributesSyntaxTree = CSharpSyntaxTree.ParseText(File.ReadAllText("ConfigurationSchemaAttributes.cs"));
private static readonly ImmutableArray<MetadataReference> s_defaultReferences =
[
MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location),
MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location.Replace("System.Private.CoreLib", "System.Runtime")),
MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location),
MetadataReference.CreateFromFile(typeof(HttpContent).Assembly.Location)
];
private static readonly JsonSerializerOptions s_testSerializerOptions = new() { WriteIndented = true };
[Theory]
[InlineData("abc\n def", "abc def")]
[InlineData("\n def", "def")]
[InlineData(" \n def", "def")]
[InlineData(" \n def", "def")]
[InlineData("abc\n", "abc")]
[InlineData("abc\n ", "abc")]
[InlineData("abc\n ", "abc")]
[InlineData("abc\n\n ", "abc")]
[InlineData("\n\n\t def", "def")]
[InlineData("abc\n def \n ghi", "abc def ghi")]
[InlineData("abc\r\n def", "abc def")]
[InlineData("\r\n def", "def")]
[InlineData(" \r\n def", "def")]
[InlineData(" \r\n def", "def")]
[InlineData("abc\r\n", "abc")]
[InlineData("abc\r\n ", "abc")]
[InlineData("abc\r\n ", "abc")]
[InlineData("abc\t\r\n\r\n ", "abc")]
[InlineData("\r\n\r\n def", "def")]
[InlineData("\r\nabc\r\ndef\r\nghi\r\n", "abc def ghi")]
[InlineData("abc\r\n def \r\n ghi", "abc def ghi")]
[InlineData(" \r\n \r\n ", "")]
[InlineData(" abc \n \n def ", "abc\n\ndef")]
public void ShouldRemoveInsignificantWhitespace(string input, string expected)
{
var summaryElement = ConvertToSummaryElement(input);
var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
Assert.Equal(expected, description);
}
[Theory]
[InlineData("\n<para>abc</para><p>def</p>\n", "abc\n\ndef")]
[InlineData("<para>\nabc\n</para><para>\ndef\n</para>", "abc\n\ndef")]
[InlineData("abc<para>def</para>ghi", "abc\n\ndef\n\nghi")]
[InlineData("abc\n<para>\ndef\n</para>\nghi", "abc\n\ndef\n\nghi")]
[InlineData("abc<br/>def<br/>ghi", "abc\ndef\nghi")]
[InlineData("<br/>abc<br/>def<br/>ghi<br/>", "abc\ndef\nghi")]
[InlineData("abc\n<br />\ndef", "abc\ndef")]
[InlineData("abc\n\ndef", "abc\n\ndef")]
public void ShouldInsertLineBreaks(string input, string expected)
{
var summaryElement = ConvertToSummaryElement(input);
var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
Assert.Equal(expected, description);
}
[Theory]
[InlineData("Do <b><i>not</i></b> use!", "Do not use!")]
[InlineData("A valid <see cref=\"T:System.Uri\"/>. See remarks.", "A valid 'System.Uri'. See remarks.")]
[InlineData("The <see cref=\"T:System.Uri\"/> or <c>null</c>.", "The 'System.Uri' or null.")]
[InlineData("Use <example><code>Console.WriteLine();</code></example> to print.", "Use Console.WriteLine(); to print.")]
[InlineData("Generic <example><code><![CDATA[List<string>]]></code></example>", "Generic List<string>")]
public void ShouldStripHtmlTags(string input, string expected)
{
var summaryElement = ConvertToSummaryElement(input);
var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
Assert.Equal(expected, description);
}
[Theory]
[InlineData("<see cref=\"N:System.Diagnostics\"/>", "'System.Diagnostics'")]
[InlineData("<see cref=\"T:System.Uri\"/>", "'System.Uri'")]
[InlineData("<see cref=\"T:Azure.Core.Extensions.IAzureClientBuilder`2\"/>", "'Azure.Core.Extensions.IAzureClientBuilder`2'")]
[InlineData("<see cref=\"F:Azure.Storage.Queues.QueueMessageEncoding.None\"/>", "'Azure.Storage.Queues.QueueMessageEncoding.None'")]
[InlineData("<see cref=\"P:Aspire.Azure.Storage.Blobs.AzureStorageBlobsSettings.ConnectionString\"/>", "'Aspire.Azure.Storage.Blobs.AzureStorageBlobsSettings.ConnectionString'")]
[InlineData("<see cref=\"M:System.Diagnostics.Debug.Assert(bool)\"/>", "'System.Diagnostics.Debug.Assert(bool)'")]
[InlineData("<see cref=\"E:System.Windows.Input.ICommand.CanExecuteChanged\"/>", "'System.Windows.Input.ICommand.CanExecuteChanged'")]
[InlineData("<exception cref=\"T:System.InvalidOperationException\" />", "'System.InvalidOperationException'")]
[InlineData("<para><exception cref=\"T:System.InvalidOperationException\" /></para>", "'System.InvalidOperationException'")]
public void ShouldQuoteCrefAttributes(string input, string expected)
{
var summaryElement = ConvertToSummaryElement(input);
var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
Assert.Equal(expected, description);
}
[Theory]
[InlineData("<see href=\"https://aka.ms/azsdk/blog/vault-uri\"/>", "https://aka.ms/azsdk/blog/vault-uri")]
[InlineData("<see langword=\"true\"/>", "true")]
public void ShouldNotQuoteNonCrefAttributes(string input, string expected)
{
var summaryElement = ConvertToSummaryElement(input);
var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
Assert.Equal(expected, description);
}
[Theory]
[InlineData("<param name=\"connectionName\">A name used to retrieve the connection string from the ConnectionStrings configuration section.</param>", "A name used to retrieve the connection string from the ConnectionStrings configuration section.")]
[InlineData("<paramref name=\"arg\"/>", "arg")]
public void ShouldNotFormatMiscAttributes(string input, string expected)
{
var summaryElement = ConvertToSummaryElement(input);
var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
Assert.Equal(expected, description);
}
private static XElement ConvertToSummaryElement(string input)
{
var bytes = Encoding.UTF8.GetBytes($"<summary>{input}</summary>");
using var stream = new MemoryStream(bytes);
using var reader = XmlReader.Create(stream);
reader.MoveToContent();
return (XElement)XNode.ReadFrom(reader);
}
[Theory]
[InlineData("bool",
"""
"type": "boolean"
""")]
[InlineData("bool?",
"""
"type": "boolean"
""")]
[InlineData("byte",
"""
"type": "integer"
""")]
[InlineData("sbyte",
"""
"type": "integer"
""")]
[InlineData("char",
"""
"type": "integer"
""")]
[InlineData("int",
"""
"type": "integer"
""")]
[InlineData("uint",
"""
"type": "integer"
""")]
[InlineData("long",
"""
"type": "integer"
""")]
[InlineData("ulong",
"""
"type": "integer"
""")]
[InlineData("short",
"""
"type": "integer"
""")]
[InlineData("ushort",
"""
"type": "integer"
""")]
[InlineData("decimal",
"""
"type": ["number", "string"]
""")]
[InlineData("double",
"""
"type": ["number", "string"]
""")]
[InlineData("float",
"""
"type": ["number", "string"]
""")]
[InlineData("byte[]?",
"""
"oneOf": [
{
"type": "string",
"pattern": "^[-A-Za-z0-9+/]*={0,3}$"
},
{
"type": "array",
"items": {
"type": "integer"
}
}
]
""")]
[InlineData("object?",
"""
"type": "object"
""")]
[InlineData("string?",
"""
"type": "string"
""")]
[InlineData("Version?",
"""
"type": "string"
""")]
[InlineData("CultureInfo?",
"""
"type": "string"
""")]
[InlineData("DateTime",
"""
"type": "string"
""")]
[InlineData("DateTimeOffset",
"""
"type": "string"
""")]
[InlineData("TimeSpan",
"""
"type": "string",
"pattern": "^-?(\\d{1,7}|((\\d{1,7}[\\.:])?(([01]?\\d|2[0-3]):[0-5]?\\d|([01]?\\d|2[0-3]):[0-5]?\\d:[0-5]?\\d)(\\.\\d{1,7})?))$"
""")]
[InlineData("Guid",
"""
"type": "string",
"format": "uuid"
""")]
[InlineData("Uri?",
"""
"type": "string",
"format": "uri"
""")]
[InlineData("DayOfWeek",
"""
"enum": [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
]
""")]
[InlineData("int[]?",
"""
"type": "array",
"items": {
"type": "integer"
}
""")]
[InlineData("ICollection<int>?",
"""
"type": "array",
"items": {
"type": "integer"
}
""")]
[InlineData("IDictionary<string, int>?",
"""
"type": "object",
"additionalProperties": {
"type": "integer"
}
""")]
[InlineData("string[]?",
"""
"type": "array",
"items": {
"type": "string"
}
""")]
[InlineData("IEnumerable<string>?",
"""
"type": "array",
"items": {
"type": "string"
}
""")]
[InlineData("IDictionary<string, string>?",
"""
"type": "object",
"additionalProperties": {
"type": "string"
}
""")]
[InlineData("object[]?",
"""
"type": "array",
"items": {
"type": "object"
}
""")]
[InlineData("IList<object>?",
"""
"type": "array",
"items": {
"type": "object"
}
""")]
[InlineData("IDictionary<string, object>?",
"""
"type": "object",
"additionalProperties": {
"type": "object"
}
""")]
[InlineData("ChildType?",
"""
"type": "object",
"properties": {
"ChildValue": {
"type": "string"
},
"Parent": {}
}
""")]
[InlineData("ChildType[]?",
"""
"type": "array",
"items": {
"type": "object",
"properties": {
"ChildValue": {
"type": "string"
},
"Parent": {}
}
}
""")]
[InlineData("IList<ChildType>?",
"""
"type": "array",
"items": {
"type": "object",
"properties": {
"ChildValue": {
"type": "string"
},
"Parent": {}
}
}
""")]
[InlineData("IDictionary<string, ChildType>?",
"""
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"ChildValue": {
"type": "string"
},
"Parent": {}
}
}
""")]
[InlineData("Int128",
"""
"type": "integer"
""")]
[InlineData("UInt128",
"""
"type": "integer"
""")]
[InlineData("Half",
"""
"type": [
"number",
"string"
]
""")]
[InlineData("DateOnly",
"""
"type": "string"
""")]
[InlineData("TimeOnly",
"""
"type": "string"
""")]
public void ShouldConvertTypes(string propertyType, string expected)
{
var source =
$$"""
using System.Globalization;
using System.Security.Cryptography.X509Certificates;
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings), ["TestProperty:ClientCertificate"])]
public class TestSettings
{
public {{propertyType}} TestProperty { get; set; }
}
public record ChildType
{
public string? ChildValue { get; set; }
// Simple read-only property, ignored.
public bool HasParent => Parent != null;
// Circular reference, included but without type.
public ChildType? Parent { get; set; }
// Ignored via exclusionPaths.
public X509Certificate2? ClientCertificate { get; set; }
}
""";
ImmutableArray<MetadataReference> references = [
MetadataReference.CreateFromFile(typeof(Uri).Assembly.Location),
MetadataReference.CreateFromFile(typeof(X509Certificate2).Assembly.Location)
];
var schema = GenerateSchemaFromCode(source, references);
AssertIsJson(schema,
$$"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty": {
{{expected}}
}
}
}
}
}
""");
}
[Fact]
public void ShouldAllowGetOnlyObject()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
public class TestSettings
{
public JsonFormattingOptions FormatSettings { get; } = new();
}
public class JsonFormattingOptions
{
public bool WriteIndented { get; set; }
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"FormatSettings": {
"type": "object",
"properties": {
"WriteIndented": {
"type": "boolean"
}
}
}
}
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"FormatSettings": {
"type": "object",
"properties": {
"WriteIndented": {
"type": "boolean"
}
}
}
}
}
}
}
""");
}
[Fact]
public void ShouldAllowGetOnlyCollections()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
public class TestSettings
{
public ICollection<int> CollectionOfInt { get; } = [];
public IDictionary<string, int> DictionaryOfStringToInt { get; } = new Dictionary<string, int>();
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"CollectionOfInt": {
"type": "array",
"items": {
"type": "integer"
}
},
"DictionaryOfStringToInt": {
"type": "object",
"additionalProperties": {
"type": "integer"
}
}
}
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"CollectionOfInt": {
"type": "array",
"items": {
"type": "integer"
}
},
"DictionaryOfStringToInt": {
"type": "object",
"additionalProperties": {
"type": "integer"
}
}
}
}
}
}
""");
}
[Fact]
public void ShouldAllowFreeFormatViaRecursiveSubtree()
{
var source =
"""
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
public sealed class TestSettings
{
public TreeOfString TestProperty1 { get; } = new();
public IDictionary<string, TreeOfString> TestProperty2 { get; } = new Dictionary<string, TreeOfString>(StringComparer.OrdinalIgnoreCase);
}
[DebuggerDisplay("{ToString(),nq}")]
[TypeConverter(typeof(TreeOfStringConverter))]
public sealed class TreeOfString : Dictionary<string, TreeOfString>
{
public string? Value { get; }
public TreeOfString() : base(StringComparer.OrdinalIgnoreCase)
{
}
public TreeOfString(string value)
{
Value = value;
}
public override string ToString()
{
return Value != null ? $"Value = '{Value}'" : $"Subtree of {Count} items";
}
}
public sealed class TreeOfStringConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
{
return value is string stringValue ? new TreeOfString(stringValue) : base.ConvertFrom(context, culture, value);
}
}
public static class AppUsageDemo
{
public static void Run()
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["TestComponent:TestSettings:TestProperty1:UserName"] = "john.doe@email.com",
["TestComponent:TestSettings:TestProperty1:Password"] = "p@ssw0rd1",
["TestComponent:TestSettings:TestProperty1:Options:RequireSSL"] = "false",
["TestComponent:TestSettings:TestProperty2:UserName"] = "jane.doe@email.com",
["TestComponent:TestSettings:TestProperty2:Auth:Token:Key"] = "39B4D4E4-AC46-471D-A89A-EBEAB4CA1697",
["TestComponent:TestSettings:TestProperty2:Options:UseSingleSignOn"] = "true"
});
builder.Services.AddOptions<TestSettings>().BindConfiguration("TestComponent:TestSettings");
using var host = builder.Build();
var optionsMonitor = host.Services.GetRequiredService<IOptionsMonitor<TestSettings>>();
var testSettings = optionsMonitor.CurrentValue;
Console.WriteLine(nameof(TestSettings.TestProperty1));
Print(testSettings.TestProperty1, 1);
Console.WriteLine(nameof(TestSettings.TestProperty2));
Print(testSettings.TestProperty2, 1);
/*
When run, prints the following to the console:
TestProperty1
Options
RequireSSL = false
Password = p@ssw0rd1
UserName = john.doe@email.com
TestProperty2
Auth
Token
Key = 39B4D4E4-AC46-471D-A89A-EBEAB4CA1697
Options
UseSingleSignOn = true
UserName = jane.doe@email.com
*/
}
private static void Print(IDictionary<string, TreeOfString> source, int depth = 0)
{
var indent = new string(' ', depth * 2);
foreach ((string key, TreeOfString subTree) in source)
{
if (!string.IsNullOrEmpty(subTree.Value))
{
Console.WriteLine($"{indent}{key} = {subTree.Value}");
}
else
{
Console.WriteLine($"{indent}{key}");
Print(subTree, depth + 1);
}
}
}
}
""";
ImmutableArray<MetadataReference> references = [
MetadataReference.CreateFromFile(typeof(TypeConverter).Assembly.Location),
MetadataReference.CreateFromFile(typeof(TypeConverterAttribute).Assembly.Location),
MetadataReference.CreateFromFile(typeof(Host).Assembly.Location),
MetadataReference.CreateFromFile(typeof(IHost).Assembly.Location),
MetadataReference.CreateFromFile(typeof(IServiceCollection).Assembly.Location),
MetadataReference.CreateFromFile(typeof(IServiceProvider).Assembly.Location),
MetadataReference.CreateFromFile(typeof(IConfigurationBuilder).Assembly.Location),
MetadataReference.CreateFromFile(typeof(ConfigurationManager).Assembly.Location),
MetadataReference.CreateFromFile(typeof(IOptionsMonitor<>).Assembly.Location),
MetadataReference.CreateFromFile(typeof(OptionsBuilderConfigurationExtensions).Assembly.Location),
MetadataReference.CreateFromFile(typeof(BinderOptions).Assembly.Location),
MetadataReference.CreateFromFile(typeof(Console).Assembly.Location)
];
var schema = GenerateSchemaFromCode(source, references);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty1": {},
"TestProperty2": {
"type": "object",
"additionalProperties": {}
}
}
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty1": {},
"TestProperty2": {
"type": "object",
"additionalProperties": {}
}
}
}
}
}
""");
}
[Fact]
public void ShouldSkipProperties()
{
var source =
"""
using System.ComponentModel;
using System.Text.Json;
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
public class TestSettings
{
public static IList<string> StaticReadOnlyList { get; } = [];
public int ReadOnlyProperty { get; } = 42;
public dynamic? DynamicProperty { get; set; }
public Delegate? DelegateProperty { get; set; }
public JsonNamingPolicy? EmptyProperty { get; set; } // has no settable properties
public IList<JsonNamingPolicy> CollectionOfEmptyProperty { get; } = [];
[Obsolete]
public string? ObsoleteProperty { get; }
[EditorBrowsable(EditorBrowsableState.Never)]
public string? HiddenProperty { get; }
public bool Field;
}
""";
ImmutableArray<MetadataReference> references = [
MetadataReference.CreateFromFile(typeof(DynamicAttribute).Assembly.Location),
MetadataReference.CreateFromFile(typeof(JsonNamingPolicy).Assembly.Location)
];
var schema = GenerateSchemaFromCode(source, references);
AssertIsJson(schema,
"""
{}
""");
}
[Fact]
public void ShouldTakeConfigurationKeyName()
{
var source =
"""
using Microsoft.Extensions.Configuration;
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
public class TestSettings
{
[ConfigurationKeyName("AlternateName")]
public string? TestProperty { get; set; }
}
""";
ImmutableArray<MetadataReference> references =
[
MetadataReference.CreateFromFile(typeof(ConfigurationKeyNameAttribute).Assembly.Location)
];
var schema = GenerateSchemaFromCode(source, references);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"AlternateName": {
"type": "string"
}
}
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"AlternateName": {
"type": "string"
}
}
}
}
}
""");
}
[Fact]
public void ShouldUseConfigurationKeyNameInExclusionPaths()
{
var source =
"""
using Microsoft.Extensions.Configuration;
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings), ["AlternateName"])]
public class TestSettings
{
[ConfigurationKeyName("AlternateName")]
public string? TestProperty { get; set; }
}
""";
ImmutableArray<MetadataReference> references =
[
MetadataReference.CreateFromFile(typeof(ConfigurationKeyNameAttribute).Assembly.Location)
];
var schema = GenerateSchemaFromCode(source, references);
AssertIsJson(schema,
"""
{}
""");
}
[Fact]
public void ShouldAllowEmptyConfigPath()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("", typeof(SettingsOne))]
[assembly: Aspire.ConfigurationSchema("", typeof(SettingsTwo))]
public class SettingsOne
{
public string? PropertyOne { get; set; }
}
public class SettingsTwo
{
public int? PropertyTwo { get; set; }
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"PropertyOne": {
"type": "string"
},
"PropertyTwo": {
"type": "integer"
}
}
}
"""{
"type": "object",
"properties": {
"PropertyOne": {
"type": "string"
},
"PropertyTwo": {
"type": "integer"
}
}
}
""");
}
[Fact]
public void ShouldAllowNestedConfigPath()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("One:Two:Three", typeof(OneTwoThreeSettings))]
[assembly: Aspire.ConfigurationSchema("One", typeof(OneSettings))]
public class OneTwoThreeSettings
{
public string? PropertyInThree { get; set; }
}
public class OneSettings
{
public int? PropertyInOne { get; set; }
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"One": {
"type": "object",
"properties": {
"PropertyInOne": {
"type": "integer"
},
"Two": {
"type": "object",
"properties": {
"Three": {
"type": "object",
"properties": {
"PropertyInThree": {
"type": "string"
}
}
}
}
}
}
}
}
}
"""{
"type": "object",
"properties": {
"One": {
"type": "object",
"properties": {
"PropertyInOne": {
"type": "integer"
},
"Two": {
"type": "object",
"properties": {
"Three": {
"type": "object",
"properties": {
"PropertyInThree": {
"type": "string"
}
}
}
}
}
}
}
}
}
""");
}
[Fact]
public void ShouldAllowSimpleTypeInConfigPath()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("One:Two:Three", typeof(int))]
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"One": {
"type": "object",
"properties": {
"Two": {
"type": "object",
"properties": {
"Three": {
"type": "integer"
}
}
}
}
}
}
}
"""{
"type": "object",
"properties": {
"One": {
"type": "object",
"properties": {
"Two": {
"type": "object",
"properties": {
"Three": {
"type": "integer"
}
}
}
}
}
}
}
""");
}
[Fact]
public void MergesPropertiesAtSameConfigPath()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings1))]
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings2))]
public record TestSettings1
{
/// <summary>1</summary>
public string? TestProperty1 { get; set; }
}
public record TestSettings2
{
/// <summary>2</summary>
public int? TestProperty2 { get; set; }
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty1": {
"type": "string",
"description": "1"
},
"TestProperty2": {
"type": "integer",
"description": "2"
}
}
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty1": {
"type": "string",
"description": "1"
},
"TestProperty2": {
"type": "integer",
"description": "2"
}
}
}
}
}
""");
}
[Fact]
public void LastUsedCasingOfConfigPathWins()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("ONE:two:THREE", typeof(TestSettings1))]
[assembly: Aspire.ConfigurationSchema("One:Two:Three", typeof(TestSettings2))]
[assembly: Aspire.ConfigurationSchema("one:TWO:three", typeof(Empty))]
public record TestSettings1
{
/// <summary>1</summary>
public string? TestProperty1 { get; set; }
}
public record TestSettings2
{
/// <summary>2</summary>
public int? TestProperty2 { get; set; }
}
public record Empty;
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"One": {
"type": "object",
"properties": {
"Two": {
"type": "object",
"properties": {
"Three": {
"type": "object",
"properties": {
"TestProperty1": {
"type": "string",
"description": "1"
},
"TestProperty2": {
"type": "integer",
"description": "2"
}
}
}
}
}
}
}
}
}
"""{
"type": "object",
"properties": {
"One": {
"type": "object",
"properties": {
"Two": {
"type": "object",
"properties": {
"Three": {
"type": "object",
"properties": {
"TestProperty1": {
"type": "string",
"description": "1"
},
"TestProperty2": {
"type": "integer",
"description": "2"
}
}
}
}
}
}
}
}
}
""");
}
[Fact]
public void LastUsedTypeDocumentationWins()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(testSETTINGS))]
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
/// <summary>Initial documentation.</summary>
public class testSETTINGS
{
public int TestProperty1 { get; set; }
}
/// <summary>Replaced documentation.</summary>
public class TestSettings
{
public bool TestProperty2 { get; set; }
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty1": {
"type": "integer"
},
"TestProperty2": {
"type": "boolean"
}
},
"description": "Replaced documentation."
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty1": {
"type": "integer"
},
"TestProperty2": {
"type": "boolean"
}
},
"description": "Replaced documentation."
}
}
}
""");
}
[Fact]
public void LastUsedCasingOfPropertyWins()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
public class TestSettings
{
/// <summary>Discarded documentation.</summary>
public int testPROPERTY { get; set; }
public string? TestProperty { get; set; }
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty": {
"type": "string"
}
}
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty": {
"type": "string"
}
}
}
}
}
""");
}
[Fact]
public void PreservesExistingTypeWhenConfigPathSkipped()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(IList<string>))]
[assembly: Aspire.ConfigurationSchema("TestComponent:One", typeof(Empty))]
[assembly: Aspire.ConfigurationSchema("OtherComponent", typeof(Empty))]
public record Empty;
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
""");
}
[Fact]
public void PreservesExistingPropertyTypeWhenSkipped()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings1))]
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings2))]
[assembly: Aspire.ConfigurationSchema("TestComponent:TestProperty", typeof(IList<Empty>))]
public class TestSettings1
{
public string? TestProperty { get; set; }
}
public class TestSettings2
{
public Empty? TestProperty { get; set; }
}
public record Empty;
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty": {
"type": "string"
}
}
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"TestProperty": {
"type": "string"
}
}
}
}
}
""");
}
[Fact]
public void PreservesExistingPropertyWhenSkipped()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("One:Two", typeof(TestSettings))]
[assembly: Aspire.ConfigurationSchema("One", typeof(Empty))]
public class TestSettings
{
public string? TestProperty { get; set; }
}
/// <summary>Empty</summary>
public record Empty;
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"One": {
"type": "object",
"properties": {
"Two": {
"type": "object",
"properties": {
"TestProperty": {
"type": "string"
}
}
}
}
}
}
}
"""{
"type": "object",
"properties": {
"One": {
"type": "object",
"properties": {
"Two": {
"type": "object",
"properties": {
"TestProperty": {
"type": "string"
}
}
}
}
}
}
}
""");
}
[Fact]
public void ReadsInheritDocFromInterfaceInSource()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
/// <inheritdoc cref="ITestSettings" />
public class TestSettings : ITestSettings
{
/// <inheritdoc />
public string? AppName { get; set; }
}
/// <summary>
/// Contains configuration settings.
/// </summary>
public interface ITestSettings
{
/// <summary>
/// Gets or sets the name of this app.
/// </summary>
public string? AppName { get; set; }
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"AppName": {
"type": "string",
"description": "Gets or sets the name of this app."
}
},
"description": "Contains configuration settings."
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"AppName": {
"type": "string",
"description": "Gets or sets the name of this app."
}
},
"description": "Contains configuration settings."
}
}
}
""");
}
[Fact]
public void CanClearInheritDocFromInterfaceInSource()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
public class TestSettings : ITestSettings
{
/// <summary />
public string? AppName { get; set; }
}
public interface ITestSettings
{
/// <summary>
/// Gets or sets the name of this app.
/// </summary>
public string? AppName { get; set; }
}
""";
var schema = GenerateSchemaFromCode(source, []);
Assert.DoesNotContain("Gets or sets the name of this app.", schema);
}
[Fact]
public void ReadsInheritDocFromInterfaceInNuGetPackage()
{
// Requires <CopyDocumentationFilesFromPackages>true</CopyDocumentationFilesFromPackages> in csproj.
var source =
"""
using Newtonsoft.Json;
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
/// <inheritdoc cref="IJsonLineInfo" />
public class TestSettings : IJsonLineInfo
{
/// <inheritdoc />
public int LineNumber { get; set; }
/// <inheritdoc />
public int LinePosition { get; set; }
public bool HasLineInfo() => throw new NotImplementedException();
}
""";
ImmutableArray<MetadataReference> references = [
ConfigSchemaGenerator.CreateMetadataReference(typeof(IJsonLineInfo).Assembly.Location)
];
var schema = GenerateSchemaFromCode(source, references);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"LineNumber": {
"type": "integer",
"description": "Gets the current line number."
},
"LinePosition": {
"type": "integer",
"description": "Gets the current line position."
}
},
"description": "Provides an interface to enable a class to return line and position information."
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"LineNumber": {
"type": "integer",
"description": "Gets the current line number."
},
"LinePosition": {
"type": "integer",
"description": "Gets the current line position."
}
},
"description": "Provides an interface to enable a class to return line and position information."
}
}
}
""");
}
[Fact]
public void DetectsBooleanDefaultValueInDocComments()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
public class TestSettings
{
/// <summary />
/// <value>
/// The default value is <c>false</c>.
/// </value>
public bool FalseByDefault { get; set; } = false;
/// <summary />
/// <value>
/// The default value is <c>true</c>.
/// </value>
public bool TrueByDefault { get; set; } = true;
/// <summary />
/// <value>
/// The default value is <c>true</c>,
/// which is not <c>false</c>.
/// </value>
public bool UnknownDefault { get; set; } = true;
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"FalseByDefault": {
"type": "boolean",
"default": false
},
"TrueByDefault": {
"type": "boolean",
"default": true
},
"UnknownDefault": {
"type": "boolean"
}
}
}
}
}
"""{
"type": "object",
"properties": {
"TestComponent": {
"type": "object",
"properties": {
"FalseByDefault": {
"type": "boolean",
"default": false
},
"TrueByDefault": {
"type": "boolean",
"default": true
},
"UnknownDefault": {
"type": "boolean"
}
}
}
}
}
""");
}
[Fact]
public void LastUsedCasingOfLogCategoryWins()
{
var source =
"""
[assembly: Aspire.LoggingCategories("ONE", "one.TWO.three", "One.Two.Three")]
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"definitions": {
"logLevel": {
"properties": {
"ONE": {
"$ref": "#/definitions/logLevelThreshold"
},
"One.Two.Three": {
"$ref": "#/definitions/logLevelThreshold"
}
}
}
}
}
"""{
"definitions": {
"logLevel": {
"properties": {
"ONE": {
"$ref": "#/definitions/logLevelThreshold"
},
"One.Two.Three": {
"$ref": "#/definitions/logLevelThreshold"
}
}
}
}
}
""");
}
[Fact]
public void GeneratesSchemaForNamedOptionsOnAsterisk()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("Certificates:*", typeof(CertificateSettings))]
public class CertificateSettings
{
/// <summary>
/// The private key of the certificate, in base64 format.
/// </summary>
public string? PrivateKey { get; set; }
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"Certificates": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"PrivateKey": {
"type": "string",
"description": "The private key of the certificate, in base64 format."
}
}
}
}
}
}
"""{
"type": "object",
"properties": {
"Certificates": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"PrivateKey": {
"type": "string",
"description": "The private key of the certificate, in base64 format."
}
}
}
}
}
}
""");
}
[Fact]
public void CanGenerateSchemaForNamedOptionsWithDefaultOptions()
{
var source =
"""
[assembly: Aspire.ConfigurationSchema("Certificates", typeof(CertificateSettings))]
[assembly: Aspire.ConfigurationSchema("Certificates:*", typeof(CertificateSettings))]
public class CertificateSettings
{
/// <summary>
/// The private key of the certificate, in base64 format.
/// </summary>
public string? PrivateKey { get; set; }
}
""";
var schema = GenerateSchemaFromCode(source, []);
AssertIsJson(schema,
"""
{
"type": "object",
"properties": {
"Certificates": {
"type": "object",
"properties": {
"PrivateKey": {
"type": "string",
"description": "The private key of the certificate, in base64 format."
}
},
"additionalProperties": {
"type": "object",
"properties": {
"PrivateKey": {
"type": "string",
"description": "The private key of the certificate, in base64 format."
}
}
}
}
}
}
"""{
"type": "object",
"properties": {
"Certificates": {
"type": "object",
"properties": {
"PrivateKey": {
"type": "string",
"description": "The private key of the certificate, in base64 format."
}
},
"additionalProperties": {
"type": "object",
"properties": {
"PrivateKey": {
"type": "string",
"description": "The private key of the certificate, in base64 format."
}
}
}
}
}
}
""");
}
private static string GenerateSchemaFromCode(string sourceText, IEnumerable<MetadataReference> references)
{
var sourceSyntaxTree = SyntaxFactory.ParseSyntaxTree(SourceText.From(sourceText));
var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithNullableContextOptions(NullableContextOptions.Enable);
var compilation = CSharpCompilation.Create("Test", [s_implicitUsingsSyntaxTree, s_attributesSyntaxTree, sourceSyntaxTree], s_defaultReferences.AddRange(references), compilationOptions);
Assert.Empty(compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity > DiagnosticSeverity.Hidden));
var configSchemaInfo = ConfigSchemaGenerator.GetConfigurationSchema(compilation.Assembly);
Assert.NotNull(configSchemaInfo);
var parser = new ConfigurationBindingGenerator.Parser(configSchemaInfo, new KnownTypeSymbols(compilation));
var spec = parser.GetSchemaGenerationSpec(CancellationToken.None);
Assert.NotNull(spec);
var emitter = new ConfigSchemaEmitter(spec, compilation);
return emitter.GenerateSchema();
}
private static void AssertIsJson(string actual, string expected)
{
var actualJson = JsonNode.Parse(actual)!;
var expectedJson = JsonNode.Parse(expected)!;
var expectedText = expectedJson.ToJsonString(s_testSerializerOptions);
var actualText = actualJson.ToJsonString(s_testSerializerOptions);
Assert.Equal(expectedText, actualText);
}
[Fact]
public void IntegrationTest()
{
// the 'refs' folder is populated by PreserveCompilationContext in the .csproj
var referenceAssemblies = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.dll", SearchOption.AllDirectories)
.ToArray();
var outputPath = Path.Combine(Directory.GetCurrentDirectory(), "IntegrationTest.json");
ConfigSchemaGenerator.GenerateSchema(
typeof(GeneratorTests).Assembly.Location,
referenceAssemblies,
outputPath);
// Compare the two, normalizing line endings.
var actual = File.ReadAllText(outputPath).ReplaceLineEndings();
var baseline = File.ReadAllText(Path.Combine("Baselines", "IntegrationTest.baseline.json")).ReplaceLineEndings();
Assert.Equal(baseline, actual);
}
[GeneratedRegex(ConfigSchemaEmitter.TimeSpanRegex)]
private static partial Regex TimeSpanRegex();
[Theory]
[InlineData("6")]
[InlineData("6:12")]
[InlineData("6:12:14")]
[InlineData("6:12:14.45")]
[InlineData("6.12:14:45")]
[InlineData("6:12:14:45")]
[InlineData("6.12:14:45.3448")]
[InlineData("6:12:14:45.3448")]
[InlineData("-6:12:14:45.3448")]
[InlineData("9999999")]
[InlineData("9:7")]
public void TestTimeSpanRegexValid(string validTimeSpanString)
{
Assert.Matches(TimeSpanRegex(), validTimeSpanString);
Assert.True(TimeSpan.TryParse(validTimeSpanString, CultureInfo.InvariantCulture, out _));
}
[Theory]
[InlineData("24:00")]
[InlineData("23:61")]
[InlineData("6:12:60")]
[InlineData("19999999")]
[InlineData("+6:12:14:45.3448")]
public void TestTimeSpanRegexInvalid(string invalidTimeSpanString)
{
Assert.DoesNotMatch(TimeSpanRegex(), invalidTimeSpanString);
Assert.False(TimeSpan.TryParse(invalidTimeSpanString, CultureInfo.InvariantCulture, out _));
}
}
|